feat: port TechLog content format and renderer
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||
import {
|
||||
ContentFormatError,
|
||||
parseCaseContent,
|
||||
} from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
||||
import { projectWorkingCopy } from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts";
|
||||
import { serializeCaseContent } from "../../../src/features/tech-log/domain/content-format/serialize-case-content.ts";
|
||||
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
|
||||
|
||||
const rich = `## 측정 결과 {#measurements}
|
||||
|
||||
:::table id="fetch-loss" caption="반환 손실" rowHeaderColumn="1"
|
||||
| 상태 | 결과 |
|
||||
| --- | ---: |
|
||||
| before | :status[20건]{tone="warning"} |
|
||||
:::
|
||||
|
||||
\`\`\`sql label="재현 쿼리"
|
||||
select * from feed_item;
|
||||
\`\`\``;
|
||||
|
||||
describe("Content Format v1", () => {
|
||||
it("parses and serializes the rich source fixture byte-for-byte", () => {
|
||||
const parsed = parseCaseContent(rich);
|
||||
|
||||
expect(serializeCaseContent(parsed)).toBe(rich);
|
||||
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
|
||||
expect(parsed.map((block) => block.type)).toEqual([
|
||||
"HEADING",
|
||||
"DATA_TABLE",
|
||||
"CODE_BLOCK",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
|
||||
const rejected = [
|
||||
"<script>alert(1)</script>",
|
||||
"[x](javascript:alert(1))",
|
||||
"[x](//evil.example/path)",
|
||||
"- outer\n - nested",
|
||||
"# level one",
|
||||
"- [ ] task",
|
||||
"> > nested",
|
||||
':::unknown key="value"\ntext\n:::',
|
||||
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
|
||||
];
|
||||
|
||||
for (const source of rejected) {
|
||||
expect(() => parseCaseContent(source), source).toThrow(
|
||||
/CONTENT_FORMAT_INVALID/,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
parseCaseContent("safe\n\n<script>alert(1)</script>");
|
||||
throw new Error("expected malformed content to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ContentFormatError);
|
||||
expect((error as ContentFormatError).issues).toEqual([
|
||||
{
|
||||
line: 3,
|
||||
column: 1,
|
||||
detail: "unsupported block syntax: html",
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("generates stable Korean heading IDs and suffixes duplicates", () => {
|
||||
const blocks = parseCaseContent("## 한글 API!\n\n## 한글 API?\n\n## !!!");
|
||||
|
||||
expect(
|
||||
blocks.map((block) => (block.type === "HEADING" ? block.id : null)),
|
||||
).toEqual(["한글-api", "한글-api-2", "section"]);
|
||||
});
|
||||
|
||||
it("round trips every supported block and inline variant", () => {
|
||||
const source = `## 본문 {#body}
|
||||
|
||||
일반 *강조* **강함** \`code\` [내부](/path) [메일](mailto:test@example.com)
|
||||
|
||||
> 한 문단 인용
|
||||
|
||||
- 첫째
|
||||
- 둘째
|
||||
|
||||
1. 하나
|
||||
2. 둘
|
||||
|
||||
:::callout tone="info" label="정보"
|
||||
안전한 안내입니다.
|
||||
:::
|
||||
|
||||
:::evidence key="fetch-strategy-boundary" alt="비교" caption="경계" zoom="false"
|
||||
:::`;
|
||||
const parsed = parseCaseContent(source);
|
||||
|
||||
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
|
||||
expect(parsed.map((block) => block.type)).toEqual([
|
||||
"HEADING",
|
||||
"PARAGRAPH",
|
||||
"BLOCKQUOTE",
|
||||
"UNORDERED_LIST",
|
||||
"ORDERED_LIST",
|
||||
"CALLOUT",
|
||||
"EVIDENCE_FIGURE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives stable table column and row IDs", () => {
|
||||
const [table] = parseCaseContent(`:::table id="metrics" caption="측정" rowHeaderColumn="none"
|
||||
| 이름 | 수치 |
|
||||
| :--- | ---: |
|
||||
| before | 20 |
|
||||
:::`);
|
||||
|
||||
expect(table?.type).toBe("DATA_TABLE");
|
||||
if (table?.type !== "DATA_TABLE") return;
|
||||
expect(table.columns.map((column) => column.id)).toEqual([
|
||||
"metrics-column-1",
|
||||
"metrics-column-2",
|
||||
]);
|
||||
expect(table.rows.map((row) => row.id)).toEqual(["metrics-row-1"]);
|
||||
});
|
||||
|
||||
it("rejects unsupported inline syntax in table headers", () => {
|
||||
for (const header of [
|
||||
"<script>alert(1)</script>",
|
||||
"",
|
||||
"~~deleted~~",
|
||||
]) {
|
||||
const source = `:::table id="unsafe-header" caption="검증" rowHeaderColumn="none"
|
||||
| ${header} |
|
||||
| --- |
|
||||
| value |
|
||||
:::`;
|
||||
expect(() => parseCaseContent(source), header).toThrow(
|
||||
/CONTENT_FORMAT_INVALID/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const metricsTable = `:::table id="metrics" caption="측정" rowHeaderColumn="none"
|
||||
| 이름 | 값 |
|
||||
| --- | --- |
|
||||
| before | 20 |
|
||||
:::`;
|
||||
|
||||
it("rejects duplicate table IDs", () => {
|
||||
expect(() =>
|
||||
parseCaseContent(`${metricsTable}\n\n${metricsTable}`),
|
||||
).toThrow(/duplicate explicit ID: metrics/);
|
||||
});
|
||||
|
||||
it("rejects heading and table base ID collisions in either order", () => {
|
||||
expect(() =>
|
||||
parseCaseContent(`## 제목 {#metrics}\n\n${metricsTable}`),
|
||||
).toThrow(/duplicate explicit ID: metrics/);
|
||||
expect(() =>
|
||||
parseCaseContent(`${metricsTable}\n\n## 제목 {#metrics}`),
|
||||
).toThrow(/duplicate explicit ID: metrics/);
|
||||
});
|
||||
|
||||
it("rejects heading IDs that collide with a later table column ID", () => {
|
||||
expect(() =>
|
||||
parseCaseContent(`## 열 {#metrics-column-1}\n\n${metricsTable}`),
|
||||
).toThrow(/duplicate explicit ID: metrics-column-1/);
|
||||
});
|
||||
|
||||
it("rejects table row IDs that collide with a later heading ID", () => {
|
||||
expect(() =>
|
||||
parseCaseContent(`${metricsTable}\n\n## 행 {#metrics-row-1}`),
|
||||
).toThrow(/duplicate explicit ID: metrics-row-1/);
|
||||
});
|
||||
|
||||
function expectRoundTrip(source: string) {
|
||||
const parsed = parseCaseContent(source);
|
||||
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
|
||||
}
|
||||
|
||||
it("round trips literal backslashes and intraword underscores", () => {
|
||||
expectRoundTrip("literal_under_score and \\*star\\* and \\\\slash");
|
||||
});
|
||||
|
||||
it("round trips literal emphasis-shaped underscores", () => {
|
||||
expectRoundTrip("literal \\_emphasis\\_");
|
||||
});
|
||||
|
||||
it("keeps escaped block-leading list and quote markers as paragraphs", () => {
|
||||
for (const source of ["\\- literal", "\\> quote"]) {
|
||||
const parsed = parseCaseContent(source);
|
||||
expect(parsed[0]?.type).toBe("PARAGRAPH");
|
||||
expect(parseCaseContent(serializeCaseContent(parsed))).toEqual(parsed);
|
||||
}
|
||||
});
|
||||
|
||||
it("round trips inline code containing backticks", () => {
|
||||
expectRoundTrip("Use ``code ` tick`` here.");
|
||||
});
|
||||
|
||||
it("round trips all-space inline code without semantic padding", () => {
|
||||
expectRoundTrip("Use `` `` here.");
|
||||
});
|
||||
|
||||
it("round trips escaped pipes in table cells", () => {
|
||||
expectRoundTrip(`:::table id="pipes" caption="파이프" rowHeaderColumn="none"
|
||||
| 표현 | 값 |
|
||||
| --- | --- |
|
||||
| a \\| b | c |
|
||||
:::`);
|
||||
});
|
||||
|
||||
it("round trips directive-looking text inside a longer code fence", () => {
|
||||
expectRoundTrip(`\`\`\`\`text label="문법 예시"
|
||||
:::callout tone="warning" label="코드 안"
|
||||
\`\`\`inner
|
||||
literal
|
||||
\`\`\`
|
||||
\`\`\`\``);
|
||||
});
|
||||
});
|
||||
|
||||
const catalog: components["schemas"]["CatalogEntry"][] = [
|
||||
{
|
||||
id: "topic-jpa",
|
||||
type: "TOPIC",
|
||||
label: "JPA",
|
||||
publicPath: "/topics/jpa",
|
||||
dependencyRevision: "r1",
|
||||
},
|
||||
{
|
||||
id: "project-backend",
|
||||
type: "PROJECT",
|
||||
label: "Backend Skeleton",
|
||||
publicPath: "/projects/backend-skeleton",
|
||||
dependencyRevision: "r1",
|
||||
},
|
||||
{
|
||||
id: "case-fetch",
|
||||
type: "RELATION",
|
||||
kind: "CASE",
|
||||
label: "Fetch Join Case",
|
||||
publicPath: "/cases/collection-fetch-join-pagination",
|
||||
dependencyRevision: "r1",
|
||||
},
|
||||
{
|
||||
id: "evidence-fetch",
|
||||
type: "EVIDENCE",
|
||||
label: "Fetch 전략 비교",
|
||||
publicPath: "/media/fetch-strategy-boundary.svg",
|
||||
dependencyRevision: "r1",
|
||||
},
|
||||
{
|
||||
id: "resolution-evidence",
|
||||
type: "EVIDENCE",
|
||||
label: "Fetch Join Case",
|
||||
publicPath: "/cases/collection-fetch-join-pagination",
|
||||
dependencyRevision: "r1",
|
||||
},
|
||||
];
|
||||
|
||||
function caseWithEvidence(
|
||||
key: string,
|
||||
): components["schemas"]["CaseInput"] {
|
||||
return {
|
||||
kind: "CASE",
|
||||
title: "Case",
|
||||
slug: "case",
|
||||
summary: "summary",
|
||||
topicId: "topic-jpa",
|
||||
projectId: "project-backend",
|
||||
relations: [
|
||||
{
|
||||
id: "relation-1",
|
||||
targetId: "case-fetch",
|
||||
reason: "근거",
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
problem: "problem",
|
||||
conclusion: "conclusion",
|
||||
environment: "environment",
|
||||
reproduction: "dataset",
|
||||
lastVerifiedOn: "2026-08-14",
|
||||
bodyMarkdown: `## 본문\n\n:::evidence key="${key}" alt="설명" caption="근거" zoom="true"\n:::`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Public render model projection", () => {
|
||||
it("is pure and resolves a supported catalog evidence entry", () => {
|
||||
const input = caseWithEvidence("fetch-strategy-boundary");
|
||||
const before = structuredClone(input);
|
||||
const model = projectWorkingCopy(
|
||||
input,
|
||||
catalog,
|
||||
{
|
||||
generatedAt: "2026-08-14T00:00:00Z",
|
||||
dependencyRevision: "r1",
|
||||
},
|
||||
isSupportedEvidenceKey,
|
||||
);
|
||||
|
||||
expect(model.kind).toBe("CASE");
|
||||
expect(model.topic.label).toBe("JPA");
|
||||
expect(model.project?.label).toBe("Backend Skeleton");
|
||||
expect(model.relations[0]?.title).toBe("Fetch Join Case");
|
||||
expect(input).toEqual(before);
|
||||
});
|
||||
|
||||
it("preserves resolved Question fields and requires EVIDENCE", () => {
|
||||
const input: components["schemas"]["QuestionInput"] = {
|
||||
kind: "QUESTION",
|
||||
title: "Question",
|
||||
slug: "question",
|
||||
summary: "summary",
|
||||
topicId: "topic-jpa",
|
||||
projectId: null,
|
||||
relations: [],
|
||||
questionStatus: "RESOLVED",
|
||||
facts: [],
|
||||
assumptions: [],
|
||||
unknowns: [],
|
||||
constraints: [],
|
||||
options: [],
|
||||
nextValidation: "다음에도 측정합니다.",
|
||||
resolution: {
|
||||
summary: "분리하기로 결정했습니다.",
|
||||
evidenceTargetId: "resolution-evidence",
|
||||
linkLabel: "Case 읽기",
|
||||
},
|
||||
};
|
||||
const model = projectWorkingCopy(
|
||||
input,
|
||||
catalog,
|
||||
{ mode: "PREVIEW", publishedAt: null },
|
||||
isSupportedEvidenceKey,
|
||||
);
|
||||
|
||||
expect(model.kind).toBe("QUESTION");
|
||||
if (model.kind !== "QUESTION") return;
|
||||
expect(model.nextValidation).toBe(input.nextValidation);
|
||||
expect(model.resolution?.summary).toBe(input.resolution?.summary);
|
||||
expect(model.resolution?.evidenceTarget.publicPath).toBe(
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
);
|
||||
expect(() =>
|
||||
projectWorkingCopy(
|
||||
{
|
||||
...input,
|
||||
resolution: {
|
||||
...input.resolution!,
|
||||
evidenceTargetId: "topic-jpa",
|
||||
},
|
||||
},
|
||||
catalog,
|
||||
{ mode: "PREVIEW", publishedAt: null },
|
||||
isSupportedEvidenceKey,
|
||||
),
|
||||
).toThrow(/EVIDENCE catalog/);
|
||||
});
|
||||
|
||||
const unsupportedEvidenceCatalog = [
|
||||
...catalog,
|
||||
{
|
||||
id: "unsupported-id",
|
||||
type: "EVIDENCE",
|
||||
label: "지원하지 않음",
|
||||
publicPath: "/media/unsupported-id.svg",
|
||||
dependencyRevision: "r1",
|
||||
},
|
||||
{
|
||||
id: "other-id",
|
||||
type: "EVIDENCE",
|
||||
label: "unsupported-label",
|
||||
publicPath: "/media/unsupported-label.svg",
|
||||
dependencyRevision: "r1",
|
||||
},
|
||||
] satisfies components["schemas"]["CatalogEntry"][];
|
||||
|
||||
function expectUnsupportedEvidence(key: string) {
|
||||
expect(() =>
|
||||
projectWorkingCopy(
|
||||
caseWithEvidence(key),
|
||||
unsupportedEvidenceCatalog,
|
||||
{ mode: "PREVIEW", publishedAt: null },
|
||||
isSupportedEvidenceKey,
|
||||
),
|
||||
).toThrow(/supported local evidence key/);
|
||||
}
|
||||
|
||||
it("rejects an unknown evidence key", () => {
|
||||
expectUnsupportedEvidence("unknown-asset");
|
||||
});
|
||||
|
||||
it("rejects a catalog-matching unsupported evidence ID", () => {
|
||||
expectUnsupportedEvidence("unsupported-id");
|
||||
});
|
||||
|
||||
it("rejects a catalog-matching unsupported evidence label", () => {
|
||||
expectUnsupportedEvidence("unsupported-label");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,617 @@
|
||||
// @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 } 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,
|
||||
};
|
||||
|
||||
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={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: 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user