Files

198 lines
6.3 KiB
TypeScript

// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { deriveAsyncState } from "../../src/application/view-models/async-state.ts";
import { AsyncSurface } from "../../src/presentation/components/async-surface.tsx";
import { createFailure } from "../../src/contracts/errors.ts";
describe("async UI state matrix", () => {
it.each([
[{ isInitialLoading: true }, "initial-loading"],
[{ data: [{ id: "1" }] }, "success"],
[{ data: [] }, "empty"],
[
{ failure: createFailure("SERVER_FAILURE", "LIST", 0) },
"terminal-error",
],
])("derives base state %#", (signals, expected) => {
expect(deriveAsyncState(signals).base).toBe(expected);
});
it.each([
[{ data: ["value"], isFetching: true }, "refreshing"],
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
[
{ data: ["value"], hasMutationEffectUnknown: true },
"mutation-effect-unknown",
],
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
])("derives overlay state %#", (signals, indicator) => {
expect(deriveAsyncState(signals).indicator).toBe(indicator);
});
it("makes crossed overlay inputs mutually exclusive by priority", () => {
const state = deriveAsyncState({
data: ["value"],
isFetching: true,
isMutationPending: true,
hasMutationEffectUnknown: true,
hasMutationConflict: true,
});
expect(state.indicator).toBe("mutation-effect-unknown");
expect(state.overlay).toMatchObject({
refreshing: false,
mutationPending: false,
mutationEffectUnknown: true,
mutationConflict: false,
});
});
it("renders unknown mutation effects with reconciliation-only actions", async () => {
const user = userEvent.setup();
const retry = vi.fn();
const reconcile = vi.fn();
const state = deriveAsyncState({
data: ["value"],
hasMutationEffectUnknown: true,
});
render(
<AsyncSurface
state={state}
onRetry={retry}
onReconcileUnknownEffect={reconcile}
>
existing content
</AsyncSurface>,
);
expect(screen.getByRole("status")).toHaveTextContent(
"변경 결과를 확인할 수 없습니다.",
);
expect(
screen.getByText("existing content").closest("section"),
).toHaveAttribute("aria-busy", "false");
expect(
screen.queryByRole("button", { name: "다시 시도" }),
).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "변경됨으로 확인" }));
await user.click(
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
);
expect(reconcile).toHaveBeenNthCalledWith(1, "APPLIED");
expect(reconcile).toHaveBeenNthCalledWith(2, "NOT_APPLIED");
expect(retry).not.toHaveBeenCalled();
});
it("keeps content visible while a non-blocking refresh runs", () => {
const state = deriveAsyncState({ data: ["value"], isFetching: true });
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
expect(screen.getByText("existing content")).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent(
"최신 정보를 확인하고 있습니다.",
);
});
it("connects stale retry and conflict resolution to real callbacks", async () => {
const user = userEvent.setup();
const retry = vi.fn();
const resolveConflict = vi.fn();
const stale = deriveAsyncState({
data: ["value"],
isStale: true,
isDegraded: true,
});
const view = render(
<AsyncSurface state={stale} onRetry={retry}>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(retry).toHaveBeenCalledOnce();
const conflict = deriveAsyncState({
data: ["value"],
hasMutationConflict: true,
});
view.rerender(
<AsyncSurface
state={conflict}
onResolveConflict={resolveConflict}
>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
expect(resolveConflict).toHaveBeenCalledOnce();
});
it("renders only safe error vocabulary", () => {
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
code: "SERVER_FAILURE",
});
const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} onRetry={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent(
"요청을 완료하지 못했습니다.",
);
expect(screen.getByRole("alert")).toHaveAttribute(
"data-message-key",
failure.userMessageKey,
);
expect(screen.getByRole("button")).toHaveTextContent("다시 시도");
expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
});
it("routes terminal actions by failure semantics", async () => {
const user = userEvent.setup();
const retry = vi.fn();
const action = vi.fn();
const forbidden = deriveAsyncState({
failure: createFailure("FORBIDDEN", "LIST", 0),
});
const view = render(
<AsyncSurface
state={forbidden}
onAction={action}
onRetry={retry}
/>,
);
await user.click(
screen.getByRole("button", { name: "안전한 화면으로 이동" }),
);
expect(action).toHaveBeenCalledOnce();
expect(retry).not.toHaveBeenCalled();
const retryable = deriveAsyncState({
failure: createFailure("SERVER_FAILURE", "LIST", 0),
});
view.rerender(
<AsyncSurface
state={retryable}
onAction={action}
onRetry={retry}
/>,
);
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(retry).toHaveBeenCalledOnce();
expect(action).toHaveBeenCalledOnce();
});
it("does not retain a terminal error after usable data is restored", () => {
const failed = deriveAsyncState({
failure: createFailure("SERVER_FAILURE", "LIST", 0),
});
const recovered = deriveAsyncState({ data: ["value"] });
expect(failed.base).toBe("terminal-error");
expect(recovered.base).toBe("success");
expect(recovered.failure).toBeUndefined();
});
});