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

@@ -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.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
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-dot') === dot, 'the anchor dot remains in the group');
assert.ok(svg());
@@ -330,7 +330,7 @@ test('a removed mark fades out, then leaves the board', async (t) => {
assert.equal(group.isConnected, false);
renderer.destroy();
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) ───────────────────────────────────────
+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.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) => {
@@ -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(f.focusCalls(), calls + 1);
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.
f.tab();
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');
root.dispatchEvent(new Event('transitionend'));
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) => {
@@ -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.
t.mock.timers.tick(400);
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
* 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`
* goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the
* covered time when live or at its end, unless `canPlay` is false or there
* 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
* way to step through them.
*/
@@ -68,13 +68,16 @@ function moveWorldTime(world, frames, { play, latest, step }, canPlay) {
if (play === true) {
const state = world.getState();
if (state.playing) world.pause();
else if (canPlay) {
const covered = world.coverage();
// The covered time reaches to now, as the timeline's track does.
const end = covered && Math.max(covered.end, world.wallNow());
if (covered && (state.live || state.timeMs >= end))
world.seek(Math.min(covered.start, state.timeMs));
world.play({ continuous: true });
else {
// With no time to show there is no timeline, and nothing to play.
const covered = canPlay && state.supported ? world.coverage() : null;
if (covered) {
// The covered time reaches to now, as the timeline's track does.
const end = Math.max(covered.end, world.wallNow());
if (state.live || state.timeMs >= end)
world.seek(Math.min(covered.start, state.timeMs));
world.play({ continuous: true });
}
}
}
if (latest === true) world.goLive();
@@ -153,6 +156,7 @@ export function createWeatherLayer({
manifest = null,
request = null,
listener = null;
let destroyed = false;
let enabled = false,
loading = false;
let generation = 0,
@@ -366,6 +370,8 @@ export function createWeatherLayer({
notify();
},
enable() {
// Destroyed, the layer stays off: no clock is built for it again.
if (destroyed) return;
enabled = true;
ensureClock();
if (!removeCamera) {
@@ -870,6 +876,7 @@ export function createWeatherLayer({
};
},
destroy() {
destroyed = true;
layer.disable();
unsubscribeClock?.();
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);
});
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 () => {
const h = layerHarness();
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,
'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');
assert.equal(video.controls, true);
assert.equal(video.preload, 'none');
+33 -6
View File
@@ -33,9 +33,37 @@
*/
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;
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
* null for one with no finite time.
@@ -53,10 +81,6 @@ function readMark(input) {
const value = input[key];
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);
}
@@ -484,9 +508,12 @@ export function createWorldClock({
const told = new Set();
for (const mark of details.values())
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()]
.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))];
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.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) => {
const { panel, publish } = fixture(t);
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) => {
@@ -290,10 +290,10 @@ test('a photo whose image fails to load is hidden until another arrives', async
.querySelector('.military-awareness-photo img')
.dispatchEvent(new Event('error'));
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.
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.
const next = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' });
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);
assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 });
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.
const centre = store.instruments.compass.find(
(division) => division.slot === 0,
@@ -849,7 +849,7 @@ test('arrows do nothing new in an aircraft cockpit', async (t) => {
prograde: null,
retrograde: null,
});
assert.equal(document.querySelector('.cockpit-compass-marker'), null);
assert.equal(document.querySelector('.cockpit-compass-marker') === null, true);
assert.doesNotMatch(
document.querySelector('.cockpit-help').textContent,
/ARROWS LOOK/,
@@ -1248,7 +1248,7 @@ test('a ship with neither heading nor course makes up no direction', async (t) =
prograde: null,
retrograde: null,
});
assert.equal(document.querySelector('.cockpit-compass-marker'), null);
assert.equal(document.querySelector('.cockpit-compass-marker') === null, true);
assert.match(
$('.cockpit-visor-status-right').textContent,
/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) => {
const f = fixture(t);
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) => {
@@ -97,7 +97,7 @@ test('an unknown velocity leaves its row out', async (t) => {
const f = fixture(t);
f.store.publishOrbital({ ...VIEW, velocityMps: null });
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');
});
@@ -145,7 +145,7 @@ test('publishing null hides the orbital HUD again', async (t) => {
f.store.publishOrbital(null);
await nextTick();
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({
@@ -160,7 +160,7 @@ test('the place below shows at the top once known', async (t) => {
const f = fixture(t);
f.store.publishOrbital({ ...VIEW, place: undefined });
await nextTick();
assert.equal(f.section().querySelector('#cockpit-orbital-over'), null);
assert.equal(f.section().querySelector('#cockpit-orbital-over') === null, true);
const over = () =>
f.section().querySelector('#cockpit-orbital-over strong')?.textContent;
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.
f.store.publishOrbital({ ...VIEW, place: null });
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) => {
@@ -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' },
});
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');
images[0].onerror();
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,
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) => {
@@ -327,7 +327,7 @@ test('unknown identity fields leave their rows out', async (t) => {
'.cockpit-orbital-ident',
'#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.
f.store.publishOrbital({ ...VIEW, tle: ISS_TLE });
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.getAttribute('aria-pressed'), 'true');
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();
esri.click();
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();
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');
});
@@ -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 }),
);
await flush();
assert.equal(document.getElementById('layer-finder-menu'), null);
assert.equal(document.getElementById('layer-finder-menu') === null, true);
assert.equal(
document.activeElement,
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);
t.after(() => app.unmount());
const store = useLocaleStore(pinia);
assert.equal(document.getElementById('locale-picker'), null);
assert.equal(document.getElementById('locale-picker') === null, true);
store.publishPicker({ current: 'de', onSelect() {} });
await nextTick();
const code = document.querySelector('#corner-actions .locale-picker-code');
assert.equal(code?.textContent, 'DE');
store.publishPicker(null);
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(first.querySelector('.news-card__credit').textContent, 'A');
// 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.
panel.classList.add('collapsed');
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', () => {
const { news, panel, calls } = fixture();
news.update(view());
assert.equal(panel.querySelector('[aria-label="Local"]'), null);
assert.equal(panel.querySelector('.news-toggle'), null);
assert.equal(panel.querySelector('[aria-label="Local"]') === null, true);
assert.equal(panel.querySelector('.news-toggle') === null, true);
const severity = panel.querySelector('select[aria-label="Severity"]');
assert.equal(severity.value, '2');
severity.value = '3';
@@ -214,7 +214,7 @@ test('filters, the Local section and empty or failed reads', () => {
panel.textContent,
/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.
const localView = (inView) =>
@@ -225,9 +225,9 @@ test('filters, the Local section and empty or failed reads', () => {
news.update(localView({ active: false }));
const section = panel.querySelector('[aria-label="Local"]');
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('select[aria-label="Severity"]'), null);
assert.equal(panel.querySelector('[aria-label="Latest news"]'), null);
assert.equal(panel.querySelector('[data-chip-id="ua-ru-war"]') === null, true);
assert.equal(panel.querySelector('select[aria-label="Severity"]') === null, true);
assert.equal(panel.querySelector('[aria-label="Latest news"]') === null, true);
assert.equal(document.getElementById('news-panel-count').textContent, '0');
news.update(
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,
'via GDELT Project',
);
assert.equal(own.querySelector('.news-card__via'), null);
assert.equal(own.querySelector('.news-card__via') === null, true);
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', () => {
const { news, panel, calls } = fixture();
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"]');
assert.deepEqual(
[...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)] }));
const card = panel.querySelector('.news-card');
assert.equal(card.querySelector('.news-card__play').textContent, '▶');
assert.equal(card.querySelector('video'), null);
assert.equal(card.querySelector('video') === null, true);
news.update(
view({
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),
}),
);
assert.equal(panel.querySelector('.news-card.is-open video'), null);
assert.equal(panel.querySelector('.news-card.is-open video') === null, true);
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');
assert.equal(panel.tagName, 'SECTION');
assert.equal(panel.querySelector('.panel-glow'), null);
assert.equal(panel.querySelector('.panel-collapse-btn'), null);
assert.equal(panel.querySelector('.panel-glow') === null, true);
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) => {
+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.placeholder, 'paste GOOGLE_MAPS_API_KEY');
assert.equal(google.querySelector('.key-setup-remove'), null);
assert.equal(google.querySelector('.key-setup-cloud-auth'), null);
assert.equal(google.querySelector('.key-setup-remove') === null, true);
assert.equal(google.querySelector('.key-setup-cloud-auth') === null, true);
const openai = $('[data-key-id="openai"]');
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,
'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) => {
@@ -337,12 +337,12 @@ test('Cyber moves the chip into the top toolbar and other themes return it to th
await wait();
await nextTick();
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';
await wait();
await nextTick();
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;
});
+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();
assert.equal(ui.intervals.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,
/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']);
store.publishRenderFailure({
reason: 'context-lost',
@@ -82,7 +82,7 @@ test('a lost context offers Reload, and a panel that cannot reload offers nothin
});
await nextTick();
assert.deepEqual(buttons(), []);
assert.equal(notice().querySelector('.render-failure-hint'), null);
assert.equal(notice().querySelector('.render-failure-hint') === null, true);
});
const failure = () => ({
+24 -11
View File
@@ -96,13 +96,20 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
span: spanOf(state),
});
const time = shallowRef(read());
// The speed the world clock keeps, where it is one of those offered.
const speed = shallowRef(
Object.keys(WORLD_TIME_SPEEDS).find(
(key) => WORLD_TIME_SPEEDS[key] === worldClock.getRate(),
) ?? 'real',
);
const rate = () => WORLD_TIME_SPEEDS[speed.value];
// The speed shown follows the world clock's rate, whoever set it: a rate
// not among those offered shows as the nearest that is (by ratio), while
// play still runs at the clock's own rate.
const speedFor = (clockRate) => {
let best = 'real';
for (const key of Object.keys(WORLD_TIME_SPEEDS))
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;
const syncTicker = () => {
// Playing, the clock tells of the time itself.
@@ -117,6 +124,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
}
};
const unsubscribe = worldClock.subscribe((state) => {
if (speed.value !== speedFor(state.rate))
speed.value = speedFor(state.rate);
if (stopAtEnd(state)) return;
time.value = read(state);
syncTicker();
@@ -131,7 +140,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
return {
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
* the clock itself: weather follows the time frame by frame like every
* other layer the track covers.
@@ -142,7 +152,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
const covered = reach();
if (span && covered && (live || timeMs >= covered.end))
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,
/**
@@ -154,10 +165,12 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
setSpeed(next) {
if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return;
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();
if (!playing || !stepping) return worldClock.setRate(rate());
if (!playing || !stepping) return;
worldClock.pause();
worldClock.play({ rate: rate(), continuous: true });
worldClock.play({ continuous: true });
},
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']);
mountApplicationViews(document, { omit: ['provider-settings'] });
assert.deepEqual(hostNames(), ['welcome']);
assert.equal(document.getElementById('key-setup-chip'), null);
assert.equal(document.getElementById('key-setup-chip') === null, true);
assert.ok(
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'));
mountApplicationViews(document, { omit: ['provider-settings'] });
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', () => {
+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');
assert.equal(note.getAttribute('role'), 'status');
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]] };
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: true, text: '' }]])) });
await nextTick();
+48 -11
View File
@@ -19,6 +19,8 @@ function mount(t) {
// The store outlives the view; dispose it with its clock subscription.
useWorldTimeStore(pinia).$dispose();
worldClock.goLive();
// The clock keeps its rate; the next test starts at real time.
worldClock.setRate(1);
});
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 at = Date.UTC(2026, 9, 30, 6, 15, 42);
await nextTick();
assert.equal(bar(), null, 'live, nothing to play');
assert.equal(bar() === null, true, 'live, nothing to play');
worldClock.seek(at);
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);
await nextTick();
assert.ok(bar());
remove();
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) => {
@@ -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.
await new Promise((resolve) => setTimeout(resolve, 250));
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.
const until = async (shown) => {
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 }));
const tip = () => bar().querySelector('.world-timeline-mark-label');
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.
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.
press('pointermove', 690);
await nextTick();
assert.equal(tip(), null);
assert.equal(tip()?.textContent ?? null, null);
assert.equal(track.hasAttribute('title'), true);
press('pointermove', 2);
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);
track.dispatchEvent(new PointerEvent('pointerleave', { clientX: 1_100, pointerId: 1 }));
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.
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');
input.dispatchEvent(new Event('blur'));
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) => {
@@ -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 }));
await nextTick();
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.
pointer('pointermove', firstAt());
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 }),
);
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.
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();
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,
'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(
new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url),
'utf8',
@@ -343,11 +343,11 @@ test('missing, restricted, obscured and Acartia photos all use the same compact
const f = fixture(context, { ...row, ...extra });
await f.click();
const card = f.cardElement();
assert.equal(card.querySelector('img'), null);
assert.equal(card.querySelector('[role=status]'), null);
assert.equal(card.querySelector('img') === null, true);
assert.equal(card.querySelector('[role=status]') === null, true);
assert.equal(card.querySelector('h2').textContent, row.commonName);
assert.equal(card.querySelector('i').textContent, row.scientificName);
assert.equal(document.querySelector('dialog'), null);
assert.equal(document.querySelector('dialog') === null, true);
if (extra.obscured) {
assert.doesNotMatch(card.textContent, /ago|Photographer/);
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)',
});
await f.click();
assert.equal(f.cardElement().querySelector('a'), null);
assert.equal(f.cardElement().querySelector('a') === null, true);
assert.equal(
f.cardElement().querySelector('time').getAttribute('datetime'),
row.observedOn,