mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
* feat(share): add compact versioned share-view codec Add a standalone codec for `#s=<base64url>` share links: a version byte followed by protobuf-style tag-length-value fields. Settings equal to their default are omitted and refilled from the same table on decode; unknown fields are skipped by wire type, so new fields never break older apps. Views are a place (camera) or a thing (an aircraft, military aircraft, vessel or satellite to follow, with camera offset and last-known fix); an unknown kind falls back to the camera. The decoder bounds its input and rejects malformed payloads with a reason instead of throwing. A converter maps the long form's parsed state to and from the codec state; the long form is unchanged. * fix(share): tighten codec decoding after review Unknown detection mode and allocation names now fall back to their defaults instead of reaching the lenient long-form migration. Future panel values and style-parameter tokens are skipped rather than applied or rejected, and layer options are accepted only under their owner layer. Wrap longitudes before scaling, normalize negative zero, reuse the layer-state tracking ID grammar, and align the format doc with the decoder. * feat(share): let a host share the link its own way ShareLinkManager.buildLink() returns the current share link without copying it, and copyLink() now copies exactly that. The share button passes the link to an optional services.shareLink(url), which resolves to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the service the button copies as before. * feat(share): open and make short /share/<code> links The share button now makes `<app>/share/<code>` links with the compact share-view code, and the app opens them at start-up through the same restore pipeline as long links. Once the app next writes the address, the share path is replaced (no new history entry) by the app's own address and the live long-form hash, so a reload follows the live view. A host can pass the code as `initialShareCode` instead. A code that fails to decode restores nothing and shows "This share link can't be opened". The code gains a trailing CRC-8 check byte, so any single mistyped character is rejected instead of opening a different view. Thing views no longer store a camera: it is derived from the entity's last-known fix and the follow offset on a sphere, the entity kind is implied by the id's field, and the followed entity's layer bit is implied; an aircraft followed from the app's own camera is a 42-character code. The `#s=` hash form is gone. While the camera follows an aircraft, military aircraft or satellite, the link is a thing view with the camera's offset and the entity's position. Opening one turns the layer on and follows the entity through the existing shared-follow restore, then swings the camera round to the shared offset; a vessel view selects the vessel once the vessel feed has it. If the entity is not reporting within its window, the view stays at its last-known fix and says so. The panel and style-parameter tables now come from the codec, and a host share hook that returns a plain value or throws still gets a toast. * fix(share): encode style settings against the style's own look Settings a style applies when selected (bloom, sharpen, HUD, detection, celestial ring and the style's shader parameters) now default, per field, to what that style applies in a fresh session, read from the tables the app applies (the first-load baseline, the style presets and the shader defaults). The encoder compares against the view's style and the decoder fills omitted settings from the decoded style, so a view that keeps its style's look costs only its camera and style. The previous defaults did not match a fresh session (sharpen, HUD and detection were off), so every link paid for them, and a style's own shader parameters were always written. A test pins the per-style defaults as frozen v1 meanings. A followed aircraft in Noir is now 44 characters and the real long-link example 39. * docs: changelog for #1418
750 lines
37 KiB
JavaScript
750 lines
37 KiB
JavaScript
import { ShareRestoration } from './ui/shareRestoration.js';
|
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
|
import { expandApplicationHtml } from '../build/application-html.js';
|
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
const retrySite = (stats = {}) => ({ id: 'military-installations', name: 'Mapped Installations', enabled: true,
|
|
stats: { status: 'unavailable', error: 'Unavailable', retryAt: Date.now() + 30000, ...stats } });
|
|
|
|
test('installation retry remains visible after failure dwell without a false spinner', () => {
|
|
const summary = aggregateLayerLoading([retrySite()]);
|
|
const view = presentLoadingFeedback(createLoadingFeedbackState(), summary, 100);
|
|
assert.equal(view.state, 'retry');
|
|
assert.equal(view.label, 'OVERPASS TEMPORARILY UNAVAILABLE');
|
|
assert.match(view.detail, /retrying in 30s/);
|
|
assert.equal(presentLoadingFeedback(createLoadingFeedbackState(), aggregateLayerLoading([{ ...retrySite(), enabled: false }]), 100), null);
|
|
});
|
|
test('an installation retry never conceals another participant failure', () => {
|
|
const state = { visible: true, phase: 'terminal', terminal: 'error', activeIds: ['military-installations', 'flights'] };
|
|
const summary = aggregateLayerLoading([retrySite(), { id: 'flights', enabled: true, stats: { error: 'Failed' } }]);
|
|
assert.equal(presentLoadingFeedback(state, summary, 100).label, 'LOAD FAILED');
|
|
const healthyNow = aggregateLayerLoading([retrySite()]);
|
|
assert.equal(presentLoadingFeedback({ ...state, failedEventIds: ['flights'] }, healthyNow, 100).label, 'LOAD FAILED');
|
|
});
|
|
test('a fresh installation retry can finish successfully without inheriting the old error', () => {
|
|
let state = { ...createLoadingFeedbackState(), phase: 'terminal', terminal: 'error', visible: true, activeIds: ['military-installations'] };
|
|
const loading = aggregateLayerLoading([retrySite({ status: 'loading', error: null, loading: true, retryAt: 0, retrying: true })]);
|
|
state = reduceLoadingFeedback(state, loading, 1000);
|
|
state = reduceLoadingFeedback(state, loading, 1200);
|
|
assert.equal(presentLoadingFeedback(state, loading, 1200).label, 'RETRYING MAPPED SITES');
|
|
const done = aggregateLayerLoading([retrySite({ status: 'ready', error: null, loading: false, retryAt: 0, retrying: false, count: 3 })]);
|
|
state = reduceLoadingFeedback(state, done, 1500);
|
|
assert.equal(presentLoadingFeedback(state, done, 1500).label, 'MAPPED SITES LOADED');
|
|
});
|
|
test('turning off a retrying installation layer does not report the old fetch failure as a disable failure', () => {
|
|
const stopping = aggregateLayerLoading([{ ...retrySite(), lifecycleState: 'disabling' }]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), stopping, 1000);
|
|
state = reduceLoadingFeedback(state, stopping, 1200);
|
|
const off = aggregateLayerLoading([{ ...retrySite({ status: 'idle', error: null, retryAt: 0 }), enabled: false }]);
|
|
state = reduceLoadingFeedback(state, off, 1400);
|
|
assert.equal(presentLoadingFeedback(state, off, 1400).label, 'LIVE DATA OFF');
|
|
});
|
|
import {
|
|
aggregateLayerLoading,
|
|
canPresentDeferredStatusNotice,
|
|
createGlobalStatusNotice,
|
|
createLoadingFeedbackState,
|
|
createTrafficSyncFeedbackState,
|
|
LOADING_FAILURE_DWELL_MS,
|
|
normalizeLayerLoading,
|
|
presentGlobalLoadingStatus,
|
|
presentGlobalStatusNotice,
|
|
presentLoadingFeedback,
|
|
reduceLoadingFeedback,
|
|
reduceTrafficSyncFeedback,
|
|
TRAFFIC_SYNC_CONFIRM_MS,
|
|
} from './loadingFeedback.js';
|
|
|
|
test('universal status notices reuse the standard failure dwell', () => {
|
|
const notice = createGlobalStatusNotice('Shared satellite is unavailable', 1000);
|
|
assert.equal(notice.hideAt, null, 'finite dwell waits until first presentation');
|
|
assert.equal(presentGlobalStatusNotice(notice, 1001).label, 'Shared satellite is unavailable');
|
|
assert.equal(notice.hideAt, 1001 + LOADING_FAILURE_DWELL_MS);
|
|
assert.deepEqual(presentGlobalStatusNotice(notice, notice.hideAt - 1), {
|
|
state: 'error',
|
|
label: 'Shared satellite is unavailable',
|
|
detail: '',
|
|
});
|
|
assert.equal(presentGlobalStatusNotice(notice, notice.hideAt), null);
|
|
});
|
|
|
|
test('acquiring notices persist without a dwell until explicitly cleared', () => {
|
|
const notice = createGlobalStatusNotice('ACQUIRING', 1000, {
|
|
state: 'acquiring',
|
|
detail: 'SHARED FLIGHT',
|
|
persistent: true,
|
|
});
|
|
assert.equal(notice.hideAt, null);
|
|
assert.deepEqual(presentGlobalStatusNotice(notice, 1_000_000), {
|
|
state: 'acquiring',
|
|
label: 'ACQUIRING',
|
|
detail: 'SHARED FLIGHT',
|
|
});
|
|
});
|
|
|
|
test('deferred terminal notices lose ownership to newer acquisition epochs and disposal', () => {
|
|
assert.equal(canPresentDeferredStatusNotice(4, 4, false), true);
|
|
assert.equal(canPresentDeferredStatusNotice(4, 5, false), false,
|
|
'a newer ACQUIRING epoch blocks the older deferred failure');
|
|
assert.equal(canPresentDeferredStatusNotice(5, 5, true), false,
|
|
'disposal blocks even the current deferred notice');
|
|
});
|
|
|
|
test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
|
|
const handler = shellMethod('_handleShareTrackingRestoreStatus').toString();
|
|
const present = shellMethod('_presentAfterStartupCover').toString();
|
|
assert.match(
|
|
handler,
|
|
/this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/,
|
|
);
|
|
assert.match(present, /this\.showStatus\(message\)/);
|
|
assert.match(present, /this\._lifetime\.frame\(\(\) => \{/);
|
|
assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
|
|
assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/);
|
|
assert.match(present, /canPresentDeferredStatusNotice\(/);
|
|
for (const source of [handler, present]) {
|
|
assert.doesNotMatch(source, /this\._showToast\(message\)/);
|
|
assert.doesNotMatch(source, /pushCockpitSignal/);
|
|
}
|
|
assert.match(handler, /result\.classification === 'pending'/);
|
|
assert.match(handler, /state: 'acquiring'/);
|
|
assert.match(handler, /persistent: true/);
|
|
assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/);
|
|
assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/);
|
|
assert.match(handler, /result\.classification === 'followed'/);
|
|
assert.match(handler, /result\.classification === 'cancelled'\) return/);
|
|
});
|
|
|
|
test('universal notice masks active loading only for its own fixed dwell', () => {
|
|
const summary = aggregateLayerLoading([{ id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }]);
|
|
let loading = reduceLoadingFeedback(createLoadingFeedbackState(), summary, 0);
|
|
loading = reduceLoadingFeedback(loading, summary, 200);
|
|
const notice = createGlobalStatusNotice('Shared satellite is unavailable', 300);
|
|
|
|
assert.equal(presentGlobalLoadingStatus(notice, loading, summary, 301).label, 'Shared satellite is unavailable');
|
|
assert.equal(presentGlobalLoadingStatus(notice, loading, summary, notice.hideAt).label, 'LOADING LIVE DATA');
|
|
});
|
|
|
|
test('terminal failure preempts a finite notice, whose full dwell starts afterward', () => {
|
|
const active = aggregateLayerLoading([{ id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }]);
|
|
let loading = reduceLoadingFeedback(createLoadingFeedbackState(), active, 0);
|
|
loading = reduceLoadingFeedback(loading, active, 200);
|
|
loading = reduceLoadingFeedback(loading, aggregateLayerLoading([]), 300, {
|
|
type: 'visibility-failed', layerId: 'satellites', error: new Error('offline'),
|
|
});
|
|
const notice = createGlobalStatusNotice('Shared satellite is unavailable', 400);
|
|
|
|
assert.equal(
|
|
presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 401).label,
|
|
'LOAD FAILED',
|
|
);
|
|
assert.equal(notice.hideAt, null, 'masked finite notice has not started its dwell');
|
|
loading = reduceLoadingFeedback(loading, aggregateLayerLoading([]), 5300);
|
|
assert.equal(
|
|
presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 5300).label,
|
|
'Shared satellite is unavailable',
|
|
);
|
|
assert.equal(notice.hideAt, 5300 + LOADING_FAILURE_DWELL_MS);
|
|
assert.equal(
|
|
presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), notice.hideAt - 1).label,
|
|
'Shared satellite is unavailable',
|
|
);
|
|
assert.equal(presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), notice.hideAt), null);
|
|
});
|
|
|
|
test('persistent acquisition never hides an unrelated manager failure', () => {
|
|
const active = aggregateLayerLoading([{
|
|
id: 'traffic', name: 'Street Traffic', lifecycleState: 'enabling',
|
|
}]);
|
|
const idle = aggregateLayerLoading([]);
|
|
let loading = reduceLoadingFeedback(createLoadingFeedbackState(), active, 0);
|
|
loading = reduceLoadingFeedback(loading, active, 200);
|
|
loading = reduceLoadingFeedback(loading, idle, 300, {
|
|
type: 'visibility-failed', layerId: 'traffic', error: new Error('offline'),
|
|
});
|
|
const acquiring = createGlobalStatusNotice('ACQUIRING', 100, {
|
|
state: 'acquiring',
|
|
detail: 'SHARED FLIGHT',
|
|
persistent: true,
|
|
});
|
|
|
|
assert.deepEqual(presentGlobalLoadingStatus(acquiring, loading, idle, 301), {
|
|
state: 'error',
|
|
label: 'LOAD FAILED',
|
|
detail: '',
|
|
});
|
|
assert.equal(
|
|
presentGlobalLoadingStatus(acquiring, loading, idle, 300 + LOADING_FAILURE_DWELL_MS - 1).label,
|
|
'LOAD FAILED',
|
|
);
|
|
loading = reduceLoadingFeedback(loading, idle, 300 + LOADING_FAILURE_DWELL_MS);
|
|
assert.equal(
|
|
presentGlobalLoadingStatus(acquiring, loading, idle, 300 + LOADING_FAILURE_DWELL_MS).label,
|
|
'ACQUIRING',
|
|
'the still-owned acquisition resumes only after the full failure dwell is visible',
|
|
);
|
|
});
|
|
|
|
test('replacement, repetition, and hidden-tab elapsed time use the newest fixed deadline', () => {
|
|
const first = createGlobalStatusNotice('Shared satellite is unavailable', 100);
|
|
const repeated = createGlobalStatusNotice('Shared satellite is unavailable', 200);
|
|
const replacement = createGlobalStatusNotice('Shared satellite follow expired', 300);
|
|
|
|
presentGlobalStatusNotice(first, 100);
|
|
presentGlobalStatusNotice(repeated, 200);
|
|
presentGlobalStatusNotice(replacement, 300);
|
|
|
|
assert.ok(repeated.hideAt > first.hideAt, 'a repeated event is a new accessible notice epoch');
|
|
assert.equal(presentGlobalStatusNotice(replacement, replacement.hideAt - 1).label, 'Shared satellite follow expired');
|
|
assert.equal(presentGlobalStatusNotice(replacement, replacement.hideAt), null,
|
|
'elapsed wall time while hidden expires the notice instead of replaying it');
|
|
});
|
|
|
|
test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => {
|
|
const ui = readShellSource();
|
|
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
|
const disposeStart = ui.indexOf(' async dispose() {');
|
|
const disposeEnd = ui.indexOf('\n }\n', disposeStart);
|
|
const dispose = ui.slice(disposeStart, disposeEnd);
|
|
|
|
assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/);
|
|
assert.match(dispose, /this\._shareRestoration\.destroy\(\)/);
|
|
assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/);
|
|
assert.match(html, /<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>/);
|
|
});
|
|
|
|
test('normalizes lifecycle and refresh loading without owning manager state', () => {
|
|
assert.equal(normalizeLayerLoading({ lifecycleState: 'enabling' }).loading, true);
|
|
assert.equal(normalizeLayerLoading({ lifecycleState: 'disabling' }).disabling, true);
|
|
assert.equal(normalizeLayerLoading({ enabled: true, stats: { loading: true, count: 8 } }).refresh, true);
|
|
assert.equal(normalizeLayerLoading({ enabled: true, stats: { refreshing: true } }).refresh, true);
|
|
assert.match(
|
|
normalizeLayerLoading({ stats: { managerRefreshError: 'refresh failed' } }).error,
|
|
/refresh failed/,
|
|
);
|
|
});
|
|
|
|
test('delays initial loading so instant operations never flash', () => {
|
|
const summary = aggregateLayerLoading([{ id: 'a', name: 'A', lifecycleState: 'enabling' }]);
|
|
const pending = reduceLoadingFeedback(createLoadingFeedbackState(), summary, 100);
|
|
assert.equal(pending.visible, false);
|
|
const finished = reduceLoadingFeedback(pending, aggregateLayerLoading([]), 150);
|
|
assert.equal(presentLoadingFeedback(finished, aggregateLayerLoading([]), 150), null);
|
|
});
|
|
|
|
test('reveals sustained loading and then a bounded completion state', () => {
|
|
const summary = aggregateLayerLoading([{ id: 'a', name: 'A', lifecycleState: 'enabling' }]);
|
|
const pending = reduceLoadingFeedback(createLoadingFeedbackState(), summary, 100);
|
|
const visible = reduceLoadingFeedback(pending, summary, 300);
|
|
assert.equal(presentLoadingFeedback(visible, summary, 300).label, 'LOADING LIVE DATA');
|
|
const complete = reduceLoadingFeedback(visible, aggregateLayerLoading([]), 350);
|
|
assert.equal(presentLoadingFeedback(complete, aggregateLayerLoading([]), 350).label, 'LOAD COMPLETE');
|
|
});
|
|
|
|
test('terminal loading feedback centers its label without an empty detail slot', () => {
|
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
|
assert.match(
|
|
css,
|
|
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/,
|
|
);
|
|
assert.match(
|
|
css,
|
|
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/,
|
|
);
|
|
});
|
|
|
|
test('distinguishes accepted-data refresh from initial loading', () => {
|
|
const summary = aggregateLayerLoading([{
|
|
id: 'a', name: 'A', enabled: true, lifecycleState: 'enabled', stats: { loading: true, count: 4 },
|
|
}]);
|
|
assert.equal(summary.refresh, true);
|
|
});
|
|
|
|
test('surfaces cancellation and failure terminal states', () => {
|
|
const summary = aggregateLayerLoading([{ id: 'a', lifecycleState: 'enabling' }]);
|
|
const pending = reduceLoadingFeedback(createLoadingFeedbackState(), summary, 0);
|
|
const visible = reduceLoadingFeedback(pending, summary, 200);
|
|
assert.equal(reduceLoadingFeedback(visible, aggregateLayerLoading([]), 250, {
|
|
type: 'visibility-cancelled', layerId: 'a', cancelled: true,
|
|
}).terminal, 'cancelled');
|
|
assert.equal(reduceLoadingFeedback(visible, aggregateLayerLoading([]), 250, {
|
|
type: 'visibility-failed', layerId: 'a', error: new Error('no'),
|
|
}).terminal, 'error');
|
|
});
|
|
|
|
test('surfaces manager-owned refresh failure and recovery through the shared banner', () => {
|
|
const refreshing = aggregateLayerLoading([{
|
|
id: 'satellites',
|
|
name: 'Satellites',
|
|
enabled: true,
|
|
lifecycleState: 'enabled',
|
|
stats: { refreshing: true, count: 0, lastUpdate: null },
|
|
}]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), refreshing, 0, {
|
|
type: 'refresh-transition', layerId: 'satellites', refreshEpoch: 1,
|
|
});
|
|
state = reduceLoadingFeedback(state, refreshing, 200);
|
|
assert.equal(presentLoadingFeedback(state, refreshing, 200).label, 'REFRESHING LIVE DATA');
|
|
state = reduceLoadingFeedback(state, aggregateLayerLoading([]), 250, {
|
|
type: 'refresh-failed', layerId: 'satellites', error: new Error('offline'), refreshEpoch: 1,
|
|
});
|
|
assert.equal(presentLoadingFeedback(state, aggregateLayerLoading([]), 250).label, 'LOAD FAILED');
|
|
|
|
const recovered = reduceLoadingFeedback(state, refreshing, 6000, {
|
|
type: 'refresh-transition', layerId: 'satellites', refreshEpoch: 2,
|
|
});
|
|
const complete = reduceLoadingFeedback(recovered, aggregateLayerLoading([]), 6200, {
|
|
type: 'refresh', layerId: 'satellites', refreshEpoch: 2,
|
|
});
|
|
assert.equal(complete.terminal, 'complete');
|
|
});
|
|
|
|
test('AIS first-connect grace expiry reports failure even without a terminal manager event', () => {
|
|
const waiting = aggregateLayerLoading([{
|
|
id: 'ais-live-vessels',
|
|
name: 'AIS Vessels',
|
|
enabled: true,
|
|
lifecycleState: 'enabled',
|
|
stats: { loading: true, count: 0, lastUpdate: null },
|
|
}]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), waiting, 0);
|
|
state = reduceLoadingFeedback(state, waiting, 200);
|
|
|
|
const unavailable = aggregateLayerLoading([{
|
|
id: 'ais-live-vessels',
|
|
name: 'AIS Vessels',
|
|
enabled: true,
|
|
lifecycleState: 'enabled',
|
|
stats: {
|
|
loading: false,
|
|
count: 0,
|
|
lastUpdate: null,
|
|
status: 'unavailable',
|
|
error: 'awaiting first AIS message…',
|
|
},
|
|
}]);
|
|
state = reduceLoadingFeedback(state, unavailable, 300);
|
|
|
|
assert.equal(state.terminal, 'error');
|
|
assert.equal(presentLoadingFeedback(state, unavailable, 300).label, 'LOAD FAILED');
|
|
});
|
|
|
|
test('participant stats failure outranks a simultaneous visibility completion', () => {
|
|
const enabling = aggregateLayerLoading([{
|
|
id: 'ais-live-vessels',
|
|
name: 'AIS Vessels',
|
|
lifecycleState: 'enabling',
|
|
stats: { loading: true },
|
|
}]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), enabling, 0);
|
|
state = reduceLoadingFeedback(state, enabling, 200);
|
|
|
|
const missingKey = aggregateLayerLoading([{
|
|
id: 'ais-live-vessels',
|
|
name: 'AIS Vessels',
|
|
enabled: true,
|
|
lifecycleState: 'enabled',
|
|
stats: { loading: false, keyRequired: true },
|
|
}]);
|
|
state = reduceLoadingFeedback(state, missingKey, 250, {
|
|
type: 'visibility', layerId: 'ais-live-vessels', enabled: true,
|
|
});
|
|
|
|
assert.equal(state.terminal, 'error');
|
|
});
|
|
|
|
test('retains the worst terminal outcome until every concurrent load drains', () => {
|
|
const both = aggregateLayerLoading([
|
|
{ id: 'a', name: 'A', lifecycleState: 'enabling' },
|
|
{ id: 'b', name: 'B', lifecycleState: 'enabling' },
|
|
]);
|
|
const onlyB = aggregateLayerLoading([
|
|
{ id: 'b', name: 'B', lifecycleState: 'enabling' },
|
|
]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), both, 0, {
|
|
type: 'visibility-transition', layerId: 'a',
|
|
});
|
|
state = reduceLoadingFeedback(state, both, 200, {
|
|
type: 'visibility-transition', layerId: 'b',
|
|
});
|
|
state = reduceLoadingFeedback(state, onlyB, 250, {
|
|
type: 'visibility-failed', layerId: 'a', error: new Error('A failed'),
|
|
});
|
|
assert.deepEqual(state.activeIds, ['a', 'b']);
|
|
assert.equal(state.batchOutcome, 'error');
|
|
state = reduceLoadingFeedback(state, onlyB, 300);
|
|
state = reduceLoadingFeedback(state, aggregateLayerLoading([]), 350, {
|
|
type: 'visibility', layerId: 'b', enabled: true,
|
|
});
|
|
assert.equal(state.terminal, 'error');
|
|
assert.equal(presentLoadingFeedback(state, aggregateLayerLoading([]), 350).label, 'LOAD FAILED');
|
|
});
|
|
|
|
test('retains cancellation across overlapping success and resets it for a later epoch', () => {
|
|
const both = aggregateLayerLoading([
|
|
{ id: 'a', name: 'A', lifecycleState: 'enabling' },
|
|
{ id: 'b', name: 'B', lifecycleState: 'enabling' },
|
|
]);
|
|
const onlyB = aggregateLayerLoading([{ id: 'b', name: 'B', lifecycleState: 'enabling' }]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), both, 0);
|
|
state = reduceLoadingFeedback(state, onlyB, 200, {
|
|
type: 'visibility-cancelled', layerId: 'a', cancelled: true,
|
|
});
|
|
state = reduceLoadingFeedback(state, aggregateLayerLoading([]), 250, {
|
|
type: 'visibility', layerId: 'b', enabled: true,
|
|
});
|
|
assert.equal(state.terminal, 'cancelled');
|
|
|
|
const next = aggregateLayerLoading([{ id: 'c', name: 'C', lifecycleState: 'enabling' }]);
|
|
state = reduceLoadingFeedback(state, next, 300, {
|
|
type: 'visibility-transition', layerId: 'c',
|
|
});
|
|
assert.equal(state.batchOutcome, null);
|
|
assert.deepEqual(state.activeIds, ['c']);
|
|
state = reduceLoadingFeedback(state, aggregateLayerLoading([]), 500, {
|
|
type: 'visibility', layerId: 'c', enabled: true,
|
|
});
|
|
assert.equal(state.terminal, 'complete');
|
|
});
|
|
|
|
test('ignores terminal events from layers outside the active loading epoch', () => {
|
|
const active = aggregateLayerLoading([{ id: 'a', name: 'A', lifecycleState: 'enabling' }]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), active, 0);
|
|
state = reduceLoadingFeedback(state, active, 200, {
|
|
type: 'visibility-failed', layerId: 'unrelated', error: new Error('not this batch'),
|
|
});
|
|
assert.equal(state.batchOutcome, null);
|
|
state = reduceLoadingFeedback(state, aggregateLayerLoading([]), 250, {
|
|
type: 'visibility', layerId: 'a', enabled: true,
|
|
});
|
|
assert.equal(state.terminal, 'complete');
|
|
});
|
|
|
|
test('a final failure outranks an earlier success in the same loading epoch', () => {
|
|
const both = aggregateLayerLoading([
|
|
{ id: 'a', lifecycleState: 'enabling' },
|
|
{ id: 'b', lifecycleState: 'enabling' },
|
|
]);
|
|
const onlyB = aggregateLayerLoading([{ id: 'b', lifecycleState: 'enabling' }]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), both, 0);
|
|
state = reduceLoadingFeedback(state, onlyB, 200, {
|
|
type: 'visibility', layerId: 'a', enabled: true,
|
|
});
|
|
state = reduceLoadingFeedback(state, aggregateLayerLoading([]), 250, {
|
|
type: 'visibility-failed', layerId: 'b', error: new Error('B failed'),
|
|
});
|
|
assert.equal(state.terminal, 'error');
|
|
});
|
|
|
|
test('describes disable work without reporting it as a completed load', () => {
|
|
const summary = aggregateLayerLoading([{
|
|
id: 'a', name: 'A', enabled: true, lifecycleState: 'disabling',
|
|
}]);
|
|
const pending = reduceLoadingFeedback(createLoadingFeedbackState(), summary, 0);
|
|
const visible = reduceLoadingFeedback(pending, summary, 200);
|
|
assert.equal(presentLoadingFeedback(visible, summary, 200).label, 'TURNING OFF LIVE DATA');
|
|
const complete = reduceLoadingFeedback(visible, aggregateLayerLoading([]), 250);
|
|
assert.equal(presentLoadingFeedback(complete, aggregateLayerLoading([]), 250).label, 'LIVE DATA OFF');
|
|
});
|
|
|
|
test('a flow failure landing after the roads settle still ends the batch as LOAD FAILED', () => {
|
|
// Traffic's 250 ms paint race lets a TomTom request outlive the road load
|
|
// that started it. The layer keeps stats.loading true while it still owns
|
|
// that request, so the batch cannot close early and report LOAD COMPLETE
|
|
// over a failure that has not landed yet.
|
|
const sample = (stats) => aggregateLayerLoading([
|
|
{ id: 'traffic', name: 'Street Traffic', enabled: true, lifecycleState: 'enabled', stats },
|
|
]);
|
|
// Roads loading; flow request outstanding.
|
|
const roadsLoading = sample({ loading: true, count: 0, mode: 'live', error: null });
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), roadsLoading, 0);
|
|
state = reduceLoadingFeedback(state, roadsLoading, 200);
|
|
assert.equal(state.visible, true);
|
|
// Cached roads have painted, but the flow fetch has NOT settled: the layer
|
|
// still reports loading, so the batch stays open.
|
|
const flowStillPending = sample({ loading: true, count: 544, mode: 'live', error: null });
|
|
state = reduceLoadingFeedback(state, flowStillPending, 400);
|
|
assert.equal(state.phase, 'loading');
|
|
// Flow fails late.
|
|
const flowFailed = sample({
|
|
loading: false,
|
|
count: 544,
|
|
mode: 'live',
|
|
error: 'SIMULATED — TomTom daily budget reached',
|
|
});
|
|
state = reduceLoadingFeedback(state, flowFailed, 900);
|
|
assert.equal(state.terminal, 'error');
|
|
assert.equal(
|
|
presentLoadingFeedback(state, flowFailed, 900).label,
|
|
'LOAD FAILED',
|
|
'a late flow failure must not be announced as LOAD COMPLETE',
|
|
);
|
|
});
|
|
|
|
test('keeps cold idle traffic hidden even with a truthful zero-coverage label', () => {
|
|
let state = createTrafficSyncFeedbackState();
|
|
const sample = {
|
|
enabled: true,
|
|
stats: { loading: false, loadingLabel: 'LIVE · TomTom flow · 0% cov', flowCoveragePct: 0 },
|
|
};
|
|
for (const now of [0, 220, 440, 2200]) state = reduceTrafficSyncFeedback(state, sample, now);
|
|
assert.equal(state.visible, false);
|
|
assert.equal(state.busy, false);
|
|
});
|
|
|
|
test('shows traffic busy work and one fixed busy-to-idle confirmation', () => {
|
|
const busySample = {
|
|
enabled: true,
|
|
stats: { loading: true, loadingLabel: 'syncing LIVE traffic flow' },
|
|
};
|
|
const idleSample = {
|
|
enabled: true,
|
|
stats: { loading: false, loadingLabel: 'LIVE · TomTom flow · 0% cov' },
|
|
};
|
|
let state = reduceTrafficSyncFeedback(createTrafficSyncFeedbackState(), busySample, 100);
|
|
assert.deepEqual({ visible: state.visible, progress: state.progressText }, { visible: true, progress: '...' });
|
|
state = reduceTrafficSyncFeedback(state, busySample, 320);
|
|
state = reduceTrafficSyncFeedback(state, idleSample, 500);
|
|
const fixedDeadline = state.confirmationUntil;
|
|
assert.equal(fixedDeadline, 500 + TRAFFIC_SYNC_CONFIRM_MS);
|
|
assert.equal(state.progressText, '');
|
|
state = reduceTrafficSyncFeedback(state, idleSample, 900);
|
|
assert.equal(state.confirmationUntil, fixedDeadline);
|
|
state = reduceTrafficSyncFeedback(state, idleSample, fixedDeadline + 1);
|
|
assert.equal(state.visible, false);
|
|
});
|
|
|
|
test('the settled traffic chip shows exactly one percentage — the coverage it measured', () => {
|
|
// "LIVE · TomTom flow · 0% cov" beside a hard-coded "100%" read as a chip
|
|
// arguing with itself. The 100% was never a measurement: a settled chip is
|
|
// complete by definition, so the progress slot goes quiet and the label's
|
|
// coverage figure is the only number left.
|
|
const idleSample = {
|
|
enabled: true,
|
|
stats: { loading: false, loadingLabel: 'LIVE · TomTom flow · 0% cov' },
|
|
};
|
|
let state = reduceTrafficSyncFeedback(
|
|
createTrafficSyncFeedbackState(),
|
|
{ enabled: true, stats: { loading: true, loadingLabel: 'syncing LIVE traffic flow' } },
|
|
0,
|
|
);
|
|
state = reduceTrafficSyncFeedback(state, idleSample, 100);
|
|
assert.equal(state.visible, true);
|
|
assert.equal(state.label, 'LIVE · TomTom flow · 0% cov');
|
|
assert.equal(state.progressText, '');
|
|
const rendered = `${state.label} ${state.progressText}`.trim();
|
|
assert.equal(rendered.match(/\d+%/g).length, 1, 'the settled chip must carry one percentage');
|
|
assert.doesNotMatch(rendered, /100%/);
|
|
});
|
|
|
|
test('the chip renderer clears the progress slot instead of stranding the last value', () => {
|
|
const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8');
|
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
|
const start = ui.indexOf(' _updateTrafficSyncChip(');
|
|
assert.ok(start > 0, '_updateTrafficSyncChip is missing');
|
|
const body = ui.slice(start, ui.indexOf('\n }', start));
|
|
// A truthiness guard here would leave the busy "..." sitting beside the
|
|
// settled label, which is the contradiction wearing a different hat.
|
|
assert.doesNotMatch(body, /if \(presentation\.progressText\s*\n?\s*&&/);
|
|
assert.match(
|
|
body,
|
|
/if \(this\._trafficSyncProgress\.textContent !== presentation\.progressText\) \{/,
|
|
);
|
|
// …and the emptied slot must collapse rather than leave a min-width stub.
|
|
assert.match(css, /#traffic-sync-progress:empty \{\s*display: none;\s*\}/);
|
|
});
|
|
|
|
test('work still in flight keeps its progress number beside a label that has none', () => {
|
|
const state = reduceTrafficSyncFeedback(
|
|
createTrafficSyncFeedbackState(),
|
|
{ enabled: true, stats: { phaseLabel: 'warming roads', phaseProgressPct: 42 } },
|
|
0,
|
|
);
|
|
assert.deepEqual(
|
|
{ busy: state.busy, label: state.label, progress: state.progressText },
|
|
{ busy: true, label: 'warming roads', progress: '42%' },
|
|
);
|
|
assert.doesNotMatch(state.label, /%/, 'a busy label must not carry its own percentage');
|
|
});
|
|
|
|
test('resets traffic feedback on disable and permits a later fresh cycle', () => {
|
|
const busy = { enabled: true, stats: { loading: true } };
|
|
const idle = { enabled: true, stats: { loading: false, loadingLabel: 'simulated traffic' } };
|
|
let state = reduceTrafficSyncFeedback(createTrafficSyncFeedbackState(), busy, 0);
|
|
state = reduceTrafficSyncFeedback(state, idle, 100);
|
|
state = reduceTrafficSyncFeedback(state, { enabled: false, stats: {}, forceShow: true }, 200);
|
|
assert.deepEqual(state, createTrafficSyncFeedbackState());
|
|
state = reduceTrafficSyncFeedback(state, idle, 300);
|
|
assert.equal(state.visible, false);
|
|
state = reduceTrafficSyncFeedback(state, busy, 400);
|
|
state = reduceTrafficSyncFeedback(state, idle, 500);
|
|
assert.equal(state.visible, true);
|
|
});
|
|
|
|
test('new busy work replaces confirmation and force-show remains bounded', () => {
|
|
const idle = { enabled: true, stats: { loadingLabel: 'simulated traffic' } };
|
|
const busy = {
|
|
enabled: true,
|
|
stats: { phaseProgressPct: -20, prewarmQueueDepth: 1, phaseLabel: 'warming roads' },
|
|
};
|
|
let state = reduceTrafficSyncFeedback(createTrafficSyncFeedbackState(), idle, 0);
|
|
state = reduceTrafficSyncFeedback(state, { ...idle, forceShow: true }, 100);
|
|
const forcedDeadline = state.confirmationUntil;
|
|
state = reduceTrafficSyncFeedback(state, { ...idle, forceShow: true }, 300);
|
|
assert.equal(state.confirmationUntil, forcedDeadline);
|
|
state = reduceTrafficSyncFeedback(state, busy, 400);
|
|
assert.deepEqual({ busy: state.busy, progress: state.progressText }, { busy: true, progress: '0%' });
|
|
state = reduceTrafficSyncFeedback(state, idle, 500);
|
|
assert.equal(state.confirmationUntil, 500 + TRAFFIC_SYNC_CONFIRM_MS);
|
|
state = reduceTrafficSyncFeedback(state, idle, 500 + TRAFFIC_SYNC_CONFIRM_MS + 1);
|
|
assert.equal(state.visible, false);
|
|
});
|
|
|
|
test('aggregates Mapped Installations refresh beside CCTV without changing either owner', () => {
|
|
const summary = aggregateLayerLoading([
|
|
{
|
|
id: 'cctv', name: 'CCTV', enabled: true, lifecycleState: 'enabled',
|
|
stats: { count: 500, lastUpdate: 10, loading: true },
|
|
},
|
|
{
|
|
id: 'military-installations', name: 'Mapped Installations', enabled: true,
|
|
lifecycleState: 'enabled',
|
|
stats: { count: 4, lastUpdate: 20, loading: true },
|
|
},
|
|
]);
|
|
assert.deepEqual(summary.activeIds, ['cctv', 'military-installations']);
|
|
assert.equal(summary.refresh, true);
|
|
const pending = reduceLoadingFeedback(createLoadingFeedbackState(), summary, 0);
|
|
const visible = reduceLoadingFeedback(pending, summary, 200);
|
|
assert.deepEqual(
|
|
presentLoadingFeedback(visible, summary, 200),
|
|
{ state: 'refresh', label: 'REFRESHING LIVE DATA', detail: 'CCTV · Mapped Installations' },
|
|
);
|
|
});
|
|
|
|
// The reducer above is pure and fully covered; its DRIVER lives inside the
|
|
// StyleManager class (a full viewer to instantiate), so — as with the panel
|
|
// stack layout contract — the ticker's lifecycle is pinned against ui.js
|
|
// source. (perf rebase 2026-08-17)
|
|
test('the loading ticker never runs hidden and stops after loading and notices settle', () => {
|
|
const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8');
|
|
// Scope every assertion to _armLoadingFeedbackTicker's own body. The
|
|
// neighbouring _startTrafficChipTicker is a deliberately PERMANENT 500ms
|
|
// safety-net poll, so its `if (document.hidden) return;` is correct there
|
|
// and must not be confused with this self-stopping ticker's leak.
|
|
const armBody = ui.slice(ui.indexOf('_armLoadingFeedbackTicker() {'));
|
|
assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in the UI shell source');
|
|
const arm = armBody.slice(0, armBody.indexOf('\n }\n') + 5);
|
|
|
|
// 1. The ARM guard itself refuses while hidden. Previously the only hidden
|
|
// check sat INSIDE the interval body, so a batch that completed while
|
|
// hidden left the 60ms timer scheduled for the whole hidden period: the
|
|
// idle check that clears it was unreachable behind the hidden `return`.
|
|
assert.match(
|
|
arm,
|
|
/if \(this\._loadingFeedbackTicker \|\| document\.hidden\) return;/,
|
|
'the ticker must not arm while the document is hidden',
|
|
);
|
|
|
|
// 2. Going hidden STOPS the interval rather than idling inside it.
|
|
assert.match(
|
|
arm,
|
|
/if \(document\.hidden\) \{\s*this\._stopLoadingFeedbackTicker\(\);\s*return;\s*\}/,
|
|
'a hidden tick must clear the interval, not just skip the work',
|
|
);
|
|
assert.doesNotMatch(
|
|
arm,
|
|
/setInterval\(\(\) => \{\s*if \(document\.hidden\) return;/,
|
|
'the bare hidden `return` inside this interval is the leak — it must be gone',
|
|
);
|
|
|
|
// 3. Reaching idle stops it after any time-driven notice has completed.
|
|
// Persistent ACQUIRING notices remain visible without a 60ms timer.
|
|
assert.match(
|
|
arm,
|
|
/const noticeNeedsTicker = Number\.isFinite\(\s*this\._globalStatusNotice\?\.hideAt,?\s*\);[\s\S]*?if \(\s*this\._loadingFeedbackState\?\.phase === 'idle'\s*&&\s*!noticeNeedsTicker\s*\) \{\s*this\._stopLoadingFeedbackTicker\(\);\s*\}/,
|
|
'an idle phase with no expiring notice must stop the ticker',
|
|
);
|
|
assert.match(
|
|
ui,
|
|
/_stopLoadingFeedbackTicker\(\)\s*\{[\s\S]*?clearInterval\(this\._loadingFeedbackTicker\);[\s\S]*?this\._loadingFeedbackTicker = null;/,
|
|
'the shared stopper must actually clear and null the handle',
|
|
);
|
|
|
|
// 4. Returning to visible resamples, so a batch that finished while hidden
|
|
// is reconciled and a still-running one re-arms its ticker. The handler
|
|
// must live on the class that OWNS _updateGlobalLoadingFeedback
|
|
// (ShellFeedback) — wiring it into a neighbouring controller instead
|
|
// throws on every visibilitychange — and must be torn down with the rest.
|
|
assert.match(
|
|
ui,
|
|
/this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*else this\._stopLoadingFeedbackTicker\(\);\s*\};\s*document\.addEventListener\(\s*'visibilitychange',\s*this\._loadingVisibilityHandler,?\s*\);/,
|
|
'visibilitychange must resample the chip on return',
|
|
);
|
|
const styleManager = ui.slice(ui.indexOf('export class ShellFeedback'));
|
|
assert.ok(
|
|
styleManager.includes('this._loadingVisibilityHandler'),
|
|
'the resample handler must be owned by ShellFeedback, which defines _updateGlobalLoadingFeedback',
|
|
);
|
|
assert.match(
|
|
ui,
|
|
/document\.removeEventListener\(\s*'visibilitychange',\s*this\._loadingVisibilityHandler,?\s*\);/,
|
|
'the resample handler must be removed on teardown',
|
|
);
|
|
});
|
|
|
|
test('a guidance status such as zoom-in never counts as a participant failure', () => {
|
|
const zoomIn = { id: 'military-installations', name: 'Mapped Installations', enabled: true,
|
|
stats: { status: 'zoom-in', error: 'Zoom in to load mapped installation context', loading: true, count: 0 } };
|
|
const loading = aggregateLayerLoading([zoomIn]);
|
|
assert.equal(loading.records[0].error, null);
|
|
assert.equal(loading.records[0].degraded, false);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), loading, 1000);
|
|
state = reduceLoadingFeedback(state, loading, 1200);
|
|
const settled = aggregateLayerLoading([{ ...zoomIn, stats: { ...zoomIn.stats, loading: false } }]);
|
|
state = reduceLoadingFeedback(state, settled, 1500);
|
|
assert.equal(state.terminal, 'complete');
|
|
assert.equal(presentLoadingFeedback(state, settled, 1500).label, 'MAPPED SITES LOADED');
|
|
});
|
|
|
|
|
|
test('guidance does not suppress independent manager and feed failures', () => {
|
|
for (const field of ['lastError', 'managerRefreshError']) {
|
|
const record = normalizeLayerLoading({
|
|
id: 'militaryInstallations', enabled: true,
|
|
stats: { status: 'zoom-in', error: 'Zoom in to load mapped sites.', [field]: 'Network unavailable' },
|
|
});
|
|
assert.equal(record.error, 'Network unavailable');
|
|
}
|
|
});
|
|
|
|
test('ALPR retries show a countdown, preserve other failures, and clear when disabled', () => {
|
|
const camera = { ...retrySite({ error: 'Overpass rate-limited' }), id: 'alpr-cameras', name: 'ALPR cameras' };
|
|
const summary = aggregateLayerLoading([camera]);
|
|
const view = presentLoadingFeedback(createLoadingFeedbackState(), summary, 0);
|
|
assert.equal(view.state, 'retry');
|
|
assert.equal(view.label, 'OVERPASS RATE-LIMITED');
|
|
assert.match(view.detail, /ALPR cameras · retrying in 30s/);
|
|
const failed = { visible: true, phase: 'terminal', terminal: 'error', activeIds: ['alpr-cameras', 'flights'] };
|
|
const otherFailure = aggregateLayerLoading([camera, { id: 'flights', enabled: true, stats: { error: 'Failed' } }]);
|
|
assert.equal(presentLoadingFeedback(failed, otherFailure, 0).label, 'LOAD FAILED');
|
|
assert.equal(presentLoadingFeedback({ ...failed, failedEventIds: ['flights'] }, summary, 0).label, 'LOAD FAILED');
|
|
assert.equal(presentLoadingFeedback(createLoadingFeedbackState(), aggregateLayerLoading([{ ...camera, enabled: false }]), 0), null);
|
|
});
|
|
|
|
test('ALPR retry success does not inherit its prior error, including turning the layer off', () => {
|
|
const camera = stats => ({ id: 'alpr-cameras', enabled: true, stats });
|
|
const loading = aggregateLayerLoading([camera({ status: 'loading', loading: true, retrying: true })]);
|
|
let state = reduceLoadingFeedback(createLoadingFeedbackState(), loading, 0);
|
|
state = reduceLoadingFeedback(state, loading, 200);
|
|
assert.equal(presentLoadingFeedback(state, loading, 200).label, 'RETRYING ALPR CAMERAS');
|
|
const done = aggregateLayerLoading([camera({ status: 'ready', count: 3 })]);
|
|
state = reduceLoadingFeedback(state, done, 300);
|
|
assert.equal(presentLoadingFeedback(state, done, 300).label, 'LOAD COMPLETE');
|
|
const stopping = normalizeLayerLoading({ ...camera({ status: 'unavailable', error: 'Old failure' }), lifecycleState: 'disabling' });
|
|
assert.equal(stopping.error, null);
|
|
assert.equal(stopping.unavailable, false);
|
|
});
|