r""" The professional/HCFA "Claim" screen (Commercial payers) has no separate CPT/HCPCS tab and a different set of per-field ids from the UB Claim form — see hcfa_claim.html's own comment for exactly what differs and why. python tests\test_hcfa_claim.py Real live bug this guards (claim 9103000, Logs\run_20260917_162939.log): `open_cpt_hcpcs_tab()` waited 30s for a tab that does not exist on this screen, raised, and aborted the whole claim read before Progress Notes was ever reached — so it never opened for that claim at all. Fixed by making `open_cpt_hcpcs_tab()` a no-op here, and by giving `extract()` and `set_cpt_pos_modifier()` a `data-fieldname` fallback for this screen's differently-shaped CPT row fields. Since then: ICD and the payer (#billingClaimTbl8) are read here too, and close_claim() no longer Escapes this form's own window. The rest of extract() (header, revenue, payments) is still UB-only. """ import sys import pathlib import inspect ROOT = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "src")) FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" from ecw_bot.pages import claim_form # noqa: E402 from ecw_bot.selectors import claim_form as sel # noqa: E402 CASES = [] def case(fn): CASES.append(fn) return fn @case def is_ub_claim_form_is_false_for_the_hcfa_screen(page): assert claim_form.is_ub_claim_form(page) is False @case def open_cpt_hcpcs_tab_is_a_no_op_here_and_does_not_raise(page): """The real bug: this used to wait 30s for a tab that will never appear, then raise and abort the whole claim read. It must now return promptly without touching the page at all.""" assert claim_form.open_cpt_hcpcs_tab(page) is False # Still on the same screen, untouched — no exception propagated. assert claim_form.is_ub_claim_form(page) is False @case def extract_reads_all_three_real_cpt_rows_via_data_fieldname(page): data = claim_form.extract(page) cpt = data.get("cpt") or [] assert len(cpt) == 3, cpt row0, row1, row2 = cpt assert row0["code"] == "99213", row0 assert row0["pos"] == "11" and row0["mod1"] == "", row0 assert row0["icd1"] == "1" and row0["icd2"] == "2" and row0["icd3"] == "3", row0 assert row1["code"] == "J7298", row1 assert row1["pos"] == "11" and row1["mod1"] == "", row1 assert row2["code"] == "G8510", row2 assert row2["icd1"] == "1", row2 @case def extract_reads_the_icd_table_via_id_prefix_on_the_hcfa_screen(page): data = claim_form.extract(page) icd = data.get("icd") or [] assert len(icd) == 2, icd assert icd[0]["#"] == "1" and icd[0]["Code"] == "Z30.430", icd[0] assert icd[1]["#"] == "2" and icd[1]["Code"] == "Z13.31", icd[1] @case def extract_reads_the_payer_from_the_insurances_and_payment_grid(page): """ Every live HCFA snapshot had `insurance: []` (9109000, 9119000 — extract() only knew the UB form's #UBClaimTbl3), so telehealth never chose 95 and vaccine_admin_codes never fired on a Commercial claim. #billingClaimTbl8 carries the UB table's columns; #billingClaimTbl5's Name cell also holds an edit input, which the value-first cell reader would return instead of the payer. """ page.goto(FIXTURE.as_uri()) ins = claim_form.extract(page).get("insurance") or [] assert len(ins) == 1, ins assert ins[0]["Name"] == "United Health Care", ins[0] assert ins[0]["Ins Id"] == "1660", ins[0] @case def set_cpt_pos_modifier_writes_via_data_fieldname_and_blurs_mod1(page): changes = claim_form.set_cpt_pos_modifier(page, pos="2", modifier="95") assert len(changes) == 3, changes row0, row1, row2 = changes assert row0["code"] == "99213" and row0["after"]["pos"] == "2" \ and row0["after"]["mod1"] == "95", row0 assert row1["code"] == "J7298" and row1["after"]["pos"] == "2" \ and row1["after"]["mod1"] == "95", row1 assert row2["code"] == "G8510" and row2["after"]["pos"] == "2" \ and row2["after"]["mod1"] == "95", row2 mod1_field = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( sel.SEL_CPT_MOD1).first assert mod1_field.get_attribute("data-blurred") == "1", \ "M1 was filled but never blurred - updateCPTMod() would not fire" @case def retag_cpt_icd_pointer_moves_the_depression_pointer_onto_g8510_only(page): """ The real bug (claim 9103000): 99213 wrongly carried the depression pointer ("2") in ICD2 alongside its own diagnosis in ICD1 (and, in this fixture, a third pointer "3" in ICD3 to prove the shift-left behaviour). G8510 wrongly carried "1" (Z30.430) instead of the depression pointer. J7298 carries neither and must be left untouched entirely. """ page.goto(FIXTURE.as_uri()) changes = claim_form.retag_cpt_icd_pointer( page, target_pointer="2", target_codes={"G8510", "G8431"}) by_code = {c["code"]: c for c in changes} assert set(by_code) == {"99213", "G8510"}, changes row0 = by_code["99213"] assert row0["before"]["icd"] == ["1", "2", "3", ""], row0 assert row0["after"]["icd"] == ["1", "3", "", ""], row0 row2 = by_code["G8510"] assert row2["before"]["icd"] == ["1", "", "", ""], row2 assert row2["after"]["icd"] == ["2", "", "", ""], row2 # Read back from the live DOM, not just the returned dict. row0_fields = page.locator(sel.SEL_CPT_ROWS).nth(0) assert [row0_fields.locator(s).first.input_value() for s in sel.SEL_CPT_ICD] \ == ["1", "3", "", ""] row2_fields = page.locator(sel.SEL_CPT_ROWS).nth(2) assert [row2_fields.locator(s).first.input_value() for s in sel.SEL_CPT_ICD] \ == ["2", "", "", ""] # J7298 (row 1) never carried the pointer - untouched, not even read-written. row1_fields = page.locator(sel.SEL_CPT_ROWS).nth(1) assert row1_fields.locator(sel.SEL_CPT_ICD1).first.input_value() == "1" @case def retag_cpt_icd_pointer_is_a_no_op_on_an_already_correct_claim(page): page.goto(FIXTURE.as_uri()) claim_form.retag_cpt_icd_pointer( page, target_pointer="2", target_codes={"G8510", "G8431"}) # Second pass over the now-corrected claim: nothing left to change. changes = claim_form.retag_cpt_icd_pointer( page, target_pointer="2", target_codes={"G8510", "G8431"}) assert changes == [], changes @case def ensure_cpt_rows_checked_checks_the_unchecked_g8510_row(page): """ The real bug report (claim 9103000): G8510's own checkbox sat unchecked - excluded from the claim - and needed checking once its ICD pointer was corrected. 99213/J7298 are already checked and are not target codes anyway; neither should be touched. """ page.goto(FIXTURE.as_uri()) changes = claim_form.ensure_cpt_rows_checked( page, {"G8510", "G8431"}) assert changes == [{"row": 2, "code": "G8510"}], changes checkbox = page.locator(sel.SEL_CPT_ROWS).nth(2).locator( sel.SEL_CPT_ASSIGN_TO_PATIENT).first assert checkbox.is_checked() is True @case def ensure_cpt_rows_checked_is_a_no_op_once_already_checked(page): page.goto(FIXTURE.as_uri()) claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) changes = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) assert changes == [], changes def _inject_g8510_duplicate_row(page, checked=False, fee="0.00"): """ A second G8510 row, after the fixture's own (CPTROW_2) — injected the same way `_inject_j2001_row` below adds its row, rather than growing the shared fixture and its row-count assumptions. """ page.evaluate( """(args) => { const tbody = document.querySelector( '#claimCPTCodesTable8888888888888 tbody'); const addRowTr = tbody.querySelector('tr:last-child'); const tr = document.createElement('tr'); tr.id = 'CPTROW_3'; tr.innerHTML = '' + '' + ''; tbody.insertBefore(tr, addRowTr); }""", {"checked": checked, "fee": fee}) @case def ensure_cpt_rows_checked_checks_only_the_first_row_for_a_code(page): """ Live claim 9131000: a second G8510 row, already excluded, got checked here and then unchecked again by zero_and_uncheck_duplicate_cpt_rows() — a no-op on the claim, reported as two contradictory changes. Only the first real row for a code is this function's to check. Both start unchecked: the first is checked, the second is left alone, and a second pass (the first now checked) must not fall through to the duplicate. """ page.goto(FIXTURE.as_uri()) _inject_g8510_duplicate_row(page, checked=False) changes = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) assert changes == [{"row": 2, "code": "G8510"}], changes rows = page.locator(sel.SEL_CPT_ROWS) assert rows.nth(2).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True assert rows.nth(3).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False changes = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) assert changes == [], changes assert rows.nth(3).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False @case def ensure_cpt_rows_checked_checks_the_priced_copy_not_a_zero_fee_first_g8510(page): """ The operator's common rule (zero_fee_uncheck.py, 2026-09-30): a line billed at zero is never billed. zero_fee_uncheck runs first and unchecks a checked 0.00 G8510; checking it again here would be the same check/uncheck toggle as claim 9131000, between two conditions instead of two steps of one. Blank is zero by the same shared rule. Flipped by the operator's ruling of the same day: the row kept for a code is the first whose fee is NOT zero, so the priced G8510 further down is the one re-included here — `kept_cpt_row_per_code()`, the same helper `zero_and_uncheck_duplicate_cpt_rows()` uses. It used to be left unchecked too ("first" by position alone), leaving no G8510 billed. """ for fee in ("0.00", ""): page.goto(FIXTURE.as_uri()) page.evaluate("(fee) => { document.getElementById('billingClaimIpt17ngR2')" ".value = fee; }", fee) _inject_g8510_duplicate_row(page, checked=False, fee="65.00") changes = claim_form.ensure_cpt_rows_checked(page, {"G8510", "G8431"}) assert changes == [{"row": 3, "code": "G8510"}], (fee, changes) rows = page.locator(sel.SEL_CPT_ROWS) assert rows.nth(2).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False, fee assert rows.nth(3).locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is True, fee assert rows.nth(2).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == fee, \ "the fee must never be written" assert rows.nth(3).locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "65.00" @case def set_cpt_pos_modifier_only_first_stops_after_row_0(page): page.goto(FIXTURE.as_uri()) changes = claim_form.set_cpt_pos_modifier( page, pos="2", modifier="93", only_first=True) assert len(changes) == 1, changes assert changes[0]["code"] == "99213", changes row1_mod1 = page.locator(sel.SEL_CPT_ROWS).nth(1).locator( sel.SEL_CPT_MOD1).first assert row1_mod1.input_value() != "93", \ "only_first=True must not touch the second row on the HCFA screen" @case def zero_and_uncheck_other_cpt_rows_unchecks_the_checked_rows_here(page): """ The checkbox half of document condition #22, only provable on this screen: ub_claim.html's checkboxes carry no ng-click at all, but this fixture's do (`ng-click="updateBillToIns($index)"`, verified live on claim 9103000). 99213 and J7298 start checked with real fees; G8510 starts unchecked on purpose and is the kept code here, so it must come back with no change entry at all. """ page.goto(FIXTURE.as_uri()) changes = claim_form.zero_and_uncheck_other_cpt_rows( page, keep_codes={"G8510"}) by_code = {c["code"]: c for c in changes} assert set(by_code) == {"99213", "J7298"}, changes assert by_code["99213"]["unchecked"] is True assert by_code["99213"]["fee"] == {"before": "210.00", "after": "0.00"} assert by_code["J7298"]["unchecked"] is True assert by_code["J7298"]["fee"] == {"before": "1200.00", "after": "0.00"} assert "G8510" not in by_code, changes row0_checkbox = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( sel.SEL_CPT_ASSIGN_TO_PATIENT).first assert row0_checkbox.is_checked() is False row2_fee = page.locator(sel.SEL_CPT_ROWS).nth(2).locator( sel.SEL_CPT_BILLED_FEE).first assert row2_fee.input_value() == "65.00", \ "the kept code's fee must be left alone" def _inject_j2001_row(page, checked=True, fee="45.00"): """ hcfa_claim.html has no J2001 row of its own — injected the same way the medicare_telehealth tests inject a G2025 row, mirroring the real markup this fixture already uses for its own checked/fee rows. """ page.evaluate( """(args) => { const tbody = document.querySelector( '#claimCPTCodesTable8888888888888 tbody'); const addRowTr = tbody.querySelector('tr:last-child'); const tr = document.createElement('tr'); tr.id = 'CPTROW_3'; tr.innerHTML = '' + '' + ''; tbody.insertBefore(tr, addRowTr); }""", {"checked": checked, "fee": fee}) @case def zero_and_uncheck_cpt_rows_excludes_only_the_target_codes(page): """ Document condition #5's J2001 half: "this code can be unchecked in the claim" plus zeroing its fee, the same uncheck+zero shape as condition #22's cleanup step but inverted — only the target codes are touched, every other row (99213, J7298, G8510) is left alone. """ page.goto(FIXTURE.as_uri()) _inject_j2001_row(page, checked=True, fee="45.00") changes = claim_form.zero_and_uncheck_cpt_rows(page, {"J2001"}) by_code = {c["code"]: c for c in changes} assert set(by_code) == {"J2001"}, changes assert by_code["J2001"]["unchecked"] is True assert by_code["J2001"]["fee"] == {"before": "45.00", "after": "0.00"} j2001_row = page.locator(sel.SEL_CPT_ROWS).nth(3) assert j2001_row.locator(sel.SEL_CPT_ASSIGN_TO_PATIENT).first.is_checked() is False assert j2001_row.locator(sel.SEL_CPT_BILLED_FEE).first.input_value() == "0.00" row0_fee = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( sel.SEL_CPT_BILLED_FEE).first assert row0_fee.input_value() == "210.00", \ "an untargeted row's fee must be left alone" row0_checkbox = page.locator(sel.SEL_CPT_ROWS).nth(0).locator( sel.SEL_CPT_ASSIGN_TO_PATIENT).first assert row0_checkbox.is_checked() is True, \ "an untargeted row's checkbox must be left alone" # A second pass has nothing left to touch. assert claim_form.zero_and_uncheck_cpt_rows(page, {"J2001"}) == [] @case def add_cpt_row_uses_the_real_add_button_and_fee_schedule_picker(page): """ The real flow (claim 9109000's own live evidence): click "Add", search the picker for the code, select the one exact match, click the picker's own OK — never typing straight into the grid's trailing "*" row, which is a different field entirely. """ page.goto(FIXTURE.as_uri()) assert claim_form.cpt_code_present(page, "G2025") is False assert claim_form.add_cpt_row(page, "G2025") is True assert claim_form.cpt_code_present(page, "G2025") is True assert page.locator(sel.SEL_CPT_ROWS).count() == 4 new_row = page.locator(sel.SEL_CPT_ROWS).last assert new_row.locator(sel.SEL_CPT_CODE).first.input_value() == "G2025" # The picker itself must be closed afterward, not left open over the claim. assert page.locator("#claimFeeScheduleModal").is_visible() is False @case def add_cpt_row_searches_by_the_real_search_field_not_a_substring_of_everything(page): """ The catalog also carries G2026 (a decoy), which starts with the same "G20" prefix — proves the search actually narrows to the exact code asked for, not just whatever the picker happened to show first. """ page.goto(FIXTURE.as_uri()) assert claim_form.add_cpt_row(page, "G2026") is True new_row = page.locator(sel.SEL_CPT_ROWS).last assert new_row.locator(sel.SEL_CPT_CODE).first.input_value() == "G2026" @case def add_cpt_row_cancels_the_picker_and_returns_false_on_no_match(page): """ A code the seeded catalog has no match for at all. Must close the picker with its own Cancel rather than leave it open — an open Fee Schedule modal would block every click on the claim form behind it, the exact class of bug dismiss_stray_modal() exists to recover from. """ page.goto(FIXTURE.as_uri()) assert claim_form.add_cpt_row(page, "NOPE99") is False assert page.locator("#claimFeeScheduleModal").is_visible() is False @case def open_progress_notes_works_the_same_as_the_ub_form(page): page.goto(FIXTURE.as_uri()) note = claim_form.open_progress_notes(page) assert note.get("opened") not in (None, "absent", "disabled", "nothing"), note @case def close_claim_uses_the_real_cancel_not_escape_no_wasted_fallback(page): """ Live, on every HCFA claim the bot closed itself (9109000, Logs\\run_20260929_160143.log): dismiss_stray_modal() took this form's own uib window (.claimModalBackdrop) for a stray panel and pressed Escape, which closed the form without closeClaimScreen() — then Cancel, X and the Dental Cancel each timed out on buttons already gone. ~45s and a false "Could not close the claim form", per claim. """ import time page.goto(FIXTURE.as_uri()) assert page.locator("#billingClaimBtn36").first.is_visible() started = time.monotonic() assert claim_form.close_claim(page) is True elapsed = time.monotonic() - started assert page.evaluate("window.closeClaimScreenRan") is True, \ "the form was closed by Escape, not by its own Cancel" assert elapsed < 5, f"took {elapsed:.1f}s — a fallback was tried" assert not page.locator("#billingClaimBtn36").first.is_visible() @case def close_claim_still_dismisses_a_real_stray_panel_over_the_form(page): """ The claim's own window is spared, not the stray check as a whole: a .claimModalBackdrop panel with no claim form inside it, sitting on top, still gets its Escape — and the form under it is then closed by Cancel. """ page.goto(FIXTURE.as_uri()) page.evaluate("""() => { const p = document.createElement('div'); p.className = 'modal fade ng-isolate-scope claimModalBackdrop in'; p.id = 'strayPanel'; p.style.cssText = 'position:fixed;top:0;left:0;width:100%;' + 'height:100%;z-index:2000;display:block;'; document.body.appendChild(p); // Escape goes to the topmost window only: the stray panel, first. document.addEventListener('keydown', e => { if (e.key === 'Escape' && p.isConnected) { p.remove(); e.stopImmediatePropagation(); } }, true); }""") assert claim_form.close_claim(page) is True assert page.locator("#strayPanel").count() == 0 assert page.evaluate("window.closeClaimScreenRan") is True @case def save_claim_clicks_the_hcfa_ok_and_the_modal_closes(page): page.goto(FIXTURE.as_uri()) assert page.locator("#claimScreenOkBtn8888888888888").first.is_visible() assert claim_form.save_claim(page) is True assert not page.locator("#claimScreenOkBtn8888888888888").first.is_visible() def _run() -> int: if not FIXTURE.exists(): print(f" FAIL fixture missing: {FIXTURE}") return 1 try: from playwright.sync_api import sync_playwright except ImportError: print(" SKIP playwright is not installed") return 0 failures = 0 with sync_playwright() as pw: browser = pw.chromium.launch(headless=True) page = browser.new_page() page.goto(FIXTURE.as_uri()) for fn in CASES: name = fn.__name__.replace("_", " ") try: if len(inspect.signature(fn).parameters) == 0: fn() else: fn(page) 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 browser.close() print(f"\n{len(CASES) - failures}/{len(CASES)} passed") return 1 if failures else 0 def main() -> int: """ shot() writes into the operator's dated Screenshots\ folder; a test run must not, so the cases run with it pointed at a temp folder. """ import tempfile from ecw_bot import settings as _cfg orig = (_cfg.SCREENSHOTS, _cfg.SAVE_CORRECTIONS) with tempfile.TemporaryDirectory() as tmp: _cfg.SCREENSHOTS = pathlib.Path(tmp) # These cases exercise eCW's own click and Add flow, as a LIVE-WRITE # run does. VERIFY clicks no box and adds no row (eCW saves both at # once, 2026-10-16): that is tests/test_verify_no_live_writes.py. _cfg.SAVE_CORRECTIONS = True try: return _run() finally: _cfg.SCREENSHOTS, _cfg.SAVE_CORRECTIONS = orig if __name__ == "__main__": sys.exit(main())