The parser has no asset catalogue, so it cannot know whether an evidence figure is decorative. Move the empty-alt rejection from parse-time (a syntax error) to publish validation, where the mock treats static evidence assets as decorative: false since the static registry predates the Asset capability. The real rule is that the server judges alt against Asset.decorative.
460 lines
13 KiB
TypeScript
460 lines
13 KiB
TypeScript
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)",
|
|
"[x](//techlog.invalid/path)",
|
|
"[x](///techlog.invalid/path)",
|
|
"[x](//user@techlog.invalid/path)",
|
|
"[x](/\\evil.example/path)",
|
|
"[x](/\\\\evil.example/path)",
|
|
"[x](</safe\u0001path>)",
|
|
"[x](</safe\u001fpath>)",
|
|
"[x](</safe\u007fpath>)",
|
|
"- 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("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 [
|
|
"<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");
|
|
});
|
|
});
|