// Parity tests: the transforms must produce the same roster as the Python // scripts they were ported from, which are the version that had actually been // used to print cards. // // The `.merge`/`.strip`/`.convert`/`.all` fixtures are that Python output, // checked in - so this suite compares the code against an implementation that // is genuinely independent of it, without needing a Python interpreter. See // README.md and scripts/update-fixtures.mjs before regenerating any of them. // // Comparison is structural rather than textual. The Python scripts edit the // file as text and preserve its byte-for-byte formatting; these transforms build // DOM nodes, so attribute order and whitespace legitimately differ. What has to // match is the tree: elements, nesting, attribute values, and text. import { readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { defaultConfig } from "./config.js"; import { convertChoiceAbilities, splitDescription, } from "./convertChoiceAbilities.js"; import { applyTransforms } from "./index.js"; import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js"; import { removeLeaderAbilities } from "./removeLeaderAbilities.js"; const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__"); const examples = readdirSync(FIXTURES) .filter((name) => name.endsWith(".ros") && name.split(".").length === 2) .map((name) => name.replace(/\.ros$/, "")); const parse = (xml) => { const doc = new DOMParser().parseFromString(xml, "text/xml"); const failure = doc.querySelector("parsererror"); if (failure) throw new Error(`fixture did not parse: ${failure.textContent}`); return doc; }; const load = (name) => parse(readFileSync(join(FIXTURES, `${name}.ros`), "utf8")); /** * A stable text rendering of a document's tree, for comparison. * * `id` and `typeId` are replaced with a token per distinct value, in * first-appearance order: the splitter mints ids for the profiles it generates, * and the JS port hashes them differently from the Python original. What * matters is that ids are shared and distinct in the same *pattern*, which the * tokens preserve, not that the two implementations agree on a hash function. * Every other attribute - including `entryId` and `publicationId`, which are * never generated - has to match exactly. */ function canonicalize(doc) { const tokens = new Map(); const token = (value) => { if (!tokens.has(value)) tokens.set(value, `#${tokens.size}`); return tokens.get(value); }; const generated = new Set(["id", "typeId"]); const lines = []; const walk = (element, depth) => { const attributes = Array.from(element.attributes) .map((attr) => [ attr.name, generated.has(attr.name) ? token(attr.value) : attr.value, ]) .sort(([a], [b]) => (a < b ? -1 : 1)) .map(([name, value]) => `${name}=${JSON.stringify(value)}`) .join(" "); const text = Array.from(element.childNodes) .filter((node) => node.nodeType === 3 /* Text */) .map((node) => node.data) .join("") .trim(); lines.push( `${" ".repeat(depth)}<${element.localName} ${attributes}>` + (text ? ` ${JSON.stringify(text)}` : ""), ); for (const child of element.children) walk(child, depth + 1); }; walk(doc.documentElement, 0); return lines.join("\n"); } const cases = [ ["merge", (doc) => mergeDuplicateUnits(doc, defaultConfig)], ["strip", (doc) => removeLeaderAbilities(doc, defaultConfig)], ["convert", (doc) => convertChoiceAbilities(doc, defaultConfig)], ["all", (doc) => applyTransforms(doc)], ]; describe.each(cases)("%s", (label, run) => { it.each(examples)("matches the python output for %s", (example) => { const doc = load(example); run(doc); expect(canonicalize(doc)).toBe(canonicalize(load(`${example}.${label}`))); }); it.each(examples)("is a no-op on second run for %s", (example) => { const doc = load(example); run(doc); const once = canonicalize(doc); run(doc); expect(canonicalize(doc)).toBe(once); }); }); describe("splitDescription", () => { it("keeps a plain ability whole", () => { const { intro, options } = splitDescription( "While this model is leading a unit, add 1 to the Objective Control characteristic of models in that unit.", ); expect(options).toEqual([]); expect(intro).toMatch(/^While this model/); }); it("splits blank-line separated options", () => { const { intro, options } = splitDescription( "At the start of your Command phase, select one:\n\n" + "Shroudpsalm: Models in this army have the Stealth ability.\n\n" + "Chant of the Remorseless Fist: Melee weapons have the [LANCE] ability.", ); expect(intro).toBe("At the start of your Command phase, select one:"); expect(options.map((option) => option.name)).toEqual([ "Shroudpsalm", "Chant of the Remorseless Fist", ]); expect(options[0].text).toBe( "Models in this army have the Stealth ability.", ); }); it("splits a bulleted list inside one paragraph", () => { const { intro, options } = splitDescription( "Select one protocol:\n" + "- Protocol of the Eternal Guardian: Models have Feel No Pain 6+.\n" + "- Protocol of the Sudden Storm: Models have the Assault ability.", ); expect(intro).toBe("Select one protocol:"); expect(options.map((option) => option.name)).toEqual([ "Protocol of the Eternal Guardian", "Protocol of the Sudden Storm", ]); }); it("treats a long prose colon as prose, not an option", () => { const sentence = `${"a".repeat(70)}: still the same paragraph.`; const { options } = splitDescription(`Intro paragraph.\n\n${sentence}`); expect(options).toEqual([]); }); });