A row plays only with a timeline to play; the speed shown follows the world clock; mark labels merge whole and cut by character

A weather row's play started the world clock even with no time to show,
where there is no timeline; it now does nothing then, as the timeline's
own Play cannot be reached. A weather layer made without a weather clock
does nothing once destroyed, so enabling it after builds no clock.

The timeline's speed follows the world clock's rate whoever set it, and
its Play runs at that rate, so it and a row's play never run at different
rates; a rate not offered shows as the nearest offered speed. Choosing a
speed sets the clock's rate first, also while a driver steps.

Marks merge on their whole label and are cut for showing after, so two
whose labels only differ past the cut stay two, and the cut counts
graphemes (code points where the runtime cannot tell them), so an emoji is
never split.

Tests that compared a DOM element with null built an assertion message
from the element when they failed, which hung the run; they compare plain
values now, so they fail at once. The timeline tests put the clock back to
real time after each.
This commit is contained in:
Sameh Khamis committed 2026-11-14 21:12:43 -08:00
1 parent 1dc2dbe4ea
commit ac04812db2
26 files changed
+274 -108

No files matched your search

@@ -6,11 +6,12 @@
mark, and the handle then tells the mark's label with the time to screen mark, and the handle then tells the mark's label with the time to screen
readers and shows it over the track while focused. A layer's world clock readers and shows it over the track while focused. A layer's world clock
coverage may give each mark as `{ time, label, id, kind }` as well as a coverage may give each mark as `{ time, label, id, kind }` as well as a
bare time (a label over 60 characters, `WORLD_CLOCK_MARK_LABEL_LENGTH`, is bare time. `worldClock.coverage()` keeps `marks` as the sorted times and
cut, ending in `…`); `worldClock.coverage()` keeps `marks` as the sorted adds `markDetails`, the merged marks themselves, merged on their whole
times and adds `markDetails`, the merged marks themselves. A zoom or a pan clears the label; a label longer than `WORLD_CLOCK_MARK_LABEL_LENGTH` (60 characters,
hovered label, and a label is never wider than the track (samehkhamis, exported from `gods-eye-view/time`) is then cut, ending in `…`, never
#1745). splitting an emoji. A zoom or a pan clears the hovered label, and a label
is never wider than the track (samehkhamis, #1745).
- The timeline marks only events: launches, and earthquakes of magnitude 6 - The timeline marks only events: launches, and earthquakes of magnitude 6
and up while the Earthquakes layer is on (about one every two days and up while the Earthquakes layer is on (about one every two days
worldwide). It no longer draws a tick for every weather frame, which came worldwide). It no longer draws a tick for every weather frame, which came
@@ -36,11 +37,13 @@
first. A weather row's `play` and `latest` params move the world clock as first. A weather row's `play` and `latest` params move the world clock as
the timeline does: `play` runs at the timeline's chosen speed, from the the timeline does: `play` runs at the timeline's chosen speed, from the
start when live or at the end, and does nothing while every product the start when live or at the end, and does nothing while every product the
row shows is suspended. The world clock keeps a chosen rate while paused row shows is suspended or while there is no time to show. The world clock
and live (`goLive()` no longer sets it back to 1), so any play runs at it. keeps a chosen rate while paused and live (`goLive()` no longer sets it
A weather layer made without a weather clock builds its own when first back to 1), so any play runs at it, and the timeline's speed shows the
enabled, and destroying it leaves the world clock's time where it is clock's rate whoever set it (the nearest offered speed for another rate).
(`createWeatherClock({ shared: false })`). **Breaking:** the catalog's A weather layer made without a weather clock leaves the world clock alone
until it is first enabled, does nothing once destroyed, and destroying it
leaves the world clock's time where it is. **Breaking:** the catalog's
`weatherClock` (`gods-eye-view/application/layers`) no longer has `weatherClock` (`gods-eye-view/application/layers`) no longer has
`setTarget`, `step`, `latest`, `play`, `pause` or `togglePlay`, its state `setTarget`, `step`, `latest`, `play`, `pause` or `togglePlay`, its state
no longer has `playing`, and it no longer attaches itself to the world no longer has `playing`, and it no longer attaches itself to the world
+11 -6
View File
@@ -8545,8 +8545,10 @@ that timeline but are not marks on it: the timeline marks only events (launches,
and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys
step to those, or along the track where there are none. A mark may carry a step to those, or along the track where there are none. A mark may carry a
label (a launch's mission name, cut to 27 characters in capitals as the mission label (a launch's mission name, cut to 27 characters in capitals as the mission
index shows names; an earthquake's magnitude and place) of up to 60 characters; index shows names; an earthquake's magnitude and place). Marks merge on their
a longer one ends in `…`, and a label is never wider than the track. A zoom or whole label; one longer than 60 characters (graphemes, so an emoji is never
split) then shows cut, ending in `…`, and a label is never wider than the
track. A zoom or
a pan clears a hovered label. The pointer within a pan clears a hovered label. The pointer within
6 px of a tick shows its label over the track, and a tap there goes to the 6 px of a tick shows its label over the track, and a tap there goes to the
mark's time exactly; a tap elsewhere goes to the time under it and a drag mark's time exactly; a tap elsewhere goes to the time under it and a drag
@@ -8579,12 +8581,15 @@ The catalog exposes `weatherClock` and destroys it with its lifetime signal. It
has no time controls of its own: `weatherClock.world` is the world clock it has no time controls of its own: `weatherClock.world` is the world clock it
follows, where time moves. A weather row's `play`, `latest` and `step` params follows, where time moves. A weather row's `play`, `latest` and `step` params
move that world clock as the timeline does: `play` runs at the speed chosen on move that world clock as the timeline does: `play` runs at the speed chosen on
the timeline (the world clock keeps its rate while paused and live), from the the timeline (the world clock keeps its rate while paused and live, and the
start of the covered time when live or at its end, and does nothing while timeline's speed selector shows the clock's rate, or the nearest offered speed
every product the row shows is suspended. A weather layer built without a for a rate a host set), from the start of the covered time when live or at its
end, and does nothing while every product the row shows is suspended or while
there is no covered time. A weather layer built without a
shared clock builds one of its own on the page's world clock when first shared clock builds one of its own on the page's world clock when first
enabled (`createWeatherClock({ shared: false })`) and keeps it for its enabled (`createWeatherClock({ shared: false })`) and keeps it for its
lifetime; destroying it leaves the world's time where it is, and only the last lifetime; destroying it leaves the world's time where it is, and enabling it
after that does nothing, and only the last
shared weather clock destroyed sends the world live. Neither has a frame replay shared weather clock destroyed sends the world live. Neither has a frame replay
of its own. of its own.
@@ -216,7 +216,7 @@ test('outline upgrade preserves the existing SVG group identity', async (t) => {
assert.ok(after === before, 'the rendered group is mutated, not replaced'); assert.ok(after === before, 'the rendered group is mutated, not replaced');
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor'); assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
assert.equal(groups().length, 1); assert.equal(groups().length, 1);
assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place'); assert.equal(after.querySelector('.gev-anno-ring') === null, true, 'reticle rings are removed in place');
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group'); assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group'); assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
assert.ok(svg()); assert.ok(svg());
@@ -330,7 +330,7 @@ test('a removed mark fades out, then leaves the board', async (t) => {
assert.equal(group.isConnected, false); assert.equal(group.isConnected, false);
renderer.destroy(); renderer.destroy();
await settle(); await settle();
assert.equal(document.querySelector('.gev-screen-whiteboard'), null); assert.equal(document.querySelector('.gev-screen-whiteboard') === null, true);
}); });
// ── Partial-add unwind (review round 2) ─────────────────────────────────────── // ── Partial-add unwind (review round 2) ───────────────────────────────────────
+2 -2
View File
@@ -209,7 +209,7 @@ test('rows render the signals the controller presents', async (t) => {
); );
assert.equal(status.className, 'warning'); assert.equal(status.className, 'warning');
assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN'); assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
assert.equal(status.querySelector('button'), null); assert.equal(status.querySelector('button') === null, true);
}); });
test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => { test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
@@ -315,7 +315,7 @@ test('removing the focused identity uses the shown briefing tab once and permits
assert.equal(document.activeElement === f.signalTab(), true); assert.equal(document.activeElement === f.signalTab(), true);
assert.equal(f.focusCalls(), calls + 1); assert.equal(f.focusCalls(), calls + 1);
assert.equal(f.losses(), 0); assert.equal(f.losses(), 0);
assert.equal(f.signalTab().closest('[hidden]'), null); assert.equal(f.signalTab().closest('[hidden]') === null, true);
// Traversal carries on forward from the footer tab. // Traversal carries on forward from the footer tab.
f.tab(); f.tab();
assert.equal(document.activeElement === f.pageTab(1), true); assert.equal(document.activeElement === f.pageTab(1), true);
+2 -2
View File
@@ -165,7 +165,7 @@ test('the DISMISS button closes the card the way ESC does', async (t) => {
assert.notEqual(sessionStorageRef.read(), null, 'dismissed for the session'); assert.notEqual(sessionStorageRef.read(), null, 'dismissed for the session');
root.dispatchEvent(new Event('transitionend')); root.dispatchEvent(new Event('transitionend'));
await nextTick(); await nextTick();
assert.equal(document.getElementById('first-run-launcher'), null); assert.equal(document.getElementById('first-run-launcher') === null, true);
}); });
test('the scroll fade clears when the tiles fit after a resize', async (t) => { test('the scroll fade clears when the tiles fit after a resize', async (t) => {
@@ -191,5 +191,5 @@ test('ESC closes the card, returns focus, and removes it without a transition',
// Reduced motion: no transitionend, so the bounded fallback removes it. // Reduced motion: no transitionend, so the bounded fallback removes it.
t.mock.timers.tick(400); t.mock.timers.tick(400);
await nextTick(); await nextTick();
assert.equal(document.getElementById('first-run-launcher'), null); assert.equal(document.getElementById('first-run-launcher') === null, true);
}); });
+16 -9
View File
@@ -58,8 +58,8 @@ const utc = (value) =>
/** /**
* Move `world`, the world clock, as its timeline does: `play` toggles play at * Move `world`, the world clock, as its timeline does: `play` toggles play at
* the world clock's rate (the speed the timeline chose), from the start of the * the world clock's rate (the speed the timeline chose), from the start of the
* covered time when live or at its end, unless `canPlay` is false; `latest` * covered time when live or at its end, unless `canPlay` is false or there
* goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the * is no covered time (no timeline to play); `latest` goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the
* weather frames; the timeline does not mark them, so this is the row's own * weather frames; the timeline does not mark them, so this is the row's own
* way to step through them. * way to step through them.
*/ */
@@ -68,13 +68,16 @@ function moveWorldTime(world, frames, { play, latest, step }, canPlay) {
if (play === true) { if (play === true) {
const state = world.getState(); const state = world.getState();
if (state.playing) world.pause(); if (state.playing) world.pause();
else if (canPlay) { else {
const covered = world.coverage(); // With no time to show there is no timeline, and nothing to play.
// The covered time reaches to now, as the timeline's track does. const covered = canPlay && state.supported ? world.coverage() : null;
const end = covered && Math.max(covered.end, world.wallNow()); if (covered) {
if (covered && (state.live || state.timeMs >= end)) // The covered time reaches to now, as the timeline's track does.
world.seek(Math.min(covered.start, state.timeMs)); const end = Math.max(covered.end, world.wallNow());
world.play({ continuous: true }); if (state.live || state.timeMs >= end)
world.seek(Math.min(covered.start, state.timeMs));
world.play({ continuous: true });
}
} }
} }
if (latest === true) world.goLive(); if (latest === true) world.goLive();
@@ -153,6 +156,7 @@ export function createWeatherLayer({
manifest = null, manifest = null,
request = null, request = null,
listener = null; listener = null;
let destroyed = false;
let enabled = false, let enabled = false,
loading = false; loading = false;
let generation = 0, let generation = 0,
@@ -366,6 +370,8 @@ export function createWeatherLayer({
notify(); notify();
}, },
enable() { enable() {
// Destroyed, the layer stays off: no clock is built for it again.
if (destroyed) return;
enabled = true; enabled = true;
ensureClock(); ensureClock();
if (!removeCamera) { if (!removeCamera) {
@@ -870,6 +876,7 @@ export function createWeatherLayer({
}; };
}, },
destroy() { destroy() {
destroyed = true;
layer.disable(); layer.disable();
unsubscribeClock?.(); unsubscribeClock?.();
unsubscribeClock = null; unsubscribeClock = null;
+25
View File
@@ -660,6 +660,31 @@ test('a layer without a clock leaves the world clock alone until enabled, and ne
assert.equal(worldClock.now(), past); assert.equal(worldClock.now(), past);
}); });
test('destroyed, a layer without a clock stays off: enabling it builds no clock', (t) => {
const subscribe = t.mock.method(worldClock, 'subscribe');
const h = layerHarness();
assert.equal(subscribe.mock.callCount(), 1, 'its clock, built on enable');
h.layer.destroy();
harnessed.delete(h.layer);
h.layer.enable();
assert.equal(subscribe.mock.callCount(), 1, 'no clock built after destroy');
assert.equal(worldClock.follows('weather-radar'), false);
h.layer.setParams({ play: true });
assert.equal(worldClock.isPlaying(), false);
});
test('with no frames yet there is no timeline, and a row plays nothing', () => {
worldClock.setRate(604_800);
const h = layerHarness({
feed: { getSnapshot: () => new Promise(() => {}) },
});
void h.layer.update();
assert.equal(worldClock.coverage(), null);
h.layer.setParams({ play: true });
assert.equal(worldClock.isLive(), true);
assert.equal(worldClock.isPlaying(), false);
});
test('a row plays at the speed the world clock keeps, from the start at the end of the covered time', async () => { test('a row plays at the speed the world clock keeps, from the start at the end of the covered time', async () => {
const h = layerHarness(); const h = layerHarness();
const update = h.layer.update(); const update = h.layer.update();
@@ -66,7 +66,11 @@ test('a loaded pack shows its attribution card in the viewer container', async (
card.querySelector('div').textContent, card.querySelector('div').textContent,
'clip · Synthetic · CC0-1.0', 'clip · Synthetic · CC0-1.0',
); );
assert.equal(card.querySelector('a'), null, 'no link without a source URL'); assert.equal(
card.querySelector('a') === null,
true,
'no link without a source URL',
);
const video = card.querySelector('video'); const video = card.querySelector('video');
assert.equal(video.controls, true); assert.equal(video.controls, true);
assert.equal(video.preload, 'none'); assert.equal(video.preload, 'none');
+33 -6
View File
@@ -33,9 +33,37 @@
*/ */
const MARK_TEXT = ['label', 'id', 'kind']; const MARK_TEXT = ['label', 'id', 'kind'];
/** The longest label a mark carries, ellipsis and all. */ /**
* The longest label a mark carries, ellipsis and all, in characters as a
* reader sees them (graphemes where the runtime can tell them, otherwise code
* points), so an emoji or an accented letter is never split.
*/
export const WORLD_CLOCK_MARK_LABEL_LENGTH = 60; export const WORLD_CLOCK_MARK_LABEL_LENGTH = 60;
const graphemes =
typeof Intl?.Segmenter === 'function'
? (() => {
const segmenter = new Intl.Segmenter(undefined, {
granularity: 'grapheme',
});
return (text) =>
Array.from(segmenter.segment(text), ({ segment }) => segment);
})()
: (text) => Array.from(text);
/** `mark` with its label cut to WORLD_CLOCK_MARK_LABEL_LENGTH, ending in `…`. */
function shownMark(mark) {
if (!mark.label || mark.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH)
return mark;
const parts = graphemes(mark.label);
if (parts.length <= WORLD_CLOCK_MARK_LABEL_LENGTH) return mark;
const label = `${parts
.slice(0, WORLD_CLOCK_MARK_LABEL_LENGTH - 1)
.join('')
.trimEnd()}…`;
return Object.freeze({ ...mark, label });
}
/** /**
* A mark (a number, or `{time, label?, id?, kind?}`) in its one shape, or * A mark (a number, or `{time, label?, id?, kind?}`) in its one shape, or
* null for one with no finite time. * null for one with no finite time.
@@ -53,10 +81,6 @@ function readMark(input) {
const value = input[key]; const value = input[key];
if (typeof value === 'string' && value.trim()) mark[key] = value.trim(); if (typeof value === 'string' && value.trim()) mark[key] = value.trim();
} }
if (mark.label?.length > WORLD_CLOCK_MARK_LABEL_LENGTH)
mark.label = `${mark.label
.slice(0, WORLD_CLOCK_MARK_LABEL_LENGTH - 1)
.trimEnd()}…`;
return Object.freeze(mark); return Object.freeze(mark);
} }
@@ -484,9 +508,12 @@ export function createWorldClock({
const told = new Set(); const told = new Set();
for (const mark of details.values()) for (const mark of details.values())
if (Object.keys(mark).length > 1) told.add(mark.time); if (Object.keys(mark).length > 1) told.add(mark.time);
// Marks merge on their whole label, and are cut for showing after, so
// two whose labels only differ past the cut stay two.
const markDetails = [...details.values()] const markDetails = [...details.values()]
.filter((mark) => Object.keys(mark).length > 1 || !told.has(mark.time)) .filter((mark) => Object.keys(mark).length > 1 || !told.has(mark.time))
.sort(byMark); .sort(byMark)
.map(shownMark);
const marks = [...new Set(markDetails.map((mark) => mark.time))]; const marks = [...new Set(markDetails.map((mark) => mark.time))];
return { start, end, marks, markDetails }; return { start, end, marks, markDetails };
}, },
+45
View File
@@ -489,3 +489,48 @@ test('a mark label longer than the timeline shows is cut, ending in an ellipsis'
assert.ok(cut.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH); assert.ok(cut.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH);
assert.equal(whole.label, 'B'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH)); assert.equal(whole.label, 'B'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH));
}); });
test('a cut label never splits an emoji, and counts it as one character', () => {
const clock = createWorldClock(fakeTime().options);
const a = 'A'.repeat(58);
clock.register({
id: 'emoji',
coverage: () => ({
start: 1,
end: 3,
marks: [
{ time: 1, label: `${a}🚀 LIFTOFF` },
{ time: 2, label: `${a}👩‍🚀 CREW` },
// Sixty characters, though more UTF-16 units: whole.
{ time: 3, label: `${'A'.repeat(59)}🚀` },
],
}),
});
const [rocket, crew, whole] = clock.coverage().markDetails;
assert.equal(rocket.label, `${a}🚀…`);
assert.equal(crew.label, `${a}👩‍🚀…`);
assert.equal(whole.label, `${'A'.repeat(59)}🚀`);
for (const { label } of [rocket, crew, whole])
assert.equal(label.isWellFormed(), true, 'no lone surrogate');
});
test('marks merge on their whole label, so labels alike up to the cut stay two', () => {
const clock = createWorldClock(fakeTime().options);
const stem = 'C'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH - 1);
for (const id of ['one', 'two'])
clock.register({
id,
coverage: () => ({
start: 5,
end: 5,
marks: [{ time: 5, label: `${stem}${id === 'one' ? ' FIRST' : ' SECOND'}` }],
}),
});
const covered = clock.coverage();
assert.deepEqual(covered.marks, [5]);
assert.equal(covered.markDetails.length, 2);
for (const mark of covered.markDetails) {
assert.equal(mark.label, `${stem}…`);
assert.equal(Object.isFrozen(mark), true);
}
});
+3 -3
View File
@@ -280,7 +280,7 @@ test('a tracked flight’s photo shows under its name, credited and linked to it
test('no photo renders without one', async (t) => { test('no photo renders without one', async (t) => {
const { panel, publish } = fixture(t); const { panel, publish } = fixture(t);
await publish({ ...results(), photo: null }); await publish({ ...results(), photo: null });
assert.equal(panel().querySelector('.military-awareness-photo'), null); assert.equal(panel().querySelector('.military-awareness-photo') === null, true);
}); });
test('a photo whose image fails to load is hidden until another arrives', async (t) => { test('a photo whose image fails to load is hidden until another arrives', async (t) => {
@@ -290,10 +290,10 @@ test('a photo whose image fails to load is hidden until another arrives', async
.querySelector('.military-awareness-photo img') .querySelector('.military-awareness-photo img')
.dispatchEvent(new Event('error')); .dispatchEvent(new Event('error'));
await nextTick(); await nextTick();
assert.equal(panel().querySelector('.military-awareness-photo'), null); assert.equal(panel().querySelector('.military-awareness-photo') === null, true);
// A repaint with the same photo keeps it hidden. // A repaint with the same photo keeps it hidden.
await publish({ ...results({ previousDisabled: false }), photo: photo() }); await publish({ ...results({ previousDisabled: false }), photo: photo() });
assert.equal(panel().querySelector('.military-awareness-photo'), null); assert.equal(panel().querySelector('.military-awareness-photo') === null, true);
// Another plane's photo shows. // Another plane's photo shows.
const next = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' }); const next = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' });
await publish({ ...results(), photo: next }); await publish({ ...results(), photo: next });
+3 -3
View File
@@ -839,7 +839,7 @@ test('arrows do nothing new in an aircraft cockpit', async (t) => {
near(after.pitchDeg, before.pitchDeg, 'the aircraft pitch', 1e-6); near(after.pitchDeg, before.pitchDeg, 'the aircraft pitch', 1e-6);
assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 });
assert.equal(store.instruments.look.offset, false); assert.equal(store.instruments.look.offset, false);
assert.equal(document.querySelector('.cockpit-look'), null); assert.equal(document.querySelector('.cockpit-look') === null, true);
// The tape stays on the aircraft's heading, with no bow or stern marks. // The tape stays on the aircraft's heading, with no bow or stern marks.
const centre = store.instruments.compass.find( const centre = store.instruments.compass.find(
(division) => division.slot === 0, (division) => division.slot === 0,
@@ -849,7 +849,7 @@ test('arrows do nothing new in an aircraft cockpit', async (t) => {
prograde: null, prograde: null,
retrograde: null, retrograde: null,
}); });
assert.equal(document.querySelector('.cockpit-compass-marker'), null); assert.equal(document.querySelector('.cockpit-compass-marker') === null, true);
assert.doesNotMatch( assert.doesNotMatch(
document.querySelector('.cockpit-help').textContent, document.querySelector('.cockpit-help').textContent,
/ARROWS LOOK/, /ARROWS LOOK/,
@@ -1248,7 +1248,7 @@ test('a ship with neither heading nor course makes up no direction', async (t) =
prograde: null, prograde: null,
retrograde: null, retrograde: null,
}); });
assert.equal(document.querySelector('.cockpit-compass-marker'), null); assert.equal(document.querySelector('.cockpit-compass-marker') === null, true);
assert.match( assert.match(
$('.cockpit-visor-status-right').textContent, $('.cockpit-visor-status-right').textContent,
/NO HEADING · AIS/, /NO HEADING · AIS/,
+8 -8
View File
@@ -45,7 +45,7 @@ function fixture(t) {
test('the orbital HUD is hidden until a satellite view is published', async (t) => { test('the orbital HUD is hidden until a satellite view is published', async (t) => {
const f = fixture(t); const f = fixture(t);
assert.equal(f.section().hidden, true); assert.equal(f.section().hidden, true);
assert.equal(f.section().querySelector('#cockpit-orbital-zoom-in'), null); assert.equal(f.section().querySelector('#cockpit-orbital-zoom-in') === null, true);
}); });
test('a published orbital view renders its readouts and zoom controls', async (t) => { test('a published orbital view renders its readouts and zoom controls', async (t) => {
@@ -97,7 +97,7 @@ test('an unknown velocity leaves its row out', async (t) => {
const f = fixture(t); const f = fixture(t);
f.store.publishOrbital({ ...VIEW, velocityMps: null }); f.store.publishOrbital({ ...VIEW, velocityMps: null });
await nextTick(); await nextTick();
assert.equal(f.section().querySelector('.cockpit-orbital-velocity'), null); assert.equal(f.section().querySelector('.cockpit-orbital-velocity') === null, true);
assert.equal(f.text('.cockpit-orbital-altitude'), '418 KM'); assert.equal(f.text('.cockpit-orbital-altitude'), '418 KM');
}); });
@@ -145,7 +145,7 @@ test('publishing null hides the orbital HUD again', async (t) => {
f.store.publishOrbital(null); f.store.publishOrbital(null);
await nextTick(); await nextTick();
assert.equal(f.section().hidden, true); assert.equal(f.section().hidden, true);
assert.equal(f.section().querySelector('.cockpit-orbital-name'), null); assert.equal(f.section().querySelector('.cockpit-orbital-name') === null, true);
}); });
const ISS_TLE = Object.freeze({ const ISS_TLE = Object.freeze({
@@ -160,7 +160,7 @@ test('the place below shows at the top once known', async (t) => {
const f = fixture(t); const f = fixture(t);
f.store.publishOrbital({ ...VIEW, place: undefined }); f.store.publishOrbital({ ...VIEW, place: undefined });
await nextTick(); await nextTick();
assert.equal(f.section().querySelector('#cockpit-orbital-over'), null); assert.equal(f.section().querySelector('#cockpit-orbital-over') === null, true);
const over = () => const over = () =>
f.section().querySelector('#cockpit-orbital-over strong')?.textContent; f.section().querySelector('#cockpit-orbital-over strong')?.textContent;
f.store.publishOrbital({ f.store.publishOrbital({
@@ -195,7 +195,7 @@ test('the place below shows at the top once known', async (t) => {
// says nothing rather than call land open ocean. // says nothing rather than call land open ocean.
f.store.publishOrbital({ ...VIEW, place: null }); f.store.publishOrbital({ ...VIEW, place: null });
await nextTick(); await nextTick();
assert.equal(document.querySelector('#cockpit-orbital-over'), null); assert.equal(document.querySelector('#cockpit-orbital-over') === null, true);
}); });
test('an owner country shows its flag from the sheet, or its code if the sheet will not load', async (t) => { test('an owner country shows its flag from the sheet, or its code if the sheet will not load', async (t) => {
@@ -216,7 +216,7 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w
owner: { countryCode: 'US', label: 'USA' }, owner: { countryCode: 'US', label: 'USA' },
}); });
await nextTick(); await nextTick();
assert.equal(f.section().querySelector('.cockpit-orbital-flag'), null); assert.equal(f.section().querySelector('.cockpit-orbital-flag') === null, true);
assert.equal(images.length, 1, 'the sheet is asked for'); assert.equal(images.length, 1, 'the sheet is asked for');
images[0].onerror(); images[0].onerror();
await settle(); await settle();
@@ -244,7 +244,7 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w
flag.style.backgroundPosition, flag.style.backgroundPosition,
ownerFlagStyle('FR').backgroundPosition, ownerFlagStyle('FR').backgroundPosition,
); );
assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge'), null); assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge') === null, true);
}); });
test('the readout names the owner, operator and element set', async (t) => { test('the readout names the owner, operator and element set', async (t) => {
@@ -327,7 +327,7 @@ test('unknown identity fields leave their rows out', async (t) => {
'.cockpit-orbital-ident', '.cockpit-orbital-ident',
'#cockpit-orbital-tle', '#cockpit-orbital-tle',
]) ])
assert.equal(f.section().querySelector(selector), null, selector); assert.equal(f.section().querySelector(selector) === null, true, selector);
// An element set with no NORAD id still dates itself. // An element set with no NORAD id still dates itself.
f.store.publishOrbital({ ...VIEW, tle: ISS_TLE }); f.store.publishOrbital({ ...VIEW, tle: ISS_TLE });
await nextTick(); await nextTick();
+1 -1
View File
@@ -142,7 +142,7 @@ test('the map source row renders the published chips and selects through the con
assert.equal(osm.classList.contains('active'), true); assert.equal(osm.classList.contains('active'), true);
assert.equal(osm.getAttribute('aria-pressed'), 'true'); assert.equal(osm.getAttribute('aria-pressed'), 'true');
assert.equal(osm.hasAttribute('aria-label'), false); assert.equal(osm.hasAttribute('aria-label'), false);
assert.equal(esri.querySelector('.map-stack-chip-req'), null); assert.equal(esri.querySelector('.map-stack-chip-req') === null, true);
bing.click(); bing.click();
esri.click(); esri.click();
assert.deepEqual(selected, ['esri-imagery']); assert.deepEqual(selected, ['esri-imagery']);
+2 -2
View File
@@ -146,7 +146,7 @@ test('Escape in the search field closes it without reaching the panel', async (t
); );
await flush(); await flush();
assert.equal(reached, false); assert.equal(reached, false);
assert.equal(document.getElementById('layer-finder-input'), null); assert.equal(document.getElementById('layer-finder-input') === null, true);
assert.equal(document.activeElement?.id, 'layer-finder-search-btn'); assert.equal(document.activeElement?.id, 'layer-finder-search-btn');
}); });
@@ -167,7 +167,7 @@ test('the filter menu takes focus, moves with the arrows and closes on Escape',
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
); );
await flush(); await flush();
assert.equal(document.getElementById('layer-finder-menu'), null); assert.equal(document.getElementById('layer-finder-menu') === null, true);
assert.equal( assert.equal(
document.activeElement, document.activeElement,
document.querySelector('[aria-controls="layer-finder-menu"]'), document.querySelector('[aria-controls="layer-finder-menu"]'),
+2 -2
View File
@@ -340,12 +340,12 @@ test('the provider settings corner shows the picker only while the shell publish
app.mount(host); app.mount(host);
t.after(() => app.unmount()); t.after(() => app.unmount());
const store = useLocaleStore(pinia); const store = useLocaleStore(pinia);
assert.equal(document.getElementById('locale-picker'), null); assert.equal(document.getElementById('locale-picker') === null, true);
store.publishPicker({ current: 'de', onSelect() {} }); store.publishPicker({ current: 'de', onSelect() {} });
await nextTick(); await nextTick();
const code = document.querySelector('#corner-actions .locale-picker-code'); const code = document.querySelector('#corner-actions .locale-picker-code');
assert.equal(code?.textContent, 'DE'); assert.equal(code?.textContent, 'DE');
store.publishPicker(null); store.publishPicker(null);
await nextTick(); await nextTick();
assert.equal(document.getElementById('locale-picker'), null); assert.equal(document.getElementById('locale-picker') === null, true);
}); });
+11 -11
View File
@@ -156,7 +156,7 @@ test('the panel shows the last 24 h as event cards and opens once', () => {
assert.equal(img.getAttribute('referrerpolicy'), 'no-referrer'); assert.equal(img.getAttribute('referrerpolicy'), 'no-referrer');
assert.equal(first.querySelector('.news-card__credit').textContent, 'A'); assert.equal(first.querySelector('.news-card__credit').textContent, 'A');
// A lone report has no picture. // A lone report has no picture.
assert.equal(second.querySelector('.news-card__thumb'), null); assert.equal(second.querySelector('.news-card__thumb') === null, true);
// A later update does not reopen a panel the reader collapsed. // A later update does not reopen a panel the reader collapsed.
panel.classList.add('collapsed'); panel.classList.add('collapsed');
news.update(view()); news.update(view());
@@ -192,8 +192,8 @@ test('a card click flies there; open, it links the source and closes', () => {
test('filters, the Local section and empty or failed reads', () => { test('filters, the Local section and empty or failed reads', () => {
const { news, panel, calls } = fixture(); const { news, panel, calls } = fixture();
news.update(view()); news.update(view());
assert.equal(panel.querySelector('[aria-label="Local"]'), null); assert.equal(panel.querySelector('[aria-label="Local"]') === null, true);
assert.equal(panel.querySelector('.news-toggle'), null); assert.equal(panel.querySelector('.news-toggle') === null, true);
const severity = panel.querySelector('select[aria-label="Severity"]'); const severity = panel.querySelector('select[aria-label="Severity"]');
assert.equal(severity.value, '2'); assert.equal(severity.value, '2');
severity.value = '3'; severity.value = '3';
@@ -214,7 +214,7 @@ test('filters, the Local section and empty or failed reads', () => {
panel.textContent, panel.textContent,
/No news matches these filters in the last 24 h/, /No news matches these filters in the last 24 h/,
); );
assert.equal(panel.querySelector('.news-readout__footer button'), null); assert.equal(panel.querySelector('.news-readout__footer button') === null, true);
// Local: no hotspot chips or severity floor, and its own empty states. // Local: no hotspot chips or severity floor, and its own empty states.
const localView = (inView) => const localView = (inView) =>
@@ -225,9 +225,9 @@ test('filters, the Local section and empty or failed reads', () => {
news.update(localView({ active: false })); news.update(localView({ active: false }));
const section = panel.querySelector('[aria-label="Local"]'); const section = panel.querySelector('[aria-label="Local"]');
assert.match(section.textContent, /Zoom in to see local news/); assert.match(section.textContent, /Zoom in to see local news/);
assert.equal(panel.querySelector('[data-chip-id="ua-ru-war"]'), null); assert.equal(panel.querySelector('[data-chip-id="ua-ru-war"]') === null, true);
assert.equal(panel.querySelector('select[aria-label="Severity"]'), null); assert.equal(panel.querySelector('select[aria-label="Severity"]') === null, true);
assert.equal(panel.querySelector('[aria-label="Latest news"]'), null); assert.equal(panel.querySelector('[aria-label="Latest news"]') === null, true);
assert.equal(document.getElementById('news-panel-count').textContent, '0'); assert.equal(document.getElementById('news-panel-count').textContent, '0');
news.update( news.update(
localView({ active: true, items: [feedCardView(report('near'), NOW)] }), localView({ active: true, items: [feedCardView(report('near'), NOW)] }),
@@ -320,7 +320,7 @@ test('a card credits the service its report came through', () => {
credited.querySelector('.news-card__via').textContent, credited.querySelector('.news-card__via').textContent,
'via GDELT Project', 'via GDELT Project',
); );
assert.equal(own.querySelector('.news-card__via'), null); assert.equal(own.querySelector('.news-card__via') === null, true);
news.destroy(); news.destroy();
}); });
@@ -370,7 +370,7 @@ test('scrolling to the end asks once per page, again after a filter change or a
test('the window choice offers shorter windows; the longest is the server window', () => { test('the window choice offers shorter windows; the longest is the server window', () => {
const { news, panel, calls } = fixture(); const { news, panel, calls } = fixture();
news.update(view({ windowHours: 12, windowChoices: [12, 6, 3, 1] })); news.update(view({ windowHours: 12, windowChoices: [12, 6, 3, 1] }));
assert.equal(panel.querySelector('.news-readout__window'), null); assert.equal(panel.querySelector('.news-readout__window') === null, true);
const choice = panel.querySelector('select[aria-label="Time window"]'); const choice = panel.querySelector('select[aria-label="Time window"]');
assert.deepEqual( assert.deepEqual(
[...choice.options].map((option) => option.textContent), [...choice.options].map((option) => option.textContent),
@@ -410,7 +410,7 @@ test('a clip waits behind its picture and plays, on request, in the open card',
news.update(view({ items: [feedCardView(clipped, NOW)] })); news.update(view({ items: [feedCardView(clipped, NOW)] }));
const card = panel.querySelector('.news-card'); const card = panel.querySelector('.news-card');
assert.equal(card.querySelector('.news-card__play').textContent, '▶'); assert.equal(card.querySelector('.news-card__play').textContent, '▶');
assert.equal(card.querySelector('video'), null); assert.equal(card.querySelector('video') === null, true);
news.update( news.update(
view({ view({
items: [feedCardView(clipped, NOW)], items: [feedCardView(clipped, NOW)],
@@ -436,6 +436,6 @@ test('a clip waits behind its picture and plays, on request, in the open card',
selected: feedCardView(clipped, NOW), selected: feedCardView(clipped, NOW),
}), }),
); );
assert.equal(panel.querySelector('.news-card.is-open video'), null); assert.equal(panel.querySelector('.news-card.is-open video') === null, true);
news.destroy(); news.destroy();
}); });
+2 -2
View File
@@ -166,8 +166,8 @@ test('UiPanel without collapsible has no button, and glow is optional', (t) => {
}); });
const panel = document.getElementById('plain-panel'); const panel = document.getElementById('plain-panel');
assert.equal(panel.tagName, 'SECTION'); assert.equal(panel.tagName, 'SECTION');
assert.equal(panel.querySelector('.panel-glow'), null); assert.equal(panel.querySelector('.panel-glow') === null, true);
assert.equal(panel.querySelector('.panel-collapse-btn'), null); assert.equal(panel.querySelector('.panel-collapse-btn') === null, true);
}); });
test('RailPanel follows the store, forwards clicks, and never restores a class its owner removed', async (t) => { test('RailPanel follows the store, forwards clicks, and never restores a class its owner removed', async (t) => {
+5 -5
View File
@@ -135,8 +135,8 @@ test('the chip counts what is missing and the rows render from the status', asyn
); );
assert.equal(field.type, 'password'); assert.equal(field.type, 'password');
assert.equal(field.placeholder, 'paste GOOGLE_MAPS_API_KEY'); assert.equal(field.placeholder, 'paste GOOGLE_MAPS_API_KEY');
assert.equal(google.querySelector('.key-setup-remove'), null); assert.equal(google.querySelector('.key-setup-remove') === null, true);
assert.equal(google.querySelector('.key-setup-cloud-auth'), null); assert.equal(google.querySelector('.key-setup-cloud-auth') === null, true);
const openai = $('[data-key-id="openai"]'); const openai = $('[data-key-id="openai"]');
assert.equal(openai.dataset.managed, 'file'); assert.equal(openai.dataset.managed, 'file');
@@ -159,7 +159,7 @@ test('the chip counts what is missing and the rows render from the status', asyn
firms.querySelector('.key-setup-external').textContent, firms.querySelector('.key-setup-external').textContent,
'configured externally', 'configured externally',
); );
assert.equal(firms.querySelector('.key-setup-fields'), null); assert.equal(firms.querySelector('.key-setup-fields') === null, true);
}); });
test('the chip opens the dialog onto its first field; closing fades it and restores the status line', async (t) => { test('the chip opens the dialog onto its first field; closing fades it and restores the status line', async (t) => {
@@ -337,12 +337,12 @@ test('Cyber moves the chip into the top toolbar and other themes return it to th
await wait(); await wait();
await nextTick(); await nextTick();
assert.equal(toolbar.lastElementChild?.id, 'key-setup-chip'); assert.equal(toolbar.lastElementChild?.id, 'key-setup-chip');
assert.equal(corner.querySelector('#key-setup-chip'), null); assert.equal(corner.querySelector('#key-setup-chip') === null, true);
document.documentElement.dataset.uiTheme = 'tactical'; document.documentElement.dataset.uiTheme = 'tactical';
await wait(); await wait();
await nextTick(); await nextTick();
assert.equal(corner.firstElementChild?.id, 'key-setup-chip'); assert.equal(corner.firstElementChild?.id, 'key-setup-chip');
assert.equal(toolbar.querySelector('#key-setup-chip'), null); assert.equal(toolbar.querySelector('#key-setup-chip') === null, true);
delete document.documentElement.dataset.uiTheme; delete document.documentElement.dataset.uiTheme;
}); });
+1 -1
View File
@@ -188,5 +188,5 @@ test('the refresh timer reloads with a cache-buster, skips hidden tabs, and stop
ui.overlay.dispose(); ui.overlay.dispose();
assert.equal(ui.intervals.size, 0); assert.equal(ui.intervals.size, 0);
assert.equal(ui.renderListeners.size, 0); assert.equal(ui.renderListeners.size, 0);
assert.equal(ui.window.document.querySelector('.qci-panel'), null); assert.equal(ui.window.document.querySelector('.qci-panel') === null, true);
}); });
+2 -2
View File
@@ -72,7 +72,7 @@ test('a lost context offers Reload, and a panel that cannot reload offers nothin
document.getElementById('render-failure-message').textContent, document.getElementById('render-failure-message').textContent,
/3D graphics/, /3D graphics/,
); );
assert.equal(notice().querySelector('.render-failure-detail'), null); assert.equal(notice().querySelector('.render-failure-detail') === null, true);
assert.deepEqual(buttons(), ['Reload', 'Reset view']); assert.deepEqual(buttons(), ['Reload', 'Reset view']);
store.publishRenderFailure({ store.publishRenderFailure({
reason: 'context-lost', reason: 'context-lost',
@@ -82,7 +82,7 @@ test('a lost context offers Reload, and a panel that cannot reload offers nothin
}); });
await nextTick(); await nextTick();
assert.deepEqual(buttons(), []); assert.deepEqual(buttons(), []);
assert.equal(notice().querySelector('.render-failure-hint'), null); assert.equal(notice().querySelector('.render-failure-hint') === null, true);
}); });
const failure = () => ({ const failure = () => ({
+24 -11
View File
@@ -96,13 +96,20 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
span: spanOf(state), span: spanOf(state),
}); });
const time = shallowRef(read()); const time = shallowRef(read());
// The speed the world clock keeps, where it is one of those offered. // The speed shown follows the world clock's rate, whoever set it: a rate
const speed = shallowRef( // not among those offered shows as the nearest that is (by ratio), while
Object.keys(WORLD_TIME_SPEEDS).find( // play still runs at the clock's own rate.
(key) => WORLD_TIME_SPEEDS[key] === worldClock.getRate(), const speedFor = (clockRate) => {
) ?? 'real', let best = 'real';
); for (const key of Object.keys(WORLD_TIME_SPEEDS))
const rate = () => WORLD_TIME_SPEEDS[speed.value]; if (
Math.abs(Math.log(WORLD_TIME_SPEEDS[key] / clockRate)) <
Math.abs(Math.log(WORLD_TIME_SPEEDS[best] / clockRate))
)
best = key;
return best;
};
const speed = shallowRef(speedFor(worldClock.getRate()));
let ticker = null; let ticker = null;
const syncTicker = () => { const syncTicker = () => {
// Playing, the clock tells of the time itself. // Playing, the clock tells of the time itself.
@@ -117,6 +124,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
} }
}; };
const unsubscribe = worldClock.subscribe((state) => { const unsubscribe = worldClock.subscribe((state) => {
if (speed.value !== speedFor(state.rate))
speed.value = speedFor(state.rate);
if (stopAtEnd(state)) return; if (stopAtEnd(state)) return;
time.value = read(state); time.value = read(state);
syncTicker(); syncTicker();
@@ -131,7 +140,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
return { return {
time, time,
/** /**
* Play runs the track at the speed chosen, from the start when it is at * Play runs the track at the world clock's rate (the speed chosen, which
* sets it), from the start when it is at
* its end (at live, say), as a video plays again from the top. It runs * its end (at live, say), as a video plays again from the top. It runs
* the clock itself: weather follows the time frame by frame like every * the clock itself: weather follows the time frame by frame like every
* other layer the track covers. * other layer the track covers.
@@ -142,7 +152,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
const covered = reach(); const covered = reach();
if (span && covered && (live || timeMs >= covered.end)) if (span && covered && (live || timeMs >= covered.end))
worldClock.seek(span.start); worldClock.seek(span.start);
worldClock.play({ rate: rate(), continuous: true }); // At the clock's rate: the speed chosen, or a host's own.
worldClock.play({ continuous: true });
}, },
speed, speed,
/** /**
@@ -154,10 +165,12 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
setSpeed(next) { setSpeed(next) {
if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return; if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return;
speed.value = next; speed.value = next;
// The clock's rate first: the speed shown follows it from here on.
worldClock.setRate(WORLD_TIME_SPEEDS[next]);
const { playing, stepping } = worldClock.getState(); const { playing, stepping } = worldClock.getState();
if (!playing || !stepping) return worldClock.setRate(rate()); if (!playing || !stepping) return;
worldClock.pause(); worldClock.pause();
worldClock.play({ rate: rate(), continuous: true }); worldClock.play({ continuous: true });
}, },
goLive: () => worldClock.goLive(), goLive: () => worldClock.goLive(),
/** /**
+2 -2
View File
@@ -25,7 +25,7 @@ test('omitted views lose their hosts and the rest render', () => {
page(['welcome', 'provider-settings']); page(['welcome', 'provider-settings']);
mountApplicationViews(document, { omit: ['provider-settings'] }); mountApplicationViews(document, { omit: ['provider-settings'] });
assert.deepEqual(hostNames(), ['welcome']); assert.deepEqual(hostNames(), ['welcome']);
assert.equal(document.getElementById('key-setup-chip'), null); assert.equal(document.getElementById('key-setup-chip') === null, true);
assert.ok( assert.ok(
document.querySelector('[data-gev-view="welcome"]').children.length, document.querySelector('[data-gev-view="welcome"]').children.length,
); );
@@ -37,7 +37,7 @@ test('a view an earlier call rendered is unmounted when a later call omits it',
assert.ok(document.getElementById('key-setup-chip')); assert.ok(document.getElementById('key-setup-chip'));
mountApplicationViews(document, { omit: ['provider-settings'] }); mountApplicationViews(document, { omit: ['provider-settings'] });
assert.deepEqual(hostNames(), ['welcome']); assert.deepEqual(hostNames(), ['welcome']);
assert.equal(document.getElementById('key-setup-chip'), null); assert.equal(document.getElementById('key-setup-chip') === null, true);
}); });
test('unknown view names are refused, in the page or in the omit list', () => { test('unknown view names are refused, in the page or in the omit list', () => {
+1 -1
View File
@@ -77,7 +77,7 @@ test('an application\'s row actions sit before fly to, labelled, and act on thei
const note = row('a1').querySelector('.draw-mark-note'); const note = row('a1').querySelector('.draw-mark-note');
assert.equal(note.getAttribute('role'), 'status'); assert.equal(note.getAttribute('role'), 'status');
assert.equal(note.textContent, ''); assert.equal(note.textContent, '');
assert.equal(row('a2').querySelector('.draw-mark-note'), null, 'a row without actions has no note'); assert.equal(row('a2').querySelector('.draw-mark-note') === null, true, 'a row without actions has no note');
const line = { ...LINE, path: [[0, 0], [1, 0]] }; const line = { ...LINE, path: [[0, 0], [1, 0]] };
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: true, text: '' }]])) }); store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: true, text: '' }]])) });
await nextTick(); await nextTick();
+48 -11
View File
@@ -19,6 +19,8 @@ function mount(t) {
// The store outlives the view; dispose it with its clock subscription. // The store outlives the view; dispose it with its clock subscription.
useWorldTimeStore(pinia).$dispose(); useWorldTimeStore(pinia).$dispose();
worldClock.goLive(); worldClock.goLive();
// The clock keeps its rate; the next test starts at real time.
worldClock.setRate(1);
}); });
return () => document.getElementById('world-timeline'); return () => document.getElementById('world-timeline');
} }
@@ -79,16 +81,16 @@ test('the timeline hides while no layer with time to show is on', async (t) => {
const bar = mount(t); const bar = mount(t);
const at = Date.UTC(2026, 9, 30, 6, 15, 42); const at = Date.UTC(2026, 9, 30, 6, 15, 42);
await nextTick(); await nextTick();
assert.equal(bar(), null, 'live, nothing to play'); assert.equal(bar() === null, true, 'live, nothing to play');
worldClock.seek(at); worldClock.seek(at);
await nextTick(); await nextTick();
assert.equal(bar(), null, 'away from live, but nothing to play'); assert.equal(bar() === null, true, 'away from live, but nothing to play');
const remove = supportingLayer(t, at); const remove = supportingLayer(t, at);
await nextTick(); await nextTick();
assert.ok(bar()); assert.ok(bar());
remove(); remove();
await nextTick(); await nextTick();
assert.equal(bar(), null, 'the layer went off'); assert.equal(bar() === null, true, 'the layer went off');
}); });
test('the track runs over the time the layers show, to now, and scrubs', async (t) => { test('the track runs over the time the layers show, to now, and scrubs', async (t) => {
@@ -465,7 +467,7 @@ test('play held for a layer to load shows a spinner once the hold lasts', async
// A clock tick (a quarter second) tells of the hold; the spinner waits on. // A clock tick (a quarter second) tells of the hold; the spinner waits on.
await new Promise((resolve) => setTimeout(resolve, 250)); await new Promise((resolve) => setTimeout(resolve, 250));
await nextTick(); await nextTick();
assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame'); assert.equal(bubble().querySelector('.world-timeline-spinner') === null, true, 'not for a quick frame');
// The clock tells of the hold on its next tick, and the bar shows it after. // The clock tells of the hold on its next tick, and the bar shows it after.
const until = async (shown) => { const until = async (shown) => {
for (let waited = 0; waited < 3_000; waited += 50) { for (let waited = 0; waited < 3_000; waited += 50) {
@@ -615,7 +617,7 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async
track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId })); track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId }));
const tip = () => bar().querySelector('.world-timeline-mark-label'); const tip = () => bar().querySelector('.world-timeline-mark-label');
assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'ticks stay ticks'); assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'ticks stay ticks');
assert.equal(tip(), null); assert.equal(tip()?.textContent ?? null, null);
// Hovering within 6 px of a tick shows its label, in place of the track's hint. // Hovering within 6 px of a tick shows its label, in place of the track's hint.
press('pointermove', 205); press('pointermove', 205);
@@ -628,15 +630,15 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async
// Further off, or on a tick with no label, nothing shows. // Further off, or on a tick with no label, nothing shows.
press('pointermove', 690); press('pointermove', 690);
await nextTick(); await nextTick();
assert.equal(tip(), null); assert.equal(tip()?.textContent ?? null, null);
assert.equal(track.hasAttribute('title'), true); assert.equal(track.hasAttribute('title'), true);
press('pointermove', 2); press('pointermove', 2);
await nextTick(); await nextTick();
assert.equal(tip(), null, 'an unlabelled mark has no label'); assert.equal(tip()?.textContent ?? null, null, 'an unlabelled mark has no label');
press('pointermove', 205); press('pointermove', 205);
track.dispatchEvent(new PointerEvent('pointerleave', { clientX: 1_100, pointerId: 1 })); track.dispatchEvent(new PointerEvent('pointerleave', { clientX: 1_100, pointerId: 1 }));
await nextTick(); await nextTick();
assert.equal(tip(), null, 'leaving the track hides it'); assert.equal(tip()?.textContent ?? null, null, 'leaving the track hides it');
// A tap by a tick goes to its mark exactly; elsewhere, to the time there. // A tap by a tick goes to its mark exactly; elsewhere, to the time there.
press('pointerdown', 704); press('pointerdown', 704);
@@ -667,7 +669,7 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async
assert.equal(tip()?.textContent, 'GAUNTLET PAYLOAD'); assert.equal(tip()?.textContent, 'GAUNTLET PAYLOAD');
input.dispatchEvent(new Event('blur')); input.dispatchEvent(new Event('blur'));
await nextTick(); await nextTick();
assert.equal(tip(), null); assert.equal(tip()?.textContent ?? null, null);
}); });
test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan clears it', async (t) => { test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan clears it', async (t) => {
@@ -717,7 +719,7 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan
track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true })); track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
await nextTick(); await nextTick();
assert.equal(track.classList.contains('is-zoomed'), true); assert.equal(track.classList.contains('is-zoomed'), true);
assert.equal(tip(), null, 'a zoom clears the label'); assert.equal(tip()?.textContent ?? null, null, 'a zoom clears the label');
// So does a pan, by wheel or by drag. // So does a pan, by wheel or by drag.
pointer('pointermove', firstAt()); pointer('pointermove', firstAt());
await nextTick(); await nextTick();
@@ -726,7 +728,7 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan
new WheelEvent('wheel', { deltaY: 40, shiftKey: true, cancelable: true }), new WheelEvent('wheel', { deltaY: 40, shiftKey: true, cancelable: true }),
); );
await nextTick(); await nextTick();
assert.equal(tip(), null, 'a pan clears the label'); assert.equal(tip()?.textContent ?? null, null, 'a pan clears the label');
// A tap between them goes to the nearer mark. // A tap between them goes to the nearer mark.
track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
@@ -737,3 +739,38 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan
await nextTick(); await nextTick();
assert.equal(worldClock.now(), second, 'a tap goes to the nearer mark'); assert.equal(worldClock.now(), second, 'a tap goes to the nearer mark');
}); });
test('the speed shown follows the world clock, and Play runs at its rate', async (t) => {
const bar = mount(t);
const now = Date.now();
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start: now - 10 * 86_400_000, end: now - 86_400_000 }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const select = () => bar().querySelector('select.world-timeline-speed');
assert.equal(select().value, 'real');
// A host sets the rate: the selector shows it.
worldClock.setRate(86_400);
await nextTick();
assert.equal(select().value, 'day');
// A rate not offered shows as the nearest offered speed, and Play keeps it.
worldClock.setRate(5_000);
await nextTick();
assert.equal(select().value, 'hour');
bar().querySelector('.world-timeline-play').click();
assert.equal(worldClock.isPlaying(), true);
assert.equal(worldClock.getRate(), 5_000, 'Play runs at the clock\'s own rate');
worldClock.pause();
// Chosen on the selector while paused, the clock takes it at once.
select().value = 'week';
select().dispatchEvent(new Event('change'));
await nextTick();
assert.equal(worldClock.getRate(), 604_800);
assert.equal(select().value, 'week');
});
+5 -5
View File
@@ -309,7 +309,7 @@ test('dot, icon and ambient photo clicks open one contained medium-photo card at
true, true,
'the loaded medium photo is painted inside the card', 'the loaded medium photo is painted inside the card',
); );
assert.equal(card.querySelector('[role=status]'), null); assert.equal(card.querySelector('[role=status]') === null, true);
const style = readFileSync( const style = readFileSync(
new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url), new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url),
'utf8', 'utf8',
@@ -343,11 +343,11 @@ test('missing, restricted, obscured and Acartia photos all use the same compact
const f = fixture(context, { ...row, ...extra }); const f = fixture(context, { ...row, ...extra });
await f.click(); await f.click();
const card = f.cardElement(); const card = f.cardElement();
assert.equal(card.querySelector('img'), null); assert.equal(card.querySelector('img') === null, true);
assert.equal(card.querySelector('[role=status]'), null); assert.equal(card.querySelector('[role=status]') === null, true);
assert.equal(card.querySelector('h2').textContent, row.commonName); assert.equal(card.querySelector('h2').textContent, row.commonName);
assert.equal(card.querySelector('i').textContent, row.scientificName); assert.equal(card.querySelector('i').textContent, row.scientificName);
assert.equal(document.querySelector('dialog'), null); assert.equal(document.querySelector('dialog') === null, true);
if (extra.obscured) { if (extra.obscured) {
assert.doesNotMatch(card.textContent, /ago|Photographer/); assert.doesNotMatch(card.textContent, /ago|Photographer/);
assert.ok( assert.ok(
@@ -676,7 +676,7 @@ test('date-only and unsafe-link rows remain readable, and no sighting rail or li
link: 'javascript:alert(1)', link: 'javascript:alert(1)',
}); });
await f.click(); await f.click();
assert.equal(f.cardElement().querySelector('a'), null); assert.equal(f.cardElement().querySelector('a') === null, true);
assert.equal( assert.equal(
f.cardElement().querySelector('time').getAttribute('datetime'), f.cardElement().querySelector('time').getAttribute('datetime'),
row.observedOn, row.observedOn,