390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
// @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<TResult>(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();
|
|
});
|
|
});
|