r""" core/run_lock.py — the record of the latest bot run, Logs\bot_run.json (spec 2026-10-14-start-bot-from-web-page-design.md). python tests\test_run_lock.py Every case runs with cfg.LOGS pointed at a temp folder. """ import os import sys import json import pathlib import tempfile import subprocess from datetime import datetime, timedelta 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_lock # noqa: E402 CASES = [] def case(fn): CASES.append(fn) return fn def _dead_pid() -> int: p = subprocess.Popen([sys.executable, "-c", "pass"]) p.wait() return p.pid @case def pid_alive_is_true_for_this_python_process(): assert run_lock.pid_alive(os.getpid()) is True @case def pid_alive_is_false_for_junk_and_ended_processes(): for junk in (None, "", "x", 0, -5): assert run_lock.pid_alive(junk) is False, junk assert run_lock.pid_alive(_dead_pid()) is False @case def pid_alive_is_false_for_a_live_process_that_is_not_python(): """A stale record's pid reused by another program must not read as a live bot run.""" p = subprocess.Popen(["ping", "-n", "6", "127.0.0.1"], stdout=subprocess.DEVNULL) try: assert run_lock.pid_alive(p.pid) is False finally: p.kill() p.wait() @case def process_created_reads_the_creation_time(): made = run_lock.process_created(os.getpid()) assert made is not None and made <= datetime.now(), made assert datetime.now() - made < timedelta(days=1), made assert run_lock.process_created(_dead_pid()) is None assert run_lock.process_created(None) is None @case def process_created_is_none_for_an_ended_process_still_held_open(): """The Popen object still holds its handle after wait(): the ended process can be opened and has times, but it is not running.""" p = subprocess.Popen([sys.executable, "-c", "pass"]) p.wait() assert run_lock.process_created(p.pid) is None del p @case def an_offset_aware_start_time_keeps_the_pid_verdict(): """A record whose started_at carries a UTC offset must not crash is_live() (aware vs naive) — in the page or in the bot's acquire().""" rec = {"pid": os.getpid(), "started_at": "2026-10-14T10:00:00+05:30", "ended_at": None} assert run_lock.is_live(rec) is True run_lock.path().parent.mkdir(parents=True, exist_ok=True) run_lock.path().write_text(json.dumps(rec), encoding="utf-8") try: run_lock.acquire("b.log") except run_lock.Busy: pass else: raise AssertionError("acquire went over a live record") @case def a_reused_pid_does_not_read_as_live(): """The record's pid is this (live python) process, but the record was started an hour before this process existed: the pid was reused.""" old = run_lock.process_created(os.getpid()) - timedelta(hours=1) run_lock.path().parent.mkdir(parents=True, exist_ok=True) run_lock.path().write_text(json.dumps( {"pid": os.getpid(), "started_at": old.isoformat(timespec="seconds"), "log_path": "old.log", "ended_at": None}), encoding="utf-8") assert run_lock.is_live(run_lock.read()) is False rec = run_lock.acquire("new.log") assert rec["log_path"] == "new.log", rec @case def an_unreadable_start_time_keeps_the_pid_verdict(): rec = {"pid": os.getpid(), "started_at": "garbage", "ended_at": None} assert run_lock.is_live(rec) is True assert run_lock.is_live({"pid": os.getpid(), "ended_at": None}) is True @case def acquire_writes_this_process_record(): rec = run_lock.acquire(pathlib.Path("Logs/run_x.log"), source="web") assert rec["pid"] == os.getpid() and rec["source"] == "web", rec assert rec["ended_at"] is None and rec["exit_code"] is None, rec on_disk = json.loads(run_lock.path().read_text(encoding="utf-8")) assert on_disk == rec, on_disk @case def acquire_source_defaults_to_the_env_then_terminal(): os.environ.pop("ECW_RUN_SOURCE", None) assert run_lock.acquire("a.log")["source"] == "terminal" run_lock.finish(0) os.environ["ECW_RUN_SOURCE"] = "web" try: assert run_lock.acquire("a.log")["source"] == "web" finally: os.environ.pop("ECW_RUN_SOURCE", None) @case def a_live_record_refuses_a_second_run(): run_lock.acquire("a.log") try: run_lock.acquire("b.log") except run_lock.Busy as exc: assert "already going" in str(exc), exc else: raise AssertionError("second acquire was allowed") @case def a_stale_record_of_a_dead_pid_is_replaced(): run_lock.path().parent.mkdir(parents=True, exist_ok=True) run_lock.path().write_text(json.dumps( {"pid": _dead_pid(), "started_at": "2026-10-14T09:00:00", "log_path": "old.log", "ended_at": None}), encoding="utf-8") rec = run_lock.acquire("new.log") assert rec["log_path"] == "new.log", rec @case def an_ended_record_does_not_block(): run_lock.acquire("a.log") run_lock.finish(0) assert run_lock.acquire("b.log")["log_path"] == "b.log" @case def finish_records_the_exit_code_and_end_time(): run_lock.acquire("a.log") run_lock.finish(5) rec = run_lock.read() assert rec["exit_code"] == 5 and rec["ended_at"], rec assert run_lock.is_live(rec) is False @case def finish_leaves_another_process_record_alone(): run_lock.path().parent.mkdir(parents=True, exist_ok=True) other = {"pid": os.getpid() + 100000, "ended_at": None, "exit_code": None} run_lock.path().write_text(json.dumps(other), encoding="utf-8") run_lock.finish(0) assert run_lock.read() == other @case def finish_never_raises_without_a_record(): run_lock.finish(0) # no file at all run_lock.path().parent.mkdir(parents=True, exist_ok=True) run_lock.path().write_text("{not json", encoding="utf-8") run_lock.finish(0) assert run_lock.read() is None @case def mark_stopped_ends_the_named_run(): rec = run_lock.acquire("a.log") run_lock.mark_stopped(rec["pid"]) rec = run_lock.read() assert rec["stopped_by_operator"] is True and rec["ended_at"], rec assert rec["exit_code"] is None, rec # ── the flow: main() and the [RESULT] line ─────────────────────────────────── import io # noqa: E402 import contextlib # noqa: E402 from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 def _main_with(inner): """flow.main() with runlog stubbed and `_main` replaced by `inner`.""" orig = (flow.runlog.start, flow._main) # In a temp folder: main() writes the Run's events beside the log, and # a test must not leave one in the real Logs\. test_log = pathlib.Path(tempfile.mkdtemp()) / "run_test.log" flow.runlog.start = lambda: test_log flow._main = inner out = io.StringIO() try: with contextlib.redirect_stdout(out): return flow.main(), out.getvalue() finally: flow.runlog.start, flow._main = orig @case def main_records_the_run_and_its_exit_code(): seen = {} def inner(log_path): seen["live"] = run_lock.is_live(run_lock.read()) seen["log"] = str(log_path) return 5 code, _ = _main_with(inner) assert code == 5 assert seen["live"] is True and seen["log"].endswith("run_test.log"), seen rec = run_lock.read() assert rec["exit_code"] == 5 and rec["ended_at"], rec @case def main_finishes_the_record_when_the_run_raises(): def inner(log_path): raise RuntimeError("boom") try: _main_with(inner) except RuntimeError: pass rec = run_lock.read() assert rec["ended_at"] and rec["exit_code"] == 1, rec @case def main_refuses_a_second_run_before_login(): run_lock.acquire("other.log") # this process: live python called = [] code, out = _main_with(lambda log_path: called.append(1) or 0) assert code == 2 and called == [], (code, out) assert "[RUN] A bot run is already going" in out, out @case def main_stops_cleanly_when_the_record_cannot_be_written(): """acquire() failing for a reason other than Busy (disk full, a locked file): a [RUN] line and exit 2, not a traceback.""" def broken(log_path, source=None): raise PermissionError(13, "Access is denied\nsecond line") orig = flow.run_lock.acquire flow.run_lock.acquire = broken called = [] try: code, out = _main_with(lambda log_path: called.append(1) or 0) finally: flow.run_lock.acquire = orig assert code == 2 and called == [], (code, out) assert ("[RUN] Could not write the run record ([Errno 13] Access is " "denied) — not starting") in out, out @case def each_claim_prints_its_result_line(): orig = (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, flow.claim_results.search_claim, flow._read_claim_form, flow.claim_filters.clear_claim_no, flow._write_charge_summary, flow._save_results_db, flow.claim_form.clear_screen) cfg.SHOW_EACH_CLAIM = False cfg.SHOW_CLAIM_IDS = False cfg.CLAIM_PROCESS_LIMIT = 0 def search(page, cid): return {"id": cid, "count": 1, "found": cid != "3", "seconds": 1.0} flow.claim_results.search_claim = search flow._read_claim_form = lambda page, cid, head: { "claim_no": cid, "cpt": [{"code": "99213", "checked": True, "fee": "100.00"}], "icd": [{"code": "Z00.00"}], "progress_notes": {"opened": "modal", "text": "Office visit."}, "_conditions": {"fired": [], "saved": False, "failed": [], "review": [], "comments": {}}} flow.claim_filters.clear_claim_no = lambda page: None flow._write_charge_summary = lambda info, o: None flow._save_results_db = lambda info, o, d: None flow.claim_form.clear_screen = lambda page, cid, keep=(): [] out = io.StringIO() try: with contextlib.redirect_stdout(out): flow._process_claims(None, {"ids": ["1", "3"], "file": None}) finally: (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, flow.claim_results.search_claim, flow._read_claim_form, flow.claim_filters.clear_claim_no, flow._write_charge_summary, flow._save_results_db, flow.claim_form.clear_screen) = orig text = out.getvalue() assert "[RESULT] claim 1/2 1: Pass" in text, text assert "[RESULT] claim 2/2 3: Not found" in text, text # The whole queue up front (2026-10-15): the page's Claim status panel # lists the claims still waiting, which no other line names. assert "[CLAIMS] Queue: 1, 3" in text, text assert text.index("[CLAIMS] Queue:") < text.index("--- claim 1/2"), text def main() -> int: failures = 0 orig = cfg.LOGS for fn in CASES: name = fn.__name__.replace("_", " ") with tempfile.TemporaryDirectory() as tmp: cfg.LOGS = pathlib.Path(tmp) / "Logs" 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 finally: cfg.LOGS = orig print(f"\n{len(CASES) - failures}/{len(CASES)} passed") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())