548 lines
24 KiB
JavaScript
548 lines
24 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|