// @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. (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 } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Remittances } from "./Remittances"; import { api } from "@/lib/api"; import { useTailStore } from "@/store/tail-store"; // Module-level mock: vitest hoists `vi.mock` calls above imports. We only // stub the method this page touches via the `useRemittances` hook. // `isConfigured: true` is critical — without it, `useRemittances` falls // back to the empty zustand store instead of calling our mocked API. vi.mock("@/lib/api", () => ({ api: { isConfigured: true, listRemittances: vi.fn(), getRemittance: vi.fn(), }, })); // Mock the live-tail hook so the page renders the pill in the settled // `live` state without driving the real streamTail parser. The hook's // own test file (`useTailStream.test.ts`) covers the lifecycle in depth. vi.mock("@/hooks/useTailStream", () => ({ useTailStream: vi.fn(() => ({ status: "live", lastEventAt: null, error: null, forceReconnect: vi.fn(), })), })); /** * Minimal `render` helper using react-dom/client + act(). Mirrors the * `renderIntoContainer` helper in `Reconciliation.test.tsx` — see that * file's comment for the rationale (project doesn't ship a DOM test * library yet, and adding one just for these tests would inflate the * dev-deps tree). Returns the rendered container so tests can assert * against the live DOM via `document.body.textContent`. */ function renderIntoContainer(element: React.ReactElement): { container: HTMLDivElement; unmount: () => void; } { const container = document.createElement("div"); document.body.appendChild(container); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const root: Root = createRoot(container); act(() => { root.render( React.createElement(QueryClientProvider, { client: qc }, element) ); }); return { container, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } /** * Flush microtasks + react state updates until the predicate holds or we * time out. react-query's fetch resolves asynchronously, so we have to * await a tick before the rendered DOM reflects the mocked data. */ async function waitForText( text: string, timeoutMs = 2000 ): Promise { const start = Date.now(); while (!document.body.textContent?.includes(text)) { if (Date.now() - start > timeoutMs) { throw new Error(`waitForText: "${text}" did not appear within ${timeoutMs}ms`); } await act(async () => { await Promise.resolve(); }); } } /** * Poll a predicate until it holds or we time out. react-query's fetch * resolves asynchronously, and Radix portals content into * `document.body`, so the DOM doesn't reflect new state synchronously * after a keypress — we have to await a tick before asserting. */ 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)); }); } } /** * Dispatch a synthetic keydown on `window`. Same pattern used in * `Claims.test.tsx` and `useDrawerKeyboard.test.ts`. */ function pressKey(key: string): void { act(() => { window.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }) ); }); } /** The data row at `idx` (per `data-row-index={idx}`). */ function rowAt(idx: number): HTMLTableRowElement | null { return document.querySelector( `tbody tr[data-row-index="${idx}"]` ) as HTMLTableRowElement | null; } /** True iff exactly one row carries `data-state="selected"`. */ function hasExactlyOneSelectedRow(): boolean { const selected = document.querySelectorAll( 'tbody tr[data-state="selected"]' ); return selected.length === 1; } /** Fixture used by every test below so the page has rows to navigate. */ const SAMPLE_REMITS = [ { id: "PCN-1", claimId: "CLM-1", payerName: "Colorado Medicaid", paidAmount: 100, adjustmentAmount: 60, receivedDate: "2026-06-19T12:00:00Z", checkNumber: "", status: "received", adjustments: [ { group: "CO", reason: "45", label: "Charge exceeds fee schedule/maximum allowable", amount: 50, quantity: null, }, { group: "PR", reason: "1", label: "Deductible amount", amount: 10, quantity: 1, }, ], }, { id: "PCN-2", claimId: "CLM-2", payerName: "Aetna", paidAmount: 200, adjustmentAmount: 0, receivedDate: "2026-06-18T12:00:00Z", checkNumber: "", status: "received", adjustments: [], }, { id: "PCN-3", claimId: "CLM-3", payerName: "United", paidAmount: 300, adjustmentAmount: 0, receivedDate: "2026-06-17T12:00:00Z", checkNumber: "", status: "received", adjustments: [], }, ] as const; describe("Remittances", () => { beforeEach(() => { vi.clearAllMocks(); // Singleton tail-store: clear the remittances slice between tests // so a tail-arrival case (if added later) doesn't see rows from a // previous test. useTailStore.getState().reset("remittances"); ( api.listRemittances as unknown as ReturnType ).mockResolvedValue({ items: SAMPLE_REMITS, total: SAMPLE_REMITS.length, returned: SAMPLE_REMITS.length, has_more: false, }); }); it("renders a CAS adjustment label inside the expanded detail row", async () => { const { unmount } = renderIntoContainer(React.createElement(Remittances)); await waitForText("PCN-1"); // The chevron + "Adjustments" header should not yet be visible because // the row hasn't been expanded yet. expect(document.body.textContent).not.toContain("Adjustments (2)"); // Expand the row by clicking on the remit ID cell. We click the parent // row by selecting the cell containing "PCN-1" and bubbling up. const cell = Array.from(document.querySelectorAll("td")).find( (td) => td.textContent === "PCN-1" ); const row = cell?.closest("tr"); expect(row).toBeTruthy(); await act(async () => { (row as HTMLTableRowElement).click(); }); await waitForText("Adjustments (2)"); // Both CAS labels must surface (not the raw codes alone). expect(document.body.textContent).toContain( "Charge exceeds fee schedule/maximum allowable" ); expect(document.body.textContent).toContain("Deductible amount"); // Group/reason pills show the CARC code alongside. expect(document.body.textContent).toContain("CO-45"); expect(document.body.textContent).toContain("PR-1"); unmount(); }); it("test_initial_render_shows_no_selection_highlight", async () => { const { unmount } = renderIntoContainer(React.createElement(Remittances)); await waitForText("PCN-3"); // The spec requires the initial render shows no selection // highlight — none of the three data rows should be marked // selected. expect( document.querySelectorAll('tbody tr[data-state="selected"]').length ).toBe(0); unmount(); }); it("test_pressing_j_increments_selection_with_wrap", async () => { const { unmount } = renderIntoContainer(React.createElement(Remittances)); await waitForText("PCN-3"); // From null, j lands on row 0. pressKey("j"); await settle(() => rowAt(0)?.getAttribute("data-state") === "selected"); expect(rowAt(0)?.getAttribute("data-state")).toBe("selected"); // j again → row 1, then row 2. pressKey("j"); await settle(() => rowAt(1)?.getAttribute("data-state") === "selected"); expect(rowAt(1)?.getAttribute("data-state")).toBe("selected"); pressKey("j"); await settle(() => rowAt(2)?.getAttribute("data-state") === "selected"); expect(rowAt(2)?.getAttribute("data-state")).toBe("selected"); // j from the last row wraps to row 0 (wrap policy documented in // Remittances.tsx — matches Claims' drawer behavior). pressKey("j"); await settle(() => rowAt(0)?.getAttribute("data-state") === "selected"); expect(rowAt(0)?.getAttribute("data-state")).toBe("selected"); expect(hasExactlyOneSelectedRow()).toBe(true); unmount(); }); it("test_pressing_k_decrements_selection_with_wrap", async () => { const { unmount } = renderIntoContainer(React.createElement(Remittances)); await waitForText("PCN-3"); // From null, k jumps to the last row (one-keystroke "bottom of // list" gesture). pressKey("k"); await settle(() => rowAt(2)?.getAttribute("data-state") === "selected"); expect(rowAt(2)?.getAttribute("data-state")).toBe("selected"); // k → row 1, then row 0. pressKey("k"); await settle(() => rowAt(1)?.getAttribute("data-state") === "selected"); expect(rowAt(1)?.getAttribute("data-state")).toBe("selected"); pressKey("k"); await settle(() => rowAt(0)?.getAttribute("data-state") === "selected"); expect(rowAt(0)?.getAttribute("data-state")).toBe("selected"); // k from row 0 wraps to row 2 (last). pressKey("k"); await settle(() => rowAt(2)?.getAttribute("data-state") === "selected"); expect(rowAt(2)?.getAttribute("data-state")).toBe("selected"); unmount(); }); it("test_j_does_not_trigger_expand_collapse", async () => { // j/k selection is a focus indicator, NOT a substitute for the // click-to-expand gesture. Pressing j through a row that has // adjustments must NOT open the detail row. const { unmount } = renderIntoContainer(React.createElement(Remittances)); await waitForText("PCN-3"); // Row 0 has adjustments — moving selection onto it should leave // the row collapsed (the user can still click to expand). pressKey("j"); await settle(() => rowAt(0)?.getAttribute("data-state") === "selected"); expect(document.body.textContent).not.toContain("Adjustments (2)"); pressKey("j"); await settle(() => rowAt(1)?.getAttribute("data-state") === "selected"); expect(document.body.textContent).not.toContain("Adjustments (2)"); unmount(); }); it("test_question_mark_toggles_cheatsheet_and_escape_closes", async () => { const { unmount } = renderIntoContainer(React.createElement(Remittances)); await waitForText("PCN-3"); // No cheatsheet initially. expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); // `?` opens it. pressKey("?"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !== null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).not.toBeNull(); // Escape closes it. pressKey("Escape"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') === null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); // `?` again re-opens (toggle works in both directions). pressKey("?"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !== null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).not.toBeNull(); unmount(); }); it("test_j_does_not_navigate_while_cheatsheet_is_open", async () => { // `enabled: !helpOpen` is the recommended pairing with // useRowKeyboard: j/k nav yields to the cheatsheet while it's // open. Pressing j while the cheatsheet is open closes the // cheatsheet (its own dismiss listener handles any non-`?` // keypress) but the row should NOT have moved. const { unmount } = renderIntoContainer(React.createElement(Remittances)); await waitForText("PCN-3"); // Move selection to row 0 first. pressKey("j"); await settle(() => rowAt(0)?.getAttribute("data-state") === "selected"); // Open cheatsheet. pressKey("?"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !== null ); // j while cheatsheet open: closes cheatsheet, row stays at 0. pressKey("j"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') === null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); expect(hasExactlyOneSelectedRow()).toBe(true); expect(rowAt(0)?.getAttribute("data-state")).toBe("selected"); unmount(); }); // ------------------------------------------------------------------- // Live-tail status pill (sub-project 5, Phase 5 Task 23, optional // page-level test). Cheap smoke check: the toolbar renders the pill // with the `live` label whenever the mocked stream is settled. The // hook's lifecycle is covered exhaustively in its own test file. // ------------------------------------------------------------------- it("test_status_pill_shows_live_in_toolbar", async () => { ( api.listRemittances as unknown as ReturnType ).mockResolvedValue({ items: [], total: 0, returned: 0, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Remittances)); // Wait for the empty-state to mount — once the page body is rendered, // the toolbar (and thus the pill) is in the DOM. await waitForText("Remittances · awaiting first 835"); const pill = document.body.querySelector( '[data-testid="tail-status-pill"]', ); expect(pill).not.toBeNull(); expect(pill?.textContent).toContain("Live"); unmount(); }); });