Files
tech-log-frontend/tests/features/tech-log/public-render.test.tsx
T
DongHyeonkaandClaude Opus 5 fd73bc88a1 fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다
Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 18:03:11 +09:00

647 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(
"이 기록의 연결",
);
});
/*
Case 렌더러가 둘이었을 때 이 테스트는 "축약본" 쪽을 지켰다. 어느 쪽을 쓸지는 슬러그 비교가
정했고, 설계 픽스처 문서 하나만 breadcrumb 과 목차가 있는 배치를 받았다 — 실제로 작성한 Case 는
전부 축약본이었고 오른쪽 목차가 어디에도 나오지 않았다. 배치는 이제 하나다.
*/
it("uses the one Case layout 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("case-page");
expect(main).toHaveAttribute("id", "main-content");
// 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
expect(main).toHaveTextContent("게시 전");
expect(main).not.toHaveTextContent("2035.05.06");
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
"문제문제결론결론",
);
expect(screen.getByText("일반 본문")).toBeVisible();
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
expect(screen.queryByLabelText("문서 목차")).toBeNull();
});
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",
);
});
});