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