1// Behavior-parity tour. Pressing F9 in a running Atlas walks the2// user-reachable surface — selection, search, filters, zones, the geohash3// grid, keyboard shortcuts, the overview, and map switching — records a4// diagnostics snapshot after every step, and posts the log to the5// development build's /parity/result route, which writes it to disk. Two6// runs of the same build must produce identical output for every stable7// field; the refactor gates diff this output across builds. The tour clears8// the saved session before and after so every run starts from the same9// place, and it announces completion with an on-screen badge so a driver10// that can only look at pixels knows when to collect the file.11const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));12const tourQuery = (selector) => document.querySelector(selector);1314async function waitForBoot() {15 for (let i = 0; i < 300; i += 1) {16 if (window.__atlasDebug && !tourQuery("#map-loading")?.checkVisibility?.()) break;17 await sleep(100);18 }19}2021// A step is settled when tiles stop arriving and the view stops moving. Each22// poll waits through a real animation frame so work the app deferred with23// requestAnimationFrame - the post-selection map fit above all - lands during24// the step that caused it rather than surfacing in a later one.25async function settle() {26 let previous = "";27 for (let i = 0; i < 100; i += 1) {28 await sleep(120);29 await new Promise((resolve) => requestAnimationFrame(resolve));30 window.advanceTime?.();31 const s = window.__atlasDebug.snapshot();32 const key = JSON.stringify([s.zoom, s.center, s.resolution, s.tileStats]);33 if (key === previous) return;34 previous = key;35 }36}3738function keydown(key, options = {}) {39 window.dispatchEvent(new KeyboardEvent("keydown", {40 key, bubbles: true, cancelable: true, ...options,41 }));42}4344function keyup(key, options = {}) {45 window.dispatchEvent(new KeyboardEvent("keyup", {46 key, bubbles: true, cancelable: true, ...options,47 }));48}4950function viewportKeydown(key) {51 tourQuery("#map").dispatchEvent(new KeyboardEvent("keydown", {52 key, bubbles: true, cancelable: true,53 }));54}5556function change(select, value) {57 select.value = value;58 select.dispatchEvent(new Event("change", { bubbles: true }));59}6061function type(selector, value) {62 const input = tourQuery(selector);63 input.value = value;64 input.dispatchEvent(new Event("input", { bubbles: true }));65}6667// 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.74const countPattern = /^([\d,]+) of ([\d,]+) features? in view$/;75const dockPattern = /^([\d,]+) features?$/;76const readCount = (text) => Number(text.replace(/,/g, ""));7778function checkSync(name, snapshot) {79 const sync = snapshot.sync;80 const problems = [];81 const complain = (message) => problems.push(`${name}: ${message}`);8283 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 }9798 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}113114// 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.117function cameraOf(snapshot) {118 return {119 center: (snapshot.center || []).map((value) => Math.round(value)),120 zoom: Number((snapshot.zoom || 0).toFixed(3)),121 };122}123124function sameCamera(a, b) {125 return JSON.stringify(cameraOf(a)) === JSON.stringify(cameraOf(b));126}127128function badge(text, failed = false) {129 let element = tourQuery("#parity-badge");130 if (!element) {131 element = document.createElement("div");132 element.id = "parity-badge";133 element.style.cssText = "position:fixed;top:8px;right:8px;z-index:9999;" +134 "padding:6px 12px;font:700 13px monospace;border-radius:6px;" +135 "color:#fff;pointer-events:none;";136 document.body.append(element);137 }138 element.style.background = failed ? "#b3261e" : "#1a7f37";139 element.textContent = text;140}141142async function tour() {143 const steps = [];144 const problems = [];145 const snapshotOf = (name) => steps.find((step) => step.name === name)?.snapshot;146 const record = async (name) => {147 await settle();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;154 };155156 // Start from a virgin session on the first game so every run of the tour157 // begins in the same place regardless of what was explored beforehand.158 await waitForBoot();159 localStorage.clear();160 const gameSelect = tourQuery("#volume-select");161 const firstGame = gameSelect.options[0].value;162 change(gameSelect, firstGame);163 await record("initial");164165 // Search, select the first result, read the detail panel, close it.166 type("#pin-search", "a");167 await record("search-a");168 const firstResult = tourQuery(".search-result");169 if (firstResult) {170 firstResult.click();171 await record("search-select-first");172 tourQuery("#close-detail").click();173 await record("detail-closed");174 }175 type("#pin-search", "");176 await record("search-cleared");177178 // Category filtering: hide the first category, then solo it, then clear.179 const firstCategory = tourQuery(".category-row input");180 if (firstCategory) {181 firstCategory.click();182 await record("category-hidden");183 firstCategory.click();184 await record("category-restored");185 }186 const firstOnly = tourQuery(".only-button");187 if (firstOnly) {188 firstOnly.click();189 await record("solo-first");190 const chip = tourQuery("#solo-chip");191 if (chip && !chip.hidden) chip.click();192 await record("solo-cleared");193 }194195 // Section folding and the bulk visibility actions.196 const firstSection = tourQuery("#legend .layer-title");197 if (firstSection) {198 firstSection.click();199 await record("section-folded");200 tourQuery("#expand-all").click();201 await record("sections-unfolded");202 }203 tourQuery("#hide-all").click();204 await record("all-hidden");205 tourQuery("#show-all").click();206 await record("all-shown");207208 // Zones: jump to the first zone, highlight it, clear the highlight.209 const firstZone = tourQuery(".zone-index-item");210 if (firstZone) {211 firstZone.click();212 await record("zone-jumped");213 firstZone.dispatchEvent(new MouseEvent("contextmenu", {214 bubbles: true, cancelable: true,215 }));216 await record("zone-highlighted");217 firstZone.dispatchEvent(new MouseEvent("contextmenu", {218 bubbles: true, cancelable: true,219 }));220 await record("zone-unhighlighted");221 }222223 // The unified legend's own states: a shape collection folds and unfolds224 // its feature index, hides and returns, isolates, and flips its label225 // policy -- everything the one-tree rebuild added.226 const shapeExpand = tourQuery("[data-expand-collection]");227 if (shapeExpand) {228 shapeExpand.click();229 await record("collection-folded");230 shapeExpand.click();231 await record("collection-unfolded");232 }233 const shapeRows = [...document.querySelectorAll(".category-row")]234 .filter((row) => row.querySelector("[data-expand-collection]"));235 const shapeRow = shapeRows[1] || shapeRows[0];236 if (shapeRow) {237 const checkbox = shapeRow.querySelector("input[data-collection]");238 checkbox.click();239 await record("collection-hidden");240 checkbox.click();241 await record("collection-restored");242 const only = shapeRow.querySelector("[data-only-collection]");243 if (only) {244 only.click();245 await record("collection-solo");246 const soloChip = tourQuery("#solo-chip");247 if (soloChip && !soloChip.hidden) soloChip.click();248 await record("collection-solo-cleared");249 }250 }251 const labelToggle = tourQuery("[data-label-toggle]");252 if (labelToggle) {253 labelToggle.click();254 await record("labels-flipped");255 labelToggle.click();256 await record("labels-curated");257 }258 // A point collection's labels answer the same toggle: static names on,259 // and back to the curation.260 const pointLabelToggle = [...document.querySelectorAll(".category-row")]261 .filter((row) => !row.querySelector("[data-expand-collection]"))262 .map((row) => row.querySelector("[data-label-toggle]"))263 .find(Boolean);264 if (pointLabelToggle) {265 pointLabelToggle.click();266 await record("point-labels-flipped");267 pointLabelToggle.click();268 await record("point-labels-curated");269 }270271 // Highlights across two collections read AND: the ground both name is272 // the only ground that keeps its pins, and holding Z while they stand273 // reveals the quiet names among them.274 const indexRows = [...document.querySelectorAll(".feature-index")]275 .map((index) => index.querySelector(".zone-index-item"))276 .filter(Boolean);277 if (indexRows.length > 1) {278 const rightClick = (row) => row.dispatchEvent(new MouseEvent("contextmenu", {279 bubbles: true, cancelable: true,280 }));281 const near = indexRows[0];282 const far = indexRows[indexRows.length - 1];283 rightClick(near);284 rightClick(far);285 await record("and-highlighted");286 keydown("z");287 await record("labels-held-highlighted");288 keyup("z");289 rightClick(near);290 rightClick(far);291 await record("and-cleared");292 }293294 // 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 }339340 // The geohash grid: open, descend one character, hide the subgrid,341 // ascend, close.342 keydown("g");343 await record("grid-open");344 type("#grid-input", "m");345 await record("grid-descended");346 keydown(" ");347 await record("subgrid-hidden");348 keydown(" ");349 keydown("Escape");350 await record("grid-ascended");351 keydown("Escape");352 await record("grid-closed");353354 // Label and chrome shortcuts.355 keydown("z");356 await record("labels-on");357 keyup("z");358 await record("labels-off");359 keydown("b", { metaKey: true });360 await record("sidebar-collapsed");361 keydown("b", { metaKey: true });362 await record("sidebar-restored");363364 // The zoom controls, and overview docking while zoomed in — the shelf365 // puts itself away whenever the whole map fits the viewport, so it is only366 // reachable once the view is deeper than the fit.367 tourQuery("#zoom-in").click();368 await record("zoomed-in");369 const dock = tourQuery("#overview-dock");370 if (dock && !tourQuery("#overview-shelf").hidden) {371 dock.click();372 await record("overview-docked");373 dock.click();374 await record("overview-undocked");375 }376 tourQuery("#zoom-out").click();377 await record("zoomed-out");378 viewportKeydown("Escape");379 await record("viewport-escape");380381 // Variant, map, and game switching, then return to the start.382 const variant = tourQuery("#lens-select");383 if (variant.options.length > 1) {384 change(variant, variant.options[1].value);385 await record("variant-second");386 change(variant, variant.options[0].value);387 await record("variant-first");388 }389 const mapSelect = tourQuery("#world-select");390 if (mapSelect.options.length > 1) {391 change(mapSelect, mapSelect.options[1].value);392 await record("map-second");393 change(mapSelect, mapSelect.options[0].value);394 await record("map-first");395 }396 if (gameSelect.options.length > 1) {397 change(gameSelect, gameSelect.options[1].value);398 await record("game-second");399 change(gameSelect, firstGame);400 await record("game-first");401 }402403 localStorage.clear();404 return { viewport: [window.innerWidth, window.innerHeight], problems, steps };405}406407let touring = false;408409async function runTour() {410 if (touring) return;411 touring = true;412 badge("parity tour running…");413 try {414 const result = await tour();415 const response = await fetch("/parity/result", {416 method: "POST",417 headers: { "Content-Type": "application/json" },418 body: JSON.stringify(result, null, 2),419 });420 const path = (await response.text()).trim();421 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 }430 badge(`parity tour complete ✓ ${result.steps.length} steps`);431 console.log("parity tour saved to", path);432 } catch (error) {433 badge(`parity tour failed: ${error}`, true);434 console.error("parity tour failed", error);435 } finally {436 touring = false;437 }438}439440window.__atlasTour = runTour;441window.addEventListener("keydown", (event) => {442 if (event.key === "F9") runTour();443});