feat(reconciliation): card body drillable, select button split
This commit is contained in:
@@ -4,9 +4,10 @@
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import React, { act, useEffect } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReconciliationPage } from "./Reconciliation";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -28,6 +29,23 @@ vi.mock("@/lib/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Tracks the current MemoryRouter location so tests can assert on
|
||||
* navigation without poking at window.location (MemoryRouter doesn't
|
||||
* touch it). Mounted as a sibling under the same router.
|
||||
*/
|
||||
function LocationTracker({
|
||||
on,
|
||||
}: {
|
||||
on: (pathname: string, search: string) => void;
|
||||
}) {
|
||||
const loc = useLocation();
|
||||
useEffect(() => {
|
||||
on(loc.pathname, loc.search);
|
||||
}, [loc.pathname, loc.search, on]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
||||
* `renderHook` helper in `useReconciliation.test.ts` — see that file's
|
||||
@@ -35,22 +53,50 @@ vi.mock("@/lib/api", () => ({
|
||||
* 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 `container.textContent`.
|
||||
*
|
||||
* SP21 Phase 5 Task 5.5: optionally wraps with a MemoryRouter so the
|
||||
* `useNavigate()` call (claim drill to /claims?claim=ID) has a router
|
||||
* context. Without this, `useNavigate()` would throw in tests that
|
||||
* trigger a claim-card click. Tests that don't need a router can keep
|
||||
* using the default (no router) path.
|
||||
*/
|
||||
function renderIntoContainer(element: React.ReactElement): {
|
||||
function renderIntoContainer(
|
||||
element: React.ReactElement,
|
||||
options: { withRouter?: boolean; initialEntries?: string[] } = {},
|
||||
): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
tracker?: { pathname: string; search: string };
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
let tracker: { pathname: string; search: string } | undefined;
|
||||
const track = (pathname: string, search: string) => {
|
||||
if (!tracker) tracker = { pathname, search };
|
||||
tracker.pathname = pathname;
|
||||
tracker.search = search;
|
||||
};
|
||||
|
||||
const inner = options.withRouter
|
||||
? React.createElement(
|
||||
MemoryRouter,
|
||||
{
|
||||
initialEntries: options.initialEntries ?? ["/reconciliation"],
|
||||
},
|
||||
React.createElement(LocationTracker, { on: track }),
|
||||
element,
|
||||
)
|
||||
: element;
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: qc },
|
||||
element
|
||||
inner
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -61,6 +107,7 @@ function renderIntoContainer(element: React.ReactElement): {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
tracker,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +141,33 @@ describe("ReconciliationPage", () => {
|
||||
(
|
||||
api.getRemittance as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(new Promise(() => {}));
|
||||
// SP21 Phase 5 Task 5.5: reset window.location to a clean
|
||||
// /reconciliation URL so `useRemitDrawerUrlState`'s mount-time
|
||||
// read doesn't see a `?remit=REM-DRILL` from the previous
|
||||
// test's `open()` history push. Without this, a test that
|
||||
// asserts the drawer isn't open still finds the drawer
|
||||
// mounted on initial render (driven by history state, not by
|
||||
// a click).
|
||||
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||
.happyDOM.setURL("http://localhost/reconciliation");
|
||||
// SP21 Phase 5 Task 5.5: the previous test may have left a Radix
|
||||
// portal in document.body. Clear them before each test so a
|
||||
// "drawer not in DOM" assertion isn't polluted by a stale portal.
|
||||
document.body
|
||||
.querySelectorAll('[role="dialog"]')
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// SP21 Phase 5 Task 5.5: the RemitDrawer portals into
|
||||
// document.body. Each test renders + unmounts, but happy-dom's
|
||||
// document.body persists across tests within the file, so we
|
||||
// explicitly remove any lingering Radix portals here. Without
|
||||
// this, a test that asserts `drawer not in DOM` would still
|
||||
// find a leftover portal from the previous test.
|
||||
document.body
|
||||
.querySelectorAll('[role="dialog"]')
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
|
||||
@@ -138,6 +212,7 @@ describe("ReconciliationPage", () => {
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true },
|
||||
);
|
||||
|
||||
// Wait for the loaded two-column view (the "Pair them." headline
|
||||
@@ -188,7 +263,8 @@ describe("ReconciliationPage", () => {
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage)
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true },
|
||||
);
|
||||
await waitForText("CLM-1");
|
||||
|
||||
@@ -206,7 +282,8 @@ describe("ReconciliationPage", () => {
|
||||
).mockResolvedValue({ claims: [], remittances: [] });
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage)
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true },
|
||||
);
|
||||
await waitForText("nothing pending");
|
||||
|
||||
@@ -216,4 +293,213 @@ describe("ReconciliationPage", () => {
|
||||
expect(document.body.textContent).not.toContain("Unmatched claims (");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("SP21 Task 5.5: clicking the claim card body drills to /claims?claim=ID", async () => {
|
||||
// Task 5.5 splits the gesture: clicking the card body drills into
|
||||
// the ClaimDrawer via /claims?claim=ID, while a separate
|
||||
// "Select for match" button toggles the row's selection. The
|
||||
// card is no longer a single <button>; the body region is a
|
||||
// button with its own onClick that calls navigate().
|
||||
(
|
||||
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
claims: [
|
||||
{
|
||||
id: "CLM-DRILL",
|
||||
patientName: "Patient Drill",
|
||||
billedAmount: 250,
|
||||
providerNpi: "1234567890",
|
||||
serviceDate: "2026-06-19",
|
||||
payerId: "P1",
|
||||
state: "submitted",
|
||||
},
|
||||
],
|
||||
remittances: [],
|
||||
});
|
||||
|
||||
const { unmount, tracker } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true, initialEntries: ["/reconciliation"] },
|
||||
);
|
||||
await waitForText("CLM-DRILL");
|
||||
|
||||
// Click the claim card body (the body region is the inner <button>
|
||||
// that contains the id). It's not the "Select for match" toggle
|
||||
// — the body region has aria-label="View claim CLM-DRILL in detail".
|
||||
const bodyBtn = document.body.querySelector(
|
||||
'button[aria-label="View claim CLM-DRILL in detail"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(bodyBtn).not.toBeNull();
|
||||
await act(async () => {
|
||||
bodyBtn!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(tracker?.pathname).toBe("/claims");
|
||||
expect(tracker?.search).toBe("?claim=CLM-DRILL");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("SP21 Task 5.5: clicking the 'Select for match' button toggles selection without drilling", async () => {
|
||||
// The select button calls e.stopPropagation() before setSelectedClaim,
|
||||
// so the row body click handler doesn't fire. Verify the URL stays
|
||||
// on /reconciliation and the "Match selected" button becomes enabled
|
||||
// (or at least that selection state advances).
|
||||
(
|
||||
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
claims: [
|
||||
{
|
||||
id: "CLM-SEL",
|
||||
patientName: "Patient Select",
|
||||
billedAmount: 175,
|
||||
providerNpi: "1234567890",
|
||||
serviceDate: "2026-06-19",
|
||||
payerId: "P1",
|
||||
state: "submitted",
|
||||
},
|
||||
],
|
||||
remittances: [
|
||||
{
|
||||
id: "REM-SEL",
|
||||
payerClaimControlNumber: "PCN-SEL",
|
||||
status: "received",
|
||||
paidAmount: 175,
|
||||
adjustmentAmount: 0,
|
||||
receivedDate: "2026-06-19",
|
||||
isReversal: false,
|
||||
totalCharge: 175,
|
||||
serviceDate: "2026-06-19",
|
||||
batchId: "b1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount, tracker } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true },
|
||||
);
|
||||
await waitForText("CLM-SEL");
|
||||
|
||||
// Click the claim "Select for match" toggle. It has
|
||||
// data-testid="recon-claim-select-CLM-SEL" and should NOT drill.
|
||||
const selectBtn = document.body.querySelector(
|
||||
'[data-testid="recon-claim-select-CLM-SEL"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(selectBtn).not.toBeNull();
|
||||
expect(selectBtn!.textContent).toContain("Select for match");
|
||||
await act(async () => {
|
||||
selectBtn!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// URL stays on /reconciliation — selection didn't drill.
|
||||
expect(tracker?.pathname).toBe("/reconciliation");
|
||||
// The toggle button now reads "Unselect" (clicked once).
|
||||
const updatedBtn = document.body.querySelector(
|
||||
'[data-testid="recon-claim-select-CLM-SEL"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(updatedBtn?.textContent).toContain("Unselect");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("SP21 Task 5.5: clicking the remit card body opens the RemitDrawer", async () => {
|
||||
// Task 5.5 also restructures the remits column — the body is no
|
||||
// longer role="button" with a select-onClick handler. The body
|
||||
// drills into the RemitDrawer via open(r.id); selection moves to
|
||||
// a dedicated "Select for match" toggle.
|
||||
(
|
||||
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
claims: [],
|
||||
remittances: [
|
||||
{
|
||||
id: "REM-DRILL",
|
||||
payerClaimControlNumber: "PCN-DRILL",
|
||||
status: "received",
|
||||
paidAmount: 100,
|
||||
adjustmentAmount: 0,
|
||||
receivedDate: "2026-06-19",
|
||||
isReversal: false,
|
||||
totalCharge: 100,
|
||||
serviceDate: "2026-06-19",
|
||||
batchId: "b1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true },
|
||||
);
|
||||
await waitForText("PCN-DRILL");
|
||||
|
||||
// Click the remit card body (inner <button> with aria-label).
|
||||
const bodyBtn = document.body.querySelector(
|
||||
'button[aria-label="View remittance PCN-DRILL in detail"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(bodyBtn).not.toBeNull();
|
||||
await act(async () => {
|
||||
bodyBtn!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// RemitDrawer should be mounted in document.body.
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||
).not.toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("SP21 Task 5.5: clicking the remit 'Select for match' button toggles selection without opening drawer", async () => {
|
||||
// The remit select toggle has e.stopPropagation() — clicking it
|
||||
// shouldn't open the RemitDrawer (which used to be a side effect
|
||||
// when the outer card was role="button" with onClick=select).
|
||||
(
|
||||
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
claims: [],
|
||||
remittances: [
|
||||
{
|
||||
id: "REM-SEL",
|
||||
payerClaimControlNumber: "PCN-SEL2",
|
||||
status: "received",
|
||||
paidAmount: 100,
|
||||
adjustmentAmount: 0,
|
||||
receivedDate: "2026-06-19",
|
||||
isReversal: false,
|
||||
totalCharge: 100,
|
||||
serviceDate: "2026-06-19",
|
||||
batchId: "b1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true },
|
||||
);
|
||||
await waitForText("PCN-SEL2");
|
||||
|
||||
const selectBtn = document.body.querySelector(
|
||||
'[data-testid="recon-remit-select-REM-SEL"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(selectBtn).not.toBeNull();
|
||||
expect(selectBtn!.textContent).toContain("Select for match");
|
||||
await act(async () => {
|
||||
selectBtn!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// No drawer.
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||
).toBeNull();
|
||||
// Toggle flipped.
|
||||
const updated = document.body.querySelector(
|
||||
'[data-testid="recon-remit-select-REM-SEL"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(updated?.textContent).toContain("Unselect");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user