/* Unit tests for Tier 2 structure typography rules (RFC-1069). Tests each of the 10 rules (TYPO-HEAD-01..04, TYPO-PAIR-01..03, TYPO-MD-01..03) with positive and negative cases, including edge cases for frontmatter heading detection, multi-sentence heading gating, link-stripping interactions, and placeholder exclusions. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. warpgogol-m000193: tests for TYPO-HEAD-04 and the single-sentence gate on TYPO-HEAD-01/03. RFC-1069 */ import { describe, it, expect } from "vitest"; import { TIER2_STRUCTURE_RULES, createTypographyContext, type TextSegment, type TypographyFinding, } from "./index.ts"; function makeSegment(text: string, overrides: Partial = {}): TextSegment { return { file: "test.md", line: 1, path: "$body", source: "body", text, raw: text, isTableRow: false, locale: "de", ...overrides, }; } function makeFmSegment( text: string, path: string, overrides: Partial = {}, ): TextSegment { return makeSegment(text, { source: "frontmatter", path, ...overrides, }); } const ctx = createTypographyContext("de", new Set()); function runRule(ruleId: string, segment: TextSegment): TypographyFinding[] { const rule = TIER2_STRUCTURE_RULES.find((r) => r.id === ruleId); if (!rule) throw new Error(`Rule ${ruleId} not found`); return rule.check(segment, ctx); } // --------------------------------------------------------------------------- // TYPO-HEAD-01: Heading ends with a period // --------------------------------------------------------------------------- describe("TYPO-HEAD-01", () => { it("flags a body heading ending with a period", () => { const seg = makeSegment("## My Heading."); const findings = runRule("TYPO-HEAD-01", seg); expect(findings).toHaveLength(1); expect(findings[0].ruleId).toBe("TYPO-HEAD-01"); }); it("does not flag a body heading without trailing period", () => { const seg = makeSegment("## My Heading"); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(0); }); it("flags a frontmatter heading ending with a period", () => { const seg = makeFmSegment("My Heading.", "$.blocks[0].props.heading"); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(1); }); it("flags a frontmatter subheading ending with a period", () => { const seg = makeFmSegment("My Subheading.", "$.blocks[0].props.subheading"); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(1); }); it("does not flag a frontmatter non-heading key ending with a period", () => { const seg = makeFmSegment("Some text.", "$.blocks[0].props.title"); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(0); }); it("does not flag a non-heading body line ending with a period", () => { const seg = makeSegment("This is a sentence."); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(0); }); // RFC-1083: multi-line body fields starting with ## are NOT headings it("does not flag a multi-line body field starting with ##", () => { const seg = makeSegment("## Heading\nMore body content."); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(0); }); it("still flags a single-line heading ending with a period", () => { const seg = makeSegment("## Heading."); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(1); }); // warpgogol-m000193: multi-sentence headings must end with a period (HEAD-04) — // a trailing period there is required, not a defect. it("does not flag a multi-sentence body heading ending with a period", () => { const seg = makeSegment("## Own domain. Fixed price. Regulated support."); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(0); }); it("does not flag a multi-sentence frontmatter subheading ending with a period", () => { const seg = makeFmSegment( "Eigene Domain. Fester Preis. Schriftlich geregelte Betreuung.", "$.blocks[0].props.subheading", ); expect(runRule("TYPO-HEAD-01", seg)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TYPO-HEAD-02: Heading ends with a colon or dash // --------------------------------------------------------------------------- describe("TYPO-HEAD-02", () => { it("flags a heading ending with a colon", () => { const seg = makeSegment("## Heading:"); expect(runRule("TYPO-HEAD-02", seg)).toHaveLength(1); }); it("flags a heading ending with an em-dash", () => { const seg = makeSegment("## Heading\u2014"); expect(runRule("TYPO-HEAD-02", seg)).toHaveLength(1); }); it("flags a heading ending with an en-dash", () => { const seg = makeSegment("## Heading\u2013"); expect(runRule("TYPO-HEAD-02", seg)).toHaveLength(1); }); it("flags a heading ending with a hyphen", () => { const seg = makeSegment("## Heading-"); expect(runRule("TYPO-HEAD-02", seg)).toHaveLength(1); }); it("does not flag a heading without trailing colon or dash", () => { const seg = makeSegment("## Heading"); expect(runRule("TYPO-HEAD-02", seg)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TYPO-HEAD-03: Heading ends with other closing punctuation // --------------------------------------------------------------------------- describe("TYPO-HEAD-03", () => { it("flags a heading ending with !", () => { const seg = makeSegment("## Heading!"); expect(runRule("TYPO-HEAD-03", seg)).toHaveLength(1); }); it("flags a heading ending with ?", () => { const seg = makeSegment("## Heading?"); expect(runRule("TYPO-HEAD-03", seg)).toHaveLength(1); }); it("flags a heading ending with )", () => { const seg = makeSegment("## Heading)"); expect(runRule("TYPO-HEAD-03", seg)).toHaveLength(1); }); it("does not flag a heading without trailing closing punctuation", () => { const seg = makeSegment("## Heading"); expect(runRule("TYPO-HEAD-03", seg)).toHaveLength(0); }); // warpgogol-m000193: a multi-sentence heading may end with ! or ? — any // sentence terminator satisfies HEAD-04. it("does not flag a multi-sentence heading ending with !", () => { const seg = makeSegment("## What happened? We explain it!"); expect(runRule("TYPO-HEAD-03", seg)).toHaveLength(0); }); it("still flags a single-sentence heading ending with !", () => { const seg = makeSegment("## Watch out!"); expect(runRule("TYPO-HEAD-03", seg)).toHaveLength(1); }); }); // --------------------------------------------------------------------------- // TYPO-HEAD-04: Multi-sentence heading lacks terminal punctuation // --------------------------------------------------------------------------- describe("TYPO-HEAD-04", () => { it("flags a frontmatter subheading with unterminated final sentence", () => { const seg = makeFmSegment( "Власний домен. Фіксована ціна. Письмово врегульована підтримка", "$.blocks[0].props.subheading", { locale: "uk" }, ); const findings = runRule("TYPO-HEAD-04", seg); expect(findings).toHaveLength(1); expect(findings[0].ruleId).toBe("TYPO-HEAD-04"); }); it("does not flag a multi-sentence subheading ending with a period", () => { const seg = makeFmSegment( "Eigene Domain. Fester Preis. Schriftlich geregelte Betreuung.", "$.blocks[0].props.subheading", ); expect(runRule("TYPO-HEAD-04", seg)).toHaveLength(0); }); it("does not flag a multi-sentence heading ending with ! or ?", () => { expect(runRule("TYPO-HEAD-04", makeSegment("## What now? We act!"))).toHaveLength(0); expect(runRule("TYPO-HEAD-04", makeSegment("## We act. What now?"))).toHaveLength(0); }); it("does not flag a single-sentence heading without punctuation", () => { const seg = makeFmSegment("My Heading", "$.blocks[0].props.heading"); expect(runRule("TYPO-HEAD-04", seg)).toHaveLength(0); }); it("does not flag a single-sentence heading ending with a period (HEAD-01 owns it)", () => { const seg = makeFmSegment("My Heading.", "$.blocks[0].props.heading"); expect(runRule("TYPO-HEAD-04", seg)).toHaveLength(0); }); it("does not flag a non-heading frontmatter key with unterminated sentences", () => { const seg = makeFmSegment("First sentence. Second sentence", "$.blocks[0].props.tagline"); expect(runRule("TYPO-HEAD-04", seg)).toHaveLength(0); }); it("does not flag a non-heading body line", () => { const seg = makeSegment("First sentence. Second sentence"); expect(runRule("TYPO-HEAD-04", seg)).toHaveLength(0); }); it("flags a body heading with unterminated final sentence", () => { const seg = makeSegment("## Not just promised. Documented"); expect(runRule("TYPO-HEAD-04", seg)).toHaveLength(1); }); it("does not flag a heading whose internal period is a list marker or abbreviation", () => { // "vs. betreutes" — lowercase continuation never splits a sentence const seg = makeFmSegment("Einmalprojekt vs. betreutes Modell", "$.blocks[0].props.subheading"); expect(runRule("TYPO-HEAD-04", seg)).toHaveLength(0); }); it("attaches an insert fix for a plain YAML-safe value", () => { const seg = makeFmSegment( "Eigene Domain. Fester Preis. Schriftlich geregelte Betreuung", "$.blocks[0].props.subheading", ); const findings = runRule("TYPO-HEAD-04", seg); expect(findings[0].fix).toEqual({ type: "insert", at: seg.text.trimEnd().length, text: ".", }); }); it("omits the fix when the raw value contains YAML-escapable characters", () => { // A single quote is doubled in YAML single-quoted scalars, so the parsed // value cannot be located verbatim on the source line. const seg = makeFmSegment("We can't wait. We act now", "$.blocks[0].props.subheading"); const findings = runRule("TYPO-HEAD-04", seg); expect(findings).toHaveLength(1); expect(findings[0].fix).toBeNull(); }); }); // --------------------------------------------------------------------------- // TYPO-PAIR-01: Unbalanced round parentheses // --------------------------------------------------------------------------- describe("TYPO-PAIR-01", () => { it("flags 3 opening and 2 closing parentheses", () => { const seg = makeSegment("Text (foo (bar) baz"); const findings = runRule("TYPO-PAIR-01", seg); expect(findings).toHaveLength(1); expect(findings[0].ruleId).toBe("TYPO-PAIR-01"); }); it("does not flag balanced parentheses", () => { const seg = makeSegment("Text (foo) and (bar)"); expect(runRule("TYPO-PAIR-01", seg)).toHaveLength(0); }); it("does not flag text with formula placeholder", () => { const seg = makeSegment("Text \uE000 end"); expect(runRule("TYPO-PAIR-01", seg)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TYPO-PAIR-02: Orphaned closing square bracket // --------------------------------------------------------------------------- describe("TYPO-PAIR-02", () => { it("flags an orphaned ]", () => { const seg = makeSegment("Text ] end"); expect(runRule("TYPO-PAIR-02", seg)).toHaveLength(1); }); it("does not flag balanced [text]", () => { const seg = makeSegment("Text [text] end"); expect(runRule("TYPO-PAIR-02", seg)).toHaveLength(0); }); it("does not flag [text from a stripped link (orphaned [ is not a defect)", () => { const seg = makeSegment("See [text for details"); expect(runRule("TYPO-PAIR-02", seg)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TYPO-PAIR-03: Unbalanced curly braces // --------------------------------------------------------------------------- describe("TYPO-PAIR-03", () => { it("flags unbalanced curly braces (more opening)", () => { const seg = makeSegment("Text {foo {bar}"); expect(runRule("TYPO-PAIR-03", seg)).toHaveLength(1); }); it("flags unbalanced curly braces (more closing)", () => { const seg = makeSegment("Text {foo}}"); expect(runRule("TYPO-PAIR-03", seg)).toHaveLength(1); }); it("does not flag balanced curly braces", () => { const seg = makeSegment("Text {foo} and {bar}"); expect(runRule("TYPO-PAIR-03", seg)).toHaveLength(0); }); it("does not flag CMS placeholder (braces already stripped)", () => { const seg = makeSegment("Text \uE002 end"); expect(runRule("TYPO-PAIR-03", seg)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TYPO-MD-01: Unclosed inline code span (odd backticks) // --------------------------------------------------------------------------- describe("TYPO-MD-01", () => { it("flags an odd number of backticks", () => { const seg = makeSegment("Text `code end"); expect(runRule("TYPO-MD-01", seg)).toHaveLength(1); }); it("does not flag an even number of backticks", () => { const seg = makeSegment("Text `code` end"); expect(runRule("TYPO-MD-01", seg)).toHaveLength(0); }); it("does not flag frontmatter segments", () => { const seg = makeFmSegment("Text `code", "$.blocks[0].props.text"); expect(runRule("TYPO-MD-01", seg)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TYPO-MD-02: Broken link target syntax // --------------------------------------------------------------------------- describe("TYPO-MD-02", () => { it("flags ]( without preceding [", () => { const seg = makeSegment("Text ](url end"); expect(runRule("TYPO-MD-02", seg)).toHaveLength(1); }); it("does not flag a complete link after stripping", () => { // After link stripping, [text](url) becomes [text — no ]( remains const seg = makeSegment("See [text for details"); expect(runRule("TYPO-MD-02", seg)).toHaveLength(0); }); it("does not flag frontmatter segments", () => { const seg = makeFmSegment("Text ](url", "$.blocks[0].props.text"); expect(runRule("TYPO-MD-02", seg)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TYPO-MD-03: Unclosed emphasis marker // --------------------------------------------------------------------------- describe("TYPO-MD-03", () => { it("flags an odd number of asterisks", () => { const seg = makeSegment("Text *bold end"); expect(runRule("TYPO-MD-03", seg)).toHaveLength(1); }); it("does not flag an even number of asterisks", () => { const seg = makeSegment("Text **bold** end"); expect(runRule("TYPO-MD-03", seg)).toHaveLength(0); }); it("flags an odd number of underscores", () => { const seg = makeSegment("Text _italic end"); expect(runRule("TYPO-MD-03", seg)).toHaveLength(1); }); it("does not flag an even number of underscores", () => { const seg = makeSegment("Text _italic_ end"); expect(runRule("TYPO-MD-03", seg)).toHaveLength(0); }); it("does not flag frontmatter segments", () => { const seg = makeFmSegment("Text *bold", "$.blocks[0].props.text"); expect(runRule("TYPO-MD-03", seg)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TIER2_STRUCTURE_RULES export // --------------------------------------------------------------------------- describe("TIER2_STRUCTURE_RULES", () => { it("exports exactly 10 rules", () => { expect(TIER2_STRUCTURE_RULES).toHaveLength(10); }); it("all rules have tier 2", () => { for (const rule of TIER2_STRUCTURE_RULES) { expect(rule.tier).toBe(2); } }); it("all rules have severity error", () => { for (const rule of TIER2_STRUCTURE_RULES) { expect(rule.severity).toBe("error"); } }); it("covers HEAD, PAIR, and MD families", () => { const families = new Set(TIER2_STRUCTURE_RULES.map((r) => r.family)); expect(families).toEqual(new Set(["HEAD", "PAIR", "MD"])); }); });