feat(sp28): AckDrawer matched-claim panel + manual-match dropdown

This commit is contained in:
Nora
2026-07-02 11:39:02 -06:00
parent 490a1b9478
commit 602dc352c5
4 changed files with 695 additions and 0 deletions
+266
View File
@@ -22,6 +22,84 @@ vi.mock("@/hooks/useAckDetail", () => ({
useAckDetail,
}));
// SP28: the new <MatchedClaim /> panel mounts three hooks (data,
// live-tail, live-tail merge) and calls `useNavigate` to navigate to
// /claims?claim=<id> when the operator clicks a claim card's
// "open claim" button. Mock each one at the module boundary so the
// test never opens a real fetch or a real navigation. The hook
// mocks return "empty" by default so the panel renders the
// "no claim links yet" empty state — tests that need to populate
// the panel override via `setAckClaimsRows(...)`.
const {
useAckClaims,
useTailStream,
useMergedTail,
useNavigate,
matchAckToClaim,
unmatchAck,
listClaims,
} = vi.hoisted(() => ({
useAckClaims: vi.fn(),
useTailStream: vi.fn(),
useMergedTail: vi.fn(),
useNavigate: vi.fn(),
matchAckToClaim: vi.fn(),
unmatchAck: vi.fn(),
listClaims: vi.fn(),
}));
vi.mock("@/hooks/useAckClaims", () => ({ useAckClaims }));
vi.mock("@/hooks/useTailStream", () => ({ useTailStream }));
vi.mock("@/hooks/useMergedTail", () => ({ useMergedTail }));
vi.mock("@/lib/api", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
return {
...actual,
matchAckToClaim,
unmatchAck,
listClaims,
};
});
vi.mock("react-router-dom", async () => {
const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...actual,
useNavigate,
};
});
/**
* Configure the mocked `useAckClaims` + `useMergedTail` hooks for a
* single test. The `refetch` default is a fresh `vi.fn()` — tests
* that need to assert on it can override via `overrides.refetch`.
*/
function setAckClaimsRows(
rows: Array<{
id: number;
claimId: string | null;
batchId: string | null;
ackId: number;
ackKind: "999" | "277ca" | "ta1";
ak2Index: number | null;
setControlNumber: string | null;
setAcceptRejectCode: string | null;
linkedAt: string;
claimState?: string;
}>,
) {
useAckClaims.mockReturnValue({
data: rows,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useMergedTail.mockReturnValue(rows);
}
/**
* Minimal valid `Ack` fixture — every required key present so the
* component typechecks. The wire shape extends with `raw_999_text`
@@ -68,6 +146,38 @@ function mockDetail(
afterEach(() => {
cleanup();
vi.clearAllMocks();
// Default SP28 mocks — empty list, no errors, no-op navigate. Tests
// that exercise the MatchedClaim panel override via
// `setAckClaimsRows(...)`.
useAckClaims.mockReturnValue({
data: [],
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useTailStream.mockReturnValue({
status: "live",
lastEventAt: null,
error: null,
forceReconnect: vi.fn(),
});
useMergedTail.mockImplementation(
(_resource: unknown, baseItems: unknown) => baseItems,
);
useNavigate.mockReturnValue(vi.fn());
matchAckToClaim.mockResolvedValue({
link_id: 1,
claim_id: "CLM-1",
ack_id: 42,
ack_kind: "999",
set_control_number: "991102989",
set_accept_reject_code: "A",
linked_at: "2026-07-02T12:00:00Z",
linked_by: "manual",
});
unmatchAck.mockResolvedValue(undefined);
listClaims.mockResolvedValue({ items: [], total: 0, returned: 0, has_more: false });
});
describe("AckDrawer", () => {
@@ -189,4 +299,160 @@ describe("AckDrawer", () => {
// No raw_999_text → no Download button.
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
});
// -------------------------------------------------------------------------
// SP28: the new <MatchedClaim /> panel.
// Tests assert:
// - panel renders when `useAckClaims` returns a non-empty list
// - panel renders empty state with "Link to claim…" dropdown when
// the list is empty (default)
// - the "open claim" button navigates to /claims?claim=<id>
// - the panel mounts the live-tail subscription via
// `useTailStream("claim-acks")`
// - TA1 batch-level rows (claimId === null) render with the
// originating Batch id, not a claim card
// -------------------------------------------------------------------------
it("test_matched_claim_panel_renders_empty_state_when_no_links", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The panel must be present.
expect(document.querySelector('[data-testid="matched-claim-panel"]'))
.not.toBeNull();
// The empty-state copy must surface.
expect(document.querySelector('[data-testid="matched-claim-empty"]'))
.not.toBeNull();
// The "Link to claim…" dropdown is the manual-match affordance
// (per D5/D9 — any logged-in user can run it).
expect(document.querySelector('[data-testid="link-to-claim-dropdown"]'))
.not.toBeNull();
expect(document.querySelector('[data-testid="link-to-claim-toggle"]'))
.not.toBeNull();
// No row rendered.
expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull();
});
it("test_matched_claim_panel_renders_card_per_claim_link", () => {
setAckClaimsRows([
{
id: 1,
claimId: "CLM-1",
batchId: null,
ackId: 42,
ackKind: "999",
ak2Index: 0,
setControlNumber: "991102989",
setAcceptRejectCode: "A",
linkedAt: "2026-07-02T12:00:00Z",
claimState: "submitted",
},
{
id: 2,
claimId: "CLM-2",
batchId: null,
ackId: 42,
ackKind: "999",
ak2Index: 1,
setControlNumber: "991102990",
setAcceptRejectCode: "R",
linkedAt: "2026-07-02T12:00:01Z",
claimState: "denied",
},
]);
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// Per-AK2 granularity (D1): one card per link row.
const cards = document.querySelectorAll('[data-testid="matched-claim-card"]');
expect(cards.length).toBe(2);
// The header count must reflect the row count.
expect(document.querySelector('[data-testid="matched-claim-count"]')
?.textContent).toContain("(2)");
// Each card renders its claim id.
const ids = document.querySelectorAll('[data-testid="matched-claim-id"]');
expect(ids[0]?.textContent).toContain("CLM-1");
expect(ids[1]?.textContent).toContain("CLM-2");
});
it("test_matched_claim_panel_renders_ta1_batch_row_with_originating_batch", () => {
// TA1 batch-level rows have claimId === null. The panel renders
// them with the originating Batch id, not a claim card (D4).
setAckClaimsRows([
{
id: 1,
claimId: null,
batchId: "B-TA1-1",
ackId: 42,
ackKind: "ta1",
ak2Index: null,
setControlNumber: null,
setAcceptRejectCode: null,
linkedAt: "2026-07-02T12:00:00Z",
claimState: "n/a",
},
]);
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The TA1 row renders as a Batch card, not a claim card.
expect(document.querySelector('[data-testid="matched-claim-card-ta1"]'))
.not.toBeNull();
expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull();
// The originating batch id is surfaced verbatim.
expect(document.querySelector('[data-testid="matched-claim-batch-id"]')
?.textContent).toContain("B-TA1-1");
});
it("test_matched_claim_panel_open_claim_button_navigates_to_claims_with_claim_id", () => {
setAckClaimsRows([
{
id: 1,
claimId: "CLM-1",
batchId: null,
ackId: 42,
ackKind: "999",
ak2Index: 0,
setControlNumber: "991102989",
setAcceptRejectCode: "A",
linkedAt: "2026-07-02T12:00:00Z",
claimState: "submitted",
},
]);
const navigate = vi.fn();
useNavigate.mockReturnValue(navigate);
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
const openBtn = document.querySelector(
'[data-testid="open-claim-drawer"]',
) as HTMLButtonElement | null;
expect(openBtn).not.toBeNull();
openBtn!.click();
// Cross-page navigation — the ClaimDrawer is mounted on /claims.
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/claims?claim=CLM-1");
});
it("test_matched_claim_panel_mounts_live_tail_subscription", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The panel opens one claim-acks live-tail stream. Per
// cyclone-frontend-page convention #3, the subscription lives on
// the drawer, not on the data hook.
expect(useTailStream).toHaveBeenCalledWith("claim-acks");
});
it("test_matched_claim_panel_calls_useAckClaims_with_kind_and_ack_id", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The panel must pass (kind="999", ackId=42) to useAckClaims so
// the hook builds the right query key.
expect(useAckClaims).toHaveBeenCalledWith("999", 42);
});
});