// @vitest-environment happy-dom // Tell React this is an `act`-aware test environment so react-query's // internal state updates flush through without noisy console warnings. // Mirrors the convention used by Batches.test.tsx and Acks.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 { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi, beforeEach, afterEach, } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BatchDiff } from "./BatchDiff"; import { api, ApiError } from "@/lib/api"; import type { BatchDiff as BatchDiffResponse } from "@/types"; // Mock the api module. Only the surface the page actually touches is // stubbed; everything else is left undefined so an accidental call // blows up loudly. vi.mock("@/lib/api", () => ({ api: { isConfigured: true, listBatches: vi.fn(), getBatchDiff: vi.fn(), getRemittance: vi.fn(), }, ApiError: class ApiError extends Error { constructor(public status: number, message: string) { super(message); } }, })); /** * Mount the page into a container wrapped in MemoryRouter + * QueryClientProvider. Uses a 10ms retryDelay so non-404 errors don't * blow past the test timeout — same pattern used by Batches.test.tsx. */ function renderIntoContainer( element: React.ReactElement, initialEntries: string[] = ["/batch-diff"], ): { container: HTMLDivElement; unmount: () => void } { const container = document.createElement("div"); document.body.appendChild(container); const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 10, }, }, }); const root: Root = createRoot(container); act(() => { root.render( {element} , ); }); return { container, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } /** * Flush microtasks + react state updates until the predicate holds or * the deadline expires. react-query's fetch resolves asynchronously * so we have to await a tick before the rendered DOM reflects the * mocked data. */ async function waitFor( predicate: () => boolean, description: string, timeoutMs = 2000, ): Promise { const start = Date.now(); while (!predicate()) { if (Date.now() - start > timeoutMs) { throw new Error(`waitFor: ${description} did not become true within ${timeoutMs}ms`); } await act(async () => { await Promise.resolve(); }); } } // Fixtures used by the data-driven tests. const BATCH_A = { id: "B-A", kind: "837p" as const, inputFilename: "original.837", parsedAt: "2026-06-15T12:00:00Z", claimCount: 2, }; const BATCH_B = { id: "B-B", kind: "837p" as const, inputFilename: "corrected.837", parsedAt: "2026-06-16T12:00:00Z", claimCount: 3, }; function makeDiffPayload(): BatchDiffResponse { return { a: { id: BATCH_A.id, kind: "837p", parsedAt: BATCH_A.parsedAt, inputFilename: BATCH_A.inputFilename, claimCount: 2, }, b: { id: BATCH_B.id, kind: "837p", parsedAt: BATCH_B.parsedAt, inputFilename: BATCH_B.inputFilename, claimCount: 3, }, added: [ { claim_id: "CLM-3", patient_name: "Sam Roe", total_charge: 200, service_date: "2026-06-02", status: "submitted", cpt_codes: ["99213"], }, ], removed: [ { claim_id: "CLM-2", patient_name: "Jane Doe", total_charge: 75, service_date: "2026-06-01", status: "submitted", cpt_codes: ["99213"], }, ], changed: [ { a: { claim_id: "CLM-1", patient_name: "Pat Lee", total_charge: 100, service_date: "2026-06-01", status: "submitted", cpt_codes: ["99213"], }, b: { claim_id: "CLM-1", patient_name: "Pat Lee", total_charge: 150, service_date: "2026-06-01", status: "submitted", cpt_codes: ["99213"], }, diff: [{ field: "total_charge", a: 100, b: 150 }], }, ], summary: { addedCount: 1, removedCount: 1, changedCount: 1, unchangedCount: 1, }, }; } describe("BatchDiff page", () => { beforeEach(() => { vi.clearAllMocks(); // Default for the per-remit detail fetch — the drawer fetches // this whenever `?remit=` is in the URL. Return a never-resolving // promise so the drawer stays in the loading state; the smoke // test only asserts the drawer mounts, not the loaded data. ( api.getRemittance as unknown as ReturnType ).mockReturnValue(new Promise(() => {})); }); afterEach(() => { // happy-dom persists window.location between tests; reset so the // URL-state hook starts from a clean slate. (window as unknown as { happyDOM: { setURL: (u: string) => void } }) .happyDOM.setURL("http://localhost/batch-diff"); }); it("SP21 Task 4.5: deep-link ?remit=ID opens the RemitDrawer on mount", async () => { // Pre-set the URL with `?remit=`. The page doesn't surface any // per-remit rows (the diff payload is claim-level), but the // drawer must still mount so the URL contract is honored. // // `useRemitDrawerUrlState` reads `window.location.search` // (NOT React Router's search params) so we have to set the // global window URL via happyDOM AND give MemoryRouter an // initial entry — both stay in lockstep. (window as unknown as { happyDOM: { setURL: (u: string) => void } }) .happyDOM.setURL("http://localhost/batch-diff?remit=REM-7"); ( api.listBatches as unknown as ReturnType ).mockResolvedValue([BATCH_A, BATCH_B]); const { unmount } = renderIntoContainer( , ["/batch-diff?remit=REM-7"], ); // Page header must be present + drawer must be open. await waitFor( () => !!document.querySelector('[data-testid="batch-diff-page"]'), "page header mounted", ); await waitFor( () => !!document.querySelector('[data-testid="remit-drawer"]'), "remit drawer mounted via deep link", ); expect( document.querySelector('[data-testid="remit-drawer"]'), ).not.toBeNull(); unmount(); }); it("renders the awaiting-picks empty state when no batches are selected", async () => { (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); const { unmount } = renderIntoContainer(); await waitFor( () => !!document.querySelector('[data-testid="batch-diff-page"]'), "page header mounted", ); // The copy unique to the empty state — verifier the page rendered // the awaiting-picks branch and not the loading skeleton. expect(document.body.textContent).toContain("awaiting picks"); expect(document.querySelector('[data-testid="diff-picker-a"]')).not.toBeNull(); expect(document.querySelector('[data-testid="diff-picker-b"]')).not.toBeNull(); // Diff not requested yet — getBatchDiff should NOT have been // called because the hook gates on both picks being non-null. expect(api.getBatchDiff).not.toHaveBeenCalled(); unmount(); }); it("renders the loading skeleton while the diff is in flight", async () => { (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); // Never resolves — keeps the query in the loading state. (api.getBatchDiff as unknown as ReturnType).mockReturnValue( new Promise(() => {}), ); // Start the page with both picks already in the URL. const { unmount } = renderIntoContainer( , [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`], ); await waitFor( () => !!document.querySelector('[data-testid="batch-diff-skeleton"]'), "loading skeleton visible", ); unmount(); }); it("renders the three-section diff view when the backend returns deltas", async () => { (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); (api.getBatchDiff as unknown as ReturnType).mockResolvedValue( makeDiffPayload(), ); const { unmount } = renderIntoContainer( , [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`], ); // Wait for the diff view (full, non-empty payload). await waitFor( () => !!document.querySelector('[data-testid="batch-diff-view"]'), "diff view rendered", ); // Sections are present. expect( document.querySelector('[data-testid="batch-diff-added-section"]'), ).not.toBeNull(); expect( document.querySelector('[data-testid="batch-diff-removed-section"]'), ).not.toBeNull(); expect( document.querySelector('[data-testid="batch-diff-changed-section"]'), ).not.toBeNull(); // Per-claim rows render with the right indicator + id. expect( document.querySelector('[data-testid="diff-added-row-CLM-3"]'), ).not.toBeNull(); expect( document.querySelector('[data-testid="diff-removed-row-CLM-2"]'), ).not.toBeNull(); expect( document.querySelector('[data-testid="diff-changed-row-CLM-1"]'), ).not.toBeNull(); // Indicator chips are present for each bucket. expect( document.querySelector('[data-testid="diff-indicator-added"]'), ).not.toBeNull(); expect( document.querySelector('[data-testid="diff-indicator-removed"]'), ).not.toBeNull(); expect( document.querySelector('[data-testid="diff-indicator-changed"]'), ).not.toBeNull(); unmount(); }); it("renders the no-deltas state when the backend reports an empty diff", async () => { (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); const empty = makeDiffPayload(); empty.added = []; empty.removed = []; empty.changed = []; empty.summary = { addedCount: 0, removedCount: 0, changedCount: 0, unchangedCount: 2, }; (api.getBatchDiff as unknown as ReturnType).mockResolvedValue( empty, ); const { unmount } = renderIntoContainer( , [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`], ); await waitFor( () => !!document.querySelector('[data-testid="batch-diff-empty"]'), "empty-diff state visible", ); // Summary cards still surface the counts even when zero. expect( document.querySelector('[data-testid="batch-diff-summary"]'), ).not.toBeNull(); unmount(); }); it("renders the not-found state on a 404 from getBatchDiff", async () => { (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); // Mirror the backend's 404 contract: ApiError(404). (api.getBatchDiff as unknown as ReturnType).mockRejectedValue( new ApiError(404, "Not found — batch B-A not found"), ); const { unmount } = renderIntoContainer( , [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`], ); // The not-found state uses the ErrorState primitive with a retry // affordance; assert by message text since we don't ship a // dedicated testid for the variant. await waitFor( () => (document.body.textContent ?? "").includes("not found"), "not-found error text visible", ); expect( (document.body.textContent ?? "").toLowerCase(), ).toContain("one of these batches was not found"); unmount(); }); it("renders the network error state on a non-404 failure", async () => { (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); (api.getBatchDiff as unknown as ReturnType).mockRejectedValue( new Error("500 Internal Server Error — upstream"), ); const { unmount } = renderIntoContainer( , [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`], ); await waitFor( () => (document.body.textContent ?? "").includes( "Couldn't compute the diff", ), "network error text visible", ); // Retry button is rendered (the ErrorState primitive's affordance). expect( (document.body.textContent ?? "").toLowerCase(), ).toContain("retry"); unmount(); }); it("passes the URL a/b picks through to getBatchDiff", async () => { (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); (api.getBatchDiff as unknown as ReturnType).mockResolvedValue( makeDiffPayload(), ); const { unmount } = renderIntoContainer( , [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`], ); await waitFor( () => (api.getBatchDiff as unknown as ReturnType).mock.calls.length > 0, "getBatchDiff invoked", ); // The two picks are passed through in order: a, b. expect(api.getBatchDiff).toHaveBeenCalledWith(BATCH_A.id, BATCH_B.id); unmount(); }); it("Clear picks button resets both pickers and returns to awaiting-picks state", async () => { (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); (api.getBatchDiff as unknown as ReturnType).mockResolvedValue( makeDiffPayload(), ); const { unmount } = renderIntoContainer( , [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`], ); await waitFor( () => !!document.querySelector('[data-testid="diff-clear-button"]'), "clear button visible", ); const clear = document.querySelector( '[data-testid="diff-clear-button"]', ) as HTMLButtonElement | null; await act(async () => { clear?.click(); await Promise.resolve(); }); // Back to the awaiting-picks empty state. The hook's // `enabled: a && b` predicate stops getBatchDiff from being // re-invoked once both ids are null. await waitFor( () => (document.body.textContent ?? "").includes("awaiting picks"), "empty state re-appears", ); // The picker for B should now be empty (the trigger renders the // placeholder text). expect( (document.body.textContent ?? "").toLowerCase(), ).toContain("pick a batch"); unmount(); }); it("SP21 Task 5.6: clicking an added-claim id navigates to /claims?claim=ID", async () => { // Each ClaimIdCell wraps its id text with DrillableCell, whose // onClick navigates to /claims?claim=ID. The MemoryRouter gives // us a router context so the navigation completes; we observe // it via the rendered pathname on a LocationTracker spy. (api.listBatches as unknown as ReturnType).mockResolvedValue([ BATCH_A, BATCH_B, ]); (api.getBatchDiff as unknown as ReturnType).mockResolvedValue( makeDiffPayload(), ); const captured: { pathname: string; search: string } = { pathname: "/batch-diff", search: "", }; const Tracker = () => { const loc = useLocationSafe(); React.useEffect(() => { captured.pathname = loc.pathname; captured.search = loc.search; }, [loc.pathname, loc.search]); return null; }; const { unmount } = renderIntoContainer( <> , [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`], ); await waitFor( () => !!document.querySelector('[data-testid="diff-added-row-CLM-3"]'), "added row rendered", ); const cell = document.querySelector( '[data-testid="diff-added-row-CLM-3"] [data-testid="diff-claim-id"]', ); expect(cell).not.toBeNull(); // DrillableCell wraps the id text in its own