/* Unit tests for Tier 3 advisory typography rules (RFC-1071). Tests cover all 5 rules: TYPO-UNICODE-01..02, TYPO-LINK-01..02, TYPO-SENT-01. 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-1071: initial creation with unit tests for 5 Tier 3 advisory rules. */ import { describe, it, expect } from "vitest"; import { TIER3_ADVISORY_RULES, type Tier3AdvisoryRuleId } from "./rules-tier3-advisory.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", raw?: string, ): TextSegment { return { file: `test.${locale}.md`, line: 1, path: "$body", source, text, raw: raw ?? text, isTableRow: false, locale, }; } function makeCtx( locale: string, allowedTokens: ReadonlySet = new Set(), ): TypographyContext { return createTypographyContext(locale, allowedTokens); } function runRule( ruleId: Tier3AdvisoryRuleId, text: string, locale: string, source: "frontmatter" | "body" = "body", raw?: string, ): TypographyFinding[] { const rule = TIER3_ADVISORY_RULES.find((r) => r.id === ruleId) as TypographyRule; const segment = makeSegment(text, locale, source, raw); const ctx = makeCtx(locale); return rule.check(segment, ctx); } function expectFinding( ruleId: Tier3AdvisoryRuleId, text: string, locale: string, raw?: string, ): void { const findings = runRule(ruleId, text, locale, "body", raw); expect(findings.length).toBeGreaterThanOrEqual(1); expect(findings[0].ruleId).toBe(ruleId); } function expectNoFinding( ruleId: Tier3AdvisoryRuleId, text: string, locale: string, source: "frontmatter" | "body" = "body", raw?: string, ): void { const findings = runRule(ruleId, text, locale, source, raw); expect(findings).toHaveLength(0); } // --------------------------------------------------------------------------- // Verify all rules have tier 3 and severity warning // --------------------------------------------------------------------------- describe("TIER3_ADVISORY_RULES — metadata", () => { it("has 5 rules", () => { expect(TIER3_ADVISORY_RULES).toHaveLength(5); }); it("all rules have tier 3 and severity warning", () => { for (const rule of TIER3_ADVISORY_RULES) { expect(rule.tier).toBe(3); expect(rule.severity).toBe("warning"); } }); it("has expected rule IDs", () => { const ids = TIER3_ADVISORY_RULES.map((r) => r.id); expect(ids).toContain("TYPO-UNICODE-01"); expect(ids).toContain("TYPO-UNICODE-02"); expect(ids).toContain("TYPO-LINK-01"); expect(ids).toContain("TYPO-LINK-02"); expect(ids).toContain("TYPO-SENT-01"); }); }); // --------------------------------------------------------------------------- // TYPO-UNICODE-01: Zero-width characters // --------------------------------------------------------------------------- describe("TYPO-UNICODE-01 — zero-width characters", () => { it("detects U+200B (zero-width space)", () => { expectFinding("TYPO-UNICODE-01", "Hello\u200BWorld", "en"); }); it("detects U+200C (zero-width non-joiner)", () => { expectFinding("TYPO-UNICODE-01", "Hello\u200CWorld", "en"); }); it("detects U+200D (zero-width joiner)", () => { expectFinding("TYPO-UNICODE-01", "Hello\u200DWorld", "en"); }); it("detects U+FEFF (BOM)", () => { expectFinding("TYPO-UNICODE-01", "Hello\uFEFFWorld", "en"); }); it("does not flag clean text", () => { expectNoFinding("TYPO-UNICODE-01", "Hello World", "en"); }); it("reports correct character name in message", () => { const findings = runRule("TYPO-UNICODE-01", "Hello\u200BWorld", "en"); expect(findings[0].message).toContain("U+200B"); }); }); // --------------------------------------------------------------------------- // TYPO-UNICODE-02: Soft hyphen // --------------------------------------------------------------------------- describe("TYPO-UNICODE-02 — soft hyphen", () => { it("detects U+00AD (soft hyphen)", () => { expectFinding("TYPO-UNICODE-02", "Hello\u00ADWorld", "en"); }); it("does not flag clean text", () => { expectNoFinding("TYPO-UNICODE-02", "Hello World", "en"); }); it("does not flag HTML entity ­ (ASCII, not U+00AD)", () => { expectNoFinding("TYPO-UNICODE-02", "Hello­World", "en"); }); }); // --------------------------------------------------------------------------- // TYPO-LINK-01: Link text is a bare URL // --------------------------------------------------------------------------- describe("TYPO-LINK-01 — bare URL link text", () => { it("detects [https://example.com](https://example.com)", () => { const raw = "[https://example.com](https://example.com)"; const text = "[https://example.com]"; // extractor strips ](url) expectFinding("TYPO-LINK-01", text, "en", raw); }); it("detects [http://example.com](http://example.com)", () => { const raw = "[http://example.com](http://example.com)"; const text = "[http://example.com]"; expectFinding("TYPO-LINK-01", text, "en", raw); }); it("does not flag descriptive link text", () => { const raw = "[Learn more](https://example.com)"; const text = "[Learn more]"; expectNoFinding("TYPO-LINK-01", text, "en", "body", raw); }); it("does not flag frontmatter segments", () => { const raw = "[https://example.com](https://example.com)"; const text = "[https://example.com]"; expectNoFinding("TYPO-LINK-01", text, "en", "frontmatter", raw); }); it("does not flag bare URLs inside inline code spans", () => { const raw = "`[https://example.com](https://example.com)`"; const text = "\uE002"; // extractor replaces code spans with placeholder expectNoFinding("TYPO-LINK-01", text, "en", "body", raw); }); }); // --------------------------------------------------------------------------- // TYPO-LINK-02: Link text is a generic phrase // --------------------------------------------------------------------------- describe("TYPO-LINK-02 — generic link text", () => { it("detects 'click here'", () => { const raw = "[click here](https://example.com)"; const text = "[click here]"; expectFinding("TYPO-LINK-02", text, "en", raw); }); it("detects 'hier klicken'", () => { const raw = "[hier klicken](https://example.com)"; const text = "[hier klicken]"; expectFinding("TYPO-LINK-02", text, "de", raw); }); it("detects 'тут'", () => { const raw = "[тут](https://example.com)"; const text = "[тут]"; expectFinding("TYPO-LINK-02", text, "uk", raw); }); it("detects 'here' case-insensitive", () => { const raw = "[HERE](https://example.com)"; const text = "[HERE]"; expectFinding("TYPO-LINK-02", text, "en", raw); }); it("detects 'hier' (German)", () => { const raw = "[hier](https://example.com)"; const text = "[hier]"; expectFinding("TYPO-LINK-02", text, "de", raw); }); it("does not flag descriptive link text", () => { const raw = "[Learn more about our services](https://example.com)"; const text = "[Learn more about our services]"; expectNoFinding("TYPO-LINK-02", text, "en", "body", raw); }); it("does not flag 'here' within a longer phrase", () => { const raw = "[See here for details](https://example.com)"; const text = "[See here for details]"; expectNoFinding("TYPO-LINK-02", text, "en", "body", raw); }); it("does not flag frontmatter segments", () => { const raw = "[click here](https://example.com)"; const text = "[click here]"; expectNoFinding("TYPO-LINK-02", text, "en", "frontmatter", raw); }); it("does not flag generic phrases inside inline code spans", () => { const raw = "`[click here](https://example.com)`"; const text = "\uE002"; expectNoFinding("TYPO-LINK-02", text, "en", "body", raw); }); }); // --------------------------------------------------------------------------- // TYPO-SENT-01: Sentence longer than 40 words // --------------------------------------------------------------------------- function makeLongSentence(wordCount: number): string { return Array.from({ length: wordCount }, (_, i) => `word${i}`).join(" ") + "."; } describe("TYPO-SENT-01 — long sentence", () => { it("detects a sentence with 41 words", () => { const text = makeLongSentence(41); expectFinding("TYPO-SENT-01", text, "en"); }); it("does not flag a sentence with 40 words", () => { const text = makeLongSentence(40); expectNoFinding("TYPO-SENT-01", text, "en"); }); it("does not flag a short sentence", () => { expectNoFinding("TYPO-SENT-01", "This is a short sentence.", "en"); }); it("does not flag frontmatter segments", () => { const text = makeLongSentence(41); expectNoFinding("TYPO-SENT-01", text, "en", "frontmatter"); }); it("counts words correctly with Unicode punctuation", () => { const words = Array.from({ length: 41 }, (_, i) => `word${i}`).join(", "); const text = words + "."; expectFinding("TYPO-SENT-01", text, "en"); }); });