// @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, ): 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"] { 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({node}); } 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( , ); 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( , ); 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( ', }, ]} />, ); expect(view.container.querySelector("img")).toBeNull(); expect(view.container.querySelector("script")).toBeNull(); expect(view.container).toHaveTextContent( '', ); }); 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(); 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( , ); 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( , ); expect(screen.getByRole("complementary", { name: "정보" })).toHaveTextContent( "정보설명", ); }); }); describe("shared Public record renderer", () => { it("preserves the canonical Fetch Join layout, TOC, metadata, and relations", () => { renderInRouter( , ); 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( , ); 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; 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( , ); 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( , ); 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( , ); 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( , ); 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(); 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(); 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( , ), ).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( <>

첫째

둘째

, ); 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( , ); expect(screen.getByRole("complementary", { name: "문서 목차" })).toHaveTextContent( "이 글에서첫째둘째", ); expect(screen.getByRole("link", { name: "첫째" })).toHaveAttribute( "aria-current", "location", ); }); });