// @vitest-environment happy-dom (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 { MemoryRouter } from "react-router-dom"; import { Acks } from "./Acks"; import { api } from "@/lib/api"; vi.mock("@/lib/api", () => { class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } return { api: { isConfigured: true, listAcks: vi.fn(), getAck: vi.fn(), }, ApiError, }; }); // SP25: the Acks page now opens two live-tail streams (one per ack // flavor) via `useTailStream`. The hook has its own dedicated test // suite that covers the full lifecycle; here we just want the page to // render against a stubbed hook so the test never opens a real fetch. // The mock matches the production-default state (`live`) so the pill // renders the "Live" label without surfacing a misleading error. vi.mock("@/hooks/useTailStream", () => ({ useTailStream: vi.fn(() => ({ status: "live", lastEventAt: null, error: null, forceReconnect: vi.fn(), })), })); 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(() => { // SP28: the AckDrawer mounts the new panel // which calls `useNavigate()` for cross-page navigation to // /claims?claim=. The hook requires a router context, so // the test harness wraps the page in . root.render( React.createElement( QueryClientProvider, { client: qc }, React.createElement( MemoryRouter, null, element, ), ), ); }); return { container, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } 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`. Matches the dispatching * pattern from `useDrawerKeyboard.test.ts` and `Claims.test.tsx` — * `bubbles: true` so the event propagates like a real keypress, and * `cancelable: true` so the hook's `preventDefault` has something to * act on. */ function pressKey(key: string): void { act(() => { window.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }) ); }); } /** The row at `idx` (per `data-row-index={idx}` on the row). */ 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; } describe("Acks", () => { beforeEach(() => { vi.clearAllMocks(); // Reset URL state between tests so a previous `?ack=` doesn't leak. (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL( "http://localhost/acks" ); }); it("renders a single ack row with counts and ack code", async () => { (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 42, sourceBatchId: "b-uuid-1", acceptedCount: 3, rejectedCount: 1, receivedCount: 4, ackCode: "P", parsedAt: "2026-06-20T12:00:00Z", }, ], total: 1, returned: 1, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("42"); // The row's ack code badge must be visible. expect(document.body.textContent).toContain("P"); // Counts must be visible (3 accepted, 1 rejected, 4 received). expect(document.body.textContent).toContain("3"); expect(document.body.textContent).toContain("1"); expect(document.body.textContent).toContain("4"); // The source batch id must be visible. expect(document.body.textContent).toContain("b-uuid-1"); unmount(); }); // ------------------------------------------------------------------------- // 2026-07-02: rejections were getting buried inside the accepted acks // (1,156 accepted + 5 rejected) — the operator had to scroll the // entire table to find the 5 they cared about. Fix: client-side sort // bubbles rejected rows to the top of page 1. Within each group // (rejected vs accepted) newest-id-first is preserved. // ------------------------------------------------------------------------- it("test_rejected_acks_float_to_top_of_table", async () => { (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 100, sourceBatchId: "b-newest-accepted", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-07-01T12:00:00Z", }, { id: 99, sourceBatchId: "b-rejected", acceptedCount: 0, rejectedCount: 1, receivedCount: 1, ackCode: "R", parsedAt: "2026-07-01T11:00:00Z", }, { id: 98, sourceBatchId: "b-old-accepted", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-06-25T12:00:00Z", }, ], total: 3, returned: 3, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("100"); // The first table row must be the rejected one (id=99), even though // id=100 has a higher id. After that, accepted rows sort newest-first // (id=100 before id=98). const tableRows = Array.from( document.querySelectorAll("table tbody tr"), ).filter((row) => { // Exclude any TA1-section rows by requiring the sourceBatchId // pattern (TA1 rows have a different layout). return row.textContent?.includes("b-"); }); expect(tableRows.length).toBe(3); expect(tableRows[0]?.textContent).toContain("b-rejected"); expect(tableRows[1]?.textContent).toContain("b-newest-accepted"); expect(tableRows[2]?.textContent).toContain("b-old-accepted"); unmount(); }); it("test_initial_render_shows_no_selection_highlight", async () => { // Three rows in the fixture so there's plenty to select; the test // asserts the *initial* state has no row marked selected. (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 1, sourceBatchId: "b-1", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", }, { id: 2, sourceBatchId: "b-2", acceptedCount: 2, rejectedCount: 0, receivedCount: 2, ackCode: "A", parsedAt: "2026-06-20T12:01:00Z", }, { id: 3, sourceBatchId: "b-3", acceptedCount: 3, rejectedCount: 0, receivedCount: 3, ackCode: "A", parsedAt: "2026-06-20T12:02:00Z", }, ], total: 3, returned: 3, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-3"); // None of the three rows should be marked selected — the spec // requires the initial render shows no selection highlight. expect( document.querySelectorAll('tbody tr[data-state="selected"]').length ).toBe(0); unmount(); }); it("test_pressing_j_increments_selection", async () => { (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 1, sourceBatchId: "b-1", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", }, { id: 2, sourceBatchId: "b-2", acceptedCount: 2, rejectedCount: 0, receivedCount: 2, ackCode: "A", parsedAt: "2026-06-20T12:01:00Z", }, { id: 3, sourceBatchId: "b-3", acceptedCount: 3, rejectedCount: 0, receivedCount: 3, ackCode: "A", parsedAt: "2026-06-20T12:02:00Z", }, ], total: 3, returned: 3, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-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"); expect(rowAt(1)?.getAttribute("data-state")).not.toBe("selected"); expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected"); // j again → row 1. pressKey("j"); await settle(() => rowAt(1)?.getAttribute("data-state") === "selected"); expect(rowAt(1)?.getAttribute("data-state")).toBe("selected"); expect(rowAt(0)?.getAttribute("data-state")).not.toBe("selected"); // j again → row 2. pressKey("j"); await settle(() => rowAt(2)?.getAttribute("data-state") === "selected"); expect(rowAt(2)?.getAttribute("data-state")).toBe("selected"); // j from last row wraps to row 0 (per the wrap-or-clamp policy // documented in Acks.tsx). pressKey("j"); await settle(() => rowAt(0)?.getAttribute("data-state") === "selected"); expect(rowAt(0)?.getAttribute("data-state")).toBe("selected"); unmount(); }); it("test_pressing_k_decrements_selection_with_wrap", async () => { (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 1, sourceBatchId: "b-1", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", }, { id: 2, sourceBatchId: "b-2", acceptedCount: 2, rejectedCount: 0, receivedCount: 2, ackCode: "A", parsedAt: "2026-06-20T12:01:00Z", }, { id: 3, sourceBatchId: "b-3", acceptedCount: 3, rejectedCount: 0, receivedCount: 3, ackCode: "A", parsedAt: "2026-06-20T12:02:00Z", }, ], total: 3, returned: 3, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-3"); // From null, k jumps to the last row — a 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 again → row 1. pressKey("k"); await settle(() => rowAt(1)?.getAttribute("data-state") === "selected"); expect(rowAt(1)?.getAttribute("data-state")).toBe("selected"); expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected"); // k twice more lands on row 0. 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_question_mark_toggles_cheatsheet_and_escape_closes", async () => { (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 1, sourceBatchId: "b-1", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", }, ], total: 1, returned: 1, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-1"); // No cheatsheet initially. expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); // `?` opens it (Radix portals into document.body — query there). pressKey("?"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !== null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).not.toBeNull(); // Escape closes it. The page wires onClose via useRowKeyboard // (Escape → onClose → setHelpOpen(false)). pressKey("Escape"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') === null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); // `?` again re-opens (proving the 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. The cheatsheet's own dismiss listener still closes it on // any non-`?` keypress (per KeyboardCheatsheet.tsx), so pressing // j will close the cheatsheet but the row should NOT have moved. (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 1, sourceBatchId: "b-1", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", }, { id: 2, sourceBatchId: "b-2", acceptedCount: 2, rejectedCount: 0, receivedCount: 2, ackCode: "A", parsedAt: "2026-06-20T12:01:00Z", }, ], total: 2, returned: 2, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-2"); // Move selection to row 0 first (so we can later verify it didn't // jump to row 1 while the cheatsheet is open). pressKey("j"); await settle(() => rowAt(0)?.getAttribute("data-state") === "selected"); // Open cheatsheet. pressKey("?"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !== null ); // Press j while cheatsheet is open. Cheatsheet closes (its own // listener handles any non-? key), but selection should NOT have // moved — row 0 is still selected. pressKey("j"); await settle( () => document.body.querySelector('[data-testid="keyboard-cheatsheet"]') === null ); expect( document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ).toBeNull(); // Row 0 is still the selected row. expect(hasExactlyOneSelectedRow()).toBe(true); expect(rowAt(0)?.getAttribute("data-state")).toBe("selected"); unmount(); }); it("test_clicking_a_row_opens_the_ack_drawer", async () => { // SP21 Phase 5 Task 5.3: clicking an acks row drills into the // matching ack via `?ack=ID` URL state. The AckDrawer mounts // but the actual content depends on `useAckDetail` — we don't // need to verify drawer internals here, just that the URL got // pushed and the drawer portal opens. (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 42, sourceBatchId: "b-uuid-1", acceptedCount: 3, rejectedCount: 1, receivedCount: 4, ackCode: "P", parsedAt: "2026-06-20T12:00:00Z", }, ], total: 1, returned: 1, has_more: false, }); // Stub the per-ack fetch so `useAckDetail` resolves cleanly // (avoids TanStack Query's "Query data cannot be undefined" // warning). We only assert on the drawer's presence, so the // shape doesn't need to be precise. (api.getAck as unknown as ReturnType).mockResolvedValue({ id: 42, sourceBatchId: "b-uuid-1", acceptedCount: 3, rejectedCount: 1, receivedCount: 4, ackCode: "P", parsedAt: "2026-06-20T12:00:00Z", raw_999_text: "ISA*~\n", }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-uuid-1"); // No drawer yet. expect( document.body.querySelector('[data-testid="ack-drawer"]') ).toBeNull(); // Click the row. const row = rowAt(0); expect(row).not.toBeNull(); await act(async () => { row!.click(); await Promise.resolve(); }); await settle( () => document.body.querySelector('[data-testid="ack-drawer"]') !== null ); // The drawer is now in the DOM. expect( document.body.querySelector('[data-testid="ack-drawer"]') ).not.toBeNull(); unmount(); }); it("test_deep_link_with_ack_param_opens_drawer_on_mount", async () => { // /acks?ack=42 deep link → drawer opens on mount without a click. (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 42, sourceBatchId: "b-uuid-1", acceptedCount: 3, rejectedCount: 1, receivedCount: 4, ackCode: "P", parsedAt: "2026-06-20T12:00:00Z", }, ], total: 1, returned: 1, has_more: false, }); (api.getAck as unknown as ReturnType).mockResolvedValue({ id: 42, sourceBatchId: "b-uuid-1", acceptedCount: 3, rejectedCount: 1, receivedCount: 4, ackCode: "P", parsedAt: "2026-06-20T12:00:00Z", raw_999_text: "ISA*~\n", }); (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL( "http://localhost/acks?ack=42" ); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-uuid-1"); await settle( () => document.body.querySelector('[data-testid="ack-drawer"]') !== null ); // The drawer is in the DOM on first render. expect( document.body.querySelector('[data-testid="ack-drawer"]') ).not.toBeNull(); unmount(); }); // ------------------------------------------------------------------------- // SP25: the page now mounts two live-tail streams (one per ack // flavor) via useTailStream. The mocked hook returns status: "live", // so the page should render TWO TailStatusPills — one in the 999 // hero and one in the TA1 section — both showing the human label // "Live" (per `STATUS_LABEL` in `TailStatusPill.tsx`). // ------------------------------------------------------------------------- it("test_renders_two_tail_status_pills_live", async () => { (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [], total: 0, returned: 0, has_more: false, aggregates: { accepted_count: 0, rejected_count: 0, received_count: 0 }, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); // Wait for the page to settle — both pills need the query to // resolve (and even the TA1 section renders a tail pill even // when items is empty). await settle( () => document.body.textContent?.includes("Live") ?? false, ); // Two "Live" labels: one for the 999 stream, one for the TA1 // stream. (Plus the description text on the page may say "live" // in another context — check via the surrounding pill markup.) const pills = Array.from( document.querySelectorAll('[data-testid="tail-status-pill"]'), ); expect(pills.length).toBe(2); unmount(); }); // ------------------------------------------------------------------------- // SP28: the "Claims" column on the 999 register. The badge carries // the distinct-claim link count (per-AK2 granularity, D1). Empty // link count → "Orphan" amber pill. Single link → muted "1 claim" // text. Multi link → success "{count} claims" badge. // ------------------------------------------------------------------------- it("test_claims_badge_shows_orphan_when_no_linked_claims", async () => { (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 42, sourceBatchId: "b-orphan", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", linkedClaimIds: [], }, ], total: 1, returned: 1, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-orphan"); // An orphan ack renders the warning "Orphan" badge — the // operator's cue that the auto-link didn't resolve and they // may need to drill in to run a manual match. const orphanBadge = document.body.querySelector( '[data-testid="claims-badge-orphan"]', ); expect(orphanBadge).not.toBeNull(); expect(orphanBadge?.textContent).toContain("Orphan"); unmount(); }); it("test_claims_badge_shows_single_link_text_for_one_claim", async () => { (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 43, sourceBatchId: "b-single", acceptedCount: 2, rejectedCount: 0, receivedCount: 2, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", linkedClaimIds: ["CLM-1"], }, ], total: 1, returned: 1, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-single"); // Single link → muted mono text "1 claim" (not a colored pill — // a single resolved link is the common case and doesn't warrant // visual emphasis). const singleBadge = document.body.querySelector( '[data-testid="claims-badge-single"]', ); expect(singleBadge).not.toBeNull(); expect(singleBadge?.textContent).toContain("1 claim"); // Not the orphan variant. expect( document.body.querySelector('[data-testid="claims-badge-orphan"]'), ).toBeNull(); unmount(); }); it("test_claims_badge_shows_count_for_multiple_linked_claims", async () => { // A 999 with three AK2 set-responses, each linked to a different // claim batch, gets a multi-link badge. (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 44, sourceBatchId: "b-multi", acceptedCount: 3, rejectedCount: 0, receivedCount: 3, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", linkedClaimIds: ["CLM-1", "CLM-2", "CLM-3"], }, ], total: 1, returned: 1, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-multi"); // Multi link → success "{N} claims" badge. const manyBadge = document.body.querySelector( '[data-testid="claims-badge-many"]', ); expect(manyBadge).not.toBeNull(); expect(manyBadge?.textContent).toContain("3 claims"); expect(manyBadge?.getAttribute("data-claims-count")).toBe("3"); unmount(); }); it("test_claims_badge_treats_undefined_linked_claim_ids_as_orphan", async () => { // Defensive: older ack rows (pre-SP28) might not carry // `linkedClaimIds` at all. The page must render them as // orphan (count == 0) rather than crashing on undefined.length. (api.listAcks as unknown as ReturnType).mockResolvedValue({ items: [ { id: 45, sourceBatchId: "b-legacy", acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", // linkedClaimIds intentionally omitted (legacy row) }, ], total: 1, returned: 1, has_more: false, }); const { unmount } = renderIntoContainer(React.createElement(Acks)); await waitForText("b-legacy"); const orphanBadge = document.body.querySelector( '[data-testid="claims-badge-orphan"]', ); expect(orphanBadge).not.toBeNull(); unmount(); }); });