Files
gods-eye-view/src/layers/streetLevel/selection.test.mjs
T
Said TurkseverandClaude Opus 5.5 3af0f80cdb test(street-level): close the mutation survivors; Esc in rich text
A mutation review left 18 earlier survivors; each now has a test that
fails with the mutant applied:

- the expanded viewer returns into the panel on shrink, close and destroy
  (the fake DOM had no parentNode, so reinsertion was never exercised); the
  fake DOM gains offsetParent and a MutationObserver that honours its
  options, and a <body> child added while expanded goes inert;
- each pose moves the marker, drives FOLLOW and selects a new sequence;
  close() retires an open still mounting or opening; a provider toggled
  while the layer is off activates nothing; the adapter drops late and
  superseded poses;
- share-link defaults: "any date" (0 days), the codec max pinned to the
  filter's MAX_SINCE_DAYS (layerState cannot import it across package
  boundaries, so a test ties the two), Mapillary on when a link omits it;
- Esc that something else handled, or typed in a field, keeps the
  selection; a key problem outranks LOADING on the pill;
- the marker's terrain height (mesh, DEM, clamp then re-place);
- proxy: LRU memory cache and its half-budget rule, Retry-After capped,
  a request after a cancelled fetch starts a fresh one;
- date-line tile ranking with and without focus; the horizon cull idles
  while the camera stands still; the old zoom goes as soon as the new one
  lands; a rate limit holds every refresh inside its wait.

One real bug found and fixed: Esc inside a contenteditable cleared the
selected sequence (selection.js now ignores isContentEditable targets).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 16:54:06 +01:00

260 lines
7.7 KiB
JavaScript

import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import * as Cesium from 'cesium';
import { createSelection } from './selection.js';
/**
* The selection installed on a stand-in viewer. Animation frames queue until
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
* has a known timeline.
*/
function harness({ selected = false } = {}) {
const saved = {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
};
mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const frames = [];
globalThis.requestAnimationFrame = (task) => frames.push(task);
globalThis.document = new EventTarget();
// The document's keydown listeners, so a test can hand them any target.
const keyListeners = new Set();
const addListener = globalThis.document.addEventListener.bind(
globalThis.document,
);
const removeListener = globalThis.document.removeEventListener.bind(
globalThis.document,
);
globalThis.document.addEventListener = (type, listener, options) => {
if (type === 'keydown') keyListeners.add(listener);
addListener(type, listener, options);
};
globalThis.document.removeEventListener = (type, listener, options) => {
if (type === 'keydown') keyListeners.delete(listener);
removeListener(type, listener, options);
};
const sequences = { selected, cleared: 0 };
const picks = [];
/** What the pointer is over: a line this layer owns, or nothing. */
const scene = { under: null };
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
});
const camera = { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() };
const viewer = {
scene: {
canvas,
pick(position) {
picks.push(position);
return scene.under && { id: scene.under };
},
},
camera,
isDestroyed: () => false,
};
const state = {
viewer,
enabled: true,
clickHandler: null,
services: { picking: null, input: null },
};
const selection = createSelection({
state,
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected,
clearSequences() {
sequences.cleared++;
sequences.selected = false;
},
},
});
selection.install(viewer);
const onMove = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
return {
state,
sequences,
/** A keydown reaching the document from `target`; the event as handled. */
key(key, target, { defaultPrevented = false } = {}) {
const event = {
key,
target,
defaultPrevented,
preventDefault() {
this.defaultPrevented = true;
},
};
for (const listener of [...keyListeners]) listener(event);
return event;
},
scene,
canvas,
camera,
picks,
move: (x) => onMove({ endPosition: { x, y: 10 } }),
frame() {
for (const task of frames.splice(0)) task();
},
/** Let `ms` pass, running the frames that come due on the way. */
wait(ms) {
this.frame();
mock.timers.tick(ms);
this.frame();
},
buttons(type, buttons) {
const event = new Event(type);
Object.defineProperty(event, 'buttons', { value: buttons });
canvas.dispatchEvent(event);
},
restore() {
selection.uninstall();
mock.timers.reset();
Object.assign(globalThis, saved);
},
};
}
test('hover picks at most every 120 ms through a burst of moves, ending on the last one', () => {
const h = harness();
try {
// One second of a pointer moving at 60 Hz.
for (let i = 0; i < 60; i++) {
h.move(i);
h.wait(16);
}
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
h.wait(200);
assert.deepEqual(h.picks.at(-1), { x: 59, y: 10 }, 'the resting position');
const settled = h.picks.length;
h.wait(1000);
assert.equal(h.picks.length, settled, 'a still pointer costs nothing');
} finally {
h.restore();
}
});
test('hover does not pick while a mouse button is held', () => {
const h = harness();
try {
h.buttons('pointerdown', 1);
h.move(1);
h.wait(200);
assert.equal(h.picks.length, 0, 'a drag in progress');
h.buttons('pointerup', 0);
h.move(2);
h.wait(200);
assert.equal(h.picks.length, 1);
} finally {
h.restore();
}
});
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off (review P3)', () => {
const h = harness();
try {
// An orbiting or tracking camera raises moveStart and never moveEnd.
h.camera.moveStart.raiseEvent();
for (let i = 0; i < 60; i++) {
h.move(i);
h.wait(16);
}
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
} finally {
h.restore();
}
});
test('when the camera stops, the pointer is picked again so the cursor cannot stick (review P3)', () => {
const h = harness();
try {
h.scene.under = 'mly:seq:1';
h.move(5);
h.wait(200);
assert.equal(h.canvas.style.cursor, 'pointer', 'over a line');
// The camera moves the line out from under a still pointer.
h.camera.moveStart.raiseEvent();
h.scene.under = null;
h.camera.moveEnd.raiseEvent();
h.wait(200);
assert.equal(h.picks.length, 2, 'one pick when the camera rests');
assert.deepEqual(h.picks.at(-1), { x: 5, y: 10 });
assert.equal(h.canvas.style.cursor, '', 'the pointer cursor is gone');
} finally {
h.restore();
}
});
test('a hover frame queued before the layer went off does not pick', () => {
const h = harness();
try {
h.move(1);
h.state.enabled = false;
h.wait(200);
assert.equal(h.picks.length, 0);
} finally {
h.restore();
}
});
/**
* An element as Esc sees it: `closest` matches the tag names a selector list
* names, and `isContentEditable` is what the browser reports, inherited from
* an editing host.
*/
function element(tag, { isContentEditable = false } = {}) {
return {
tagName: tag.toUpperCase(),
isContentEditable,
closest(selector) {
const names = selector.split(',').map((part) => part.trim());
return names.includes(tag) ? this : null;
},
};
}
test('Esc on the globe clears the selected sequence', () => {
const h = harness({ selected: true });
try {
const event = h.key('Escape', element('canvas'));
assert.equal(h.sequences.cleared, 1);
assert.equal(event.defaultPrevented, true, 'and claims the key');
h.key('Escape', element('canvas'));
assert.equal(h.sequences.cleared, 1, 'nothing left to clear');
} finally {
h.restore();
}
});
test('an Esc something else already handled keeps the selection (M60)', () => {
const h = harness({ selected: true });
try {
h.key('Escape', element('canvas'), { defaultPrevented: true });
assert.equal(h.sequences.cleared, 0);
} finally {
h.restore();
}
});
test('Esc in a text field or rich-text editor keeps the selection (M61)', () => {
const h = harness({ selected: true });
try {
for (const tag of ['input', 'textarea', 'select']) {
const event = h.key('Escape', element(tag));
assert.equal(h.sequences.cleared, 0, `typing in a <${tag}>`);
assert.equal(event.defaultPrevented, false, `<${tag}> keeps its Esc`);
}
// A <div contenteditable> (or anything inside one) is a text field too.
h.key('Escape', element('div', { isContentEditable: true }));
assert.equal(h.sequences.cleared, 0, 'typing in a contenteditable');
} finally {
h.restore();
}
});