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,342 @@
|
||||
r"""
|
||||
web/run_status.py — the bot's run log read into the page's live status.
|
||||
|
||||
python tests\test_run_status.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from web import run_status as rs # noqa: E402
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
def _status(**over):
|
||||
"""The runner's status (web/bot_runner.py): no run yet, unless `over`
|
||||
says otherwise."""
|
||||
st = {"running": False, "starting": False, "log_path": None,
|
||||
"out_path": None, "exit_code": None, "stopped_by_operator": False,
|
||||
"started_at": None, "ended_at": None}
|
||||
st.update(over)
|
||||
return st
|
||||
|
||||
|
||||
LOG = """\
|
||||
======================================================================
|
||||
2026-10-14 10:00:00 — run start
|
||||
[CONFIG] user=x otp=imap
|
||||
[LOGIN] Opening the login page
|
||||
[OTP] Waiting for the approval mail
|
||||
[OK] Logged in in 95s
|
||||
[CLAIMS] --- claim 1/3: 9000001 ---
|
||||
[RESULT] claim 1/3 9000001: Pass
|
||||
[CLAIMS] --- claim 2/3: 9000002 ---
|
||||
[CLAIMS] --- claim 2/3 done, moving to next ---
|
||||
[RESULT] claim 2/3 9000002: Fail
|
||||
[CLAIMS] --- claim 3/3: 9000003 ---
|
||||
"""
|
||||
|
||||
|
||||
@case
|
||||
def phase_follows_the_last_tagged_line():
|
||||
assert rs.parse("[LOGIN] x")["phase"] == "Logging in"
|
||||
assert rs.parse("[LOGIN] x\n[2FA] y")["phase"] == \
|
||||
"Waiting for the 2FA approval mail"
|
||||
assert rs.parse("[OTP] y\n[OK] Logged in in 3s")["phase"] == "Logged in"
|
||||
assert rs.parse(LOG)["phase"] == "Checking claims"
|
||||
assert rs.parse("")["phase"] == "Starting"
|
||||
|
||||
|
||||
@case
|
||||
def progress_and_current_claim_come_from_the_claim_header():
|
||||
p = rs.parse(LOG)
|
||||
assert p["progress"] == [3, 3] and p["current"] == "9000003", p
|
||||
|
||||
|
||||
@case
|
||||
def a_done_line_is_not_a_claim_header():
|
||||
p = rs.parse("[CLAIMS] --- claim 2/3 done, moving to next ---")
|
||||
assert p["progress"] is None, p
|
||||
|
||||
|
||||
@case
|
||||
def counts_come_from_result_lines():
|
||||
c = rs.parse(LOG + "[RESULT] claim 3/3 9000003: Review\n")["counts"]
|
||||
assert c == {"Pass": 1, "Fail": 1, "Review": 1, "Not found": 0}, c
|
||||
|
||||
|
||||
@case
|
||||
def tail_is_the_last_lines():
|
||||
text = "\n".join(f"line {i}" for i in range(100))
|
||||
tail = rs.parse(text)["tail"]
|
||||
assert len(tail) == rs.TAIL_LINES and tail[-1] == "line 99", tail
|
||||
|
||||
|
||||
@case
|
||||
def guard_line_is_kept():
|
||||
p = rs.parse("[GUARD] Refused: next attempt allowed at 10:40")
|
||||
assert p["guard"] == "[GUARD] Refused: next attempt allowed at 10:40"
|
||||
|
||||
|
||||
@case
|
||||
def exit_codes_read_as_plain_words():
|
||||
assert rs.exit_text(0) == "Finished"
|
||||
assert "setting" in rs.exit_text(2)
|
||||
assert "guard" in rs.exit_text(3)
|
||||
assert "claims step failed" in rs.exit_text(5)
|
||||
assert "2FA" in rs.exit_text(6)
|
||||
assert "restricted" in rs.exit_text(4) and "do not retry" in rs.exit_text(4)
|
||||
assert rs.exit_text(1) == "The run failed (see the log)"
|
||||
assert rs.exit_text(9) == "Ended with code 9"
|
||||
assert rs.exit_text(None, stopped=True) == "Stopped by operator"
|
||||
assert "no exit code" in rs.exit_text(None)
|
||||
|
||||
|
||||
@case
|
||||
def read_tail_reads_the_end_of_a_file_and_missing_is_empty():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p = pathlib.Path(tmp) / "run.log"
|
||||
p.write_text("a\n" * 1000 + "LAST\n", encoding="utf-8")
|
||||
assert rs.read_tail(p, max_bytes=100).endswith("LAST\n")
|
||||
assert rs.read_tail(pathlib.Path(tmp) / "none.log") == ""
|
||||
assert rs.read_tail(None) == ""
|
||||
|
||||
|
||||
@case
|
||||
def summarize_running_uses_the_phase_as_the_message():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p = pathlib.Path(tmp) / "run.log"
|
||||
p.write_text(LOG, encoding="utf-8")
|
||||
s = rs.summarize(_status(running=True, log_path=str(p),
|
||||
started_at="2026-10-14T10:00:00", pid=123,
|
||||
source="web"))
|
||||
assert s["message"] == "Checking claims", s
|
||||
assert s["progress"] == [3, 3], s
|
||||
for k in ("log_path", "out_path", "pid"):
|
||||
assert k not in s, (k, s)
|
||||
assert set(s) == {"running", "starting", "started_at", "ended_at",
|
||||
"exit_code", "stopped_by_operator", "source", "phase",
|
||||
"progress", "current", "counts", "tail", "message",
|
||||
"elapsed_s", "claims", "results_run_at",
|
||||
"started_by"}, set(s)
|
||||
|
||||
|
||||
@case
|
||||
def summarize_ended_says_how_and_how_long():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p = pathlib.Path(tmp) / "run.log"
|
||||
p.write_text("[GUARD] Refused: next attempt allowed at 10:40\n",
|
||||
encoding="utf-8")
|
||||
s = rs.summarize(_status(log_path=str(p), exit_code=3,
|
||||
started_at="2026-10-14T10:00:00",
|
||||
ended_at="2026-10-14T10:04:12"))
|
||||
assert s["message"].startswith("Login guard refused"), s
|
||||
assert "next attempt allowed at 10:40" in s["message"], s
|
||||
assert "[GUARD]" not in s["message"], s
|
||||
assert s["elapsed_s"] == 252, s
|
||||
|
||||
|
||||
QUEUE_LOG = """\
|
||||
[CLAIMS] Queue: 9000001, 9000002, 9000003, 9000004
|
||||
[CLAIMS] --- claim 1/4: 9000001 ---
|
||||
[RESULT] claim 1/4 9000001: Pass
|
||||
[CLAIMS] --- claim 2/4: 9000002 ---
|
||||
[RESULT] claim 2/4 9000002: Fail
|
||||
[CLAIMS] --- claim 3/4: 9000003 ---
|
||||
"""
|
||||
|
||||
|
||||
@case
|
||||
def claims_lists_every_queued_claim_with_its_status():
|
||||
"""The Claim status panel (operator, 2026-10-15): every claim of the
|
||||
run — done with its result, the one being checked, the ones waiting."""
|
||||
claims = rs.parse(QUEUE_LOG)["claims"]
|
||||
assert claims == [
|
||||
{"n": 1, "id": "9000001", "status": "Pass", "done": True},
|
||||
{"n": 2, "id": "9000002", "status": "Fail", "done": True},
|
||||
{"n": 3, "id": "9000003", "status": "Checking", "done": False},
|
||||
{"n": 4, "id": "9000004", "status": "Waiting", "done": False},
|
||||
], claims
|
||||
|
||||
|
||||
@case
|
||||
def claims_without_a_queue_line_come_from_the_claim_lines():
|
||||
"""An older log, written before the Queue line existed."""
|
||||
claims = rs.parse(LOG)["claims"]
|
||||
assert [c["id"] for c in claims] == ["9000001", "9000002", "9000003"]
|
||||
assert [c["status"] for c in claims] == ["Pass", "Fail", "Checking"]
|
||||
|
||||
|
||||
@case
|
||||
def a_claim_left_half_way_is_not_finished_once_the_run_ended():
|
||||
def summary(running):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p = pathlib.Path(tmp) / "run.log"
|
||||
p.write_text(QUEUE_LOG, encoding="utf-8")
|
||||
return rs.summarize(_status(
|
||||
running=running, log_path=str(p),
|
||||
stopped_by_operator=not running,
|
||||
started_at="2026-10-15T10:00:00",
|
||||
ended_at=None if running else "2026-10-15T10:05:00"))
|
||||
live = summary(True)["claims"]
|
||||
assert live[2]["status"] == "Checking" and live[3]["status"] == "Waiting"
|
||||
ended = summary(False)["claims"]
|
||||
assert ended[2]["status"] == "Not finished", ended
|
||||
assert ended[3]["status"] == "Not checked", ended
|
||||
assert [c["done"] for c in ended] == [True, True, False, False], ended
|
||||
|
||||
|
||||
@case
|
||||
def a_not_found_claim_is_done_with_no_result_category():
|
||||
"""Not found ends the claim (it counts as done) — the panel shows it
|
||||
done, but it has no results row to open."""
|
||||
claims = rs.parse("[CLAIMS] Queue: 9000001, 9000002\n"
|
||||
"[CLAIMS] --- claim 1/2: 9000001 ---\n"
|
||||
"[RESULT] claim 1/2 9000001: Not found\n")["claims"]
|
||||
assert claims[0] == {"n": 1, "id": "9000001", "status": "Not found",
|
||||
"done": True}, claims
|
||||
assert claims[1]["done"] is False, claims
|
||||
|
||||
|
||||
@case
|
||||
def the_results_run_at_line_names_the_run_the_claims_are_saved_in():
|
||||
"""Review 2026-10-19: the Claim status panel opened a claim in the run
|
||||
the table showed, not the one it was saved in. The bot names that run
|
||||
right after the Queue line (only with RESULTS_DB=1)."""
|
||||
text = ("[CLAIMS] Queue: 9000001, 9000002\n"
|
||||
"[CLAIMS] Results run_at: 2026-10-16T19:08:37\n"
|
||||
"[CLAIMS] --- claim 1/2: 9000001 ---\n")
|
||||
assert rs.parse(text)["results_run_at"] == "2026-10-16T19:08:37"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p = pathlib.Path(tmp) / "run.log"
|
||||
p.write_text(text, encoding="utf-8")
|
||||
s = rs.summarize(_status(running=True, log_path=str(p)))
|
||||
assert s["results_run_at"] == "2026-10-16T19:08:37", s
|
||||
|
||||
|
||||
@case
|
||||
def no_results_run_at_line_is_none():
|
||||
"""RESULTS_DB off (or an older log): no line, no saved run."""
|
||||
assert rs.parse(QUEUE_LOG)["results_run_at"] is None
|
||||
assert rs.parse("")["results_run_at"] is None
|
||||
assert rs.summarize(_status())["results_run_at"] is None
|
||||
|
||||
|
||||
@case
|
||||
def a_later_run_in_the_same_log_drops_the_earlier_runs_time():
|
||||
"""A new Queue line starts a new run: a run_at from before it is not
|
||||
this run's."""
|
||||
text = ("[CLAIMS] Queue: 9000001\n"
|
||||
"[CLAIMS] Results run_at: 2026-10-16T19:08:37\n"
|
||||
"[CLAIMS] Queue: 9000002\n")
|
||||
assert rs.parse(text)["results_run_at"] is None
|
||||
|
||||
|
||||
@case
|
||||
def a_results_run_at_that_is_not_a_time_is_ignored():
|
||||
"""It goes into the page's link: only the bot's own ISO form counts."""
|
||||
for bad in ("[CLAIMS] Results run_at: 2026-10-16T19:08:37&x=1",
|
||||
"[CLAIMS] Results run_at: soon",
|
||||
"[CLAIMS] Results run_at: "):
|
||||
assert rs.parse(bad)["results_run_at"] is None, bad
|
||||
|
||||
|
||||
@case
|
||||
def a_run_that_ended_with_no_end_time_has_no_duration():
|
||||
"""Review 2026-10-14: a run killed outside the page (or crashed) never
|
||||
wrote ended_at; measured to now(), its duration grew on every poll —
|
||||
"Ended ... in 87m 3s". No end time, no duration. A running run still
|
||||
counts up to now."""
|
||||
ended = rs.summarize(_status(started_at="2026-10-14T10:00:00"))
|
||||
assert ended["elapsed_s"] is None, ended
|
||||
running = rs.summarize(_status(running=True,
|
||||
started_at="2026-10-14T10:00:00"))
|
||||
assert isinstance(running["elapsed_s"], int), running
|
||||
|
||||
|
||||
@case
|
||||
def summarize_starting_reads_the_child_output_file():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = pathlib.Path(tmp) / "web_run.out"
|
||||
out.write_text("Traceback: boom\n", encoding="utf-8")
|
||||
s = rs.summarize(_status(running=True, starting=True,
|
||||
out_path=str(out)))
|
||||
assert s["message"] == "Starting" and s["tail"] == ["Traceback: boom"], s
|
||||
|
||||
|
||||
@case
|
||||
def a_done_line_with_extra_words_is_not_a_claim_header():
|
||||
p = rs.parse("[CLAIMS] --- claim 2/3 done (shown in its review box), "
|
||||
"moving to next ---")
|
||||
assert p["progress"] is None and p["current"] is None, p
|
||||
|
||||
|
||||
@case
|
||||
def a_crlf_log_parses_the_same():
|
||||
a, b = rs.parse(LOG), rs.parse(LOG.replace("\n", "\r\n"))
|
||||
for k in ("phase", "progress", "current", "counts", "guard"):
|
||||
assert a[k] == b[k], (k, a[k], b[k])
|
||||
assert b["tail"] == a["tail"], b["tail"]
|
||||
|
||||
|
||||
@case
|
||||
def summarize_counts_the_whole_log_not_just_its_tail():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p = pathlib.Path(tmp) / "run.log"
|
||||
body = ("[RESULT] claim 1/9 111: Pass\n"
|
||||
"[RESULT] claim 2/9 222: Fail\n"
|
||||
+ "[CLAIMS] noise line to push the results out of the tail\n"
|
||||
* 10_000
|
||||
+ "[CLAIMS] --- claim 9/9: 999 ---\n")
|
||||
p.write_text(body, encoding="utf-8")
|
||||
assert p.stat().st_size > 256_000
|
||||
s = rs.summarize(_status(running=True, log_path=str(p)))
|
||||
assert s["counts"]["Pass"] == 1 and s["counts"]["Fail"] == 1, s["counts"]
|
||||
assert s["progress"] == [9, 9] and len(s["tail"]) == rs.TAIL_LINES, s
|
||||
|
||||
|
||||
@case
|
||||
def the_tail_hides_the_otp_code_and_the_username():
|
||||
text = ("[CONFIG] user=jsmith otp=imap proxy=-:- headless=False\n"
|
||||
"[OTP] Code 482913 in 'Your code 482913' (sent 10:01:02)\n"
|
||||
"[OTP] Approval link in 'Sign-in' (sent 10:01:02)\n")
|
||||
p = rs.parse(text)
|
||||
assert p["tail"] == [
|
||||
"[CONFIG] user=(hidden) otp=imap proxy=-:- headless=False",
|
||||
"[OTP] Code (hidden)",
|
||||
"[OTP] Approval link in 'Sign-in' (sent 10:01:02)"], p["tail"]
|
||||
joined = "\n".join(p["tail"])
|
||||
assert "482913" not in joined and "jsmith" not in joined, joined
|
||||
assert p["phase"] == "Waiting for the 2FA approval mail", p
|
||||
|
||||
|
||||
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
|
||||
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