From 94f83c59deefd7d863315f32199e32b80c67af7d Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:54:07 +0530 Subject: [PATCH 1/3] fix: prevent Nepal media from opening external browsers in Pinokio --- CHANGELOG.md | 4 ++ docs/CURRENT-STATE.md | 8 ++++ src/data/bhoteKoshiEmbeddedMedia.js | 8 +++- src/data/bhoteKoshiEmbeddedMedia.test.mjs | 51 +++++++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ead0bcd..3ee9dc855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Keep Nepal provider media inside the Pinokio compatibility boundary: use + source-linked fallback cards instead of automatic embeds or hidden preloads + that launch an external browser. Ordinary browsers retain embedded playback. + - Extract a portable Director shot runner and connect existing scene playback to it. Preserve authored content, project files, camera/layer behavior and source attribution; document the planned timeline, scene-file and data-pack boundaries. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 70ae1db5d..ca745d3ed 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -11,6 +11,14 @@ Search framing and annotations request semantic map features from an explicit so Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease. +In the Pinokio browser shell, Nepal provider embeds and hidden preloads are +disabled before creating frames or loading provider SDKs, because the shell can +redirect iframe navigation to an external browser. Existing source-linked cards +remain available, and Open Original is an explicit action. Optional approved +local clips still use the existing fallback; the public pack bundles no witness +clips. Chrome and Safari retain embeds even when visiting a Pinokio-launched +server. This compatibility fallback does not repair Pinokio's navigation handler. + Nepal flood lines classify the active terrain or photoreal surface, and the surge marker follows that surface. Geographic route coordinates are unchanged; dynamic lines do not rely on unsupported depth-failure materials. Later media-only shots keep their completed source-path history throughout arrival and card reveal. Scene Stop, replacement, seek, and teardown revoke the Upper Valley locator's delayed camera approach and orbit while preserving its visible callout. A revoked timer or camera callback cannot start another flight or interfere with a newer shot. diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index b00f265a8..d92a4edc0 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -595,7 +595,13 @@ 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 { warm: () => false, show: () => false, diff --git a/src/data/bhoteKoshiEmbeddedMedia.test.mjs b/src/data/bhoteKoshiEmbeddedMedia.test.mjs index 5223fff6c..6829adca5 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.test.mjs +++ b/src/data/bhoteKoshiEmbeddedMedia.test.mjs @@ -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; From 621d186de11c73fd471c2bd09bad4f8733d0c656 Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:14:15 +0530 Subject: [PATCH 2/3] fix: scope Nepal media playback to explicit scene actions --- CHANGELOG.md | 3 + docs/CURRENT-STATE.md | 2 +- scripts/qa-nepal-media-playback.mjs | 204 ++++++++++++++++++++++++++++ src/data/bhoteKoshiEmbeddedMedia.js | 2 + src/data/bhoteKoshiEvent.js | 48 ++++++- src/data/bhoteKoshiEvent.test.mjs | 97 ++++++++++++- src/data/bhoteKoshiLocator.test.mjs | 1 + src/scenes/director.js | 62 ++++++++- src/scenes/director.test.mjs | 67 +++++++++ src/scenes/playbackAdapter.js | 1 + 10 files changed, 482 insertions(+), 5 deletions(-) create mode 100644 scripts/qa-nepal-media-playback.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 460477394..ba81961af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ - Keep Nepal provider media inside the Pinokio compatibility boundary: use source-linked fallback cards instead of automatic embeds or hidden preloads that launch an external browser. Ordinary browsers retain embedded playback. + Source-only timed shots use their authored card dwell and continue normally. + Media autoplay now requires a live Play Scene or Play Shot action; passive + loading, saved state and seeking do not grant playback authority. - Add Director import previews, validated scene/shot detail drafts and selected-scene JSON or asset-bundle sharing. Preserve attribution; verify bounded bundle bytes diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc6940fc5..d19de0f38 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -87,7 +87,7 @@ The Nepal overview route and lake-shot trail share the river-centerline starting The event panel omits the WITNESS shortcut. Shot media, source attribution, Open Original links and corridor navigation remain available. -The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. The authored estimate remains seven seconds plus the exit, replacing older saved holds at runtime without rewriting them. Stop, replacement and teardown revoke pending playback callbacks. +The Mailung Bazzar, Dandaguan YouTube embed follows the provider's playback clock through 0:07, then the scene advances after its 0.65-second media exit. The controller reconciles provider state and source time when the API attaches and while waiting for playback, including missed PLAYING notifications and an already-ended clip. Startup is bounded to five seconds, and stalled playback has a bounded timeout; unavailable or blocked media cannot strand the scene. When the host deliberately suppresses provider playback and there is no approved local clip, the source card instead uses the authored 7.65-second dwell before the next shot. The authored estimate replaces older saved holds at runtime without rewriting them. Autoplay requires transient Director ownership from Play Scene or Play Shot; LOAD, restoration and seek remain passive. Stop, replacement and teardown revoke that ownership and pending playback callbacks. Persistent media-led Nepal shots preserve their completed upstream flood path during camera travel. The next reach still reveals after arrival; direct loads reconstruct the prefix and backward replay trims downstream history. diff --git a/scripts/qa-nepal-media-playback.mjs b/scripts/qa-nepal-media-playback.mjs new file mode 100644 index 000000000..87dc5d64f --- /dev/null +++ b/scripts/qa-nepal-media-playback.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +// Composed-app media policy/lifecycle checks. Pinokio UA is a policy fixture, +// not native-host acceptance; the YouTube SDK clock below is deterministic. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const output = path.resolve( + process.env.QA_OUTPUT_DIR || 'qa-shots/nepal-media', +); +fs.mkdirSync(output, { recursive: true }); +const browser = await puppeteer.launch({ + headless: process.env.QA_HEADFUL !== '1', + args: process.env.QA_HEADFUL === '1' ? ['--use-angle=metal'] : [], +}); +let assertions = 0; +const check = (name, value) => { + assert.ok(value, name); + assertions++; + console.log(`[PASS] ${name}`); +}; +try { + for (const pinokio of [true, false]) { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + if (pinokio) + await page.setUserAgent((await browser.userAgent()) + ' Pinokio/8.0.40'); + const popups = []; + page.on('popup', (popup) => popups.push(popup.url())); + // Do not depend on provider availability to prove our startup/trim/exit handoff. + await page.setRequestInterception(true); + page.on('request', (request) => { + if (/youtube(?:-nocookie)?\.com\/embed\//.test(request.url())) { + void request.respond({ + status: 200, + contentType: 'text/html', + body: '

Player clock fixture

', + }); + } else void request.continue(); + }); + await page.evaluateOnNewDocument(() => { + window.__mediaFixture = { starts: [], stops: [] }; + window.YT = { + Player: class { + constructor(_frame, { events }) { + this.events = events; + this.began = null; + this.stoppedAt = null; + this.timer = setTimeout( + () => events.onReady({ target: this }), + 1200, + ); + } + mute() {} + playVideo() { + if (this.began != null) return; + this.began = Date.now(); + window.__mediaFixture.starts.push(this.began); + this.events.onStateChange({ target: this, data: 1 }); + } + getCurrentTime() { + return this.began == null + ? 0 + : ((this.stoppedAt || Date.now()) - this.began) / 1000; + } + getPlayerState() { + return this.began == null ? -1 : this.stoppedAt ? 2 : 1; + } + pauseVideo() { + if (this.began != null && !this.stoppedAt) { + this.stoppedAt = Date.now(); + window.__mediaFixture.stops.push(this.getCurrentTime()); + } + } + destroy() { + clearTimeout(this.timer); + this.pauseVideo(); + } + }, + }; + }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector && + document.querySelector('#loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async (pinokio) => { + const d = window.__godsEyeView.sceneDirector; + const scene = d._project.scenes.find((s) => /Nepal/.test(s.title)); + const shot = scene.shots.find((s) => /Mailung Bazzar/.test(s.title)); + const following = scene.shots[scene.shots.indexOf(shot) + 1]; + const previous = scene.shots[scene.shots.indexOf(shot) - 1]; + const frames = () => + [...document.querySelectorAll('iframe')].map((f) => f.src); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 }); + const openingFrames = frames(); + await d.loadShot(scene.id, shot.id, { flyDuration: 0.1 }); + const passiveFrames = frames(); + const started = Date.now(); + const run = d.startScene(scene.id, { + single: true, + afterShotId: previous.id, + preview: false, + }); + let advanced = null; + while (Date.now() - started < 25000) { + if (d._selectedShotId === following.id) { + advanced = Date.now() - started; + break; + } + if (!d.running) break; + await sleep(100); + } + const runningAtAdvance = d.running; + const clipStops = [...window.__mediaFixture.stops]; + const status = document.querySelector('#scene-status')?.textContent; + d.stopScene(); + await run; + await sleep(250); + const stoppedFrames = frames(); + let replayAutoplay = false; + let replacementSilent = false; + if (!pinokio) { + await d.replayShot(scene.id, shot.id); + replayAutoplay = frames().some((src) => /autoplay=1/.test(src)); + await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 }); + const starts = window.__mediaFixture.starts.length; + await sleep(1600); + replacementSilent = + frames().length === 0 && + starts === window.__mediaFixture.starts.length; + } + const gl = d.viewer.scene.context._gl; + const info = gl.getExtension('WEBGL_debug_renderer_info'); + return { + openingFrames, + passiveFrames, + advanced, + runningAtAdvance, + status, + clipStops, + stoppedFrames, + replayAutoplay, + replacementSilent, + renderer: info + ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) + : gl.getParameter(gl.RENDERER), + }; + }, pinokio); + console.log(JSON.stringify({ pinokioPolicyFixture: pinokio, ...result })); + check( + 'opening shot creates no unrelated provider frames', + result.openingFrames.length === 0, + ); + check( + 'passive LOAD never requests autoplay', + result.passiveFrames.every((src) => !/autoplay=1/.test(src)), + ); + check( + 'actual next-shot identity reached while scene remains running', + result.advanced > 0 && result.runningAtAdvance, + ); + check('no scene timeout', !/timed out|Error:/i.test(result.status || '')); + check('Stop removes provider resources', result.stoppedFrames.length === 0); + check('no automatic external window', popups.length === 0); + if (pinokio) { + check( + 'suppressed providers allocate no iframe', + result.passiveFrames.length === 0, + ); + check( + 'fallback keeps authored dwell', + result.advanced >= 7500 && result.advanced < 18000, + ); + } else { + check( + 'supported clip follows seven-second source clock', + result.clipStops.some((sec) => sec >= 7 && sec < 7.6), + ); + check( + 'explicit Play Shot still requests autoplay', + result.replayAutoplay, + ); + check( + 'replacement disowns pending player startup', + result.replacementSilent, + ); + } + await page.screenshot({ + path: path.join(output, pinokio ? 'pinokio-policy.png' : 'chrome.png'), + }); + await page.close(); + } + console.log(`PASS: ${assertions} assertions`); +} finally { + await browser.close(); +} diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index d92a4edc0..234d2e095 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -603,6 +603,7 @@ export function createBhoteKoshiEmbeddedMedia({ ); if (pinokioShell || !documentRef?.createElement || !viewer) { return { + supportsPlayback: false, warm: () => false, show: () => false, play: () => false, @@ -1084,6 +1085,7 @@ export function createBhoteKoshiEmbeddedMedia({ } return { + supportsPlayback: true, warm, show, getPlaybackState: () => record?.youtubeSession?.getState() || null, diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index d53999f21..ce8d4829b 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -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, diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index bd69cd55e..48183ee01 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -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'); diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 42ce6b919..c67ca8d52 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -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 }); diff --git a/src/scenes/director.js b/src/scenes/director.js index f4e215902..983e4454c 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -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 diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index de00c8cc4..95552025f 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -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; diff --git a/src/scenes/playbackAdapter.js b/src/scenes/playbackAdapter.js index 2c5378e63..450929da5 100644 --- a/src/scenes/playbackAdapter.js +++ b/src/scenes/playbackAdapter.js @@ -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, From a25ee642095e4308fb472e3669178d3ced7ccc06 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 16 Sep 2026 11:52:26 -0700 Subject: [PATCH 3/3] fix: distinguish partial vessel snapshots from stale data --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 6 +++ src/data/feedState.js | 3 +- src/layers/vessels/evidence.js | 1 + src/layers/vessels/ingestion.js | 5 ++- src/layers/vessels/lifecycle.js | 1 + src/layers/vessels/ownership.test.mjs | 62 ++++++++++++++++++++++++++- src/layers/vessels/queries.js | 1 + src/layers/vessels/testing.js | 1 + src/ui/layerPanel.js | 12 ++++++ src/ui/layerPanel.test.mjs | 60 ++++++++++++++++++++++++++ src/ui/styles/layers.css | 1 + src/ui/styles/radio.css | 2 + 13 files changed, 155 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba81961af..0f3fd7822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with + accepted-record counts and unchanged retention, freshness and outage safeguards. + - Keep Nepal provider media inside the Pinokio compatibility boundary: use source-linked fallback cards instead of automatic embeds or hidden preloads that launch an external browser. Ordinary browsers retain embedded playback. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d19de0f38..b5727f353 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +Vessel snapshot completeness is separate from freshness. A current snapshot with +rejected or duplicate records shows PARTIAL with accepted/received counts; stale +or unknown freshness and transport failures retain their warnings. Partial +snapshots still retain missing contacts within the existing age and row limits. +A complete successful snapshot clears the partial indicator. + Director imports now open a non-mutating preview before Apply. EDIT DETAILS authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports a selected scene or a bounded bundle of explicitly chosen pack files. Bundled diff --git a/src/data/feedState.js b/src/data/feedState.js index f65bbd9bd..d0e8b187f 100644 --- a/src/data/feedState.js +++ b/src/data/feedState.js @@ -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'; } diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js index 07a67c967..c5e593e26 100644 --- a/src/layers/vessels/evidence.js +++ b/src/layers/vessels/evidence.js @@ -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; diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index 73bb764dc..29d0f60eb 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -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, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index f00e68d16..3c8c8e245 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -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; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index b9a3709f6..ce55aaf0f 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -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); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index ab6a6af0c..5544379fe 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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' diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index c4b7a2547..dc11f3003 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -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; diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index fff6b9a33..c5d07b098 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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 diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index 03f44cfd5..890a05257 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -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'); +}); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 7a85745cf..72207fa52 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -1298,6 +1298,7 @@ .feed-loading, .feed-degraded, .feed-stale, + .feed-partial, .feed-fallback, .feed-unavailable ) diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 7b2ce0a69..131dc1753 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -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);