merge: ui/batch-diff

This commit is contained in:
Tyler
2026-06-20 17:30:30 -06:00
12 changed files with 2556 additions and 0 deletions
+444
View File
@@ -0,0 +1,444 @@
// @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(),
},
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(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
</MemoryRouter>,
);
});
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<void> {
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();
});
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("renders the awaiting-picks empty state when no batches are selected", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
const { unmount } = renderIntoContainer(<BatchDiff />);
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<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
// Never resolves — keeps the query in the loading state.
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise(() => {}),
);
// Start the page with both picks already in the URL.
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/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<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
empty,
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/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<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
// Mirror the backend's 404 contract: ApiError(404).
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new ApiError(404, "Not found — batch B-A not found"),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/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<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("500 Internal Server Error — upstream"),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/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<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/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();
});
});
// Keep `ApiError` referenced so the import isn't tree-shaken by
// vitest's transformer when the mock factory above is hoisted.
void ApiError;
+320
View File
@@ -0,0 +1,320 @@
import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { GitCompareArrows, CircleDashed, AlertCircle } from "lucide-react";
import { ApiError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { BatchDiffView, BatchDiffViewSkeleton } from "@/components/BatchDiffView";
import { useBatches } from "@/hooks/useBatches";
import { useBatchDiff } from "@/hooks/useBatchDiff";
import type { BatchSummary as ApiBatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Inline URL state — uses react-router's useSearchParams so the page
// participates in the standard routing lifecycle (MemoryRouter in
// tests, BrowserRouter in production). `replace: true` keeps the
// picker tweaks from polluting the back-button stack.
// ---------------------------------------------------------------------------
function readIdsFromParams(params: URLSearchParams): {
a: string | null;
b: string | null;
} {
const a = params.get("a");
const b = params.get("b");
return {
a: a && a.length > 0 ? a : null,
b: b && b.length > 0 ? b : null,
};
}
// ---------------------------------------------------------------------------
// Batch picker — one Select dropdown for each side. Keeps the same
// kind-colored badge inline so the operator can spot which batch is
// which at a glance.
// ---------------------------------------------------------------------------
function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) {
const cls =
kind === "837p"
? "text-sky-300 border-sky-400/30 bg-sky-400/10"
: "text-amber-300 border-amber-400/30 bg-amber-400/10";
return (
<span
data-testid={`diff-picker-kind-${kind}`}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-px text-[9.5px] font-semibold uppercase tracking-[0.14em] font-mono",
cls,
)}
>
{kind}
</span>
);
}
function BatchPicker({
label,
value,
onChange,
items,
excludeId,
testid,
}: {
label: string;
value: string | null;
onChange: (id: string) => void;
items: ApiBatchSummary[];
excludeId: string | null;
testid: string;
}) {
// Filter out the value already chosen on the *other* side so the
// operator can't accidentally pick the same batch twice (which
// produces a meaningless diff). Disabled rather than hidden so the
// selection stays transparent.
const visible = useMemo(
() => items.filter((it) => it.id !== excludeId),
[items, excludeId],
);
const selected = items.find((it) => it.id === value);
return (
<div className="space-y-1.5" data-testid={testid}>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{label}
</div>
<Select value={value ?? ""} onValueChange={(v) => onChange(v)}>
<SelectTrigger>
{selected ? (
<span className="flex items-center gap-2 truncate">
<KindBadge kind={selected.kind} />
<span className="font-mono num text-[12.5px]">{selected.id}</span>
<span className="text-muted-foreground text-[12px] truncate">
· {selected.inputFilename}
</span>
</span>
) : (
<SelectValue placeholder="Pick a batch…" />
)}
</SelectTrigger>
<SelectContent>
{visible.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No other batches available.
</div>
) : (
visible.map((b) => (
<SelectItem
key={b.id}
value={b.id}
data-testid={`diff-picker-option-${b.id}`}
>
<span className="flex items-center gap-2">
<KindBadge kind={b.kind} />
<span className="font-mono num text-[12px]">{b.id}</span>
<span className="text-muted-foreground text-[12px] truncate">
· {b.inputFilename}
</span>
</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
);
}
// ---------------------------------------------------------------------------
// Not-found branch — surfaces when the backend reports 404 for one of
// the picks. Most likely cause: a bookmark referencing a now-deleted
// batch, or another agent deleting between the picker load and the
// diff fetch.
// ---------------------------------------------------------------------------
function NotFoundState({
a,
b,
onReset,
}: {
a: string;
b: string;
onReset: () => void;
}) {
return (
<ErrorState
message="One of these batches was not found."
detail={`The backend couldn't find batch ${a} or ${b}. It may have been deleted between picker load and diff fetch.`}
onRetry={onReset}
/>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function BatchDiff() {
// URL → state via react-router. Keeps MemoryRouter (tests) and
// BrowserRouter (production) symmetric: both update the
// useSearchParams hook on every navigation.
const [searchParams, setSearchParams] = useSearchParams();
const { a, b } = useMemo(
() => readIdsFromParams(searchParams),
[searchParams],
);
const updateIds = useCallback(
(next: { a: string | null; b: string | null }) => {
const params = new URLSearchParams(searchParams);
if (next.a === null) params.delete("a");
else params.set("a", next.a);
if (next.b === null) params.delete("b");
else params.set("b", next.b);
setSearchParams(params, { replace: true });
},
[searchParams, setSearchParams],
);
const setA = useCallback(
(id: string) => updateIds({ a: id, b }),
[b, updateIds],
);
const setB = useCallback(
(id: string) => updateIds({ a, b: id }),
[a, updateIds],
);
const reset = useCallback(() => updateIds({ a: null, b: null }), [updateIds]);
const { data: batches, isLoading: loadingBatches, isError: batchesError, error: batchesErrorMsg, refetch: refetchBatches } =
useBatches(100);
// Only fire the diff once both pickers have a value. The hook also
// gates on this internally (defense-in-depth), so a half-picked URL
// never hits the backend.
const diff = useBatchDiff(a, b);
const ready = a !== null && b !== null;
// --- error / status derivation ------------------------------------
const errKind: "network" | "not_found" | null = diff.error
? diff.error instanceof ApiError && diff.error.status === 404
? "not_found"
: "network"
: null;
// --- render -------------------------------------------------------
return (
<div className="space-y-6 md:space-y-8 animate-fade-in" data-testid="batch-diff-page">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Diff
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Batch diff
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Pick two parsed batches typically a submitted 837P and its
corrected follow-up and see what was added, removed, or changed
between them.
</p>
</header>
<div className="surface rounded-xl p-4 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
<BatchPicker
label="A · left"
value={a}
onChange={setA}
items={batches ?? []}
excludeId={b}
testid="diff-picker-a"
/>
<BatchPicker
label="B · right"
value={b}
onChange={setB}
items={batches ?? []}
excludeId={a}
testid="diff-picker-b"
/>
</div>
{batchesError ? (
<ErrorState
message="Couldn't load batches from the backend."
detail={
batchesErrorMsg instanceof Error
? batchesErrorMsg.message
: String(batchesErrorMsg)
}
onRetry={() => refetchBatches()}
/>
) : null}
{!ready ? (
<div className="surface rounded-xl">
<EmptyState
icon={<GitCompareArrows className="h-4 w-4" strokeWidth={1.5} />}
eyebrow="Batch diff · awaiting picks"
message="Choose one batch for A and one for B to compute the diff."
/>
</div>
) : loadingBatches ? (
<BatchDiffViewSkeleton />
) : errKind === "not_found" ? (
<NotFoundState a={a as string} b={b as string} onReset={reset} />
) : diff.isError ? (
<ErrorState
message="Couldn't compute the diff between these two batches."
detail={
diff.error instanceof Error
? diff.error.message
: String(diff.error)
}
onRetry={() => diff.refetch()}
/>
) : diff.isLoading || !diff.data ? (
<BatchDiffViewSkeleton />
) : (
<BatchDiffView data={diff.data} />
)}
{/* Action footer — always visible so the operator can clear or
force-refresh without scrolling back to the picker. */}
{ready ? (
<div className="flex items-center justify-end gap-2 pt-2">
{diff.isFetching ? (
<span className="text-[12px] text-muted-foreground inline-flex items-center gap-1.5">
<CircleDashed className="h-3 w-3 animate-spin" strokeWidth={1.75} />
Refreshing
</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => diff.refetch()}
data-testid="diff-refresh-button"
>
Refresh
</Button>
<Button
variant="ghost"
size="sm"
onClick={reset}
data-testid="diff-clear-button"
>
<AlertCircle className="h-3.5 w-3.5 mr-1" strokeWidth={1.75} />
Clear picks
</Button>
</div>
) : null}
</div>
);
}