434 lines
12 KiB
TypeScript
434 lines
12 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("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");
|
|
});
|
|
});
|