/*
Unit tests for rules tier1 covering TYPO-PUNCT-01 (doubled/clashing punctuation); TYPO-PUNCT-02 (two dots or 4+ dots); TYPO-PUNCT-03 (whitespace before closing punctuation).
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1068: initial creation.
*/
import { describe, it, expect } from "vitest";
import { TIER1_RULES, createTypographyContext } from "./rules-tier1.ts";
import type { TypographyRule, TypographyContext } from "./rules-tier1.ts";
import type { TextSegment } from "./text-surface.ts";
function makeSegment(text: string, opts: Partial = {}): TextSegment {
return {
file: opts.file ?? "src/content/pages/de/test.md",
line: opts.line ?? 1,
path: opts.path ?? "$body",
source: opts.source ?? "body",
text,
raw: opts.raw ?? text,
isTableRow: opts.isTableRow ?? false,
locale: opts.locale ?? "de",
};
}
function runRule(
ruleId: string,
segment: TextSegment,
ctx?: TypographyContext,
): ReturnType {
const rule = TIER1_RULES.find((r) => r.id === ruleId);
if (!rule) throw new Error(`Rule ${ruleId} not found`);
const context = ctx ?? createTypographyContext(segment.locale, new Set());
return rule.check(segment, context);
}
// ---------------------------------------------------------------------------
// TYPO-PUNCT-01
// ---------------------------------------------------------------------------
describe("TYPO-PUNCT-01 (doubled/clashing punctuation)", () => {
it("detects clashing comma-period", () => {
const seg = makeSegment("Hello,. World");
const findings = runRule("TYPO-PUNCT-01", seg);
expect(findings.length).toBe(1);
expect(findings[0].ruleId).toBe("TYPO-PUNCT-01");
});
it("detects doubled colon", () => {
const seg = makeSegment("Hello:: World");
const findings = runRule("TYPO-PUNCT-01", seg);
expect(findings.length).toBe(1);
});
it("detects three exclamation marks", () => {
const seg = makeSegment("Hello!!!");
const findings = runRule("TYPO-PUNCT-01", seg);
expect(findings.length).toBe(1);
});
it("allows ?! and !?", () => {
expect(runRule("TYPO-PUNCT-01", makeSegment("What?! Really"))).toHaveLength(0);
expect(runRule("TYPO-PUNCT-01", makeSegment("What!? Really"))).toHaveLength(0);
});
it("passes clean text", () => {
expect(runRule("TYPO-PUNCT-01", makeSegment("Hello, World!"))).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-PUNCT-02
// ---------------------------------------------------------------------------
describe("TYPO-PUNCT-02 (two dots or 4+ dots)", () => {
it("detects two consecutive dots", () => {
const seg = makeSegment("Hello.. World");
const findings = runRule("TYPO-PUNCT-02", seg);
expect(findings.length).toBe(1);
});
it("detects four dots", () => {
const seg = makeSegment("Hello.... World");
const findings = runRule("TYPO-PUNCT-02", seg);
expect(findings.length).toBe(1);
});
it("allows three-dot ellipsis", () => {
expect(runRule("TYPO-PUNCT-02", makeSegment("Hello... World"))).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-PUNCT-03
// ---------------------------------------------------------------------------
describe("TYPO-PUNCT-03 (whitespace before closing punctuation)", () => {
it("detects space before comma", () => {
const seg = makeSegment("Hello , World");
const findings = runRule("TYPO-PUNCT-03", seg);
expect(findings.length).toBe(1);
});
it("detects space before period", () => {
const seg = makeSegment("Hello . World");
const findings = runRule("TYPO-PUNCT-03", seg);
expect(findings.length).toBe(1);
});
it("passes clean text", () => {
expect(runRule("TYPO-PUNCT-03", makeSegment("Hello, World."))).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-PUNCT-04
// ---------------------------------------------------------------------------
describe("TYPO-PUNCT-04 (missing space after closing punctuation)", () => {
it("detects missing space after comma", () => {
const seg = makeSegment("Hello,World");
const findings = runRule("TYPO-PUNCT-04", seg);
expect(findings.length).toBe(1);
});
it("detects missing space after exclamation", () => {
const seg = makeSegment("Hello!World");
const findings = runRule("TYPO-PUNCT-04", seg);
expect(findings.length).toBe(1);
});
it("allows decimal separator (digit,digit)", () => {
expect(runRule("TYPO-PUNCT-04", makeSegment("3,14"))).toHaveLength(0);
});
it("passes clean text", () => {
expect(runRule("TYPO-PUNCT-04", makeSegment("Hello, World"))).toHaveLength(0);
});
// RFC-1083: version pattern exclusion
it("does not flag version pattern 4.x", () => {
expect(runRule("TYPO-PUNCT-04", makeSegment("Requires 4.x or later"))).toHaveLength(0);
});
it("does not flag version pattern 2.0.x", () => {
expect(runRule("TYPO-PUNCT-04", makeSegment("Supports 2.0.x"))).toHaveLength(0);
});
it("still flags 4.The (not a version pattern)", () => {
expect(runRule("TYPO-PUNCT-04", makeSegment("4.The"))).toHaveLength(1);
});
it("still flags end.Start (not a version pattern)", () => {
expect(runRule("TYPO-PUNCT-04", makeSegment("end.Start"))).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// TYPO-SPACE-01
// ---------------------------------------------------------------------------
describe("TYPO-SPACE-01 (double spaces)", () => {
it("detects double spaces", () => {
const seg = makeSegment("Hello World");
const findings = runRule("TYPO-SPACE-01", seg);
expect(findings.length).toBe(1);
});
it("skips table rows", () => {
const seg = makeSegment("Hello World", { isTableRow: true });
expect(runRule("TYPO-SPACE-01", seg)).toHaveLength(0);
});
it("passes single space", () => {
expect(runRule("TYPO-SPACE-01", makeSegment("Hello World"))).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-SPACE-02
// ---------------------------------------------------------------------------
describe("TYPO-SPACE-02 (trailing whitespace)", () => {
it("detects trailing spaces", () => {
const seg = makeSegment("Hello World ");
const findings = runRule("TYPO-SPACE-02", seg);
expect(findings.length).toBe(1);
});
it("detects trailing tabs", () => {
const seg = makeSegment("Hello World\t");
const findings = runRule("TYPO-SPACE-02", seg);
expect(findings.length).toBe(1);
});
it("passes clean text", () => {
expect(runRule("TYPO-SPACE-02", makeSegment("Hello World"))).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-SPACE-03
// ---------------------------------------------------------------------------
describe("TYPO-SPACE-03 (tab character)", () => {
it("detects tab inside text", () => {
const seg = makeSegment("Hello\tWorld");
const findings = runRule("TYPO-SPACE-03", seg);
expect(findings.length).toBe(1);
});
it("passes text without tabs", () => {
expect(runRule("TYPO-SPACE-03", makeSegment("Hello World"))).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-SPACE-04
// ---------------------------------------------------------------------------
describe("TYPO-SPACE-04 (leading whitespace in frontmatter)", () => {
it("detects leading whitespace in frontmatter string", () => {
const seg = makeSegment(" Hello World", { source: "frontmatter" });
const findings = runRule("TYPO-SPACE-04", seg);
expect(findings.length).toBe(1);
});
it("skips body lines", () => {
expect(runRule("TYPO-SPACE-04", makeSegment(" Hello World", { source: "body" }))).toHaveLength(
0,
);
});
it("passes clean frontmatter string", () => {
expect(
runRule("TYPO-SPACE-04", makeSegment("Hello World", { source: "frontmatter" })),
).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-CASE-01
// ---------------------------------------------------------------------------
describe("TYPO-CASE-01 (uppercase between lowercase)", () => {
it("detects camelCase corruption", () => {
const seg = makeSegment("This is miXed text");
const findings = runRule("TYPO-CASE-01", seg);
expect(findings.length).toBe(1);
});
it("allows tokens in allowedTokens", () => {
const seg = makeSegment("Use iPhone for photos");
const ctx = createTypographyContext("de", new Set());
expect(runRule("TYPO-CASE-01", seg, ctx)).toHaveLength(0);
});
it("passes clean text", () => {
expect(runRule("TYPO-CASE-01", makeSegment("This is mixed text"))).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-CASE-02
// ---------------------------------------------------------------------------
describe("TYPO-CASE-02 (lowercase sentence start)", () => {
it("detects lowercase after period", () => {
const seg = makeSegment("Hello. this is wrong");
const findings = runRule("TYPO-CASE-02", seg);
expect(findings.length).toBe(1);
});
it("allows lowercase after abbreviation (de)", () => {
const seg = makeSegment("Das ist z. B. eine Sache");
const ctx = createTypographyContext("de", new Set());
expect(runRule("TYPO-CASE-02", seg, ctx)).toHaveLength(0);
});
it("allows lowercase after digit sequence", () => {
expect(runRule("TYPO-CASE-02", makeSegment("See 3. item for details"))).toHaveLength(0);
});
it("passes clean text", () => {
expect(runRule("TYPO-CASE-02", makeSegment("Hello. World is nice"))).toHaveLength(0);
});
it("allows lowercase after u. a. abbreviation (de)", () => {
const seg = makeSegment("Berater u. a.). Die Teilnahme");
const ctx = createTypographyContext("de", new Set());
expect(runRule("TYPO-CASE-02", seg, ctx)).toHaveLength(0);
});
it("skips tokens containing CMS placeholder", () => {
const seg = makeSegment("zum \uE002. eines jeden Monats");
expect(runRule("TYPO-CASE-02", seg)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-LOCALE-01
// ---------------------------------------------------------------------------
describe("TYPO-LOCALE-01 (mixed-script word)", () => {
it("detects Latin+Cyrillic in one token", () => {
const seg = makeSegment("This is wоrld text");
const findings = runRule("TYPO-LOCALE-01", seg);
expect(findings.length).toBe(1);
});
it("passes clean Latin text", () => {
expect(runRule("TYPO-LOCALE-01", makeSegment("This is world text"))).toHaveLength(0);
});
it("passes clean Cyrillic text", () => {
expect(runRule("TYPO-LOCALE-01", makeSegment("Це текст українською"))).toHaveLength(0);
});
it("excludes hyphenated tokens when Latin parts are in allowedTokens", () => {
const ctx = createTypographyContext("uk", new Set(["IT", "PDF", "SEO"]));
const seg = makeSegment("IT-сервіс-менеджер", { locale: "uk" });
const findings = runRule("TYPO-LOCALE-01", seg, ctx);
expect(findings).toHaveLength(0);
});
it("flags hyphenated tokens when Latin parts are NOT in allowedTokens", () => {
const ctx = createTypographyContext("uk", new Set());
const seg = makeSegment("IT-сервіс-менеджер", { locale: "uk" });
const findings = runRule("TYPO-LOCALE-01", seg, ctx);
expect(findings).toHaveLength(1);
});
it("RFC-1201: path-like tokens get the domain-qualified URL-placeholder hint", () => {
const seg = makeSegment("Перейдіть на /r/<ім'я> для деталей", { locale: "uk" });
const findings = runRule("TYPO-LOCALE-01", seg);
expect(findings).toHaveLength(1);
expect(findings[0]!.fixHint).toContain("domain-qualified");
expect(findings[0]!.fixHint).toContain("");
});
it("RFC-1201: plain mixed-script words keep the generic single-script hint", () => {
const seg = makeSegment("This is wоrld text");
const findings = runRule("TYPO-LOCALE-01", seg);
expect(findings).toHaveLength(1);
expect(findings[0]!.fixHint).toBe(
"Separate scripts — this is likely a homoglyph or translation artifact.",
);
});
});
// ---------------------------------------------------------------------------
// TYPO-LOCALE-02
// ---------------------------------------------------------------------------
describe("TYPO-LOCALE-02 (foreign-alphabet letters in uk)", () => {
it("detects Russian letters in Ukrainian text", () => {
const seg = makeSegment("Це текст з буквою ы", { locale: "uk" });
const findings = runRule("TYPO-LOCALE-02", seg);
expect(findings.length).toBe(1);
});
it("skips non-uk locale", () => {
const seg = makeSegment("This has ы in it", { locale: "de" });
expect(runRule("TYPO-LOCALE-02", seg)).toHaveLength(0);
});
it("passes clean Ukrainian text", () => {
expect(
runRule("TYPO-LOCALE-02", makeSegment("Це чистий текст", { locale: "uk" })),
).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-LOCALE-03
// ---------------------------------------------------------------------------
describe("TYPO-LOCALE-03 (Cyrillic in non-Cyrillic locale)", () => {
it("detects Cyrillic in German text", () => {
const seg = makeSegment("Das ist eine Тest", { locale: "de" });
const findings = runRule("TYPO-LOCALE-03", seg);
expect(findings.length).toBe(1);
});
it("skips Cyrillic in Ukrainian locale", () => {
const seg = makeSegment("Це український текст", { locale: "uk" });
expect(runRule("TYPO-LOCALE-03", seg)).toHaveLength(0);
});
it("skips Cyrillic in a Russian locale", () => {
const seg = makeSegment("Это русский текст", { locale: "ru" });
expect(runRule("TYPO-LOCALE-03", seg)).toHaveLength(0);
});
it("still detects Cyrillic in a Latin-script Uzbek locale", () => {
const seg = makeSegment("Bu oʻzbek matni, lekin т harfi bor", { locale: "uz" });
expect(runRule("TYPO-LOCALE-03", seg)).toHaveLength(1);
});
it("allows Cyrillic in blockquote lines", () => {
const seg = makeSegment("Das ist ein Zitat", { locale: "de", raw: "> Das ist ein Zitat" });
expect(runRule("TYPO-LOCALE-03", seg)).toHaveLength(0);
});
it("passes clean Latin text", () => {
expect(
runRule("TYPO-LOCALE-03", makeSegment("Das ist ein Test", { locale: "de" })),
).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// TYPO-YAML-02
// ---------------------------------------------------------------------------
describe("TYPO-YAML-02 (odd double quotes)", () => {
it("detects odd number of double quotes", () => {
const seg = makeSegment('Hello "World', { source: "frontmatter" });
const findings = runRule("TYPO-YAML-02", seg);
expect(findings.length).toBe(1);
});
it("passes even number of double quotes", () => {
expect(
runRule("TYPO-YAML-02", makeSegment('Hello "World"', { source: "frontmatter" })),
).toHaveLength(0);
});
it("skips body lines", () => {
expect(runRule("TYPO-YAML-02", makeSegment('Hello "World', { source: "body" }))).toHaveLength(
0,
);
});
});
// ---------------------------------------------------------------------------
// TIER1_RULES registry
// ---------------------------------------------------------------------------
describe("TIER1_RULES", () => {
it("has 14 rules", () => {
expect(TIER1_RULES.length).toBe(14);
});
it("all rules have unique IDs", () => {
const ids = TIER1_RULES.map((r) => r.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("all rules are tier 1", () => {
expect(TIER1_RULES.every((r) => r.tier === 1)).toBe(true);
});
it("all rules have error severity", () => {
expect(TIER1_RULES.every((r) => r.severity === "error")).toBe(true);
});
});
// ---------------------------------------------------------------------------
// createTypographyContext
// ---------------------------------------------------------------------------
describe("createTypographyContext", () => {
it("merges allowed tokens with defaults", () => {
const ctx = createTypographyContext("de", new Set(["MyToken"]));
expect(ctx.allowedTokens.has("MyToken")).toBe(true);
expect(ctx.allowedTokens.has("iPhone")).toBe(true);
});
it("loads abbreviations for de locale", () => {
const ctx = createTypographyContext("de", new Set());
expect(ctx.abbreviations.has("z. B.")).toBe(true);
});
it("loads abbreviations for uk locale", () => {
const ctx = createTypographyContext("uk", new Set());
expect(ctx.abbreviations.has("напр.")).toBe(true);
});
it("returns empty abbreviations for unknown locale", () => {
const ctx = createTypographyContext("fr", new Set());
expect(ctx.abbreviations.size).toBe(0);
});
});