feat(drill): AckDrawer with header + segment status list
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment happy-dom
|
||||
// AckDrawer wires `useAckDetail` (TanStack Query) and renders a Radix
|
||||
// Dialog portal — both need an act-aware, DOM-backed environment or
|
||||
// React logs warnings and the portal can't mount.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { AckDrawer } from "@/components/AckDrawer";
|
||||
import type { Ack } from "@/types";
|
||||
|
||||
// Mock the hook BEFORE the import above is resolved (vitest hoists
|
||||
// `vi.mock` to the top of the file regardless of where it appears
|
||||
// syntactically). Mocking the hook directly — rather than mocking
|
||||
// `api.getAck` — lets each test pin the hook's exact return shape
|
||||
// without standing up a real `QueryClient`.
|
||||
const { useAckDetail } = vi.hoisted(() => ({
|
||||
useAckDetail: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/hooks/useAckDetail", () => ({
|
||||
useAckDetail,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Minimal valid `Ack` fixture — every required key present so the
|
||||
* component typechecks. The wire shape extends with `raw_999_text`
|
||||
* (and `rawJson`), per `useAckDetail`'s `AckDetail` type — populated
|
||||
* when the backend serves it; absent on older rows.
|
||||
*/
|
||||
const SAMPLE_ACK: Ack & { raw_999_text: string } = {
|
||||
id: 42,
|
||||
sourceBatchId: "b-uuid-1",
|
||||
acceptedCount: 3,
|
||||
rejectedCount: 1,
|
||||
receivedCount: 4,
|
||||
ackCode: "P",
|
||||
parsedAt: "2026-06-20T12:00:00Z",
|
||||
raw_999_text: "ISA*...*~\nGS*...*~\nST*999*0001~",
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure the mocked hook's return value for a single test. The
|
||||
* `refetch` default is a fresh `vi.fn()` — tests that need to assert
|
||||
* on it can override via `overrides.refetch`.
|
||||
*/
|
||||
function mockDetail(
|
||||
overrides: Partial<{
|
||||
data: (Ack & { raw_999_text?: string }) | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}> = {}
|
||||
) {
|
||||
useAckDetail.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// happy-dom keeps `document.body` between tests; without cleanup,
|
||||
// `screen.getByText(...)` would find nodes from earlier renders.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("AckDrawer", () => {
|
||||
it("test_renders_nothing_when_ackId_is_null", () => {
|
||||
mockDetail({ data: null });
|
||||
render(<AckDrawer ackId={null} onClose={() => {}} />);
|
||||
|
||||
// No ack content should be in the document when the drawer is
|
||||
// closed — Radix's Dialog gates the portal on `open`.
|
||||
expect(document.body.textContent).not.toContain("b-uuid-1");
|
||||
});
|
||||
|
||||
it("test_calls_useAckDetail_with_ackId", () => {
|
||||
mockDetail({ data: SAMPLE_ACK });
|
||||
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||
|
||||
expect(useAckDetail).toHaveBeenCalledWith("42");
|
||||
});
|
||||
|
||||
it("test_renders_ack_summary_on_success", () => {
|
||||
mockDetail({ data: SAMPLE_ACK });
|
||||
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||
|
||||
// Header shows the source batch id as the title.
|
||||
expect(document.body.textContent).toContain("b-uuid-1");
|
||||
// Counts (3 accepted, 1 rejected, 4 received) are surfaced as
|
||||
// StatTile values.
|
||||
expect(document.body.textContent).toContain("3");
|
||||
expect(document.body.textContent).toContain("1");
|
||||
expect(document.body.textContent).toContain("4");
|
||||
});
|
||||
|
||||
it("test_renders_ack_code_pill_with_human_label", () => {
|
||||
mockDetail({ data: { ...SAMPLE_ACK, ackCode: "P" } });
|
||||
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||
|
||||
// The pill renders a human label, not just the bare code letter.
|
||||
expect(document.body.textContent).toContain("Partially accepted");
|
||||
});
|
||||
|
||||
it("test_renders_skeleton_while_loading", () => {
|
||||
mockDetail({ isLoading: true });
|
||||
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||
|
||||
// The `Skeleton` primitive sets `aria-busy="true"` — a stable
|
||||
// hook for the loading state.
|
||||
expect(document.querySelectorAll('[aria-busy="true"]').length).toBeGreaterThan(0);
|
||||
// And the ack id should NOT have leaked in yet.
|
||||
expect(document.body.textContent).not.toContain("b-uuid-1");
|
||||
});
|
||||
|
||||
it("test_renders_not_found_error_on_404", () => {
|
||||
mockDetail({
|
||||
isError: true,
|
||||
error: new ApiError(404, "Ack ghost not found"),
|
||||
});
|
||||
render(<AckDrawer ackId="9999" onClose={() => {}} />);
|
||||
|
||||
const errEl = document.querySelector('[data-testid="ack-drawer-error-not_found"]');
|
||||
expect(errEl).not.toBeNull();
|
||||
// Body should mention "doesn't exist" — the not_found COPY key.
|
||||
expect(errEl?.textContent).toContain("doesn't exist");
|
||||
// And the retry button should NOT be present (not_found has no
|
||||
// retry affordance — retrying a 404 won't help).
|
||||
expect(document.querySelector('[data-testid="error-retry"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("test_renders_network_error_with_retry", () => {
|
||||
mockDetail({ isError: true, error: new Error("network down") });
|
||||
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||
|
||||
const errEl = document.querySelector('[data-testid="ack-drawer-error-network"]');
|
||||
expect(errEl).not.toBeNull();
|
||||
expect(errEl?.textContent).toContain("Couldn't reach the server");
|
||||
|
||||
// Network variant shows a Retry button.
|
||||
const retryBtn = document.querySelector(
|
||||
'[data-testid="error-retry"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(retryBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it("test_close_button_calls_onClose", () => {
|
||||
const onClose = vi.fn<() => void>();
|
||||
mockDetail({ isError: true, error: new Error("network down") });
|
||||
render(<AckDrawer ackId="42" onClose={onClose} />);
|
||||
|
||||
const closeBtn = document.querySelector(
|
||||
'[data-testid="error-close"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(closeBtn).not.toBeNull();
|
||||
closeBtn!.click();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("test_renders_download_button_when_raw_999_text_present", () => {
|
||||
mockDetail({ data: SAMPLE_ACK });
|
||||
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||
|
||||
// The header action slot populates with the Download 999 button
|
||||
// only when the ack detail carries raw_999_text.
|
||||
const dlBtn = document.querySelector('[data-testid="ack-drawer-download"]');
|
||||
expect(dlBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it("test_omits_download_button_when_raw_999_text_absent", () => {
|
||||
const data: Ack = {
|
||||
id: 42,
|
||||
sourceBatchId: "b-uuid-1",
|
||||
acceptedCount: 3,
|
||||
rejectedCount: 1,
|
||||
receivedCount: 4,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:00:00Z",
|
||||
};
|
||||
mockDetail({ data });
|
||||
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||
|
||||
// No raw_999_text → no Download button.
|
||||
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user