diff --git a/src/store/tail-store.test.ts b/src/store/tail-store.test.ts index ca61566..16eefee 100644 --- a/src/store/tail-store.test.ts +++ b/src/store/tail-store.test.ts @@ -5,7 +5,7 @@ // state shape. import { beforeEach, describe, expect, it } from "vitest"; -import type { Activity, Claim, Remittance } from "@/types"; +import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types"; import { TAIL_CAP, useTailStore } from "./tail-store"; function makeClaim(id: string, overrides: Partial = {}): Claim { @@ -47,6 +47,36 @@ function makeActivity(id: string, overrides: Partial = {}): Activity { }; } +function makeAck(id: number, overrides: Partial = {}): Ack { + return { + id, + sourceBatchId: `BATCH-${id}`, + acceptedCount: 1, + rejectedCount: 0, + receivedCount: 1, + ackCode: "A", + parsedAt: "2026-07-02T12:00:00Z", + patientControlNumber: `PCN-${id}`, + ...overrides, + }; +} + +function makeTa1Ack(id: number, overrides: Partial = {}): Ta1Ack { + return { + id, + controlNumber: `0000000${id}`, + ackCode: "A", + noteCode: "000", + interchangeDate: "2026-07-02", + interchangeTime: "1200", + senderId: "SENDER", + receiverId: "RECEIVER", + sourceBatchId: `TA1-BATCH-${id}`, + parsedAt: "2026-07-02T12:00:00Z", + ...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 @@ -55,6 +85,8 @@ describe("useTailStore", () => { useTailStore.getState().reset("claims"); useTailStore.getState().reset("remittances"); useTailStore.getState().reset("activity"); + useTailStore.getState().reset("acks"); + useTailStore.getState().reset("ta1_acks"); }); it("test_add_claim_adds_new_claim_keyed_by_id", () => { @@ -128,4 +160,71 @@ describe("useTailStore", () => { const lastKey = `CLM-${(N - 1).toString().padStart(6, "0")}`; expect(claims[lastKey]).toBeDefined(); }, 60_000); + + // ------------------------------------------------------------------------- + // SP25: acks + ta1_acks slices mirror the claims/remittances key-by-id + // pattern (both have a stable `id` from the database row). + // ------------------------------------------------------------------------- + + it("test_add_ack_adds_new_ack_keyed_by_id", () => { + const { addAck } = useTailStore.getState(); + addAck(makeAck(1)); + addAck(makeAck(2)); + const { acks } = useTailStore.getState(); + expect(Object.keys(acks)).toHaveLength(2); + expect(acks["1"]?.sourceBatchId).toBe("BATCH-1"); + expect(acks["2"]?.sourceBatchId).toBe("BATCH-2"); + }); + + it("test_add_ack_with_duplicate_id_is_noop", () => { + const { addAck } = useTailStore.getState(); + addAck(makeAck(1, { sourceBatchId: "BATCH-original" })); + addAck(makeAck(1, { sourceBatchId: "BATCH-replay" })); + const { acks } = useTailStore.getState(); + expect(Object.keys(acks)).toHaveLength(1); + // First write wins; snapshot replay must not trample the canonical row. + expect(acks["1"]?.sourceBatchId).toBe("BATCH-original"); + }); + + it("test_reset_acks_clears_only_acks_slice", () => { + const { addAck, addClaim, reset } = useTailStore.getState(); + addAck(makeAck(1)); + addClaim(makeClaim("CLM-1")); + + reset("acks"); + const s = useTailStore.getState(); + expect(Object.keys(s.acks)).toHaveLength(0); + // The other slices are untouched. + expect(Object.keys(s.claims)).toHaveLength(1); + }); + + it("test_add_ta1_ack_adds_new_ta1_ack_keyed_by_id", () => { + const { addTa1Ack } = useTailStore.getState(); + addTa1Ack(makeTa1Ack(1)); + addTa1Ack(makeTa1Ack(2)); + const { ta1Acks } = useTailStore.getState(); + expect(Object.keys(ta1Acks)).toHaveLength(2); + expect(ta1Acks["1"]?.controlNumber).toBe("00000001"); + expect(ta1Acks["2"]?.controlNumber).toBe("00000002"); + }); + + it("test_add_ta1_ack_with_duplicate_id_is_noop", () => { + const { addTa1Ack } = useTailStore.getState(); + addTa1Ack(makeTa1Ack(7, { senderId: "ORIGINAL" })); + addTa1Ack(makeTa1Ack(7, { senderId: "REPLAY" })); + const { ta1Acks } = useTailStore.getState(); + expect(Object.keys(ta1Acks)).toHaveLength(1); + expect(ta1Acks["7"]?.senderId).toBe("ORIGINAL"); + }); + + it("test_reset_ta1_acks_clears_only_ta1_acks_slice", () => { + const { addTa1Ack, addAck, reset } = useTailStore.getState(); + addTa1Ack(makeTa1Ack(1)); + addAck(makeAck(1)); + + reset("ta1_acks"); + const s = useTailStore.getState(); + expect(Object.keys(s.ta1Acks)).toHaveLength(0); + expect(Object.keys(s.acks)).toHaveLength(1); + }); }); diff --git a/src/store/tail-store.ts b/src/store/tail-store.ts index 29de8f8..d6b2630 100644 --- a/src/store/tail-store.ts +++ b/src/store/tail-store.ts @@ -21,7 +21,7 @@ // --------------------------------------------------------------------------- import { create } from "zustand"; -import type { Activity, Claim, Remittance } from "@/types"; +import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types"; import type { TailResource } from "@/lib/tail-stream"; /** Maximum number of items retained per slice before FIFO eviction kicks in. */ @@ -46,6 +46,12 @@ interface TailStore { claims: Record; remittances: Record; activity: Activity[]; + // SP25: the Acks page needs the same live-tail triplet as + // Claims/Remittances/Activity. `acks` and `ta1_acks` both have stable + // numeric ids from the database row, so they're keyed-by-id like + // `claims`/`remittances` (first write wins, FIFO-capped at TAIL_CAP). + acks: Record; + ta1Acks: Record; // --- Insertion-order trackers (kept in sync with the dicts above) ---- // These are private to the store; consumers only read the dicts. We @@ -54,11 +60,15 @@ interface TailStore { // what triggers a re-render in subscribers that select `claims`). claimOrder: string[]; remitOrder: string[]; + ackOrder: number[]; + ta1AckOrder: number[]; // --- Setters --------------------------------------------------------- addClaim: (c: Claim) => void; addRemittance: (r: RemitListItem) => void; addActivity: (a: Activity) => void; + addAck: (a: Ack) => void; + addTa1Ack: (a: Ta1Ack) => void; reset: (resource: TailResource) => void; } @@ -69,11 +79,11 @@ interface TailStore { */ type RemitListItem = Remittance; -function evictOldest( - order: string[], - dict: Record, +function evictOldest( + order: Array, + dict: Record, batch: number, -): { order: string[]; dict: Record } { +): { order: Array; dict: Record } { if (order.length <= TAIL_CAP) return { order, dict }; // Drain down to the cap; never evict more than `batch` per call so a // 5-item overflow evicts 5, but a 1 000-item overflow evicts 100 in @@ -83,8 +93,8 @@ function evictOldest( const nextOrder = order.slice(toDrop); // Object.assign is consistently faster than `{ ...dict }` in V8 for // large dicts; we follow up with `delete` for each evicted id. - const nextDict: Record = Object.assign({}, dict); - for (const id of dropped) delete nextDict[id]; + const nextDict: Record = Object.assign({}, dict); + for (const id of dropped) delete nextDict[String(id)]; return { order: nextOrder, dict: nextDict }; } @@ -92,8 +102,12 @@ export const useTailStore = create((set) => ({ claims: {}, remittances: {}, activity: [], + acks: {}, + ta1Acks: {}, claimOrder: [], remitOrder: [], + ackOrder: [], + ta1AckOrder: [], addClaim: (c) => set((s) => { @@ -147,6 +161,38 @@ export const useTailStore = create((set) => ({ return { activity: next }; }), + addAck: (a) => + set((s) => { + // First write wins; snapshot replay must not trample the + // canonical row. `id` is a number from the database row. + if (s.acks[String(a.id)]) return s; + const nextAcks: Record = Object.assign({}, s.acks, { + [String(a.id)]: a, + }); + const nextOrder = s.ackOrder.concat(a.id); + if (nextOrder.length > TAIL_CAP) { + const { order, dict } = evictOldest(nextOrder, nextAcks, EVICT_BATCH); + return { acks: dict, ackOrder: order as number[] }; + } + return { acks: nextAcks, ackOrder: nextOrder }; + }), + + addTa1Ack: (a) => + set((s) => { + if (s.ta1Acks[String(a.id)]) return s; + const nextTa1Acks: Record = Object.assign( + {}, + s.ta1Acks, + { [String(a.id)]: a }, + ); + const nextOrder = s.ta1AckOrder.concat(a.id); + if (nextOrder.length > TAIL_CAP) { + const { order, dict } = evictOldest(nextOrder, nextTa1Acks, EVICT_BATCH); + return { ta1Acks: dict, ta1AckOrder: order as number[] }; + } + return { ta1Acks: nextTa1Acks, ta1AckOrder: nextOrder }; + }), + reset: (resource) => set(() => { switch (resource) { @@ -156,6 +202,10 @@ export const useTailStore = create((set) => ({ return { remittances: {}, remitOrder: [] }; case "activity": return { activity: [] }; + case "acks": + return { acks: {}, ackOrder: [] }; + case "ta1_acks": + return { ta1Acks: {}, ta1AckOrder: [] }; } }), }));