Files
gods-eye-view/src/ui/recentImagery.test.mjs
T
Sameh Khamis 3a2acf9b0f test: compare DOM nodes by identity, never through assert.equal
A failed assert.equal or deepEqual prints both operands, and a happy-dom
node reaches the whole page and window: printing one exhausted memory and
took the machine down. assertSameNode() compares identity and fails with
a short description of each node; every node comparison in the happy-dom
tests uses it.
2026-10-30 20:38:29 -07:00

1087 lines
36 KiB
JavaScript
Raw 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.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { readFileSync } from 'node:fs';
import { createRecentImageryPanel } from './recentImagery.js';
import { createRecentImageryLayer } from '../layers/recentImagery/index.js';
import {
BOX,
fakeCatalog,
fakeController,
fakeRenderer,
fakeThumbnails,
manualTimers,
response,
settle,
} from '../layers/recentImagery/testDoubles.mjs';
const S18 = 'S30:2026-09-18';
const L16 = 'L30:2026-09-16';
const V21 = 'VIIRS:2026-09-21';
// ---- the test document ------------------------------------------------------
// The readout renders its cards with Vue, so it runs in a real document. These
// test-only conveniences count focus, blur and listeners per element.
const elementProto = globalThis.HTMLElement.prototype;
const { focus: nativeFocus, blur: nativeBlur } = elementProto;
elementProto.focus = function (options) {
this.focused = (this.focused || 0) + 1;
this.focusOptions = options;
return nativeFocus.call(this, options);
};
elementProto.blur = function () {
this.blurred = (this.blurred || 0) + 1;
return nativeBlur.call(this);
};
const listeners = new WeakMap();
const { addEventListener: nativeAdd, removeEventListener: nativeRemove } =
globalThis.Element.prototype;
globalThis.Element.prototype.addEventListener = function (
type,
handler,
...rest
) {
if (!listeners.has(this)) listeners.set(this, new Map());
const byType = listeners.get(this);
if (!byType.has(type)) byType.set(type, new Set());
byType.get(type).add(handler);
return nativeAdd.call(this, type, handler, ...rest);
};
globalThis.Element.prototype.removeEventListener = function (
type,
handler,
...rest
) {
listeners.get(this)?.get(type)?.delete(handler);
return nativeRemove.call(this, type, handler, ...rest);
};
Object.assign(globalThis.Element.prototype, {
listenerCount() {
return [...(listeners.get(this)?.values() ?? [])].reduce(
(n, set) => n + set.size,
0,
);
},
/** The first descendant matching `predicate`, in document order. */
find(predicate) {
return [...this.querySelectorAll('*')].find(predicate) ?? null;
},
findAll(predicate) {
return [...this.querySelectorAll('*')].filter(predicate);
},
/** Bubble an event of `type` carrying `init`'s fields; returns the event. */
dispatch(type, init = {}) {
const event = new Event(type, { bubbles: true, cancelable: true });
for (const [key, value] of Object.entries(init))
Object.defineProperty(event, key, { value });
this.dispatchEvent(event);
return event;
},
});
/** The static rail panel skeleton from views/ContextView.vue. */
function fakeDocument() {
document.body.textContent = '';
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
value: 1200,
});
const panel = document.createElement('div');
panel.id = 'recent-imagery-panel';
panel.className = 'panel-collapsible collapsed';
panel.hidden = true;
const count = document.createElement('span');
count.id = 'recent-imagery-panel-count';
const collapse = document.createElement('button');
collapse.dataset.collapseTarget = 'recent-imagery-panel';
collapse.clicked = 0;
collapse.click = () => {
collapse.clicked += 1;
panel.classList.toggle('collapsed');
};
const body = document.createElement('div');
body.id = 'recent-imagery-panel-body';
panel.append(count, collapse, body);
document.body.appendChild(panel);
return { document, panel, count, collapse, body };
}
// ---- the real layer, with fakes for everything it injects ------------------
function fixture({ fetchImpl, controller = fakeController() } = {}) {
const dom = fakeDocument();
const renderer = fakeRenderer();
const thumbnails = fakeThumbnails();
const catalog = fakeCatalog();
const timers = manualTimers();
const layer = createRecentImageryLayer({
catalog,
renderer,
thumbnails,
host: () => ({ collection: {}, kind: 'globe' }),
now: () => new Date('2026-09-21T15:00:00Z'),
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
layer.setSources({ viirs: true });
const viewer = {
camera: {},
scene: { canvas: { clientWidth: 1000 }, requestRender() {} },
};
layer.init(viewer);
layer.attachMapStackController(controller);
let active = false;
const tool = {
calls: [],
start() {
active = true;
this.calls.push('start');
layer.setToolActive(true);
},
cancel(reason) {
active = false;
this.calls.push(`cancel:${reason}`);
layer.setToolActive(false);
},
isActive: () => active,
};
const splits = [];
const fetches = [];
const urls = { created: [], revoked: [] };
const readout = createRecentImageryPanel({
container: dom.body,
layer,
viewer,
tool,
createSplit: (options) => {
const split = {
options,
value: options.initialValue,
values: [],
destroyed: false,
setValue(next) {
this.value = next;
this.values.push(next);
},
destroy() {
this.destroyed = true;
},
};
splits.push(split);
return split;
},
fetchImpl:
fetchImpl ||
(async (url) => {
fetches.push(url);
return response();
}),
createObjectUrl: () => {
urls.created.push('blob:export');
return 'blob:export';
},
revokeObjectUrl: (url) => urls.revoked.push(url),
});
const root = readout.root;
const byId = (id) => root.find((node) => node.id === id);
const byAction = (id) => root.find((node) => node.dataset.actionId === id);
const cards = () =>
root.findAll((node) => node.classList.contains('ri-card'));
const card = (key) => cards().find((node) => node.dataset.key === key);
const chip = (key, slot) =>
card(key).find((node) => node.dataset.slot === slot);
const part = (node, name) =>
node.find((child) => child.classList.contains(name));
const strip = byId('ri-strip');
const key = (name, extra = {}) =>
strip.dispatch('keydown', { key: name, ...extra });
/** Enable, select BOX and land the catalog. */
const ready = async () => {
layer.enable();
layer.setBox(BOX);
catalog.resolveLast();
await settle();
};
const settleLease = async () => {
controller.lease?.settle();
await settle();
};
return {
...dom,
layer,
viewer,
renderer,
thumbnails,
catalog,
controller,
timers,
tool,
splits,
fetches,
urls,
readout,
root,
byId,
byAction,
cards,
card,
chip,
part,
strip,
key,
ready,
settleLease,
snap: () => layer.getSnapshot(),
};
}
const rowText = (f, slotId) =>
[
part(f, `ri-slot-${slotId}`, 'ri-slot-tag').textContent,
part(f, `ri-slot-${slotId}`, 'ri-slot-value').textContent,
].join(' | ');
function part(f, id, name) {
return f.byId(id).find((node) => node.classList.contains(name));
}
// ---- tests ---------------------------------------------------------------------
test('the readout mounts in the rail body, hides while the layer is off and opens the panel once on first appearance', async () => {
const f = fixture();
assertSameNode(f.root.parentNode, f.body);
assert.equal(f.panel.hidden, true);
assert.equal(f.count.textContent, '');
await f.ready();
const panel = f.document.getElementById('recent-imagery-panel');
assert.equal(panel.hidden, false);
assert.equal(f.collapse.clicked, 1, 'first appearance expands');
assert.equal(panel.classList.contains('collapsed'), false);
assert.equal(f.count.textContent, '4 DAYS');
f.collapse.click();
f.layer.disable();
f.layer.enable();
assert.equal(f.collapse.clicked, 2, 'never again: the user collapsed it');
f.layer.disable();
assert.equal(panel.hidden, true);
assert.equal(f.count.textContent, '');
// A stored or shared collapse choice wins over the first appearance.
const g = fixture();
g.document.getElementById(
'recent-imagery-panel',
).dataset.collapsedPreference = 'stored';
g.layer.enable();
assert.equal(g.collapse.clicked, 0);
});
test('the body is a fixed stack of seven blocks in a fixed order, each with a fixed height in the stylesheet', () => {
const f = fixture();
assert.deepEqual(
[...f.root.children].map((node) => node.id || node.className),
[
'ri-actions',
'ri-notice',
'ri-strip',
'ri-hint',
'ri-selection',
'ri-controls',
'ri-details',
],
);
const css = readFileSync(
new URL('./styles/recent-imagery.css', import.meta.url),
'utf8',
);
const rule = (selector) => {
const start = css.indexOf(`${selector} {`);
assert.ok(start >= 0, selector);
return css.slice(start, css.indexOf('}', start));
};
assert.match(rule('.recent-imagery-readout .ri-actions-row'), /height: 22px/);
assert.match(rule('.recent-imagery-readout .ri-line'), /height: 14px/);
assert.match(rule('.recent-imagery-readout .ri-strip'), /height: 120px/);
assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/);
assert.match(
rule('.recent-imagery-readout .ri-selection'),
/grid-template-rows: 26px 20px 20px/,
);
assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/);
assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/);
assert.match(
rule('.recent-imagery-readout .is-reserved'),
/visibility: hidden/,
);
});
/*
* A DOM-structure guard, not a layout-shift check: the fake DOM has no
* layout, so this pins what the layout rests on (every control keeps its
* place in the tree, nothing is hidden, and every block that must not change
* height keeps the class the stylesheet fixes it by). The live gate
* `scripts/qa-recent-imagery.mjs` measures the real rectangles.
*/
test('no control changes place in the tree, no block is hidden and every fixed-height block keeps its height class across every state (DOM-structure guard)', async () => {
const f = fixture();
const cssRule = (file, selector) => {
const css = readFileSync(new URL(file, import.meta.url), 'utf8');
const start = css.indexOf(`${selector} {`);
assert.ok(start >= 0, `${file} has ${selector}`);
return css.slice(start, css.indexOf('}', start));
};
const readout = (selector) =>
cssRule(
'./styles/recent-imagery.css',
`.recent-imagery-readout ${selector}`,
);
/**
* Every block that must keep its height: the node carrying the class, that
* class, and the rule it gets its height from. The mode row is the first
* 26 px track of the selection grid. The details header has no height of
* its own: it is the last block, so it moves nothing below it, and its
* title is held to one line.
*/
const fixedHeightBlocks = () => {
const mode = f.byId('ri-mode');
const header = f
.byId('ri-details')
.find((node) => node.classList.contains('rail-card-header'));
return {
'ri-actions': [
f.byId('ri-actions'),
'ri-actions-row',
readout('.ri-actions-row'),
/height: 22px/,
],
'ri-notice': [
f.byId('ri-notice'),
'ri-line',
readout('.ri-line'),
/height: 14px/,
],
'ri-zoom-in': [
f.byId('ri-zoom-in')?.parentNode === f.byId('ri-notice')
? f.byId('ri-zoom-in')
: null,
'ri-zoom-in',
readout('.ri-zoom-in'),
/height: 14px/,
],
'ri-strip': [
f.byId('ri-strip'),
'ri-strip',
readout('.ri-strip'),
/height: 120px/,
],
'ri-hint': [
f.byId('ri-hint'),
'ri-line',
readout('.ri-line'),
/height: 14px/,
],
'ri-mode': [
mode.parentNode.children[0] === mode ? mode.parentNode : null,
'ri-selection',
readout('.ri-selection'),
/grid-template-rows: 26px 20px 20px/,
],
'ri-slot-a': [
f.byId('ri-slot-a'),
'ri-slot',
readout('.ri-slot'),
/height: 20px/,
],
'ri-slot-b': [
f.byId('ri-slot-b'),
'ri-slot',
readout('.ri-slot'),
/height: 20px/,
],
'ri-controls': [
f.byId('ri-controls'),
'ri-controls',
readout('.ri-controls'),
/height: 22px/,
],
'ri-swap': [
f.byId('ri-swap')?.parentNode === f.byId('ri-controls')
? f.byId('ri-swap')
: null,
'data-toggle-chip',
readout('.data-toggle-chip'),
/height: 22px/,
],
'details header': [
[...f.root.children].at(-1) === f.byId('ri-details')
? header?.find((node) => node.classList.contains('rail-card-title'))
: null,
'rail-card-nowrap',
cssRule('./styles/weather.css', '.rail-card-nowrap'),
/white-space: nowrap/,
],
};
};
const assertFixedHeights = (label) => {
for (const [name, [node, className, rule, fixed]] of Object.entries(
fixedHeightBlocks(),
)) {
assert.ok(node, `${name} is in place (${label})`);
assert.ok(
node.classList.contains(className),
`${name} carries .${className} (${label})`,
);
assert.match(rule, fixed, `.${className} fixes ${name}`);
}
};
const controls = () => ({
'select-box': f.byAction('select-box'),
'use-view': f.byAction('use-view'),
clear: f.byAction('clear'),
notice: f.byId('ri-notice'),
'zoom-in': f.byId('ri-zoom-in'),
strip: f.strip,
hint: f.byId('ri-hint'),
'mode-image': f.root.find(
(node) =>
node.classList.contains('ri-mode-btn') && node.dataset.mode === 'image',
),
'mode-basemap': f.root.find(
(node) =>
node.classList.contains('ri-mode-btn') &&
node.dataset.mode === 'basemap',
),
'mode-ab': f.root.find(
(node) =>
node.classList.contains('ri-mode-btn') && node.dataset.mode === 'ab',
),
'row-a': f.byId('ri-slot-a'),
'row-b': f.byId('ri-slot-b'),
'unpin-a': f.byId('ri-unpin-a'),
'unpin-b': f.byId('ri-unpin-b'),
opacity: f.byId('ri-opacity'),
swap: f.byId('ri-swap'),
'export-a': f.byAction('export-a'),
'export-b': f.byAction('export-b'),
details: f
.byId('ri-details')
.find((node) => node.classList.contains('rail-card-header')),
});
/** Tag and index from the root down, plus every hidden flag on the way. */
const layout = () => {
const out = {};
for (const [name, node] of Object.entries(controls())) {
assert.ok(node, name);
const path = [];
for (let at = node; at && at !== f.root; at = at.parentNode) {
assert.equal(at.hidden, false, `${name} is never hidden`);
path.unshift(`${at.tagName}${[...at.parentNode.children].indexOf(at)}`);
}
out[name] = path.join('/');
}
return out;
};
const baseline = layout();
assertFixedHeights('built');
const states = [];
const record = (label) => {
assert.deepEqual(layout(), baseline, label);
assertFixedHeights(label);
states.push(label);
};
f.layer.enable();
record('enabled, no box');
f.layer.setBox(BOX);
record('searching');
f.catalog.resolveLast();
await settle();
await f.settleLease();
record('preview');
f.chip(S18, 'a').click();
record('pinned');
f.byId('ri-opacity').value = '40';
f.byId('ri-opacity').dispatch('input');
record('opacity');
f.root
.find(
(node) =>
node.classList.contains('ri-mode-btn') &&
node.dataset.mode === 'basemap',
)
.click();
record('vs basemap');
f.root
.find(
(node) =>
node.classList.contains('ri-mode-btn') && node.dataset.mode === 'ab',
)
.click();
f.chip(L16, 'b').click();
record('A / B');
f.byId('ri-swap').click();
record('swapped');
f.byId('ri-unpin-b').click();
record('B unpinned');
f.byId('ri-unpin-a').click();
record('A unpinned');
f.layer.setBox({ west: 0, south: 0, east: 20, north: 20 });
record('refused');
f.byAction('clear').click();
record('cleared');
assert.equal(states.length, 12);
});
test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the chips follow the mode without moving', async () => {
const f = fixture();
await f.ready();
f.thumbnails.setStatus(S18, 'present');
f.layer.setVisibleRange(0, 3);
f.layer.focus(1);
const s18 = f.card(S18);
assert.equal(f.part(s18, 'ri-card-date').textContent, 'Sep 18');
assert.equal(
f.part(s18, 'ri-card-sensor').textContent,
'Sentinel-2 · 30\u00a0m',
);
assert.equal(f.part(s18, 'ri-card-cloud').textContent, '12% cloud');
assert.equal(f.part(s18, 'ri-card-start').hidden, false);
assert.match(f.part(s18, 'ri-card-start').title, /Newest low-cloud day/);
assert.equal(f.part(s18, 'ri-card-flag').textContent, 'PREVIEW');
assert.equal(
f.part(f.card(V21), 'ri-card-cloud').textContent,
'cloud unknown',
);
assert.equal(f.part(f.card(V21), 'ri-thumb-text').textContent, 'Checking');
assert.equal(
f.part(f.card(V21), 'ri-card-sensor').textContent,
'Daily overview · 250\u00a0m',
);
assert.equal(
f.part(f.card(L16), 'ri-card-sensor').textContent,
'Landsat 8/9 · 30\u00a0m',
);
// IMAGE: one SHOW chip; the B chip keeps its box but is invisible.
const [a, b] = [f.chip(S18, 'a'), f.chip(S18, 'b')];
assert.deepEqual(
[
a.textContent,
a.getAttribute('aria-pressed'),
b.classList.contains('is-reserved'),
b.disabled,
],
['SHOW', 'false', true, true],
);
assert.equal(a.getAttribute('aria-label'), 'Show Sep 18');
a.click();
assert.equal(a.getAttribute('aria-pressed'), 'true');
assert.equal(a.classList.contains('active'), true);
assert.equal(
f.part(s18, 'ri-card-flag').hidden,
true,
'pinned, not previewed',
);
assert.match(s18.getAttribute('aria-label'), /· shown$/);
// A / B: the same chip slots, relabelled.
f.root
.find(
(node) =>
node.classList.contains('ri-mode-btn') && node.dataset.mode === 'ab',
)
.click();
assert.deepEqual(
[
a.textContent,
b.textContent,
b.classList.contains('is-reserved'),
b.disabled,
],
['A', 'B', false, false],
);
assert.equal([...a.parentNode.children].indexOf(a), 0);
assert.equal(b.getAttribute('aria-label'), 'Pin Sep 18 as B');
// A day is one slot only: B moves it.
b.click();
assert.deepEqual(
[a.getAttribute('aria-pressed'), b.getAttribute('aria-pressed')],
['false', 'true'],
);
// An empty day cannot be pinned.
f.thumbnails.probe(V21, 'empty');
f.layer.setShowUnavailable(true);
assert.equal(f.chip(V21, 'a').disabled, true);
assert.equal(f.part(f.card(V21), 'ri-card-cloud').textContent, 'no imagery');
});
test('the selection bar reads the mode, both rows and a preview, with × reserved when empty', async () => {
const f = fixture();
await f.ready();
const modes = f.root.findAll((node) =>
node.classList.contains('ri-mode-btn'),
);
assert.deepEqual(
modes.map((node) => [
node.textContent,
node.getAttribute('role'),
node.getAttribute('aria-checked'),
]),
[
['IMAGE', 'radio', 'true'],
['VS BASEMAP', 'radio', 'false'],
['A / B', 'radio', 'false'],
],
);
assert.equal(
rowText(f, 'a'),
'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m · preview',
);
assert.equal(f.byId('ri-slot-a').dataset.state, 'preview');
assert.equal(rowText(f, 'b'), 'VS | Basemap');
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), false, 'dimmed');
const unpinA = f.byId('ri-unpin-a');
assert.deepEqual(
[
unpinA.disabled,
unpinA.classList.contains('is-reserved'),
unpinA.textContent,
],
[true, true, '×'],
);
f.key('s');
assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(f.byId('ri-slot-a').dataset.state, 'pinned');
assert.equal(unpinA.disabled, false);
assert.equal(unpinA.getAttribute('aria-label'), 'Unpin the image');
modes[1].click();
assert.equal(f.snap().mode, 'basemap');
assert.equal(modes[1].getAttribute('aria-checked'), 'true');
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), true, 'lit');
modes[2].click();
assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(rowText(f, 'b'), 'B | Not set');
assert.equal(f.byId('ri-slot-b').dataset.state, 'empty');
assert.equal(f.byId('ri-unpin-b').classList.contains('is-reserved'), true);
f.chip(L16, 'b').click();
assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30\u00a0m');
f.byId('ri-unpin-b').click();
assert.equal(f.snap().pins.b.key, null);
unpinA.click();
assert.equal(f.snap().pins.a.key, null);
assert.equal(f.renderer.ownedCount(), 0, 'unpinning drops the layer');
// Mode keys move the radio selection.
f.byId('ri-mode').dispatch('keydown', { key: 'ArrowRight' });
assert.equal(f.snap().mode, 'image');
});
test('strip keys: arrows preview after the debounce, S / A / B pin, Enter previews, repeats are ignored', async () => {
const f = fixture();
await f.ready();
f.key('ArrowRight');
assert.equal(f.snap().focus.key, L16);
f.timers.flush();
assert.equal(f.snap().preview.key, L16);
f.key('Home');
assert.equal(f.snap().focus.key, V21);
f.key('End');
assert.equal(f.snap().focus.index, 3);
f.key('ArrowLeft');
f.timers.flush();
f.key('a', { repeat: true });
assert.equal(f.snap().pins.a.key, null, 'key repeat never pins');
f.key('b');
assert.equal(f.snap().pins.b.key, null, 'no B outside A / B');
f.key('A');
assert.equal(f.snap().pins.a.key, L16);
f.key('a');
assert.equal(f.snap().pins.a.key, null, 'A again unpins');
f.layer.setMode('ab');
f.key('a');
f.key('ArrowLeft');
f.key('b');
assert.deepEqual([f.snap().pins.a.key, f.snap().pins.b.key], [L16, S18]);
f.key('a', { metaKey: true });
assert.equal(f.snap().pins.a.key, L16, 'modified keys are not ours');
f.layer.setAssignment('a', null);
f.layer.setAssignment('b', null);
f.key('Enter');
assert.equal(f.snap().preview.key, S18, 'Enter previews the focused day');
});
test('clicks: a card previews and focuses the strip, a chip pins, SELECT BOX toggles the tool, USE VIEW and CLEAR drive the layer', async () => {
const f = fixture();
await f.ready();
f.card(L16).click();
assert.equal(f.snap().preview.key, L16);
assert.equal(f.strip.focused, 1);
assert.deepEqual(f.strip.focusOptions, { preventScroll: true });
f.chip(S18, 'a').click();
assert.equal(f.snap().pins.a.key, S18);
const selectBox = f.byAction('select-box');
selectBox.click();
assert.deepEqual(f.tool.calls, ['start']);
assert.equal(selectBox.getAttribute('aria-pressed'), 'true');
selectBox.click();
assert.deepEqual(f.tool.calls, ['start', 'cancel:toggle']);
assert.equal(selectBox.getAttribute('aria-pressed'), 'false');
f.byAction('use-view').click();
assert.equal(
f.snap().boxError,
'Point the camera at the ground to use the view',
);
assert.equal(f.byAction('clear').disabled, false);
f.byAction('clear').click();
assert.equal(f.snap().box, null);
assert.equal(f.byAction('clear').disabled, true);
});
test('Escape clears the preview first, then cancels the box tool, then blurs the strip', async () => {
const f = fixture();
await f.ready();
f.tool.start();
f.key('Escape');
assert.equal(f.snap().preview.key, null);
assert.equal(f.tool.isActive(), true);
f.key('Escape');
assert.equal(f.tool.isActive(), false);
f.key('Escape');
assert.equal(f.strip.blurred, 1);
// From elsewhere in the panel Escape only acts when there is something to undo.
f.card(L16).click();
const escape = f.byId('ri-opacity').dispatch('keydown', { key: 'Escape' });
assert.equal(escape.defaultPrevented, true);
assert.equal(f.snap().preview.key, null);
const idle = f.byId('ri-opacity').dispatch('keydown', { key: 'Escape' });
assert.equal(idle.defaultPrevented, false);
});
test('the notice line shows the refusal, then errors, then CLEAR, then the Esri note; the hint names the next step and that SWAP trades the sides', async () => {
const controller = fakeController();
let active = 'photoreal';
controller.getActiveId = () => active;
const f = fixture({ controller });
const notice = f.byId('ri-notice');
const noticeText = f.byId('ri-notice-text');
const hint = f.byId('ri-hint');
f.layer.enable();
assert.equal(noticeText.textContent, '');
assert.equal(hint.textContent, 'Select a box or use the view');
f.layer.setBox(BOX);
assert.equal(hint.textContent, 'Searching the last 30 days');
f.catalog.resolveLast();
await settle();
assert.equal(hint.textContent, '← → preview · S shows the focused day');
active = 'esri-imagery';
controller.lease.settle({ status: 'ready', activeId: active });
await settle();
assert.equal(
noticeText.textContent,
'Imagery on Esri · Google 3D returns when cleared',
);
assert.equal(notice.classList.contains('info'), true);
f.layer.setBox({ west: 0, south: 0, east: 20, north: 20 });
assert.equal(
noticeText.textContent,
'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
);
assert.equal(notice.classList.contains('warn'), true);
assert.equal(hint.textContent, 'Zoom in or draw a smaller box');
f.layer.setBox(BOX);
f.chip(S18, 'a').click();
assert.equal(hint.textContent, 'S on another day replaces it · × unpins');
f.layer.setMode('basemap');
assert.equal(hint.textContent, 'Drag the divider · SWAP trades sides');
f.layer.setMode('ab');
assert.equal(hint.textContent, '← → preview the other side · A or B pins it');
f.chip(L16, 'b').click();
assert.equal(hint.textContent, 'Drag the divider · SWAP trades sides');
f.layer.setAssignment('b', null);
f.layer.setAssignment('a', null);
assert.equal(hint.textContent, '← → preview · A or B pins the focused day');
f.byAction('clear').click();
assert.equal(noticeText.textContent, 'Box and images cleared');
assert.equal(notice.classList.contains('info'), true);
});
test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice line; it asks the layer to fit', async () => {
const f = fixture();
const flights = [];
f.viewer.scene.canvas.clientHeight = 500;
f.viewer.scene.ellipsoid = {
cartographicToCartesian: (c) => ({ ...c }),
};
Object.assign(f.viewer.camera, {
frustum: { fovy: 2 * Math.atan(Math.tan(Math.PI / 6) / 2) },
flyTo: (options) => flights.push(options),
});
const notice = f.byId('ri-notice');
const zoom = f.byId('ri-zoom-in');
assert.equal(zoom.tagName, 'BUTTON');
assert.equal(zoom.textContent, 'ZOOM IN');
assert.equal([...notice.children].at(-1), zoom, 'the right end of the line');
const reserved = () => [
zoom.classList.contains('is-reserved'),
zoom.disabled,
zoom.getAttribute('aria-hidden'),
notice.classList.contains('has-action'),
];
await f.ready();
assert.deepEqual(reserved(), [true, true, 'true', false]);
zoom.click();
assert.equal(flights.length, 0);
f.layer.setBox({ west: 0, south: 0, east: 20, north: 20 });
assert.deepEqual(reserved(), [false, false, 'false', true]);
assert.equal(
f.byId('ri-notice-text').textContent,
'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
);
assert.equal(f.byId('ri-hint').textContent, 'Zoom in or draw a smaller box');
assert.equal(zoom.title, 'Fly in until the view fits the 1,000 km limit');
zoom.click();
assert.equal(flights.length, 1);
assert.ok(
Math.abs(
flights[0].destination.height - 400_000 / (2 * Math.tan(Math.PI / 6)),
) < 1e-6,
);
// Another refusal has nothing to fit; a box that succeeds hides it again.
f.layer.reportBoxRefusal('Select one side of the dateline');
assert.deepEqual(reserved(), [true, true, 'true', false]);
f.layer.setBox({ west: 0, south: 0, east: 20, north: 20 });
f.layer.setBox(BOX);
assert.deepEqual(reserved(), [true, true, 'true', false]);
});
test('the divider exists only while a swipe is live, labelled A / B or IMAGE / BASEMAP; SWAP trades its sides and Space does nothing', async () => {
const f = fixture();
const swap = f.byId('ri-swap');
const swapState = () => [
swap.disabled,
swap.classList.contains('is-reserved'),
swap.getAttribute('aria-hidden'),
swap.getAttribute('aria-pressed'),
];
const labels = () => {
const { beforeLabel, afterLabel } = f.splits.at(-1).options;
return [beforeLabel, afterLabel];
};
const directions = () => {
const owned = f.renderer.getOwned();
return [owned.a?.splitDirection, owned.b?.splitDirection];
};
assert.equal(swap.tagName, 'BUTTON');
assert.equal(swap.textContent, 'SWAP');
assertSameNode(swap.parentNode, f.byId('ri-controls'));
await f.ready();
await f.settleLease();
assert.equal(f.splits.length, 0, 'one image, no divider');
assert.deepEqual(swapState(), [true, true, 'true', 'false']);
swap.click();
assert.equal(f.snap().swapped, false, 'no swipe, nothing to swap');
f.layer.setMode('basemap');
assert.equal(f.splits.length, 1);
assert.deepEqual(labels(), ['IMAGE', 'BASEMAP']);
assert.equal(f.splits[0].options.id, 'recent-imagery-split-line');
assert.deepEqual(swapState(), [false, false, 'false', 'false']);
assert.deepEqual(directions(), ['left', undefined]);
// VS BASEMAP: SWAP puts the image right of the basemap, and back.
swap.click();
assert.equal(f.snap().swapped, true);
assert.deepEqual(labels(), ['BASEMAP', 'IMAGE']);
assert.deepEqual(directions(), ['right', undefined]);
assert.deepEqual(swapState(), [false, false, 'false', 'true']);
swap.click();
assert.deepEqual(labels(), ['IMAGE', 'BASEMAP']);
assert.deepEqual(directions(), ['left', undefined]);
f.layer.setMode('ab');
f.layer.setAssignment('a', S18);
f.layer.setAssignment('b', L16);
assert.equal(f.splits[0].destroyed, true);
const split = f.splits.at(-1);
assert.deepEqual(
[split.options.beforeLabel, split.options.afterLabel],
['A', 'B'],
);
assert.equal(
split.options.afterTitle,
'Landsat 8/9 · 30\u00a0m · 2026-09-16',
);
split.options.onChange(0.3);
assert.equal(f.snap().split, 0.3);
assert.deepEqual(directions(), ['left', 'right']);
// Space is push-to-talk: the panel never takes it.
for (const target of [f.strip, f.byId('ri-opacity'), f.root]) {
const space = target.dispatch('keydown', { key: ' ' });
assert.equal(space.defaultPrevented, false);
}
assert.equal(f.splits.at(-1), split, 'the divider is untouched');
assert.equal(f.snap().swapped, false);
// SWAP: A moves right and B left, the labels follow; the divider stays.
swap.click();
assert.equal(split.destroyed, true);
assert.deepEqual(labels(), ['B', 'A']);
assert.equal(
f.splits.at(-1).options.beforeTitle,
'Landsat 8/9 · 30\u00a0m · 2026-09-16',
);
assert.equal(f.splits.at(-1).options.initialValue, 0.3);
assert.deepEqual(directions(), ['right', 'left']);
assert.equal(swap.getAttribute('aria-pressed'), 'true');
assert.equal(f.layer.getParams().swapped, undefined, 'not in the link');
// Again restores.
swap.click();
assert.deepEqual(labels(), ['A', 'B']);
assert.deepEqual(directions(), ['left', 'right']);
assert.equal(swap.getAttribute('aria-pressed'), 'false');
// Unpinning B ends the swipe and SWAP goes back to its reserved slot.
f.byId('ri-unpin-b').click();
assert.equal(f.splits.at(-1).destroyed, true);
assert.deepEqual(swapState(), [true, true, 'true', 'false']);
});
test('opacity drives both images; EXPORT downloads the pin or the preview and EXPORT B only exists in A / B', async () => {
const f = fixture();
await f.ready();
const exportA = f.byAction('export-a');
const exportB = f.byAction('export-b');
assert.deepEqual(
[
exportA.textContent,
exportA.disabled,
exportB.classList.contains('is-reserved'),
exportB.disabled,
],
['EXPORT', false, true, true],
);
f.byId('ri-opacity').value = '40';
f.byId('ri-opacity').dispatch('input');
assert.equal(f.snap().alpha, 0.4);
assert.equal(
f.part(f.byId('ri-controls'), 'ri-opacity-value').textContent,
'40%',
);
assert.equal(await f.readout.exportImage('a'), true);
assert.match(f.fetches[0], /LAYERS=HLS_S30_Nadir_BRDF_Adjusted_Reflectance/);
assert.match(f.fetches[0], /WIDTH=1024/);
assert.deepEqual(f.urls, {
created: ['blob:export'],
revoked: ['blob:export'],
});
assert.equal(await f.readout.exportImage('b'), false, 'no B outside A / B');
// A wide box exports wide: the longer side at the limit, degrees kept.
f.layer.setBox({ ...BOX, east: -97.6 });
f.catalog.resolveLast();
await settle();
assert.equal(await f.readout.exportImage('a'), true);
assert.match(f.fetches.at(-1), /WIDTH=1024&HEIGHT=512/);
f.layer.setMode('ab');
assert.deepEqual(
[
exportA.textContent,
exportB.textContent,
exportB.classList.contains('is-reserved'),
],
['EXPORT A', 'EXPORT B', false],
);
const failing = fixture({
fetchImpl: async () => response({ ok: false, status: 503 }),
});
await failing.ready();
assert.equal(await failing.readout.exportImage('a'), false);
assert.equal(
failing.byId('ri-notice-text').textContent,
'Export failed · HTTP 503',
);
// A retained export after teardown neither fetches nor mounts again.
const fetched = f.fetches.length;
const container = f
.byId('ri-strip')
.closest('.recent-imagery-readout').parentElement;
f.readout.destroy();
assert.equal(await f.readout.exportImage('a'), false);
assert.equal(f.fetches.length, fetched);
assertSameNode(container.querySelector('.recent-imagery-readout'), null);
});
test('DETAILS is a collapsed rail card holding every note, and the empty-days toggle', async () => {
const f = fixture();
await f.ready();
const header = f
.byId('ri-details')
.find((node) => node.classList.contains('rail-card-header'));
const article = header.parentNode;
assert.equal(article.dataset.open, 'false');
assert.equal(header.getAttribute('aria-expanded'), 'false');
header.click();
assert.equal(article.dataset.open, 'true');
const text = f.byId('ri-details').textContent;
assert.match(text, /Box \d+\.\d × \d+\.\d km/);
assert.match(text, /Sep 18, 2026 17:12Z/);
assert.match(text, /Acquired 17:12Z–17:14Z · Coverage full/);
assert.match(text, /S30-2026-09-18 · 17:12Z · 12% cloud/);
assert.match(text, /START HERE · newest clear day/);
assert.match(
text,
/Daily overview for today may still be empty until the pass/,
);
assert.match(text, /Daily overview shows little detail in a box this small/);
assert.match(text, /NASA GIBS/);
const toggle = f.byAction('toggle-empty');
assert.equal(toggle.disabled, true, 'nothing hidden yet');
f.thumbnails.probe(L16, 'empty');
assert.equal(toggle.textContent, 'SHOW EMPTY DAYS · 1');
toggle.click();
assert.equal(f.snap().showUnavailable, true);
assert.equal(toggle.textContent, 'HIDE EMPTY DAYS');
header.click();
assert.equal(article.dataset.open, 'false');
});
test('the body scroll position survives renders even when DOM mutations reset it', async () => {
const f = fixture();
await f.ready();
f.body.scrollTop = 120;
const insert = f.strip.insertBefore.bind(f.strip);
f.strip.insertBefore = (...args) => {
f.body.scrollTop = 0;
return insert(...args);
};
f.thumbnails.probe(L16, 'empty');
f.layer.setShowUnavailable(true);
assert.equal(f.body.scrollTop, 120);
});
test('opening DETAILS scrolls its card into view, and closing it does not scroll', async () => {
const f = fixture();
await f.ready();
// The body is 100 px high and scrolled 40 px; the card opens 150 px down.
f.body.scrollTop = 40;
Object.defineProperty(f.body, 'clientHeight', { value: 100 });
f.body.getBoundingClientRect = () => ({ top: 0 });
const header = f
.byId('ri-details')
.find((node) => node.classList.contains('rail-card-header'));
const card = header.parentNode;
card.getBoundingClientRect = () => ({
top: 150,
height: card.dataset.open === 'true' ? 60 : 0,
});
header.click();
assert.equal(card.dataset.open, 'true');
assert.equal(f.body.scrollTop, 150);
header.click();
assert.equal(card.dataset.open, 'false');
assert.equal(f.body.scrollTop, 150);
});
test('destroy returns every listener, the divider and the panel state', async () => {
const f = fixture();
await f.ready();
await f.settleLease();
f.layer.setMode('basemap');
const split = f.splits.at(-1);
f.readout.destroy();
assert.equal(split.destroyed, true);
assert.equal(f.root.listenerCount(), 0);
assert.equal(f.strip.listenerCount(), 0);
assertSameNode(f.root.parentNode, null);
assert.equal(f.document.getElementById('recent-imagery-panel').hidden, true);
f.layer.setMode('ab');
assert.equal(f.splits.length, 1, 'no renders after destroy');
f.readout.destroy();
});