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 = [ "", "[x](javascript:alert(1))", "[x](//evil.example/path)", "[x](//techlog.invalid/path)", "[x](///techlog.invalid/path)", "[x](//user@techlog.invalid/path)", "[x](/\\evil.example/path)", "[x](/\\\\evil.example/path)", "[x]()", "[x]()", "[x]()", "- 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"); 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("keeps explicitly allowed links and same-origin paths", () => { const [paragraph] = parseCaseContent( "[fragment](#section) [path](/safe/path) [query](/safe/path?q=one) [http](http://example.com/path) [https](https://example.com/path) [mail](mailto:test@example.com)", ); expect(paragraph?.type).toBe("PARAGRAPH"); if (paragraph?.type !== "PARAGRAPH") return; expect( paragraph.content .filter((inline) => inline.type === "LINK") .map((inline) => inline.href), ).toEqual([ "#section", "/safe/path", "/safe/path?q=one", "http://example.com/path", "https://example.com/path", "mailto:test@example.com", ]); }); 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 [ "", "![image](https://example.com/image.png)", "~~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"); }); });