Files
m-senior-developer 8c9a174ddc WYL-44 follow-up: add tests and extract poll_once for testability
R1: Add unit tests (22 total, all passing)
- tests/test_queue.py: enqueue/persist, is_issue_pending_or_running for all
  statuses, update_status mutate+persist, save/load roundtrip, corrupt file
  handling, pending() filtering
- tests/test_watcher.py: FakeMulticaClient drives poll_once across first
  observation, same updated_at dedupe, updated_at change while pending/running
  dedupe, re-enqueue after done, multi-issue, mix of new and seen

Refactor: extract poll_once(client, state, queue, logger) from watch_loop so
tests can call it directly without mocking time.sleep.

R2: Document known race near is_issue_pending_or_running — comment added
noting that orchestrator marking round done before updating issue status can
fire a second round; WYL-45 must resolve atomically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:20:23 +00:00

163 lines
5.5 KiB
Python

"""Unit tests for the poll_once watcher logic."""
import logging
import pathlib
from typing import Any
import pytest
from coordinator.__main__ import poll_once
from coordinator.queue import DebateQueue
from coordinator.state import SeenState
# ---------------------------------------------------------------------------
# Fake client
# ---------------------------------------------------------------------------
class FakeMulticaClient:
"""Minimal stand-in for MulticaClient.
``responses`` is a list of lists; each call to ``list_issues_by_status``
pops the next item from the front. If the list is exhausted the last
entry is repeated (steady-state behaviour).
"""
def __init__(self, responses: list[list[dict[str, Any]]]):
self._responses = list(responses)
def list_issues_by_status(self, status: str, limit: int = 200) -> list[dict[str, Any]]:
if len(self._responses) > 1:
return self._responses.pop(0)
return self._responses[0]
def _issue(iid: str, updated_at: str, identifier: str = "WYL-X", title: str = "T") -> dict:
return {
"id": iid,
"updated_at": updated_at,
"identifier": identifier,
"title": title,
}
@pytest.fixture
def setup(tmp_path):
"""Return (state, queue, logger) wired to tmp_path."""
state = SeenState.load(tmp_path / "seen.json")
queue = DebateQueue.load(tmp_path / "queue.json")
logger = logging.getLogger("test.watcher")
return state, queue, logger
# ---------------------------------------------------------------------------
# Scenario (a): first observation of a new in_review issue → enqueue
# ---------------------------------------------------------------------------
def test_first_observation_enqueues(setup):
state, queue, logger = setup
client = FakeMulticaClient([[_issue("id-1", "2026-01-01T00:00:00Z", "WYL-1", "Title A")]])
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 1
assert queue.rounds[0].issue_id == "id-1"
assert queue.rounds[0].identifier == "WYL-1"
assert queue.rounds[0].status == "pending"
assert state.last_seen("id-1") == "2026-01-01T00:00:00Z"
# ---------------------------------------------------------------------------
# Scenario (b): same issue at same updated_at → no second enqueue
# ---------------------------------------------------------------------------
def test_same_updated_at_no_reenqueue(setup):
state, queue, logger = setup
issue = _issue("id-1", "2026-01-01T00:00:00Z")
client = FakeMulticaClient([[issue]]) # steady-state
poll_once(client, state, queue, logger)
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 1 # only the first poll fired
# ---------------------------------------------------------------------------
# Scenario (c): updated_at changes while round still pending → dedupe, no re-enqueue
# ---------------------------------------------------------------------------
def test_updated_at_changes_while_pending_no_reenqueue(setup):
state, queue, logger = setup
client = FakeMulticaClient([
[_issue("id-1", "2026-01-01T00:00:00Z")], # first poll: enqueue
[_issue("id-1", "2026-01-01T00:01:00Z")], # second poll: new updated_at, but round pending
])
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 1
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 1 # dedupe fired, no second round
def test_updated_at_changes_while_running_no_reenqueue(setup):
state, queue, logger = setup
client = FakeMulticaClient([
[_issue("id-1", "2026-01-01T00:00:00Z")],
[_issue("id-1", "2026-01-01T00:01:00Z")],
])
poll_once(client, state, queue, logger)
queue.update_status(queue.rounds[0].round_id, "running")
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 1
# ---------------------------------------------------------------------------
# After round completes, a *new* updated_at triggers a fresh round
# ---------------------------------------------------------------------------
def test_reenqueue_after_round_done(setup):
state, queue, logger = setup
client = FakeMulticaClient([
[_issue("id-1", "2026-01-01T00:00:00Z")], # first poll
[_issue("id-1", "2026-01-01T00:02:00Z")], # third poll: new updated_at, round done
])
poll_once(client, state, queue, logger)
queue.update_status(queue.rounds[0].round_id, "done")
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 2
assert queue.rounds[1].status == "pending"
# ---------------------------------------------------------------------------
# Multiple distinct issues in one poll
# ---------------------------------------------------------------------------
def test_multiple_issues_each_enqueued_once(setup):
state, queue, logger = setup
client = FakeMulticaClient([[
_issue("id-1", "2026-01-01T00:00:00Z", "WYL-1"),
_issue("id-2", "2026-01-01T00:00:00Z", "WYL-2"),
_issue("id-3", "2026-01-01T00:00:00Z", "WYL-3"),
]])
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 3
def test_mix_new_and_seen(setup):
state, queue, logger = setup
client = FakeMulticaClient([
[_issue("id-1", "t1"), _issue("id-2", "t2")], # first poll: both new
[_issue("id-1", "t1"), _issue("id-2", "t2")], # second poll: same updated_at
])
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 2
poll_once(client, state, queue, logger)
assert len(queue.rounds) == 2 # nothing new