mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge upstream main into feature/catalonia-cctv-cameras to resolve PR #595 conflicts
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
7a3af0c129
22 files changed
+707
-9
No files matched your search
@@ -595,8 +595,15 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
facebookLoader = loadFacebookSdk,
|
||||
youtubeLoader = loadYouTubeApi,
|
||||
} = {}) {
|
||||
if (!documentRef?.createElement || !viewer) {
|
||||
// Pinokio externalizes HTTPS iframe navigation, including hidden preloads.
|
||||
// Use the existing source-card/local-clip fallback before allocating provider
|
||||
// resources. Browsers visiting the same Pinokio-launched server keep embeds.
|
||||
const pinokioShell = /(?:^|\s)Pinokio\/[^\s]+/i.test(
|
||||
globalRef.navigator?.userAgent || '',
|
||||
);
|
||||
if (pinokioShell || !documentRef?.createElement || !viewer) {
|
||||
return {
|
||||
supportsPlayback: false,
|
||||
warm: () => false,
|
||||
show: () => false,
|
||||
play: () => false,
|
||||
@@ -1078,6 +1085,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
return {
|
||||
supportsPlayback: true,
|
||||
warm,
|
||||
show,
|
||||
getPlaybackState: () => record?.youtubeSession?.getState() || null,
|
||||
|
||||
@@ -10,6 +10,57 @@ import {
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
|
||||
test('Pinokio fallback never creates provider DOM or loads SDKs', async () => {
|
||||
const unexpected = () => { throw new Error('Provider resource allocated in Pinokio'); };
|
||||
const media = createBhoteKoshiEmbeddedMedia({
|
||||
viewer: {},
|
||||
documentRef: { createElement: unexpected },
|
||||
globalRef: {
|
||||
navigator: { userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Pinokio/8.0.40' },
|
||||
setTimeout: unexpected,
|
||||
requestAnimationFrame: unexpected,
|
||||
},
|
||||
facebookLoader: unexpected,
|
||||
youtubeLoader: unexpected,
|
||||
xLoader: unexpected,
|
||||
});
|
||||
for (const sourceUrl of [
|
||||
'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
'https://www.facebook.com/watch/?v=123456789',
|
||||
'https://x.com/example/status/123456789',
|
||||
]) {
|
||||
const options = { observation: { media: { sourceUrl } }, autoplay: true };
|
||||
assert.equal(media.warm(options), false, 'no hidden preload');
|
||||
assert.equal(media.show(options), false, 'retain the existing fallback card');
|
||||
assert.equal(media.play(), false);
|
||||
assert.equal(media.pause(), false);
|
||||
media.hide();
|
||||
}
|
||||
media.destroy();
|
||||
media.hide();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
test('ordinary browsers retain real preloads on the same launcher URL', () => {
|
||||
for (const userAgent of [
|
||||
'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 Version/18.0 Safari/605.1.15',
|
||||
'Mozilla/5.0 Electron/38.0.0',
|
||||
'',
|
||||
]) {
|
||||
const { media, root } = preloadFixture({ globalRef: {
|
||||
navigator: { userAgent }, location: { href: 'http://127.0.0.1:42003/' },
|
||||
} });
|
||||
assert.equal(media.warm({ observation: { media: {
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abcdefghijk',
|
||||
} } }), true);
|
||||
assert.equal(root.children.length, 1);
|
||||
media.pause();
|
||||
assert.equal(root.children.length, 0);
|
||||
media.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
function trimmedPlaybackFixture() {
|
||||
let now = 0;
|
||||
let nextId = 0;
|
||||
|
||||
@@ -1000,6 +1000,8 @@ export function createBhoteKoshiEventLayer({
|
||||
let _sceneMediaPlaybackBeatId = null;
|
||||
let _sceneMediaPlaybackCompletedBeatId = null;
|
||||
let _sceneMediaPlaybackTimer = null;
|
||||
let _sceneMediaOwner = null;
|
||||
let _removeSceneMediaAbort = null;
|
||||
let _witnessEmbedActive = false;
|
||||
let _sceneRevealAnimationFrame = null;
|
||||
let _sceneRevealHoldActive = false;
|
||||
@@ -1261,8 +1263,41 @@ export function createBhoteKoshiEventLayer({
|
||||
return true;
|
||||
}
|
||||
|
||||
// Playback authority is supplied by the Director, never restored from params.
|
||||
function setSceneMediaPlayback(owner = null) {
|
||||
_removeSceneMediaAbort?.();
|
||||
_removeSceneMediaAbort = null;
|
||||
_sceneMediaOwner = owner;
|
||||
stopSceneMediaPlayback();
|
||||
releaseEvidenceVideo({ restorePoster: false });
|
||||
_embeddedMedia?.hide?.({ immediate: true });
|
||||
_embeddedEvidenceIndex = -1;
|
||||
_sceneMediaPlaybackCompletedBeatId = null;
|
||||
if (owner?.token?.signal) {
|
||||
const signal = owner.token.signal;
|
||||
const cancel = () => setSceneMediaPlayback();
|
||||
if (signal.aborted) cancel();
|
||||
else {
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
_removeSceneMediaAbort = () =>
|
||||
signal.removeEventListener('abort', cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sceneMediaPlaybackActive() {
|
||||
return (
|
||||
_sceneMediaOwner != null &&
|
||||
!_sceneMediaOwner.token.cancelled &&
|
||||
!_sceneMediaOwner.token.signal?.aborted &&
|
||||
_sceneMediaOwner.sceneId === _sceneContext?.sceneId &&
|
||||
_sceneMediaOwner.shotId === _sceneContext?.shotId
|
||||
);
|
||||
}
|
||||
|
||||
function sceneEvidenceMediaAutoplays() {
|
||||
return (
|
||||
sceneMediaPlaybackActive() &&
|
||||
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
|
||||
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
|
||||
_sceneControls.evidenceMediaAutoplay === true &&
|
||||
@@ -1333,7 +1368,7 @@ export function createBhoteKoshiEventLayer({
|
||||
stopSceneMediaPlayback({ pauseMedia: false });
|
||||
_sceneMediaPlaybackBeatId = beatId;
|
||||
_embeddedMedia?.play?.();
|
||||
if (sceneUsesTrimmedMedia(beatId)) {
|
||||
if (sceneUsesTrimmedMedia(beatId) && _embeddedEvidenceIndex === index) {
|
||||
const deadline = Date.now() + 18000;
|
||||
const check = () => {
|
||||
if (
|
||||
@@ -1415,6 +1450,14 @@ export function createBhoteKoshiEventLayer({
|
||||
!sceneUsesTrimmedMedia(beatId)
|
||||
)
|
||||
return null;
|
||||
const media = _evidenceTimeline.find(
|
||||
(item) => item.observation?.id === beatId,
|
||||
)?.observation?.media;
|
||||
// Deliberate provider suppression is a source card, not a player waiting
|
||||
// to load. Returning no gate selects the Director's authored card dwell.
|
||||
// A local clip still owns playback; a starting supported provider still waits.
|
||||
if (_embeddedMedia?.supportsPlayback === false && !media?.videoPath)
|
||||
return null;
|
||||
return {
|
||||
pending:
|
||||
_sceneMediaPlaybackCompletedBeatId !== beatId ||
|
||||
@@ -2103,6 +2146,7 @@ export function createBhoteKoshiEventLayer({
|
||||
releaseEvidenceVideo();
|
||||
_embeddedEvidenceIndex = -1;
|
||||
const warmingEvidence =
|
||||
sceneMediaPlaybackActive() &&
|
||||
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
|
||||
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT;
|
||||
// Keep the same preload across camera frames; stop/disable still cancels it.
|
||||
@@ -3476,6 +3520,7 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
setSceneMediaPlayback();
|
||||
_enabled = false;
|
||||
++_comparisonSurfaceGeneration;
|
||||
_mapStackController?.clearTerrainComparison?.();
|
||||
@@ -3770,6 +3815,7 @@ export function createBhoteKoshiEventLayer({
|
||||
setParams,
|
||||
getParams,
|
||||
getSceneShotMediaHold,
|
||||
setSceneMediaPlayback,
|
||||
getStats() {
|
||||
return {
|
||||
count: _enabled ? 1 : 0,
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
} from './bhoteKoshiEvent.js';
|
||||
import { SCENE_RECIPES } from '../scenes/recipes.js';
|
||||
import { LayerLifecycle } from './lifecycle.js';
|
||||
import { createBhoteKoshiEmbeddedMedia } from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
|
||||
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
|
||||
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
|
||||
@@ -277,8 +279,9 @@ test('trimmed scene media holds through provider startup, playback and fade, the
|
||||
try {
|
||||
const viewer = eventViewer();
|
||||
await layer.init(viewer);
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
|
||||
layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
|
||||
sceneContext: { holdSec: 7.65 },
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
|
||||
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaExitDurationSec: 0.65 } }, { origin: 'scene' });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true);
|
||||
@@ -301,6 +304,94 @@ test('trimmed scene media holds through provider startup, playback and fade, the
|
||||
}
|
||||
});
|
||||
|
||||
test('Pinokio source-card fallback selects authored Director dwell, not an impossible player hold', async () => {
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
installEventDom();
|
||||
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const layer = createBhoteKoshiEventLayer({
|
||||
eventLoader: async () => event,
|
||||
imageryProviderFactory: async url => ({ url }),
|
||||
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
|
||||
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
renderHost: { request() {}, hold() {}, release() {} },
|
||||
embeddedMediaFactory: options => createBhoteKoshiEmbeddedMedia({ ...options,
|
||||
globalRef: { navigator: { userAgent: 'Chrome Pinokio/8.0.40' } },
|
||||
documentRef: { createElement() { assert.fail('unsupported host must allocate no provider DOM'); } },
|
||||
}),
|
||||
});
|
||||
const token = { cancelled: false, signal: new AbortController().signal };
|
||||
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
|
||||
sceneControls: { evidenceMediaAutoplay: true, mediaPlaybackHoldSec: 7, cameraSettled: true } };
|
||||
try {
|
||||
const viewer = eventViewer();
|
||||
await layer.init(viewer);
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token });
|
||||
layer.setParams(params, { origin: 'scene' });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
|
||||
const waits = [];
|
||||
await SceneDirector.prototype._holdShot.call({
|
||||
_layerStatesForShot: () => ({ [layer.id]: { enabled: true, params } }),
|
||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||
_effectiveShotHoldSec: () => 7.65,
|
||||
_sleep: async ms => waits.push(ms),
|
||||
stopScene: () => assert.fail('a source card must not time out'),
|
||||
}, { id: 'scene' }, { id: 'shot' }, token);
|
||||
assert.deepEqual(waits, [7650], 'use the authored dwell instead of completing instantly or polling 20 seconds');
|
||||
event.evidenceSpine.find(item => item.id === 'mailung-bazzar').media.videoPath = 'fixture-approved.mp4';
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
|
||||
'an approved local clip remains a real playback owner even with the provider suppressed');
|
||||
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
|
||||
});
|
||||
|
||||
test('saved autoplay controls cannot authorize media; live shot ownership is cancellable and never serialized', async () => {
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
installEventDom();
|
||||
const timers = new Map(); let timerId = 0;
|
||||
globalThis.window = { setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) };
|
||||
const calls = [];
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const layer = createBhoteKoshiEventLayer({
|
||||
eventLoader: async () => event,
|
||||
imageryProviderFactory: async url => ({ url }),
|
||||
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
|
||||
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
renderHost: { request() {}, hold() {}, release() {} },
|
||||
embeddedMediaFactory: () => ({ supportsPlayback: true, warm: () => true,
|
||||
show: options => { calls.push(options.autoplay); return true; },
|
||||
getPlaybackState: () => null, play() {}, pause() {}, hide() {}, destroy() {} }),
|
||||
});
|
||||
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
|
||||
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaPlaybackHoldSec: 7 } };
|
||||
try {
|
||||
const viewer = eventViewer(); await layer.init(viewer);
|
||||
layer.setParams(params, { origin: 'local-restore' }); await layer.enable(viewer);
|
||||
assert.ok(calls.length); assert.ok(calls.every(value => value === false));
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
|
||||
const saved = layer.getParams();
|
||||
const abort = new AbortController();
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false, signal: abort.signal } });
|
||||
layer.setParams(params, { origin: 'scene' });
|
||||
assert.equal(calls.at(-1), true);
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
|
||||
'a temporarily null supported-provider state is still startup, not unsupported');
|
||||
assert.deepEqual(layer.getParams(), saved, 'live playback authority must not enter saved params');
|
||||
const late = [...timers.values()];
|
||||
abort.abort(); late.forEach(fn => fn());
|
||||
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
|
||||
layer.setParams(params, { origin: 'local-restore' });
|
||||
assert.equal(calls.at(-1), false, 'late callbacks and restored flags cannot restart revoked playback');
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'different-shot', token: { cancelled: false } });
|
||||
layer.setParams(params, { origin: 'scene' });
|
||||
assert.equal(calls.at(-1), false, 'a different shot cannot borrow this playback owner');
|
||||
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
|
||||
});
|
||||
|
||||
test('Bhote Koshi timeline helpers clamp untrusted UI values', () => {
|
||||
assert.equal(clampUnit(-1), 0);
|
||||
assert.equal(clampUnit(0.42), 0.42);
|
||||
@@ -1695,7 +1786,9 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
|
||||
sceneControls: { evidenceMediaAutoplay: true },
|
||||
beatId: 'immediate-collapse-viewpoint',
|
||||
beatReveal: 0.36,
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot' },
|
||||
}, { origin: 'scene' });
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
assert.equal(videos.length, 1);
|
||||
const video = videos[0];
|
||||
@@ -1874,6 +1967,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
|
||||
sceneSurface: 'evidence-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
beatReveal: 0.36,
|
||||
sceneContext: { sceneId: 'scene', shotId: 'shot' },
|
||||
sceneControls: {
|
||||
evidenceSequence: true,
|
||||
evidenceSequenceDurationSec: 0.9,
|
||||
@@ -1886,6 +1980,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
|
||||
try {
|
||||
await layer.init(viewer);
|
||||
layer.setParams(params, { origin: 'scene' });
|
||||
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
const card = entries.find(({ id }) => id === 'evidence-card-gyirong-border-gate');
|
||||
const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core');
|
||||
|
||||
@@ -512,6 +512,7 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
};
|
||||
try {
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
/**
|
||||
* Normalize heterogeneous layer stats into one honest control-chip state.
|
||||
* @param {object|null} stats Layer getStats() result.
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'} Feed state.
|
||||
*/
|
||||
export function layerFeedState(stats = {}) {
|
||||
const state = stats || {};
|
||||
@@ -50,5 +50,6 @@ export function layerFeedState(stats = {}) {
|
||||
state.available === false
|
||||
)
|
||||
return 'degraded';
|
||||
if (state.partial === true) return 'partial';
|
||||
return 'nominal';
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function createEvidence({
|
||||
state.feed.loaded = true;
|
||||
state.feed.error = null;
|
||||
state.feed.stale = false;
|
||||
state.feed.partial = false;
|
||||
state.feed.lastUpdate = Date.now();
|
||||
state.feed.transportStatus = 'synthetic';
|
||||
state.feed.lastMessageAt = null;
|
||||
|
||||
@@ -102,6 +102,7 @@ export function createIngestion({
|
||||
feed.lastMessageAt = snapshot.lastMessageAt;
|
||||
feed.rawRowCount = snapshot.rawRowCount;
|
||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||
feed.partial = payload?.complete === false;
|
||||
|
||||
if (snapshot.acceptedRowCount === 0) {
|
||||
feed.count = readCount();
|
||||
@@ -133,7 +134,8 @@ export function createIngestion({
|
||||
feed.count = readCount();
|
||||
feed.stale =
|
||||
Boolean(payload?.refreshing) ||
|
||||
payload?.complete === false ||
|
||||
payload?.freshness === 'stale' ||
|
||||
snapshot.transportStatus === 'stale' ||
|
||||
payload?.freshness === 'unknown';
|
||||
feed.newestPositionAt = payload?.newestPositionAt || null;
|
||||
// Not unconditionally null: a degraded feed keeps its reason even though the
|
||||
@@ -190,6 +192,7 @@ export function createVesselFeed() {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
stale: false,
|
||||
partial: false,
|
||||
error: null,
|
||||
loadingLabel: '',
|
||||
lastUpdate: null,
|
||||
|
||||
@@ -100,6 +100,7 @@ export function createLifecycle({
|
||||
state.feed.loading = false;
|
||||
state.feed.loaded = false;
|
||||
state.feed.stale = false;
|
||||
state.feed.partial = false;
|
||||
state.feed.error = null;
|
||||
state.feed.loadingLabel = '';
|
||||
state.feed.lastUpdate = null;
|
||||
|
||||
@@ -124,7 +124,8 @@ test('partial observations retain missing contacts only until their receipt dead
|
||||
current = snapshot([observation('111')], { complete: false, rawRowCount: 2 });
|
||||
await layer.update();
|
||||
assert.equal(layer.hasContact('222'), true);
|
||||
assert.equal(layer.getStats().stale, true);
|
||||
assert.equal(layer.getStats().stale, false);
|
||||
assert.equal(layer.getStats().partial, true);
|
||||
assert.equal(layer.getStats().rawRowCount, 2);
|
||||
assert.equal(layer.getStats().acceptedRowCount, 1);
|
||||
assert.equal(layer.getStats().lastUpdate, 500);
|
||||
@@ -301,3 +302,62 @@ test('refresh keeps pick identity and renderer geometry separate, and eviction r
|
||||
assert.equal(primitives.has(primitive), false);
|
||||
assert.equal(layer.findByQuery('111'), null);
|
||||
});
|
||||
|
||||
test('partial vessel snapshots preserve freshness and recover after a complete update', async (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
});
|
||||
const { vesselSnapshot } = await import('../../sources/live/vessels.js');
|
||||
const { layerFeedState } = await import('../../data/feedState.js');
|
||||
const valid = {
|
||||
mmsi: '123456789',
|
||||
lat: 51.93,
|
||||
lon: 4.05,
|
||||
last_position_epoch: 1,
|
||||
};
|
||||
let current = vesselSnapshot({
|
||||
rows: [valid, { ...valid }, { mmsi: '987654321', lat: 200, lon: 4 }],
|
||||
status: 'live',
|
||||
});
|
||||
const { layer } = setup({ getSnapshot: async () => current });
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().partial, true);
|
||||
assert.equal(layer.getStats().stale, false);
|
||||
assert.equal(layer.getStats().rawRowCount, 3);
|
||||
assert.equal(layer.getStats().acceptedRowCount, 1);
|
||||
assert.equal(layerFeedState(layer.getStats()), 'partial');
|
||||
assert.equal(layer.hasContact('123456789'), true);
|
||||
|
||||
for (const extra of [
|
||||
{ stale: true },
|
||||
{ freshness: 'stale' },
|
||||
{ freshness: 'unknown', observedAtMs: null },
|
||||
{ transportStatus: 'stale' },
|
||||
]) {
|
||||
const original = current;
|
||||
current = { ...original, ...extra };
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().partial, true);
|
||||
assert.equal(layerFeedState(layer.getStats()), 'stale');
|
||||
current = original;
|
||||
}
|
||||
current = { ...current, transportStatus: 'reconnecting' };
|
||||
await layer.update();
|
||||
assert.equal(layerFeedState(layer.getStats()), 'degraded');
|
||||
assert.match(layer.getStats().error, /reconnecting/);
|
||||
|
||||
current = vesselSnapshot({ rows: [valid], status: 'live' });
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().partial, false);
|
||||
assert.equal(layer.getStats().stale, false);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(layerFeedState(layer.getStats()), 'nominal');
|
||||
current = { ...current, complete: false };
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().partial, true);
|
||||
layer.destroy();
|
||||
assert.equal(layer.getStats().partial, false);
|
||||
});
|
||||
@@ -508,6 +508,7 @@ export function createQueries({
|
||||
: state.feed.loadingLabel,
|
||||
error: state.feed.error,
|
||||
stale: state.feed.stale,
|
||||
partial: state.feed.partial,
|
||||
status:
|
||||
state.feed.firstConnectPhase === 'unavailable'
|
||||
? 'unavailable'
|
||||
|
||||
@@ -37,6 +37,7 @@ export function createTesting({
|
||||
state.feed.loaded = options.loaded === true;
|
||||
state.feed.loading = options.loading === true;
|
||||
state.feed.stale = options.stale === true;
|
||||
state.feed.partial = options.partial === true;
|
||||
state.feed.error = options.error || null;
|
||||
state.feed.lastUpdate = options.lastUpdate ?? null;
|
||||
state.records.all = records;
|
||||
|
||||
+60
-2
@@ -1009,13 +1009,39 @@ export class SceneDirector {
|
||||
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
this._sceneSeekGeneration++;
|
||||
this._interactionTransitions = 0;
|
||||
return this._trackWork(this._loadShot(sceneId, shotId, options));
|
||||
const previousGeneration = this._loadGeneration;
|
||||
const work = this._loadShot(sceneId, shotId, options);
|
||||
const generation = this._loadGeneration;
|
||||
const ownsLoad = generation !== previousGeneration;
|
||||
return this._trackWork(
|
||||
work.then(
|
||||
(result) => {
|
||||
if (
|
||||
ownsLoad &&
|
||||
!result?.started &&
|
||||
generation === this._loadGeneration
|
||||
)
|
||||
this._setSceneMediaPlayback();
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
if (ownsLoad && generation === this._loadGeneration)
|
||||
this._setSceneMediaPlayback();
|
||||
throw error;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async _loadShot(
|
||||
sceneId,
|
||||
shotId,
|
||||
{ flyDuration = null, fromCamera = null, sceneSeek = null } = {},
|
||||
{
|
||||
flyDuration = null,
|
||||
fromCamera = null,
|
||||
sceneSeek = null,
|
||||
playMedia = false,
|
||||
} = {},
|
||||
) {
|
||||
if (this._running) return { started: false, reason: 'already-running' };
|
||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||
@@ -1034,6 +1060,7 @@ export class SceneDirector {
|
||||
// manager) rather than merely ignored once it has already committed.
|
||||
this._cancelActiveSceneTravel();
|
||||
this._usesAuthoredCamera = !!shot.move;
|
||||
this._setSceneMediaPlayback();
|
||||
this._interactions?.clear();
|
||||
this._dataPacks?.clear();
|
||||
this._loadAbort?.abort();
|
||||
@@ -1064,6 +1091,10 @@ export class SceneDirector {
|
||||
if (token.cancelled) return;
|
||||
const seekState =
|
||||
sceneSeek && typeof sceneSeek === 'object' ? sceneSeek : null;
|
||||
// Previous-scene disable revokes media ownership. Grant the target only
|
||||
// after release and visual setup have succeeded for this live LOAD.
|
||||
if (playMedia && !seekState)
|
||||
this._setSceneMediaPlayback(scene, shot, token);
|
||||
const layerResult = await this._applyLayerStates(
|
||||
this._layerStatesForShot(scene, shot, {
|
||||
cameraSettled: seekState ? seekState.cameraProgress >= 1 : false,
|
||||
@@ -1159,6 +1190,29 @@ export class SceneDirector {
|
||||
return layerStatesForShot(scene, shot, this._scenePacks, options);
|
||||
}
|
||||
|
||||
/** Give opt-in media layers a transient, cancellable shot owner. */
|
||||
_setSceneMediaPlayback(scene = null, shot = null, token = null) {
|
||||
for (const module of this._sceneMediaModules || [])
|
||||
module.setSceneMediaPlayback();
|
||||
this._sceneMediaModules = new Set();
|
||||
if (!scene || !shot || !token || token.cancelled || token.signal?.aborted)
|
||||
return;
|
||||
for (const [id, state] of Object.entries(shot.layers || {})) {
|
||||
const module = this.dataManager?.layers?.get(id)?.module;
|
||||
if (
|
||||
!state?.enabled ||
|
||||
typeof module?.setSceneMediaPlayback !== 'function'
|
||||
)
|
||||
continue;
|
||||
module.setSceneMediaPlayback({
|
||||
sceneId: scene.id,
|
||||
shotId: shot.id,
|
||||
token,
|
||||
});
|
||||
this._sceneMediaModules.add(module);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep installed append-pack shots on any surface declared by their source recipe. */
|
||||
_visualStateForShot(shot) {
|
||||
return visualStateForShot(
|
||||
@@ -1314,6 +1368,7 @@ export class SceneDirector {
|
||||
const previousShot =
|
||||
scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length];
|
||||
const result = await this.loadShot(sceneId, shotId, {
|
||||
playMedia: true,
|
||||
flyDuration: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
|
||||
fromCamera: shot.move
|
||||
? null
|
||||
@@ -1658,6 +1713,7 @@ export class SceneDirector {
|
||||
// load cannot land a stale shot's layers on top of the run's first shot.
|
||||
// Aborting cancels a layer transition already in flight; bumping the
|
||||
// generation disowns everything the load has not yet started.
|
||||
this._setSceneMediaPlayback();
|
||||
this._cancelActiveSceneTravel();
|
||||
this._interactions?.clear();
|
||||
this._dataPacks?.clear();
|
||||
@@ -1771,6 +1827,7 @@ export class SceneDirector {
|
||||
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
|
||||
*/
|
||||
stopScene(reason = 'Stopped') {
|
||||
this._setSceneMediaPlayback();
|
||||
this._interactions?.clear();
|
||||
this._dataPacks?.clear();
|
||||
this._sceneSeekGeneration++;
|
||||
@@ -2314,6 +2371,7 @@ export class SceneDirector {
|
||||
* and resets UI buttons to the idle state.
|
||||
*/
|
||||
_finishRun() {
|
||||
this._setSceneMediaPlayback();
|
||||
this._clock.finish();
|
||||
this._setPlaybackKeyboardEnabled(false);
|
||||
// Covers the error path too: a run that threw mid-shot must not leave a
|
||||
|
||||
@@ -731,6 +731,73 @@ function makeDirector(options = {}) {
|
||||
return { director, viewer, styleManager, dataManager, restore };
|
||||
}
|
||||
|
||||
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
|
||||
const { director, dataManager, restore } = makeDirector();
|
||||
let owner = null;
|
||||
const owners = [];
|
||||
dataManager.layers = new Map([['flights', { module: {
|
||||
setSceneMediaPlayback(value = null) { owner = value; if (value) owners.push(value.shotId); },
|
||||
} }]]);
|
||||
try {
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
assert.equal(owner, null, 'passive LOAD is not playback');
|
||||
await director.replayShot('scene-1', 'shot-a');
|
||||
assert.equal(owner?.shotId, 'shot-a');
|
||||
assert.equal(owner.token.cancelled, false);
|
||||
await director.loadShot('missing-scene', 'missing-shot');
|
||||
assert.equal(owner?.shotId, 'shot-a', 'rejected navigation does not revoke another action');
|
||||
director._running = true;
|
||||
await director.loadShot('scene-1', 'shot-b');
|
||||
assert.equal(owner?.shotId, 'shot-a', 'LOAD refused during a run cannot stop its media');
|
||||
director._running = false;
|
||||
await director.loadShot('scene-1', 'shot-b', { flyDuration: 0 });
|
||||
assert.equal(owner, null, 'replacement LOAD revokes the prior replay');
|
||||
await director.replayShot('scene-1', 'shot-a');
|
||||
director.stopScene();
|
||||
assert.equal(owner, null);
|
||||
director._sleep = async () => {};
|
||||
owners.length = 0;
|
||||
await director.startScene('scene-1', { single: true, preview: false });
|
||||
assert.deepEqual(owners, ['shot-a'], 'only enabled opt-in layers receive playback authority');
|
||||
assert.equal(owner, null, 'run completion releases the last shot owner');
|
||||
} finally { await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('cross-scene replay grants media ownership only after the previous scene releases its layers', async () => {
|
||||
const { director, dataManager, restore } = makeDirector();
|
||||
let owner = null;
|
||||
const events = [];
|
||||
const media = { setSceneMediaPlayback(value = null) { owner = value; } };
|
||||
dataManager.layers = new Map([['flights', { module: media }]]);
|
||||
const setEnabled = dataManager.setEnabled.bind(dataManager);
|
||||
dataManager.setEnabled = async (id, enabled, options) => {
|
||||
if (id === 'flights') {
|
||||
events.push({ enabled, owner: owner?.shotId ?? null });
|
||||
if (!enabled) media.setSceneMediaPlayback();
|
||||
}
|
||||
return setEnabled(id, enabled, options);
|
||||
};
|
||||
try {
|
||||
const previous = director._project.scenes[0];
|
||||
previous.releaseLayerIds = ['flights'];
|
||||
const target = structuredClone(previous);
|
||||
target.id = 'other-media-scene';
|
||||
director._project.scenes.push(target);
|
||||
await director.loadShot(previous.id, 'shot-a', { flyDuration: 0 });
|
||||
events.length = 0;
|
||||
const result = await director.replayShot(target.id, 'shot-a');
|
||||
assert.equal(result.started, true);
|
||||
assert.deepEqual(events.slice(0, 2), [
|
||||
{ enabled: false, owner: null },
|
||||
{ enabled: true, owner: 'shot-a' },
|
||||
], 'old-scene disable must finish before granting the new owner');
|
||||
assert.equal(owner?.sceneId, target.id);
|
||||
assert.equal(owner?.token.cancelled, false);
|
||||
director.stopScene();
|
||||
assert.equal(owner, null);
|
||||
} finally { await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('scene camera waits for provider completion and fade rather than the saved media hold', async () => {
|
||||
const { director, viewer, dataManager, restore } = makeDirector();
|
||||
let release;
|
||||
|
||||
@@ -30,6 +30,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
);
|
||||
},
|
||||
async applyLayers({ scene, shot, token }) {
|
||||
director._setSceneMediaPlayback(scene, shot, token);
|
||||
const result = await director._applyLayerStates(
|
||||
director._layerStatesForShot(scene, shot),
|
||||
token,
|
||||
|
||||
@@ -7,6 +7,7 @@ const FEED_STATE_LABELS = Object.freeze({
|
||||
loading: 'LOADING',
|
||||
degraded: 'DEGRADED',
|
||||
stale: 'STALE',
|
||||
partial: 'PARTIAL',
|
||||
fallback: 'FALLBACK',
|
||||
unavailable: 'UNAVAILABLE',
|
||||
});
|
||||
@@ -505,6 +506,17 @@ export class LayerPanel {
|
||||
: stats.coverage || ago;
|
||||
return `${stateLabel} · ${source} · ${detail}`;
|
||||
}
|
||||
if (feedState === 'partial') {
|
||||
const { acceptedRowCount, rawRowCount } = stats;
|
||||
const detail =
|
||||
Number.isInteger(acceptedRowCount) &&
|
||||
Number.isInteger(rawRowCount) &&
|
||||
acceptedRowCount >= 0 &&
|
||||
rawRowCount > acceptedRowCount
|
||||
? `${acceptedRowCount} of ${rawRowCount} records accepted`
|
||||
: 'incomplete snapshot';
|
||||
return `${stateLabel} · ${source} · ${detail} · ${ago}`;
|
||||
}
|
||||
if (feedState === 'stale') {
|
||||
const retry =
|
||||
typeof stats.retryInSec === 'number' && stats.retryInSec > 0
|
||||
|
||||
@@ -29,3 +29,63 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
|
||||
);
|
||||
assert.equal(order.filter(({ id }) => id === 'transit').length, 1);
|
||||
});
|
||||
|
||||
test('partial feed controls distinguish incomplete records from stale data and outages', async () => {
|
||||
const { LayerPanel, layerFeedState } = await import('./layerPanel.js');
|
||||
const classes = new Map();
|
||||
const attrs = new Map();
|
||||
const button = {
|
||||
classList: { toggle: (key, value) => classes.set(key, value) },
|
||||
dataset: {},
|
||||
setAttribute: (key, value) => attrs.set(key, value),
|
||||
};
|
||||
const layer = {
|
||||
id: 'ais-live-vessels',
|
||||
name: 'Live Vessels',
|
||||
source: 'AISStream',
|
||||
enabled: true,
|
||||
stats: {
|
||||
partial: true,
|
||||
stale: false,
|
||||
count: 2,
|
||||
acceptedRowCount: 2,
|
||||
rawRowCount: 3,
|
||||
lastUpdate: Date.now(),
|
||||
},
|
||||
};
|
||||
const panel = LayerPanel.prototype;
|
||||
panel._syncToggleButton(button, layer);
|
||||
assert.equal(button.textContent, 'PARTIAL');
|
||||
assert.equal(button.dataset.feedState, 'partial');
|
||||
assert.equal(classes.get('feed-partial'), true);
|
||||
assert.equal(classes.get('feed-stale'), false);
|
||||
assert.match(attrs.get('aria-label'), /PARTIAL/);
|
||||
assert.match(
|
||||
panel._buildMetaText(layer),
|
||||
/^PARTIAL · AISStream · 2 of 3 records accepted · /,
|
||||
);
|
||||
assert.match(
|
||||
panel._buildMetaText({
|
||||
...layer,
|
||||
stats: { ...layer.stats, rawRowCount: 2 },
|
||||
}),
|
||||
/incomplete snapshot/,
|
||||
);
|
||||
assert.equal(layerFeedState({ ...layer.stats, stale: true }), 'stale');
|
||||
assert.equal(
|
||||
layerFeedState({ ...layer.stats, error: 'Connection lost' }),
|
||||
'degraded',
|
||||
);
|
||||
assert.equal(
|
||||
layerFeedState({ ...layer.stats, status: 'unavailable' }),
|
||||
'unavailable',
|
||||
);
|
||||
assert.equal(layerFeedState({ ...layer.stats, loading: true }), 'loading');
|
||||
layer.stats = { ...layer.stats, partial: false };
|
||||
panel._syncToggleButton(button, layer);
|
||||
assert.equal(button.textContent, 'ON');
|
||||
assert.equal(classes.get('feed-partial'), false);
|
||||
layer.enabled = false;
|
||||
panel._syncToggleButton(button, layer);
|
||||
assert.equal(button.textContent, 'OFF');
|
||||
});
|
||||
@@ -1298,6 +1298,7 @@
|
||||
.feed-loading,
|
||||
.feed-degraded,
|
||||
.feed-stale,
|
||||
.feed-partial,
|
||||
.feed-fallback,
|
||||
.feed-unavailable
|
||||
)
|
||||
|
||||
@@ -666,6 +666,7 @@
|
||||
.data-toggle-btn.feed-loading,
|
||||
.data-toggle-btn.feed-degraded,
|
||||
.data-toggle-btn.feed-stale,
|
||||
.data-toggle-btn.feed-partial,
|
||||
.data-toggle-btn.feed-fallback,
|
||||
.data-toggle-btn.feed-unavailable {
|
||||
min-width: 82px;
|
||||
@@ -685,6 +686,7 @@
|
||||
}
|
||||
|
||||
.data-toggle-btn.active.feed-stale,
|
||||
.data-toggle-btn.active.feed-partial,
|
||||
.data-toggle-btn.active.feed-fallback {
|
||||
color: #ffd27a;
|
||||
border-color: rgba(255, 190, 82, 0.75);
|
||||
|
||||
Reference in new issue
Block a user