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>
209 lines
7.9 KiB
Python
209 lines
7.9 KiB
Python
"""
|
|
export_medcor_ready._categorize_by_payer(): the payer -> Group Name step.
|
|
|
|
python tests\\test_categorize_by_payer.py
|
|
|
|
Builds a real xlsx (same helper shape as test_claim_file.py's `xlsx()`) so
|
|
this exercises the actual read -> classify -> write path, not a mock of it.
|
|
"""
|
|
|
|
import sys
|
|
import zipfile
|
|
import pathlib
|
|
import tempfile
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
|
|
|
|
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
|
|
from ecw_bot.core import spreadsheet # noqa: E402
|
|
from ecw_bot import settings as cfg # noqa: E402
|
|
|
|
TMP = pathlib.Path(tempfile.mkdtemp())
|
|
NS = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
|
|
|
|
# Hermetic before anything below runs: _categorize_by_payer() builds its
|
|
# output path from cfg.ROOT / "Output" / ..., and without this every test run
|
|
# would write into the REAL Output\ folder. It would also leave
|
|
# NOTIFY_UNMAPPED_PAYERS at its True default, which pops a real Windows
|
|
# message box (case_unmapped_payer_gets_blank_group_not_a_guess triggers it)
|
|
# and blocks for up to MSGBOX_TIMEOUT_SECONDS. Must run before CASES is built
|
|
# and before main() runs, so every case is affected.
|
|
cfg.ROOT = TMP
|
|
cfg.NOTIFY_UNMAPPED_PAYERS = False
|
|
|
|
|
|
def xlsx(header, data_rows, name="t.xlsx") -> pathlib.Path:
|
|
"""A minimal real xlsx: header + data rows, both plain string lists."""
|
|
rows = [header] + data_rows
|
|
body = []
|
|
for r, row in enumerate(rows, start=1):
|
|
cells = "".join(
|
|
f'<c r="{chr(65 + c)}{r}" t="str"><v>{v}</v></c>'
|
|
for c, v in enumerate(row))
|
|
body.append(f'<row r="{r}">{cells}</row>')
|
|
sheet = (f'<?xml version="1.0"?><worksheet {NS}><sheetData>'
|
|
f'{"".join(body)}</sheetData></worksheet>')
|
|
path = TMP / name
|
|
with zipfile.ZipFile(path, "w") as z:
|
|
z.writestr("xl/worksheets/sheet1.xml", sheet)
|
|
return path
|
|
|
|
|
|
HEADER = ["CLAIMS#", "PAYER", "CHARGES"]
|
|
|
|
|
|
def case_categorizes_known_payers():
|
|
saved = xlsx(HEADER, [
|
|
["9107000", "Medicaid", "275.00"],
|
|
["9106000", "DentaQuest", "117.00"],
|
|
], name="known.xlsx")
|
|
out = flow._categorize_by_payer(saved)
|
|
assert out is not None and out.exists(), out
|
|
rows = spreadsheet.read_rows(out)
|
|
assert rows[0] == HEADER + ["Group Name"], rows[0]
|
|
assert rows[1] == ["9107000", "Medicaid", "275.00", "Medicaid"], rows[1]
|
|
assert rows[2] == ["9106000", "DentaQuest", "117.00", "Dental - CHP+/DentaQuest"], rows[2]
|
|
|
|
|
|
def case_unmapped_payer_gets_blank_group_not_a_guess():
|
|
saved = xlsx(HEADER, [
|
|
["9108000", "Some Brand New Payer", "50.00"],
|
|
], name="unmapped.xlsx")
|
|
out = flow._categorize_by_payer(saved)
|
|
rows = spreadsheet.read_rows(out)
|
|
assert rows[1][-1] == "", rows[1] # blank, not a guessed group
|
|
|
|
|
|
def case_none_input_is_a_no_op():
|
|
assert flow._categorize_by_payer(None) is None
|
|
|
|
|
|
def case_missing_payer_column_does_not_raise():
|
|
saved = xlsx(["CLAIMS#", "CHARGES"], [["9108000", "50.00"]],
|
|
name="nopayer.xlsx")
|
|
assert flow._categorize_by_payer(saved) is None # skipped, not a crash
|
|
|
|
|
|
def case_input_file_is_never_modified():
|
|
saved = xlsx(HEADER, [["9107000", "Medicaid", "275.00"]], name="untouched.xlsx")
|
|
before_bytes = saved.read_bytes()
|
|
before_mtime = saved.stat().st_mtime
|
|
flow._categorize_by_payer(saved)
|
|
assert saved.read_bytes() == before_bytes, "Input file content changed"
|
|
assert saved.stat().st_mtime == before_mtime, "Input file was rewritten"
|
|
|
|
|
|
# The same-day output, under the temp cfg.ROOT set at the top of this file.
|
|
OUT = TMP / "Output" / f"Medcor Ready_{cfg.TODAY}.xlsx"
|
|
EARLIER = [HEADER + ["Group Name"],
|
|
["9108000", "Some Brand New Payer", "50.00", "hand-filled"]]
|
|
|
|
|
|
def case_same_day_rerun_keeps_the_earlier_file():
|
|
"""The operator's hand-filled Group Names survive a second run."""
|
|
spreadsheet.write_xlsx(OUT, EARLIER)
|
|
earlier_bytes = OUT.read_bytes()
|
|
before = set(OUT.parent.glob(f"{OUT.stem}__*.xlsx"))
|
|
saved = xlsx(HEADER, [["9107000", "Medicaid", "275.00"]], name="rerun.xlsx")
|
|
out = flow._categorize_by_payer(saved)
|
|
assert out == OUT, out
|
|
kept = set(OUT.parent.glob(f"{OUT.stem}__*.xlsx")) - before
|
|
assert len(kept) == 1, kept
|
|
assert kept.pop().read_bytes() == earlier_bytes, "the kept file changed"
|
|
|
|
|
|
def case_locked_same_day_file_does_not_lose_the_categorized_output():
|
|
"""
|
|
Review item D10: the earlier file is open in Excel, so Windows refuses
|
|
to rename it (WinError 32; a Python `open(..., "rb")` handle does the
|
|
same). That used to throw this run's categorized rows away. Now they
|
|
land under a `__HHMMSS` name, the open file is untouched, and the path
|
|
returned is the new one.
|
|
"""
|
|
spreadsheet.write_xlsx(OUT, EARLIER)
|
|
earlier_bytes = OUT.read_bytes()
|
|
saved = xlsx(HEADER, [
|
|
["9107000", "Medicaid", "275.00"],
|
|
["9106000", "DentaQuest", "117.00"],
|
|
], name="locked.xlsx")
|
|
with open(OUT, "rb"):
|
|
out = flow._categorize_by_payer(saved)
|
|
assert out is not None, "the categorized output was lost"
|
|
assert out != OUT and out.parent == OUT.parent, out
|
|
assert out.name.startswith(f"{OUT.stem}__"), out.name
|
|
rows = spreadsheet.read_rows(out)
|
|
assert rows[1] == ["9107000", "Medicaid", "275.00", "Medicaid"], rows
|
|
assert rows[2] == ["9106000", "DentaQuest", "117.00", "Dental - CHP+/DentaQuest"], rows
|
|
assert OUT.read_bytes() == earlier_bytes, "the open file was changed"
|
|
|
|
|
|
def _categorize_with_boxes(saved):
|
|
"""
|
|
_categorize_by_payer with NOTIFY_UNMAPPED_PAYERS on and the message box
|
|
replaced by a recorder (never a real box). Returns (path, [(title, body)]).
|
|
"""
|
|
shown = []
|
|
orig = (cfg.NOTIFY_UNMAPPED_PAYERS, flow.msgbox.info_box)
|
|
try:
|
|
cfg.NOTIFY_UNMAPPED_PAYERS = True
|
|
flow.msgbox.info_box = lambda t, b: shown.append((t, b))
|
|
out = flow._categorize_by_payer(saved)
|
|
finally:
|
|
cfg.NOTIFY_UNMAPPED_PAYERS, flow.msgbox.info_box = orig
|
|
return out, shown
|
|
|
|
|
|
def case_locked_same_day_file_tells_the_operator_the_new_name():
|
|
"""
|
|
Fix round 1 (review item D10, 2026-10-05): whoever has the usual file
|
|
open in Excel is at the desk, so the `__HHMMSS` name goes in a box too,
|
|
not only the log — else they keep filling in the earlier run's sheet.
|
|
"""
|
|
spreadsheet.write_xlsx(OUT, EARLIER)
|
|
saved = xlsx(HEADER, [["9107000", "Medicaid", "275.00"]],
|
|
name="locked-box.xlsx")
|
|
with open(OUT, "rb"):
|
|
out, shown = _categorize_with_boxes(saved)
|
|
assert out is not None and out != OUT, out
|
|
assert len(shown) == 1, shown
|
|
title, body = shown[0]
|
|
assert title == "eCW Bot — Medcor Ready saved under another name", title
|
|
assert f"could not be saved as {OUT.name}" in body, body
|
|
assert out.name in body, body
|
|
# Fix round 2: the box must not promise the earlier file was untouched —
|
|
# a failed move-back can leave a half-file under the usual name.
|
|
assert "left as it was" not in body, body
|
|
|
|
|
|
def case_unlocked_same_day_rerun_shows_no_new_name_box():
|
|
spreadsheet.write_xlsx(OUT, EARLIER)
|
|
saved = xlsx(HEADER, [["9107000", "Medicaid", "275.00"]],
|
|
name="unlocked-box.xlsx")
|
|
out, shown = _categorize_with_boxes(saved)
|
|
assert out == OUT, out
|
|
assert shown == [], shown
|
|
|
|
|
|
CASES = [v for k, v in sorted(globals().items()) if k.startswith("case_")]
|
|
|
|
|
|
def main() -> int:
|
|
failures = 0
|
|
for fn in CASES:
|
|
name = fn.__name__[5:].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())
|