404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// Pure prop-driven component; match the act() convention used by the
|
|
// other ClaimDrawer test files (ClaimDrawerHeader / ClaimDrawerSkeleton /
|
|
// ClaimDrawerError / ValidationPanel / ServiceLinesTable / DiagnosesList).
|
|
(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 { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import { PartiesGrid } from "./PartiesGrid";
|
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
|
import type {
|
|
ClaimDetailAddress,
|
|
ClaimDetailParties,
|
|
} from "@/types";
|
|
|
|
// Mock the api module so PayerPeekContent's usePayerSummary call
|
|
// doesn't make a real network request. The spy is set per-test in
|
|
// beforeEach.
|
|
vi.mock("@/hooks/usePayerSummary", () => ({
|
|
usePayerSummary: vi.fn(),
|
|
}));
|
|
|
|
import { usePayerSummary } from "@/hooks/usePayerSummary";
|
|
|
|
function renderIntoContainer(element: React.ReactElement): {
|
|
container: HTMLDivElement;
|
|
unmount: () => void;
|
|
} {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root: Root = createRoot(container);
|
|
// SP21 Phase 5 Task 5.8: PartiesGrid now uses useDrillStack() to
|
|
// open payer peeks. Wrap every render in a DrillStackProvider so
|
|
// the hook has a context to read from.
|
|
act(() => {
|
|
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
|
|
});
|
|
return {
|
|
container,
|
|
unmount: () => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeAddress(
|
|
overrides: Partial<ClaimDetailAddress> = {}
|
|
): ClaimDetailAddress {
|
|
return {
|
|
line1: "100 Main St",
|
|
line2: null,
|
|
city: "Springfield",
|
|
state: "IL",
|
|
zip: "62701",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeParties(
|
|
overrides: Partial<ClaimDetailParties> = {}
|
|
): ClaimDetailParties {
|
|
return {
|
|
billingProvider: {
|
|
name: "Acme Medical Group",
|
|
npi: "1234567890",
|
|
taxId: "12-3456789",
|
|
address: makeAddress(),
|
|
},
|
|
subscriber: {
|
|
firstName: "Jane",
|
|
lastName: "Doe",
|
|
memberId: "MEM12345",
|
|
dob: "1980-05-15",
|
|
gender: "F",
|
|
},
|
|
payer: {
|
|
name: "Aetna",
|
|
id: "PAYER01",
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("PartiesGrid", () => {
|
|
it("test_section_label_is_parties", () => {
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
const label = container.querySelector('[data-testid="section-label"]');
|
|
expect(label).not.toBeNull();
|
|
|
|
// `uppercase` is applied via Tailwind CSS, so textContent gives the
|
|
// source-case string "Parties"; assert case-insensitively and
|
|
// confirm the visual transform via the className sniff below.
|
|
const text = (label?.textContent ?? "").toLowerCase();
|
|
expect(text).toContain("parties");
|
|
|
|
// Eyebrow style (uppercase + tracking) — sniff the className.
|
|
// The shared `.eyebrow` class in index.css consolidates the
|
|
// eyebrow treatment; tests assert it lands on the section label.
|
|
const cls = label?.className ?? "";
|
|
expect(cls).toContain("eyebrow");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_three_cards_rendered_for_each_party", () => {
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
const billing = container.querySelector('[data-testid="party-billing-provider"]');
|
|
const subscriber = container.querySelector('[data-testid="party-subscriber"]');
|
|
const payer = container.querySelector('[data-testid="party-payer"]');
|
|
|
|
expect(billing).not.toBeNull();
|
|
expect(subscriber).not.toBeNull();
|
|
expect(payer).not.toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_billing_provider_card_shows_name_npi_taxId_and_address", () => {
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
const card = container.querySelector(
|
|
'[data-testid="party-billing-provider"]'
|
|
);
|
|
expect(card).not.toBeNull();
|
|
|
|
const text = card?.textContent ?? "";
|
|
expect(text).toContain("Acme Medical Group");
|
|
expect(text).toContain("1234567890");
|
|
expect(text).toContain("12-3456789");
|
|
// Address components.
|
|
expect(text).toContain("100 Main St");
|
|
expect(text).toContain("Springfield");
|
|
expect(text).toContain("IL");
|
|
expect(text).toContain("62701");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_subscriber_card_shows_first_last_memberId_and_gender", () => {
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
const card = container.querySelector('[data-testid="party-subscriber"]');
|
|
expect(card).not.toBeNull();
|
|
|
|
const text = card?.textContent ?? "";
|
|
expect(text).toContain("Jane");
|
|
expect(text).toContain("Doe");
|
|
expect(text).toContain("MEM12345");
|
|
expect(text).toContain("F");
|
|
// DOB is also present in this fixture.
|
|
expect(text).toContain("1980");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_subscriber_card_omits_dob_when_null", () => {
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid
|
|
parties={makeParties({
|
|
subscriber: {
|
|
firstName: "John",
|
|
lastName: "Smith",
|
|
memberId: "MEM99999",
|
|
dob: null,
|
|
gender: "M",
|
|
},
|
|
})}
|
|
/>
|
|
);
|
|
|
|
const card = container.querySelector('[data-testid="party-subscriber"]');
|
|
expect(card).not.toBeNull();
|
|
|
|
const text = (card?.textContent ?? "").toLowerCase();
|
|
// Still renders the identity pieces.
|
|
expect(text).toContain("john");
|
|
expect(text).toContain("smith");
|
|
expect(text).toContain("mem99999");
|
|
expect(text).toContain("m");
|
|
// No "undefined" or "null" stringification for the absent DOB.
|
|
expect(text).not.toContain("undefined");
|
|
expect(text).not.toContain("null");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_payer_card_shows_name_and_id", () => {
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
const card = container.querySelector('[data-testid="party-payer"]');
|
|
expect(card).not.toBeNull();
|
|
|
|
const text = card?.textContent ?? "";
|
|
expect(text).toContain("Aetna");
|
|
expect(text).toContain("PAYER01");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_billing_provider_address_line2_rendered_when_present", () => {
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid
|
|
parties={makeParties({
|
|
billingProvider: {
|
|
name: "Suite Clinic",
|
|
npi: "1111111111",
|
|
taxId: "99-9999999",
|
|
address: makeAddress({
|
|
line1: "200 Oak Ave",
|
|
line2: "Suite 300",
|
|
city: "Portland",
|
|
state: "OR",
|
|
zip: "97201",
|
|
}),
|
|
},
|
|
})}
|
|
/>
|
|
);
|
|
|
|
const card = container.querySelector(
|
|
'[data-testid="party-billing-provider"]'
|
|
);
|
|
const text = card?.textContent ?? "";
|
|
expect(text).toContain("200 Oak Ave");
|
|
expect(text).toContain("Suite 300");
|
|
expect(text).toContain("Portland");
|
|
expect(text).toContain("OR");
|
|
expect(text).toContain("97201");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_empty_address_object_does_not_render_garbage", () => {
|
|
// The backend can return `{}` for a missing address (per the
|
|
// ClaimDetailBillingProvider type's `Record<string, never>` branch).
|
|
// The component must not stringify an empty object.
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid
|
|
parties={makeParties({
|
|
billingProvider: {
|
|
name: "Addrless Clinic",
|
|
npi: "2222222222",
|
|
taxId: "11-1111111",
|
|
address: {},
|
|
},
|
|
})}
|
|
/>
|
|
);
|
|
|
|
const card = container.querySelector(
|
|
'[data-testid="party-billing-provider"]'
|
|
);
|
|
const text = (card?.textContent ?? "").toLowerCase();
|
|
expect(text).toContain("addrless clinic");
|
|
expect(text).toContain("2222222222");
|
|
expect(text).not.toContain("[object object]");
|
|
expect(text).not.toContain("undefined");
|
|
expect(text).not.toContain("null");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_responsive_grid_uses_3_columns_on_desktop", () => {
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
// The section follows the established `flex flex-col gap-3 px-6 py-4`
|
|
// pattern; the responsive grid is the inner wrapper that holds the
|
|
// three party cards. Sniff its className.
|
|
const grid = container.querySelector('[data-testid="parties-grid-inner"]');
|
|
expect(grid).not.toBeNull();
|
|
const cls = grid?.className ?? "";
|
|
expect(cls).toContain("grid");
|
|
expect(cls).toContain("grid-cols-1");
|
|
expect(cls).toContain("md:grid-cols-3");
|
|
expect(cls).toContain("gap-4");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("SP21 Task 5.8: payer name is drillable (renders as a button)", () => {
|
|
// The payer card's name now wraps in a button with the
|
|
// `drillable` affordance + `data-testid="party-payer-name-drill"`.
|
|
// The other two cards (billing-provider, subscriber) keep the
|
|
// name as a plain div.
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
const payerNameBtn = container.querySelector(
|
|
'[data-testid="party-payer-name-drill"]',
|
|
);
|
|
expect(payerNameBtn).not.toBeNull();
|
|
expect(payerNameBtn?.textContent).toContain("Aetna");
|
|
|
|
// The billing-provider and subscriber names stay plain divs.
|
|
expect(
|
|
container.querySelector(
|
|
'[data-testid="party-billing-provider-name-drill"]',
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
container.querySelector(
|
|
'[data-testid="party-subscriber-name-drill"]',
|
|
),
|
|
).toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("SP21 Task 5.8: clicking the payer name opens the PeekModal", async () => {
|
|
// Mock the payer summary fetch so PayerPeekContent resolves
|
|
// without a real network call. The peek modal renders
|
|
// regardless of fetch state (loading skeleton → content).
|
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
data: null,
|
|
isLoading: true,
|
|
});
|
|
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
const payerNameBtn = container.querySelector(
|
|
'[data-testid="party-payer-name-drill"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(payerNameBtn).not.toBeNull();
|
|
await act(async () => {
|
|
payerNameBtn!.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// The PeekModal is a Radix Dialog that portals into document.body.
|
|
// Look for the [role="dialog"] (modal) with our eyebrow "Payer".
|
|
const dialogs = document.body.querySelectorAll('[role="dialog"]');
|
|
// One dialog is the modal itself (the peek). PartiesGrid doesn't
|
|
// open a drawer, so we expect exactly one.
|
|
expect(dialogs.length).toBeGreaterThanOrEqual(1);
|
|
const peekDialog = Array.from(dialogs).find((d) =>
|
|
d.textContent?.includes("Payer"),
|
|
);
|
|
expect(peekDialog).not.toBeNull();
|
|
expect(peekDialog?.textContent).toContain("Aetna");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("SP21 Task 5.8: peek uses the X12 payer id, not the human name", () => {
|
|
// The PayerPeekContent is given the X12 payer id from
|
|
// `payer.id`. Verify the click flow passes the right id by
|
|
// checking usePayerSummary was called with "PAYER01".
|
|
const mockUsePayerSummary = vi.fn().mockReturnValue({
|
|
data: null,
|
|
isLoading: true,
|
|
});
|
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
|
mockUsePayerSummary,
|
|
);
|
|
|
|
const { container, unmount } = renderIntoContainer(
|
|
<PartiesGrid parties={makeParties()} />
|
|
);
|
|
|
|
// No peek yet → no usePayerSummary call.
|
|
expect(mockUsePayerSummary).not.toHaveBeenCalled();
|
|
|
|
const payerNameBtn = container.querySelector(
|
|
'[data-testid="party-payer-name-drill"]',
|
|
) as HTMLButtonElement | null;
|
|
act(() => {
|
|
payerNameBtn!.click();
|
|
});
|
|
|
|
// After the click, PayerPeekContent mounts and calls
|
|
// usePayerSummary("PAYER01") — NOT the human name "Aetna".
|
|
expect(mockUsePayerSummary).toHaveBeenCalledWith("PAYER01");
|
|
|
|
unmount();
|
|
});
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Reset the mock between tests so per-test implementations stick.
|
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
data: null,
|
|
isLoading: true,
|
|
});
|
|
});
|