// @vitest-environment happy-dom // ClaimDrawer wires `useClaimDetail` (TanStack Query) + a window-level // keyboard listener. Both paths need an act-aware environment or React // logs noisy warnings. Mirror the convention from ClaimDrawer.test.tsx. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { Claims } from "./Claims"; import { DrillStackProvider } from "@/components/drill/DrillStackProvider"; import { api } from "@/lib/api"; import { useTailStore } from "@/store/tail-store"; import type { Claim, ClaimDetail } from "@/types"; // Module-level mock — vitest hoists vi.mock above imports. We mock the // two API methods the test path actually touches: // // - `api.listClaims` is called by `useClaims` (via TanStack Query) so // the table has data to render rows. // - `api.getClaimDetail` is called by `useClaimDetail` from inside the // ClaimDrawer once the URL has `?claim=…`. // // Same shape as ClaimDrawer.test.tsx; we just add `listClaims` to the // mock object so the page-under-test populates its table. vi.mock("@/lib/api", () => { class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } return { api: { isConfigured: true, listClaims: vi.fn(), getClaimDetail: vi.fn(), }, ApiError, }; }); // --------------------------------------------------------------------------- // Live-tail hook mock (sub-project 5, Phase 5 Task 22 page-level tests). // // We mock `useTailStream` directly (per the plan's "latter is simpler" // guidance) so the page-level tests don't have to drive the real // `streamTail` parser or the real AbortController-based reconnect loop. // `useTailStream` is exercised exhaustively in its own test file // (`useTailStream.test.ts`); the page only needs to observe that the // returned status flows through to `` and that items // pushed into the tail store surface as rows via `useMergedTail`. // // The mock returns `status: "live"` by default — that matches what the // real hook settles to after a `snapshot_end` event arrives, which is // the state the page sees in production ~all of the time. // --------------------------------------------------------------------------- vi.mock("@/hooks/useTailStream", () => ({ useTailStream: vi.fn(() => ({ status: "live", lastEventAt: null, error: null, forceReconnect: vi.fn(), })), })); const SAMPLE_CLAIMS: Claim[] = [ { id: "CLM-1", patientName: "Jane Doe", providerNpi: "1234567890", payerName: "Colorado Medicaid", cptCode: "99213", billedAmount: 123.45, receivedAmount: 0, status: "submitted", submissionDate: "2026-06-15", }, { id: "CLM-2", patientName: "John Smith", providerNpi: "1234567890", payerName: "Aetna", cptCode: "99214", billedAmount: 200, receivedAmount: 200, status: "paid", submissionDate: "2026-06-16", }, ]; const SAMPLE_DETAIL: ClaimDetail = { id: "CLM-1", batchId: "B-1", state: "submitted", stateLabel: "Submitted", billedAmount: 123.45, patientName: "Jane Doe", providerNpi: "1234567890", providerName: "Acme Clinic", payerName: "Colorado Medicaid", payerId: "MEDCO", submissionDate: "2026-06-15T00:00:00Z", serviceDateFrom: null, serviceDateTo: null, parsedAt: "2026-06-15T00:00:01Z", diagnoses: [], serviceLines: [], parties: { billingProvider: { name: "Acme Clinic", npi: "1234567890", taxId: "12-3456789", address: { line1: "123 Main St", line2: null, city: "Denver", state: "CO", zip: "80202", }, }, subscriber: { firstName: "Jane", lastName: "Doe", memberId: "M-1", dob: null, gender: "F", }, payer: { name: "Colorado Medicaid", id: "MEDCO" }, }, validation: { passed: true, errors: [], warnings: [] }, rawSegments: [], matchedRemittance: null, stateHistory: [], }; /** * Point happy-dom at a known URL. happy-dom exposes `setURL` on * `window.happyDOM` (a non-standard helper for tests) — copying the * pattern from `useDrawerUrlState.test.ts` so the two files stay * consistent. */ function setLocation(url: string): void { (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url); } /** * Render harness. Mirrors the pattern from Acks/Remittances test files: * a real DOM container wrapped in `QueryClientProvider` so the page's * TanStack Query calls (via `useClaims`) resolve against our mocked * `api.listClaims`. * * Also wraps in a `MemoryRouter` so the page's `useSearchParams` * (added when Claims started reading `?status=` / `?sort=` off the URL * for Dashboard KPI drill-through) has a router context to read from. * The default `initialEntries` mirrors whatever `setLocation(...)` set * on `window.location`, so the existing tests' pre-set URLs keep * working without per-test changes. */ function renderClaims(): { unmount: () => void } { const initialEntries = [ window.location.pathname + window.location.search, ]; return renderClaimsAt(initialEntries); } /** * Variant of `renderClaims` that lets a test pin a specific initial * URL. Used by the URL-param filter tests below so each one can start * at exactly the URL it's asserting on. */ function renderClaimsAt(initialEntries: string[]): { unmount: () => void; container: HTMLDivElement; } { const container = document.createElement("div"); document.body.appendChild(container); const qc = new QueryClient({ defaultOptions: { queries: { retry: false, // Default retry delay is exponential backoff — way too slow for // unit tests. Zero keeps the happy path fast. retryDelay: 0, }, }, }); const root: Root = createRoot(container); act(() => { root.render( React.createElement( QueryClientProvider, { client: qc }, React.createElement( MemoryRouter, { initialEntries }, // SP21 Phase 5 Task 5.8/5.9: the ClaimDrawer mounted by // Claims now uses useDrillStack() in PartiesGrid + // ValidationPanel. Wrap in a DrillStackProvider so the // hook has a context (the provider is also mounted at // the App root in production). React.createElement( DrillStackProvider, null, React.createElement(Claims), ), ), ), ); }); return { container, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } /** * Flush microtasks + react state updates until `predicate` holds (or we * time out). react-query's fetch resolves asynchronously, then Radix * portals the drawer into document.body — both need a tick before the * DOM reflects the new state. */ async function settle( predicate: () => boolean, timeoutMs = 2000 ): Promise { const start = Date.now(); while (!predicate()) { if (Date.now() - start > timeoutMs) { throw new Error("settle: predicate did not hold within timeout"); } await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); } } describe("Claims page drawer wiring", () => { beforeEach(() => { vi.clearAllMocks(); // Reset URL to bare claims page between tests. setLocation("http://localhost/claims"); (api.listClaims as unknown as ReturnType).mockResolvedValue({ items: SAMPLE_CLAIMS, total: SAMPLE_CLAIMS.length, returned: SAMPLE_CLAIMS.length, has_more: false, }); (api.getClaimDetail as unknown as ReturnType).mockResolvedValue( SAMPLE_DETAIL ); // Live-tail slice is a singleton zustand store — clear it between // tests so the live-arrival test doesn't see rows from a previous // case. useTailStore.getState().reset("claims"); }); afterEach(() => { // Defensive: leave the URL clean for the next test file. setLocation("http://localhost/claims"); }); it("test_clicking_a_row_opens_the_drawer", async () => { const { unmount } = renderClaims(); // Wait for the table to populate from the mocked api.listClaims. await settle( () => document.body.textContent?.includes("CLM-1") ?? false ); // Drawer must not be open yet — no `?claim=` in URL means // ClaimDrawer renders null (per its early-return when claimId is // null). expect(document.body.querySelector('[data-testid="claim-drawer"]')).toBeNull(); // Click the first row (the one showing CLM-1). The page wires // `onClick={() => open(c.id)}` to each row, so this should // (a) push `?claim=CLM-1` into the URL, and // (b) cause the ClaimDrawer to mount in document.body. // // Note: the id cell renders "CLM-1" plus a sibling "CPT 99213" line, // so the row's textContent is "CLM-1CPT 99213…". Match by substring // instead of exact equality. const row = Array.from(document.querySelectorAll("tbody tr")).find( (r) => r.textContent?.includes("CLM-1") ) as HTMLTableRowElement | undefined; expect(row).toBeDefined(); await act(async () => { row!.click(); }); // URL side effect — happy-dom's pushState updates window.location, // so this assertion is end-to-end against the real hook. expect(window.location.href).toContain("?claim=CLM-1"); // Drawer mounts in a Radix portal under document.body, so we poll // for it. await settle( () => document.body.querySelector('[data-testid="claim-drawer"]') !== null ); expect( document.body.querySelector('[data-testid="claim-drawer"]') ).not.toBeNull(); unmount(); }); it("test_deep_link_with_?claim=opens_drawer_on_mount", async () => { // Pre-set the URL so the hook reads CLM-1 off `window.location.search` // during its `useState` initializer — no click needed. setLocation("http://localhost/claims?claim=CLM-1"); renderClaims(); // Drawer should appear immediately, without user interaction. await settle( () => document.body.querySelector('[data-testid="claim-drawer"]') !== null ); expect( document.body.querySelector('[data-testid="claim-drawer"]') ).not.toBeNull(); // SP21 Phase 5 Task 5.10: the claim id is now the title of the // shared DrillDrawerHeader (rendered as an

). Find the h2 // inside the drawer and assert its text matches the claim id — // proves the id propagated end-to-end (URL → useDrawerUrlState // → ClaimDrawer prop → useClaimDetail → header render), not // just that some drawer is mounted. await settle(() => { const drawer = document.body.querySelector('[data-testid="claim-drawer"]'); const h2 = drawer?.querySelector("h2"); return h2?.textContent === "CLM-1"; }); const drawer = document.body.querySelector('[data-testid="claim-drawer"]'); expect(drawer?.querySelector("h2")?.textContent).toBe("CLM-1"); }); it("test_escape_closes_the_drawer", async () => { setLocation("http://localhost/claims?claim=CLM-1"); renderClaims(); await settle( () => document.body.querySelector('[data-testid="claim-drawer"]') !== null ); // Press Escape on `window` — useDrawerKeyboard (called from inside // ClaimDrawer) listens there. Page wires onClose → useDrawerUrlState's // close() → pushState + setClaimIdState(null). await act(async () => { window.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true }) ); }); // Drawer should unmount (ClaimDrawer returns null when claimId is null). await settle( () => document.body.querySelector('[data-testid="claim-drawer"]') === null ); expect( document.body.querySelector('[data-testid="claim-drawer"]') ).toBeNull(); // URL must no longer carry ?claim=. expect(window.location.href).not.toContain("?claim="); }); it("test_url_clears_after_close", async () => { setLocation("http://localhost/claims?claim=CLM-1"); renderClaims(); // Wait for the header to render — the close button only mounts once // `useClaimDetail` resolves with a real ClaimDetail (the skeleton / // error states don't render the header). SP21 Phase 5 Task 5.10: // the close button is now inside the shared DrillDrawerHeader; // find it via its accessible name. await settle( () => document.body.querySelector( '[data-testid="claim-drawer"] button[aria-label="Close drawer"]' ) !== null ); // URL currently carries the claim. expect(window.location.href).toContain("?claim=CLM-1"); // Click the X close button in the header. The page wires this to // onClose → useDrawerUrlState.close(), which pushState's a URL with // the ?claim= param stripped. const closeBtn = document.body.querySelector( '[data-testid="claim-drawer"] button[aria-label="Close drawer"]' ) as HTMLButtonElement | null; expect(closeBtn).not.toBeNull(); await act(async () => { closeBtn!.click(); }); // URL must no longer carry ?claim=. await settle(() => !window.location.href.includes("?claim=")); expect(window.location.href).not.toContain("?claim="); }); it("test_question_mark_toggles_cheatsheet", async () => { // The cheatsheet keystroke is wired through `useDrawerKeyboard` // (which lives inside ClaimDrawer and only enables when // `claimId !== null`). Deep-link with `?claim=CLM-1` so the // drawer mounts and the keyboard listener is active. setLocation("http://localhost/claims?claim=CLM-1"); const { unmount } = renderClaims(); // Drawer must be open before we test the toggle. await settle( () => document.body.querySelector('[data-testid="claim-drawer"]') !== null ); expect( document.body.querySelector('[data-testid="claim-drawer"]') ).not.toBeNull(); // Cheatsheet starts closed. expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); // First `?` press: useDrawerKeyboard's handler flips `helpOpen` // true on the page; the cheatsheet portals into document.body. await act(async () => { window.dispatchEvent( new KeyboardEvent("keydown", { key: "?", bubbles: true }) ); }); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !== null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).not.toBeNull(); // Second `?` press: same handler flips `helpOpen` back to false; // the cheatsheet unmounts. (The cheatsheet's own window-level // listener explicitly returns early on `?` — it never pre-empts // the parent's toggle, by design.) await act(async () => { window.dispatchEvent( new KeyboardEvent("keydown", { key: "?", bubbles: true }) ); }); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') === null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); // Unmount so the next test (which asserts the drawer is absent) // doesn't see this test's still-open drawer leaking into // document.body. unmount(); }); it("test_question_mark_does_nothing_when_drawer_closed", async () => { // The `?` keystroke is owned by `useDrawerKeyboard`, which only // attaches its `window` listener when `claimId !== null`. With no // `?claim=` in the URL, the drawer is closed and `?` is a no-op // — the cheatsheet must not appear. setLocation("http://localhost/claims"); renderClaims(); // Wait for the table to populate (so the page is fully mounted). await settle( () => document.body.textContent?.includes("CLM-1") ?? false ); expect( document.body.querySelector('[data-testid="claim-drawer"]') ).toBeNull(); await act(async () => { window.dispatchEvent( new KeyboardEvent("keydown", { key: "?", bubbles: true }) ); }); // No cheatsheet should be present — and not just "not yet": // flush a tick to make sure a delayed listener couldn't have // snuck it in. await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); }); // ------------------------------------------------------------------- // Live-tail integration (sub-project 5, Phase 5 Task 22 page tests). // // We mock `useTailStream` at the module level (see the top of this // file) so the page sees a settled `status: "live"` without driving // the real streamTail parser. To exercise the "tail arrival triggers a // row" path we push a new claim directly into `useTailStore` — this // is exactly what the real `useTailStream` hook does on `item` events, // so we cover the same render path (zustand notify → useMergedTail // re-derive → `` mount). // ------------------------------------------------------------------- it("test_live_tail_arrival_triggers_row_in_table", async () => { const { unmount } = renderClaims(); // Base list (SAMPLE_CLAIMS, set in beforeEach) must be visible first. await settle( () => document.body.textContent?.includes("CLM-1") ?? false, ); // The new claim id is brand new (not in base, not in tail store yet). const TAIL_ID = "CLM-TAIL-1"; expect( document.body.textContent?.includes(TAIL_ID) ?? false, ).toBe(false); // Simulate a live-arriving claim — this is what `useTailStream` // does when it dispatches an `item` event. Wrap in `act` because // zustand subscribers re-render synchronously. await act(async () => { useTailStore.getState().addClaim({ id: TAIL_ID, patientName: "Live Arrival", providerNpi: "1234567890", payerName: "Colorado Medicaid", cptCode: "99215", billedAmount: 999, receivedAmount: 0, status: "submitted", submissionDate: "2026-06-20", }); }); // The new row must appear — proves the end-to-end path // zustand.addClaim → useTailStore notify → useMergedTail re-derive // → mounts the row with the new id. await settle( () => document.body.textContent?.includes(TAIL_ID) ?? false, ); expect(document.body.textContent).toContain(TAIL_ID); // The row's secondary cells should also be present so we know the // row fully rendered, not just that the id appeared somewhere // (e.g. in the search-input placeholder or similar). expect(document.body.textContent).toContain("Live Arrival"); expect(document.body.textContent).toContain("99215"); unmount(); }); it("test_status_pill_shows_live_after_stream_connects", async () => { const { unmount } = renderClaims(); // Wait for the base table to mount — once it's there, the toolbar // (which contains the pill) has also mounted. await settle( () => document.body.textContent?.includes("CLM-1") ?? false, ); // The mocked `useTailStream` returns `status: "live"`, so the // should render with the human label "Live" // (per `STATUS_LABEL` in `TailStatusPill.tsx`). We assert on the // pill's textContent so this stays robust against future styling // changes (the test doesn't depend on Tailwind classes). const pill = document.body.querySelector( '[data-testid="tail-status-pill"]', ); expect(pill).not.toBeNull(); expect(pill?.textContent).toContain("Live"); unmount(); }); // ------------------------------------------------------------------- // URL-driven filter state (sub-project 6, Phase 1 Task 1.8 follow-up). // // Dashboard KPI tiles drill through to Claims with `?status=` and // `?sort=` query params. These tests pin each URL and assert that // the page actually filters (not just lands on a default view). // // The strongest assertion is on `api.listClaims.mock.calls` — that's // the exact params the page forwards to the API, so a passing test // proves end-to-end that URL → useSearchParams → useClaims → fetch. // ------------------------------------------------------------------- it("test_url_param_status_denied_filters_and_selects_chip", async () => { const { unmount, container } = renderClaimsAt(["/claims?status=denied"]); // The first render of FilterChips is reached before useSearchParams // has the URL-derived `status` value committed (MemoryRouter // initializes synchronously but the chip's aria-checked only flips // to "true" on the next React commit). Wait for that commit so // the assertion below isn't a race against the first render. // We settle on `useClaims` having been called with status=denied // — that's the strongest invariant: it proves the URL → state → // API-call chain has fully propagated end-to-end. await settle( () => { const calls = (api.listClaims as ReturnType).mock.calls; const lastCallParams = calls[calls.length - 1]?.[0] as | { status?: string; sort?: string; order?: string } | undefined; return lastCallParams?.status === "denied"; }, ); // Scope queries to this test's container so we can't accidentally // pick up a chip from a leaked render of a previous test. const deniedChip = Array.from( container.querySelectorAll('[role="radio"]'), ).find( (el) => el.textContent?.trim().toLowerCase() === "denied", ) as HTMLButtonElement | undefined; expect(deniedChip).toBeDefined(); expect(deniedChip?.getAttribute("aria-checked")).toBe("true"); // No other status chip should be checked. const checkedChips = Array.from( container.querySelectorAll('[role="radio"][aria-checked="true"]'), ); expect(checkedChips).toHaveLength(1); expect(checkedChips[0]?.textContent?.trim().toLowerCase()).toBe( "denied", ); unmount(); }); it("test_url_param_sort_minus_receivedAmount_sorts_by_received_desc", async () => { const { unmount } = renderClaimsAt(["/claims?sort=-receivedAmount"]); // Wait for useClaims to be called once. await settle( () => (api.listClaims as ReturnType).mock.calls.length > 0, ); const calls = (api.listClaims as ReturnType).mock.calls; const lastCallParams = calls[calls.length - 1]?.[0] as | { status?: string; sort?: string; order?: string } | undefined; // The `-` prefix must be stripped off and order set to desc. expect(lastCallParams?.sort).toBe("receivedAmount"); expect(lastCallParams?.order).toBe("desc"); // No status filter — user only asked for a sort change. expect(lastCallParams?.status).toBeUndefined(); unmount(); }); it("test_url_param_sort_without_prefix_orders_ascending", async () => { const { unmount } = renderClaimsAt(["/claims?sort=submittedDate"]); await settle( () => (api.listClaims as ReturnType).mock.calls.length > 0, ); const calls = (api.listClaims as ReturnType).mock.calls; const lastCallParams = calls[calls.length - 1]?.[0] as | { status?: string; sort?: string; order?: string } | undefined; // No `-` prefix → order is asc. expect(lastCallParams?.sort).toBe("submittedDate"); expect(lastCallParams?.order).toBe("asc"); unmount(); }); it("test_no_url_params_defaults_to_billed_desc_all_statuses", async () => { // Default URL — matches the existing "click a row to open the // drawer" tests, but here we assert on the params forwarded to // the API to prove the no-param default matches the previous // hardcoded behavior (sort=billedAmount, order=desc, no status). const { unmount } = renderClaimsAt(["/claims"]); await settle( () => (api.listClaims as ReturnType).mock.calls.length > 0, ); const calls = (api.listClaims as ReturnType).mock.calls; const lastCallParams = calls[calls.length - 1]?.[0] as | { status?: string; sort?: string; order?: string } | undefined; expect(lastCallParams?.status).toBeUndefined(); expect(lastCallParams?.sort).toBe("billedAmount"); expect(lastCallParams?.order).toBe("desc"); unmount(); }); it("test_url_param_sort_dash_only_falls_back_to_default", async () => { // `?sort=-` is the prefix-with-no-field degenerate case. A naive // `slice(1)` would yield "" and the API would get `sort=""`. // We assert the page falls back to the default sort instead. const { unmount } = renderClaimsAt(["/claims?sort=-"]); await settle( () => (api.listClaims as ReturnType).mock.calls.length > 0, ); const calls = (api.listClaims as ReturnType).mock.calls; const lastCallParams = calls[calls.length - 1]?.[0] as | { status?: string; sort?: string; order?: string } | undefined; expect(lastCallParams?.sort).toBe("billedAmount"); expect(lastCallParams?.order).toBe("desc"); unmount(); }); // ------------------------------------------------------------------- // Regression for SP21 Task 2.4 — DrillableCell click bubbling. // // The provider cell renders a