/* Unit tests for Tier 2 locale typography rules (RFC-1070). Tests cover all 7 rules: TYPO-NUM-01..03, TYPO-ABBR-01..02, TYPO-APOS-01..02. Each rule is tested for positive detection, negative (no finding), and key exclusions. Do not test text-surface extraction — that is text-surface.test.ts. Do not test command adapter — that is typography.test.ts in werkstatt-site. RFC-1070: initial creation with unit tests for 7 Tier 2 locale rules. RFC-1324: ABBR-01 digit-required coverage — Art citation vs noun; No citation vs determiner. */ import { describe, it, expect } from "vitest"; import { TIER2_LOCALE_RULES, type Tier2LocaleRuleId } from "./rules-tier2-locale.ts"; import type { TextSegment } from "./text-surface.ts"; import type { TypographyContext, TypographyRule, TypographyFinding } from "./rules-tier1.ts"; import { createTypographyContext } from "./rules-tier1.ts"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeSegment( text: string, locale: string, source: "frontmatter" | "body" = "body", ): TextSegment { return { file: `test.${locale}.md`, line: 1, path: "$body", source, text, raw: text, isTableRow: false, locale, }; } function makeCtx( locale: string, allowedTokens: ReadonlySet = new Set(), ): TypographyContext { return createTypographyContext(locale, allowedTokens); } function runRule( ruleId: Tier2LocaleRuleId, text: string, locale: string, source: "frontmatter" | "body" = "body", ): TypographyFinding[] { const rule = TIER2_LOCALE_RULES.find((r) => r.id === ruleId) as TypographyRule; const segment = makeSegment(text, locale, source); const ctx = makeCtx(locale); return rule.check(segment, ctx); } function expectFinding(ruleId: Tier2LocaleRuleId, text: string, locale: string): void { const findings = runRule(ruleId, text, locale); expect(findings.length).toBeGreaterThanOrEqual(1); expect(findings[0].ruleId).toBe(ruleId); } function expectNoFinding(ruleId: Tier2LocaleRuleId, text: string, locale: string): void { const findings = runRule(ruleId, text, locale); expect(findings).toHaveLength(0); } // --------------------------------------------------------------------------- // TYPO-NUM-01: German number with dot decimal separator // --------------------------------------------------------------------------- describe("TYPO-NUM-01 — German dot decimal", () => { it("detects 1,234.56 in German text", () => { expectFinding("TYPO-NUM-01", "Der Preis betraegt 1,234.56 Euro.", "de"); }); it("does not fire on correct German format 1.234,56", () => { expectNoFinding("TYPO-NUM-01", "Der Preis betraegt 1.234,56 Euro.", "de"); }); it("does not fire on dates (12.04.2026)", () => { expectNoFinding("TYPO-NUM-01", "Am 12.04.2026 fand das Treffen statt.", "de"); }); it("does not fire on version numbers (v1.23)", () => { expectNoFinding("TYPO-NUM-01", "Wir nutzen v1.23 des Tools.", "de"); }); it("does not fire on time values (10.30 Uhr)", () => { expectNoFinding("TYPO-NUM-01", "Das Treningen beginnt um 10.30 Uhr.", "de"); }); it("does not fire on Ukrainian text", () => { expectNoFinding("TYPO-NUM-01", "Цiна 1,234.56 евро.", "uk"); }); it("does not fire on English text", () => { expectNoFinding("TYPO-NUM-01", "The price is 1,234.56 dollars.", "en"); }); }); // --------------------------------------------------------------------------- // TYPO-NUM-02: Ukrainian number with dot as thousands separator // --------------------------------------------------------------------------- describe("TYPO-NUM-02 — Ukrainian dot thousands", () => { it("detects 1.234,56 in Ukrainian text", () => { expectFinding("TYPO-NUM-02", "Цiна 1.234,56 евро.", "uk"); }); it("does not fire on correct Ukrainian format with NNBSP", () => { expectNoFinding("TYPO-NUM-02", "Цiна 1\u202F234,56 евро.", "uk"); }); it("does not fire on German text", () => { expectNoFinding("TYPO-NUM-02", "Der Preis betraegt 1.234,56 Euro.", "de"); }); it("does not fire on dates", () => { expectNoFinding("TYPO-NUM-02", "12.04.2026", "uk"); }); }); // --------------------------------------------------------------------------- // TYPO-NUM-03: Regular space as thousands separator in Ukrainian // --------------------------------------------------------------------------- describe("TYPO-NUM-03 — Regular space thousands in Ukrainian", () => { it("detects 1 234,56 with regular space in Ukrainian text", () => { const text = "Цiна 1 234,56 евро."; expectFinding("TYPO-NUM-03", text, "uk"); }); it("does not fire on NBSP (U+00A0) as thousands separator", () => { expectNoFinding("TYPO-NUM-03", "Цiна 1\u00A0234,56 евро.", "uk"); }); it("does not fire on NNBSP (U+202F) as thousands separator", () => { expectNoFinding("TYPO-NUM-03", "Цiна 1\u202F234,56 евро.", "uk"); }); it("does not fire on German text", () => { expectNoFinding("TYPO-NUM-03", "Der Preis 1 234,56 Euro.", "de"); }); }); // --------------------------------------------------------------------------- // TYPO-ABBR-01: Known abbreviation missing required trailing period // --------------------------------------------------------------------------- describe("TYPO-ABBR-01 — Missing trailing period on abbreviation", () => { it("detects Nr without period in German text", () => { expectFinding("TYPO-ABBR-01", "Siehe Nr 5 fuer Details.", "de"); }); it("does not fire on Nr. with period", () => { expectNoFinding("TYPO-ABBR-01", "Siehe Nr. 5 fuer Details.", "de"); }); it("detects usw without period in German text", () => { expectFinding("TYPO-ABBR-01", "Artikel, Preise usw werden gelistet.", "de"); }); it("does not fire on usw. with period", () => { expectNoFinding("TYPO-ABBR-01", "Artikel, Preise usw. werden gelistet.", "de"); }); it("detects напр without period in Ukrainian text", () => { expectFinding("TYPO-ABBR-01", "Див напр роздiл 5.", "uk"); }); it("does not fire on напр. with period", () => { expectNoFinding("TYPO-ABBR-01", "Наприклад напр. роздiл 5.", "uk"); }); it("detects Art without period in citation context (Art 6)", () => { expectFinding("TYPO-ABBR-01", "Informationsdokument nach Art 13 DSGVO.", "de"); }); it("does not fire on Art as the noun (jede Art der Verwertung)", () => { expectNoFinding( "TYPO-ABBR-01", "Vervielfältigung und jede Art der Verwertung bedürfen der Zustimmung.", "de", ); }); it("detects No without period in citation context (No 5)", () => { expectFinding("TYPO-ABBR-01", "See section No 5 for details.", "en"); }); it("does not fire on No as the determiner (No preview available)", () => { expectNoFinding("TYPO-ABBR-01", "No preview available", "en"); }); }); // --------------------------------------------------------------------------- // TYPO-ABBR-02: German abbreviation with wrong internal spacing // --------------------------------------------------------------------------- describe("TYPO-ABBR-02 — Wrong internal spacing in German abbreviations", () => { it("detects z.B. without space", () => { expectFinding("TYPO-ABBR-02", "Das ist z.B. ein Beispiel.", "de"); }); it("does not fire on z. B. with space", () => { expectNoFinding("TYPO-ABBR-02", "Das ist z. B. ein Beispiel.", "de"); }); it("detects u.a. without space", () => { expectFinding("TYPO-ABBR-02", "Es gibt u.a. viele Optionen.", "de"); }); it("detects d.h. without space", () => { expectFinding("TYPO-ABBR-02", "Das ist d.h. wichtig.", "de"); }); it("does not fire on Ukrainian text", () => { expectNoFinding("TYPO-ABBR-02", "Це z.B. приклад.", "uk"); }); }); // --------------------------------------------------------------------------- // TYPO-APOS-01: Ukrainian word with curly apostrophe (U+2019) // --------------------------------------------------------------------------- describe("TYPO-APOS-01 — Curly apostrophe in Ukrainian", () => { it("detects curly apostrophe in Ukrainian word", () => { const text = "Слово iм\u2019я тут."; expectFinding("TYPO-APOS-01", text, "uk"); }); it("does not fire on straight apostrophe", () => { expectNoFinding("TYPO-APOS-01", "Слово iм'я тут.", "uk"); }); it("does not fire on German text", () => { expectNoFinding("TYPO-APOS-01", "Das ist ein\u2019Test.", "de"); }); it("respects allowedTokens exclusion", () => { const text = "Слово i\u043C\u2019\u044F тут."; const token = "i\u043C\u2019\u044F"; const segment = makeSegment(text, "uk"); const ctx = makeCtx("uk", new Set([token])); const rule = TIER2_LOCALE_RULES.find((r) => r.id === "TYPO-APOS-01") as TypographyRule; const findings = rule.check(segment, ctx); expect(findings).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // TYPO-APOS-02: Ukrainian word with modifier letter apostrophe (U+02BC) // --------------------------------------------------------------------------- describe("TYPO-APOS-02 — Modifier letter apostrophe in Ukrainian", () => { it("detects modifier letter apostrophe in Ukrainian word", () => { const text = "Слово iм\u02BCя тут."; expectFinding("TYPO-APOS-02", text, "uk"); }); it("does not fire on straight apostrophe", () => { expectNoFinding("TYPO-APOS-02", "Слово iм'я тут.", "uk"); }); it("does not fire on German text", () => { expectNoFinding("TYPO-APOS-02", "Das ist ein\u02BCtest.", "de"); }); it("respects allowedTokens exclusion", () => { const text = "Слово i\u043C\u02BC\u044F тут."; const token = "i\u043C\u02BC\u044F"; const segment = makeSegment(text, "uk"); const ctx = makeCtx("uk", new Set([token])); const rule = TIER2_LOCALE_RULES.find((r) => r.id === "TYPO-APOS-02") as TypographyRule; const findings = rule.check(segment, ctx); expect(findings).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // Export count verification // --------------------------------------------------------------------------- describe("TIER2_LOCALE_RULES export", () => { it("exports exactly 7 rules", () => { expect(TIER2_LOCALE_RULES).toHaveLength(7); }); it("all rules are tier 2", () => { for (const rule of TIER2_LOCALE_RULES) { expect(rule.tier).toBe(2); } }); it("all rule IDs use NUM, ABBR, or APOS families", () => { for (const rule of TIER2_LOCALE_RULES) { expect(["NUM", "ABBR", "APOS"]).toContain(rule.family); } }); });