Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
35 kB · 1044 lines
JavaScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045/** * Hangar: the arithmetic a force is built by, and the wiring around it. * * The numbers are the part worth testing — a screen that adds up wrong is * wrong quietly — so `force.ts` is imported and exercised directly. The rest * is structural, like the other screen checks here: there is no DOM under * `npm test`, and a screen that waits on a session it does not need still * renders perfectly well until the API is down. * * Run with `npm test`. */import { test } from "node:test";import assert from "node:assert/strict";import { readFile } from "node:fs/promises";import { fileURLToPath } from "node:url";
import { NO_TARGET, shares, standing, targetLine, targetTerms, withinTarget,} from "../src/force.ts";import { FILTER_KEYS, FLAG_KEYS, filterValues, legs, loadUnits, matchesFilter, movement, parseQuery, search, secondName, stringifyQuery, suggestions,} from "../src/units.ts";import { HELM_RELEASE, MEGAMEK_VERSION, unitIndexUrl } from "../src/megamek.ts";
const web = fileURLToPath(new URL("../", import.meta.url));
/** Real figures, from the index this screen reads. */const ATLAS = { chassis: "Atlas", model: "AS7-D", name: "Atlas AS7-D", bv: 1897, mass: 100, year: 2755,};const JENNER = { chassis: "Jenner", model: "JR7-D", name: "Jenner JR7-D", bv: 875, mass: 35, year: 2785,};const NOVA = { chassis: "Black Hawk", model: "Prime", name: "Black Hawk (Nova) Prime", bv: 2663, mass: 50, year: 2870,};const pick = (unit, id) => ({ unit, id });
test("the index is addressed by both versions", () => { // helm's artifacts are keyed by the build and the MegaMek it describes; // either half wrong is a screen with no designs in it. assert.equal( unitIndexUrl(), `/assets/helm/${HELM_RELEASE}/${MEGAMEK_VERSION}/units.json`, );});
test("a Clan design is found by the name a player types", () => { // `nova prime` is in neither field on its own: the chassis is "Black Hawk" // and the model is "Prime". 434 designs are like this. const found = search([NOVA, ATLAS], "nova prime"); assert.equal(found.length, 1); assert.equal(found[0].name, "Black Hawk (Nova) Prime");});
test("a Clan design's other name is read out of the display name", () => { assert.equal(secondName(NOVA), "Nova"); assert.equal(secondName(ATLAS), null); // The model is a parenthetical too, and it is not the name. 11 designs in // 0.51.0 carry both, and 229 more have a parenthetical model and no second // name at all — `Archer (Wolf)` is one mech, not two. assert.equal( secondName({ chassis: "Dasher", model: "(Aletha)", name: "Dasher (Fire Moth) (Aletha)", }), "Fire Moth", ); assert.equal( secondName({ chassis: "Archer", model: "(Wolf)", name: "Archer (Wolf)" }), null, ); // No model at all, which is how a base design is written. assert.equal( secondName({ chassis: "Baboon", model: "", name: "Baboon (Howler)" }), "Howler", );});
test("word order does not matter and case does not either", () => { assert.equal(search([NOVA], "PRIME black").length, 1);});
test("an empty query offers nothing rather than everything", () => { assert.deepEqual(search([NOVA, ATLAS], " "), []);});
test("a force with no target has no target to be over", () => { const now = standing([pick(ATLAS, 1), pick(JENNER, 2)], NO_TARGET); assert.equal(now.battleValue, 2772); assert.equal(now.tonnage, 135); assert.equal(now.overBy, 0); assert.equal(now.remaining, null);});
test("over a target is a number, not a refusal", () => { const target = { ...NO_TARGET, battleValue: 2000 }; const now = standing([pick(ATLAS, 1), pick(JENNER, 2)], target); assert.equal(now.overBy, 772); assert.equal(now.remaining, -772);});
test("under a target, a segment is its share of the target", () => { const target = { ...NO_TARGET, battleValue: 8000 }; const [atlas] = shares([pick(ATLAS, 1)], target); assert.ok(Math.abs(atlas - 23.7) < 0.1, `atlas is ${atlas}% of the target`); assert.equal( targetLine([pick(ATLAS, 1)], target), null, "the target is off to the right, not on the bar", );});
test("over a target, the force fills the strip and the target moves onto it", () => { const target = { ...NO_TARGET, battleValue: 2000 }; const picks = [pick(ATLAS, 1), pick(JENNER, 2)]; const total = shares(picks, target).reduce((a, b) => a + b, 0); assert.ok(Math.abs(total - 100) < 0.001, "the segments fill the bar"); const line = targetLine(picks, target); assert.ok( line !== null && line > 70 && line < 73, `the target line is at ${line}%`, );});
test("a year target warns, and only filters when it is locked", () => { const target = { ...NO_TARGET, year: 2800 }; assert.equal(standing([pick(NOVA, 1)], target).tooLate.length, 1); assert.equal( withinTarget(NOVA, target), true, "advisory targets do not filter", ); assert.equal(withinTarget(NOVA, { ...target, mode: "locked" }), false); assert.equal(withinTarget(ATLAS, { ...target, mode: "locked" }), true);});
test("a short query ranks the chassis that starts with it first", () => { // `an` used to answer with Albatross ALB-5W (Dantalion) — matched on a word // nobody typed, inside a bracket — before any Annihilator. const albatross = { chassis: "Albatross", model: "ALB-5W (Dantalion)", name: "Albatross ALB-5W (Dantalion)", bv: 1900, mass: 95, year: 3073, }; const annihilator = { chassis: "Annihilator", model: "ANH-1A", name: "Annihilator ANH-1A", bv: 2178, mass: 100, year: 3009, }; const found = search([albatross, annihilator], "an"); assert.equal(found[0].chassis, "Annihilator"); assert.equal( found.length, 2, "the other one is still a match, just not first", );});
test("nothing typed still shows something to look at", () => { // An empty list under an empty box reads as a screen still loading. const library = [ { chassis: "Atlas", model: "AS7-D", name: "Atlas AS7-D", bv: 1897, mass: 100, weight_class: 4, }, { chassis: "Banshee", model: "BNC-3E", name: "Banshee BNC-3E", bv: 1400, mass: 95, weight_class: 4, }, { chassis: "Locust", model: "LCT-1V", name: "Locust LCT-1V", bv: 432, mass: 20, weight_class: 1, }, ]; const shown = suggestions(library, 1); assert.equal(shown.length, 2, "one from each weight class present"); assert.equal( shown[0].chassis, "Atlas", "heaviest class first, dearest design of it", );});
test("a locked target filters on every field it sets, not only the year", () => { // "What still fits in what is left" is the question a player with 260 BV to // spend is asking. const target = { ...NO_TARGET, battleValue: 8000, mode: "locked" }; const now = standing([pick(ATLAS, 1)], target); assert.equal(withinTarget(JENNER, target, now), true, "875 fits in 6,103"); const nearlyFull = standing( [pick(ATLAS, 1), pick({ ...ATLAS, bv: 5500 }, 2)], target, ); assert.equal( withinTarget(JENNER, target, nearlyFull), false, "875 does not fit in 603", );});
test("Hangar is routed before the session is read", async () => { // The library is a published file and nothing is saved yet, so the screen // needs no account — and one that waits on /api/session is one that goes // down with the API. const router = await readFile(`${web}src/router.ts`, "utf8"); const hangar = router.indexOf('route === "hangar"'); const session = router.indexOf( "await currentSession()", router.indexOf("const error = takeErrorFromUrl()"), ); assert.ok(hangar > 0, "the hangar route is gone"); assert.ok(hangar < session, "Hangar now waits on a session it does not need");});
test("Hangar is a destination rather than a promise", async () => { const destinations = await readFile(`${web}src/destinations.ts`, "utf8"); assert.match( destinations, /\{ id: "hangar", href: "\/#hangar", label: "Hangar" \}/, );});
test("the camo strip works signed out, without offering saved camo", async () => { // Signed out you still get None and a roll — a camo made in the browser and // kept nowhere. What an account adds is the list of ones you saved, and the // strip says so rather than standing empty behind a prompt. const picker = await readFile(`${web}src/screens/camo-picker.ts`, "utf8"); assert.match( picker, /export function camoPicker\(\s*session: Session \| null,/, "the picker takes a session again, so a signed-out screen has no strip", ); assert.match( picker, /if \(!session\) \{[\s\S]{0,200}Sign in to fight in a camo you saved/, ); const signedOut = picker.indexOf("if (!session)"); const list = picker.indexOf("repo.list(session.did)"); assert.ok( signedOut < list, "the list is read before the signed-out case is handled", );});
test("Hangar renders a camo strip whether or not anyone is signed in", async () => { // One picker for both answers to the session read, rather than one call per // answer: `mountCamo` takes `Session | null` and hands it straight on, so // there is no signed-out strip left to go missing on its own. const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); assert.equal( [...hangar.matchAll(/camoPicker\(/g)].length, 1, "the strip is built in more than one place, so one can fall behind", ); assert.match( hangar, /function mountCamo\(session: Session \| null\): void \{\s*const picker = follow\(camoPicker\(session, "random"\)\);/, "the strip is gone, or is no longer built from the session read", ); // And it is opened from the force it paints rather than from a card of its // own, which is the whole reason the picker has a swatch shape. assert.match( hangar, /camoModal\(picker, "Choose camo", "swatch"\)/, "the hangar no longer opens the picker as a swatch on its force", );});
test("a library thumbnail is bare art at a whole scale", async () => { // The complaint this answers: every design in the library was the same // smear. Two causes, and the row needs both fixed - a random high-contrast // camo over the art, and 84 pixels of it squeezed into 64. const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); assert.match( hangar, /sprite\(unit, 1, state, false\)/, "the library row paints its thumbnail, so a camo hides what it is", ); assert.match( hangar, /sprite\(pick\.unit, 0\.5, state, true\)/, "the force is not painted in the camo the strip chose", ); assert.match(hangar, /const camo = paint \? state\.camo : null/); assert.match( hangar, /scale: 1 \| 0\.5/, "a sprite can be drawn at a fraction of a pixel again", ); assert.doesNotMatch( hangar, /canvas\.style\.width/, "an inline width beats the media query that halves it on a phone", ); const css = await readFile(`${web}src/styles.css`, "utf8"); assert.match(css, /\.hangar-sprite \{[^}]*image-rendering: pixelated/); assert.match(css, /\.hangar-sprite \{[^}]*width: 84px;[^}]*height: 72px;/); assert.match(css, /\.hangar-sprite-half \{\s*width: 42px;\s*height: 36px;/);});
test("a query is words and filters together", () => { const parsed = parseQuery("warhammer bv<=1500 tech:inner"); assert.deepEqual(parsed.words, ["warhammer"]); assert.deepEqual(parsed.filters, [ { key: "bv", op: "<=", value: "1500" }, { key: "tech", op: ":", value: "inner" }, ]);});
test("a colon in a name is a word, not a broken filter", () => { // A player typing something the parser does not know should get a search // rather than an error. const parsed = parseQuery("nova:prime"); assert.deepEqual(parsed.words, ["nova:prime"]); assert.equal(parsed.filters.length, 0);});
test("filters gate and words rank", () => { const library = [ { chassis: "Annihilator", model: "ANH-1A", name: "Annihilator ANH-1A", bv: 2178, mass: 100, year: 3009, }, { chassis: "Anubis", model: "ABS-3L", name: "Anubis ABS-3L", bv: 806, mass: 30, year: 3063, }, ]; const cheap = search(library, "an bv<=1000"); assert.equal(cheap.length, 1); assert.equal( cheap[0].chassis, "Anubis", "the expensive one is filtered out, not just ranked down", );});
test("a query of filters alone is a search", () => { const library = [ { chassis: "Atlas", model: "AS7-D", name: "Atlas AS7-D", bv: 1897, mass: 100, year: 2755, }, { chassis: "Locust", model: "LCT-1V", name: "Locust LCT-1V", bv: 432, mass: 20, year: 2499, }, ]; assert.equal(search(library, "mass>=50").length, 1); assert.equal(search(library, "year<2600")[0].chassis, "Locust");});
test("a weight class is named, not numbered", () => { const heavy = { chassis: "Warhammer", model: "WHM-6R", name: "Warhammer WHM-6R", weight_class: 3, }; assert.equal( matchesFilter(heavy, { key: "class", op: ":", value: "heavy" }), true, ); assert.equal( matchesFilter(heavy, { key: "class", op: ":", value: "light" }), false, );});
test("the split index is joined by position, and melee comes with it", async () => { // helm publishes the library as a spine and a file of columns per group, // so the row a screen reads is assembled here. The join is positional and // both ends carry the same build digest, which is what makes that safe. const build = { id: "abcd1234", megamek: "0.51.0", units: 2 }; const documents = { "units.json": { megamek: "0.51.0", helm: "main-test", stats_producer: "megamek-bridge", build, chunks: ["art.json", "figures.json", "paperwork.json", "combat.json"], units: [ { chassis: "Hatchetman", model: "HCT-3F", name: "Hatchetman HCT-3F" }, { chassis: "Foot Platoon", model: "(Rifle)", name: "Foot Platoon (Rifle)", }, ], }, "art.json": { build, chunk: "art", rows: ["meks/hatchetman.gif", null] }, "figures.json": { build, chunk: "figures", rows: [ { bv: 1015, mass: 45 }, { bv: 44, mass: 3 }, ], }, "paperwork.json": { build, chunk: "paperwork", rows: [ { year: 3023, role: "Striker" }, { year: 2350, role: "Ambusher" }, ], }, // The platoon says nothing about melee, which is not the same as saying // no - helm leaves the column out for anything wearing a suit. "combat.json": { build, chunk: "combat", rows: [ { firepower: 18.3, melee: true, symmetrical: false }, { firepower: 0.8 }, ], }, };
const asked = []; const real = globalThis.fetch; globalThis.fetch = (url) => { asked.push(String(url)); const name = String(url).split("/").pop(); const body = documents[name]; return Promise.resolve({ ok: body !== undefined, status: body === undefined ? 404 : 200, json: () => Promise.resolve(body), }); }; try { const index = await loadUnits("/assets/helm/test/0.51.0/units.json"); const [hatchetman, platoon] = index.units;
assert.equal(hatchetman.bv, 1015); assert.equal(hatchetman.year, 3023); assert.equal(hatchetman.sprite, "meks/hatchetman.gif"); assert.equal(hatchetman.firepower, 18.3); assert.equal(hatchetman.melee, true); // A medium laser in each arm, and the hatchet in only one of them. assert.equal(hatchetman.symmetrical, false);
assert.equal(platoon.bv, 44); assert.equal(platoon.sprite, null); // Absent, not false. A filter on melee must return this platoon for // neither answer, and `?? false` here would put it in the list of // machines that cannot brawl. assert.equal(platoon.melee, null); assert.equal(platoon.symmetrical, null);
// Every side document is addressed beside the spine. assert.ok(asked.includes("/assets/helm/test/0.51.0/combat.json")); } finally { globalThis.fetch = real; }});
test("an index written before the split is read exactly as it always was", async () => { // The site and the index are deployed separately, so for as long as that // takes the browser is reading a prefix with the columns on the spine and // no side documents at all. A fresh copy of the module, because the index // is fetched once per page and the test above has already fetched one. const units = await import("../src/units.ts?before-the-split"); const flat = { megamek: "0.51.0", helm: "main-old", stats_producer: "megamek-bridge", units: [ { chassis: "Atlas", model: "AS7-D", name: "Atlas AS7-D", bv: 1897 }, ], };
const asked = []; const real = globalThis.fetch; globalThis.fetch = (url) => { asked.push(String(url)); return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(flat), }); }; try { const index = await units.loadUnits("/assets/helm/old/0.51.0/units.json"); assert.equal(index.units[0].bv, 1897); // Nothing beside the spine was asked for: a prefix with no manifest has // no side documents, and a 404 per screen load is a cost for nothing. assert.deepEqual(asked, ["/assets/helm/old/0.51.0/units.json"]); } finally { globalThis.fetch = real; }});
test("melee is asked with a word, and answered by machines only", () => { // helm answers nothing for infantry and battle armour, because every // platoon can swarm a Mek: the question is about infantry rather than // about this platoon. So a design with no answer is returned by neither // side, and `melee:no` is not two thousand platoons. const hatchetman = { chassis: "Hatchetman", model: "HCT-3F", melee: true }; const atlas = { ...ATLAS, melee: false }; const platoon = { chassis: "Foot Platoon", model: "(Rifle)", melee: null };
for (const [value, wanted] of [ ["yes", hatchetman], ["no", atlas], ]) { const others = [hatchetman, atlas, platoon].filter((u) => u !== wanted); assert.equal(matchesFilter(wanted, { key: "melee", op: ":", value }), true); for (const other of others) { assert.equal( matchesFilter(other, { key: "melee", op: ":", value }), false, `${value} matched ${other.chassis}`, ); } }
// The words a player might reach for, and one nobody means. for (const value of ["y", "true"]) { assert.equal( matchesFilter(hatchetman, { key: "melee", op: ":", value }), true, value, ); } assert.equal( matchesFilter(hatchetman, { key: "melee", op: ":", value: "sword" }), false, );});
test("the flags are asked the same way as each other", () => { // Three columns, one kind of question. `clan` is the flag MegaMek sets // rather than a match on how the tech base is spelled, which is why it is // worth having beside `tech:clan`: the two agree on every design in 0.51.0 // and only one of them is the judgement rather than the spelling. const omni = { chassis: "Black Hawk", model: "Prime", omni: true, clan: true, }; const atlas = { ...ATLAS, omni: false, clan: false };
assert.equal( matchesFilter(omni, { key: "omni", op: ":", value: "yes" }), true, ); assert.equal( matchesFilter(atlas, { key: "omni", op: ":", value: "yes" }), false, ); assert.equal( matchesFilter(atlas, { key: "omni", op: ":", value: "no" }), true, );
assert.equal( matchesFilter(omni, { key: "clan", op: ":", value: "yes" }), true, ); assert.equal( matchesFilter(atlas, { key: "clan", op: ":", value: "yes" }), false, );
// A design the index says nothing about is returned by neither answer, the // same rule melee follows. const unknown = { chassis: "X", model: "1", name: "X 1" }; for (const key of ["omni", "clan", "melee", "symmetrical"]) { for (const value of ["yes", "no"]) { assert.equal( matchesFilter(unknown, { key, op: ":", value }), false, `${key}:${value}`, ); } }});
test("melee is a key the box knows and writes back", () => { const query = parseQuery("melee:yes bv<=2000"); assert.deepEqual(query.filters, [ { key: "melee", op: ":", value: "yes" }, { key: "bv", op: "<=", value: "2000" }, ]); assert.equal(stringifyQuery(query), "melee:yes bv<=2000"); for (const key of ["melee", "omni", "clan", "symmetrical"]) { assert.ok(FILTER_KEYS.includes(key), key); }});
test("symmetry is asked with a word, and answered by Meks only", () => { // helm answers nothing for anything that is not a Mek: a tank has a left // side and a right side and no rules-level notion of mirroring them. So a // design with no answer is returned by neither side, and `symmetrical:no` // is not every vehicle in the library. const warhammer = { chassis: "Warhammer", model: "WHM-6J", symmetrical: true, }; const hatchetman = { chassis: "Hatchetman", model: "HCT-3F", symmetrical: false, }; const tank = { chassis: "Manticore", model: "Standard", symmetrical: null };
for (const [value, wanted] of [ ["yes", warhammer], ["no", hatchetman], ]) { const others = [warhammer, hatchetman, tank].filter((u) => u !== wanted); assert.equal( matchesFilter(wanted, { key: "symmetrical", op: ":", value }), true, ); for (const other of others) { assert.equal( matchesFilter(other, { key: "symmetrical", op: ":", value }), false, `${value} matched ${other.chassis}`, ); } }
// The words a player might reach for, and one nobody means. for (const value of ["y", "true"]) { assert.equal( matchesFilter(warhammer, { key: "symmetrical", op: ":", value }), true, value, ); } assert.equal( matchesFilter(warhammer, { key: "symmetrical", op: ":", value: "mirrored", }), false, );});
test("symmetrical is a key the box knows and writes back", () => { const query = parseQuery("symmetrical:yes class:heavy"); assert.deepEqual(query.filters, [ { key: "symmetrical", op: ":", value: "yes" }, { key: "class", op: ":", value: "heavy" }, ]); assert.equal(stringifyQuery(query), "symmetrical:yes class:heavy");});
test("the legs a design stands on are read the way helm reads them", () => { // The two configurations that do not say their own shape are the ones worth // pinning: a QuadVee stands on four legs and a LAM on two. The `OmniMek` and // `FrankenMek` suffixes say how a design was built, not what shape it is. assert.equal(legs({ config: "Biped OmniMek" }), "biped"); assert.equal(legs({ config: "Biped FrankenMek" }), "biped"); assert.equal(legs({ config: "LAM" }), "biped"); assert.equal(legs({ config: "Quad" }), "quad"); assert.equal(legs({ config: "QuadVee OmniMek" }), "quad"); assert.equal(legs({ config: "Tripod" }), "tripod"); // Only a Mek has one, and no answer is not "biped". assert.equal(legs({ config: null }), null);
const quad = { chassis: "Goliath", model: "GOL-1H", config: "Quad" }; const tank = { chassis: "Manticore", model: "Standard", config: null }; assert.equal( matchesFilter(quad, { key: "quad", op: ":", value: "yes" }), true, ); assert.equal( matchesFilter(quad, { key: "biped", op: ":", value: "no" }), true, ); assert.equal( matchesFilter(quad, { key: "tripod", op: ":", value: "yes" }), false, ); // A design with no configuration is returned by neither answer, the rule // every other flag follows. for (const key of ["biped", "quad", "tripod"]) { for (const value of ["yes", "no"]) { assert.equal( matchesFilter(tank, { key, op: ":", value }), false, `${key}:${value}`, ); } }});
test("a picker is told which keys take which answers", () => { // The controls beside the search box are built from the field table, so a // key that takes a closed set of values is offered as a list rather than as // an empty box: not every player knows a flag is asked with `yes`. for (const key of ["biped", "quad", "tripod", "melee", "omni", "clan"]) { assert.ok(FILTER_KEYS.includes(key), key); assert.ok(FLAG_KEYS.includes(key), key); assert.deepEqual(filterValues(key, []), ["yes", "no"]); } // Every flag, and nothing that is not one. assert.deepEqual(FLAG_KEYS, [ "biped", "clan", "melee", "omni", "quad", "symmetrical", "tripod", ]);
// The weight classes a player names, and nothing for a column that takes // whatever can be typed into it. assert.deepEqual(filterValues("class", []), [ "light", "medium", "heavy", "assault", ]); assert.deepEqual(filterValues("chassis", []), []);
// Roles and tech bases are read off the index in hand rather than copied // from MegaMek's list, so they cannot go stale without the library moving. const library = [ { role: "Sniper", tech_base: "Clan" }, { role: "Brawler", tech_base: "Clan" }, { role: "Sniper", tech_base: "Inner Sphere" }, ]; assert.deepEqual(filterValues("role", library), ["Brawler", "Sniper"]); assert.deepEqual(filterValues("tech", library), ["Clan", "Inner Sphere"]);});
test("a design with no figure fails a comparison rather than passing it", () => { // A filter that quietly includes the rows it knows nothing about is one // nobody can trust. const unknown = { chassis: "X", model: "1", name: "X 1", bv: null }; assert.equal( matchesFilter(unknown, { key: "bv", op: "<=", value: "1500" }), false, );});
test("a locked target speaks the same language as the box", () => { assert.deepEqual(targetTerms({ ...NO_TARGET, year: 3025, mode: "locked" }), [ "year<=3025", ]); assert.deepEqual( targetTerms({ ...NO_TARGET, year: 3025 }), [], "advisory targets impose nothing", );});
test("a quoted value is one value, not two words", () => { // A library full of two-word names needs this: without quoting, `shadow // hawk` is two words that both have to appear somewhere, which also finds a // Hawk Moth standing next to a Shadow Cat. const parsed = parseQuery('chassis:"black hawk" bv<=3000'); assert.deepEqual(parsed.filters, [ { key: "chassis", op: ":", value: "black hawk" }, { key: "bv", op: "<=", value: "3000" }, ]); assert.deepEqual(parsed.words, []);});
test("a quoted phrase with no key is one word", () => { assert.deepEqual(parseQuery('"shadow hawk"').words, ["shadow hawk"]);});
test("a query survives the round trip through text", () => { // The pills are a view of the box, and taking one off rewrites the string — // so parse and stringify have to agree, quotes included. const text = 'atlas bv<=2000 role:"missile boat"'; const once = stringifyQuery(parseQuery(text)); assert.equal(once, 'atlas bv<=2000 role:"missile boat"'); assert.equal(stringifyQuery(parseQuery(once)), once, "and again");});
test("a chassis with a space is searchable by name", () => { const nova = { chassis: "Black Hawk", model: "Prime", name: "Black Hawk (Nova) Prime", bv: 2663, }; const shadow = { chassis: "Shadow Hawk", model: "SHD-2H", name: "Shadow Hawk SHD-2H", bv: 1064, }; assert.deepEqual( search([nova, shadow], 'chassis:"black hawk"').map((u) => u.chassis), ["Black Hawk"], );});
test("the filter reference says what is not supported yet, and marks it", async () => { // A player who tries equipment:"ER PPC" and gets nothing deserves to know // whether they got the syntax wrong or asked the library for something it // does not carry. const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); for (const missing of [ "era", "equipment", "quirks", "damage", "engine", "faction", ]) { assert.match( hangar, new RegExp(`soon\\(\\s*"${missing}"`), `${missing} is not listed as unsupported`, ); } assert.match(hangar, /coming soon/, "nothing marks the unsupported terms"); assert.ok( !hangar.includes('soon("alpha"'), "Alpha Strike is back on the list of things to come", ); // And they are not silently accepted by the parser either. assert.equal(parseQuery('equipment:"ER PPC"').filters.length, 0);});
test("era is not another word for year, and is not accepted", () => { // A year is when a design was introduced; an era is a period it was // available in, and a 2750 design belongs to Star League and to Dark Age // both. Taking `era` as `year` would answer a different question quietly. assert.equal(parseQuery("era:jihad").filters.length, 0); assert.equal(parseQuery("era<=3025").filters.length, 0); assert.ok(!FILTER_KEYS.includes("era"), "era is still claimed as a key"); assert.deepEqual(parseQuery("year<=3025").filters, [ { key: "year", op: "<=", value: "3025" }, ]);});
test("weight asks the same question as mass", () => { const light = { name: "Locust LCT-1V", chassis: "Locust", mass: 20 }; const heavy = { name: "Atlas AS7-D", chassis: "Atlas", mass: 100 }; assert.deepEqual( search([light, heavy], "weight>=70").map((u) => u.chassis), ["Atlas"], ); for (const key of ["mass", "tons", "tonnage", "weight"]) { assert.equal( search([light, heavy], `${key}<=20`).length, 1, `${key} does not read tonnage`, ); }});
test("the reference explains every key the box accepts", async () => { // The dialog is the only place a key's meaning is written down, so a key // the parser takes and the dialog omits is a key nobody can learn. Two // lists: the columns under Filters, and the yes-or-no keys under Booleans, // which the dialog builds from FLAG_KEYS and this table of one-liners. const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); const meanings = /const FLAG_MEANINGS[^{]*\{([\s\S]*?)\n\};/.exec(hangar); const documented = new Set([ ...[...hangar.matchAll(/filter\(\s*"([^"]+)"/g)].flatMap((m) => m[1].split(",").map((key) => key.trim()), ), ...[...meanings[1].matchAll(/^\s*(\w+):/gm)].map((m) => m[1]), ]); for (const key of FILTER_KEYS) { assert.ok(documented.has(key), `${key} is accepted but never explained`); } // Every flag has a line of its own rather than falling back to the one // sentence that is true of all of them. for (const key of FLAG_KEYS) { assert.match(meanings[1], new RegExp(`^\\s*${key}:`, "m"), key); } // And each row carries a term a player can paste into the box. for (const [, doubled, singled] of hangar.matchAll( /filter\(\s*"(?:[^"\\]|\\.)*"\s*,\s*"(?:[^"\\]|\\.)*"\s*,\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*,?\s*\)/g, )) { const query = doubled ?? singled; assert.equal( parseQuery(query).filters.length, 1, `${query} is not a term the parser takes`, ); }});
test("choosing a camo repaints the sprites on the screen", async () => { // The complaint this answers: the strip changed and the machines did not. // A canvas rather than an <img> is what makes it possible - the compositor // is MegaMek's own port and it works on pixels. const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); assert.match(hangar, /el\(\s*"canvas",\s*\{\s*className: sized,/); assert.match(hangar, /"hangar-sprite hangar-sprite-half"/); assert.match(hangar, /camo \? applyCamo\(art, camo\) : art/); assert.match( hangar, /picker\.onChange = \(\) => \{[\s\S]{0,400}pickedPixels\(picker\)/, "the strip's selection does not reach the paint", ); assert.match( hangar, /state\.paint \+= 1/, "a late sprite can still land in the previous camo", ); // Both halves of the selection resolve to pixels: a saved camo and a roll. const picker = await readFile(`${web}src/screens/camo-picker.ts`, "utf8"); assert.match( picker, /if \(picker\.rolled\) return blobPixels\(picker\.rolled\)/, "a rolled camo does not paint", ); assert.match( picker, /remoteCamoPixels\(url\)/, "a saved camo does not paint", );});
test("a design's chassis and model are separate lines", async () => { // "Mechanized Assault XCT" and "Word of Blake Militia NBC" on one line wrap // into each other and neither reads. const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); const chassisRows = [...hangar.matchAll(/className: "hangar-chassis"/g)]; const modelRows = [...hangar.matchAll(/className: "hint hangar-model"/g)]; assert.equal( chassisRows.length, 2, "results and roster both carry a chassis", ); assert.equal(modelRows.length, 2, "results and roster both carry a model"); const css = await readFile(`${web}src/styles.css`, "utf8"); assert.match(css, /\.hangar-chassis \{/); assert.match(css, /\.hangar-model \{/);});
test("the reference is a dialog rather than a caption", async () => { const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); assert.match(hangar, /el\("dialog", \{ className: "hangar-help" \}/); assert.match(hangar, /syntaxDialog\.showModal\(\)/);});
test("every filter the builder can make is a term the box can hold", async () => { // The builder writes into the query rather than holding a filter of its // own, which is what keeps "expressible as text" true by construction // rather than by discipline. const hangar = await readFile(`${web}src/screens/hangar.ts`, "utf8"); assert.match(hangar, /query\.filters\.push\(\{ key: key\.value/); assert.match(hangar, /setQuery\(stringifyQuery\(query\)\)/); assert.match( hangar, /const typed = parseQuery\(state\.query\)/, "the pills are not read from the query", );});
test("a boosted run is printed as the record sheet prints it", () => { // The library carries the run the design actually has, and 442 Meks in // 0.51.0 have one faster than walking and a half - a Fire Moth's MASC, a // Caesar's supercharger. Printed as the one figure it read as this machine // simply being that fast, which is the half of it that costs a roll. assert.equal( movement({ walk_mp: 10, run_mp: 20, jump_mp: 0 }), "10/15(20)/0", ); assert.equal(movement({ walk_mp: 4, run_mp: 8, jump_mp: 0 }), "4/6(8)/0"); // Ordinary designs are unchanged, including the ones that round up. assert.equal(movement({ walk_mp: 5, run_mp: 8, jump_mp: 5 }), "5/8/5"); assert.equal(movement({ walk_mp: 4, run_mp: 6, jump_mp: 0 }), "4/6/0"); // Slower than the arithmetic is not a boost and has no bracket to print. assert.equal(movement({ walk_mp: 3, run_mp: 4, jump_mp: 0 }), "3/4/0"); assert.equal(movement({}), "0/0/0");});