Initial import: eCW Charge Validation bot and results Portal
Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,265 @@
|
||||
r"""
|
||||
The claim watchdog in the flow — `flows/export_medcor_ready.py` (2026-10-22).
|
||||
No browser, no eCW: login, run record, disk and the claims phase are
|
||||
stand-ins.
|
||||
|
||||
python tests\test_watchdog_flow.py
|
||||
|
||||
* `_process_claims` runs the claim loop under `watchdog.watching()`.
|
||||
* A fired watchdog stops the loop at the top of the next claim — before
|
||||
its search — so the rest of the queue is Not Reached, not a row of
|
||||
failed searches.
|
||||
* `main()` turns the stall into **exit 8** with a Run Error at step
|
||||
`stalled`, whether it arrives as `Stalled` or as the error the frozen
|
||||
call came back with once the driver was closed.
|
||||
|
||||
Claim numbers here are made up.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
import json
|
||||
import types
|
||||
import pathlib
|
||||
import tempfile
|
||||
import contextlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from ecw_bot import settings as cfg # noqa: E402
|
||||
from ecw_bot.core import run_events, watchdog # noqa: E402
|
||||
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
class Patch:
|
||||
def __init__(self, *triples):
|
||||
self.triples = triples
|
||||
|
||||
def __enter__(self):
|
||||
self.saved = [(o, n, getattr(o, n)) for o, n, _ in self.triples]
|
||||
for o, n, v in self.triples:
|
||||
setattr(o, n, v)
|
||||
|
||||
def __exit__(self, *e):
|
||||
for o, n, v in self.saved:
|
||||
setattr(o, n, v)
|
||||
|
||||
|
||||
def _fire(msg="No progress for 10 min at claim 2/3 9002: read the form"):
|
||||
watchdog._state.update(fired=True, message=msg, answered=False)
|
||||
|
||||
|
||||
def _unfire():
|
||||
watchdog._state.update(fired=False, message="", answered=False)
|
||||
|
||||
|
||||
@case
|
||||
def the_claim_loop_runs_under_the_watchdog():
|
||||
seen = {}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def watching(limit):
|
||||
seen["limit"] = limit
|
||||
yield
|
||||
|
||||
with Patch((watchdog, "watching", watching),
|
||||
(flow, "_check_claims", lambda p, i: seen.setdefault("ran",
|
||||
True))):
|
||||
flow._process_claims(None, {"ids": ["9001"]})
|
||||
assert seen == {"limit": watchdog.limit_seconds(), "ran": True}, seen
|
||||
|
||||
|
||||
@case
|
||||
def a_fired_watchdog_stops_the_loop_before_the_next_search():
|
||||
searched = []
|
||||
stubs = (
|
||||
(flow.preflight, "reset", lambda: None),
|
||||
(flow.preflight, "check_search", lambda page: None),
|
||||
(flow.claim_form, "clear_screen", lambda *a: None),
|
||||
(flow.claim_results, "search_claim",
|
||||
lambda page, cid: searched.append(cid) or {"found": False}),
|
||||
(cfg, "RESULTS_DB", False),
|
||||
)
|
||||
_fire()
|
||||
try:
|
||||
with Patch(*stubs), contextlib.redirect_stdout(io.StringIO()):
|
||||
try:
|
||||
flow._check_claims(types.SimpleNamespace(context=None),
|
||||
{"ids": ["9001", "9002"]})
|
||||
raise AssertionError("did not stop")
|
||||
except watchdog.Stalled as exc:
|
||||
assert "9002: read the form" in str(exc), exc
|
||||
finally:
|
||||
_unfire()
|
||||
assert searched == [], searched
|
||||
|
||||
|
||||
def _main_with(claims_phase, *extra):
|
||||
run_events._reset()
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
log = tmp / "run_20261022_190000.log"
|
||||
browser = types.SimpleNamespace(close=lambda: None)
|
||||
page = types.SimpleNamespace(url="fake://landing")
|
||||
with Patch((flow.runlog, "start", lambda: log),
|
||||
(flow.run_lock, "acquire", lambda p, source=None: {}),
|
||||
(flow.run_lock, "finish", lambda c: None),
|
||||
(cfg, "validate", lambda: []),
|
||||
(flow, "start_mode_gate", lambda: True),
|
||||
(flow, "_disk_check", lambda: True),
|
||||
(cfg, "BROWSER_CDP_URL", ""),
|
||||
(flow, "sync_playwright", lambda: contextlib.nullcontext(None)),
|
||||
(flow, "_login_with_retry", lambda pw: (browser, None, page)),
|
||||
(flow.guard, "record_success", lambda: None),
|
||||
(flow, "KEEP_OPEN", False),
|
||||
(flow, "_claims_phase", claims_phase),
|
||||
# No real box on the desk (the "Chrome open" one included).
|
||||
(flow.msgbox, "info_box", lambda *a, **k: None), *extra):
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
code = flow.main()
|
||||
lines = run_events.path_for(log).read_text(encoding="utf-8").splitlines()
|
||||
return code, [json.loads(l) for l in lines]
|
||||
|
||||
|
||||
@case
|
||||
def stalled_is_exit_8_with_a_run_error():
|
||||
def phase(page):
|
||||
raise watchdog.Stalled("No progress for 10 min at claim 1/1 9001")
|
||||
|
||||
code, ev = _main_with(phase)
|
||||
assert code == 8, code
|
||||
err = [e for e in ev if e["kind"] == "run_error"]
|
||||
assert err and err[0]["step"] == "stalled", ev
|
||||
assert "claim 1/1 9001" in err[0]["message"], err
|
||||
assert ev[-1]["kind"] == "run_end" and ev[-1]["exit_code"] == 8
|
||||
|
||||
|
||||
@case
|
||||
def the_frozen_calls_own_error_after_a_stall_is_exit_8_too():
|
||||
def phase(page):
|
||||
_fire("No progress for 10 min at clear the page after the last claim")
|
||||
raise RuntimeError("Target page, context or browser has been closed")
|
||||
|
||||
try:
|
||||
code, ev = _main_with(phase)
|
||||
finally:
|
||||
_unfire()
|
||||
assert code == 8, code
|
||||
err = [e for e in ev if e["kind"] == "run_error"][0]
|
||||
assert err["step"] == "stalled" and "clear the page" in err["message"]
|
||||
assert "Target page" in err["traceback"], "the call's error kept"
|
||||
|
||||
|
||||
@case
|
||||
def any_other_claims_error_is_still_exit_5():
|
||||
def phase(page):
|
||||
raise RuntimeError("menu broke")
|
||||
|
||||
code, ev = _main_with(phase)
|
||||
assert code == 5, code
|
||||
assert [e for e in ev if e["kind"] == "run_error"][0]["step"] == "claims"
|
||||
|
||||
|
||||
@case
|
||||
def no_chrome_on_the_port_is_opened_by_the_run():
|
||||
"""Start on the Portal is all it takes (operator, 2026-10-22): with
|
||||
BROWSER_CDP_URL set and no Chrome there, the run opens it, then logs
|
||||
in; if it cannot, exit 2 before any login attempt."""
|
||||
opened = []
|
||||
|
||||
def start(url):
|
||||
opened.append(url)
|
||||
return True
|
||||
|
||||
code, ev = _main_with(lambda page: None,
|
||||
(cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"),
|
||||
(flow, "cdp_ready", lambda url: False),
|
||||
(flow, "start_chrome", start),
|
||||
(flow, "chrome_proxy_problem", lambda url: None))
|
||||
assert opened == ["http://127.0.0.1:9222"] and code == 0, (opened, code)
|
||||
|
||||
# A Chrome on the port without the eCW proxy: exit 2, no login.
|
||||
logged_in = []
|
||||
code, ev = _main_with(lambda page: None,
|
||||
(cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"),
|
||||
(flow, "cdp_ready", lambda url: True),
|
||||
(flow, "chrome_proxy_problem",
|
||||
lambda url: "started without the eCW proxy"),
|
||||
(flow, "_login_with_retry",
|
||||
lambda pw: logged_in.append(1)))
|
||||
assert code == 2 and not logged_in, (code, logged_in)
|
||||
|
||||
logged_in = []
|
||||
code, ev = _main_with(lambda page: None,
|
||||
(cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"),
|
||||
(flow, "cdp_ready", lambda url: False),
|
||||
(flow, "start_chrome", lambda url: False),
|
||||
(flow, "_login_with_retry",
|
||||
lambda pw: logged_in.append(1)))
|
||||
assert code == 2 and not logged_in, (code, logged_in)
|
||||
err = [e for e in ev if e["kind"] == "run_error"][0]
|
||||
assert err["step"] == "config" and "Could not open Chrome" in err["message"], err
|
||||
|
||||
|
||||
@case
|
||||
def a_box_says_chrome_is_open_before_the_login():
|
||||
"""Operator, 2026-10-23: "just chrome open nu sonna pothum" — the run
|
||||
that opened Chrome says so and waits, so the Claude extension can be
|
||||
added before eCW. A Chrome already on the port: no box."""
|
||||
seen = []
|
||||
|
||||
def login(pw):
|
||||
seen.append("login")
|
||||
return (types.SimpleNamespace(close=lambda: None), None,
|
||||
types.SimpleNamespace(url="fake://landing"))
|
||||
|
||||
box = (flow.msgbox, "info_box",
|
||||
lambda title, msg, needs_person=False: seen.append(
|
||||
("box", title, needs_person)))
|
||||
code, _ = _main_with(lambda page: None, box,
|
||||
(cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"),
|
||||
(flow, "cdp_ready", lambda url: False),
|
||||
(flow, "start_chrome", lambda url: True),
|
||||
(flow, "chrome_proxy_problem", lambda url: None),
|
||||
(flow, "_login_with_retry", login))
|
||||
assert code == 0, code
|
||||
assert seen[-1] == "login" and len(seen) == 2, seen
|
||||
_, title, needs_person = seen[0]
|
||||
assert "Chrome" in title and needs_person is True, seen
|
||||
|
||||
seen.clear()
|
||||
code, _ = _main_with(lambda page: None, box,
|
||||
(cfg, "BROWSER_CDP_URL", "http://127.0.0.1:9222"),
|
||||
(flow, "cdp_ready", lambda url: True),
|
||||
(flow, "chrome_proxy_problem", lambda url: None),
|
||||
(flow, "_login_with_retry", login))
|
||||
assert seen == ["login"], seen
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = 0
|
||||
for fn in CASES:
|
||||
name = fn.__name__.replace("_", " ")
|
||||
try:
|
||||
fn()
|
||||
print(f" ok {name}")
|
||||
except AssertionError as exc:
|
||||
print(f" FAIL {name}: {exc}")
|
||||
failures += 1
|
||||
except Exception as exc:
|
||||
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
failures += 1
|
||||
run_events._reset()
|
||||
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user