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,311 @@
|
||||
r"""
|
||||
Progress Notes: polled, not slept on (review item D8).
|
||||
|
||||
python tests\test_progress_note_wait.py
|
||||
|
||||
The old `open_progress_notes` clicked, slept a fixed 3 s and probed once. The
|
||||
logs show 3 misses in ~115 note reads, and in every one the viewer had opened
|
||||
on a document that had not loaded yet — so the claim was reported Clean with
|
||||
telehealth never checked. Now the note is polled every 250 ms for up to
|
||||
`PROGRESS_NOTE_WAIT_MS`, accepted once its `[prisma-section]` count holds
|
||||
still, with one retry of the click if nothing opened at all.
|
||||
|
||||
Local headless chromium against `tests\fixtures\progress_note_delayed.html`
|
||||
(the note arrives late, never, only on a second click, or never settles).
|
||||
`PROGRESS_NOTE_WAIT_MS` is shrunk per case so the suite stays quick. No eCW;
|
||||
screenshots go to a temp folder.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
import pathlib
|
||||
import inspect
|
||||
import tempfile
|
||||
import contextlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
HOST = pathlib.Path(__file__).resolve().parent / "fixtures" / \
|
||||
"progress_note_delayed.html"
|
||||
|
||||
from ecw_bot import settings as cfg # noqa: E402
|
||||
from ecw_bot.pages import claim_form, progress_note # noqa: E402
|
||||
from ecw_bot.selectors import claim_form as sel # noqa: E402
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
def _open(page, query: str, wait_ms: int):
|
||||
"""open_progress_notes against the fixture: (note, seconds, log)."""
|
||||
page.goto(HOST.as_uri() + "?" + query)
|
||||
orig = cfg.PROGRESS_NOTE_WAIT_MS
|
||||
cfg.PROGRESS_NOTE_WAIT_MS = wait_ms
|
||||
buf = io.StringIO()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf):
|
||||
note = claim_form.open_progress_notes(page)
|
||||
finally:
|
||||
cfg.PROGRESS_NOTE_WAIT_MS = orig
|
||||
return note, time.monotonic() - started, buf.getvalue()
|
||||
|
||||
|
||||
@case
|
||||
def a_note_that_loads_after_the_old_3s_sleep_is_read(page):
|
||||
"""3.5 s: the old sleep-3-then-probe-once returned "nothing" here."""
|
||||
note, took, log = _open(page, "mode=slow&delay=3500", 10_000)
|
||||
assert note.get("opened") == "frame", (note, log)
|
||||
codes = [p["code"] for p in note.get("procedure_codes") or []]
|
||||
assert "H0023" in codes, codes
|
||||
assert took >= 3.5, f"read after {took:.1f}s — before the note existed"
|
||||
assert "Note loaded in the frame" in log, log
|
||||
assert "partial" not in note, "a note that settled was marked partial"
|
||||
|
||||
|
||||
@case
|
||||
def a_note_that_loads_quickly_is_accepted_in_about_a_second(page):
|
||||
"""The normal claim no longer pays a fixed 3 s."""
|
||||
note, took, _ = _open(page, "mode=slow&delay=200", 10_000)
|
||||
assert note.get("opened") == "frame", note
|
||||
assert took < 2.5, f"took {took:.1f}s on a note that loaded in 0.2s"
|
||||
|
||||
|
||||
@case
|
||||
def a_note_that_never_appears_is_nothing_within_the_configured_wait(page):
|
||||
note, took, log = _open(page, "mode=never", 2_000)
|
||||
assert note == {"opened": "nothing"}, note
|
||||
assert 1.8 <= took < 5.0, f"took {took:.1f}s for a 2s wait"
|
||||
# A viewer WAS open (slow-document case), so no second click was made.
|
||||
assert page.evaluate("window.progNoteClicks") == 1, log
|
||||
# And the viewer was closed on the way out.
|
||||
assert page.locator(sel.SEL_PN_CLOSE).count() == 0, "viewer left open"
|
||||
|
||||
|
||||
@case
|
||||
def a_click_that_did_not_take_is_retried_once(page):
|
||||
note, _, log = _open(page, "mode=reclick", 3_000)
|
||||
assert note.get("opened") == "frame", (note, log)
|
||||
assert page.evaluate("window.progNoteClicks") == 2
|
||||
assert "clicking Prog. Notes once more" in log, log
|
||||
|
||||
|
||||
@case
|
||||
def a_note_that_never_settles_is_read_anyway_and_logged_as_partial(page):
|
||||
note, took, log = _open(page, "mode=growing", 2_000)
|
||||
assert note.get("opened") == "frame", (note, log)
|
||||
assert note.get("sections"), note
|
||||
assert "may be partial" in log, log
|
||||
assert took < 5.0, f"took {took:.1f}s"
|
||||
# On the note, not only in the log, so the summary can act on it.
|
||||
assert note.get("partial") is True, note
|
||||
assert note.get("partial_sections", 0) >= 1, note
|
||||
|
||||
|
||||
@case
|
||||
def a_note_header_with_no_sections_is_read_but_marked_partial(page):
|
||||
"""
|
||||
The note's header arrives and not one section under it: read at the
|
||||
deadline, it has no Procedure Codes, so telehealth is never seen — the
|
||||
D8 false Clean. The log line alone could not stop that; the mark on the
|
||||
note is what the summary can turn into a Review row.
|
||||
"""
|
||||
note, _, log = _open(page, "mode=empty", 1_500)
|
||||
assert note.get("opened") == "frame", (note, log)
|
||||
assert note.get("partial") is True, note
|
||||
assert note.get("partial_sections") == 0, note
|
||||
assert not note.get("procedure_codes"), note
|
||||
|
||||
|
||||
@case
|
||||
def section_count_never_raises():
|
||||
class Broken:
|
||||
def evaluate(self, *a, **k):
|
||||
raise RuntimeError("frame detached")
|
||||
assert progress_note.section_count(Broken()) == 0
|
||||
|
||||
|
||||
@case
|
||||
def polling_find_does_not_repeat_the_60s_tab_load_wait():
|
||||
"""
|
||||
find() waits on each new tab's domcontentloaded; at its 60s default, a
|
||||
poll every 250 ms could sit a minute per poll. The poll passes 1s.
|
||||
"""
|
||||
waits = []
|
||||
tab = types.SimpleNamespace(
|
||||
wait_for_load_state=lambda state, timeout: waits.append(timeout),
|
||||
evaluate=lambda *a: False)
|
||||
fake = types.SimpleNamespace(
|
||||
context=types.SimpleNamespace(pages=["claim page", tab]),
|
||||
frames=[], main_frame=None)
|
||||
assert progress_note.find(fake, tabs_before=1, load_timeout=1_000) == \
|
||||
(None, "nothing")
|
||||
assert waits == [1_000], waits
|
||||
|
||||
calls = []
|
||||
orig_find, orig_wait = progress_note.find, cfg.PROGRESS_NOTE_WAIT_MS
|
||||
progress_note.find = lambda page, **kw: (calls.append(kw)
|
||||
or (None, "nothing"))
|
||||
cfg.PROGRESS_NOTE_WAIT_MS = 0
|
||||
fake_page = types.SimpleNamespace(
|
||||
context=types.SimpleNamespace(pages=[]),
|
||||
wait_for_timeout=lambda ms: None)
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
claim_form._wait_for_note(fake_page, 0)
|
||||
finally:
|
||||
progress_note.find, cfg.PROGRESS_NOTE_WAIT_MS = orig_find, orig_wait
|
||||
assert calls and all(kw.get("load_timeout") == 1_000 for kw in calls), \
|
||||
calls
|
||||
|
||||
|
||||
@case
|
||||
def a_note_whose_document_stalls_mid_stream_is_not_taken_early(page):
|
||||
"""
|
||||
Final review, 2026-10-05: the note is a whole HTML document, so a slow
|
||||
server delivers it in pieces. Here two sections are parsed, then the
|
||||
stream stalls for 1.5 s with readyState still "loading" — the same
|
||||
count on every poll. A count-only check took the note at ~0.25 s,
|
||||
without its Procedure Codes, unmarked: the D8 false Clean again.
|
||||
"""
|
||||
note, took, log = _open(page, "mode=stalled&delay=1500", 6_000)
|
||||
assert note.get("opened") == "frame", (note, log)
|
||||
codes = [p["code"] for p in note.get("procedure_codes") or []]
|
||||
assert codes == ["H0023"], (codes, log)
|
||||
assert "partial" not in note, note
|
||||
assert took >= 1.5, f"read after {took:.1f}s — mid-stream"
|
||||
|
||||
|
||||
@case
|
||||
def a_stall_that_never_ends_is_read_at_the_deadline_as_partial(page):
|
||||
"""Still "loading" at the deadline: read anyway, and marked partial."""
|
||||
note, _, log = _open(page, "mode=stalled&delay=60000", 1_500)
|
||||
assert note.get("opened") == "frame", (note, log)
|
||||
assert note.get("partial") is True, note
|
||||
assert note.get("partial_sections") == 2, note
|
||||
|
||||
|
||||
@case
|
||||
def an_earlier_claims_note_left_in_a_reused_viewer_is_not_read(page):
|
||||
"""
|
||||
Final review, 2026-10-05: Close may only hide the viewer, and a reused
|
||||
iframe still shows the previous claim's note until the new one commits.
|
||||
Without the pre-click mark, A1111 (the earlier claim) was read as this
|
||||
claim's note on the first two polls.
|
||||
"""
|
||||
note, took, log = _open(page, "mode=reused&delay=1500", 6_000)
|
||||
assert note.get("opened") == "frame", (note, log)
|
||||
codes = [p["code"] for p in note.get("procedure_codes") or []]
|
||||
assert codes == ["H0023"], (codes, log)
|
||||
assert took >= 1.5, f"read after {took:.1f}s — before this claim's note"
|
||||
assert "earlier note document(s) still on the page" in log, log
|
||||
|
||||
|
||||
@case
|
||||
def a_note_in_a_slow_new_tab_gets_no_second_click_and_no_close_wait(page):
|
||||
"""
|
||||
The tab opens at once but its note arrives past the halfway point.
|
||||
A new tab is the note on its way, so Prog. Notes is NOT clicked again
|
||||
(that would open a second tab). And with no in-page viewer, closing
|
||||
is an instant check, not a 5 s wait for a Close button that is not
|
||||
there (final review, 2026-10-05).
|
||||
"""
|
||||
tabs = len(page.context.pages)
|
||||
note, took, log = _open(page, "mode=tab&delay=2000", 3_000)
|
||||
assert note.get("opened") == "tab", (note, log)
|
||||
codes = [p["code"] for p in note.get("procedure_codes") or []]
|
||||
assert "H0023" in codes, codes
|
||||
assert page.evaluate("window.progNoteClicks") == 1, log
|
||||
assert "clicking Prog. Notes once more" not in log, log
|
||||
assert took < 4.5, f"took {took:.1f}s — the 5s close wait is back"
|
||||
assert len(page.context.pages) == tabs, "the note's tab was left open"
|
||||
|
||||
|
||||
def _settings_default(key: str):
|
||||
"""
|
||||
`key`'s code default: settings.py run afresh with no .env beside it (the
|
||||
frozen-exe branch points ROOT at an empty temp folder — the same probe
|
||||
tests/test_settings.py uses) and the key unset in the environment.
|
||||
"""
|
||||
import os
|
||||
import importlib.util
|
||||
saved = os.environ.pop(key, None)
|
||||
had_frozen = hasattr(sys, "frozen")
|
||||
saved_frozen, saved_exe = getattr(sys, "frozen", None), sys.executable
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
try:
|
||||
sys.frozen = True
|
||||
sys.executable = str(pathlib.Path(tmp) / "bot.exe")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ecw_bot._settings_probe", cfg.__file__)
|
||||
probe = importlib.util.module_from_spec(spec)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
spec.loader.exec_module(probe)
|
||||
assert probe.ROOT != cfg.ROOT, "the probe used the real .env"
|
||||
return getattr(probe, key)
|
||||
finally:
|
||||
sys.executable = saved_exe
|
||||
if had_frozen:
|
||||
sys.frozen = saved_frozen
|
||||
else:
|
||||
del sys.frozen
|
||||
if saved is not None:
|
||||
os.environ[key] = saved
|
||||
|
||||
|
||||
@case
|
||||
def the_wait_is_a_setting_with_a_20s_default():
|
||||
# The code default, from a probe with no .env — not settings.py's source
|
||||
# text (a reformat broke that) and not the operator's .env.
|
||||
assert _settings_default("PROGRESS_NOTE_WAIT_MS") == 20_000
|
||||
assert isinstance(cfg.PROGRESS_NOTE_WAIT_MS, int)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not HOST.exists():
|
||||
print(f" FAIL fixture missing: {HOST}")
|
||||
return 1
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print(" SKIP playwright is not installed")
|
||||
return 0
|
||||
|
||||
failures = 0
|
||||
orig_shots = cfg.SCREENSHOTS
|
||||
with tempfile.TemporaryDirectory() as tmp, sync_playwright() as pw:
|
||||
cfg.SCREENSHOTS = pathlib.Path(tmp)
|
||||
browser = pw.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
try:
|
||||
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
|
||||
finally:
|
||||
cfg.SCREENSHOTS = orig_shots
|
||||
browser.close()
|
||||
|
||||
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user