A filter set on one side of the window has to land on three surfaces at once: the canvas draws it, the footer counts it, the panel lists it. Diffing two tours catches those three drifting apart between builds, but it cannot catch a build where they never agreed -- and that is exactly what shipped. The snapshot now carries what the map is drawing and what can be listed beside the words the footer and the dock actually rendered, and the tour reads them against each other after every single step: a count that does not match the model, a list longer than the map, a row count out of step with its own header. The mismatches ride home in the log and turn the badge red, so a surface that forgot to recount fails the sweep instead of being noticed months later in a screenshot. The tour also walks the case the checks were written for -- ground highlighted from the legend, the panel beside it losing exactly what the canvas lost, a row opened and closed to see the camera make the round trip, and a view steered by hand that closing must not override. And the headless runner reads the badge's own words now: matching the bare word "complete" let a tour that finished red be reported green.
- commit
- eb4894a92fe7701ced28af0f345b3a07747e8472
- parent
- 016b45e8898349d9109d6f74a9b537dc47ef25ab
- author
- Dami <dami.etoile@gmail.com>
- kind
- test risk score 1.8 of 3, Jev's estimate
| M | frontend/ | 16 | ++++- |
| M | frontend/ | 126 | ++++++++++++++++++++++++++++++- |
| M | frontend/ | 16 | ++++ |
@@ -100,7 +100,21 @@ try {
100100 }, null, { timeout: 480000 });101101 return await page.evaluate(() => document.querySelector("#parity-badge").textContent);102102 }`);103- if (!badge.includes("complete")) throw new Error(`tour did not complete:\n${badge}`);103+ // A tour that finished but found the map, the footer and the dock telling104+ // different stories fails here, with the mismatches read out of the log it105+ // still wrote.106+ // The badge's own words, not merely the word "complete": the CLI's output107+ // wraps the value returned to us, and matching loosely let a tour that108+ // finished red be reported green.109+ if (!badge.includes("parity tour complete")) {110+ const failed = readdirSync(parityDir)111+ .filter((name) => name.startsWith("tour-") && name.endsWith(".json"))112+ .map((name) => join(parityDir, name))113+ .filter((path) => statSync(path).mtimeMs >= launchedAt)114+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];115+ const found = failed ? JSON.parse(readFileSync(failed, "utf8")).problems || [] : [];116+ throw new Error([`tour did not complete:\n${badge}`, ...found].join("\n "));117+ }104118 105119 // The tour posted its log to /parity/result, which writes a timestamped106120 // file; the one belonging to this run is the one born after the launch.@@ -64,6 +64,67 @@ function type(selector, value) {
6464 input.dispatchEvent(new Event("input", { bubbles: true }));6565 }6666 67+// One filter, three surfaces: the canvas draws it, the footer counts it, the68+// dock lists it. A diff between two builds catches them drifting apart from69+// each other; these checks catch a single build where they never agreed in the70+// first place -- a filter that landed on the map and left the panel beside it71+// offering features that are no longer out there. Read straight off the72+// rendered text, so a surface that forgot to recount fails here even though73+// the model behind it is right.74+const countPattern = /^([\d,]+) of ([\d,]+) features? in view$/;75+const dockPattern = /^([\d,]+) features?$/;76+const readCount = (text) => Number(text.replace(/,/g, ""));77+78+function checkSync(name, snapshot) {79+ const sync = snapshot.sync;80+ const problems = [];81+ const complain = (message) => problems.push(`${name}: ${message}`);82+83+ if (sync.footerText === "No features shown") {84+ if (sync.drawn !== 0) complain(`footer says nothing is shown while ${sync.drawn} features draw`);85+ } else {86+ const footer = countPattern.exec(sync.footerText);87+ if (!footer) complain(`footer text "${sync.footerText}" is not a count`);88+ else {89+ const inView = readCount(footer[1]);90+ const shown = readCount(footer[2]);91+ if (shown !== sync.drawn) {92+ complain(`footer counts ${shown} features shown, the map draws ${sync.drawn}`);93+ }94+ if (inView > shown) complain(`footer has ${inView} of ${shown} in view`);95+ }96+ }97+98+ const dock = dockPattern.exec(sync.dockText);99+ if (!dock) complain(`dock text "${sync.dockText}" is not a count`);100+ else if (readCount(dock[1]) !== sync.listable) {101+ complain(`dock counts ${readCount(dock[1])} features, ${sync.listable} are listable`);102+ }103+ // The list itself is capped; the count above it never is.104+ const expectedRows = Math.min(sync.listable, 100);105+ if (sync.dockRows !== expectedRows) {106+ complain(`dock shows ${sync.dockRows} rows for ${sync.listable} listable features`);107+ }108+ if (sync.listable > sync.drawn) {109+ complain(`dock can list ${sync.listable} features from a map drawing ${sync.drawn}`);110+ }111+ return problems;112+}113+114+// Where the camera stands, to the precision worth comparing: a returning115+// animation lands on the coordinate it was given, and floating point noise116+// past a pixel is not a difference anyone can see.117+function cameraOf(snapshot) {118+ return {119+ center: (snapshot.center || []).map((value) => Math.round(value)),120+ zoom: Number((snapshot.zoom || 0).toFixed(3)),121+ };122+}123+124+function sameCamera(a, b) {125+ return JSON.stringify(cameraOf(a)) === JSON.stringify(cameraOf(b));126+}127+67128 function badge(text, failed = false) {68129 let element = tourQuery("#parity-badge");69130 if (!element) {@@ -80,9 +141,16 @@ function badge(text, failed = false) {
80141 81142 async function tour() {82143 const steps = [];144+ const problems = [];145+ const snapshotOf = (name) => steps.find((step) => step.name === name)?.snapshot;83146 const record = async (name) => {84147 await settle();85- steps.push({ name, snapshot: JSON.parse(window.render_game_to_text()) });148+ const snapshot = JSON.parse(window.render_game_to_text());149+ steps.push({ name, snapshot });150+ // Every step is a filter check: whatever the tour just did, the map, the151+ // footer and the dock have to be telling the same story afterwards.152+ problems.push(...checkSync(name, snapshot));153+ return snapshot;86154 };87155 88156 // Start from a virgin session on the first game so every run of the tour@@ -223,6 +291,52 @@ async function tour() {
223291 await record("and-cleared");224292 }225293 294+ // The legend, the map and the dock under one filter. Highlighting ground295+ // culls every location standing outside it, and the panel beside the map has296+ // to lose exactly what the canvas lost: the count in its header used to go297+ // on quoting the whole map while one pin stood on it. Then the camera's298+ // round trip -- a row in the list is a detour, and closing the card gives299+ // back the view the jump borrowed.300+ const syncZone = tourQuery(".zone-index-item");301+ if (syncZone) {302+ syncZone.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));303+ await record("filter-highlight-dock");304+ const before = await record("filter-dock-before-jump");305+ // The last row rather than the first: the tour has already flown to the306+ // first zone in the index, and a jump to where the camera is standing307+ // would prove nothing about coming back from one.308+ const rows = [...document.querySelectorAll("#dock-results .search-result")];309+ const row = rows[rows.length - 1];310+ if (row) {311+ row.click();312+ const jumped = await record("dock-jumped");313+ const moved = !sameCamera(before, jumped);314+ tourQuery("#close-detail").click();315+ const returned = await record("dock-returned");316+ if (moved && !sameCamera(before, returned)) {317+ problems.push("dock-returned: closing the card left the camera where the jump put it");318+ }319+ }320+ // A hand on the map retires the way back: the card closes on the view the321+ // reader steered to, not the one the jump came from.322+ const steered = [...document.querySelectorAll("#dock-results .search-result")].pop();323+ if (steered) {324+ steered.click();325+ await record("dock-jumped-again");326+ tourQuery("#zoom-in").click();327+ const chosen = await record("dock-steered");328+ tourQuery("#close-detail").click();329+ const kept = await record("dock-kept-view");330+ if (!sameCamera(chosen, kept)) {331+ problems.push("dock-kept-view: closing the card overrode a view the reader steered to");332+ }333+ tourQuery("#zoom-out").click();334+ await record("dock-steer-undone");335+ }336+ syncZone.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));337+ await record("filter-highlight-cleared");338+ }339+226340 // The geohash grid: open, descend one character, hide the subgrid,227341 // ascend, close.228342 keydown("g");@@ -287,7 +401,7 @@ async function tour() {
287401 }288402 289403 localStorage.clear();290- return { viewport: [window.innerWidth, window.innerHeight], steps };404+ return { viewport: [window.innerWidth, window.innerHeight], problems, steps };291405 }292406 293407 let touring = false;@@ -305,6 +419,14 @@ async function runTour() {
305419 });306420 const path = (await response.text()).trim();307421 if (!response.ok) throw new Error(`saving failed: ${response.status} ${path}`);422+ // The log is written either way -- a failing check is worth reading in423+ // full -- but a tour that found the surfaces out of step has not passed,424+ // and the badge is what says so to whatever is driving.425+ if (result.problems.length) {426+ for (const problem of result.problems) console.error("parity sync", problem);427+ badge(`parity tour failed: ${result.problems.length} sync problems`, true);428+ return;429+ }308430 badge(`parity tour complete ✓ ${result.steps.length} steps`);309431 console.log("parity tour saved to", path);310432 } catch (error) {@@ -4,6 +4,7 @@ import { state } from "./state.js";
44 import { viewMaxZoom } from "./navigation.js";55 import { refreshCatalog } from "./library.js";66 import { currentGridExtent, gridMaxLevel, pinInGridCell } from "./grid.js";7+import { drawnFeatureCount, listableFeatures } from "./visibility.js";78 89 export function exposeDiagnostics() {910 const snapshot = () => ({@@ -62,6 +63,21 @@ export function exposeDiagnostics() {
6263 focusedPins: state.features.filter((pin) => !pin.filteredHidden &&6364 pin.passesZoneFilters).length,6465 },66+ // The three places one filter has to land at once, side by side so a diff67+ // can see them disagree. The first two are the model's own count of what68+ // the map is drawing; the last two are what the footer and the dock69+ // actually say. A surface that forgot to recount after a filter moved70+ // shows up here as text out of step with the numbers beside it -- which is71+ // the whole of what the tour's sync check reads.72+ sync: {73+ drawn: drawnFeatureCount(),74+ listable: listableFeatures().length,75+ footerText: elements.visibleCount.textContent,76+ dockText: elements.dockCount.textContent,77+ dockFlag: elements.dockFlag.hidden ? null : elements.dockFlag.textContent,78+ dockRows: elements.searchResults.querySelectorAll(".search-result").length,79+ searching: Boolean(state.search),80+ },6581 grid: {6682 enabled: state.gridEnabled,6783 system: state.gridSystem,