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>
543 lines
22 KiB
Python
543 lines
22 KiB
Python
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 =
|
|
'<td><input type="checkbox" ng-click="updateBillToIns(3)"'
|
|
+ (args.checked ? ' checked' : '') + '></td>'
|
|
+ '<td><input data-fieldname="claimCPTCode" value="G8510" readonly></td>'
|
|
+ '<td><input data-fieldname="ClaimCPTBilledFee" value="' + args.fee + '"></td>';
|
|
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 =
|
|
'<td><input type="checkbox" ng-click="updateBillToIns(3)"'
|
|
+ (args.checked ? ' ng-checked="cpt.BillToIns==\\'1\\'" checked' : '')
|
|
+ '></td>'
|
|
+ '<td><input data-fieldname="claimCPTCode" value="J2001" readonly></td>'
|
|
+ '<td><input data-fieldname="ClaimCPTBilledFee" value="' + args.fee + '"></td>';
|
|
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())
|