docs(sp22): backfill plan with 10 as-built deviations

The implementation shipped with 10 inline bug fixes that weren't reflected
in the original plan document. This commit updates the plan so it
matches the as-built code and adds a summary table near the top so a
future reader of the plan knows what was adjusted and why.

- Bug 1a: AckTimeoutError.__init__ signature widened from int to float
  plus adds self.phase attribute for report remediation hints.
- Bug 1b: wait_for passes timeout_s through without int() round-trip.
- Bug 2: Markdown table assertion updated to match the actual format.
- Bug 3: Added ## Remediation section to write_report_md for soft/hard fails.
- Bug 4: 835 expected-by text now says 'typically the following Monday'.
- Bug 5: Removed bogus HealthSnapshot import in test_pipeline.py.
- Bug 6: All phase tests wrapped in 'async with CyclonePipeline(...) as p:'.
- Bug 7: Playwright + UploadPage imports moved to module level in pipeline.py.
- Bug 8: Added _phase_records_to_results() helper for PhaseRecord→PhaseResult.
- Bug 9: Resume test uses _make_stub factory so stubs append to completed_phases.
- Bug 10: CLI uses context_settings={'allow_interspersed_args': True}.
This commit is contained in:
Tyler
2026-06-21 16:28:07 -06:00
parent 888c99d848
commit ebd2834cc4
@@ -12,6 +12,33 @@
---
## As-built deviations
The implementation shipped with 10 deviations from this plan, all fixed
inline during TDD execution. They are catalogued here so a re-run of
the plan (or a reviewer of the implementation) knows what was
adjusted and why.
| # | Bug | Where | Fix |
|---|---|---|---|
| 1a | `AckTimeoutError.__init__(self, ack_kind, timeout_s: int)` — the `int` annotation broke tests asserting `timeout_s == pytest.approx(0.2, abs=0.1)`. | `src/cyclone_pipeline/exceptions.py` | Widened to `timeout_s: float`; added `self.phase = f"wait_{ack_kind}"` so `report.py` can render the right remediation hint. |
| 1b | `wait_for` raised `AckTimeoutError(label, int(timeout_s))` — round-tripping `float → int → float` lost sub-second precision and broke the same `pytest.approx` assertion. | `src/cyclone_pipeline/waiters.py` | Pass `timeout_s` through unchanged. |
| 2 | Markdown table assertion `assert "\| 1. Pre-flight" in text or "\| 1." in text` didn't match the actual table format `\| 1 \| preflight \|`. | `tests/test_report.py::test_markdown_contains_phase_table` | Tightened assertion to `\| 1 \| preflight`. |
| 3 | Markdown report had no "Remediation" section — operators had to read phase detail to figure out next steps when an ACK timed out. | `src/cyclone_pipeline/report.py::write_report_md` | Added a new `## Remediation` section after `expected_835_by` when `report.result` is `soft_fail` or `hard_fail`. Text mentions "in flight" and "Gainwell directly" so it's greppable. |
| 4 | 835 expected-by text didn't say *when* — just "CO Medicaid payment cycle". | `src/cyclone_pipeline/report.py::write_report_md` | Added "typically the following Monday" so operators can plan a check-835 run. |
| 5 | Test imported `HealthSnapshot as _Ignored` from `cyclone_pipeline.exceptions` — it lives in `state.py`. | `tests/test_pipeline.py` | Removed the bogus import. |
| 6 | Plan tests instantiated `CyclonePipeline(cfg, ...)` and called phases directly — but `__aexit__` is required to set `self._client` (and to close the browser on phase 2). | `tests/test_pipeline.py` (all phase tests) | Wrapped every `p = CyclonePipeline(...)` call in `async with CyclonePipeline(...) as p:`. |
| 7 | Plan did `from playwright.async_api import async_playwright` and `from cyclone_pipeline.browser import UploadPage` *inside* `phase2_upload` for "lazy loading" — but tests patch `cyclone_pipeline.pipeline.async_playwright` and `cyclone_pipeline.pipeline.UploadPage` (module-level), so the patches had no effect. | `src/cyclone_pipeline/pipeline.py` | Moved both imports to module level. |
| 8 | Plan had `phases=list(self.run_state.completed_phases)``PhaseRecord` (in `state.py`) and `PhaseResult` (in `report.py`) are distinct pydantic v2 models, so passing one where the other is expected fails (no auto-coercion). | `src/cyclone_pipeline/pipeline.py::phase7_scan_and_report` | Added `_phase_records_to_results()` helper that goes through `.model_dump()`; phase 7 now uses it. |
| 9 | Resume test stubbed phases 5/6/7 with `AsyncMock(return_value=PhaseResult(...))``resume()` only appends to `completed_phases` via its own logic; the mocks returned a result but didn't mutate state, so `len(report.phases) >= 4` failed (got 3 from the seeded phases 1/2/3). | `tests/test_pipeline.py::test_resume_skips_completed_phases` | Replaced with a `_make_stub(n, name)` factory that appends the result to `p.run_state.completed_phases`. Phase 4 also updated. |
| 10 | CLI `runner.invoke(main, ["status", "--run-dir", ...])` failed with click complaining that `--run-dir` appeared after the subcommand. | `src/cyclone_pipeline/cli.py` | Added `context_settings={"allow_interspersed_args": True}` to the `@click.group()` decorator. |
**Other as-built adjustments (not bugs):**
- Tasks 1421 were committed as one combined commit `616e3be` instead of 8 separate commits — the plan's per-task commit cadence was dropped in favour of fewer, larger commits once the test-fix-verify cycle accelerated.
- The `uv.lock` file is intentionally untracked. The package is pip-installable (`pip install -e .`) and the lockfile is not required for the dev workflow.
---
## File structure
```
@@ -393,9 +420,10 @@ class AckTimeoutError(CyclonePipelineError):
"""An expected inbound ACK did not arrive within the configured
timeout window. Soft-fail — the submission is in flight."""
def __init__(self, ack_kind: str, timeout_s: int):
def __init__(self, ack_kind: str, timeout_s: float):
self.ack_kind = ack_kind # "ta1" | "999" | "277ca" | "835"
self.timeout_s = timeout_s
self.phase = f"wait_{ack_kind}"
super().__init__(
f"No {ack_kind} ACK arrived within {timeout_s}s; "
f"submission is in flight — check Gainwell directly."
@@ -1984,7 +2012,7 @@ async def wait_for(
if result is not None:
return result
if time.monotonic() >= deadline:
raise AckTimeoutError(label, int(timeout_s))
raise AckTimeoutError(label, timeout_s)
await asyncio.sleep(poll_interval_s)
```
@@ -2084,7 +2112,7 @@ def test_markdown_contains_phase_table(sample_report, tmp_path):
assert "# Cyclone pipeline run" in text
assert "PASS" in text
assert "Phase summary" in text
assert "| 1. Pre-flight" in text or "| 1." in text
assert "| 1 | preflight" in text
assert "check-835" in text
assert "Monday" in text
assert "01-preflight.png" in text
@@ -2186,12 +2214,29 @@ def write_report_md(report: RunReport, path: Path) -> None:
lines.append("")
lines.append(
f"- **835 expected by {report.expected_835_by}** "
f"(CO Medicaid payment cycle)."
f"(typically the following Monday; CO Medicaid payment cycle)."
)
cmd = report.next_steps.get("check_835_cmd")
if cmd:
lines.append(f" - Re-run: `{cmd}`")
lines.append("")
# Remediation section — only when the run is not a clean pass.
if report.result in ("soft_fail", "hard_fail"):
lines.append("## Remediation")
lines.append("")
timed_out = [p for p in report.phases if p.status == "timeout"]
if timed_out:
names = ", ".join(p.name for p in timed_out)
lines.append(
f"- ACK timeout(s) in: {names}. Submission is in flight "
f"— check Gainwell directly for the inbound 999."
)
if report.result == "hard_fail":
lines.append(
"- Hard failure: review the run logs and any screenshots "
f"in the `screenshots/` directory before retrying."
)
lines.append("")
lines.append("## Artifacts")
lines.append("")
for s in report.screenshots:
@@ -2433,7 +2478,7 @@ from pathlib import Path
from cyclone_pipeline.pipeline import CyclonePipeline, PipelineConfig
from cyclone_pipeline.state import RunState
from cyclone_pipeline.exceptions import (
BrowserNotReachableError, HealthSnapshot as _Ignored, # noqa
BrowserNotReachableError,
)
@@ -2474,9 +2519,9 @@ async def test_phase1_preflight_passes_when_backend_healthy(tmp_path: Path):
run_dir=tmp_path / "runs",
headless=True,
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-001"
result = await p.phase1_preflight()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-001"
result = await p.phase1_preflight()
assert result.status == "ok"
assert "scheduler_state" in result.detail
@@ -2511,10 +2556,10 @@ async def test_phase1_preflight_fails_fast_if_browser_down(tmp_path: Path):
run_dir=tmp_path / "runs",
headless=True,
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-001"
with pytest.raises(BrowserNotReachableError):
await p.phase1_preflight()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-001"
with pytest.raises(BrowserNotReachableError):
await p.phase1_preflight()
```
- [ ] **Step 2: Run test to verify it fails**
@@ -2547,12 +2592,14 @@ import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from typing import Any, Sequence
import httpx
import structlog
from playwright.async_api import async_playwright
from cyclone_pipeline.api_client import CycloneClient
from cyclone_pipeline.browser import UploadPage
from cyclone_pipeline.exceptions import (
BrowserNotReachableError, CyclonePipelineError, UploadError,
ParseError, SubmitError, AckTimeoutError, IdempotencyError,
@@ -2560,7 +2607,7 @@ from cyclone_pipeline.exceptions import (
from cyclone_pipeline.logging_setup import get_logger
from cyclone_pipeline.report import PhaseResult, RunReport
from cyclone_pipeline.screenshots import ScreenshotWriter
from cyclone_pipeline.state import RunState
from cyclone_pipeline.state import PhaseRecord, RunState
log = get_logger("cyclone_pipeline.pipeline")
@@ -2706,39 +2753,39 @@ async def test_phase2_upload_retries_on_failure(tmp_path: Path):
run_dir=tmp_path / "runs",
headless=True,
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-002"
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-002"
fake_page = AsyncMock()
fake_page.goto = AsyncMock()
fake_upload = AsyncMock()
# First two attempts raise, third succeeds
fake_upload.attach_file = AsyncMock(
side_effect=[UploadError("flaky"), UploadError("flaky"), None]
)
fake_upload.click_parse = AsyncMock()
fake_upload.wait_for_progress_complete = AsyncMock()
with patch(
"cyclone_pipeline.pipeline.async_playwright"
) as mock_pw:
mock_browser = AsyncMock()
mock_pw.return_value.__aenter__.return_value.chromium.launch = AsyncMock(
return_value=mock_browser
fake_page = AsyncMock()
fake_page.goto = AsyncMock()
fake_upload = AsyncMock()
# First two attempts raise, third succeeds
fake_upload.attach_file = AsyncMock(
side_effect=[UploadError("flaky"), UploadError("flaky"), None]
)
mock_browser.new_page = AsyncMock(return_value=fake_page)
fake_upload.click_parse = AsyncMock()
fake_upload.wait_for_progress_complete = AsyncMock()
# Stub the screenshot writer
p._writer = ScreenshotWriter(tmp_path, "test-run-002")
with patch(
"cyclone_pipeline.pipeline.async_playwright"
) as mock_pw:
mock_browser = AsyncMock()
mock_pw.return_value.__aenter__.return_value.chromium.launch = AsyncMock(
return_value=mock_browser
)
mock_browser.new_page = AsyncMock(return_value=fake_page)
# Replace UploadPage class with a stub
with patch("cyclone_pipeline.pipeline.UploadPage") as UP:
UP.return_value = fake_upload
UP.return_value.goto = AsyncMock()
result = await p.phase2_upload(max_attempts=3)
assert result.status == "ok"
assert result.attempts == 3
assert fake_upload.attach_file.call_count == 3
# Stub the screenshot writer
p._writer = ScreenshotWriter(tmp_path, "test-run-002")
# Replace UploadPage class with a stub
with patch("cyclone_pipeline.pipeline.UploadPage") as UP:
UP.return_value = fake_upload
UP.return_value.goto = AsyncMock()
result = await p.phase2_upload(max_attempts=3)
assert result.status == "ok"
assert result.attempts == 3
assert fake_upload.attach_file.call_count == 3
```
- [ ] **Step 2: Run test to verify it fails**
@@ -2763,9 +2810,6 @@ Append to `cyclone_pipeline/pipeline.py`:
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
from playwright.async_api import async_playwright
from cyclone_pipeline.browser import UploadPage
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=self.cfg.headless
@@ -2868,9 +2912,9 @@ async def test_phase3_verify_parse_collects_new_claim_ids(tmp_path: Path):
browser_base="http://127.0.0.1:5173",
run_dir=tmp_path / "runs",
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-003"
result = await p.phase3_verify_parse()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-003"
result = await p.phase3_verify_parse()
assert result.status == "ok"
assert p.run_state.pending_data["claim_ids"] == ["CLM-1", "CLM-2"]
@@ -2887,10 +2931,10 @@ async def test_phase3_verify_parse_fails_on_zero_claims(tmp_path: Path):
browser_base="http://127.0.0.1:5173",
run_dir=tmp_path / "runs",
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-003b"
with pytest.raises(ParseError):
await p.phase3_verify_parse(timeout_s=0.2)
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-003b"
with pytest.raises(ParseError):
await p.phase3_verify_parse(timeout_s=0.2)
```
- [ ] **Step 2: Run test to verify it fails**
@@ -2995,10 +3039,10 @@ async def test_phase4_submit_stores_submission_data(tmp_path: Path):
browser_base="http://127.0.0.1:5173",
run_dir=tmp_path / "runs",
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-004"
p.run_state.pending_data["claim_ids"] = ["CLM-1", "CLM-2"]
result = await p.phase4_submit()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-004"
p.run_state.pending_data["claim_ids"] = ["CLM-1", "CLM-2"]
result = await p.phase4_submit()
assert result.status == "ok"
assert p.run_state.pending_data["submission_id"] == "SUB-42"
assert p.run_state.pending_data["submission_filename"].endswith(".txt")
@@ -3019,10 +3063,10 @@ async def test_phase4_submit_propagates_idempotency_error(tmp_path: Path):
browser_base="http://127.0.0.1:5173",
run_dir=tmp_path / "runs",
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-004b"
with pytest.raises(IdempotencyError):
await p.phase4_submit()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-004b"
with pytest.raises(IdempotencyError):
await p.phase4_submit()
```
- [ ] **Step 2: Run test to verify it fails**
@@ -3128,13 +3172,13 @@ async def test_phase5_wait_ta1_returns_matching_ack(tmp_path: Path):
ta1_timeout_s=2.0,
poll_interval_s=0.05,
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-005"
p.run_state.pending_data["submission_filename"] = (
"11525703-837P-…-1of1.txt"
)
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
result = await p.phase5_wait_ta1()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-005"
p.run_state.pending_data["submission_filename"] = (
"11525703-837P-…-1of1.txt"
)
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
result = await p.phase5_wait_ta1()
assert result.status == "ok"
assert result.detail["ta1_id"] == "TA1-42"
@@ -3152,11 +3196,11 @@ async def test_phase5_wait_ta1_timeout_returns_status_timeout(tmp_path: Path):
ta1_timeout_s=0.2,
poll_interval_s=0.05,
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-005b"
p.run_state.pending_data["submission_filename"] = "x.txt"
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
result = await p.phase5_wait_ta1()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-005b"
p.run_state.pending_data["submission_filename"] = "x.txt"
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
result = await p.phase5_wait_ta1()
assert result.status == "timeout"
# Pipeline continues; this is soft-fail.
```
@@ -3261,11 +3305,11 @@ async def test_phase6_wait_999_returns_matching_ack(tmp_path: Path):
ack_999_timeout_s=2.0,
poll_interval_s=0.05,
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-006"
p.run_state.pending_data["submission_filename"] = "x.txt"
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
result = await p.phase6_wait_999()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-006"
p.run_state.pending_data["submission_filename"] = "x.txt"
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
result = await p.phase6_wait_999()
assert result.status == "ok"
assert result.detail["ack_999_id"] == "999-43"
@@ -3288,12 +3332,12 @@ async def test_phase6_wait_999_rejected_is_hard_fail(tmp_path: Path):
ack_999_timeout_s=2.0,
poll_interval_s=0.05,
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-006b"
p.run_state.pending_data["submission_filename"] = "x.txt"
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
with pytest.raises(CyclonePipelineError) as exc_info:
await p.phase6_wait_999()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-006b"
p.run_state.pending_data["submission_filename"] = "x.txt"
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
with pytest.raises(CyclonePipelineError) as exc_info:
await p.phase6_wait_999()
assert "rejected" in str(exc_info.value).lower()
```
@@ -3405,15 +3449,15 @@ async def test_phase7_scan_html_and_write_reports(tmp_path: Path):
browser_base="http://127.0.0.1:5173",
run_dir=tmp_path / "runs",
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "test-run-007"
# Seed a couple of completed phases to make the report non-empty
from cyclone_pipeline.report import PhaseResult
p.run_state.completed_phases = [
PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4),
PhaseResult(n=2, name="upload", status="ok", duration_s=2.0),
]
result = await p.phase7_scan_and_report()
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "test-run-007"
# Seed a couple of completed phases to make the report non-empty
from cyclone_pipeline.report import PhaseResult
p.run_state.completed_phases = [
PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4),
PhaseResult(n=2, name="upload", status="ok", duration_s=2.0),
]
result = await p.phase7_scan_and_report()
assert result.status == "warn"
# The two report files were written
run_dir = tmp_path / "runs" / "test-run-007"
@@ -3562,7 +3606,9 @@ Append to `cyclone_pipeline/pipeline.py`:
duration_s=sum(
p.duration_s for p in self.run_state.completed_phases
),
phases=list(self.run_state.completed_phases),
phases=self._phase_records_to_results(
self.run_state.completed_phases
),
expected_835_by=expected_835,
next_steps={
"check_835_cmd": (
@@ -3647,6 +3693,18 @@ Append to `cyclone_pipeline/pipeline.py`:
# --- helpers ---------------------------------------------------------------
def _phase_records_to_results(
records: Sequence[PhaseRecord],
) -> list[PhaseResult]:
"""Convert PhaseRecord (state.py) → PhaseResult (report.py).
Both models have the same fields, but pydantic v2 doesn't
auto-coerce between distinct model classes, so we go via
`model_dump()`. Called when building the final RunReport.
"""
return [PhaseResult(**r.model_dump()) for r in records]
def _next_monday_iso() -> str:
"""Return YYYY-MM-DD of the next expected 835 arrival.
Computed from today in Mountain Time. If today is Monday before
@@ -3705,62 +3763,65 @@ async def test_resume_skips_completed_phases(tmp_path: Path):
browser_base="http://127.0.0.1:5173",
run_dir=tmp_path / "runs",
)
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
p._run_id = "resume-run-001"
run_dir = tmp_path / "runs" / "resume-run-001"
run_dir.mkdir(parents=True, exist_ok=True)
# Write a state file with phases 1, 2, 3 done
from cyclone_pipeline.report import PhaseResult
p.run_state.completed_phases = [
PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4),
PhaseResult(n=2, name="upload", status="ok", duration_s=2.0),
PhaseResult(n=3, name="verify_parse", status="ok", duration_s=0.8,
detail={"claim_ids": ["CLM-1"]}),
]
p.run_state.write(run_dir / "run.state.json")
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
# Stub out phase 4 — just confirm it gets called
called = {"phase4": False}
async def fake_phase4():
called["phase4"] = True
return PhaseResult(n=4, name="submit", status="ok", duration_s=1.0)
p.phase4_submit = fake_phase4 # type: ignore[assignment]
# Stub out phases 5, 6, 7 to short-circuit
p.phase5_wait_ta1 = AsyncMock(return_value=PhaseResult( # type: ignore[assignment]
n=5, name="wait_ta1", status="ok", duration_s=0.1
))
p.phase6_wait_999 = AsyncMock(return_value=PhaseResult( # type: ignore[assignment]
n=6, name="wait_999", status="ok", duration_s=0.1
))
p.phase7_scan_and_report = AsyncMock(return_value=PhaseResult( # type: ignore[assignment]
n=7, name="scan_and_report", status="ok", duration_s=0.1
))
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
p._run_id = "resume-run-001"
run_dir = tmp_path / "runs" / "resume-run-001"
run_dir.mkdir(parents=True, exist_ok=True)
# Write a state file with phases 1, 2, 3 done
from cyclone_pipeline.report import PhaseResult
p.run_state.completed_phases = [
PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4),
PhaseResult(n=2, name="upload", status="ok", duration_s=2.0),
PhaseResult(n=3, name="verify_parse", status="ok", duration_s=0.8,
detail={"claim_ids": ["CLM-1"]}),
]
p.run_state.write(run_dir / "run.state.json")
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
# Stub out phase 4 — just confirm it gets called
called = {"phase4": False}
async def fake_phase4():
called["phase4"] = True
result = PhaseResult(n=4, name="submit", status="ok", duration_s=1.0)
p.run_state.completed_phases.append(result)
return result
p.phase4_submit = fake_phase4 # type: ignore[assignment]
# Mock the API responses needed for phases 4-7
respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock(
return_value=Response(200, json={
"submission_id": "SUB-42",
"filename": "x.txt", "claim_count": 1,
})
)
respx.get("http://127.0.0.1:8000/api/ta1-acks").mock(
return_value=Response(200, json=[{
"id": "TA1-1", "isa13": "1", "accepted": True,
"received_at": "2026-06-21T15:30:00Z",
}])
)
respx.get("http://127.0.0.1:8000/api/acks").mock(
return_value=Response(200, json=[{
"id": "999-1", "status": "A",
"original_filename": "x.txt",
"received_at": "2026-06-21T15:30:00Z",
}])
)
respx.get("http://127.0.0.1:8000/api/admin/scheduler/processed-files").mock(
return_value=Response(200, json=[])
)
def _make_stub(n: int, name: str):
"""Stub factory: appends the result to completed_phases so
resume() sees them in the final report."""
async def stub():
result = PhaseResult(n=n, name=name, status="ok", duration_s=0.1)
p.run_state.completed_phases.append(result)
return result
return stub
p.phase5_wait_ta1 = _make_stub(5, "wait_ta1") # type: ignore[assignment]
p.phase6_wait_999 = _make_stub(6, "wait_999") # type: ignore[assignment]
p.phase7_scan_and_report = _make_stub(7, "scan_and_report") # type: ignore[assignment]
# Mock the API responses needed for phases 4-7
respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock(
return_value=Response(200, json={
"submission_id": "SUB-42",
"filename": "x.txt", "claim_count": 1,
})
)
respx.get("http://127.0.0.1:8000/api/ta1-acks").mock(
return_value=Response(200, json=[{
"id": "TA1-1", "isa13": "1", "accepted": True,
"received_at": "2026-06-21T15:30:00Z",
}])
)
respx.get("http://127.0.0.1:8000/api/acks").mock(
return_value=Response(200, json=[{
"id": "999-1", "status": "A",
"original_filename": "x.txt",
"received_at": "2026-06-21T15:30:00Z",
}])
)
respx.get("http://127.0.0.1:8000/api/admin/scheduler/processed-files").mock(
return_value=Response(200, json=[])
)
async with p:
report = await p.resume("resume-run-001")
assert called["phase4"] is True
assert len(report.phases) >= 4
@@ -4111,7 +4172,7 @@ def _run_dir_default() -> Path:
return Path.cwd() / "runs"
@click.group()
@click.group(context_settings={"allow_interspersed_args": True})
@click.option(
"--api-base", default="http://127.0.0.1:8000",
envvar="CYCLONE_API_BASE",