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]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 16:54:06 +01:00
1 parent 9dde7b4a62
commit 3af0f80cdb
15 files changed
+1198 -30

No files matched your search

+193 -13
View File
@@ -19,6 +19,22 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
*/
const mutations = { count: 0 };
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
/** The reflected properties a MutationObserver sees, as the record it gets. */
const REFLECTED_RECORDS = {
hidden: { type: 'attributes', attributeName: 'hidden' },
disabled: { type: 'attributes', attributeName: 'disabled' },
title: { type: 'attributes', attributeName: 'title' },
textContent: { type: 'childList' },
};
/** The fake MutationObservers currently observing (see fakeMutationObserver). */
const liveObservers = new Set();
/** Queue a mutation record for every observer whose options cover it. */
function recordMutation(target, { type, attributeName = null }) {
for (const observer of liveObservers)
observer.consider(target, type, attributeName);
}
class FakeNode {
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
@@ -42,18 +58,30 @@ class FakeNode {
set: (value) => {
mutations.count++;
this.props[key] = value;
if (REFLECTED_RECORDS[key])
recordMutation(this, REFLECTED_RECORDS[key]);
},
});
this.attributes = new Map();
const names = new Set(classes);
// Every DOMTokenList write sets the class attribute, changed or not.
const classChanged = () =>
recordMutation(this, { type: 'attributes', attributeName: 'class' });
this.classList = {
add: (name) => names.add(name),
remove: (name) => names.delete(name),
add: (name) => {
names.add(name);
classChanged();
},
remove: (name) => {
names.delete(name);
classChanged();
},
contains: (name) => names.has(name),
toggle: (name, force) => {
const on = force ?? !names.has(name);
if (on) names.add(name);
else names.delete(name);
classChanged();
return on;
},
};
@@ -73,32 +101,59 @@ class FakeNode {
names.clear();
for (const name of String(value).split(/\s+/).filter(Boolean))
names.add(name);
classChanged();
},
});
}
get childElementCount() {
return this.children.length;
}
get parentNode() {
return this.parent;
}
get nextSibling() {
if (!this.parent) return null;
const siblings = this.parent.children;
return siblings[siblings.indexOf(this) + 1] || null;
}
/**
* As layout reports it: null when this node or an ancestor is hidden (no
* box), or when it is not in the document; else its parent.
*/
get offsetParent() {
if (!this.isConnected) return null;
for (let node = this; node; node = node.parent)
if (node.hidden) return null;
return this.parent;
}
setAttribute(key, value) {
mutations.count++;
this.attributes.set(key, String(value));
recordMutation(this, { type: 'attributes', attributeName: key });
}
getAttribute(key) {
return this.attributes.get(key) ?? null;
}
removeAttribute(key) {
if (this.attributes.delete(key)) mutations.count++;
if (!this.attributes.delete(key)) return;
mutations.count++;
recordMutation(this, { type: 'attributes', attributeName: key });
}
appendChild(child) {
return this.insertBefore(child, null);
}
/** Like the DOM's: a reference that is not a child throws NotFoundError. */
insertBefore(child, reference) {
if (reference != null && reference.parent !== this)
throw new DOMException('not a child of this node', 'NotFoundError');
if (reference === child) return child;
child.remove();
const index = reference
? this.children.indexOf(reference)
: this.children.length;
this.children.splice(index < 0 ? this.children.length : index, 0, child);
this.children.splice(index, 0, child);
child.parent = this;
recordMutation(this, { type: 'childList' });
return child;
}
append(...nodes) {
@@ -109,9 +164,11 @@ class FakeNode {
this.append(...nodes);
}
remove() {
if (!this.parent) return;
this.parent.children.splice(this.parent.children.indexOf(this), 1);
const parent = this.parent;
if (!parent) return;
parent.children.splice(parent.children.indexOf(this), 1);
this.parent = null;
recordMutation(parent, { type: 'childList' });
}
contains(node) {
for (let current = node; current; current = current.parent)
@@ -272,7 +329,7 @@ function panelDom() {
globe.setAttribute('tabindex', '0');
// A field in another panel (the location search).
const search = add(document.body, 'input', { id: 'location-search' });
return { document, root, globe, search };
return { document, root, globe, search, main, wrap, settings };
}
/** Run `fn` with the fake document and an immediate animation frame. */
@@ -602,6 +659,11 @@ test('Esc shrinks the expanded viewer after focus left it, and the map keeps its
test('Tab from outside the expanded viewer brings focus back into it', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
// A tool hidden in this state (no box, so not a tab stop) ends the bar.
const hiddenTool = dom.wrap.appendChild(
new FakeNode(dom.document, 'button', { id: 'sl-hidden-tool' }),
);
hiddenTool.hidden = true;
controls.setViewerExpanded(true);
dom.globe.focus();
keydown(dom.globe, 'Tab');
@@ -614,7 +676,13 @@ test('Tab from outside the expanded viewer brings focus back into it', () =>
assert.equal(
dom.document.activeElement,
wrap.querySelector('#sl-viewer-close'),
'Shift+Tab wraps to the last control',
'Shift+Tab wraps to the last control, past the hidden one',
);
keydown(dom.document.activeElement, 'Tab');
assert.equal(
dom.document.activeElement,
wrap.querySelector('#sl-viewer-expand'),
'Tab from the last shown control wraps to the first',
);
controls.destroy();
}));
@@ -742,7 +810,12 @@ test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Es
controls.destroy();
}));
/** A MutationObserver stand-in the test fires by hand. */
/**
* A MutationObserver stand-in. Like the real one it queues only the records
* its `observe` options cover (childList, attributes / attributeFilter,
* subtree) and delivers them in a microtask, only when there are some;
* `fire()` delivers what is queued at once.
*/
function fakeMutationObserver() {
const observers = [];
class FakeMutationObserver {
@@ -750,19 +823,46 @@ function fakeMutationObserver() {
this.callback = callback;
this.target = null;
this.options = null;
this.records = [];
observers.push(this);
}
observe(target, options) {
observe(target, options = {}) {
this.target = target;
this.options = options;
this.options = {
...options,
// Per the spec, an attribute filter implies `attributes`.
attributes: options.attributes ?? Boolean(options.attributeFilter),
};
liveObservers.add(this);
}
disconnect() {
this.target = null;
this.records = [];
liveObservers.delete(this);
}
consider(node, type, attributeName) {
const { childList, attributes, attributeFilter, subtree } = this.options;
const inScope =
node === this.target ||
(subtree === true && this.target.contains(node));
if (!inScope) return;
if (type === 'childList' && childList !== true) return;
if (type === 'attributes') {
if (attributes !== true) return;
if (attributeFilter && !attributeFilter.includes(attributeName)) return;
}
this.records.push({ type, target: node, attributeName });
if (this.records.length === 1) queueMicrotask(() => this.deliver());
}
deliver() {
if (!this.target || !this.records.length) return;
const records = this.records;
this.records = [];
this.callback(records, this);
}
}
const fire = () => {
for (const observer of observers)
if (observer.target) observer.callback([], observer);
for (const observer of observers) observer.deliver();
};
const watching = () => observers.some((observer) => observer.target);
return { FakeMutationObserver, fire, watching };
@@ -776,6 +876,7 @@ async function withObserver(fn) {
try {
return await fn(fake);
} finally {
liveObservers.clear();
if (saved === undefined) delete globalThis.MutationObserver;
else globalThis.MutationObserver = saved;
}
@@ -970,3 +1071,82 @@ test('under KEY REQUIRED the provider chip stays live and explains the key inste
assert.match(calls.toasts[0], /^Mapillary: Needs /);
controls.destroy();
}));
/* ── The expanded viewer goes back where it came from ──────────────────── */
/** A node by name, so a failed comparison prints a word, not the DOM. */
const nameOf = (node) =>
node ? node.id || node.className || node.tagName.toLowerCase() : null;
/** Where the viewer sits: its parent and the sibling it sits before. */
const placeOf = (wrap) => [nameOf(wrap.parentNode), nameOf(wrap.nextSibling)];
const PANEL_PLACE = ['sl-main', 'sl-settings'];
test('shrinking returns the viewer into the panel at its own place', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
controls.setViewerExpanded(true);
assert.equal(nameOf(dom.wrap.parentNode), 'body', 'lifted out');
controls.setViewerExpanded(false);
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE, 'before the settings');
assert.equal(dom.main.children.indexOf(dom.wrap), 0);
assert.equal(dom.root.contains(dom.wrap), true);
// A second round trip lands in the same place.
controls.setViewerExpanded(true);
controls.setViewerExpanded(false);
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
controls.destroy();
}));
test('an image closed while expanded returns the viewer into the panel', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
layer.publish(uiState({ open: false }));
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
assert.equal(dom.wrap.hidden, true, 'and hidden there');
controls.destroy();
}));
test('destroying the panel while expanded returns the viewer into it', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
controls.destroy();
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
assert.equal(
dom.document.body.children.includes(dom.wrap),
false,
'nothing left on <body>',
);
}));
test('a <body> child added while the viewer is expanded goes inert with the rest', () =>
withObserver((observer) =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
// A toast, a menu or another panel's popup, appended later.
const toast = dom.document.body.appendChild(
new FakeNode(dom.document, 'div', { id: 'toast' }),
);
assert.equal(toast.inert, undefined, 'not before the observer runs');
await settle(); // records are delivered in a microtask
assert.equal(toast.inert, true);
// Changes inside the app are not the modal's business: nothing runs.
let syncs = 0;
const sync = controls._syncModal.bind(controls);
controls._syncModal = () => {
syncs++;
return sync();
};
dom.root.querySelector('#sl-status').setAttribute('class', 'x');
dom.root.appendChild(new FakeNode(dom.document, 'div'));
await settle();
assert.equal(syncs, 0);
controls.destroy();
assert.equal(toast.inert, false, 'destroy releases it too');
assert.equal(observer.watching(), false);
}),
));