orbit/dev/map-click.mjs

548 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Dev-only: repro + regression check for the MAP console's object click.
*
* Scenario under test:
* 1. open the map console (deck MAP button)
* 2. hover a discovered object on the chart → the tooltip
* (name + "TAP TO PLOT COURSE") must be part of the window
* 3. click it → the ENGAGE AUTOPILOT confirm opens (autopilot is a
* commitment, so it's confirmed before it engages):
* - CANCEL / scrim / ESC → map stays open, no course
* - ENGAGE → course plotted (ship.target) AND the map closes
* While the confirm is up the plate is inert (no zoom/hover/click).
* 4. close the map → NOTHING of the tooltip may linger on screen
*
* The known bug: ttPlate/ttName/ttSub/ttHint are created with
* scene.add() but never added to the MapWindow container (only the
* EMPTY ttCont is) — so they paint at depth 5, BEHIND the window
* (depth 80), and survive close() (nothing ever hides/clears them).
*
* node dev/server.mjs 8091
* node dev/shot-firefox.mjs \
* "http://127.0.0.1:8091/dev/map-click.html" \
* "window.__MAPCLICK ? window.__MAPCLICK.ready : null" \
* /tmp/map-click.png 30000
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
const errors = [];
const origErr = console.error.bind(console);
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => errors.push(String(e.message)));
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitScene() {
for (let i = 0; i < 100; i++) {
const s = game.scene.getScene('GameScene');
if (s && s.ship && s.mapWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
/** Walk up the container parents to the object's top-level display-list ancestor. */
function topLevel(obj) {
let top = obj;
let c = obj.parentContainer; // Phaser 4: container children track their container here
while (c) { top = c; c = c.parentContainer; }
return top;
}
/** Effective (chained) visibility down to the top-level object. */
function effVisible(obj) {
let v = true;
let o = obj;
while (o) {
v = v && o.visible !== false;
o = o.parentContainer;
}
return v;
}
try {
const s = await waitScene();
await sleep(800);
// Discover the central body + first planet + first station so the
// chart (and _hits) has something to point at.
const sysId = s.systemRecord.id;
let known = s.discovery.bySystem.get(sysId);
if (!known) { known = new Set(); s.discovery.bySystem.set(sysId, known); }
const push = (obj) => { if (obj && !known.has(obj.id)) s.discovery.check(sysId, obj.x, obj.y, [obj]); };
if (s.isHomeSystem && s.planet) push({ id: 'home', x: 0, y: 0, radius: s.planet.radius });
if (s.systemPlanets[0]) push({ id: s.systemPlanets[0].discoveryId, x: s.systemPlanets[0].x, y: s.systemPlanets[0].y, radius: s.systemPlanets[0].radius });
if (s.systemStations[0]) push({ id: s.systemStations[0].discoveryId, x: s.systemStations[0].x, y: s.systemStations[0].y, radius: s.systemStations[0].bound ?? 60 });
// Open the console, wait for the reveal + first paint.
s.deckAction('map');
await sleep(1800);
const win = s.mapWindow;
if (!win.isOpen) throw new Error('map window did not open');
// Force a fresh snapshot so _hits is populated.
const snap = win._snapOf();
if (snap && win._fp !== win._fpOf(snap)) win._applySnap(snap);
if (!win._hits || win._hits.length === 0) throw new Error('no hits on the chart');
const hit = win._hits[0];
const hitLabel = String(hit.label ?? hit.id).toUpperCase();
const sx = win.geo.mapX + hit.x;
const sy = win.geo.mapY + hit.y;
const dl = s.sys.displayList.getChildren();
const iWin = dl.indexOf(win);
const iMapImg = dl.indexOf(win.mapImg);
const describe = (o, label) => {
const top = topLevel(o);
const iTop = dl.indexOf(top);
return {
label,
visible: o.visible,
effVisible: effVisible(o),
inWindow: top === win,
topInDisplayList: top === win ? 'the map window' : (iTop >= 0 ? `${top.type || top.constructor.name} @ dl[${iTop}] (win=${iWin})` : 'NOT in display list'),
paintsAboveMap: top === win || (iTop > iWin),
};
};
// Real-input helpers (shared by the dialog + zoom + pan sections)
const frames = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const sm = win.scene.scale;
const cb = sm.canvasBounds;
const cbL = cb.left ?? cb.x, cbT = cb.top ?? cb.y;
const toClient = (gx, gy) => [cbL + gx / sm.displayScale.x, cbT + gy / sm.displayScale.y];
const canvas = win.scene.game.canvas;
const realPress = async (gx, gy) => {
const [cx, cy] = toClient(gx, gy);
for (const type of ['mousedown', 'mouseup']) {
canvas.dispatchEvent(new MouseEvent(type, { clientX: cx, clientY: cy, bubbles: true, cancelable: true, button: 0 }));
await frames();
}
};
// ---- 1: hover the object ------------------------------------------------
win._setHover({ x: sx, y: sy });
await sleep(120);
const before = {
ttPlate: describe(win.ttPlate, 'ttPlate'),
ttName: describe(win.ttName, 'ttName'),
ttHint: describe(win.ttHint, 'ttHint'),
ttCont: describe(win.ttCont, 'ttCont'),
hoverG: describe(win.hoverG, 'hoverG'),
hover: !!win._hover,
hoverId: win._hover?.id ?? null,
ttNameText: win.ttName.text,
mapImg: { inDL: iMapImg >= 0, interactive: !!(win.mapImg.input && win.mapImg.input.enabled) },
};
// ---- 1b: hover decor is drawn in WORLD coordinates ---------------------
// hits are PLATE-LOCAL (plate top-left = 0,0), hoverG draws in world
// space, and the plate sits at geo.mapX/mapY — so the ring, the course
// line's destination end and the dot must all be offset to world. (This
// used to be forgotten for the ring/line/dot: the ship end was correct,
// the destination end landed at plate-local coords, off by mapX/mapY.)
const hoverDraw = (() => {
const g = win.hoverG;
const circles = [];
const lines = [];
const origCircle = g.strokeCircle.bind(g);
const origLine = g.strokeLineShape.bind(g);
g.strokeCircle = (x, y, r) => { circles.push([x, y, r]); return origCircle(x, y, r); };
g.strokeLineShape = (shape) => { lines.push(shape); return origLine(shape); };
g.clear();
win._paintHover();
g.strokeCircle = origCircle;
g.strokeLineShape = origLine;
const ex = win.geo.mapX + hit.x;
const ey = win.geo.mapY + hit.y;
const ring = circles.find((c) => Math.abs(c[2] - (hit.r + 5)) < 1e-6);
const ln = lines[0];
return {
ex,
ey,
ringAtDest: !!ring && Math.abs(ring[0] - ex) < 0.5 && Math.abs(ring[1] - ey) < 0.5,
lineEndsAtDest: !!ln && Math.abs(ln.x2 - ex) < 0.5 && Math.abs(ln.y2 - ey) < 0.5,
lineShipEnd: ln ? [ln.x1, ln.y1] : null,
ring: ring ?? null,
};
})();
// ---- 2: click the object → the ENGAGE AUTOPILOT confirm opens ---------
// (autopilot is a commitment — the dialog asks BEFORE onSelect fires)
const dlg = win.dialog;
const targetBefore = s.ship.target;
win.mapImg.emit('pointerdown', { x: sx, y: sy });
win.mapImg.emit('pointerup', {});
await sleep(700); // open anim (~170ms) + title decode (420ms)
const zDlg = win._view.z;
const [iw, iwY] = toClient(win.geo.mapX + win.geo.mapW / 2, win.geo.mapY + win.geo.mapH / 2);
const evDlg = new WheelEvent('wheel', { clientX: iw, clientY: iwY, deltaY: -300, bubbles: true, cancelable: true });
canvas.dispatchEvent(evDlg); // plate-inert probe: this must NOT zoom
await frames();
const dialog = {
isOpen: dlg.isOpen,
title: dlg.title.text,
body: dlg.bodyTexts.map((t) => t.text),
confirmLabel: dlg.confirmBtn.labelText.text,
targetUnchanged: s.ship.target === targetBefore,
plateInertWheel: win._view.z === zDlg,
};
// ---- 2b: CANCEL keeps the map open, plots no course ---------------------
dlg.cancel();
await sleep(300); // close anim (~130ms)
const dialogCancelled = {
hidden: dlg.state === 'hidden',
mapStillOpen: win.isOpen,
targetUnchanged: s.ship.target === targetBefore,
};
// ---- 2c: ESC cancels the confirm (map stays open) -----------------------
win._setHover({ x: sx, y: sy });
win.mapImg.emit('pointerdown', { x: sx, y: sy });
win.mapImg.emit('pointerup', {});
await sleep(250); // enough for state === 'opening' (isOpen true)
const escSeen = [];
if (s.input.keyboard) {
for (const name of ['keydown-ESC', 'keydown-Escape', 'keydown-ESCAPE']) {
s.input.keyboard.once(name, () => escSeen.push(name));
}
}
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true }));
await sleep(300);
const dialogEsc = {
hidden: dlg.state === 'hidden',
mapStillOpen: win.isOpen,
targetUnchanged: s.ship.target === targetBefore,
escEventSeen: escSeen,
};
// a click on EMPTY plate must not retarget (and opens no dialog)
s.ship.target = null;
win.mapImg.emit('pointerdown', { x: win.geo.mapX + 4, y: win.geo.mapY + 4 });
win.mapImg.emit('pointerup', {});
await sleep(120);
const missClick = { targetStayedNull: s.ship.target === null, noDialog: !dlg.isOpen };
// ---- 2d: REAL pipeline: canvas click over the object opens the confirm;
// a real click on ENGAGE confirms (course + map closes) ---------
// The v3→v4 port bug lived here: mapImg's hit rect was in world-plate
// coords, but v4 hit tests in the image's UNSCALED frame space — so
// only the plate's top-left quarter was actually clickable.
const hitPl = s.sys.input.hitTestPointer({ x: sx, y: sy });
const realHit = hitPl.includes(win.mapImg);
s.ship.target = null;
await realPress(sx, sy);
await sleep(250);
const realDialog = dlg.isOpen;
// ENGAGE button (dialog-local w/2-84, h/2-24 — real click on it)
const bx = dlg.x + dlg.confirmBtn.x;
const by = dlg.y + dlg.confirmBtn.y;
await realPress(bx, by);
await sleep(600); // confirm close (~130ms) + map close (~150ms)
const realConfirm = {
targetWasSet: s.ship.target !== null && s.ship.target !== targetBefore,
target: s.ship.target ? { x: Math.round(s.ship.target.x), y: Math.round(s.ship.target.y) } : null,
mapClosed: win.openState === 'closed',
dialogHidden: dlg.state === 'hidden',
};
// re-open the console for the zoom / pan sections
win.open();
await sleep(1800);
// ---- 2e: wheel zoom ----------------------------------------------------
const geo = win.geo;
const center = { x: geo.mapW / 2, y: geo.mapH / 2 };
const baseScale = win._tf.scale;
const worldAt = (pt) => ({ x: (pt.x - win._tf.ox) / win._tf.scale, y: (pt.y - win._tf.oy) / win._tf.scale });
// (a) 2× about the plate centre — crisp repaint, cursor point anchored
const wAnchor = worldAt(center);
win._zoomAt(center.x, center.y, 2);
await frames();
const zoomIn = { z: win._view.z, scale: win._tf.scale, anchor: worldAt(center) };
// (b)/(c) clamping: below min → 1×, above max → 8×
win._zoomAt(center.x, center.y, 0.001);
await frames();
const clampedMin = win._view.z;
win._zoomAt(center.x, center.y, 999);
await frames();
const clampedMax = win._view.z;
// (d) hit accuracy at 4×: the object nearest the anchor, hover + click it
win._view = { z: 4, cx: wAnchor.x, cy: wAnchor.y };
win._saveView();
win._queueRedraw();
await frames();
let zHit = null, zBest = Infinity;
for (const h of win._hits) {
const w = worldAt({ x: h.x, y: h.y });
const d = Math.hypot(w.x - wAnchor.x, w.y - wAnchor.y);
if (d < zBest) { zBest = d; zHit = h; }
}
const zoomHit = {
id: zHit?.id ?? null,
objectAt: win._objectAt(win._platePoint({ x: geo.mapX + zHit?.x, y: geo.mapY + zHit?.y }))?.id === zHit?.id,
};
s.ship.target = null;
win.mapImg.emit('pointerdown', { x: geo.mapX + zHit.x, y: geo.mapY + zHit.y });
win.mapImg.emit('pointerup', {});
await sleep(200);
zoomHit.clickOpensDialog = dlg.isOpen;
dlg.cancel(); // cancel to restore the plate (the confirm is topmost while up)
await sleep(300);
// (e) double-tap EMPTY plate → reset to 1×
let dblTap = { reset: false, tried: false };
for (const c of [{ x: 6, y: 6 }, { x: geo.mapW - 6, y: 6 }, { x: 6, y: geo.mapH - 6 }, { x: geo.mapW - 6, y: geo.mapH - 6 }]) {
if (win._objectAt(c)) continue; // find a truly empty corner
dblTap.tried = true;
s.ship.target = null;
win.mapImg.emit('pointerdown', { x: geo.mapX + c.x, y: geo.mapY + c.y });
win.mapImg.emit('pointerup', {});
await sleep(30);
win.mapImg.emit('pointerdown', { x: geo.mapX + c.x, y: geo.mapY + c.y });
win.mapImg.emit('pointerup', {});
await frames();
dblTap.reset = win._view.z === 1;
break;
}
// (f) the REAL wheel path (DOM WheelEvent → Phaser input plugin →
// the plate's `wheel` event): over the plate zooms in; over the
// chrome neither zooms nor targets the plate.
const zBeforeWheel = win._view.z;
const [wx1, wy1] = toClient(geo.mapX + geo.mapW / 2, geo.mapY + geo.mapH / 2);
const evIn = new WheelEvent('wheel', { clientX: wx1, clientY: wy1, deltaY: -300, bubbles: true, cancelable: true });
canvas.dispatchEvent(evIn);
await frames();
const wheelIn = { prevented: evIn.defaultPrevented, z: win._view.z, moved: win._view.z !== zBeforeWheel };
const [wx2, wy2] = toClient(30, 20); // title-bar area, off the plate
const evOut = new WheelEvent('wheel', { clientX: wx2, clientY: wy2, deltaY: -300, bubbles: true, cancelable: true });
canvas.dispatchEvent(evOut);
await frames();
const wheelOut = { prevented: evOut.defaultPrevented, z: win._view.z };
// (f2) DRAG PAN (the click now fires on release, so a press that moves
// past the slop pans instead of clicking).
const vSave = { ...win._view };
const restoreView = async () => {
win._view = { ...vSave };
win._saveView();
win._queueRedraw();
await frames();
};
const pan = {};
{
// set the view EXACTLY (a direct 2× about the current centre), not via
// the relative factor, so the assertions are exact
const wC = worldAt(center);
win._view = { z: 2, cx: wC.x, cy: wC.y };
win._saveView();
win._queueRedraw();
await frames();
const c0 = { ...win._view };
const sc = win._tf.scale;
const A = { x: center.x - 60, y: center.y - 40 };
win.mapImg.emit('pointerdown', { x: A.x, y: A.y });
win.mapImg.emit('pointermove', { x: A.x + 30, y: A.y + 20 });
win.mapImg.emit('pointerup', {});
await frames();
pan.z = win._view.z;
pan.dx = Math.abs(win._view.cx - (c0.cx - 30 / sc)) * sc;
pan.dy = Math.abs(win._view.cy - (c0.cy - 20 / sc)) * sc;
pan.noTarget = s.ship.target === null;
pan.hoverCleared = !win._hover;
// hard drag left (2000px — past the frame edge, whatever the size) →
// the centre clamps to the frame's right edge
const B = { x: center.x, y: center.y };
win.mapImg.emit('pointerdown', { x: B.x, y: B.y });
win.mapImg.emit('pointermove', { x: B.x - 2000, y: B.y });
win.mapImg.emit('pointerup', {});
await frames();
const full = win._full;
const vw = full.w / win._view.z;
const edge = full.cx + (full.w - vw) / 2;
pan.clampedPx = Math.abs(win._view.cx - edge) * sc;
pan.clamped = pan.clampedPx < 1e-3;
pan.edgeSlip = Math.abs(win._view.cx - edge); // world units beyond the edge (want 0)
// a drag that STARTS on an object must not select it (or confirm it)
s.ship.target = null;
const h0 = win._hits[0];
win.mapImg.emit('pointerdown', { x: geo.mapX + h0.x, y: geo.mapY + h0.y });
win.mapImg.emit('pointermove', { x: geo.mapX + h0.x + 24, y: geo.mapY + h0.y });
win.mapImg.emit('pointerup', {});
await frames();
pan.dragNoSelect = s.ship.target === null && !dlg.isOpen;
// the REAL input pipeline: mousedown → mousemove → mouseup
const R = { x: center.x + 40, y: center.y };
const [r0x, r0y] = toClient(R.x, R.y);
const kx = 20 / sm.displayScale.x, ky = 12 / sm.displayScale.y;
const cB = { ...win._view };
const scB = win._tf.scale;
canvas.dispatchEvent(new MouseEvent('mousedown', { clientX: r0x, clientY: r0y, bubbles: true, cancelable: true, button: 0 }));
await frames();
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: r0x + kx, clientY: r0y + ky, bubbles: true, cancelable: true }));
await frames();
canvas.dispatchEvent(new MouseEvent('mouseup', { clientX: r0x + kx, clientY: r0y + ky, bubbles: true, cancelable: true }));
await frames();
pan.realDx = Math.abs(win._view.cx - (cB.cx - 20 / scB)) * scB;
pan.realDy = Math.abs(win._view.cy - (cB.cy - 12 / scB)) * scB;
await restoreView();
}
// (g) per-system memory
const viewMemory = win._views.has(win._snap?.systemId);
// ?keepOpen=1 — stop here (map still open, tooltip up) so a screenshot
// harness can capture the chart + tooltip on screen.
const keepOpen = new URLSearchParams(location.search).has('keepOpen');
let after = null, reopen = null, after2 = null;
if (keepOpen) {
win._resetZoom(); // the screenshot wants the full-frame chart
await frames();
const hk = win._hits[0];
win._setHover({ x: geo.mapX + hk.x, y: geo.mapY + hk.y });
await sleep(150);
const pass = dialog.isOpen && dialogCancelled.mapStillOpen && dialogEsc.mapStillOpen
&& missClick.targetStayedNull && realDialog && realConfirm.targetWasSet;
window.__MAPCLICK = {
ready: true, pass, keepOpen: true, before, dialog, dialogCancelled, dialogEsc, missClick, realDialog, realConfirm, realHit, errors,
};
console.log('MAPCLICK (keepOpen) ready');
} else {
// ---- 3: close the map, then inspect what lingers ------------------------
win.close();
await sleep(700); // close tween is 150 ms
after = {
openState: win.openState,
ttPlate: describe(win.ttPlate, 'ttPlate'),
ttName: describe(win.ttName, 'ttName'),
ttHint: describe(win.ttHint, 'ttHint'),
ttCont: describe(win.ttCont, 'ttCont'),
hoverG: describe(win.hoverG, 'hoverG'),
};
// ---- 4: re-open, hover, close again (no leftovers, no crash) -----------
win.open();
await sleep(1800);
const h2 = win._hits[0]; // live hit list for the (possibly zoomed) view
win._setHover({ x: geo.mapX + h2.x, y: geo.mapY + h2.y });
await sleep(120);
reopen = {
open: win.isOpen,
hoverVisible: effVisible(win.ttName),
hoverId: win._hover?.id ?? null,
zoomKept: win._view.z === wheelIn.z,
};
win.close();
await sleep(700);
after2 = {
openState: win.openState,
ttContHidden: !effVisible(win.ttCont),
ttPlateHidden: !effVisible(win.ttPlate),
ttNameHidden: !effVisible(win.ttName),
ttHintHidden: !effVisible(win.ttHint),
};
}
const results = [];
const check = (label, cond) => results.push({ label, pass: !!cond });
check('tooltip (ttPlate) belongs to the map window (paints with it)',
topLevel(win.ttPlate) === win);
check('tooltip name text belongs to the map window', topLevel(win.ttName) === win);
check('tooltip hint ("TAP TO PLOT COURSE") belongs to the map window', topLevel(win.ttHint) === win);
check('while open: tooltip effectively visible on the chart',
before.ttPlate.effVisible && before.ttName.effVisible);
check('while open: tooltip paints ABOVE the map surface (inside the window)',
before.ttPlate.paintsAboveMap && before.ttName.paintsAboveMap);
check('hover ring is drawn at the destination\u2019s WORLD position (offset by the plate origin)',
hoverDraw.ringAtDest);
check('course line ends at the destination\u2019s WORLD position (ship→destination)',
hoverDraw.lineEndsAtDest);
check('clicking a discovered object opens the ENGAGE AUTOPILOT confirm',
dialog.isOpen);
check('the confirm is titled ENGAGE AUTOPILOT',
dialog.title.trim() === 'ENGAGE AUTOPILOT');
check('the confirm names the destination',
dialog.body.join(' ').toUpperCase().includes(hitLabel));
check('the confirm button reads ENGAGE', dialog.confirmLabel === 'ENGAGE');
check('confirming is deferred (no target until ENGAGE)',
dialog.targetUnchanged);
check('while the confirm is up the plate is inert (wheel does not zoom)',
dialog.plateInertWheel);
check('CANCEL closes the dialog, keeps the map open, plots no course',
dialogCancelled.hidden && dialogCancelled.mapStillOpen && dialogCancelled.targetUnchanged);
check('ESC cancels the confirm (map stays open, no course)',
dialogEsc.hidden && dialogEsc.mapStillOpen && dialogEsc.targetUnchanged);
check('clicking empty plate does NOT retarget (and opens no dialog)',
missClick.targetStayedNull && missClick.noDialog);
check('real hit test: a point over the object resolves the chart plate (mapImg)', realHit);
check('REAL canvas click over the object opens the confirm (full input pipeline)', realDialog);
check('REAL click on ENGAGE: course plotted AND the map closes',
realConfirm.targetWasSet && realConfirm.mapClosed && realConfirm.dialogHidden);
check('zoom in 2×: chart re-rendered at double scale (crisp, not stretched)',
zoomIn.z === 2 && Math.abs(zoomIn.scale - baseScale * 2) / baseScale < 0.01);
check('zoom in 2×: the point under the cursor stays under the cursor',
Math.abs(zoomIn.anchor.x - wAnchor.x) * baseScale < 0.5 && Math.abs(zoomIn.anchor.y - wAnchor.y) * baseScale < 0.5);
check('zoom out clamps at 1×', clampedMin === 1);
check('zoom in clamps at 8×', clampedMax === 8);
check('at 4× zoom: the object under the pointer still resolves (hover target)', zoomHit.objectAt);
check('at 4× zoom: clicking it opens the confirm', zoomHit.clickOpensDialog);
check('double-tap empty plate resets zoom to 1×', dblTap.tried && dblTap.reset);
check('wheel over the plate zooms in', wheelIn.moved && wheelIn.z > 1);
check('wheel over the chrome does NOT zoom the plate', wheelOut.z === wheelIn.z);
check('drag over the plate pans the zoomed view (exact dx/scale, dy/scale)',
pan.dx < 0.5 && pan.dy < 0.5);
check('drag keeps the zoom level', pan.z === 2);
check('drag pan clamps at the frame edge (and never overshoots it)', pan.clamped && pan.edgeSlip < 1e-6);
check('a drag does not select / does not confirm', pan.noTarget && pan.dragNoSelect);
check('drag clears the hover', pan.hoverCleared);
check('REAL mousedown→mousemove→mouseup pans through the input pipeline',
pan.realDx < 0.5 && pan.realDy < 0.5);
check('the system\'s zoom view is remembered (per-system memory)', viewMemory);
if (!keepOpen) {
check('after close: tooltip plate hidden (no lingering button)',
!after.ttPlate.effVisible);
check('after close: tooltip name hidden', !after.ttName.effVisible);
check('after close: tooltip hint hidden', !after.ttHint.effVisible);
check('after close: hover ring cleared',
!after.hoverG.effVisible || win.hoverG.commandBuffer.length === 0);
check('re-open: hover tooltip visible on the chart again', reopen.open && reopen.hoverVisible);
check('re-open: the system\'s zoom view persisted (per-system memory)',
reopen.zoomKept === true);
check('second close: tooltip fully hidden (no leftovers)',
after2.ttContHidden && after2.ttPlateHidden && after2.ttNameHidden && after2.ttHintHidden);
}
check('no console errors', errors.length === 0);
const pass = results.every((r) => r.pass);
window.__MAPCLICK = { ready: true, pass, keepOpen, results, before, hoverDraw, dialog, dialogCancelled, dialogEsc, missClick, realDialog, realConfirm, realHit, zoom: { zoomIn, clampedMin, clampedMax, zoomHit, dblTap, wheelIn, wheelOut, pan, viewMemory }, after, reopen, after2, errors };
console.log(pass ? 'MAPCLICK PASS' : 'MAPCLICK FAIL');
console.log(JSON.stringify({ results, dialog, dialogCancelled, dialogEsc, realConfirm }, null, 1));
} catch (err) {
const results = [{ label: `THREW: ${err.message}`, pass: false }];
window.__MAPCLICK = { ready: true, pass: false, results, errors };
console.error('MAPCLICK FAIL (threw)', err);
}