1359b0753a
Brings the SP5 live-tail implementation into main:
Backend
* EventBus (cyclone.pubsub): per-kind fan-out with drop-oldest overflow
* FastAPI lifespan initializes the bus + db once per process
* store.add() publishes claim_written / remittance_written /
activity_recorded events on every batch write
* GET /api/{claims,remittances,activity}/stream: NDJSON snapshot +
live subscription + 15s idle heartbeat
* EventBus.unsubscribe() lets the tail loop release its queue on
client disconnect (no queue leak per open stream)
Frontend
* src/lib/tail-stream.ts: streamTail() async-generator over fetch
* src/store/tail-store.ts: zustand with FIFO cap 10k per slice
* src/hooks/useTailStream.ts: connecting/live/reconnecting/stalled/error/closed
state machine with 1→2→4→8→16→30s backoff
* src/hooks/useMergedTail.ts: base + tail merge with filter
* src/components/TailStatusPill.tsx: badge + Reconnect button
* Claims, Remittances, ActivityLog pages wired to the tail
Tests
* 437 backend tests pass (was 418 before SP5)
* 154 frontend tests pass (was 124)
* npm run typecheck clean
* end-to-end smoke: open /api/claims/stream, POST 837, see new claims
arrive in real time without refresh
# Conflicts:
# src/pages/ActivityLog.tsx
# src/pages/Remittances.test.tsx
# src/pages/Remittances.tsx
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
// @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<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`. 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<typeof vi.fn>
|
|
).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<typeof vi.fn>
|
|
).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();
|
|
});
|
|
}); |