feat(sp28): claimAcks slice + useTailStream/useMergedTail dispatch
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T extends { id: string | number }>(
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <Acknowledgments>
|
||||
// panel and AckDrawer's <MatchedClaim> 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user