From 5d733eb07b6f1123b6d665ab2445b32eb658b620 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 14:29:28 -0800 Subject: [PATCH] Fix scrubs to live on long tracks, fire ages in a past window, and gaps between hourly cloud frames - World timeline: a scrub counts as at now within a share of the track (0.5%), now held to at most two minutes. A layer whose source covers years made the window weeks wide, so most of the last month snapped to live. Short tracks keep their window. - Fires: card and detail ages count back from the end of the 24 hours the layer shows when its snapshot ends at a set time, and from now only when it runs to now. A window that ended months ago read "new 105d" on fires three hours old at the time shown. - Weather: a product's max gap and the delay after which its latest frame reads late are at least one and a half of its feed's median frame spacing (over its latest frames). A regional cloud feed with frames an hour apart drew nothing for the second half of every hour while scrubbing and read late after twenty minutes; feeds with frames minutes apart keep their gaps. --- .../1736-timeline-scrub-ages-cloud-gaps.md | 9 +++ docs/CURRENT-STATE.md | 9 ++- src/layers/firms/cards.js | 3 +- src/layers/firms/model.js | 23 +++++-- src/layers/firms/selection.js | 4 +- src/layers/firms/windowLabel.test.mjs | 53 +++++++++++++++ src/layers/weather/clock.js | 46 +++++++++++++ src/layers/weather/clock.test.mjs | 64 +++++++++++++++++++ src/layers/weather/index.js | 32 ++++++++-- src/layers/weather/weather.test.mjs | 57 +++++++++++++++++ src/ui/stores/worldTime.js | 7 +- src/ui/views/panels/WorldTimeline.test.mjs | 35 ++++++++++ 12 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 changelog.d/1736-timeline-scrub-ages-cloud-gaps.md diff --git a/changelog.d/1736-timeline-scrub-ages-cloud-gaps.md b/changelog.d/1736-timeline-scrub-ages-cloud-gaps.md new file mode 100644 index 000000000..4c8deafe8 --- /dev/null +++ b/changelog.d/1736-timeline-scrub-ages-cloud-gaps.md @@ -0,0 +1,9 @@ +- Fix three time readings on the world timeline. A scrub near now goes live + only within two minutes of now, however long the track: with a layer whose + source covers years, any time in the last few weeks used to snap to live. + Fire cards and fire details count a detection's age from the end of the + 24 hours shown, and from now only when live, so a window that ended months + ago reads "new 3h" rather than "new 105d". Weather and satellite cloud + frames stay shown, and the latest reads late, for at least one and a half + of the feed's own frame spacings, so a feed whose frames come an hour apart + no longer goes blank for half of every hour (samehkhamis, #1736). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index de8d3617c..ef7ac8719 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3455,7 +3455,8 @@ reads `NASA FIRMS · LIVE · updated …`. A source may instead answer the 24 ho to a set time, naming its end as `at` (epoch ms): the row then reads `NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and `get_active_fires` says "in the 24 hours to" that time and reports it as -`window_end`. The dev proxy never sets `at`. +`window_end`, and card and detail ages count back from that time instead of +now. The dev proxy never sets `at`. The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line. @@ -8411,7 +8412,11 @@ clock drives all enabled observation rows. Earlier/Later use the sorted union of non-suspended products' advertised times; each product selects its nearest frame at or before the requested UTC time, never a future frame or an interpolation. The maximum gap is 30 minutes for radar, regional infrared and lightning, and -3 hours for hourly global infrared. A missing eligible frame hides that product +3 hours for hourly global infrared, but never less than one and a half of the +feed's median frame spacing over its latest frames, so a regional feed whose +frames come an hour apart holds each for 90 minutes; the latest frame reads late +after the same floor (20 minutes for radar and regional infrared, 45 for +lightning, 4 hours for global infrared). A missing eligible frame hides that product and reports the gap; rows show actual displayed UTC time with synced/nearest labels. Manifest rollover preserves the requested history time, even when frames expire. diff --git a/src/layers/firms/cards.js b/src/layers/firms/cards.js index c99d29e1c..07f26e5ca 100644 --- a/src/layers/firms/cards.js +++ b/src/layers/firms/cards.js @@ -31,7 +31,8 @@ export function createCards({ function rebuildAmbientLabels() { if (!layerState._viewer) return; const scene = layerState._viewer.scene; - const now = Date.now(); + // Ages count back from the time shown: the window's end, or now when live. + const now = components.model.ageReferenceMs(); const entries = []; /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ const accepted = []; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 00873c2b2..d4766190a 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -343,7 +343,7 @@ export function createModel({ * (on top) and never distance-faded by the overlay. * Exported for unit tests. * @param {Object} fire - Detection record. - * @param {number} nowMs - Current epoch milliseconds. + * @param {number} nowMs - Epoch milliseconds ages count back from. * @returns {Object} firmsLabels entry. */ @@ -384,9 +384,10 @@ export function createModel({ * Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail * "high · 14h · N20". Ages are computed against each detection's acquisition * time — with the live feed everything reads under 24 h (and a stale cache - * reads truthfully old). Exported for unit tests. + * reads truthfully old); `nowMs` is the time ages count back from, the end + * of the window shown (see {@link ageReferenceMs}). Exported for unit tests. * @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate. - * @param {number} nowMs - Current epoch milliseconds. + * @param {number} nowMs - Epoch milliseconds ages count back from. * @returns {Object} firmsLabels entry. */ @@ -418,7 +419,7 @@ export function createModel({ * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets * it with {@link cellCardAccent}). Exported for unit tests. * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate - * @param {number} nowMs - Current epoch milliseconds. + * @param {number} nowMs - Epoch milliseconds ages count back from. * @returns {Object} firmsLabels entry. */ @@ -549,6 +550,19 @@ export function createModel({ return { unit: 'd', count: Math.round(hours / 24) }; } + /** + * The time a card's ages count back from: the end of the 24 hours shown + * when the snapshot ends at a set time, now while it runs to now. A window + * that ended months ago then reads its fires as hours old, as they were. + * @param {number} [nowMs] - Current epoch milliseconds. + * @returns {number} + */ + + function ageReferenceMs(nowMs = Date.now()) { + const at = layerState?._at; + return Number.isFinite(at) ? at : nowMs; + } + /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ function formatAge(deltaMs) { @@ -624,6 +638,7 @@ export function createModel({ cellCardAccent, formatLatLon, formatFrp, + ageReferenceMs, formatAge, formatAgeLabel, formatAgoMinutes, diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index d5cfc4d26..96b866d55 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -202,7 +202,9 @@ export function createSelection({ confidence: components.model.confidenceBucket(fire.confidence), age: fire.acqMs > 0 - ? components.model.formatAge(Date.now() - fire.acqMs) + ? components.model.formatAge( + components.model.ageReferenceMs() - fire.acqMs, + ) : 'unknown', sensor: fire.sensor || 'unknown', }, diff --git a/src/layers/firms/windowLabel.test.mjs b/src/layers/firms/windowLabel.test.mjs index c668f8bb4..d8e6bfa81 100644 --- a/src/layers/firms/windowLabel.test.mjs +++ b/src/layers/firms/windowLabel.test.mjs @@ -1,8 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; import { getLocale, setLocale } from '../../i18n/index.js'; import { createModel } from './model.js'; import { createQueries } from './queries.js'; +import { createCards } from './cards.js'; test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => { const now = 1800000000000; @@ -42,3 +44,54 @@ test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (conte setLocale(previousLocale); } }); + +test('fire cards count ages from the end of the window shown, and from now when live', (context) => { + const now = Date.UTC(2026, 10, 14, 12); + context.mock.method(Date, 'now', () => now); + // A window that ended months before now: its newest fire three hours earlier. + const at = Date.UTC(2026, 7, 1, 12); + const layerState = { _at: at }; + const model = createModel({ layerState, services: { anchors: {} } }); + const cell = { + count: 4, + maxFrp: 210, + newestAcqMs: at - 3 * 3_600_000, + latCell: 1, + lonCell: 2, + }; + const previousLocale = getLocale(); + try { + setLocale('en'); + assert.equal(model.ageReferenceMs(), at); + const card = model.buildCellCard({ cell, position: {} }, model.ageReferenceMs()); + assert.equal(card.details[0], 'max 210 MW · new 3h', 'not months old'); + + // The cards the scene shows use the same time. + const entries = []; + context.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 400, y: 300 })); + layerState._viewer = { scene: { canvas: { clientWidth: 800, clientHeight: 600 } } }; + layerState._fireByCardId = new Map(); + layerState._selectedFire = null; + layerState._labelCandidates = [{ cell, position: {} }]; + layerState._labelLodDistance = 0; + const { rebuildAmbientLabels } = createCards({ + layerState, + services: {}, + components: { model, rendering: { fireHorizonOccluder: () => null } }, + config: { overlayHost: { setEntries: (source, shown) => entries.push(...shown) } }, + }); + rebuildAmbientLabels(); + assert.equal(entries.length, 1); + assert.equal(entries[0].details[0], 'max 210 MW · new 3h'); + + // Live, they count from now. + layerState._at = null; + cell.newestAcqMs = now - 5 * 3_600_000; + assert.equal(model.ageReferenceMs(), now); + entries.length = 0; + rebuildAmbientLabels(); + assert.equal(entries[0].details[0], 'max 210 MW · new 5h'); + } finally { + setLocale(previousLocale); + } +}); diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 7384935b0..8e539473e 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -4,6 +4,52 @@ export const RADAR_MAX_GAP_MS = 30 * 60_000; export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000; export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; +/** How many of a feed's latest frames say how far apart its frames come. */ +const SPACING_FRAMES = 12; +/** A frame holds this many of its feed's frame spacings before it lapses. */ +const SPACING_SLACK = 1.5; + +/** + * How far apart a feed's frames come: the median spacing of its latest + * frames, in ms, or null with fewer than two. Takes UTC times or epoch ms. + * @param {Array} times + * @returns {number|null} + */ +export function frameSpacingMs(times) { + const at = [ + ...new Set( + (times ?? []) + .map((time) => (typeof time === 'number' ? time : Date.parse(time))) + .filter(Number.isFinite), + ), + ] + .sort((a, b) => a - b) + .slice(-(SPACING_FRAMES + 1)); + if (at.length < 2) return null; + const gaps = at + .slice(1) + .map((time, i) => time - at[i]) + .sort((a, b) => a - b); + const middle = gaps.length >> 1; + return gaps.length % 2 ? gaps[middle] : (gaps[middle - 1] + gaps[middle]) / 2; +} + +/** + * A product's least time, `leastMs`, held to its feed's own frame spacing: + * never less than one and a half spacings, so a feed whose frames come an + * hour apart covers the whole hour between them, while one with frames + * minutes apart keeps `leastMs`. + * @param {number} leastMs + * @param {Array} times + * @returns {number} + */ +export function atLeastFrameSpacing(leastMs, times) { + const spacing = frameSpacingMs(times); + return spacing === null + ? leastMs + : Math.max(leastMs, spacing * SPACING_SLACK); +} + /** The longest play waits on a frame before it goes on without it. */ export const PLAY_WAIT_MS = 8_000; // Playing on its own, the world does not wait on the frame it is at: that diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index dd2535ffc..90cd1bd6b 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -7,6 +7,8 @@ import { GLOBAL_INFRARED_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS, PLAY_WAIT_MS, + frameSpacingMs, + atLeastFrameSpacing, } from './clock.js'; import { createWorldClock } from '../../time/worldClock.js'; @@ -94,6 +96,68 @@ test('union excludes suspended products; nearest selection never uses future or assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000); }); +test('a feed’s frame spacing is the median of its latest frames', () => { + assert.equal(frameSpacingMs([]), null); + assert.equal(frameSpacingMs([time(0)]), null); + assert.equal(frameSpacingMs([0, 10, 20, 30].map(time)), 10 * 60_000); + // Out of order, repeated, unparseable, and one late frame among many. + assert.equal( + frameSpacingMs([ + time(60), + time(0), + 'never', + time(0), + time(10), + time(20), + time(30), + ]), + 10 * 60_000, + ); + assert.equal(frameSpacingMs([0, 60, 120].map(time)), 60 * 60_000); + assert.equal(frameSpacingMs([0, 10, 70, 130]), 60); + // Only the latest frames count: an hourly start gives way to ten minutes. + const times = [ + ...[-600, -540, -480, -420, -360, -300, -240, -180, -120, -60], + ...[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120], + ].map(time); + assert.equal(frameSpacingMs(times), 10 * 60_000); +}); + +test('a product’s gap is at least one and a half of its feed’s frame spacings', (t) => { + const tenMinutes = [0, 10, 20, 30].map(time); + const hourly = [0, 60, 120].map(time); + assert.equal( + atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, tenMinutes), + REGIONAL_INFRARED_MAX_GAP_MS, + 'frames minutes apart keep the product’s own gap', + ); + assert.equal( + atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, hourly), + 90 * 60_000, + ); + assert.equal( + atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, [time(0)]), + REGIONAL_INFRARED_MAX_GAP_MS, + ); + assert.equal( + atLeastFrameSpacing(GLOBAL_INFRARED_MAX_GAP_MS, hourly), + GLOBAL_INFRARED_MAX_GAP_MS, + ); + + // Selected against it, an hourly feed has a frame all through each hour. + const { clock } = harness(t); + const clouds = product( + 'clouds', + [0, 60, 120], + atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, hourly), + ); + clock.register(clouds); + assert.equal(clock.selectFor('clouds', time(45)), time(0)); + assert.equal(clock.selectFor('clouds', time(119)), time(60)); + assert.equal(clock.selectFor('clouds', time(210)), time(120)); + assert.equal(clock.selectFor('clouds', time(211)), null); +}); + test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => { const { clock } = harness(t); const radar = product('radar', [0, 10, 20]); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index cab2d2b98..9f159f3ea 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -8,6 +8,7 @@ import { REGIONAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS, + atLeastFrameSpacing, } from './clock.js'; import { formatUtcDayMonth, t, msg } from '../../i18n/index.js'; import { formatScaleLabel } from '../../ui/unitLabels.js'; @@ -96,7 +97,10 @@ export function createWeatherLayer({ let unregisterClock = null; let frameRequest = null; let noFrame = false; - const maxGap = () => + // A product's own gap, and the delay after which its latest frame reads + // late. A feed whose frames come further apart holds each frame, and waits + // for the next, as long as its frames are apart (see atLeastFrameSpacing). + const productGap = () => radar ? RADAR_MAX_GAP_MS : lightning @@ -104,6 +108,23 @@ export function createWeatherLayer({ : product === 'clouds' ? GLOBAL_INFRARED_MAX_GAP_MS : REGIONAL_INFRARED_MAX_GAP_MS; + const productDelay = () => + (product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000; + // Read for every frame a selection weighs, so worked out once per product + // and frame list. + let spaced = null; + const spacedTimes = () => { + const times = manifest?.times ?? []; + if (spaced?.times !== times || spaced?.product !== product) + spaced = { + times, + product, + gap: atLeastFrameSpacing(productGap(), times), + delay: atLeastFrameSpacing(productDelay(), times), + }; + return spaced; + }; + const maxGap = () => spacedTimes().gap; const isLatest = () => clock ? clock.getState().mode === 'latest' : followLatest; const getHost = () => @@ -117,8 +138,7 @@ export function createWeatherLayer({ }); const observationDelayed = () => manifest?.latest && - Date.now() - Date.parse(manifest.latest) > - (product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000; + Date.now() - Date.parse(manifest.latest) > spacedTimes().delay; const stop = () => { if (clock) return; playing = false; @@ -544,11 +564,11 @@ export function createWeatherLayer({ shared.products.find((entry) => entry.id === id)?.selected === null ? maxGap() / 60_000 < 60 ? t('No frame within {gap} min of {time}', { - gap: maxGap() / 60_000, + gap: Math.round(maxGap() / 60_000), time: utc(shared.target), }) : t('No frame within {gap} h of {time}', { - gap: maxGap() / 3600_000, + gap: Math.round(maxGap() / 360_000) / 10, time: utc(shared.target), }) : null; @@ -688,7 +708,7 @@ export function createWeatherLayer({ ? t('Global · 60°S–60°N') : t('North America')), shownTime: time, - maxGapMinutes: maxGap() / 60_000, + maxGapMinutes: Math.round(maxGap() / 60_000), detail, status: missing || diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index b86219ae4..aad1d0973 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1949,6 +1949,63 @@ test('satellite global selection uses the three-hour gap and preserves the share assert.equal(params.target, undefined); }); +test('satellite clouds from an hourly feed draw all through each hour and read late only past it', async (t) => { + const clock = createWeatherClock(); + const now = Date.now(); + const hour = 3_600_000; + const top = Math.floor(now / hour) * hour; + // Frames an hour apart, the latest forty minutes old. + const hourly = [3, 2, 1].map((back) => + new Date(top - back * hour + 20 * 60_000).toISOString(), + ); + const latest = hourly.at(-1); + const h = layerHarness({ + clock, + id: 'weather-satellite', + feed: { + getSnapshot: async ({ product }) => ({ + ...snapshot, + product, + times: hourly, + latest, + }), + }, + }); + t.after(() => { + h.layer.destroy(); + clock.destroy(); + }); + t.mock.method(Date, 'now', () => Date.parse(latest) + 40 * 60_000); + const update = h.layer.update(); + await flush(); + h.stages.at(-1).finish(); + await update; + assert.equal(h.layer.getParams().product, 'clouds-regional'); + assert.equal(h.layer.getRowControls().summary.maxGapMinutes, 90); + assert.equal( + h.layer.getStats().stale, + false, + 'forty minutes is within the hour', + ); + + // Forty-five minutes past a frame, between it and the next, it still shows. + const between = clock.setTarget( + new Date(Date.parse(hourly[0]) + 45 * 60_000).toISOString(), + ); + await flush(); + h.stages.at(-1).finish(); + await between; + assert.equal(h.layer.getDiagnostics().hidden, false); + assert.equal(h.stages.at(-1).time, hourly[0]); + + t.mock.method(Date, 'now', () => Date.parse(latest) + 100 * 60_000); + assert.equal( + h.layer.getStats().stale, + true, + 'past one and a half hours, late', + ); +}); + test('switching to history cancels a still-loading latest refresh and a later target cancels the first history load', async (t) => { const clock = createWeatherClock(); const h = layerHarness({ clock }); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 80f51ac71..9dcb7d4c8 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -8,6 +8,11 @@ const LIVE_TICK_MS = 1000; const LIVE_EDGE = 0.005; /** The least that counts as at now, past the readout's own second. */ const LIVE_EDGE_MS = 5000; +/** + * The most that counts as at now, however long the track: on a track that + * runs over years, a share of it would take whole days back to live. + */ +const LIVE_EDGE_MAX_MS = 2 * 60_000; /** The speeds play offers, in world ms per real ms. */ export const WORLD_TIME_SPEEDS = Object.freeze({ real: 1, @@ -51,7 +56,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { }; const nearNow = (ms, length) => Math.abs(ms - worldClock.wallNow()) <= - Math.max(LIVE_EDGE_MS, length * LIVE_EDGE); + Math.max(LIVE_EDGE_MS, Math.min(LIVE_EDGE_MAX_MS, length * LIVE_EDGE)); // Whether the track ends at now (and moves on with it), not past it. const endsAtNow = (covered) => covered.end - worldClock.wallNow() <= LIVE_EDGE_MS; diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 073d6f95e..7785e86c1 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -149,6 +149,41 @@ test('the track runs over the time the layers show, to now, and scrubs', async ( assert.equal(worldClock.now(), ahead); }); +test('on a track that runs over years, only the last minutes are near now', async (t) => { + const bar = mount(t); + const now = Date.now(); + // A layer whose source covers fourteen years, up to now. + const start = now - 14 * 365 * 86_400_000; + const release = worldClock.hold('test-years'); + const unregister = worldClock.register({ + id: 'test-years', + coverage: () => ({ start, end: now }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const input = bar().querySelector('input[type=range]'); + + // A day back is a day back, though it is well within a share of the track. + input.value = String(now - 86_400_000); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), false, 'a day back stays off live'); + assert.equal(worldClock.now(), now - 86_400_000); + + input.value = String(now - 10 * 60_000); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), false, 'ten minutes back stays off live'); + + input.value = String(Date.now() - 60_000); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), true, 'a minute back goes live'); +}); + test('play runs in real time, from the start of the track when at live', async (t) => { const bar = mount(t); const now = Date.now();