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,410 @@
|
||||
r"""
|
||||
Every claim starts from a clean search page (operator, 2026-10-12): what
|
||||
the last claim left open is closed, Claim No and Lookup are checked to be
|
||||
on screen with nothing over them, and only then is the claim searched. A
|
||||
page that will not come clean makes that ONE claim Review — the next claim
|
||||
clears and checks again. Live run that day: claim 1's viewer stayed open
|
||||
and claims 2 and 3 were never searched.
|
||||
|
||||
python tests\test_clean_between_claims.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
import pathlib
|
||||
import inspect
|
||||
import contextlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures"
|
||||
HCFA = FIXTURES / "hcfa_claim.html"
|
||||
|
||||
from ecw_bot import settings as cfg # noqa: E402
|
||||
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
|
||||
from ecw_bot.pages import claim_form, claim_results # noqa: E402
|
||||
from ecw_bot.selectors import claim_lookup as lsel # noqa: E402
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
def _quiet(fn, *a, **k):
|
||||
with contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
result = fn(*a, **k)
|
||||
return result, out.getvalue()
|
||||
|
||||
|
||||
# The two controls the search needs, as eCW's Claim Lookup has them.
|
||||
_LOOKUP = """
|
||||
<div id="filters" style="padding:20px">
|
||||
<input id="claimLookupIpt10" ng-model="_InvId" style="width:200px">
|
||||
<button id="btnclaimlookup">Lookup</button>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# A viewer-like dialog over the whole page, with a patient's name in it.
|
||||
_COVER = """
|
||||
<div class="modal in" id="pnViewer" style="display:block;position:fixed;
|
||||
left:0;top:0;width:100%;height:100%;z-index:1050;background:#fff">
|
||||
PATIENT DOE, JANE</div>
|
||||
"""
|
||||
|
||||
|
||||
# ── the check before the search ──────────────────────────────────────────────
|
||||
|
||||
@case
|
||||
def a_clean_search_page_is_ready(page):
|
||||
page.set_content(_LOOKUP)
|
||||
assert claim_results.search_blocker(page) is None
|
||||
|
||||
|
||||
@case
|
||||
def a_dialog_over_lookup_is_named_without_its_text(page):
|
||||
page.set_content(_LOOKUP + _COVER)
|
||||
why = claim_results.search_blocker(page)
|
||||
assert why and "covered by" in why and "pnViewer" in why, why
|
||||
assert "DOE" not in why and "JANE" not in why, why
|
||||
|
||||
|
||||
@case
|
||||
def a_missing_lookup_button_is_named(page):
|
||||
page.set_content('<input id="claimLookupIpt10">')
|
||||
why = claim_results.search_blocker(page)
|
||||
assert why == "Lookup is not on screen", why
|
||||
|
||||
|
||||
@case
|
||||
def a_disabled_lookup_button_is_named(page):
|
||||
page.set_content(_LOOKUP.replace("<button id", "<button disabled id"))
|
||||
assert claim_results.search_blocker(page) == "Lookup is disabled"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _ready_wait(ms):
|
||||
"""The search-ready wait shortened for a test that has to sit it out."""
|
||||
orig = claim_results._SEARCH_READY_MS
|
||||
claim_results._SEARCH_READY_MS = ms
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
claim_results._SEARCH_READY_MS = orig
|
||||
|
||||
|
||||
@case
|
||||
def search_claim_types_nothing_when_the_page_is_not_ready(page):
|
||||
page.set_content(_LOOKUP + _COVER)
|
||||
with _ready_wait(1_000):
|
||||
try:
|
||||
_quiet(claim_results.search_claim, page, "9103000")
|
||||
except RuntimeError as exc:
|
||||
assert "not ready after 1s" in str(exc), exc
|
||||
assert "covered by" in str(exc), exc
|
||||
else:
|
||||
raise AssertionError("search_claim went ahead over a dialog")
|
||||
assert page.locator(lsel.SEL_CLAIM_NO).input_value() == ""
|
||||
|
||||
|
||||
@case
|
||||
def a_page_taking_no_clicks_is_still_loading_not_covered(page):
|
||||
"""Live run 2026-10-13, claim 9126000: eCW loading, the click fell
|
||||
through to <html> — logged as "covered by html.jelly-page"."""
|
||||
page.set_content(_LOOKUP)
|
||||
page.evaluate("() => { document.body.style.pointerEvents = 'none'; }")
|
||||
why = claim_results.search_blocker(page)
|
||||
assert why == "Claim No is not taking clicks yet (eCW still loading)", why
|
||||
|
||||
|
||||
@case
|
||||
def the_route_spinner_on_screen_means_still_loading(page):
|
||||
page.set_content(_LOOKUP + '<div id="routeLoader" '
|
||||
'style="width:40px;height:40px">...</div>')
|
||||
why = claim_results.search_blocker(page)
|
||||
assert why == "eCW is still loading (its spinner is on screen)", why
|
||||
|
||||
|
||||
@case
|
||||
def a_page_busy_for_a_moment_is_ready_once_it_comes_free(page):
|
||||
"""The claim is searched, not sent to Review, when the page clears
|
||||
within the wait."""
|
||||
page.set_content(_LOOKUP + _COVER)
|
||||
page.evaluate("() => setTimeout(() => "
|
||||
"document.getElementById('pnViewer').remove(), 1200)")
|
||||
why, out = _quiet(claim_results.wait_search_ready, page, "9103000")
|
||||
assert why is None, (why, out)
|
||||
assert "not ready yet" in out and "came free after" in out, out
|
||||
assert "DOE" not in out and "JANE" not in out, out
|
||||
|
||||
|
||||
@case
|
||||
def a_page_that_stays_busy_gives_its_last_reason_after_the_wait(page):
|
||||
page.set_content(_LOOKUP + _COVER)
|
||||
with _ready_wait(1_000):
|
||||
why, _ = _quiet(claim_results.wait_search_ready, page, "9103000")
|
||||
assert why and "covered by" in why and "pnViewer" in why, why
|
||||
|
||||
|
||||
# ── clearing the screen ──────────────────────────────────────────────────────
|
||||
|
||||
@case
|
||||
def a_clean_page_needs_nothing_and_says_nothing(page):
|
||||
page.set_content(_LOOKUP)
|
||||
done, out = _quiet(claim_form.clear_screen, page, "9103000",
|
||||
page.context.pages)
|
||||
assert done == [] and out == "", (done, out)
|
||||
|
||||
|
||||
@case
|
||||
def a_tab_opened_during_a_claim_is_closed_and_the_bot_tabs_kept(page):
|
||||
page.set_content(_LOOKUP)
|
||||
keep = list(page.context.pages)
|
||||
reader = page.context.new_page() # e.g. a note reader tab
|
||||
done, out = _quiet(claim_form.clear_screen, page, "9103000", keep)
|
||||
assert reader.is_closed(), out
|
||||
assert not page.is_closed()
|
||||
assert "an extra browser tab" in done, (done, out)
|
||||
|
||||
|
||||
@case
|
||||
def a_claim_form_left_open_is_cancelled(page):
|
||||
page.goto(HCFA.as_uri())
|
||||
assert claim_form.any_claim_form_open(page)
|
||||
done, out = _quiet(claim_form.clear_screen, page, "9103000",
|
||||
page.context.pages)
|
||||
assert "a claim form" in done, (done, out)
|
||||
assert not claim_form.any_claim_form_open(page), out
|
||||
|
||||
|
||||
@case
|
||||
def a_leftover_form_is_described_in_the_log_before_it_is_cancelled(page):
|
||||
page.goto(HCFA.as_uri())
|
||||
_, out = _quiet(claim_form.clear_screen, page, "9103000",
|
||||
page.context.pages)
|
||||
assert "Open form found:" in out, out
|
||||
assert "a click there hits" in out, out
|
||||
|
||||
|
||||
@case
|
||||
def a_ghost_form_says_how_it_is_hidden_and_what_takes_its_click(page):
|
||||
"""The evidence for a form the bot sees open that a person does not."""
|
||||
page.set_content("""
|
||||
<div class="modal fade claimModalBackdrop" id="ghost"
|
||||
style="display:block;opacity:0;position:fixed;left:0;top:0;
|
||||
width:400px;height:200px">
|
||||
<button ng-click="closeClaimScreen()" style="margin:40px">Cancel
|
||||
</button>PATIENT DOE, JANE</div>
|
||||
<div id="cover" style="position:fixed;left:0;top:0;width:400px;
|
||||
height:200px;z-index:5">grid</div>
|
||||
""")
|
||||
lines = claim_form.describe_shown(page, claim_form.sel.SEL_CANCEL_HCFA)
|
||||
assert len(lines) == 1, lines
|
||||
line = lines[0]
|
||||
assert "opacity 0" in line, line
|
||||
assert "div#ghost" in line, line
|
||||
assert "hits div#cover" in line, line
|
||||
assert "DOE" not in line and "JANE" not in line, line
|
||||
|
||||
|
||||
# ── a claim window is Cancelled, never Escaped ───────────────────────────────
|
||||
# Escape drops the HCFA window without closeClaimScreen() (the fixture's own
|
||||
# keydown handler does what uib does live); `closeClaimScreenRan` tells a
|
||||
# real Cancel apart from that.
|
||||
|
||||
class _Stop(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _clear_with_cancel_failing(page):
|
||||
orig = claim_form._close_leftover_form
|
||||
|
||||
def will_not_close(page, claim_id):
|
||||
raise RuntimeError("would not close")
|
||||
claim_form._close_leftover_form = will_not_close
|
||||
try:
|
||||
return _quiet(claim_form.clear_screen, page, "9103000",
|
||||
page.context.pages)
|
||||
finally:
|
||||
claim_form._close_leftover_form = orig
|
||||
|
||||
|
||||
@case
|
||||
def clear_screen_cancels_a_claim_form_and_does_not_escape_it(page):
|
||||
page.goto(HCFA.as_uri())
|
||||
done, out = _quiet(claim_form.clear_screen, page, "9103000",
|
||||
page.context.pages)
|
||||
assert "a claim form" in done, (done, out)
|
||||
assert page.evaluate("window.closeClaimScreenRan") is True, out
|
||||
assert "LAST RESORT" not in out, out
|
||||
|
||||
|
||||
@case
|
||||
def an_hcfa_form_cancel_will_not_close_is_escaped_as_the_last_resort(page):
|
||||
"""Operator, 2026-10-14: left up, it covered the search page and every
|
||||
later claim of the run went to Review."""
|
||||
page.goto(HCFA.as_uri())
|
||||
done, out = _clear_with_cancel_failing(page)
|
||||
assert "LAST RESORT" in out and "still up (would not close)" in out, out
|
||||
assert "closeClaimScreen() does NOT run" in out, out
|
||||
assert "a claim form" in done, (done, out)
|
||||
assert not claim_form.any_claim_form_open(page), out
|
||||
assert page.evaluate("window.closeClaimScreenRan") is False
|
||||
|
||||
|
||||
@case
|
||||
def a_ub_form_cancel_will_not_close_is_not_escaped(page):
|
||||
page.goto((FIXTURES / "ub_claim.html").as_uri())
|
||||
assert claim_form.any_claim_form_open(page)
|
||||
done, out = _clear_with_cancel_failing(page)
|
||||
assert "LAST RESORT" not in out, out
|
||||
assert "Could not close a claim form" in out, out
|
||||
assert "a claim form" not in done, (done, out)
|
||||
assert claim_form.any_claim_form_open(page), out
|
||||
|
||||
|
||||
@case
|
||||
def open_claim_cancels_a_form_already_up_and_never_escapes_it(page):
|
||||
"""eCW opens the next HCFA claim's form by itself after the Lookup (live
|
||||
run 2026-10-13): open_claim()'s stray-panel check came first and would
|
||||
have Escaped it."""
|
||||
page.goto(HCFA.as_uri())
|
||||
orig = claim_form._close_leftover_form
|
||||
|
||||
def then_stop(page, claim_id):
|
||||
orig(page, claim_id)
|
||||
raise _Stop()
|
||||
claim_form._close_leftover_form = then_stop
|
||||
try:
|
||||
_quiet(claim_form.open_claim, page, "9103000")
|
||||
except _Stop:
|
||||
pass
|
||||
finally:
|
||||
claim_form._close_leftover_form = orig
|
||||
assert page.evaluate("window.closeClaimScreenRan") is True, \
|
||||
"the form was gone before _close_leftover_form() could Cancel it"
|
||||
assert not claim_form.any_claim_form_open(page)
|
||||
|
||||
|
||||
# ── the loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _run(ids, search, events=None):
|
||||
"""
|
||||
`_process_claims` with the page stubbed out. Clears are appended to
|
||||
`events` (the search can append to the same list); returns it and the
|
||||
outcomes.
|
||||
"""
|
||||
events = [] if events is None else events
|
||||
outcomes = {}
|
||||
orig = (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
|
||||
flow.claim_results.search_claim, flow._read_claim_form,
|
||||
flow.claim_filters.clear_claim_no, flow._write_charge_summary,
|
||||
flow._save_results_db, flow.claim_form.clear_screen)
|
||||
cfg.SHOW_EACH_CLAIM = False
|
||||
cfg.SHOW_CLAIM_IDS = False
|
||||
cfg.CLAIM_PROCESS_LIMIT = 0
|
||||
flow.claim_results.search_claim = search
|
||||
flow._read_claim_form = lambda page, cid, head: {
|
||||
"claim_no": cid,
|
||||
"cpt": [{"code": "99213", "checked": True, "fee": "100.00"}],
|
||||
"icd": [{"code": "Z00.00"}],
|
||||
"progress_notes": {"opened": "modal", "text": "Office visit."},
|
||||
"_conditions": {"fired": [], "saved": False, "failed": [],
|
||||
"review": [], "comments": {}}}
|
||||
flow.claim_filters.clear_claim_no = lambda page: None
|
||||
flow._write_charge_summary = lambda info, o: outcomes.update(o)
|
||||
flow._save_results_db = lambda info, o, d: None
|
||||
flow.claim_form.clear_screen = (
|
||||
lambda page, cid, keep=(): events.append(("clear", cid)) or [])
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
flow._process_claims(None, {"ids": list(ids), "file": None})
|
||||
finally:
|
||||
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
|
||||
flow.claim_results.search_claim, flow._read_claim_form,
|
||||
flow.claim_filters.clear_claim_no, flow._write_charge_summary,
|
||||
flow._save_results_db, flow.claim_form.clear_screen) = orig
|
||||
return events, outcomes
|
||||
|
||||
|
||||
@case
|
||||
def every_claim_is_cleared_before_its_search_and_the_run_ends_clear():
|
||||
events = []
|
||||
|
||||
def search(page, cid):
|
||||
events.append(("search", cid))
|
||||
return {"id": cid, "count": 1, "found": True, "seconds": 1.0}
|
||||
_run(["1", "2"], search, events)
|
||||
assert events == [("clear", "1"), ("search", "1"),
|
||||
("clear", "2"), ("search", "2"),
|
||||
("clear", "2")], events
|
||||
|
||||
|
||||
@case
|
||||
def a_page_that_will_not_clear_costs_one_claim_not_the_rest():
|
||||
searched = []
|
||||
|
||||
def search(page, cid):
|
||||
if cid == "1":
|
||||
raise RuntimeError("The search page is not ready: Lookup is "
|
||||
"covered by div#pnViewer.modal")
|
||||
searched.append(cid)
|
||||
return {"id": cid, "count": 1, "found": True, "seconds": 1.0}
|
||||
_, outcomes = _run(["1", "2", "3"], search)
|
||||
assert searched == ["2", "3"], searched
|
||||
assert outcomes["1"]["Category"] == "Review", outcomes["1"]
|
||||
assert "not ready" in outcomes["1"]["Comments"], outcomes["1"]
|
||||
assert outcomes["2"]["Category"] == "Pass", outcomes["2"]
|
||||
assert outcomes["3"]["Category"] == "Pass", outcomes["3"]
|
||||
|
||||
|
||||
def _run_cases() -> int:
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
failures = 0
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True)
|
||||
# A context of its own, so a test can open a second tab in it.
|
||||
page = browser.new_context(
|
||||
viewport={"width": 1600, "height": 1000}).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:
|
||||
"""
|
||||
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
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_cfg.SCREENSHOTS = pathlib.Path(tmp)
|
||||
try:
|
||||
return _run_cases()
|
||||
finally:
|
||||
_cfg.SCREENSHOTS = orig
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user