feat(frontend): useMergedTail merges base + tail with filter
This commit is contained in:
@@ -0,0 +1,231 @@
|
|||||||
|
// @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 { Claim, Remittance, Activity } 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live-tail merge hook (sub-project 5, Phase 5 Task 20).
|
||||||
|
//
|
||||||
|
// Reads the matching slice of `useTailStore` and returns a single array:
|
||||||
|
// `baseItems` first (in the order the caller supplied them), then any new
|
||||||
|
// tail items in arrival order, with duplicates of base ids removed.
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - The dedup runs against `baseItems` (not the other way around) because
|
||||||
|
// the base list is the authoritative snapshot from the page's JSON
|
||||||
|
// fetch — the tail slice is an opportunistic delta, so the base wins
|
||||||
|
// when an id appears in both.
|
||||||
|
// - The optional `filterFn` is applied to the tail slice AFTER dedup so a
|
||||||
|
// page-specific filter (e.g. "only show me submitted claims") doesn't
|
||||||
|
// accidentally include items that were already filtered out of base.
|
||||||
|
// - We don't `useMemo` here: `baseItems` is a new array reference on
|
||||||
|
// 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.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useTailStore } from "@/store/tail-store";
|
||||||
|
import type { TailResource } from "@/lib/tail-stream";
|
||||||
|
|
||||||
|
export function useMergedTail<T extends { id: string }>(
|
||||||
|
resource: TailResource,
|
||||||
|
baseItems: T[],
|
||||||
|
filterFn?: (item: T) => boolean,
|
||||||
|
): T[] {
|
||||||
|
// Select the slice that matches the resource. We return a fresh array
|
||||||
|
// each time so the consumer gets a stable iteration order even when
|
||||||
|
// zustand hands us a new container (Record or array) reference.
|
||||||
|
const tailSlice = useTailStore((s) => {
|
||||||
|
switch (resource) {
|
||||||
|
case "claims": {
|
||||||
|
// The store keys claims by id in a Record for O(1) updates, but
|
||||||
|
// also keeps an `claimOrder` array so we can iterate in arrival
|
||||||
|
// order. Filter out any holes (defensive — shouldn't happen but
|
||||||
|
// type-narrows the result to Claim[]).
|
||||||
|
const out: unknown[] = [];
|
||||||
|
for (const id of s.claimOrder) {
|
||||||
|
const v = s.claims[id];
|
||||||
|
if (v !== undefined) out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case "remittances": {
|
||||||
|
const out: unknown[] = [];
|
||||||
|
for (const id of s.remitOrder) {
|
||||||
|
const v = s.remittances[id];
|
||||||
|
if (v !== undefined) out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case "activity":
|
||||||
|
// Activity is already an array — the store appends in arrival
|
||||||
|
// order, so iteration order is exactly what we want.
|
||||||
|
return s.activity as unknown[];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseIds = new Set<string>();
|
||||||
|
for (const b of baseItems) baseIds.add(b.id);
|
||||||
|
|
||||||
|
const tailAfterDedup: unknown[] = [];
|
||||||
|
for (const t of tailSlice) {
|
||||||
|
const id = (t as { id: string }).id;
|
||||||
|
if (baseIds.has(id)) continue;
|
||||||
|
tailAfterDedup.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailAfterFilter = filterFn
|
||||||
|
? tailAfterDedup.filter((t) => filterFn(t as T))
|
||||||
|
: tailAfterDedup;
|
||||||
|
|
||||||
|
return [...baseItems, ...(tailAfterFilter as T[])];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user