/*
Property-based tests for Tier 2 locale typography rules (RFC-1070).
Tests algebraic properties of the pure rule functions using fast-check (DNA-101).
- Do not test specific edge cases — those are in rules-tier2-locale.test.ts.
- RFC-1070: initial creation with property-based tests for Tier 2 locale rules.
*/
import { describe, it, expect } from "vitest";
import fc from "fast-check";
import { TIER2_LOCALE_RULES } from "./rules-tier2-locale.ts";
import type { TextSegment } from "./text-surface.ts";
import {
createTypographyContext,
type TypographyContext,
type TypographyRule,
type TypographyFinding,
} from "./rules-tier1.ts";
import { fcAssert, composeWord, safeText } from "../pbt/index.ts";
function makeSegment(text: string, locale: string): TextSegment {
return {
file: `test.${locale}.md`,
line: 1,
path: "$body",
source: "body",
text,
raw: text,
isTableRow: false,
locale,
};
}
function getRule(ruleId: string): TypographyRule {
const rule = TIER2_LOCALE_RULES.find((r) => r.id === ruleId);
if (!rule) throw new Error(`Rule ${ruleId} not found`);
return rule;
}
function runRule(
ruleId: string,
text: string,
locale: string,
ctx?: TypographyContext,
): TypographyFinding[] {
const rule = getRule(ruleId);
const segment = makeSegment(text, locale);
return rule.check(segment, ctx ?? createTypographyContext(locale, new Set()));
}
// Use bounded-length strings for fast generation (RFC-1070 PBT helpers)
const FAST_TEXT = safeText(80);
// ---------------------------------------------------------------------------
// Properties for locale-specific rules: non-matching locale → zero findings
// ---------------------------------------------------------------------------
describe("PBT: locale-specific rules never fire on wrong locale", () => {
it("TYPO-NUM-01 never fires on non-de locale", () => {
fcAssert(
fc.property(FAST_TEXT, fc.constantFrom("uk", "en", "fr", "es"), (text, locale) => {
expect(runRule("TYPO-NUM-01", text, locale)).toHaveLength(0);
}),
);
});
it("TYPO-NUM-02 never fires on non-uk locale", () => {
fcAssert(
fc.property(FAST_TEXT, fc.constantFrom("de", "en", "fr", "es"), (text, locale) => {
expect(runRule("TYPO-NUM-02", text, locale)).toHaveLength(0);
}),
);
});
it("TYPO-NUM-03 never fires on non-uk locale", () => {
fcAssert(
fc.property(FAST_TEXT, fc.constantFrom("de", "en", "fr", "es"), (text, locale) => {
expect(runRule("TYPO-NUM-03", text, locale)).toHaveLength(0);
}),
);
});
it("TYPO-ABBR-02 never fires on non-de locale", () => {
fcAssert(
fc.property(FAST_TEXT, fc.constantFrom("uk", "en", "fr", "es"), (text, locale) => {
expect(runRule("TYPO-ABBR-02", text, locale)).toHaveLength(0);
}),
);
});
it("TYPO-APOS-01 never fires on non-uk locale", () => {
fcAssert(
fc.property(FAST_TEXT, fc.constantFrom("de", "en", "fr", "es"), (text, locale) => {
expect(runRule("TYPO-APOS-01", text, locale)).toHaveLength(0);
}),
);
});
it("TYPO-APOS-02 never fires on non-uk locale", () => {
fcAssert(
fc.property(FAST_TEXT, fc.constantFrom("de", "en", "fr", "es"), (text, locale) => {
expect(runRule("TYPO-APOS-02", text, locale)).toHaveLength(0);
}),
);
});
});
// ---------------------------------------------------------------------------
// Properties: text without target patterns → zero findings
// Uses conditional inside property body instead of filter (much faster)
// ---------------------------------------------------------------------------
describe("PBT: text without target patterns yields zero findings", () => {
it("TYPO-NUM-01: text without comma-thousands.dot-decimal → zero findings", () => {
fcAssert(
fc.property(FAST_TEXT, (text) => {
if (/\d{1,3}(?:,\d{3})*\.\d{2}/.test(text)) return; // skip if pattern present
expect(runRule("TYPO-NUM-01", text, "de")).toHaveLength(0);
}),
);
});
it("TYPO-NUM-02: text without dot-thousands.comma-decimal → zero findings", () => {
fcAssert(
fc.property(FAST_TEXT, (text) => {
if (/\d{1,3}(?:\.\d{3})*,\d{2}/.test(text)) return;
expect(runRule("TYPO-NUM-02", text, "uk")).toHaveLength(0);
}),
);
});
it("TYPO-APOS-01: text without U+2019 → zero findings", () => {
fcAssert(
fc.property(FAST_TEXT, (text) => {
if (text.includes("\u2019")) return;
expect(runRule("TYPO-APOS-01", text, "uk")).toHaveLength(0);
}),
);
});
it("TYPO-APOS-02: text without U+02BC → zero findings", () => {
fcAssert(
fc.property(FAST_TEXT, (text) => {
if (text.includes("\u02BC")) return;
expect(runRule("TYPO-APOS-02", text, "uk")).toHaveLength(0);
}),
);
});
});
// ---------------------------------------------------------------------------
// Properties: allowedTokens exclusion is stable
// Constructs strings guaranteed to contain the pattern by composition
// ---------------------------------------------------------------------------
// Compose: letter + "'" + letter using efficient letterString (no filter)
const STRAIGHT_APOS_WORD = composeWord("'");
describe("PBT: allowedTokens exclusion", () => {
it("TYPO-APOS-01: curly-apostrophe words in allowedTokens are never flagged", () => {
fcAssert(
fc.property(STRAIGHT_APOS_WORD, (word) => {
const curlyWord = word.replace(/'/g, "\u2019");
const match = curlyWord.match(/\p{L}+\u2019\p{L}+/u);
if (!match) return;
const allowedToken = match[0];
const ctx = createTypographyContext("uk", new Set([allowedToken]));
expect(runRule("TYPO-APOS-01", curlyWord, "uk", ctx)).toHaveLength(0);
}),
);
});
it("TYPO-APOS-02: modifier-apostrophe words in allowedTokens are never flagged", () => {
fcAssert(
fc.property(STRAIGHT_APOS_WORD, (word) => {
const modWord = word.replace(/'/g, "\u02BC");
const match = modWord.match(/\p{L}+\u02BC\p{L}+/u);
if (!match) return;
const allowedToken = match[0];
const ctx = createTypographyContext("uk", new Set([allowedToken]));
expect(runRule("TYPO-APOS-02", modWord, "uk", ctx)).toHaveLength(0);
}),
);
});
});