/* RFC-0347 + ADR-0019: property-based tests for text normalization idempotency and HTML structural invariance. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-0347: initial PBT illustrative example for text-normalize idempotency. ADR-0019: added tag balance, comment isolation, and no-content-creation properties for normalizeHtml. */ import { test, expect } from "vitest"; import fc from "fast-check"; import { normalizeText, normalizeHtml, normalizeMarkdown, DEFAULT_NORMALIZE_CONFIG, } from "../text-normalize.ts"; const textArbitrary = fc .string({ minLength: 0, maxLength: 200 }) .filter((s) => !s.includes("\uFFFD")); test("PBT: normalizeText is idempotent — normalize(normalize(x)) === normalize(x)", () => { fc.assert( fc.property(textArbitrary, (s) => { const once = normalizeText(s, DEFAULT_NORMALIZE_CONFIG); expect(normalizeText(once, DEFAULT_NORMALIZE_CONFIG)).toBe(once); }), ); }); test("PBT: normalizeHtml is idempotent — normalize(normalize(x)) === normalize(x)", () => { fc.assert( fc.property(textArbitrary, (s) => { const once = normalizeHtml(s, DEFAULT_NORMALIZE_CONFIG); expect(normalizeHtml(once, DEFAULT_NORMALIZE_CONFIG)).toBe(once); }), ); }); test("PBT: normalizeMarkdown is idempotent — normalize(normalize(x)) === normalize(x)", () => { fc.assert( fc.property(textArbitrary, (s) => { const once = normalizeMarkdown(s, DEFAULT_NORMALIZE_CONFIG); expect(normalizeMarkdown(once, DEFAULT_NORMALIZE_CONFIG)).toBe(once); }), ); }); // --------------------------------------------------------------------------- // ADR-0019: structural invariance properties for normalizeHtml // --------------------------------------------------------------------------- const STRUCTURAL_TAGS = [ "div", "span", "p", "main", "section", "article", "header", "footer", "ul", "li", ] as const; const htmlTextSegment = fc .string({ minLength: 0, maxLength: 50 }) .filter((s) => !s.includes("<") && !s.includes(">") && !s.includes("\uFFFD")); const htmlComment = fc.tuple(htmlTextSegment).map(([text]) => ``); const htmlBalancedTag = fc .tuple(fc.constantFrom(...STRUCTURAL_TAGS), htmlTextSegment) .map(([tag, text]) => `<${tag}>${text}`); const htmlSegment = fc.oneof(htmlTextSegment, htmlComment, htmlBalancedTag); const htmlArbitrary = fc .array(htmlSegment, { minLength: 1, maxLength: 8 }) .map((segments) => segments.join("\n")); function countTagOccurrences(html: string, tag: string): number { const openRe = new RegExp(`<${tag}\\b[^>]*>`, "gi"); const closeRe = new RegExp(``, "gi"); return (html.match(openRe) ?? []).length + (html.match(closeRe) ?? []).length; } test("PBT: normalizeHtml preserves tag balance — structural tags in input remain balanced in output", () => { fc.assert( fc.property(htmlArbitrary, (input) => { const output = normalizeHtml(input, DEFAULT_NORMALIZE_CONFIG); for (const tag of STRUCTURAL_TAGS) { const inputCount = countTagOccurrences(input, tag); const outputCount = countTagOccurrences(output, tag); expect(outputCount).toBe(inputCount); } }), ); }); test("PBT: normalizeHtml preserves comment isolation — comments are not created or destroyed", () => { fc.assert( fc.property(htmlArbitrary, (input) => { const output = normalizeHtml(input, DEFAULT_NORMALIZE_CONFIG); const inputCommentCount = (input.match(//g) ?? []).length; const outputCommentCount = (output.match(//g) ?? []).length; expect(outputCommentCount).toBe(inputCommentCount); }), ); }); test("PBT: normalizeHtml does not create HTML tags not present in input", () => { fc.assert( fc.property(htmlArbitrary, (input) => { const output = normalizeHtml(input, DEFAULT_NORMALIZE_CONFIG); const tagRe = /<([a-zA-Z][a-zA-Z0-9]*)\b/g; const inputTagNames = new Set(); let m: RegExpExecArray | null; while ((m = tagRe.exec(input)) !== null) { inputTagNames.add(m[1].toLowerCase()); } const outputTagNames = new Set(); while ((m = tagRe.exec(output)) !== null) { outputTagNames.add(m[1].toLowerCase()); } for (const tag of outputTagNames) { expect(inputTagNames.has(tag)).toBe(true); } }), ); });