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,354 @@
|
||||
r"""
|
||||
export_medcor_ready._claims_phase - the TEST_CLAIM_IDS_FILE override.
|
||||
|
||||
python tests\test_test_claim_ids_file.py
|
||||
|
||||
TEMPORARY testing feature: a hand-maintained "standard" sheet lists one
|
||||
known claim per implemented condition, so a run against it exercises every
|
||||
condition against a real claim without waiting on the full live-queue bulk
|
||||
search and export. `TEST_CLAIM_IDS_FILE` blank is the permanent, default
|
||||
behaviour and must be provably untouched; set, it must skip exactly the
|
||||
bulk-lookup/export/categorize steps and nothing else — the claim-status/
|
||||
date/balance filter setup and the per-claim search/open/apply pipeline stay
|
||||
identical either way, since only the *source of the id list* changes.
|
||||
|
||||
Everything `_claims_phase` touches is a real Playwright page action or a
|
||||
live network call, so every dependency is stubbed here; this proves the
|
||||
branching and wiring, not any one page module's own behaviour (those have
|
||||
their own test files).
|
||||
|
||||
Review item D1 (2026-10-05) made a test run say so and fail loudly: a
|
||||
`[TEST MODE]` line names the sheet and the `_TEST_` summary file, a path that
|
||||
does not exist is a `validate()` problem (exit 2 before login), and a sheet
|
||||
that cannot be read raises, which `main()` turns into exit 5 — it used to
|
||||
return quietly and exit 0.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
import pathlib
|
||||
import tempfile
|
||||
import types
|
||||
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 import claim_file # noqa: E402
|
||||
from ecw_bot.pages import leftnav, claim_filters, claim_results, claim_report # noqa: E402
|
||||
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
class _Called(Exception):
|
||||
"""Raised by a stub to prove it was reached, without needing a mock lib."""
|
||||
|
||||
|
||||
def _forbidden(*a, **k):
|
||||
raise _Called("must not be called")
|
||||
|
||||
|
||||
# Every attribute any case below replaces, restored after each case. The
|
||||
# real .env is loaded at import, so a case that depends on a setting sets it
|
||||
# itself rather than trusting what the operator's .env happens to say.
|
||||
_PATCHED = [
|
||||
(cfg, "TEST_CLAIM_IDS_FILE"), (cfg, "SHOW_CLAIM_IDS"),
|
||||
(cfg, "WRITE_CHARGE_SUMMARY"), (cfg, "CLAIM_STATUS_LABEL"),
|
||||
(cfg, "SAVE_CORRECTIONS"), (cfg, "ECW_USERNAME"), (cfg, "validate"),
|
||||
(cfg, "PROXY_IP"), (cfg, "PROXY_PORT"), (cfg, "BROWSER_CDP_URL"),
|
||||
(leftnav, "open_claims"), (claim_filters, "set_claim_status"),
|
||||
(claim_filters, "set_date_type"), (claim_filters, "set_date_range"),
|
||||
(claim_filters, "clear_balance"), (claim_results, "run_lookup"),
|
||||
(claim_report, "export_claims_report"), (claim_file, "claim_ids"),
|
||||
(msgbox, "info_box"), (flow, "_process_claims"),
|
||||
(flow, "sync_playwright"), (flow, "_login_with_retry"),
|
||||
(flow, "KEEP_OPEN"), (flow.runlog, "start"),
|
||||
(flow.run_lock, "acquire"), (flow.run_lock, "finish"),
|
||||
(flow.guard, "record_success"),
|
||||
]
|
||||
_ORIG = [(obj, name, getattr(obj, name)) for obj, name in _PATCHED]
|
||||
|
||||
|
||||
def _restore():
|
||||
for obj, name, value in _ORIG:
|
||||
setattr(obj, name, value)
|
||||
|
||||
|
||||
def _stub_filters(calls):
|
||||
"""Every filter-setup step _claims_phase always runs, live path or not."""
|
||||
leftnav.open_claims = lambda page: calls.append("open_claims") or True
|
||||
claim_filters.set_claim_status = \
|
||||
lambda page, *a, **k: calls.append("set_claim_status") or True
|
||||
claim_filters.set_date_type = \
|
||||
lambda page, *a, **k: calls.append("set_date_type") or True
|
||||
claim_filters.set_date_range = \
|
||||
lambda page, *a, **k: calls.append("set_date_range") or True
|
||||
claim_filters.clear_balance = \
|
||||
lambda page: calls.append("clear_balance") or True
|
||||
|
||||
|
||||
@case
|
||||
def blank_file_setting_leaves_the_live_path_untouched():
|
||||
"""
|
||||
The regression check that matters most: TEST_CLAIM_IDS_FILE unset (the
|
||||
default, "" from settings.py) must still run the live bulk search and
|
||||
export exactly as before this feature existed.
|
||||
"""
|
||||
calls = []
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = ""
|
||||
_stub_filters(calls)
|
||||
claim_results.run_lookup = \
|
||||
lambda page: calls.append("run_lookup") or True
|
||||
claim_report.export_claims_report = \
|
||||
lambda page: calls.append("export_claims_report") or None
|
||||
claim_file.claim_ids = _forbidden
|
||||
flow._process_claims = lambda page, info: calls.append("process_claims")
|
||||
# _read_back_claim_ids(None) short-circuits (saved is None) and
|
||||
# _categorize_by_payer(None) does too — both real, both no-ops here.
|
||||
flow._claims_phase(types.SimpleNamespace(url='fake://claims'))
|
||||
finally:
|
||||
_restore()
|
||||
|
||||
assert calls == ["open_claims", "set_claim_status", "set_date_type",
|
||||
"set_date_range", "clear_balance",
|
||||
"run_lookup", "export_claims_report"], calls
|
||||
|
||||
|
||||
@case
|
||||
def set_file_setting_skips_the_live_bulk_search_and_export():
|
||||
calls = []
|
||||
fake_info = {"file": pathlib.Path("standard.xlsx"), "header": "CLAIMS#",
|
||||
"column": 1, "ids": ["9103000"], "duplicates": [],
|
||||
"blank_rows": 0, "total_rows": 1}
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = str(ROOT / "standard.xlsx")
|
||||
cfg.SHOW_CLAIM_IDS = False
|
||||
_stub_filters(calls)
|
||||
claim_results.run_lookup = _forbidden
|
||||
claim_report.export_claims_report = _forbidden
|
||||
claim_file.claim_ids = \
|
||||
lambda path: calls.append(f"claim_ids:{path}") or fake_info
|
||||
flow._process_claims = \
|
||||
lambda page, info: calls.append(f"process_claims:{info['ids']}")
|
||||
flow._claims_phase(types.SimpleNamespace(url='fake://claims'))
|
||||
finally:
|
||||
_restore()
|
||||
|
||||
assert calls == ["open_claims", "set_claim_status", "set_date_type",
|
||||
"set_date_range", "clear_balance",
|
||||
f"claim_ids:{ROOT / 'standard.xlsx'}",
|
||||
"process_claims:['9103000']"], calls
|
||||
|
||||
|
||||
@case
|
||||
def set_file_setting_still_shows_the_summary_box_when_configured_to():
|
||||
calls = []
|
||||
fake_info = {"file": pathlib.Path("standard.xlsx"), "header": "CLAIMS#",
|
||||
"column": 1, "ids": ["9103000"], "duplicates": [],
|
||||
"blank_rows": 0, "total_rows": 1}
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = str(ROOT / "standard.xlsx")
|
||||
cfg.SHOW_CLAIM_IDS = True
|
||||
_stub_filters(calls)
|
||||
claim_results.run_lookup = _forbidden
|
||||
claim_report.export_claims_report = _forbidden
|
||||
claim_file.claim_ids = lambda path: fake_info
|
||||
msgbox.info_box = lambda t, m: calls.append(f"box:{t}")
|
||||
flow._process_claims = lambda page, info: calls.append("process_claims")
|
||||
flow._claims_phase(types.SimpleNamespace(url='fake://claims'))
|
||||
finally:
|
||||
_restore()
|
||||
|
||||
assert "box:eCW Bot — Test claim ids" in calls, calls
|
||||
assert "process_claims" in calls, calls
|
||||
|
||||
|
||||
@case
|
||||
def a_bad_file_shows_an_error_box_then_raises_and_never_calls_process_claims():
|
||||
"""
|
||||
`claim_file.claim_ids` is left real here (not stubbed) so this proves
|
||||
the actual FileNotFoundError a missing path raises is caught and
|
||||
reported, not just that some exception would be.
|
||||
|
||||
Changed 2026-10-05 (review item D1): this used to pin a quiet return,
|
||||
which made `main()` exit 0 after processing nothing. The box is still
|
||||
shown first; then it raises, so `main()` exits 5.
|
||||
"""
|
||||
calls = []
|
||||
path = ROOT / "does-not-exist.xlsx"
|
||||
raised = None
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = str(path)
|
||||
_stub_filters(calls)
|
||||
claim_results.run_lookup = _forbidden
|
||||
claim_report.export_claims_report = _forbidden
|
||||
msgbox.info_box = lambda t, m: calls.append(f"box:{t}")
|
||||
flow._process_claims = _forbidden
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
flow._claims_phase(types.SimpleNamespace(url='fake://claims'))
|
||||
except RuntimeError as exc:
|
||||
raised = exc
|
||||
finally:
|
||||
_restore()
|
||||
|
||||
assert raised is not None, "an unreadable test sheet must raise"
|
||||
assert str(raised).startswith(
|
||||
f"Could not read the test claim sheet {path}: "), raised
|
||||
assert isinstance(raised.__cause__, FileNotFoundError), raised.__cause__
|
||||
assert "box:eCW Bot — Could not read the test claim sheet" in calls, calls
|
||||
assert "process_claims" not in calls, calls
|
||||
|
||||
|
||||
@case
|
||||
def an_unreadable_test_sheet_makes_main_exit_5():
|
||||
"""
|
||||
End to end through `main()`: login stubbed to succeed, the claims phase
|
||||
real down to `claim_file.claim_ids`, which fails on a sheet that is
|
||||
there but is not a spreadsheet — so `validate()`'s existence check
|
||||
passes and the read failure is what stops the run.
|
||||
"""
|
||||
printed = io.StringIO()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sheet = pathlib.Path(tmp) / "Standard Claims.xlsx"
|
||||
sheet.write_bytes(b"\xd0\xcf\x11\xe0 legacy OLE2, unreadable here")
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = str(sheet)
|
||||
cfg.SAVE_CORRECTIONS = False # VERIFY: no start box
|
||||
# Nothing from the operator's .env in the captured output.
|
||||
cfg.ECW_USERNAME = "test-user"
|
||||
cfg.PROXY_IP, cfg.PROXY_PORT = "", ""
|
||||
# Never the operator's Chrome: with BROWSER_CDP_URL from .env the
|
||||
# run would open a real one — here with no proxy (2026-10-22).
|
||||
cfg.BROWSER_CDP_URL = ""
|
||||
cfg.validate = lambda: []
|
||||
# In tmp: main() writes the Run's events beside the log.
|
||||
flow.runlog.start = lambda: pathlib.Path(tmp) / "unused.log"
|
||||
flow.run_lock.acquire = lambda log_path, source=None: {}
|
||||
flow.run_lock.finish = lambda code: None
|
||||
flow.sync_playwright = lambda: contextlib.nullcontext(None)
|
||||
page = types.SimpleNamespace(url="fake://landing")
|
||||
flow._login_with_retry = lambda pw: (None, None, page)
|
||||
flow.guard.record_success = lambda: None
|
||||
flow.KEEP_OPEN = False
|
||||
_stub_filters([])
|
||||
claim_results.run_lookup = _forbidden
|
||||
claim_report.export_claims_report = _forbidden
|
||||
msgbox.info_box = lambda t, m: None
|
||||
flow._process_claims = _forbidden
|
||||
with contextlib.redirect_stdout(printed):
|
||||
code = flow.main()
|
||||
finally:
|
||||
_restore()
|
||||
|
||||
out = printed.getvalue()
|
||||
assert code == 5, (code, out)
|
||||
assert "[CLAIMS] Failed: Could not read the test claim sheet" in out, out
|
||||
|
||||
|
||||
_FAKE_INFO = {"file": pathlib.Path("standard.xlsx"), "header": "CLAIMS#",
|
||||
"column": 1, "ids": ["9103000"], "duplicates": [],
|
||||
"blank_rows": 0, "total_rows": 1}
|
||||
|
||||
|
||||
@case
|
||||
def test_mode_line_names_the_sheet_and_the_TEST_summary_file():
|
||||
printed = io.StringIO()
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = str(ROOT / "standard.xlsx")
|
||||
cfg.SHOW_CLAIM_IDS = False
|
||||
cfg.WRITE_CHARGE_SUMMARY = True
|
||||
cfg.CLAIM_STATUS_LABEL = "Medcor Ready"
|
||||
_stub_filters([])
|
||||
claim_results.run_lookup = _forbidden
|
||||
claim_report.export_claims_report = _forbidden
|
||||
claim_file.claim_ids = lambda path: _FAKE_INFO
|
||||
flow._process_claims = lambda page, info: None
|
||||
with contextlib.redirect_stdout(printed):
|
||||
flow._claims_phase(types.SimpleNamespace(url='fake://claims'))
|
||||
finally:
|
||||
_restore()
|
||||
|
||||
want = (f"[TEST MODE] Claim ids come from the test sheet standard.xlsx, "
|
||||
f"not the live Medcor Ready queue — the charge summary goes to "
|
||||
f"Charge Summary_TEST_{cfg.TODAY}.xlsx, not the live "
|
||||
f"Charge Summary_{cfg.TODAY}.xlsx")
|
||||
assert want in printed.getvalue(), printed.getvalue()
|
||||
|
||||
|
||||
@case
|
||||
def test_mode_line_says_when_no_summary_is_written():
|
||||
printed = io.StringIO()
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = str(ROOT / "standard.xlsx")
|
||||
cfg.SHOW_CLAIM_IDS = False
|
||||
cfg.WRITE_CHARGE_SUMMARY = False
|
||||
_stub_filters([])
|
||||
claim_file.claim_ids = lambda path: _FAKE_INFO
|
||||
flow._process_claims = lambda page, info: None
|
||||
with contextlib.redirect_stdout(printed):
|
||||
flow._claims_phase(types.SimpleNamespace(url='fake://claims'))
|
||||
finally:
|
||||
_restore()
|
||||
|
||||
out = printed.getvalue()
|
||||
assert "[TEST MODE] Claim ids come from the test sheet standard.xlsx" \
|
||||
in out, out
|
||||
assert "no charge summary is written (WRITE_CHARGE_SUMMARY=0)" in out, out
|
||||
assert "Charge Summary_TEST_" not in out, out
|
||||
|
||||
|
||||
@case
|
||||
def validate_reports_a_test_claim_ids_file_that_does_not_exist():
|
||||
missing = str(ROOT / "does-not-exist.xlsx")
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = missing
|
||||
problems = cfg.validate()
|
||||
finally:
|
||||
_restore()
|
||||
assert f"TEST_CLAIM_IDS_FILE={missing} does not exist" in problems, \
|
||||
problems
|
||||
|
||||
|
||||
@case
|
||||
def validate_accepts_a_test_claim_ids_file_that_exists_or_is_blank():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sheet = pathlib.Path(tmp) / "Standard Claims.xlsx"
|
||||
sheet.write_bytes(b"present")
|
||||
for value in (str(sheet), ""):
|
||||
try:
|
||||
cfg.TEST_CLAIM_IDS_FILE = value
|
||||
problems = cfg.validate()
|
||||
finally:
|
||||
_restore()
|
||||
assert not any("TEST_CLAIM_IDS_FILE" in p for p in problems), \
|
||||
(value, problems)
|
||||
|
||||
|
||||
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