Fork FancyScribe as BrevyScribe with the datasheet transforms built in
CI / check (push) Has been cancelled
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>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user