feat(batch-diff): side-by-side claim diff between two batches
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user