Vendors the canonical studio-v1.yaml, generates types via an isolated `pnpm dlx` toolchain (openapi-typescript needs TypeScript 5's classic compiler API; this repo pins TypeScript 7.0.2 per VD-01, whose root export has none), and adds an offline drift gate that checks the vendored yaml/generated types/canonical-source.json against each other without touching the sibling design-package repo or the network. Regenerating from canonical surfaces real, new required fields on existing schemas (WorkingCopyDetail.nextAction, PreviewDetail/PublicPreview .dependencyRevision, StudioDashboard.totals.needsValidation, PublicationSnapshot.contentFormatVersion/rendererContractVersion) and a new required EvidenceFigureBlock.asset. The mock gateway and fixtures are updated to satisfy the former; the latter exposes a real authoring- vs-rendering conflation in the content-format parser (it declared its output as the server's fully-resolved PublicRenderModel type, which it has no asset catalog to satisfy). Split that boundary: the parser now produces an authoring block type omitting the resolved asset, and each of its three consumers (the mock gateway, the Studio instant preview, and the static Case demo page) attaches the resolved descriptor from its own asset source through a shared, pure domain-level resolver. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
639 lines
22 KiB
TypeScript
639 lines
22 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
getEvidenceAsset,
|
|
isSupportedEvidenceKey,
|
|
resolveEvidenceAssetDescriptor,
|
|
} from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
|
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
|
|
import { parseCaseContent } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
|
import { Callout } from "../../../src/features/tech-log/presentation/shared/public-render/callout.tsx";
|
|
import { CaseBodyRenderer } from "../../../src/features/tech-log/presentation/shared/public-render/case-body-renderer.tsx";
|
|
import { CodeBlock } from "../../../src/features/tech-log/presentation/shared/public-render/code-block.tsx";
|
|
import { DataTable } from "../../../src/features/tech-log/presentation/shared/public-render/data-table.tsx";
|
|
import { DocumentToc } from "../../../src/features/tech-log/presentation/shared/public-render/document-toc.tsx";
|
|
import { EvidenceFigure } from "../../../src/features/tech-log/presentation/shared/public-render/fetch-strategy-evidence-figure.tsx";
|
|
import { InlineRenderer } from "../../../src/features/tech-log/presentation/shared/public-render/inline-renderer.tsx";
|
|
import { PublicEvidenceFigure } from "../../../src/features/tech-log/presentation/shared/public-render/evidence-figure.tsx";
|
|
import { PublicRecordRenderer } from "../../../src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx";
|
|
|
|
class NoopIntersectionObserver implements IntersectionObserver {
|
|
readonly root = null;
|
|
readonly rootMargin = "0px";
|
|
readonly scrollMargin = "0px";
|
|
readonly thresholds = [0];
|
|
|
|
disconnect() {}
|
|
observe() {}
|
|
takeRecords(): IntersectionObserverEntry[] {
|
|
return [];
|
|
}
|
|
unobserve() {}
|
|
}
|
|
|
|
const originalShowModal = HTMLDialogElement.prototype.showModal;
|
|
const originalClose = HTMLDialogElement.prototype.close;
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
|
|
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
|
configurable: true,
|
|
value(this: HTMLDialogElement) {
|
|
this.setAttribute("open", "");
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
|
configurable: true,
|
|
value(this: HTMLDialogElement) {
|
|
this.removeAttribute("open");
|
|
this.dispatchEvent(new Event("close"));
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
|
configurable: true,
|
|
value: originalShowModal,
|
|
});
|
|
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
|
configurable: true,
|
|
value: originalClose,
|
|
});
|
|
});
|
|
|
|
const renderDependencies = {
|
|
resolveEvidenceAsset: getEvidenceAsset,
|
|
resolvePublishedLabel: (path: string) =>
|
|
path === "/cases/collection-fetch-join-pagination"
|
|
? "2026.08.14"
|
|
: undefined,
|
|
};
|
|
|
|
// `parseCaseContent` returns authoring blocks (no asset catalog access); the
|
|
// renderer under test expects the resolved `CaseRenderBlock` shape, so this
|
|
// mirrors what `adapters/mock/project-public-render-model.ts` does in production.
|
|
function resolvedBlocks(
|
|
blocks: ReturnType<typeof parseCaseContent>,
|
|
): components["schemas"]["CaseRenderBlock"][] {
|
|
return blocks.map((block) => {
|
|
if (block.type !== "EVIDENCE_FIGURE") return block;
|
|
if (!isSupportedEvidenceKey(block.key)) {
|
|
throw new Error(`Unknown local evidence asset: ${block.key}`);
|
|
}
|
|
return { ...block, asset: resolveEvidenceAssetDescriptor(block.key) };
|
|
});
|
|
}
|
|
|
|
function caseModel(
|
|
overrides: Partial<components["schemas"]["CasePublicRenderModel"]> = {},
|
|
): components["schemas"]["CasePublicRenderModel"] {
|
|
return {
|
|
kind: "CASE",
|
|
slug: "draft-case",
|
|
title: "Case 제목",
|
|
summary: "Case 요약",
|
|
publicPath: "/cases/draft-case",
|
|
topic: { id: "topic-jpa", label: "JPA", publicPath: "/topics/jpa" },
|
|
project: {
|
|
id: "project-backend",
|
|
label: "Backend Skeleton",
|
|
publicPath: "/projects/backend-skeleton",
|
|
},
|
|
relations: [
|
|
{
|
|
id: "relation-1",
|
|
targetId: "reference-1",
|
|
targetKind: "REFERENCE",
|
|
title: "연결된 기준",
|
|
publicPath: "/references/linked",
|
|
reason: "판단 기준",
|
|
order: 1,
|
|
},
|
|
],
|
|
renderContext: {
|
|
generatedAt: "2035-05-06T07:08:09Z",
|
|
dependencyRevision: "r1",
|
|
},
|
|
problem: "문제",
|
|
conclusion: "결론",
|
|
environment: "환경",
|
|
reproduction: "Dataset: 데이터셋",
|
|
lastVerifiedOn: "2030-01-02",
|
|
bodyBlocks: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function renderInRouter(node: React.ReactNode) {
|
|
return render(<MemoryRouter>{node}</MemoryRouter>);
|
|
}
|
|
|
|
describe("shared Public block renderer", () => {
|
|
it("preserves rich tags, classes, labels, order, and escaped text", () => {
|
|
const blocks = parseCaseContent(`안전한 < 문장과 > 기호
|
|
|
|
## *강조* **강함** \`코드\` {#rich-heading}
|
|
|
|
문단 [내부](/safe) :status[20건]{tone="warning"}
|
|
|
|
> 인용
|
|
|
|
- 첫째
|
|
- 둘째
|
|
|
|
1. 하나
|
|
2. 둘
|
|
|
|
\`\`\`sql label="실패한 목록 조회"
|
|
select * from feed_item;
|
|
\`\`\`
|
|
|
|
:::table id="metrics" caption="반환 손실" rowHeaderColumn="1"
|
|
| 상태 | 결과 |
|
|
| --- | ---: |
|
|
| before | 20 |
|
|
:::
|
|
|
|
:::callout tone="warning" label="주의"
|
|
안전한 안내입니다.
|
|
:::
|
|
|
|
:::evidence key="fetch-strategy-boundary" alt="비교 설명" caption="비교 근거" zoom="false"
|
|
:::`);
|
|
|
|
const view = render(
|
|
<CaseBodyRenderer
|
|
blocks={resolvedBlocks(blocks)}
|
|
resolveEvidenceAsset={getEvidenceAsset}
|
|
/>,
|
|
);
|
|
|
|
expect(view.container.firstElementChild?.textContent).toContain(
|
|
"안전한 < 문장과 > 기호",
|
|
);
|
|
expect(view.container.querySelector("script")).toBeNull();
|
|
const section = view.container.querySelector(
|
|
'section[aria-labelledby="rich-heading"]',
|
|
);
|
|
expect(section).not.toBeNull();
|
|
expect(section?.querySelector("h2#rich-heading .heading-anchor")).toHaveAttribute(
|
|
"aria-label",
|
|
"강조 강함 코드 바로가기",
|
|
);
|
|
expect(section?.querySelector("h2 em")?.textContent).toBe("강조");
|
|
expect(section?.querySelector("h2 strong")?.textContent).toBe("강함");
|
|
expect(section?.querySelector("h2 code")?.textContent).toBe("코드");
|
|
expect(section?.querySelector('a[href="/safe"]')?.textContent).toBe("내부");
|
|
expect(section?.querySelector(".status.status--warning")?.textContent).toBe(
|
|
"20건",
|
|
);
|
|
expect(section?.querySelector("blockquote")?.textContent).toBe("인용");
|
|
expect(
|
|
Array.from(section?.querySelectorAll("ul > li") ?? [], (item) =>
|
|
item.textContent,
|
|
),
|
|
).toEqual(["첫째", "둘째"]);
|
|
expect(
|
|
Array.from(section?.querySelectorAll("ol > li") ?? [], (item) =>
|
|
item.textContent,
|
|
),
|
|
).toEqual(["하나", "둘"]);
|
|
expect(
|
|
section?.querySelector('.code-block[data-content-role="재현 쿼리"]'),
|
|
).not.toBeNull();
|
|
expect(screen.getByRole("region", { name: "반환 손실 표" })).toHaveClass(
|
|
"data-table-wrap",
|
|
);
|
|
expect(screen.getByRole("complementary", { name: "주의" })).toHaveClass(
|
|
"callout--warning",
|
|
);
|
|
const image = screen.getByRole("img", { name: "비교 설명" });
|
|
expect(image).toHaveAttribute("src", "/media/fetch-strategy-boundary.svg");
|
|
expect(image).toHaveAttribute("width", "1080");
|
|
expect(image).toHaveAttribute("height", "420");
|
|
expect(image).toHaveAttribute("loading", "lazy");
|
|
expect(image.closest("figure")?.querySelector("figcaption")?.textContent).toBe(
|
|
"비교 근거",
|
|
);
|
|
});
|
|
|
|
it("renders root blocks before the first owned heading section", () => {
|
|
const view = render(
|
|
<CaseBodyRenderer
|
|
resolveEvidenceAsset={getEvidenceAsset}
|
|
blocks={[
|
|
{
|
|
type: "PARAGRAPH",
|
|
content: [{ type: "TEXT", text: "도입 문단" }],
|
|
},
|
|
{
|
|
type: "UNORDERED_LIST",
|
|
items: [
|
|
{
|
|
id: "root-item",
|
|
content: [{ type: "TEXT", text: "도입 목록" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "HEADING",
|
|
id: "details",
|
|
level: 2,
|
|
content: [{ type: "TEXT", text: "세부 내용" }],
|
|
},
|
|
{
|
|
type: "PARAGRAPH",
|
|
content: [{ type: "TEXT", text: "세부 문단" }],
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
expect(view.container.innerHTML.indexOf("도입 문단")).toBeLessThan(
|
|
view.container.innerHTML.indexOf("details"),
|
|
);
|
|
expect(view.container.innerHTML.indexOf("도입 목록")).toBeLessThan(
|
|
view.container.innerHTML.indexOf("details"),
|
|
);
|
|
expect(
|
|
view.container.querySelector('section[aria-labelledby="details"]'),
|
|
).toHaveTextContent("세부 내용#세부 문단");
|
|
});
|
|
|
|
it("escapes untrusted inline text instead of inserting HTML", () => {
|
|
const view = render(
|
|
<InlineRenderer
|
|
content={[
|
|
{
|
|
type: "TEXT",
|
|
text: '<img src=x onerror="alert(1)"><script>alert(2)</script>',
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
expect(view.container.querySelector("img")).toBeNull();
|
|
expect(view.container.querySelector("script")).toBeNull();
|
|
expect(view.container).toHaveTextContent(
|
|
'<img src=x onerror="alert(1)"><script>alert(2)</script>',
|
|
);
|
|
});
|
|
|
|
it("preserves callout and table relationships", () => {
|
|
const table: components["schemas"]["DataTableBlock"] = {
|
|
type: "DATA_TABLE",
|
|
id: "metrics",
|
|
caption: "측정",
|
|
rowHeaderColumn: null,
|
|
columns: [
|
|
{ id: "metrics-column-1", label: "이름", alignment: "LEFT" },
|
|
{ id: "metrics-column-2", label: "값", alignment: "RIGHT" },
|
|
],
|
|
rows: [
|
|
{
|
|
id: "metrics-row-1",
|
|
cells: [
|
|
{
|
|
columnId: "metrics-column-1",
|
|
content: [{ type: "TEXT", text: "before" }],
|
|
},
|
|
{
|
|
columnId: "metrics-column-2",
|
|
content: [{ type: "TEXT", text: "20" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const without = render(<DataTable block={table} />);
|
|
expect(without.container.querySelector("td")?.getAttribute("headers")).toBe(
|
|
"metrics-column-1",
|
|
);
|
|
expect(without.container.querySelector("td")?.getAttribute("headers")).not.toContain(
|
|
"metrics-row-1",
|
|
);
|
|
without.unmount();
|
|
|
|
const withHeader = render(
|
|
<DataTable block={{ ...table, rowHeaderColumn: 1 }} />,
|
|
);
|
|
expect(
|
|
withHeader.container.querySelector('th[id="metrics-row-1"][scope="row"]'),
|
|
).toHaveTextContent("before");
|
|
expect(
|
|
withHeader.container.querySelector('td[headers="metrics-row-1 metrics-column-2"]'),
|
|
).toHaveTextContent("20");
|
|
|
|
render(
|
|
<Callout
|
|
block={{
|
|
type: "CALLOUT",
|
|
tone: "info",
|
|
label: "정보",
|
|
content: [{ type: "TEXT", text: "설명" }],
|
|
}}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole("complementary", { name: "정보" })).toHaveTextContent(
|
|
"정보설명",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("shared Public record renderer", () => {
|
|
it("preserves the canonical Fetch Join layout, TOC, metadata, and relations", () => {
|
|
renderInRouter(
|
|
<PublicRecordRenderer
|
|
{...renderDependencies}
|
|
model={caseModel({
|
|
slug: "collection-fetch-join-pagination",
|
|
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
|
publicPath: "/cases/collection-fetch-join-pagination",
|
|
reproduction: "Dataset: 수정 중인 데이터셋",
|
|
bodyBlocks: resolvedBlocks(
|
|
parseCaseContent(
|
|
"## *강조* **강함** `코드` {#rich-heading}\n\n본문",
|
|
),
|
|
),
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(
|
|
screen.getByRole("main").querySelector("header.case-header"),
|
|
).not.toBeNull();
|
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
|
);
|
|
expect(screen.getByText("수정 중인 데이터셋")).toBeVisible();
|
|
expect(screen.getByText(/게시 2026\.08\.14 · 마지막 검증 2030\.01\.02/)).toBeVisible();
|
|
expect(screen.getAllByRole("navigation", { name: "문서 목차" })).toHaveLength(
|
|
1,
|
|
);
|
|
expect(screen.getByText("목차 · 강조 강함 코드")).toBeVisible();
|
|
expect(screen.getByRole("link", { name: "강조 강함 코드 바로가기" })).toBeVisible();
|
|
const relation = screen.getByRole("link", { name: /판단 기준연결된 기준/ });
|
|
expect(relation).toHaveAttribute("href", "/references/linked");
|
|
expect(relation.parentElement?.parentElement?.previousElementSibling).toHaveTextContent(
|
|
"이 기록의 연결",
|
|
);
|
|
});
|
|
|
|
it("uses generic Case markup and does not present generatedAt as publication", () => {
|
|
renderInRouter(
|
|
<PublicRecordRenderer
|
|
{...renderDependencies}
|
|
model={caseModel({
|
|
bodyBlocks: [
|
|
{
|
|
type: "PARAGRAPH",
|
|
content: [{ type: "TEXT", text: "일반 본문" }],
|
|
},
|
|
],
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
const main = screen.getByRole("main");
|
|
expect(main).toHaveClass("shell", "public-document-page");
|
|
expect(main).toHaveAttribute("id", "main-content");
|
|
expect(screen.getByText("게시 전")).toBeVisible();
|
|
expect(main).not.toHaveTextContent("2035.05.06");
|
|
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
|
"문제문제결론결론",
|
|
);
|
|
expect(screen.getByText("일반 본문")).toBeVisible();
|
|
});
|
|
|
|
it("renders supplied Reference and Question preview fields and empty copy", () => {
|
|
const base = {
|
|
slug: "preview",
|
|
title: "수정 중인 제목",
|
|
summary: "저장 전 요약",
|
|
publicPath: "/preview",
|
|
topic: { id: "topic", label: "수정 Topic", publicPath: null },
|
|
project: { id: "project", label: "수정 Project", publicPath: null },
|
|
relations: [],
|
|
renderContext: {
|
|
generatedAt: "2035-05-06T07:08:09Z",
|
|
dependencyRevision: "r1",
|
|
},
|
|
} satisfies Omit<components["schemas"]["PublicRenderModelBase"], "kind">;
|
|
const reference: components["schemas"]["ReferencePublicRenderModel"] = {
|
|
...base,
|
|
kind: "REFERENCE",
|
|
purpose: "기준 목적",
|
|
rules: [{ id: "rule", title: "규칙", body: "본문", order: 1 }],
|
|
applyWhen: [{ id: "apply", text: "적용", order: 1 }],
|
|
exceptions: [{ id: "exception", text: "예외", order: 1 }],
|
|
examples: [{ id: "example", text: "예시", order: 1 }],
|
|
verifiedOn: "2030-01-02",
|
|
};
|
|
const question: components["schemas"]["QuestionPublicRenderModel"] = {
|
|
...base,
|
|
kind: "QUESTION",
|
|
status: "OPEN",
|
|
facts: [{ id: "fact", text: "사실", order: 1 }],
|
|
assumptions: [],
|
|
unknowns: [],
|
|
constraints: [{ id: "constraint", text: "제약", order: 1 }],
|
|
options: [
|
|
{
|
|
id: "option",
|
|
title: "선택지",
|
|
description: "선택 설명",
|
|
order: 1,
|
|
},
|
|
],
|
|
nextValidation: "다음에도 측정합니다.",
|
|
resolution: null,
|
|
};
|
|
|
|
const referenceView = renderInRouter(
|
|
<PublicRecordRenderer {...renderDependencies} model={reference} embedded />,
|
|
);
|
|
expect(referenceView.container.querySelector("main")).toBeNull();
|
|
const embedded = referenceView.container.querySelector(
|
|
".public-record-embedded",
|
|
);
|
|
expect(embedded).toHaveTextContent("수정 중인 제목");
|
|
expect(embedded).toHaveTextContent("저장 전 요약");
|
|
expect(embedded).toHaveTextContent("수정 Topic");
|
|
expect(embedded).toHaveTextContent("수정 Project");
|
|
expect(screen.getByText("마지막 검증 2030.01.02")).toBeVisible();
|
|
referenceView.unmount();
|
|
|
|
renderInRouter(
|
|
<PublicRecordRenderer {...renderDependencies} model={question} />,
|
|
);
|
|
expect(screen.getByText("현재 기록된 가정이 없습니다.")).toBeVisible();
|
|
expect(screen.getByText("해결 과정에서 남은 미지수가 없습니다.")).toBeVisible();
|
|
expect(screen.getByRole("heading", { name: "다음 검증" }).nextElementSibling).toHaveTextContent(
|
|
"다음에도 측정합니다.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("shared renderer interactions", () => {
|
|
it("announces code-copy success and resets its visible/live feedback", async () => {
|
|
vi.useFakeTimers();
|
|
const writeText = vi.fn(async () => undefined);
|
|
Object.defineProperty(window.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: { writeText },
|
|
});
|
|
const view = render(
|
|
<CodeBlock language="Java" label="테스트" code="return page;" />,
|
|
);
|
|
const liveRegion = view.container.querySelector('[aria-live="polite"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole("button", { name: "코드 복사" }));
|
|
await Promise.resolve();
|
|
});
|
|
expect(writeText).toHaveBeenCalledWith("return page;");
|
|
expect(screen.getByRole("button", { name: "코드 복사" })).toHaveTextContent(
|
|
"복사됨",
|
|
);
|
|
expect(liveRegion).toHaveTextContent("코드를 클립보드에 복사했습니다.");
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(2_000);
|
|
});
|
|
expect(screen.getByRole("button", { name: "코드 복사" })).toHaveTextContent(
|
|
"복사",
|
|
);
|
|
expect(liveRegion).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it("announces clipboard failure and keeps code keyboard-scrollable", async () => {
|
|
Object.defineProperty(window.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: {
|
|
writeText: vi.fn(async () => {
|
|
throw new Error("clipboard unavailable");
|
|
}),
|
|
},
|
|
});
|
|
const view = render(
|
|
<CodeBlock language="Java" label="부모 페이징" code="return page;" />,
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole("button", { name: "코드 복사" }));
|
|
await Promise.resolve();
|
|
});
|
|
expect(screen.getByRole("button", { name: "코드 복사" })).toHaveTextContent(
|
|
"복사 실패",
|
|
);
|
|
expect(view.container.querySelector('[aria-live="polite"]')).toHaveTextContent(
|
|
"코드를 클립보드에 복사하지 못했습니다.",
|
|
);
|
|
expect(screen.getByRole("region", { name: "부모 페이징 코드" })).toHaveAttribute(
|
|
"tabindex",
|
|
"0",
|
|
);
|
|
});
|
|
|
|
it("opens evidence zoom, closes from backdrop and button, and restores trigger focus", () => {
|
|
render(<EvidenceFigure resolveEvidenceAsset={getEvidenceAsset} />);
|
|
const trigger = screen.getByRole("button", {
|
|
name: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
|
|
});
|
|
const dialog = screen.getByRole("dialog", { hidden: true });
|
|
expect(within(dialog).getByRole("img", { hidden: true })).toHaveAttribute(
|
|
"loading",
|
|
"lazy",
|
|
);
|
|
|
|
trigger.focus();
|
|
fireEvent.click(trigger);
|
|
expect(dialog).toHaveAttribute("open");
|
|
fireEvent.click(dialog);
|
|
expect(dialog).not.toHaveAttribute("open");
|
|
expect(trigger).toHaveFocus();
|
|
|
|
fireEvent.click(trigger);
|
|
expect(dialog).toHaveAttribute("open");
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "닫기" }));
|
|
expect(dialog).not.toHaveAttribute("open");
|
|
expect(trigger).toHaveFocus();
|
|
});
|
|
|
|
it("keeps the evidence explanation as the zoom trigger description", () => {
|
|
render(<EvidenceFigure resolveEvidenceAsset={getEvidenceAsset} />);
|
|
const trigger = screen.getByRole("button", {
|
|
name: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
|
|
});
|
|
|
|
expect(trigger).toHaveAccessibleDescription(
|
|
"Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고르고, Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다.",
|
|
);
|
|
});
|
|
|
|
it("rejects unknown and arbitrary evidence asset keys before rendering media", () => {
|
|
expect(() =>
|
|
render(
|
|
<PublicEvidenceFigure
|
|
evidenceKey="https://example.com/arbitrary.png"
|
|
alt="unsafe"
|
|
caption="unsafe"
|
|
zoom={false}
|
|
resolveEvidenceAsset={getEvidenceAsset}
|
|
/>,
|
|
),
|
|
).toThrow(/Unknown local evidence asset/);
|
|
expect(document.querySelector('img[src^="https://example.com"]')).toBeNull();
|
|
});
|
|
|
|
it("updates mobile and desktop TOC selection without changing labels", () => {
|
|
const mobile = render(
|
|
<>
|
|
<h2 id="first">첫째</h2>
|
|
<h2 id="second">둘째</h2>
|
|
<DocumentToc
|
|
headings={[
|
|
{ id: "first", label: "첫째" },
|
|
{ id: "second", label: "둘째" },
|
|
]}
|
|
variant="mobile"
|
|
/>
|
|
</>,
|
|
);
|
|
const details = mobile.container.querySelector("details.mobile-toc");
|
|
expect(details?.querySelector("summary")).toHaveTextContent("목차 · 첫째");
|
|
if (details instanceof HTMLDetailsElement) details.open = true;
|
|
fireEvent.click(screen.getByRole("link", { name: "둘째" }));
|
|
expect(details).not.toHaveAttribute("open");
|
|
expect(screen.getByRole("link", { name: "둘째" })).toHaveAttribute(
|
|
"aria-current",
|
|
"location",
|
|
);
|
|
mobile.unmount();
|
|
|
|
render(
|
|
<DocumentToc
|
|
headings={[
|
|
{ id: "first", label: "첫째" },
|
|
{ id: "second", label: "둘째" },
|
|
]}
|
|
variant="desktop"
|
|
/>,
|
|
);
|
|
expect(screen.getByRole("complementary", { name: "문서 목차" })).toHaveTextContent(
|
|
"이 글에서첫째둘째",
|
|
);
|
|
expect(screen.getByRole("link", { name: "첫째" })).toHaveAttribute(
|
|
"aria-current",
|
|
"location",
|
|
);
|
|
});
|
|
});
|