// @vitest-environment happy-dom // React's act-aware env — mirror the other hook tests. (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 { beforeEach, describe, expect, it } from "vitest"; import { useMergedTail } from "./useMergedTail"; import { useTailStore } from "@/store/tail-store"; import type { Ack, Claim, ClaimAck, Remittance, Activity, Ta1Ack, } from "@/types"; /** * Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`, * etc. — the project doesn't ship `@testing-library/react`, so we wire a * Probe component into a real `createRoot` and read the hook's return * value through a shared `result` object. `act` flushes React's state * updates between micro-tasks. */ function renderHook(setup: () => TResult): { result: { current: TResult | undefined }; unmount: () => void; } { const result: { current: TResult | undefined } = { current: undefined }; const container = document.createElement("div"); document.body.appendChild(container); function Probe() { result.current = setup(); return null; } const root: Root = createRoot(container); act(() => { root.render(React.createElement(Probe)); }); return { result, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } // --------------------------------------------------------------------------- // Sample factories. The hook is generic over `T extends { id: string }`, // so for `claims` we use Claim, for `remittances` we use Remittance, etc. // Tests populate the store via `useTailStore.getState().addX(...)` — the // same path the live-tail hook uses — so the merge is exercised against // the real store shape (including the `claimOrder`/`remitOrder` indexing // the store maintains for the keyed-by-id slices). // --------------------------------------------------------------------------- function claim(id: string, patientName: string): Claim { return { id, patientName, providerNpi: "1234567890", payerName: "Medicaid", cptCode: "99213", billedAmount: 100, receivedAmount: 0, status: "submitted", submissionDate: "2026-06-20T00:00:00Z", }; } function remit(id: string, claimId: string): Remittance { return { id, claimId, payerName: "Medicaid", paidAmount: 100, adjustmentAmount: 0, receivedDate: "2026-06-20", checkNumber: id, status: "received", }; } function activity(id: string, message: string): Activity { return { id, kind: "claim_submitted", message, timestamp: "2026-06-20T00:00:00Z", }; } function ack(id: number, sourceBatchId: string): Ack { return { id, sourceBatchId, acceptedCount: 1, rejectedCount: 0, receivedCount: 1, ackCode: "A", parsedAt: "2026-07-02T12:00:00Z", patientControlNumber: `PCN-${id}`, }; } function ta1Ack(id: number, controlNumber: string): Ta1Ack { return { id, controlNumber, ackCode: "A", noteCode: "000", interchangeDate: "2026-07-02", interchangeTime: "1200", senderId: "S", receiverId: "R", sourceBatchId: `TA1-${id}`, parsedAt: "2026-07-02T12:00:00Z", }; } function claimAck(id: number, claimId: string): ClaimAck { return { id, claimId, batchId: null, ackId: 99, ackKind: "999", ak2Index: 0, setControlNumber: "991102989", setAcceptRejectCode: "A", linkedAt: "2026-07-02T12:00:00Z", linkedBy: "auto", claimState: "received", }; } describe("useMergedTail", () => { beforeEach(() => { // Singleton store — clear each slice between tests so cases are // independent. Mirrors the pattern in `tail-store.test.ts`. useTailStore.getState().reset("claims"); useTailStore.getState().reset("remittances"); useTailStore.getState().reset("activity"); useTailStore.getState().reset("acks"); useTailStore.getState().reset("ta1_acks"); useTailStore.getState().reset("claim-acks"); }); it("test_merges_base_and_tail_by_id_base_first", () => { // Base: [A, B]. Tail store adds [B, C, D] — note B is a duplicate // of a base item (mirrors what a snapshot replay on reconnect would // produce). After dedup, tail should contribute only [C, D]. const base = [claim("A", "Alice"), claim("B", "Bob")]; const { addClaim } = useTailStore.getState(); addClaim(claim("B", "Bob-Updated")); // deduped — first write wins addClaim(claim("C", "Carol")); addClaim(claim("D", "Dave")); const { result, unmount } = renderHook(() => useMergedTail("claims", base), ); const merged = result.current ?? []; // Base items first, in their original order; then tail in arrival // order, with duplicates of base ids dropped. expect(merged.map((c) => c.id)).toEqual(["A", "B", "C", "D"]); // The base version of B must be preserved (the store's first-write- // wins rule keeps "Bob", not "Bob-Updated"). expect(merged[1]?.patientName).toBe("Bob"); unmount(); }); it("test_filter_predicate_drops_tail_items_that_dont_match", () => { // Base: [A]. Tail store: [B, C, D]. Predicate `id !== "D"` drops // the trailing item — the filter must run AFTER dedup and only // affect the tail contribution. const base = [claim("A", "Alice")]; const { addClaim } = useTailStore.getState(); addClaim(claim("B", "Bob")); addClaim(claim("C", "Carol")); addClaim(claim("D", "Dave")); const { result, unmount } = renderHook(() => useMergedTail("claims", base, (c) => c.id !== "D"), ); const merged = result.current ?? []; expect(merged.map((c) => c.id)).toEqual(["A", "B", "C"]); unmount(); }); it("test_empty_tail_store_returns_base_unchanged", () => { const base = [claim("A", "Alice"), claim("B", "Bob")]; const { result, unmount } = renderHook(() => useMergedTail("claims", base), ); const merged = result.current ?? []; expect(merged).toHaveLength(2); expect(merged.map((c) => c.id)).toEqual(["A", "B"]); // The hook returns base verbatim when the tail slice is empty — // no copy churn, so `toBe` reference equality holds. expect(merged[0]).toBe(base[0]); expect(merged[1]).toBe(base[1]); unmount(); }); it("test_new_tail_item_appears_after_base", async () => { // Base: [A, B]. Tail store empty initially. After the component // mounts, we push a new claim to the store; the merged list must // include the new tail item, surfaced via re-render. (Plan calls // this "appears_at_top" — in this hook's contract the new item // comes AFTER base items, not before, so the assertion is that it // appears at all and is positioned correctly.) const base = [claim("A", "Alice"), claim("B", "Bob")]; const { result, unmount } = renderHook(() => useMergedTail("claims", base), ); expect(result.current?.map((c) => c.id)).toEqual(["A", "B"]); // New item lands in the store → zustand notifies subscribers → the // hook re-renders with the merged result. await act(async () => { useTailStore.getState().addClaim(claim("C", "Carol")); }); const merged = result.current ?? []; // Base first, then tail in arrival order. expect(merged.map((c) => c.id)).toEqual(["A", "B", "C"]); expect(merged[2]?.patientName).toBe("Carol"); unmount(); }); it("test_activity_resource_uses_array_slice_and_dedupes_by_id", () => { // Activity doesn't have a keyed-by-id map — it's a plain array. // The hook must still dedup against baseItems by id (Activity has // an id, even though the store's addActivity doesn't dedup). const base = [activity("A", "alpha"), activity("B", "bravo")]; const { addActivity } = useTailStore.getState(); addActivity(activity("B", "bravo-dup")); // duplicate id addActivity(activity("C", "charlie")); addActivity(activity("D", "delta")); const { result, unmount } = renderHook(() => useMergedTail("activity", base), ); const merged = result.current ?? []; // Base first, then tail in arrival order. Tail's "B" is dropped // because it's a duplicate of base's "B". expect(merged.map((a) => a.id)).toEqual(["A", "B", "C", "D"]); unmount(); }); it("test_remittance_resource_orders_by_remitOrder", () => { // Smoke test for the remittances slice — exercise the second keyed // slice so we know the claim/remit branches are symmetric. const base = [remit("R-1", "CLM-1")]; const { addRemittance } = useTailStore.getState(); addRemittance(remit("R-2", "CLM-2")); addRemittance(remit("R-3", "CLM-3")); const { result, unmount } = renderHook(() => useMergedTail("remittances", base), ); const merged = result.current ?? []; expect(merged.map((r) => r.id)).toEqual(["R-1", "R-2", "R-3"]); unmount(); }); // ------------------------------------------------------------------------- // SP25: the Acks page mounts two merge hooks — one per ack flavor. // Both have numeric ids (database row ids) instead of string ids, so // the dedup must compare via String(id) to keep the keyed-by-id // contract intact. // ------------------------------------------------------------------------- it("test_acks_resource_orders_by_ack_order_and_dedupes", () => { const base = [ack(1, "BASE-A"), ack(2, "BASE-B")]; const { addAck } = useTailStore.getState(); addAck(ack(2, "REPLAY-B")); // duplicate id — must be dropped addAck(ack(3, "TAIL-C")); addAck(ack(4, "TAIL-D")); const { result, unmount } = renderHook(() => useMergedTail("acks", base), ); const merged = result.current ?? []; // Base first, then tail in arrival order. Tail's "2" is dropped // because the base already has id 2. expect(merged.map((a) => a.id)).toEqual([1, 2, 3, 4]); // Base version wins for id 2. expect(merged[1]?.sourceBatchId).toBe("BASE-B"); unmount(); }); it("test_ta1_acks_resource_orders_by_ta1_ack_order_and_dedupes", () => { const base = [ta1Ack(1, "000000001")]; const { addTa1Ack } = useTailStore.getState(); addTa1Ack(ta1Ack(2, "000000002")); addTa1Ack(ta1Ack(3, "000000003")); const { result, unmount } = renderHook(() => useMergedTail("ta1_acks", base), ); const merged = result.current ?? []; expect(merged.map((a) => a.id)).toEqual([1, 2, 3]); expect(merged[0]?.controlNumber).toBe("000000001"); expect(merged[1]?.controlNumber).toBe("000000002"); unmount(); }); // ------------------------------------------------------------------------- // SP28: the claim↔ack link slice. Same keyed-by-id shape as the SP25 // acks/ta1_acks branches — numeric id, ordered arrival via // `claimAckOrder`, deduped against baseItems by `String(id)`. The // ClaimDrawer and AckDrawer both mount this slice and apply a // page-specific filter (e.g. `link.claimId === claimId`) so the merge // shape must remain symmetric with the existing branches. // ------------------------------------------------------------------------- it("test_claim_acks_resource_orders_by_claim_ack_order_and_dedupes", () => { const base = [claimAck(1, "CLM-A"), claimAck(2, "CLM-A")]; const { addClaimAck } = useTailStore.getState(); addClaimAck(claimAck(2, "CLM-A-replay")); // duplicate id — must be dropped addClaimAck(claimAck(3, "CLM-B")); addClaimAck(claimAck(4, "CLM-B")); const { result, unmount } = renderHook(() => useMergedTail("claim-acks", base), ); const merged = result.current ?? []; // Base first, then tail in arrival order. Tail's "2" is dropped // because the base already has id 2. expect(merged.map((a) => a.id)).toEqual([1, 2, 3, 4]); // Base version wins for id 2. expect(merged[1]?.claimId).toBe("CLM-A"); unmount(); }); it("test_claim_acks_filter_predicate_drops_tail_items_that_dont_match", () => { // The ClaimDrawer panel mounts a filter `link.claimId === claimId` // so only this claim's acks show up. Verify the merge keeps the // filter AFTER dedup against baseItems (mirrors the SP25 claim // branch shape). const base = [claimAck(1, "CLM-A")]; const { addClaimAck } = useTailStore.getState(); addClaimAck(claimAck(2, "CLM-A")); addClaimAck(claimAck(3, "CLM-B")); addClaimAck(claimAck(4, "CLM-A")); const { result, unmount } = renderHook(() => useMergedTail( "claim-acks", base, (link) => link.claimId === "CLM-A", ), ); const merged = result.current ?? []; // Filter only applies to tail (base is kept verbatim by contract); // tail's id 3 (CLM-B) is filtered out, leaving 2 and 4 in // arrival order after the base 1. expect(merged.map((a) => a.id)).toEqual([1, 2, 4]); unmount(); }); });