chore: initialize from frontend template 4dc033c
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function TestShell() {
|
||||
return <main aria-label="application shell">ready</main>;
|
||||
}
|
||||
|
||||
describe("component test level", () => {
|
||||
it("renders an accessible application shell", () => {
|
||||
render(<TestShell />);
|
||||
expect(screen.getByRole("main", { name: "application shell" })).toHaveTextContent(
|
||||
"ready",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SafeText } from "../../src/presentation/security/safe-text.tsx";
|
||||
import { assertSafeConfigNames } from "../../src/contracts/env.ts";
|
||||
import { defineStorageKey } from "../../src/contracts/storage-keys.ts";
|
||||
import { projectTelemetryEvent } from "../../src/contracts/telemetry.ts";
|
||||
|
||||
describe("browser security boundary", () => {
|
||||
it("renders untrusted text without script or inline handler injection", () => {
|
||||
render(
|
||||
<SafeText value={'<img src=x onerror="window.compromised=true"><script>x</script>'} />,
|
||||
);
|
||||
expect(screen.getByText(/<img/)).toBeVisible();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
expect(document.querySelector("[onerror]")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects secret-like client configuration names", () => {
|
||||
expect(() => assertSafeConfigNames({ PRIVATE_KEY: "not-public" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects browser token storage registration", () => {
|
||||
expect(() =>
|
||||
defineStorageKey({
|
||||
logicalName: "SESSION_TOKEN",
|
||||
scope: "auth",
|
||||
name: "session-token",
|
||||
backend: "sessionStorage",
|
||||
classification: "sensitive-forbidden",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "none",
|
||||
ttl: "session",
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("drops raw URL/query/token telemetry attributes", () => {
|
||||
const result = projectTelemetryEvent("api.request.failed", {
|
||||
error_kind: "SERVER_FAILURE",
|
||||
http_status_group: "5xx",
|
||||
attempt_count_bucket: "1",
|
||||
route_id: "APP_HOME",
|
||||
raw_url: "https://api.test?token=private",
|
||||
query_string: "token=private",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toMatch(/raw_url|query_string|private/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ChunkRecoveryBoundary,
|
||||
isChunkLoadFailure,
|
||||
} from "../../src/presentation/boundaries/chunk-recovery-boundary.tsx";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.tsx";
|
||||
|
||||
function ChunkDefect(): never {
|
||||
throw new TypeError("Failed to fetch dynamically imported module");
|
||||
}
|
||||
|
||||
function RenderDefect(): never {
|
||||
throw new Error("ordinary render defect");
|
||||
}
|
||||
|
||||
describe("chunk recovery boundary classification", () => {
|
||||
it("recognizes lazy module failures without classifying ordinary render errors", () => {
|
||||
expect(
|
||||
isChunkLoadFailure(
|
||||
new TypeError("Failed to fetch dynamically imported module"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isChunkLoadFailure(new Error("ordinary render defect"))).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the recovery input only for a lazy chunk rejection", async () => {
|
||||
const recover = vi.fn(async () => ({
|
||||
action: "support" as const,
|
||||
reason: "reload-already-attempted",
|
||||
}));
|
||||
render(
|
||||
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
|
||||
<ChunkDefect />
|
||||
</ChunkRecoveryBoundary>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "화면 자산을 복구하지 못했습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(recover).toHaveBeenCalledOnce();
|
||||
expect(recover).toHaveBeenCalledWith({
|
||||
chunkId: "route-home",
|
||||
failureKind: "CHUNK_LOAD_FAILURE",
|
||||
});
|
||||
});
|
||||
|
||||
it("rethrows an ordinary component defect to the local render boundary", () => {
|
||||
const recover = vi.fn();
|
||||
render(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
|
||||
<RenderDefect />
|
||||
</ChunkRecoveryBoundary>
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"화면을 표시하지 못했습니다.",
|
||||
);
|
||||
expect(recover).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DataTable,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuIcon,
|
||||
Pagination,
|
||||
Popover,
|
||||
RadioGroup,
|
||||
Switch,
|
||||
Tabs,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
} from "../../src/presentation/design-system/index.ts";
|
||||
|
||||
describe("design-system platform interactions", () => {
|
||||
it("keeps decorative icons out of the accessibility tree and names icon actions", () => {
|
||||
render(
|
||||
<>
|
||||
<MenuIcon />
|
||||
<IconButton accessibleName="탐색 열기">
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "탐색 열기" })).toBeVisible();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports native choices, indeterminate state, radio arrows and switches", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRadio = vi.fn();
|
||||
const onSwitch = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Checkbox indeterminate label="일부 선택" />
|
||||
<RadioGroup
|
||||
label="밀도"
|
||||
name="density"
|
||||
onChange={onRadio}
|
||||
options={[
|
||||
{ value: "normal", label: "보통" },
|
||||
{ value: "compact", label: "조밀" },
|
||||
]}
|
||||
value="normal"
|
||||
/>
|
||||
<Switch
|
||||
checked={false}
|
||||
label="알림"
|
||||
onChange={onSwitch}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: "일부 선택" });
|
||||
expect(checkbox).toBePartiallyChecked();
|
||||
const normal = screen.getByRole("radio", { name: "보통" });
|
||||
normal.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(onRadio).toHaveBeenCalledWith("compact");
|
||||
await user.click(screen.getByRole("switch", { name: "알림" }));
|
||||
expect(onSwitch).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("moves through a menu, executes once, dismisses and restores focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const inspect = vi.fn();
|
||||
render(
|
||||
<Menu
|
||||
items={[
|
||||
{ id: "open", label: "열기", onSelect: vi.fn() },
|
||||
{ id: "inspect", label: "검사", onSelect: inspect },
|
||||
]}
|
||||
triggerLabel="작업"
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "작업" });
|
||||
trigger.focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(screen.getByRole("menuitem", { name: "열기" })).toHaveFocus();
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
expect(inspect).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports manual tab activation with arrow-key roving focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Tabs
|
||||
activation="manual"
|
||||
label="계층"
|
||||
tabs={[
|
||||
{ id: "tokens", label: "토큰", panel: "토큰 내용" },
|
||||
{ id: "patterns", label: "패턴", panel: "패턴 내용" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tokenTab = screen.getByRole("tab", { name: "토큰" });
|
||||
tokenTab.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
const patternTab = screen.getByRole("tab", { name: "패턴" });
|
||||
expect(patternTab).toHaveFocus();
|
||||
expect(patternTab).toHaveAttribute("aria-selected", "false");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(patternTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "패턴" })).toHaveTextContent(
|
||||
"패턴 내용",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a modal drawer and restores focus after Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>탐색 열기</Button>
|
||||
<Drawer onClose={() => setOpen(false)} open={open} title="탐색">
|
||||
<a href="/target">대상</a>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "탐색 열기" });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole("dialog", { name: "탐색" })).toHaveAttribute("open");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it("bounds the toast queue and collapses duplicate IDs", async () => {
|
||||
const user = userEvent.setup();
|
||||
function Harness() {
|
||||
const toast = useToast();
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast.push({ id: "same", title: "저장됨", durationMs: 60_000 })
|
||||
}
|
||||
>
|
||||
중복
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
toast.push({
|
||||
id: `toast-${index}`,
|
||||
title: `알림 ${index}`,
|
||||
durationMs: 60_000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
다섯 개
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<ToastProvider limit={3}>
|
||||
<Harness />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "중복" }));
|
||||
await user.click(screen.getByRole("button", { name: "중복" }));
|
||||
expect(screen.getByText("×2")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "다섯 개" }));
|
||||
const region = screen.getByRole("region", { name: "알림" });
|
||||
expect(within(region).getAllByRole("article")).toHaveLength(3);
|
||||
expect(within(region).queryByText("알림 0")).not.toBeInTheDocument();
|
||||
expect(within(region).getByText("알림 4")).toBeVisible();
|
||||
});
|
||||
|
||||
it("pauses toast timeout while the user is interacting", () => {
|
||||
vi.useFakeTimers();
|
||||
function Harness() {
|
||||
const toast = useToast();
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast.push({ id: "timed", title: "시간 제한", durationMs: 1000 })
|
||||
}
|
||||
>
|
||||
표시
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Harness />
|
||||
</ToastProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "표시" }));
|
||||
const item = screen.getByRole("article");
|
||||
fireEvent.mouseEnter(item);
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(item).toBeVisible();
|
||||
fireEvent.mouseLeave(item);
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(screen.queryByText("시간 제한")).not.toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("provides dismissible popover and data/navigation patterns", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPage = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Popover triggerLabel="설명 열기">
|
||||
<Button>내부 작업</Button>
|
||||
</Popover>
|
||||
<DataTable
|
||||
caption="결과"
|
||||
columns={[
|
||||
{ id: "name", header: "이름", cell: (row) => row.name },
|
||||
]}
|
||||
empty="비어 있음"
|
||||
rowKey={(row) => row.id}
|
||||
rows={[{ id: "one", name: "첫 항목" }]}
|
||||
/>
|
||||
<Pagination
|
||||
label="페이지"
|
||||
nextLabel="다음"
|
||||
onChange={onPage}
|
||||
page={1}
|
||||
pageCount={2}
|
||||
pageLabel={(page) => `${page}페이지`}
|
||||
previousLabel="이전"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const popoverTrigger = screen.getByRole("button", { name: "설명 열기" });
|
||||
await user.click(popoverTrigger);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("내부 작업");
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(popoverTrigger).toHaveFocus();
|
||||
expect(screen.getByRole("table", { name: "결과" })).toHaveTextContent(
|
||||
"첫 항목",
|
||||
);
|
||||
await user.click(screen.getByRole("link", { name: "2페이지" }));
|
||||
expect(onPage).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import { Card } from "../../src/presentation/components/ui/card.ts";
|
||||
|
||||
describe("design-token fixture", () => {
|
||||
it("uses static semantic primitive classes", () => {
|
||||
render(
|
||||
<Card title="Design token fixture">
|
||||
<Button>Token action</Button>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByRole("article")).toHaveClass("ui-card");
|
||||
expect(screen.getByRole("button")).toHaveClass("ui-button");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import {
|
||||
DirtyNavigationDialog,
|
||||
ErrorSummary,
|
||||
Form,
|
||||
FormField,
|
||||
useAppForm,
|
||||
useDirtyNavigationGuard,
|
||||
} from "../../src/presentation/forms/index.ts";
|
||||
|
||||
type Values = Readonly<Record<"name" | "note", string>>;
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().trim().min(2),
|
||||
note: z.string().trim().default(""),
|
||||
})
|
||||
.strict();
|
||||
const defaults: Values = { name: "", note: "" };
|
||||
|
||||
function FormHarness(props: Readonly<{
|
||||
submit(command: Readonly<{ name: string; note?: string }>): Promise<
|
||||
| Readonly<{ ok: true; value: string }>
|
||||
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
|
||||
>;
|
||||
resetOnSuccess?: boolean;
|
||||
}>) {
|
||||
const form = useAppForm({
|
||||
schema,
|
||||
defaultValues: defaults,
|
||||
allowedServerFields: ["name", "note"],
|
||||
mapToCommand(values) {
|
||||
return {
|
||||
name: values.name,
|
||||
...(values.note ? { note: values.note } : {}),
|
||||
};
|
||||
},
|
||||
submit: props.submit,
|
||||
resetOnSuccess: props.resetOnSuccess,
|
||||
});
|
||||
return (
|
||||
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
|
||||
<ErrorSummary
|
||||
fieldErrors={form.fieldErrors}
|
||||
formErrors={form.formErrors}
|
||||
fieldLabels={{ name: "Name", note: "Note" }}
|
||||
fieldId={form.fieldId}
|
||||
onFocusField={form.focusField}
|
||||
/>
|
||||
<FormField {...form.field("name")} label="Name" required />
|
||||
<FormField {...form.field("note")} label="Note" />
|
||||
<Button type="submit" disabled={form.pending}>
|
||||
{form.pending ? "Pending" : "Submit"}
|
||||
</Button>
|
||||
<Button onClick={() => form.reset()} disabled={!form.dirty}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={() => form.settleApplied()}>Confirm applied</Button>
|
||||
<Button onClick={() => form.settleNotApplied()}>
|
||||
Confirm not applied
|
||||
</Button>
|
||||
<output data-testid="dirty">{String(form.dirty)}</output>
|
||||
<output data-testid="result">{form.result}</output>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
describe("local form facade", () => {
|
||||
it("focuses the first invalid field and performs no command", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi.fn();
|
||||
render(<FormHarness submit={submit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveFocus();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Name");
|
||||
});
|
||||
|
||||
it("submits transformed data once and clears dirty state after success", async () => {
|
||||
const user = userEvent.setup();
|
||||
let finish: ((value: { ok: true; value: string }) => void) | undefined;
|
||||
const submit = vi.fn(
|
||||
() =>
|
||||
new Promise<{ ok: true; value: string }>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
render(<FormHarness submit={submit} />);
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), " Ready ");
|
||||
await user.type(screen.getByRole("textbox", { name: "Note" }), " Safe ");
|
||||
|
||||
await user.dblClick(screen.getByRole("button", { name: "Submit" }));
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
|
||||
expect(submit).toHaveBeenCalledWith({ name: "Ready", note: "Safe" });
|
||||
expect(screen.getByRole("button", { name: "Pending" })).toBeDisabled();
|
||||
finish?.({ ok: true, value: "saved" });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("dirty")).toHaveTextContent("false"));
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("success");
|
||||
});
|
||||
|
||||
it("maps only approved 422 fields and never renders backend copy", async () => {
|
||||
const user = userEvent.setup();
|
||||
const failure = createFailure(
|
||||
"VALIDATION_REJECTED",
|
||||
"CREATE_ENTITY",
|
||||
0,
|
||||
{
|
||||
validationIssues: [
|
||||
{ path: "name", code: "REQUIRED" },
|
||||
{ path: "serverOnly", code: "raw-secret-message" },
|
||||
],
|
||||
},
|
||||
);
|
||||
render(
|
||||
<FormHarness submit={async () => ({ ok: false, error: failure })} />,
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Valid");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("필수 입력값입니다.");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"서버가 확인하지 못한 입력 항목",
|
||||
);
|
||||
expect(document.body).not.toHaveTextContent("raw-secret-message");
|
||||
});
|
||||
|
||||
it("keeps conflict input out of URL and storage", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.clear();
|
||||
window.history.replaceState({}, "", "/form-test");
|
||||
render(
|
||||
<FormHarness
|
||||
submit={async () => ({
|
||||
ok: false,
|
||||
error: createFailure("CONFLICT", "CREATE_ENTITY", 0),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const secretLike = "token-like-do-not-copy";
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), secretLike);
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
||||
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveValue(secretLike);
|
||||
expect(window.location.href).not.toContain(secretLike);
|
||||
expect(JSON.stringify(localStorage)).not.toContain(secretLike);
|
||||
});
|
||||
|
||||
it("blocks a second submit while the prior effect remains unknown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
}));
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
const name = screen.getByRole("textbox", { name: /Name/ });
|
||||
await user.type(name, "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
expect(await screen.findByTestId("result")).toHaveTextContent(
|
||||
"effect-unknown",
|
||||
);
|
||||
|
||||
await user.clear(name);
|
||||
await user.type(name, "Beta");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("effect-unknown");
|
||||
});
|
||||
|
||||
it("settles the submitted unknown snapshot without accepting later edits", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
}));
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
const name = screen.getByRole("textbox", { name: /Name/ });
|
||||
await user.type(name, "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await screen.findByText("effect-unknown");
|
||||
await user.clear(name);
|
||||
await user.type(name, "Beta");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Confirm applied" }));
|
||||
|
||||
expect(name).toHaveValue("Beta");
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("success");
|
||||
expect(screen.getByTestId("dirty")).toHaveTextContent("true");
|
||||
await user.clear(name);
|
||||
await user.type(name, "Alpha");
|
||||
expect(screen.getByTestId("dirty")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
it("releases an unknown submission only after explicit not-applied settlement", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submit = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false as const,
|
||||
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
|
||||
effect: "MAYBE_APPLIED",
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true as const, value: "saved" });
|
||||
render(<FormHarness submit={submit} resetOnSuccess={false} />);
|
||||
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Alpha");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await screen.findByText("effect-unknown");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Confirm not applied" }),
|
||||
);
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("idle");
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirty navigation guard", () => {
|
||||
it("blocks navigation, restores focus on stay and proceeds explicitly", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function GuardedPage() {
|
||||
const navigate = useNavigate();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const guard = useDirtyNavigationGuard(dirty);
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="guard-field">Guard field</label>
|
||||
<input
|
||||
id="guard-field"
|
||||
onChange={() => setDirty(true)}
|
||||
/>
|
||||
<Button onClick={() => navigate("/target")}>Leave</Button>
|
||||
<DirtyNavigationDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: "/", element: <GuardedPage /> },
|
||||
{ path: "/target", element: <h1>Target</h1> },
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
render(<RouterProvider router={router} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Guard field" }), "x");
|
||||
const leave = screen.getByRole("button", { name: "Leave" });
|
||||
await user.click(leave);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
|
||||
).toHaveAttribute("open");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "계속 작성" }));
|
||||
await waitFor(() => expect(leave).toHaveFocus());
|
||||
await user.click(leave);
|
||||
await user.click(screen.getByRole("button", { name: "변경 버리고 이동" }));
|
||||
expect(await screen.findByRole("heading", { name: "Target" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Pagination,
|
||||
Tabs,
|
||||
} from "../../src/presentation/design-system/index.ts";
|
||||
import {
|
||||
LocaleProvider,
|
||||
useLocale,
|
||||
type SupportedLocale,
|
||||
} from "../../src/presentation/i18n/index.ts";
|
||||
|
||||
function LocaleHarness() {
|
||||
const { direction, locale, message, setLocale } = useLocale();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="test-locale">{message("shell.locale")}</label>
|
||||
<select
|
||||
id="test-locale"
|
||||
onChange={(event) =>
|
||||
setLocale(event.currentTarget.value as SupportedLocale)
|
||||
}
|
||||
value={locale}
|
||||
>
|
||||
<option value="ko-KR">한국어</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="ar-EG">RTL</option>
|
||||
</select>
|
||||
<output>{`${locale}:${direction}`}</output>
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
{message("shell.menu")}
|
||||
</Button>
|
||||
<Drawer
|
||||
closeLabel={message("shell.closeMenu")}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
open={drawerOpen}
|
||||
title={message("shell.menu")}
|
||||
>
|
||||
content
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("locale provider runtime", () => {
|
||||
it("switches copy and synchronizes the document language and direction", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LocaleProvider>
|
||||
<LocaleHarness />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
|
||||
expect(document.documentElement).toHaveAttribute("lang", "ko-KR");
|
||||
await user.selectOptions(screen.getByLabelText("언어"), "en-US");
|
||||
expect(screen.getByLabelText("Language")).toHaveValue("en-US");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "en-US");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Language"), "ar-EG");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "ar-EG");
|
||||
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
||||
await user.click(screen.getByRole("button", { name: "Menu" }));
|
||||
expect(screen.getByRole("dialog", { name: "Menu" })).toHaveAttribute(
|
||||
"open",
|
||||
);
|
||||
});
|
||||
|
||||
it("reverses horizontal tab focus semantics in RTL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LocaleProvider initialLocale="ar-EG">
|
||||
<Tabs
|
||||
activation="manual"
|
||||
label="sections"
|
||||
tabs={[
|
||||
{ id: "one", label: "One", panel: "One panel" },
|
||||
{ id: "two", label: "Two", panel: "Two panel" },
|
||||
{ id: "three", label: "Three", panel: "Three panel" },
|
||||
]}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
const first = screen.getByRole("tab", { name: "One" });
|
||||
first.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Three" })).toHaveFocus();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(first).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps pagination semantics while direction-aware icons remain decorative", () => {
|
||||
render(
|
||||
<LocaleProvider initialLocale="ar-EG">
|
||||
<Pagination
|
||||
label="pages"
|
||||
nextLabel="Next page"
|
||||
onChange={() => {}}
|
||||
page={2}
|
||||
pageCount={3}
|
||||
pageLabel={(page) => `Page ${page}`}
|
||||
previousLabel="Previous page"
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PageHeader } from "../../src/presentation/components/page-header.tsx";
|
||||
|
||||
describe("page header focus ownership", () => {
|
||||
it("takes over focus handed off by the route main region", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<main id="main-content" tabIndex={-1} />
|
||||
<PageHeader title="Loading" />
|
||||
</>,
|
||||
);
|
||||
const main = screen.getByRole("main");
|
||||
main.focus();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<main id="main-content" tabIndex={-1} />
|
||||
<PageHeader title="Loaded" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Loaded" }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not steal focus from a user-controlled element", () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<button type="button">Menu</button>
|
||||
<PageHeader title="Loading" />
|
||||
</>,
|
||||
);
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
menu.focus();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<button type="button">Menu</button>
|
||||
<PageHeader title="Loaded" />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(menu).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CollectionPage,
|
||||
DetailPage,
|
||||
FormPage,
|
||||
StandardPage,
|
||||
StatusPage,
|
||||
} from "../../src/presentation/templates/index.ts";
|
||||
|
||||
describe("page template slot contracts", () => {
|
||||
it("renders StandardPage minimum and full landmarks with one h1", () => {
|
||||
const { rerender } = render(
|
||||
<StandardPage heading={{ title: "Minimum" }}>Content</StandardPage>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Minimum" })).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<StandardPage
|
||||
heading={{ title: "Full", description: "Long heading contract" }}
|
||||
breadcrumb={<a href="/">Home</a>}
|
||||
status={<span>Ready</span>}
|
||||
actions={[
|
||||
{ kind: "button", label: "Action", onAction: () => {} },
|
||||
]}
|
||||
notices={<p>Notice</p>}
|
||||
feedback={<p role="status">Refreshing</p>}
|
||||
aside={<p>Aside</p>}
|
||||
>
|
||||
Content
|
||||
</StandardPage>,
|
||||
);
|
||||
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
|
||||
expect(screen.getByRole("navigation", { name: "현재 위치" })).toBeVisible();
|
||||
expect(screen.getByRole("complementary", { name: "관련 정보" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("places collection, detail and form state in stable slots", () => {
|
||||
const { rerender } = render(
|
||||
<CollectionPage
|
||||
heading={{ title: "Collection" }}
|
||||
toolbar={<button type="button">Filter</button>}
|
||||
resultCount="12 results"
|
||||
pagination={<a href="?page=2">Next</a>}
|
||||
>
|
||||
Results
|
||||
</CollectionPage>,
|
||||
);
|
||||
expect(screen.getByRole("region", { name: "검색과 필터" })).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "페이지 탐색" })).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<DetailPage
|
||||
heading={{ title: "Detail" }}
|
||||
metadata={<dl><dt>ID</dt><dd>1</dd></dl>}
|
||||
destructiveAction={<button type="button">Delete</button>}
|
||||
>
|
||||
Sections
|
||||
</DetailPage>,
|
||||
);
|
||||
expect(screen.getByRole("region", { name: "요약 정보" })).toBeVisible();
|
||||
expect(screen.getByRole("region", { name: "위험 작업" })).toBeVisible();
|
||||
|
||||
rerender(
|
||||
<FormPage
|
||||
heading={{ title: "Form" }}
|
||||
errorSummary={<p role="alert">Invalid</p>}
|
||||
fields={<input aria-label="Field" />}
|
||||
formActions={<button type="button">Save</button>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toBeVisible();
|
||||
expect(screen.getByRole("textbox", { name: "Field" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders safe status variants without raw failure values", () => {
|
||||
render(
|
||||
<StatusPage
|
||||
variant="offline"
|
||||
heading={{ title: "Offline", description: "Safe recovery copy" }}
|
||||
primaryAction={{ kind: "button", label: "Retry", onAction: () => {} }}
|
||||
supportReference="SAFE-123"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Offline" })).toBeVisible();
|
||||
expect(screen.getByText(/SAFE-123/)).toBeVisible();
|
||||
expect(document.body).not.toHaveTextContent("stack");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
|
||||
import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
import PlatformOverviewPage from "../../src/presentation/examples/platform-overview-page.tsx";
|
||||
import { LocaleProvider } from "../../src/presentation/i18n/index.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
|
||||
|
||||
/**
|
||||
* The page must stay a projection of the installed registries. Every assertion
|
||||
* below derives its expectation from the same registry the page reads, so a
|
||||
* template that removes its sample feature still satisfies this suite.
|
||||
*/
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ApplicationProvider application={createTestApplication()}>
|
||||
<LocaleProvider>
|
||||
<PlatformOverviewPage />
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function tableByCaption(caption: string): HTMLElement {
|
||||
return screen.getByRole("table", { name: caption });
|
||||
}
|
||||
|
||||
/** A metric is a `dt`/`dd` pair, which carries no ARIA role to query by. */
|
||||
function metricByLabel(scope: HTMLElement, label: string): HTMLElement {
|
||||
const term = within(scope).getByText(label).closest(".platform-metric");
|
||||
if (!(term instanceof HTMLElement)) {
|
||||
throw new Error(`No metric is labelled ${label}`);
|
||||
}
|
||||
return term;
|
||||
}
|
||||
|
||||
describe("platform overview page", () => {
|
||||
it("renders one route row per installed route registry entry", () => {
|
||||
renderPage();
|
||||
|
||||
const table = tableByCaption("설치된 라우트 목록");
|
||||
const dataRows = within(table).getAllByRole("row").slice(1);
|
||||
|
||||
expect(dataRows).toHaveLength(Object.keys(ROUTE_REGISTRY).length);
|
||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
expect(
|
||||
within(table).getByText(definition.routeId),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders one operation row per installed HTTP contract", () => {
|
||||
const operationIds = [
|
||||
...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.keys(),
|
||||
];
|
||||
renderPage();
|
||||
|
||||
if (operationIds.length === 0) {
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "설치된 HTTP 오퍼레이션이 없습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
const table = tableByCaption("설치된 HTTP 오퍼레이션");
|
||||
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
|
||||
operationIds.length,
|
||||
);
|
||||
for (const operationId of operationIds) {
|
||||
expect(within(table).getByText(operationId)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders every fixed server-state profile with its own budget", () => {
|
||||
renderPage();
|
||||
|
||||
const table = tableByCaption("서버 상태 프로파일");
|
||||
for (const profile of Object.values(SERVER_STATE_PROFILES)) {
|
||||
expect(within(table).getByText(profile.profileId)).toBeInTheDocument();
|
||||
}
|
||||
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
|
||||
Object.keys(SERVER_STATE_PROFILES).length,
|
||||
);
|
||||
});
|
||||
|
||||
it("labels a capability that was never selected as unselected", () => {
|
||||
renderPage();
|
||||
|
||||
const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
|
||||
|
||||
expect(within(region).getAllByText("미선택")).toHaveLength(4);
|
||||
expect(within(region).queryByText("운영자가 비활성화함")).toBeNull();
|
||||
});
|
||||
|
||||
it("separates an operator disable from a capability that was never selected", () => {
|
||||
render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
runtimeCapabilities: createRuntimeCapabilitiesStub({
|
||||
SERVICE_WORKER: { selected: 1, active: 0, override: "DISABLED" },
|
||||
OFFLINE_COMMANDS: { selected: 1, active: 1 },
|
||||
}),
|
||||
})}
|
||||
>
|
||||
<LocaleProvider>
|
||||
<PlatformOverviewPage />
|
||||
</LocaleProvider>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
|
||||
const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
|
||||
|
||||
expect(within(region).getByText("운영자가 비활성화함")).toBeVisible();
|
||||
expect(within(region).getByText("활성 (1)")).toBeVisible();
|
||||
expect(within(region).getAllByText("미선택")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("counts installed contract packages separately from template fixtures", () => {
|
||||
const fixtures = COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
|
||||
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
|
||||
).length;
|
||||
renderPage();
|
||||
|
||||
const summary = screen.getByRole("region", { name: "설치 요약" });
|
||||
const packages = metricByLabel(summary, "외부 계약 패키지");
|
||||
|
||||
expect(
|
||||
within(packages).getByText(
|
||||
`${COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages.length}개`,
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(packages).getByText(`템플릿 픽스처 ${fixtures}개`),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(metricByLabel(summary, "라우트")).getByText(
|
||||
`${Object.keys(ROUTE_REGISTRY).length}개`,
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders the verified release identity once the runtime resolves it", async () => {
|
||||
renderPage();
|
||||
|
||||
const region = screen.getByRole("region", { name: "릴리스 신원" });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(metricByLabel(region, "빌드")).getByText("test-build"),
|
||||
).toBeVisible(),
|
||||
);
|
||||
expect(
|
||||
within(metricByLabel(region, "릴리스")).getByText("test-release"),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.ts";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.tsx";
|
||||
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.tsx";
|
||||
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.tsx";
|
||||
|
||||
function Defect(): ReactNode {
|
||||
throw new Error("raw render stack");
|
||||
}
|
||||
|
||||
describe("render recovery boundaries", () => {
|
||||
it("catches programmer defects and emits best-effort safe telemetry", () => {
|
||||
const onRenderFailure = vi.fn();
|
||||
render(
|
||||
<FeatureBoundary
|
||||
routeId="APP_HOME"
|
||||
buildId="build-a"
|
||||
onRenderFailure={onRenderFailure}
|
||||
>
|
||||
<Defect />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"화면을 표시하지 못했습니다.",
|
||||
);
|
||||
expect(onRenderFailure).toHaveBeenCalledWith({
|
||||
routeId: "APP_HOME",
|
||||
buildId: "build-a",
|
||||
boundaryName: "feature",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps normalized operational failures in normal async state", () => {
|
||||
const state = deriveAsyncState({
|
||||
failure: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
});
|
||||
render(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<AsyncSurface state={state} />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"요청을 완료하지 못했습니다.",
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveAttribute(
|
||||
"data-message-key",
|
||||
"error.server_failure",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a safe boot shell with no endpoint or stack", () => {
|
||||
render(
|
||||
<BootErrorShell
|
||||
kind="BOOT_CONFIG_FAILURE"
|
||||
code="CONFIG_SCHEMA_INVALID"
|
||||
buildId="build-a"
|
||||
configSchemaVersion="1"
|
||||
supportReference="build-a:CONFIG_SCHEMA_INVALID"
|
||||
/>,
|
||||
);
|
||||
const shell = screen.getByRole("alert");
|
||||
expect(shell).toHaveTextContent("build-a:CONFIG_SCHEMA_INVALID");
|
||||
expect(shell).not.toHaveTextContent("https://");
|
||||
expect(shell).not.toHaveTextContent("stack");
|
||||
});
|
||||
|
||||
it("allows a boundary reset action without reloading the page", async () => {
|
||||
let shouldThrow = true;
|
||||
function Recoverable() {
|
||||
if (shouldThrow) throw new Error("defect");
|
||||
return <p>recovered</p>;
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
const view = render(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<Recoverable />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
shouldThrow = false;
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
view.rerender(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<Recoverable />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(screen.getByText("recovered")).toBeVisible();
|
||||
});
|
||||
|
||||
it("resets a route failure when the registered location key changes", async () => {
|
||||
let shouldThrow = true;
|
||||
function RouteContent() {
|
||||
if (shouldThrow) throw new Error("route defect");
|
||||
return <p>next route</p>;
|
||||
}
|
||||
const view = render(
|
||||
<FeatureBoundary
|
||||
routeId="APP_HOME"
|
||||
buildId="build-a"
|
||||
resetKey="/first"
|
||||
>
|
||||
<RouteContent />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toBeVisible();
|
||||
|
||||
shouldThrow = false;
|
||||
view.rerender(
|
||||
<FeatureBoundary
|
||||
routeId="APP_HOME"
|
||||
buildId="build-a"
|
||||
resetKey="/second"
|
||||
>
|
||||
<RouteContent />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(await screen.findByText("next route")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||
import { AppRouter } from "../../src/presentation/routes/app-router.tsx";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
|
||||
function renderRouter() {
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
})}
|
||||
>
|
||||
<AppRouter />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("generic application router", () => {
|
||||
it("reaches the platform overview from the home starter actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
renderRouter();
|
||||
|
||||
// The starter action lives inside the lazily loaded home chunk, so this is
|
||||
// the first wait in the file that has to outlast a chunk load rather than
|
||||
// an already-mounted shell element.
|
||||
await user.click(
|
||||
await screen.findByRole(
|
||||
"link",
|
||||
{ name: "플랫폼 구성 보기" },
|
||||
{ timeout: 5000 },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the app shell and not-found route without a feature input", async () => {
|
||||
window.history.pushState({}, "", "/missing");
|
||||
renderRouter();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "페이지를 찾을 수 없습니다.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
expect(screen.getByRole("main")).toBeVisible();
|
||||
});
|
||||
|
||||
it("navigates between registry-backed platform routes", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
renderRouter();
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("link", { name: "UI 구성요소" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||
).toBeVisible();
|
||||
expect(window.location.pathname).toBe("/examples/ui");
|
||||
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("focuses the route heading again when the lazy chunk is already cached", async () => {
|
||||
// §9.7. The first visit resolves the route module asynchronously, so the
|
||||
// router lifecycle and the page header commit separately. A later visit
|
||||
// renders the cached module in the same commit, which is the ordering that
|
||||
// must still hand focus to the heading rather than leaving it on main.
|
||||
const user = userEvent.setup();
|
||||
window.history.pushState({}, "", "/");
|
||||
renderRouter();
|
||||
|
||||
for (const label of ["UI 구성요소", "화면 상태", "UI 구성요소"]) {
|
||||
await user.click(await screen.findByRole("link", { name: label }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: label, level: 1 }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRuntimeComposition } from "../../src/bootstrap/create-runtime-composition.ts";
|
||||
import { RuntimeApplication } from "../../src/bootstrap/runtime-application.tsx";
|
||||
|
||||
const runtimeConfig = {
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "http://localhost:8080",
|
||||
REQUEST_TIMEOUT_MS: 10_000,
|
||||
MAX_RETRY_ATTEMPTS: 2,
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo",
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
API_CONTRACT_VERSION: "1",
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "local-build",
|
||||
RELEASE_ID: "local-release",
|
||||
};
|
||||
|
||||
const releaseManifest = {
|
||||
schemaVersion: 1,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "local-build",
|
||||
commitSha: "local",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "test-hash",
|
||||
releaseId: "local-release",
|
||||
builtAt: "2026-07-26T00:00:00.000Z",
|
||||
routeChunks: {
|
||||
"route-home": "assets/home.js",
|
||||
"route-examples-ui": "assets/ui.js",
|
||||
"route-examples-states": "assets/states.js",
|
||||
"route-examples-auth": "assets/auth.js",
|
||||
"route-not-found": "assets/not-found.js",
|
||||
},
|
||||
};
|
||||
|
||||
describe("production runtime application tree", () => {
|
||||
it("connects validated config and release through composition and ApplicationProvider", async () => {
|
||||
const fetcher = vi.fn(async (input) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
return Response.json(
|
||||
url.includes("release-manifest") ? releaseManifest : runtimeConfig,
|
||||
);
|
||||
});
|
||||
const composition = await createRuntimeComposition({
|
||||
fetcher,
|
||||
host: {},
|
||||
});
|
||||
|
||||
window.history.pushState({}, "", "/");
|
||||
render(<RuntimeApplication composition={composition} />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "Clean Architecture Frontend",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText("빌드 local-build · 릴리스 local-release"),
|
||||
).toBeVisible();
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(composition).not.toHaveProperty("ports");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import { createServerStateGenerationStore } from "../../src/bootstrap/server-state-generation-store.ts";
|
||||
import { ServerStateGenerationProvider } from "../../src/presentation/adapters/query/server-state-generation-provider.tsx";
|
||||
|
||||
describe("server-state generation provider", () => {
|
||||
it("remounts consumers with the QueryClient owned by the READY generation", async () => {
|
||||
const store = createServerStateGenerationStore(() => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return {
|
||||
queryClient,
|
||||
queryInvalidation: {
|
||||
invalidate: async () => {},
|
||||
beginMutation: () => ({ release: async () => {} }),
|
||||
resetLocal: async () => {
|
||||
await queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
},
|
||||
dispose() {},
|
||||
},
|
||||
crossContextStatus: () => "DEGRADED_LOCAL_ONLY" as const,
|
||||
};
|
||||
});
|
||||
let sessionListener: () => void = () => {};
|
||||
let token = 0;
|
||||
const scope = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: { resetLocal: () => store.resetCurrent() },
|
||||
activateNextGeneration: () => store.activateNext(),
|
||||
tokenFactory: () =>
|
||||
`scope-generation-provider-${String(token++).padStart(4, "0")}`,
|
||||
});
|
||||
const renderedClients: QueryClient[] = [];
|
||||
const mutationIntentFactory = Object.freeze({
|
||||
create() {
|
||||
throw new Error("mutation intent is unused by this provider test");
|
||||
},
|
||||
});
|
||||
function Probe() {
|
||||
renderedClients.push(useQueryClient());
|
||||
return <div>generation-content</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<ServerStateGenerationProvider
|
||||
store={store}
|
||||
scope={scope}
|
||||
mutationIntentFactory={mutationIntentFactory}
|
||||
transitionFallback={<div>scope-transition</div>}
|
||||
>
|
||||
<Probe />
|
||||
</ServerStateGenerationProvider>,
|
||||
);
|
||||
const firstClient = renderedClients.at(-1);
|
||||
if (!firstClient) throw new Error("expected initial QueryClient");
|
||||
|
||||
act(() => sessionListener());
|
||||
|
||||
await waitFor(() => expect(scope.getPhase()).toBe("READY"));
|
||||
await waitFor(() =>
|
||||
expect(renderedClients.at(-1)).toBe(store.getSnapshot().queryClient),
|
||||
);
|
||||
expect(renderedClients.at(-1)).not.toBe(firstClient);
|
||||
|
||||
scope.dispose();
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
||||
import { ServerStateScopeProvider } from "../../src/presentation/adapters/query/server-state-scope-provider.tsx";
|
||||
|
||||
const activeRuntimes: Array<{ dispose(): void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const runtime of activeRuntimes.splice(0)) runtime.dispose();
|
||||
});
|
||||
|
||||
function scopeFixture(resetLocal: () => Promise<void>) {
|
||||
let sessionListener: () => void = () => {};
|
||||
let token = 0;
|
||||
const runtime = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: {
|
||||
resetLocal,
|
||||
},
|
||||
tokenFactory: () => `scope-provider-token-${String(token++).padStart(4, "0")}`,
|
||||
});
|
||||
activeRuntimes.push(runtime);
|
||||
return { runtime, triggerSessionChange: () => sessionListener() };
|
||||
}
|
||||
|
||||
describe("server-state scope provider", () => {
|
||||
it("removes previous-scope children synchronously while reset is pending", async () => {
|
||||
let completeReset: () => void = () => {};
|
||||
const reset = new Promise<void>((resolve) => {
|
||||
completeReset = resolve;
|
||||
});
|
||||
const fixture = scopeFixture(async () => reset);
|
||||
|
||||
render(
|
||||
<ServerStateScopeProvider
|
||||
runtime={fixture.runtime}
|
||||
transitionFallback={<div>scope-transition</div>}
|
||||
>
|
||||
<div>previous-account-secret</div>
|
||||
</ServerStateScopeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("previous-account-secret")).toBeVisible();
|
||||
|
||||
act(() => fixture.triggerSessionChange());
|
||||
|
||||
expect(screen.queryByText("previous-account-secret")).toBeNull();
|
||||
expect(screen.getByText("scope-transition")).toBeVisible();
|
||||
|
||||
completeReset();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("previous-account-secret")).toBeVisible(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never remounts previous-scope children after mandatory cleanup failure", async () => {
|
||||
const fixture = scopeFixture(async () => {
|
||||
throw new Error("reset failed");
|
||||
});
|
||||
render(
|
||||
<ServerStateScopeProvider
|
||||
runtime={fixture.runtime}
|
||||
transitionFallback={<div>scope-transition</div>}
|
||||
>
|
||||
<div>previous-account-secret</div>
|
||||
</ServerStateScopeProvider>,
|
||||
);
|
||||
|
||||
act(() => fixture.triggerSessionChange());
|
||||
|
||||
await waitFor(() => expect(fixture.runtime.getPhase()).toBe("FAILED"));
|
||||
expect(screen.queryByText("previous-account-secret")).toBeNull();
|
||||
expect(screen.getByText("scope-transition")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type StatusProps = Readonly<{
|
||||
label: string;
|
||||
tone: "neutral" | "positive";
|
||||
}>;
|
||||
|
||||
function Status({ label, tone }: StatusProps) {
|
||||
return <output data-tone={tone}>{label}</output>;
|
||||
}
|
||||
|
||||
describe("TSX test tooling", () => {
|
||||
it("parses, lints, type-checks, and renders TSX", () => {
|
||||
render(<Status label="ready" tone="positive" />);
|
||||
expect(screen.getByText("ready")).toHaveAttribute("data-tone", "positive");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Alert } from "../../src/presentation/components/ui/alert.ts";
|
||||
import { Badge } from "../../src/presentation/components/ui/badge.ts";
|
||||
import { Button } from "../../src/presentation/components/ui/button.ts";
|
||||
import { Card } from "../../src/presentation/components/ui/card.ts";
|
||||
import { Dialog } from "../../src/presentation/components/ui/dialog.ts";
|
||||
import { TextField } from "../../src/presentation/components/ui/text-field.ts";
|
||||
|
||||
describe("domain-neutral UI primitives", () => {
|
||||
it("connects field help and validation errors to the input", () => {
|
||||
render(
|
||||
<TextField
|
||||
label="이름"
|
||||
description="표시할 이름입니다."
|
||||
error="이름을 입력해 주세요."
|
||||
required
|
||||
/>,
|
||||
);
|
||||
|
||||
const field = screen.getByRole("textbox", { name: "이름" });
|
||||
expect(field).toBeRequired();
|
||||
expect(field).toHaveAccessibleDescription(
|
||||
"표시할 이름입니다. 이름을 입력해 주세요.",
|
||||
);
|
||||
expect(field).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("exposes semantic variants without changing native button behavior", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Button variant="danger" onClick={onClick}>
|
||||
제거
|
||||
</Button>
|
||||
<Button disabled>사용 불가</Button>
|
||||
</>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "제거" }));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("button", { name: "제거" })).toHaveClass(
|
||||
"ui-button--danger",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "사용 불가" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("labels cards, alerts, and badges with visible content", async () => {
|
||||
const user = userEvent.setup();
|
||||
const dismiss = vi.fn();
|
||||
render(
|
||||
<Card title="상태 카드" footer={<Badge variant="success">준비됨</Badge>}>
|
||||
<Alert title="저장됨" variant="success" onDismiss={dismiss}>
|
||||
안전하게 반영했습니다.
|
||||
</Alert>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("article", { name: "상태 카드" })).toBeVisible();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"저장됨안전하게 반영했습니다.",
|
||||
);
|
||||
expect(screen.getByText("준비됨")).toHaveClass("ui-badge--success");
|
||||
await user.click(screen.getByRole("button", { name: "저장됨 알림 닫기" }));
|
||||
expect(dismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes a modal and restores focus to its trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function DialogHarness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>모달 열기</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="연동 확인"
|
||||
actions={<Button onClick={() => setOpen(false)}>확인</Button>}
|
||||
>
|
||||
안전한 설명
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<DialogHarness />);
|
||||
const trigger = screen.getByRole("button", { name: "모달 열기" });
|
||||
await user.click(trigger);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "연동 확인" })).toHaveAttribute(
|
||||
"open",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "확인" }));
|
||||
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
expect(screen.getByRole("dialog", { hidden: true })).not.toHaveAttribute(
|
||||
"open",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user