diff --git a/src/features/tech-log/adapters/mock/validate-working-copy.ts b/src/features/tech-log/adapters/mock/validate-working-copy.ts index b429777..b0ad13b 100644 --- a/src/features/tech-log/adapters/mock/validate-working-copy.ts +++ b/src/features/tech-log/adapters/mock/validate-working-copy.ts @@ -165,6 +165,11 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") { if (!isSupportedEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`); else if (!dependencies.catalog.some((entry) => entry.type === "EVIDENCE" && (entry.id === block.key || entry.label === block.key || entry.publicPath === `/media/${block.key}.svg`))) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`); + else if (block.alt.trim().length === 0) { + // 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로에서는 서버가 + // Asset.decorative로 같은 판정을 한다. + error("EVIDENCE_ALT_REQUIRED", "/bodyMarkdown", `Evidence에 대체 텍스트가 필요합니다: ${block.key}`); + } } } catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); } if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다."); diff --git a/src/features/tech-log/domain/content-format/parse-case-content.ts b/src/features/tech-log/domain/content-format/parse-case-content.ts index c1884d0..1c8b8b6 100644 --- a/src/features/tech-log/domain/content-format/parse-case-content.ts +++ b/src/features/tech-log/domain/content-format/parse-case-content.ts @@ -514,7 +514,9 @@ function directiveBlock( if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(attributes.key)) { invalid(node, `unsafe evidence key: ${attributes.key}`); } - if (!attributes.alt) invalid(node, "evidence alt text is required"); + // alt 필요 여부는 Asset의 `decorative`에 달려 있어 여기서는 판단할 수 + // 없다. 빈 alt는 구문 오류가 아니며 publish 검증이 Asset metadata와 함께 + // 판정한다(설계 §7.7). if (attributes.zoom !== "true" && attributes.zoom !== "false") { invalid(node, "evidence zoom must be true or false"); } diff --git a/tests/features/tech-log/content-format.test.ts b/tests/features/tech-log/content-format.test.ts index 934f6a7..6033064 100644 --- a/tests/features/tech-log/content-format.test.ts +++ b/tests/features/tech-log/content-format.test.ts @@ -138,6 +138,32 @@ describe("Content Format v1", () => { ]); }); + 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" | 이름 | 수치 |