/*
Unit tests for fixpbt — fix.pbt covering Fix engine — property-based tests (RFC-1072, DNA-101).
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1311: Compass header sweep — declares the fix.pbt suite's contract for test-file scaffold coverage.
*/
import { describe, it, expect } from "vitest";
import fc from "fast-check";
import { applyFixes, FIXABLE_RULE_IDS, isFixable } from "./fix.ts";
import {
TIER1_RULES,
createTypographyContext,
type TypographyFinding,
type TypographyRule,
} from "./rules-tier1.ts";
import { TIER2_LOCALE_RULES } from "./rules-tier2-locale.ts";
import type { TextSegment } from "./text-surface.ts";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeSegment(text: string, locale = "de"): TextSegment {
return {
file: "test.md",
line: 1,
path: "$body",
source: "body",
text,
raw: text,
isTableRow: false,
locale,
};
}
const ALL_FIXABLE_RULES: readonly TypographyRule[] = [...TIER1_RULES, ...TIER2_LOCALE_RULES].filter(
(rule) => isFixable(rule.id),
);
function runFixableRules(text: string, locale: string): TypographyFinding[] {
const segment = makeSegment(text, locale);
const ctx = createTypographyContext(locale, new Set());
const findings: TypographyFinding[] = [];
for (const rule of ALL_FIXABLE_RULES) {
if (rule.id === "TYPO-ABBR-02" && locale !== "de") continue;
if ((rule.id === "TYPO-APOS-01" || rule.id === "TYPO-APOS-02") && locale !== "uk") continue;
findings.push(...rule.check(segment, ctx));
}
return findings;
}
// ---------------------------------------------------------------------------
// Property tests (DNA-101: idempotency)
// ---------------------------------------------------------------------------
describe("Fix engine — property-based tests (RFC-1072, DNA-101)", () => {
it("FIXABLE_RULE_IDS contains exactly 8 rule IDs", () => {
expect(FIXABLE_RULE_IDS).toHaveLength(8);
expect(FIXABLE_RULE_IDS).toContain("TYPO-PUNCT-01");
expect(FIXABLE_RULE_IDS).toContain("TYPO-SPACE-01");
expect(FIXABLE_RULE_IDS).toContain("TYPO-SPACE-02");
expect(FIXABLE_RULE_IDS).toContain("TYPO-APOS-01");
expect(FIXABLE_RULE_IDS).toContain("TYPO-APOS-02");
expect(FIXABLE_RULE_IDS).toContain("TYPO-ABBR-01");
expect(FIXABLE_RULE_IDS).toContain("TYPO-ABBR-02");
expect(FIXABLE_RULE_IDS).toContain("TYPO-HEAD-04");
});
it("isFixable returns true only for whitelisted rules", () => {
for (const id of FIXABLE_RULE_IDS) {
expect(isFixable(id)).toBe(true);
}
expect(isFixable("TYPO-PUNCT-02")).toBe(false);
expect(isFixable("TYPO-CASE-01")).toBe(false);
expect(isFixable("TYPO-UNICODE-01")).toBe(false);
});
it("applyFixes is idempotent: fixing an already-fixed text produces no new fixes", () => {
fc.assert(
fc.property(
fc.oneof(
fc.constant("Hello world"), // SPACE-01
fc.constant("Hello "), // SPACE-02
fc.constant("Hello,, world"), // PUNCT-01
fc.constant("Hello:: world"), // PUNCT-01
fc.constant("z.B. Hallo"), // ABBR-02 (de)
fc.constant("z. B. Hallo"), // already fixed ABBR-02
),
(text) => {
const findings1 = runFixableRules(text, "de");
const { fixedSegments } = applyFixes(findings1, createTypographyContext("de", new Set()));
const fixedText = fixedSegments.get("test.md:1:$body") ?? text;
// Running fixable rules on the fixed text should produce zero fixable findings
const findings2 = runFixableRules(fixedText, "de");
const fixableFindings2 = findings2.filter((f) => f.fix !== null);
expect(fixableFindings2).toHaveLength(0);
},
),
{ numRuns: 100 },
);
});
it("applyFixes with no fixable findings returns empty map", () => {
const findings: TypographyFinding[] = [];
const ctx = createTypographyContext("de", new Set());
const result = applyFixes(findings, ctx);
expect(result.fixedSegments.size).toBe(0);
expect(result.fixCount).toBe(0);
});
it("applyFixes skips findings with fix: null", () => {
const segment = makeSegment("Hello world", "de");
const findings: TypographyFinding[] = [
{
ruleId: "TYPO-PUNCT-02",
segment,
match: "..",
column: 5,
message: "test",
fixHint: "test",
fix: null,
},
];
const ctx = createTypographyContext("de", new Set());
const result = applyFixes(findings, ctx);
expect(result.fixedSegments.size).toBe(0);
expect(result.fixCount).toBe(0);
});
it("replace action replaces the match at the given column", () => {
const segment = makeSegment("Hello,, world", "de");
const findings: TypographyFinding[] = [
{
ruleId: "TYPO-PUNCT-01",
segment,
match: ",,",
column: 5,
message: "test",
fixHint: "test",
fix: { type: "replace", old: ",,", new: "," },
},
];
const ctx = createTypographyContext("de", new Set());
const result = applyFixes(findings, ctx);
const fixed = result.fixedSegments.get("test.md:1:$body");
expect(fixed).toBe("Hello, world");
});
it("insert action inserts text at the specified position", () => {
const segment = makeSegment("zB Hallo", "de");
const findings: TypographyFinding[] = [
{
ruleId: "TYPO-ABBR-01",
segment,
match: "zB",
column: 0,
message: "test",
fixHint: "test",
fix: { type: "insert", at: 2, text: "." },
},
];
const ctx = createTypographyContext("de", new Set());
const result = applyFixes(findings, ctx);
const fixed = result.fixedSegments.get("test.md:1:$body");
expect(fixed).toBe("zB. Hallo");
});
it("delete action removes characters at the specified position", () => {
const segment = makeSegment("Hello ", "de");
const findings: TypographyFinding[] = [
{
ruleId: "TYPO-SPACE-02",
segment,
match: " ",
column: 5,
message: "test",
fixHint: "test",
fix: { type: "delete", at: 5, length: 3 },
},
];
const ctx = createTypographyContext("de", new Set());
const result = applyFixes(findings, ctx);
const fixed = result.fixedSegments.get("test.md:1:$body");
expect(fixed).toBe("Hello");
});
it("multiple fixes in the same segment are applied correctly (right-to-left)", () => {
const segment = makeSegment("Hello,, world", "de");
const findings: TypographyFinding[] = [
{
ruleId: "TYPO-PUNCT-01",
segment,
match: ",,",
column: 5,
message: "test",
fixHint: "test",
fix: { type: "replace", old: ",,", new: "," },
},
{
ruleId: "TYPO-SPACE-01",
segment,
match: " w",
column: 7,
message: "test",
fixHint: "test",
fix: { type: "replace", old: " w", new: " w" },
},
];
const ctx = createTypographyContext("de", new Set());
const result = applyFixes(findings, ctx);
const fixed = result.fixedSegments.get("test.md:1:$body");
expect(fixed).toBe("Hello, world");
});
});