diff --git a/src/pages/Reconciliation.test.tsx b/src/pages/Reconciliation.test.tsx index 0c6e12a..e1c1667 100644 --- a/src/pages/Reconciliation.test.tsx +++ b/src/pages/Reconciliation.test.tsx @@ -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 ).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 + {/* Selection toggle — separate gesture so a drill + doesn't auto-select the row for matching. */} + + {active ? ( + <> + + Unselect + + ) : ( + <>Select for match + )} + + ); })} @@ -743,18 +798,13 @@ export function ReconciliationPage() { {remittances.map((r) => { const active = selectedRemit === r.id; return ( + // SP21 Phase 5 Task 5.5: card body drills into the + // RemitDrawer; selection for matching lives on a + // dedicated button. Same gesture-split as the + // claims column — body = drill, button = select.
setSelectedRemit(r.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setSelectedRemit(r.id); - } - }} + data-testid="recon-remit-card" className={cn( "w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors", active @@ -762,41 +812,68 @@ export function ReconciliationPage() { : "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]" )} > -
; no nested DrillableCell (a + {/* Selection toggle — separate gesture from + drill. Same pattern as the claims column. */} +
+ {active ? ( + <> + + Unselect + + ) : ( + <>Select for match + )} +
); })}