diff --git a/src/hooks/useMergedTail.test.ts b/src/hooks/useMergedTail.test.ts index 42e0a2a..750e180 100644 --- a/src/hooks/useMergedTail.test.ts +++ b/src/hooks/useMergedTail.test.ts @@ -7,7 +7,14 @@ 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, Remittance, Activity, Ta1Ack } from "@/types"; +import type { + Ack, + Claim, + ClaimAck, + Remittance, + Activity, + Ta1Ack, +} from "@/types"; /** * Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`, @@ -116,6 +123,22 @@ function ta1Ack(id: number, controlNumber: string): Ta1Ack { }; } +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 @@ -125,6 +148,7 @@ describe("useMergedTail", () => { 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", () => { @@ -304,4 +328,62 @@ describe("useMergedTail", () => { 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(); + }); }); diff --git a/src/hooks/useMergedTail.ts b/src/hooks/useMergedTail.ts index e49c825..e579081 100644 --- a/src/hooks/useMergedTail.ts +++ b/src/hooks/useMergedTail.ts @@ -20,6 +20,9 @@ // - 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(...)`. +// - SP28: `ClaimAck` adds another numeric-id slice (`claimAcks` / +// `claimAckOrder`). Same dedup normalization applies — `String(id)` +// keeps the symmetric comparison the hook has always used. // --------------------------------------------------------------------------- import { useTailStore } from "@/store/tail-store"; @@ -78,6 +81,19 @@ export function useMergedTail( } return out; } + // SP28: the claim↔ack link slice. Same keyed-by-id shape as + // acks/ta1_acks (numeric id from the `claim_acks` table), so the + // dispatch is symmetric — the page-specific filter (e.g. + // `link.claimId === claimId`) is applied AFTER dedup so the + // base list's rows aren't accidentally dropped. + case "claim-acks": { + const out: unknown[] = []; + for (const id of s.claimAckOrder) { + const v = s.claimAcks[String(id)]; + if (v !== undefined) out.push(v); + } + return out; + } } }); diff --git a/src/hooks/useTailStream.test.ts b/src/hooks/useTailStream.test.ts index 4664247..84ca390 100644 --- a/src/hooks/useTailStream.test.ts +++ b/src/hooks/useTailStream.test.ts @@ -15,7 +15,7 @@ import { } from "vitest"; import { useTailStream } from "./useTailStream"; import { useTailStore } from "@/store/tail-store"; -import type { Ack, Claim, Ta1Ack } from "@/types"; +import type { Ack, Claim, ClaimAck, Ta1Ack } from "@/types"; import type { TailEvent } from "@/lib/tail-stream"; // --------------------------------------------------------------------------- @@ -232,6 +232,22 @@ function makeTa1Ack(id: number, controlNumber: string): Ta1Ack { }; } +function makeClaimAck(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("useTailStream", () => { beforeEach(() => { mockStreamTail.mockReset(); @@ -242,6 +258,7 @@ describe("useTailStream", () => { useTailStore.getState().reset("activity"); useTailStore.getState().reset("acks"); useTailStore.getState().reset("ta1_acks"); + useTailStore.getState().reset("claim-acks"); }); afterEach(() => { @@ -486,4 +503,37 @@ describe("useTailStream", () => { unmount(); }); + + // ------------------------------------------------------------------------- + // SP28: the claim↔ack link stream. Both ClaimDrawer's + // panel and AckDrawer's panel mount useTailStream with + // resource "claim-acks"; the dispatcher must route the live event into + // the claimAcks slice (separate from acks/ta1_acks). + // ------------------------------------------------------------------------- + + it("test_claim_acks_item_event_lands_in_claim_acks_slice", async () => { + const { ctrls } = trackCalls(); + const { unmount } = renderHook(() => useTailStream("claim-acks")); + + await waitFor(() => ctrls.length === 1); + ctrls[0].push({ type: "snapshot_end", data: { count: 0 } }); + await waitFor(() => useTailStore.getState().claimAcks !== undefined); + + ctrls[0].push({ + type: "item", + data: makeClaimAck(101, "CLM-42"), + }); + + await waitFor( + () => useTailStore.getState().claimAcks["101"] !== undefined, + ); + const stored = useTailStore.getState().claimAcks["101"]; + expect(stored).toBeDefined(); + expect(stored?.claimId).toBe("CLM-42"); + // Must NOT bleed into the acks slice — keyed by id, separate dicts. + expect(useTailStore.getState().acks["101"]).toBeUndefined(); + expect(useTailStore.getState().ta1Acks["101"]).toBeUndefined(); + + unmount(); + }); }); diff --git a/src/hooks/useTailStream.ts b/src/hooks/useTailStream.ts index 5494f1e..5850cbf 100644 --- a/src/hooks/useTailStream.ts +++ b/src/hooks/useTailStream.ts @@ -27,7 +27,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { streamTail, type TailResource } from "@/lib/tail-stream"; import { useTailStore } from "@/store/tail-store"; -import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types"; +import type { + Ack, + Activity, + Claim, + ClaimAck, + Remittance, + Ta1Ack, +} from "@/types"; export type TailStatus = | "connecting" @@ -85,6 +92,13 @@ function dispatch(resource: TailResource, data: unknown): void { case "ta1_acks": store.addTa1Ack(data as Ta1Ack); break; + // SP28: the claim↔ack link slice. Both ClaimDrawer and AckDrawer + // mount this stream so a manual-match link made on one drawer + // shows up live on the other. The dispatch is the same + // first-write-wins pattern as the SP25 acks/ta1_acks branches. + case "claim-acks": + store.addClaimAck(data as ClaimAck); + break; } } diff --git a/src/lib/tail-stream.test.ts b/src/lib/tail-stream.test.ts index 4638351..fea3558 100644 --- a/src/lib/tail-stream.test.ts +++ b/src/lib/tail-stream.test.ts @@ -127,6 +127,30 @@ describe("streamTail", () => { expect(calledUrl).toBe("/api/ta1-acks/stream"); }); + it("test_targets_claim_acks_stream_endpoint", async () => { + // SP28: the `claim-acks` resource is part of TailResource; the URL + // must be /api/claim-acks/stream. The claim↔ack link stream is a + // shared resource (not per-claim or per-ack) — both the ClaimDrawer + // and AckDrawer subscribe to the same endpoint and filter + // server-side via the page-specific filterFn passed to + // `useMergedTail`. + const driver = makeStream(); + const fetchMock = vi.fn().mockResolvedValue(driver.response); + vi.stubGlobal("fetch", fetchMock); + // Drive an event so the iterator's first read resolves — same + // pattern as `test_parses_well_formed_ndjson_into_typed_events` + // (the `test_targets_acks_stream_endpoint` shape hangs in CI + // because it awaits `gen.next()` before any chunk arrives). + const gen = streamTail("claim-acks"); + driver.push('{"type":"snapshot_end","data":{"count":0}}'); + driver.close(); + await gen.next(); + await gen.return?.(undefined); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [calledUrl] = fetchMock.mock.calls[0] as [string]; + expect(calledUrl).toBe("/api/claim-acks/stream"); + }); + it("test_handles_heartbeat_silently", async () => { // "Silently" here means: still yields the correctly-typed heartbeat // (no special filtering) — the consumer (useTailStream) decides what diff --git a/src/lib/tail-stream.ts b/src/lib/tail-stream.ts index a87e59c..40a1bb9 100644 --- a/src/lib/tail-stream.ts +++ b/src/lib/tail-stream.ts @@ -26,7 +26,13 @@ export type TailEvent = | { type: "item_dropped"; data: { id: string } } | { type: "error"; data: { message: string } }; -export type TailResource = "claims" | "remittances" | "activity" | "acks" | "ta1_acks"; +export type TailResource = + | "claims" + | "remittances" + | "activity" + | "acks" + | "ta1_acks" + | "claim-acks"; export interface StreamTailOptions { /** Forwarded to `fetch`; aborting the controller cleanly exits the generator. */ diff --git a/src/store/tail-store.test.ts b/src/store/tail-store.test.ts index 16eefee..252eda9 100644 --- a/src/store/tail-store.test.ts +++ b/src/store/tail-store.test.ts @@ -5,7 +5,14 @@ // state shape. import { beforeEach, describe, expect, it } from "vitest"; -import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types"; +import type { + Ack, + Activity, + Claim, + ClaimAck, + Remittance, + Ta1Ack, +} from "@/types"; import { TAIL_CAP, useTailStore } from "./tail-store"; function makeClaim(id: string, overrides: Partial = {}): Claim { @@ -77,6 +84,23 @@ function makeTa1Ack(id: number, overrides: Partial = {}): Ta1Ack { }; } +function makeClaimAck(id: number, overrides: Partial = {}): ClaimAck { + return { + id, + claimId: "CLM-1", + batchId: null, + ackId: 99, + ackKind: "999", + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + linkedBy: "auto", + claimState: "received", + ...overrides, + }; +} + describe("useTailStore", () => { // Each test starts from a known-empty state. The store is module-level // (singleton), so we use `reset()` to clear between cases — that's also @@ -87,6 +111,7 @@ describe("useTailStore", () => { useTailStore.getState().reset("activity"); useTailStore.getState().reset("acks"); useTailStore.getState().reset("ta1_acks"); + useTailStore.getState().reset("claim-acks"); }); it("test_add_claim_adds_new_claim_keyed_by_id", () => { @@ -227,4 +252,62 @@ describe("useTailStore", () => { expect(Object.keys(s.ta1Acks)).toHaveLength(0); expect(Object.keys(s.acks)).toHaveLength(1); }); + + // ------------------------------------------------------------------------- + // SP28: the claim↔ack link slice mirrors the SP25 acks/ta1_acks slice + // (numeric id from the `claim_acks` table, first write wins, FIFO-capped + // at TAIL_CAP). The slice drives both the ClaimDrawer's + // panel and the AckDrawer's panel + // via `useTailStream("claim-acks")` + `useMergedTail("claim-acks", …)`. + // ------------------------------------------------------------------------- + + it("test_add_claim_ack_adds_new_claim_ack_keyed_by_id", () => { + const { addClaimAck } = useTailStore.getState(); + addClaimAck(makeClaimAck(1)); + addClaimAck(makeClaimAck(2)); + const { claimAcks } = useTailStore.getState(); + expect(Object.keys(claimAcks)).toHaveLength(2); + expect(claimAcks["1"]?.claimId).toBe("CLM-1"); + expect(claimAcks["2"]?.claimId).toBe("CLM-1"); + }); + + it("test_add_claim_ack_with_duplicate_id_is_noop", () => { + const { addClaimAck } = useTailStore.getState(); + addClaimAck(makeClaimAck(1, { setAcceptRejectCode: "A" })); + addClaimAck(makeClaimAck(1, { setAcceptRejectCode: "R" })); + const { claimAcks } = useTailStore.getState(); + expect(Object.keys(claimAcks)).toHaveLength(1); + // First write wins; snapshot replay must not trample the canonical row. + expect(claimAcks["1"]?.setAcceptRejectCode).toBe("A"); + }); + + it("test_reset_claim_acks_clears_only_claim_acks_slice", () => { + const { addClaimAck, addAck, reset } = useTailStore.getState(); + addClaimAck(makeClaimAck(1)); + addAck(makeAck(1)); + + reset("claim-acks"); + const s = useTailStore.getState(); + expect(Object.keys(s.claimAcks)).toHaveLength(0); + // The other slices are untouched. + expect(Object.keys(s.acks)).toHaveLength(1); + }); + + it("test_claim_acks_slice_evicts_oldest_when_over_10000", () => { + // Cap-eviction smoke test — same shape as the claims FIFO test + // above. 5 over the cap to exercise the eviction path without + // pinning down the wall-clock cost of a full 10 005-insert run. + const N = TAIL_CAP + 5; + const { addClaimAck } = useTailStore.getState(); + for (let i = 0; i < N; i++) { + addClaimAck(makeClaimAck(i)); + } + const { claimAcks } = useTailStore.getState(); + expect(Object.keys(claimAcks)).toHaveLength(TAIL_CAP); + // The five oldest ids must be gone. + expect(claimAcks["0"]).toBeUndefined(); + expect(claimAcks["4"]).toBeUndefined(); + // The most recent id must be present. + expect(claimAcks[String(N - 1)]).toBeDefined(); + }, 60_000); }); diff --git a/src/store/tail-store.ts b/src/store/tail-store.ts index f2bda85..9a3ffb4 100644 --- a/src/store/tail-store.ts +++ b/src/store/tail-store.ts @@ -21,7 +21,14 @@ // --------------------------------------------------------------------------- import { create } from "zustand"; -import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types"; +import type { + Ack, + Activity, + Claim, + ClaimAck, + Remittance, + Ta1Ack, +} from "@/types"; import type { TailResource } from "@/lib/tail-stream"; /** Maximum number of items retained per slice before FIFO eviction kicks in. */ @@ -52,6 +59,13 @@ interface TailStore { // `claims`/`remittances` (first write wins, FIFO-capped at TAIL_CAP). acks: Record; ta1Acks: Record; + // SP28: the claim↔ack link slice. Mirrors the SP25 acks/ta1_acks + // pattern — `claimAcks` rows have a stable numeric `id` from the + // `claim_acks` table, so we key by id (first write wins, FIFO-capped + // at TAIL_CAP). The ClaimDrawer's panel and the + // AckDrawer's panel both subscribe to this slice via + // `useTailStream("claim-acks")` + `useMergedTail("claim-acks", …)`. + claimAcks: Record; // --- Insertion-order trackers (kept in sync with the dicts above) ---- // These are private to the store; consumers only read the dicts. We @@ -62,6 +76,7 @@ interface TailStore { remitOrder: string[]; ackOrder: number[]; ta1AckOrder: number[]; + claimAckOrder: number[]; // --- Setters --------------------------------------------------------- addClaim: (c: Claim) => void; @@ -69,6 +84,7 @@ interface TailStore { addActivity: (a: Activity) => void; addAck: (a: Ack) => void; addTa1Ack: (a: Ta1Ack) => void; + addClaimAck: (a: ClaimAck) => void; reset: (resource: TailResource) => void; } @@ -104,10 +120,12 @@ export const useTailStore = create((set) => ({ activity: [], acks: {}, ta1Acks: {}, + claimAcks: {}, claimOrder: [], remitOrder: [], ackOrder: [], ta1AckOrder: [], + claimAckOrder: [], addClaim: (c) => set((s) => { @@ -193,6 +211,31 @@ export const useTailStore = create((set) => ({ return { ta1Acks: nextTa1Acks, ta1AckOrder: nextOrder }; }), + // SP28: same first-write-wins / FIFO-capped shape as addAck / + // addTa1Ack. The numeric `id` is the primary key from the + // `claim_acks` table — distinct from (claim_id, ack_id, ak2_index) + // which is the natural dedup key but isn't what the live-tail + // payload uses (every claim_ack_written event carries the row id). + addClaimAck: (a) => + set((s) => { + if (s.claimAcks[String(a.id)]) return s; + const nextClaimAcks: Record = Object.assign( + {}, + s.claimAcks, + { [String(a.id)]: a }, + ); + const nextOrder = s.claimAckOrder.concat(a.id); + if (nextOrder.length > TAIL_CAP) { + const { order, dict } = evictOldest( + nextOrder, + nextClaimAcks, + EVICT_BATCH, + ); + return { claimAcks: dict, claimAckOrder: order }; + } + return { claimAcks: nextClaimAcks, claimAckOrder: nextOrder }; + }), + reset: (resource) => set(() => { switch (resource) { @@ -206,6 +249,8 @@ export const useTailStore = create((set) => ({ return { acks: {}, ackOrder: [] }; case "ta1_acks": return { ta1Acks: {}, ta1AckOrder: [] }; + case "claim-acks": + return { claimAcks: {}, claimAckOrder: [] }; } }), }));