diff --git a/src/hooks/useMergedTail.test.ts b/src/hooks/useMergedTail.test.ts index 82d025b..42e0a2a 100644 --- a/src/hooks/useMergedTail.test.ts +++ b/src/hooks/useMergedTail.test.ts @@ -7,7 +7,7 @@ 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 { Claim, Remittance, Activity } from "@/types"; +import type { Ack, Claim, Remittance, Activity, Ta1Ack } from "@/types"; /** * Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`, @@ -88,6 +88,34 @@ function activity(id: string, message: string): Activity { }; } +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", + }; +} + describe("useMergedTail", () => { beforeEach(() => { // Singleton store — clear each slice between tests so cases are @@ -95,6 +123,8 @@ describe("useMergedTail", () => { useTailStore.getState().reset("claims"); useTailStore.getState().reset("remittances"); useTailStore.getState().reset("activity"); + useTailStore.getState().reset("acks"); + useTailStore.getState().reset("ta1_acks"); }); it("test_merges_base_and_tail_by_id_base_first", () => { @@ -228,4 +258,50 @@ describe("useMergedTail", () => { 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(); + }); }); diff --git a/src/hooks/useMergedTail.ts b/src/hooks/useMergedTail.ts index b1dca07..e49c825 100644 --- a/src/hooks/useMergedTail.ts +++ b/src/hooks/useMergedTail.ts @@ -17,12 +17,15 @@ // every render of the caller, so memo deps would invalidate anyway. // The merge is cheap (one Set build + two filters) and zustand's // selector ensures we only re-render when the slice actually changes. +// - SP25: the `id` type is widened to `string | number` because `Ack` +// and `Ta1Ack` use numeric database ids while `Claim` / `Remittance` +// use string ids. Dedup normalizes via `String(...)`. // --------------------------------------------------------------------------- import { useTailStore } from "@/store/tail-store"; import type { TailResource } from "@/lib/tail-stream"; -export function useMergedTail( +export function useMergedTail( resource: TailResource, baseItems: T[], filterFn?: (item: T) => boolean, @@ -56,15 +59,39 @@ export function useMergedTail( // Activity is already an array — the store appends in arrival // order, so iteration order is exactly what we want. return s.activity as unknown[]; + // SP25: same keyed-by-id shape as claims/remittances, just with + // numeric ids. The store keys the dict by String(id); the order + // array carries the numeric id for arrival-order iteration. + case "acks": { + const out: unknown[] = []; + for (const id of s.ackOrder) { + const v = s.acks[String(id)]; + if (v !== undefined) out.push(v); + } + return out; + } + case "ta1_acks": { + const out: unknown[] = []; + for (const id of s.ta1AckOrder) { + const v = s.ta1Acks[String(id)]; + if (v !== undefined) out.push(v); + } + return out; + } } }); + // Normalize ids to strings so numeric (Ack/Ta1Ack) and string + // (Claim/Remittance/Activity) ids dedup correctly against each other. + // (The hook is generic over `T extends { id: string | number }`, but + // the store's dedup set is a Set internally — using the + // string form here keeps the comparison symmetric.) const baseIds = new Set(); - for (const b of baseItems) baseIds.add(b.id); + for (const b of baseItems) baseIds.add(String(b.id)); const tailAfterDedup: unknown[] = []; for (const t of tailSlice) { - const id = (t as { id: string }).id; + const id = String((t as { id: string | number }).id); if (baseIds.has(id)) continue; tailAfterDedup.push(t); }