62d6bd211b
CI / check (push) Has been cancelled
Print generic datasheets - every option a unit could take, the way the official cards read - instead of a record of one particular list. The three Python scripts that used to rewrite the .ros file before upload are now transforms in src/transforms/, running in the browser between DOMParser and the roster parser. Most of each script was machinery for preserving the file byte for byte on the way back to disk; in the browser the document is never serialised, so only the domain logic came across. Because they run on the parsed document rather than the uploaded file, flipping a transform off rebuilds from the original XML with no re-upload. Saved rosters therefore hold the raw roster XML rather than the parsed object, under new localStorage keys. The scripts' output over four 11th-edition rosters is checked in as test fixtures, so the port is measured against an independent implementation that was verified by printing the cards; scripts/update-fixtures.mjs refuses to overwrite those without --force. Two parser call sites now use :scope> rather than repeating the scope element's own name, which means the same thing in a browser and also works under jsdom, where the old form matched nothing. Also: served at the root rather than a GitHub Pages subpath, PostHog analytics removed, and deploy/ carries an nginx site for scribe.luxick.de plus an rsync deploy script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
5.6 KiB
JavaScript
159 lines
5.6 KiB
JavaScript
// 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([]);
|
|
});
|
|
});
|