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,109 @@
|
||||
// The transforms have to leave a document the roster parser still understands,
|
||||
// and the whole point is that what comes out the far end reads like the official
|
||||
// card. These tests go through the real parser rather than inspecting the DOM.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Create40kRoster11th } from "../roster40k-11th.js";
|
||||
import { applyTransforms } from "./index.js";
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
|
||||
const GAME_TYPE = "Warhammer 40,000 11th Edition";
|
||||
|
||||
/** Parse a fixture, optionally transforming it, and build the roster. */
|
||||
function build(name, { transform }) {
|
||||
const xml = readFileSync(join(FIXTURES, `${name}.ros`), "utf8");
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml");
|
||||
if (transform) applyTransforms(doc);
|
||||
return Create40kRoster11th(doc, GAME_TYPE);
|
||||
}
|
||||
|
||||
const units = (roster) => roster.forces.flatMap((force) => force.units);
|
||||
const unitNamed = (roster, name) =>
|
||||
units(roster).find((unit) => unit.name.includes(name));
|
||||
|
||||
describe("the transformed roster still parses", () => {
|
||||
it.each(["bellisarius-cawl", "datasmith", "rangers", "skatros"])(
|
||||
"builds a roster from %s",
|
||||
(example) => {
|
||||
const roster = build(example, { transform: true });
|
||||
expect(roster).toBeTruthy();
|
||||
expect(roster.forces.length).toBeGreaterThan(0);
|
||||
expect(units(roster).length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("merging duplicates", () => {
|
||||
it("turns two Skatros cards into one carrying both weapons", () => {
|
||||
const before = build("skatros", { transform: false });
|
||||
const after = build("skatros", { transform: true });
|
||||
|
||||
const skatrosBefore = units(before).filter((unit) =>
|
||||
unit.name.includes("Skatros"),
|
||||
);
|
||||
const skatrosAfter = units(after).filter((unit) =>
|
||||
unit.name.includes("Skatros"),
|
||||
);
|
||||
expect(skatrosBefore.length).toBe(2);
|
||||
expect(skatrosAfter.length).toBe(1);
|
||||
|
||||
// The single card now lists the weapon each copy was taken for.
|
||||
const weapons = [
|
||||
...skatrosAfter[0].rangedWeapons,
|
||||
...skatrosAfter[0].meleeWeapons,
|
||||
]
|
||||
.map((weapon) => weapon.name)
|
||||
.join(" | ");
|
||||
expect(weapons).toMatch(/jezzail/i);
|
||||
expect(weapons).toMatch(/arquebus/i);
|
||||
});
|
||||
|
||||
it("brings an option's own ability along with the model that carries it", () => {
|
||||
const after = build("rangers", { transform: true });
|
||||
const rangers = unitNamed(after, "Ranger");
|
||||
|
||||
const abilityNames = Object.values(rangers.abilities).flatMap((group) =>
|
||||
Array.from(group.keys()),
|
||||
);
|
||||
expect(abilityNames.join(" | ")).toMatch(/data-tether/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripping Leader/Support", () => {
|
||||
// The Datasmith is a Support character, not a Leader; the transform strips
|
||||
// both, and this is the example that has one.
|
||||
it("leaves no attachment ability on the card", () => {
|
||||
const before = build("datasmith", { transform: false });
|
||||
const after = build("datasmith", { transform: true });
|
||||
|
||||
const abilitiesOf = (roster) =>
|
||||
units(roster)
|
||||
.flatMap((unit) => Object.values(unit.abilities))
|
||||
.flatMap((group) => Array.from(group.keys()))
|
||||
.join(" | ");
|
||||
|
||||
expect(abilitiesOf(before)).toMatch(/^Support \|/);
|
||||
expect(abilitiesOf(after)).not.toMatch(/Leader|Support/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitting choice abilities", () => {
|
||||
it("gives each Canticle its own row under one titled group", () => {
|
||||
const after = build("bellisarius-cawl", { transform: true });
|
||||
const cawl = unitNamed(after, "Cawl");
|
||||
|
||||
const group = cawl.abilities["CANTICLES OF THE OMNISSIAH"];
|
||||
expect(group).toBeTruthy();
|
||||
expect(group.size).toBeGreaterThan(1);
|
||||
|
||||
// Options print as plain text: GW's ^^keyword^^ / **emphasis** markers
|
||||
// are resolved, not passed through as punctuation.
|
||||
for (const text of group.values()) {
|
||||
expect(JSON.stringify(text)).not.toMatch(/\^\^|\*\*/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user