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>
342 lines
12 KiB
Python
342 lines
12 KiB
Python
r"""
|
|
export_medcor_ready - the [MODE] banner and the LIVE-WRITE start box.
|
|
|
|
python tests\test_mode_banner.py
|
|
|
|
Review item P1 (2026-10-01): nothing at the start of a run said whether it
|
|
could write to live eCW. The only way to tell VERIFY from LIVE-WRITE was to
|
|
open .env, and the .env reader itself could get it wrong silently. So:
|
|
|
|
* `mode_banner()` prints, after the [CONFIG] lines, which mode this is, the
|
|
claim limit, and whether the ids come from a test sheet.
|
|
* With SAVE_CORRECTIONS=1, `main()` asks for an OK BEFORE login, with
|
|
Cancel as the default button. Anything but an explicit OK stops the run
|
|
with exit 2 - nothing spent: no login attempt, no 2FA mail.
|
|
|
|
`main()` is driven with everything past the config check stubbed: the run
|
|
log, the browser and the login all raise if reached, so these cases prove
|
|
where `main()` stops, never touching eCW, the Logs\ folder or the real .env.
|
|
"""
|
|
|
|
import io
|
|
import sys
|
|
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 msgbox # noqa: E402
|
|
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
|
|
|
|
CASES = []
|
|
|
|
|
|
def case(fn):
|
|
CASES.append(fn)
|
|
return fn
|
|
|
|
|
|
_CFG_KEYS = ("SAVE_CORRECTIONS", "APPLY_CORRECTIONS", "CLAIM_PROCESS_LIMIT",
|
|
"TEST_CLAIM_IDS_FILE", "CLAIM_STATUS_LABEL", "ECW_USERNAME",
|
|
"PROXY_IP", "PROXY_PORT", "HEADLESS", "validate",
|
|
"resolved_otp_mode", "WRITE_CHARGE_SUMMARY", "BROWSER_CDP_URL")
|
|
|
|
|
|
def _with_cfg(fn, **values):
|
|
"""Run fn() with cfg attributes set, restoring every one after."""
|
|
saved = {k: getattr(cfg, k) for k in _CFG_KEYS}
|
|
saved_bad = list(cfg._BAD_VALUES)
|
|
try:
|
|
for k, v in values.items():
|
|
assert k in _CFG_KEYS, k
|
|
setattr(cfg, k, v)
|
|
return fn()
|
|
finally:
|
|
for k, v in saved.items():
|
|
setattr(cfg, k, v)
|
|
cfg._BAD_VALUES[:] = saved_bad
|
|
|
|
|
|
_VERIFY = dict(SAVE_CORRECTIONS=False, APPLY_CORRECTIONS=True,
|
|
CLAIM_PROCESS_LIMIT=1, TEST_CLAIM_IDS_FILE="",
|
|
CLAIM_STATUS_LABEL="Medcor Ready")
|
|
|
|
|
|
# =============================================================================
|
|
# mode_banner()
|
|
# =============================================================================
|
|
|
|
@case
|
|
def verify_mode_banner():
|
|
lines = _with_cfg(flow.mode_banner, **_VERIFY)
|
|
assert lines == [
|
|
"[MODE] VERIFY — corrections are filled in on screen for review "
|
|
"and NEVER saved (SAVE_CORRECTIONS=0)",
|
|
"[MODE] CLAIM_PROCESS_LIMIT=1 (0 = all)",
|
|
], lines
|
|
|
|
|
|
@case
|
|
def live_write_mode_banner():
|
|
lines = _with_cfg(flow.mode_banner,
|
|
**{**_VERIFY, "SAVE_CORRECTIONS": True})
|
|
assert lines[0] == ("[MODE] LIVE-WRITE — corrections are SAVED to eCW "
|
|
"after the per-claim OK; box ticks and added CPT "
|
|
"rows save at once, OK or not "
|
|
"(SAVE_CORRECTIONS=1)"), lines
|
|
assert not any("VERIFY" in l for l in lines), lines
|
|
|
|
|
|
@case
|
|
def apply_off_says_conditions_are_not_run():
|
|
lines = _with_cfg(flow.mode_banner,
|
|
**{**_VERIFY, "APPLY_CORRECTIONS": False})
|
|
assert "[MODE] conditions are NOT run (APPLY_CORRECTIONS=0)" in lines, \
|
|
lines
|
|
|
|
|
|
@case
|
|
def apply_on_says_nothing_about_conditions():
|
|
lines = _with_cfg(flow.mode_banner, **_VERIFY)
|
|
assert not any("APPLY_CORRECTIONS" in l for l in lines), lines
|
|
|
|
|
|
@case
|
|
def claim_limit_zero_is_shown_as_all():
|
|
lines = _with_cfg(flow.mode_banner,
|
|
**{**_VERIFY, "CLAIM_PROCESS_LIMIT": 0})
|
|
assert "[MODE] CLAIM_PROCESS_LIMIT=0 (0 = all)" in lines, lines
|
|
|
|
|
|
@case
|
|
def claim_ids_from_a_test_sheet_are_named_by_file_name():
|
|
sheet = str(pathlib.Path("C:/somewhere/Standard Claims.xlsx"))
|
|
lines = _with_cfg(flow.mode_banner,
|
|
**{**_VERIFY, "TEST_CLAIM_IDS_FILE": sheet})
|
|
assert lines[-1] == ("[MODE] TEST MODE — claim ids come from Standard "
|
|
"Claims.xlsx, not the live Medcor Ready queue"), lines
|
|
|
|
|
|
@case
|
|
def no_test_mode_line_without_a_test_file():
|
|
lines = _with_cfg(flow.mode_banner, **_VERIFY)
|
|
assert not any("TEST MODE" in l for l in lines), lines
|
|
|
|
|
|
# =============================================================================
|
|
# main() - where it stops
|
|
# =============================================================================
|
|
|
|
class _ReachedLogin(Exception):
|
|
"""The browser/login was reached."""
|
|
|
|
|
|
class _BoxShown(Exception):
|
|
"""A box that must not appear in this mode appeared."""
|
|
|
|
|
|
def _reached_login(*a, **k):
|
|
raise _ReachedLogin()
|
|
|
|
|
|
def _run_main(save, box_answer=None, validate=None):
|
|
"""
|
|
main() with the config check passed (unless `validate` is given), the
|
|
browser and login stubbed to raise _ReachedLogin, and the run log not
|
|
started. `box_answer`: what the LIVE-WRITE box answers, or None to make
|
|
any box an error. Returns (exit code or _ReachedLogin, printed text,
|
|
the boxes shown as (title, body, kwargs)).
|
|
"""
|
|
boxes = []
|
|
|
|
def _box(title, msg, **kw):
|
|
boxes.append((title, msg, kw))
|
|
if box_answer is None:
|
|
raise _BoxShown(title)
|
|
return box_answer
|
|
|
|
orig = (flow.runlog.start, flow.sync_playwright, flow._login_with_retry,
|
|
msgbox.ok_cancel, flow.run_lock.acquire, flow.run_lock.finish)
|
|
# In a temp folder: main() writes the Run's events beside the log.
|
|
unused_log = pathlib.Path(tempfile.mkdtemp()) / "unused.log"
|
|
flow.runlog.start = lambda: unused_log
|
|
flow.sync_playwright = _reached_login
|
|
flow._login_with_retry = _reached_login
|
|
msgbox.ok_cancel = _box
|
|
flow.run_lock.acquire = lambda log_path, source=None: {}
|
|
flow.run_lock.finish = lambda code: None
|
|
out = io.StringIO()
|
|
|
|
def _go():
|
|
with contextlib.redirect_stdout(out):
|
|
try:
|
|
return flow.main()
|
|
except _ReachedLogin as exc:
|
|
return exc
|
|
|
|
values = {**_VERIFY, "SAVE_CORRECTIONS": save,
|
|
"ECW_USERNAME": "test-user", "PROXY_IP": "", "PROXY_PORT": "",
|
|
# Never the operator's Chrome: with BROWSER_CDP_URL from .env
|
|
# main() would open a real one, here with no proxy — and a
|
|
# Portal run then attached to it (2026-10-22).
|
|
"BROWSER_CDP_URL": "",
|
|
"HEADLESS": False, "resolved_otp_mode": lambda: "manual",
|
|
"validate": validate or (lambda: [])}
|
|
try:
|
|
result = _with_cfg(_go, **values)
|
|
finally:
|
|
(flow.runlog.start, flow.sync_playwright, flow._login_with_retry,
|
|
msgbox.ok_cancel, flow.run_lock.acquire, flow.run_lock.finish) = orig
|
|
return result, out.getvalue(), boxes
|
|
|
|
|
|
@case
|
|
def live_write_not_confirmed_stops_before_login_with_exit_2():
|
|
result, out, boxes = _run_main(save=True, box_answer=False)
|
|
assert result == 2, (result, out)
|
|
assert ("[MODE] Operator did not confirm LIVE-WRITE mode — stopping "
|
|
"before login") in out, out
|
|
assert len(boxes) == 1, boxes
|
|
title, body, kw = boxes[0]
|
|
assert title == "eCW Bot — LIVE-WRITE mode", title
|
|
# Enter must answer Cancel here too: OK starts a run that writes.
|
|
assert kw.get("default_cancel") is True, kw
|
|
assert "live eCW" in body, body
|
|
# The banner is printed first, so the log says why the box appeared.
|
|
assert out.index("[MODE] LIVE-WRITE") < out.index("did not confirm"), out
|
|
|
|
|
|
@case
|
|
def live_write_confirmed_goes_on_to_login():
|
|
result, out, boxes = _run_main(save=True, box_answer=True)
|
|
assert isinstance(result, _ReachedLogin), (result, out)
|
|
assert len(boxes) == 1, boxes
|
|
assert "did not confirm" not in out, out
|
|
|
|
|
|
@case
|
|
def verify_mode_asks_nothing_and_goes_on_to_login():
|
|
result, out, boxes = _run_main(save=False, box_answer=None)
|
|
assert isinstance(result, _ReachedLogin), (result, out)
|
|
assert boxes == [], boxes
|
|
assert "[MODE] VERIFY" in out, out
|
|
|
|
|
|
@case
|
|
def mode_banner_follows_the_config_lines():
|
|
_, out, _ = _run_main(save=False)
|
|
assert out.index("[CONFIG] log=") < out.index("[MODE] VERIFY"), out
|
|
|
|
|
|
@case
|
|
def unreadable_setting_exits_2_before_banner_and_login():
|
|
"""
|
|
P1's strict parsing, end to end: a bad yes/no value recorded by the
|
|
settings parser is a validate() problem, so main() stops at the config
|
|
check - before the banner, the box and the login.
|
|
"""
|
|
def _validate():
|
|
cfg._BAD_VALUES.append(("SAVE_CORRECTIONS", "O",
|
|
"a valid yes/no value"))
|
|
return orig_validate()
|
|
|
|
orig_validate = cfg.validate
|
|
result, out, boxes = _run_main(save=True, box_answer=None,
|
|
validate=_validate)
|
|
assert result == 2, (result, out)
|
|
assert "SAVE_CORRECTIONS='O' is not a valid yes/no value" in out, out
|
|
assert "[MODE]" not in out, out
|
|
assert boxes == [], boxes
|
|
|
|
|
|
# ── tools/check_claims_from_excel.py (final review, 2026-10-05) ──────────────
|
|
# A second entry point that logs in and reaches the flow's real
|
|
# _process_claims (so it can save). It only printed a WARNING line and went
|
|
# straight to login with SAVE_CORRECTIONS=1; it now goes through the same
|
|
# start_mode_gate() as main().
|
|
|
|
def _tool():
|
|
import importlib.util
|
|
path = ROOT / "tools" / "check_claims_from_excel.py"
|
|
spec = importlib.util.spec_from_file_location("_check_tool", path)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def _run_tool(save, box_answer=None):
|
|
tool = _tool()
|
|
boxes = []
|
|
|
|
def _box(title, msg, **kw):
|
|
boxes.append((title, msg, kw))
|
|
if box_answer is None:
|
|
raise _BoxShown(title)
|
|
return box_answer
|
|
|
|
orig = (tool.runlog.start, msgbox.ok_cancel)
|
|
tool.runlog.start = lambda: pathlib.Path("unused.log")
|
|
tool.sync_playwright = _reached_login
|
|
tool.login = _reached_login
|
|
tool.load_claims_from_excel = lambda path: [("9103000", "")]
|
|
msgbox.ok_cancel = _box
|
|
out = io.StringIO()
|
|
|
|
def _go():
|
|
with contextlib.redirect_stdout(out):
|
|
try:
|
|
return tool.main(pathlib.Path("unused.xlsx"))
|
|
except _ReachedLogin as exc:
|
|
return exc
|
|
|
|
values = {**_VERIFY, "SAVE_CORRECTIONS": save,
|
|
"ECW_USERNAME": "test-user", "WRITE_CHARGE_SUMMARY": True,
|
|
"validate": lambda: []}
|
|
try:
|
|
result = _with_cfg(_go, **values)
|
|
finally:
|
|
tool.runlog.start, msgbox.ok_cancel = orig
|
|
return result, out.getvalue(), boxes
|
|
|
|
|
|
@case
|
|
def the_check_tool_stops_before_login_when_live_write_is_not_confirmed():
|
|
result, out, boxes = _run_tool(save=True, box_answer=False)
|
|
assert result == 2, (result, out)
|
|
assert "[MODE] LIVE-WRITE" in out, out
|
|
assert "did not confirm LIVE-WRITE" in out, out
|
|
assert len(boxes) == 1 and boxes[0][2].get("default_cancel") is True, \
|
|
boxes
|
|
|
|
|
|
@case
|
|
def the_check_tool_in_verify_mode_asks_nothing_and_goes_on_to_login():
|
|
result, out, boxes = _run_tool(save=False)
|
|
assert isinstance(result, _ReachedLogin), (result, out)
|
|
assert boxes == [], boxes
|
|
assert "[MODE] VERIFY" in out, out
|
|
|
|
|
|
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())
|