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:
@@ -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 14–21 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",
|
||||
|
||||
Reference in New Issue
Block a user