r""" conditions/medicare_telehealth.py — document condition #22: POS=2/M1=93-or-95 (per audio/video mode) on the claim's primary CPT row, PLUS adding G2025 with the same modifier and zeroing/unchecking every other CPT row once G2025 is confirmed on the claim. python tests\test_conditions_medicare_telehealth.py Two fixtures, deliberately: ub_claim.html for the base detect/POS/M1 shape (same as test_conditions_telehealth.py), and hcfa_claim.html for the full G2025 flow — the Add-button-plus-Fee-Schedule-picker interaction is only verified evidence on the "Claim"/HCFA and Dental screens (see hcfa_claim.html's own comment), not the UB form. On ub_claim.html, the condition correctly reports "could not confirm G2025" and skips the cleanup step rather than guessing at a flow with no evidence behind it there — that honest degradation is itself part of what these tests prove. Two other things this file exists to prove, beyond the basic detect/apply/describe shape: * **this condition and the general telehealth condition never both fire on the same claim** — Medicare is excluded from telehealth.py's own modifier map specifically so this doesn't happen (see test_conditions_telehealth.py's `video_modifier_is_none_for_medicare_too`); * **G2025 gets the SAME audio/video modifier as the primary row**, not a hardcoded 95 — the document's own wording ("add G2025 with 95 modifier") is the video case; an audio-only Medicare visit needs 93 on both rows, the same reasoning `telehealth_mode()` already applies elsewhere. """ 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" / "ub_claim.html" HCFA_FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "hcfa_claim.html" from ecw_bot.conditions import medicare_telehealth # noqa: E402 from ecw_bot.conditions.base import ConditionResult, run_all # noqa: E402 from ecw_bot.selectors import claim_form as sel # noqa: E402 from ecw_bot.pages import claim_form # noqa: E402 CASES = [] def case(fn): CASES.append(fn) return fn _MEDICARE_DATA = { "progress_notes": {"text": "Session Setting (PoS): TeleHealth"}, "insurance": [{"Name": "Humana Medicare"}], } @case def detect_true_for_telehealth_plus_medicare(): assert medicare_telehealth.detect("C1", _MEDICARE_DATA) is True @case def detect_false_when_the_note_does_not_mention_telehealth(): data = { "progress_notes": {"text": "Regular in-office visit."}, "insurance": [{"Name": "Humana Medicare"}], } assert medicare_telehealth.detect("C1", data) is False @case def detect_false_for_a_non_medicare_payer(): data = { "progress_notes": {"text": "Session Setting (PoS): TeleHealth"}, "insurance": [{"Name": "BH COLORADO ACCESS"}], # Medicaid } assert medicare_telehealth.detect("C1", data) is False @case def detect_false_with_no_insurance_row_at_all(): data = {"progress_notes": {"text": "Session Setting (PoS): TeleHealth"}} assert medicare_telehealth.detect("C1", data) is False @case def apply_sets_pos_2_and_mod1_95_on_the_first_row_only(page): """ ub_claim.html has no Add-flow evidence (see this file's module docstring), so G2025 must come back "could not confirm" here — the POS/M1 change on the primary row must still land regardless, and the honest failure to confirm G2025 must mean the cleanup step is SKIPPED entirely, not applied against a claim with nothing else billable. """ page.goto(FIXTURE.as_uri()) result = medicare_telehealth.apply(page, "9120000", _MEDICARE_DATA) assert isinstance(result, ConditionResult) assert result.condition_name == "medicare_telehealth" assert result.changed is True primary = result.changes[0] assert primary["code"] == "H0023", result.changes assert primary["after"]["pos"] == "2", result.changes assert primary["after"]["mod1"] == "95", result.changes assert [c for c in result.changes if c.get("added")] == [], result.changes assert [c for c in result.changes if "fee" in c] == [], result.changes @case def apply_uses_93_for_an_audio_only_medicare_visit(page): """ Real live example (a progress note reading "THV Audio Only: Telehealth audio phone call only. No video."): Medicare telehealth is NOT always modifier 95 — an audio-only visit still needs 93 on the primary row, the same as any other payer's audio-only visit. """ page.goto(FIXTURE.as_uri()) data = { "progress_notes": {"text": "THV Audio Only: Telehealth audio " "phone call only. No video."}, "insurance": [{"Name": "Humana Medicare"}], } result = medicare_telehealth.apply(page, "9120000", data) assert result.changed is True assert result.changes[0]["after"]["mod1"] == "93", result.changes @case def apply_adds_g2025_via_the_real_fee_schedule_picker_on_the_hcfa_screen(page): """ The full real flow, on the screen where it is actually verified (see hcfa_claim.html's own comment: claim 9109000's live evidence): click Add, search the Fee Schedule picker for G2025, select the one exact match, click its OK — never typing straight into the grid's trailing "*" row. "Other Primary CPT codes" then means every code but G2025, INCLUDING 99213 (which just got POS=2/M1=95) and G8510 (already excluded, but its fee is not yet zero) — per the document's own wording, not a guess. """ page.goto(HCFA_FIXTURE.as_uri()) result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) assert result.changed is True primary = result.changes[0] assert primary["code"] == "99213", result.changes added = [c for c in result.changes if c.get("added")] assert len(added) == 1, result.changes assert added[0]["code"] == "G2025" and added[0]["modifier"] == "95", added zeroed = {c["code"] for c in result.changes if "fee" in c} assert zeroed == {"99213", "J7298", "G8510"}, result.changes g2025_row = page.locator(sel.SEL_CPT_ROWS).last assert g2025_row.locator(sel.SEL_CPT_CODE).first.input_value() == "G2025" assert g2025_row.locator(sel.SEL_CPT_MOD1).first.input_value() == "95" @case def apply_leaves_g2025_alone_and_still_cleans_up_when_it_is_already_present(page): """ G2025 already on the claim (a prior run, or an operator added it by hand): add_cpt_row() must never be attempted a second time, but the unchecked/zero-fee cleanup on every OTHER row still has to run. G2025 is seeded directly here (not via add_cpt_row) - this case is about what apply() does once G2025 already exists, not about the add mechanism itself. """ page.goto(HCFA_FIXTURE.as_uri()) page.evaluate( """(code) => { 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); }""", "G2025") result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) added = [c for c in result.changes if c.get("added")] assert added == [], result.changes # never re-added zeroed = {c["code"] for c in result.changes if "fee" in c} assert zeroed == {"99213", "J7298", "G8510"}, result.changes g2025_row = page.locator(sel.SEL_CPT_ROWS).last assert g2025_row.locator(sel.SEL_CPT_MOD1).first.input_value() == "95", \ "the pre-existing G2025 row's own modifier must be left untouched" @case def describe_names_the_row_and_the_modifier(page): page.goto(HCFA_FIXTURE.as_uri()) result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) text = medicare_telehealth.describe(result) assert "99213" in text, text assert "95" in text, text assert "G2025" in text, text # _MEDICARE_DATA's note text is "Session Setting (PoS): TeleHealth" — # the "telehealth" signal — so the operator can see WHY this fired. assert "telehealth" in text, text @case def run_all_fires_only_medicare_telehealth_not_the_general_condition(page): """ Against the REAL registry (conditions.CONDITIONS), not fakes — proves telehealth.py and medicare_telehealth.py do not both write to the same Medicare claim's primary row. If they did, the second condition's fill() would overwrite the first's, and the confirmation dialog would show the same row twice. """ page.goto(FIXTURE.as_uri()) results = run_all(page, "9120000", _MEDICARE_DATA) assert len(results) == 1, results assert results[0].condition_name == "medicare_telehealth", results @case def run_all_still_fires_only_medicare_on_an_audio_only_medicare_claim(page): """ The exact scenario that would have double-fired before telehealth.py's _choose_modifier checked payer group before the audio/video split: an audio-only Medicare visit. Both conditions' detect() would return True for this claim, so this proves only one of them actually writes. """ page.goto(FIXTURE.as_uri()) data = { "progress_notes": {"text": "THV Audio Only: Telehealth audio " "phone call only. No video."}, "insurance": [{"Name": "Humana Medicare"}], } results = run_all(page, "9120000", data) assert len(results) == 1, results assert results[0].condition_name == "medicare_telehealth", results assert results[0].changes[0]["after"]["mod1"] == "93", results @case def a_g2025_that_cannot_be_added_is_reported_failed_and_sent_to_review(page): """ Review item D6: on ub_claim.html the Add flow has no evidence, so G2025 cannot be confirmed. The primary row DID change (POS 11 -> 2), so the result is still changed=True — the operator must see it — but it carries a G2025 failed record, a review reason (which keeps Save away), a plain describe() line, and a summary comment that says so instead of the fixed "G2025 added … other CPTs unchecked". """ page.goto(FIXTURE.as_uri()) result = medicare_telehealth.apply(page, "9120000", _MEDICARE_DATA) assert result.changed is True, result assert result.review == medicare_telehealth.G2025_NOT_ADDED == ( "G2025 NOT added — add it by hand; the other CPT rows were NOT " "unchecked"), result.review failed = [c for c in result.changes if c.get("failed")] assert len(failed) == 1 and failed[0]["code"] == "G2025" \ and failed[0]["row"] is None and failed[0]["modifier"] == "95", \ result.changes text = medicare_telehealth.describe(result) assert " G2025 NOT added — add it by hand; other rows NOT unchecked" \ in text, text assert medicare_telehealth.summary_comment(result) == ( "Medicare Telehealth (POS 02 / modifier 95 on H0023; G2025 NOT " "added — add by hand)") # Through the runner: the review reaches the report (what the save # gate and the Charge Summary read), the comment replaces the fixed one. page.goto(FIXTURE.as_uri()) report = {} run_all(page, "9120000", _MEDICARE_DATA, conditions=[medicare_telehealth], report=report) assert report["review"] == [{"name": "medicare_telehealth", "reason": medicare_telehealth.G2025_NOT_ADDED} ], report assert "G2025 NOT added" in report["comments"]["medicare_telehealth"] @case def a_failed_g2025_with_nothing_else_changed_is_declined(page): """Primary row already right (POS 02 / 95) and G2025 cannot be added: nothing changed, but the claim still lacks G2025 — declined, not none(), so it is a Review row rather than Pass.""" page.goto(FIXTURE.as_uri()) row0 = page.locator(sel.SEL_CPT_ROWS).nth(0) row0.locator(sel.SEL_CPT_POS).first.fill("02") row0.locator(sel.SEL_CPT_MOD1).first.fill("95") result = medicare_telehealth.apply(page, "9120000", _MEDICARE_DATA) assert result == ConditionResult.declined( "medicare_telehealth", medicare_telehealth.G2025_NOT_ADDED), result @case def a_g2025_modifier_that_does_not_take_is_sent_to_review(page): """ The add worked but M1 on the new G2025 row did not take (read back after `_set_row_modifier`, review item D6). Simulated by making the modifier write a no-op: the G2025 row reads back with a blank M1. """ page.goto(HCFA_FIXTURE.as_uri()) original = claim_form._set_row_modifier claim_form._set_row_modifier = lambda page, code, modifier: True try: result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) finally: claim_form._set_row_modifier = original assert result.changed is True, result assert result.review == ("G2025 added but its modifier did not take — " "check M1"), result.review assert [c for c in result.changes if c.get("added")], result.changes @case def g2025_modifier_ok_is_true_when_the_modifier_took(page): page.goto(HCFA_FIXTURE.as_uri()) g = claim_form.apply_medicare_telehealth_g2025(page, "95") assert g["g2025_added"] is True and g["g2025_modifier_ok"] is True, g @case def apply_twice_on_the_hcfa_screen_is_none_the_second_time(page): """ Review item D3: once G2025 is present, the primary row is POS 2 / 95 and every other row is already unchecked at 0.00, the claim follows rule #22 — the second apply is none(), not a change and not a review. """ page.goto(HCFA_FIXTURE.as_uri()) first = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) assert first.changed is True and first.review == "", first # Unchecked rows and fee-only rows are counted apart (final review, # 2026-10-05; this used to expect "3 other CPT(s) unchecked/fee # zeroed", which suggested an uncheck on the row that only had its fee # zeroed). assert medicare_telehealth.summary_comment(first) == ( "Medicare Telehealth (POS 02 / modifier 95 on 99213; G2025 added; " "2 other CPT(s) unchecked; 1 other CPT fee(s) zeroed)"), \ medicare_telehealth.summary_comment(first) second = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) assert second == ConditionResult.none("medicare_telehealth"), second @case def an_already_correct_primary_row_still_runs_the_g2025_step(page): """The primary row is already POS 02 / 95: it is not a change, but G2025 is still added and the other rows still zeroed.""" page.goto(HCFA_FIXTURE.as_uri()) row0 = page.locator(sel.SEL_CPT_ROWS).nth(0) row0.locator(sel.SEL_CPT_POS).first.fill("02") row0.locator(sel.SEL_CPT_MOD1).first.fill("95") result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) assert result.changed is True and result.review == "", result assert not [c for c in result.changes if "before" in c], result.changes assert [c for c in result.changes if c.get("added")], result.changes assert medicare_telehealth.summary_comment(result).startswith( "Medicare Telehealth (G2025 added; "), \ medicare_telehealth.summary_comment(result) def _seed_g2025(page, mod1): """Put a G2025 row with the given M1 on hcfa_claim.html directly — the same seeding as the already-present case above.""" page.evaluate( """(mod1) => { 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); }""", mod1) @case def an_already_present_g2025_with_the_right_modifier_reads_ok(page): """Review fix round 1: a pre-existing G2025's M1 is read back too.""" page.goto(HCFA_FIXTURE.as_uri()) _seed_g2025(page, "95") g = claim_form.apply_medicare_telehealth_g2025(page, "95") assert g["g2025_added"] is False and g["g2025_confirmed"] is True, g assert g["g2025_present_modifier"] == "95", g assert g["g2025_present_modifier_ok"] is True, g assert g["g2025_modifier_ok"] is None, g @case def an_already_present_g2025_with_a_blank_modifier_is_sent_to_review(page): """ Review fix round 1 (2026-10-05): G2025 already on the claim with a blank M1. Before, its M1 was never read, and once nothing else was left to change the log said "already correct … G2025 present" and the claim passed. Now: the other rows are still cleaned up (G2025 IS on the claim), but the result carries a review, a describe() line and a summary comment naming the wrong M1 — and the M1 itself is left as is. """ page.goto(HCFA_FIXTURE.as_uri()) _seed_g2025(page, "") result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) assert result.changed is True, result assert result.review == "G2025 present but M1 is blank, not 95 — check it", \ result.review zeroed = {c["code"] for c in result.changes if "fee" in c} assert zeroed == {"99213", "J7298", "G8510"}, result.changes assert [c for c in result.changes if c.get("mismatch")], result.changes text = medicare_telehealth.describe(result) assert " G2025 already present but M1 is blank, not 95 — check it" \ in text, text assert "G2025 present but M1 blank, not 95 — check it" in \ medicare_telehealth.summary_comment(result), \ medicare_telehealth.summary_comment(result) g2025_row = page.locator(sel.SEL_CPT_ROWS).last assert g2025_row.locator(sel.SEL_CPT_MOD1).first.input_value() == "", \ "a pre-existing G2025's modifier is read, never rewritten" @case def a_wrong_g2025_modifier_with_nothing_else_to_change_is_declined_not_none(page): """ The exact case the reviewer named: the first apply adds G2025 (95) and cleans up; then the G2025 M1 is blanked. The second apply has nothing to change — but the claim does NOT follow rule #22, so it is declined (a Review row), not none() (a Pass). """ page.goto(HCFA_FIXTURE.as_uri()) first = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) assert first.changed is True and first.review == "", first page.locator(sel.SEL_CPT_ROWS).last.locator( sel.SEL_CPT_MOD1).first.fill("") second = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) assert second == ConditionResult.declined( "medicare_telehealth", "G2025 present but M1 is blank, not 95 — check it"), second @case def a_95_g2025_on_an_audio_only_visit_is_sent_to_review(page): """G2025 already present with 95, but the note is audio-only (93): the primary row is moved to 93 and the G2025 mismatch goes to review.""" page.goto(HCFA_FIXTURE.as_uri()) _seed_g2025(page, "95") data = {"progress_notes": {"text": "THV Audio Only: Telehealth audio " "phone call only. No video."}, "insurance": [{"Name": "Humana Medicare"}]} result = medicare_telehealth.apply(page, "9103000", data) assert result.changed is True, result assert result.review == "G2025 present but M1 is 95, not 93 — check it", \ result.review @case def a_primary_write_that_does_not_take_stops_before_g2025(page): """ Review fix round 1 (2026-10-05): when the primary row's POS/M1 write does not take, the claim is already known to be wrong — no G2025 add and no other row zeroed/unchecked, just the changed primary row and a review. Simulated by making set_cpt_pos_modifier report ok False. """ page.goto(HCFA_FIXTURE.as_uri()) before_rows = page.locator(sel.SEL_CPT_ROWS).count() before_fees = [page.locator(sel.SEL_CPT_ROWS).nth(i) .locator(sel.SEL_CPT_BILLED_FEE).first.input_value() for i in range(before_rows)] original = claim_form.set_cpt_pos_modifier claim_form.set_cpt_pos_modifier = lambda page, pos, modifier, only_first=False: [ {"row": 0, "code": "99213", "before": {"pos": "11", "mod1": ""}, "after": {"pos": "11", "mod1": ""}, "changed": True, "ok": False}] try: result = medicare_telehealth.apply(page, "9103000", _MEDICARE_DATA) finally: claim_form.set_cpt_pos_modifier = original assert result.changed is True, result assert result.review == ( "POS/modifier did not take on row 1 — set it by hand; G2025 was NOT " "added and the other CPT rows were NOT unchecked"), result.review assert [c["code"] for c in result.changes] == ["99213"], result.changes assert not claim_form.cpt_code_present(page, "G2025"), \ "G2025 must not be added after a primary write that did not take" assert page.locator(sel.SEL_CPT_ROWS).count() == before_rows after_fees = [page.locator(sel.SEL_CPT_ROWS).nth(i) .locator(sel.SEL_CPT_BILLED_FEE).first.input_value() for i in range(before_rows)] assert after_fees == before_fees, (before_fees, after_fees) assert "did NOT take" in medicare_telehealth.describe(result) @case def describe_shows_the_mode_evidence(page): page.goto(FIXTURE.as_uri()) data = {"progress_notes": {"text": "THV Audio Only: Telehealth audio " "phone call only. No video."}, "insurance": [{"Name": "Humana Medicare"}]} result = medicare_telehealth.apply(page, "9120000", data) text = medicare_telehealth.describe(result) assert 'mode audio (matched "Audio Only")' in text, text @case def a_live_grid_that_reads_empty_after_a_full_first_read_is_declined(page): """Final review, 2026-10-05: as for telehealth — declined, not none().""" from ecw_bot.conditions.base import CPT_GRID_NOT_READ page.goto(FIXTURE.as_uri()) page.evaluate("""() => document.querySelectorAll('tr[id^="CPTROW_"]') .forEach(r => r.remove())""") result = medicare_telehealth.apply( page, "9120000", dict(_MEDICARE_DATA, cpt=[{"code": "99213"}])) assert result == ConditionResult.declined("medicare_telehealth", CPT_GRID_NOT_READ), result @case def summary_comment_counts_unchecked_and_fee_only_rows_apart(): """ Final review, 2026-10-05: on the UB form only fees are zeroed, and "N other CPT(s) unchecked/fee zeroed" suggested an uncheck that never happened. Each kind is counted on its own. A blank M1 read-back names the visit's modifier, never "?", and only the field that was fixed. """ sc = medicare_telehealth.summary_comment fee = lambda n: {"row": n, "code": f"9000{n}", "fee": {"before": "10.00", "after": "0.00"}} both = lambda n: dict(fee(n), unchecked=True) primary = {"row": 0, "code": "99213", "changed": True, "ok": False, "before": {"pos": "2", "mod1": ""}, "after": {"pos": "2", "mod1": ""}, "modifier": "95"} added = {"row": None, "code": "G2025", "added": True, "modifier": "95"} r = ConditionResult("medicare_telehealth", True, [primary, added, fee(1), fee(2)]) text = sc(r) assert text == ("Medicare Telehealth (modifier 95 on 99213 did NOT take " "— set by hand; G2025 added; 2 other CPT fee(s) " "zeroed)"), text assert "unchecked" not in text and "?" not in text, text r = ConditionResult("medicare_telehealth", True, [added, both(1), both(2)]) assert sc(r) == ("Medicare Telehealth (G2025 added; 2 other CPT(s) " "unchecked)"), sc(r) def _run_cases() -> 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() 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: from ecw_bot import settings as _cfg # 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. orig = _cfg.SAVE_CORRECTIONS _cfg.SAVE_CORRECTIONS = True try: return _run_cases() finally: _cfg.SAVE_CORRECTIONS = orig if __name__ == "__main__": sys.exit(main())