diff --git a/internal/check-selection-kinds.mjs b/internal/check-selection-kinds.mjs index 8d71157c5..68365ae39 100644 --- a/internal/check-selection-kinds.mjs +++ b/internal/check-selection-kinds.mjs @@ -111,6 +111,52 @@ for (const fn of ['onLegendExport', 'makeSelectionPacket', 'makeSelectionPoster' `${fn}() sets a busy state without a finally — a throw would leave the surface permanently busy and the control dead`) } +// ── THE VARIANT REACHES THE RENDERER, ACROSS THE FILE BOUNDARY ───────────────────────────────── +// ★ THIS IS THE GATE FOR THE DEFECT THAT COST THE MOST. The packet ▾ was built into PosterWall, +// which the profile people actually open does not mount, so the feature was landed, served and +// invisible. Nothing caught it because every check looked INSIDE one file. The legend EMITS a +// string and the plugin PARSES it, and those two live in different files — so the only assertion +// that could have failed is one that reads BOTH and compares. +{ + const LEGEND = read('library/components/legends/SemanticTreeLegend.vue') + // What the menu emits… + const emitted = [...LEGEND.matchAll(/emitExport\('([a-z:]+)'\)/g)].map((m) => m[1]) + ok(emitted.includes('packet:full') && emitted.includes('packet:lightweight'), + `the legend no longer offers both packets — it emits: ${emitted.join(', ') || '(none)'}`) + ok(emitted.includes('poster'), 'the legend no longer offers the poster') + // …and what the plugin understands. A bare 'packet' must keep working, so the consumer splits on + // ':' rather than matching the two new literals — matching literals is how the next variant + // silently becomes a no-op. + ok(/String\(kind\)\.split\(':'\)/.test(VUE), + 'onLegendExport no longer splits the kind — a packet: emit would fall through as an unknown kind and print nothing') + for (const kind of emitted) { + const [base] = kind.split(':') + ok(base === 'poster' || base === 'packet', + `the legend emits '${kind}', whose base '${base}' is neither poster nor packet — the plugin's handler has no branch for it`) + } + // The variant must actually travel to the renderer. Asserting the split alone would pass while + // the parsed value was dropped on the floor. + // ⚠ ANCHOR ON `await`, WHICH ONLY A CALL HAS. The first version of this matched + // `printPrayerPacket(zoneId,\s*variant`, and the DECLARATION — `async function + // printPrayerPacket(zoneId, variant = 'full')` — matches that too. So when a mutant deleted the + // ARGUMENT at the call site, the declaration alone kept the assertion green: true for the wrong + // reason. Same family as a tautology, reached through ambiguity rather than derivation. + ok(/await printPrayerPacket\(zoneId,\s*variant/.test(VUE), + 'the parsed variant is not passed to printPrayerPacket — the menu would offer two packets and print one') + ok(/async function printPrayerPacket\([^)]*variant/.test(VUE), + 'printPrayerPacket does not accept a variant') + ok(/printPacket\(zone,\s*\{[\s\S]{0,120}variant/.test(VUE), + 'printPrayerPacket does not forward the variant to printPacket — the choice would be parsed, held, and never used') + // ⚠ AND THE STRINGS THE MENU RENDERS MUST EXIST. A missing key prints the raw key, which reads as + // a broken translation rather than a missing string — and the explainer is the only place a + // reader meets what a UPG 100 list IS. + const EN_LEGEND = JSON.parse(read('library/i18n/locales/en/legend.json')) + for (const k of ['packet', 'packetExplain', 'packetAria', 'packetFull', 'packetLightweight']) { + ok(EN_LEGEND.export && EN_LEGEND.export[k], + `legend.json export.${k} is missing — the menu would render the raw key`) + } +} + if (fails.length) { console.error('SELECTION-KINDS: FAIL') for (const f of fails) console.error(` ✗ ${f}`)