/*
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"]));
});
});