import { describe, expect, it } from "vitest"; import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts"; import { evidenceCatalogEntriesFromAssets, supportsResolvableEvidenceKey, } from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts"; import { ContentFormatError, parseCaseContent, } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts"; import { evidenceCatalogEntryFor, 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"; import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts"; /** * Fix round 4. Calls the production composition itself * (`supportsResolvableEvidenceKey`) rather than re-deriving it: every caller * -- `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`, * `instant-preview.tsx` -- builds its key gate from this exact function, so a * caller drifting away from it now shows up here too. The question is "does a * resolvable Asset (or the legacy static key) back this", never "is there any * EVIDENCE catalog row for it" -- that is the projection's separate catalog * check against its own `catalog` argument. Tests below that pass no `assets` * reduce to the legacy check alone. */ function supportsEvidenceKey( key: string, assets: readonly Asset[] = [], ): boolean { return supportsResolvableEvidenceKey(assets, isSupportedEvidenceKey)(key); } 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("accepts the plain Markdown the server renderer already accepted", () => { /* 이 문법들은 서버 렌더러가 원래 읽던 것인데 이 파서만 거절하고 있었다 — 같은 본문이 Studio 와 공개 화면에서 다르게 읽혔다. 표는 `:::table` 로 감싸지 않아도 되고, callout 이름은 서버가 아는 note/tip/warning/danger 를 쓴다. */ expect(parseCaseContent("| A | B |\n|---|---|\n| 1 | 2 |").map((block) => block.type)).toEqual([ "DATA_TABLE", ]); expect(parseCaseContent(":::note\n\n참고할 것.\n\n:::").map((block) => block.type)).toEqual([ "CALLOUT", ]); expect(parseCaseContent(":::danger\n\n위험.\n\n:::").map((block) => block.type)).toEqual([ "CALLOUT", ]); expect(parseCaseContent("# 제목1\n\n###### 제목6").map((block) => block.type)).toEqual([ "HEADING", "HEADING", ]); expect(parseCaseContent("---").map((block) => block.type)).toEqual(["THEMATIC_BREAK"]); expect(parseCaseContent("![대체](/api/v1/public/media/x)").map((block) => block.type)).toEqual([ "IMAGE", ]); // 속성이 없는 directive 도 이름이 온전해야 한다. 정규화 정규식이 되돌아가며 마지막 글자를 // 속성 쪽으로 넘기는 바람에 `:::note` 가 `:::not{e}` 로 바뀌던 적이 있다. expect(() => parseCaseContent(":::unknown\n\ntext\n\n:::")).toThrow( /unknown block directive: unknown/, ); }); 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", "- [ ] task", "> > nested", ':::unknown key="value"\ntext\n:::', ':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::', "![x](javascript:alert(1))", "![x](//evil.example/x.png)", ]; 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("parses a decorative evidence figure with an empty alt", () => { const source = ':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::'; const [block] = parseCaseContent(source); expect(block?.type).toBe("EVIDENCE_FIGURE"); if (block?.type !== "EVIDENCE_FIGURE") return; expect(block.alt).toBe(""); }); it("round-trips an empty alt without reintroducing it as a parse error", () => { const source = ':::evidence key="fetch-strategy-boundary" alt="" caption="" zoom="false"\n:::'; const parsed = parseCaseContent(source); expect(() => parseCaseContent(serializeCaseContent(parsed))).not.toThrow(); }); it("still rejects an unsafe evidence key", () => { expect(() => parseCaseContent( ':::evidence key="../etc" alt="x" caption="" zoom="false"\n:::', ), ).toThrow(/CONTENT_FORMAT_INVALID/); }); 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", }, supportsEvidenceKey, ); 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 }, supportsEvidenceKey, ); 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 }, supportsEvidenceKey, ), ).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 }, supportsEvidenceKey, ), ).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"); }); // Fix round 2 regression coverage. `catalog` above already carries // "resolution-evidence" -- a real `EVIDENCE` row that exists for a // QUESTION resolution target (see "requires EVIDENCE" above), not media. // A production caller whose gate 1 collapsed into gate 2 (round 1's bug) // would let this key through with no Asset and no legacy key backing it; // this is the exact class of catalog row the reviewer used to reproduce // that regression. it("rejects a real EVIDENCE catalog row that exists for a different purpose than media (round 2 regression)", () => { expect(() => projectWorkingCopy( caseWithEvidence("resolution-evidence"), catalog, { mode: "PREVIEW", publishedAt: null }, supportsEvidenceKey, ), ).toThrow(/supported local evidence key/); }); it("accepts a key backed by a loaded READY Asset even though the document catalog carries no matching row", () => { const assets: Asset[] = [ { id: "99999999-9999-4999-8999-999999999999", assetKey: "boundary-check", kind: "DIAGRAM", mediaType: "image/svg+xml", originalFilename: "boundary.svg", byteSize: 10, width: null, height: null, altText: null, decorative: false, managementStatus: "READY", publicPath: "/media/boundary-check.svg", usageCount: 0, version: 1, createdAt: "2026-08-14T00:00:00.000Z", updatedAt: "2026-08-14T00:00:00.000Z", }, ]; // `catalog` alone has no row for "boundary-check" -- gate 2 only passes // because the caller merges in `evidenceCatalogEntriesFromAssets`, the // same way every production caller does. const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)]; const model = projectWorkingCopy( caseWithEvidence("boundary-check"), evidenceCatalog, { mode: "PREVIEW", publishedAt: null }, (key: string) => supportsEvidenceKey(key, assets), ); expect(model.kind).toBe("CASE"); }); });