Files
brevyscribe/src/transforms/transforms.test.js
T
luxick 62d6bd211b
CI / check (push) Has been cancelled
Fork FancyScribe as BrevyScribe with the datasheet transforms built in
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>
2026-07-25 15:11:10 +02:00

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([]);
});
});