847 lines
26 KiB
TypeScript
847 lines
26 KiB
TypeScript
// @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 <MatchedClaim /> panel
|
|
// which calls `useNavigate()` for cross-page navigation to
|
|
// /claims?claim=<id>. The hook requires a router context, so
|
|
// the test harness wraps the page in <MemoryRouter>.
|
|
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<void> {
|
|
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<void> {
|
|
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();
|
|
});
|
|
}); |