import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { claimPointer, releasePointer } from './inputOwnership.js'; import * as Cesium from 'cesium'; import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js'; import { useImagerySplitStore } from '../ui/stores/imagerySplit.js'; import { applicationPinia } from '../ui/vueRuntime.js'; import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js'; import { BHOTE_KOSHI_LAYER_ID, activeEvidenceIndex, adjacentEvidenceIndex, buildCinematicKeyframes, buildEvidenceTimeline, clampUnit, createBhoteKoshiEventLayer, evidenceCardLayout, createEvidenceFrame, elapsedLabel, evidenceBeatProgress, evidenceBeatPresentation, evidenceCorroborationLabel, evidenceSequenceWindow, evidenceSummaryLines, floodProgressForStoryProgress, interpolateHeadingDegrees, sampleCinematicPose, updateEvidenceFrame, updateCorridorPositionCache, usesProviderEmbeddedEvidence, visibleSegmentCount, } 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); /** The event's divider as the imagery split store holds it, or undefined. */ const eventSplit = () => useImagerySplitStore(applicationPinia()).splits.findLast( (split) => split.view.id === 'bhote-koshi-split-line', ); // The panel controls the layer presents, by the selector the panel view gives each. const PANEL_REFS = { '[data-role="imagery-comparison"]': 'imagerySection', '[data-role="time"]': 'time', '[data-role="caveat"]': 'caveat', '[data-role="imagery-cloud-note"]': 'imageryCloudNote', '[data-role="progress"]': 'progressInput', '[data-role="split"]': 'splitInput', '[data-role="timeline-label"]': 'timelineLabel', '[data-role="beat-index"]': 'beatIndex', '[data-role="beat-title"]': 'beatTitle', '[data-role="beat-meta"]': 'beatMeta', '[data-action="play"]': 'playButton', '[data-action="play-scene"]': 'playSceneButton', '[data-action="cinematic"]': 'cinematicButton', '[data-action="story-replay"]': 'storyReplayButton', '[data-action="corridor"]': 'corridorButton', '[data-action="previous-beat"]': 'previousButton', '[data-action="next-beat"]': 'nextButton', '[data-action="open-source"]': 'openSourceButton', }; // What each control calls, as the panel view wires it. const PANEL_EVENTS = { playButton: { click: 'play' }, playSceneButton: { click: 'playScene' }, cinematicButton: { click: 'toggleCinematic' }, storyReplayButton: { click: 'replayCinematic' }, corridorButton: { click: 'focusCorridor' }, previousButton: { click: 'previousBeat' }, nextButton: { click: 'nextBeat' }, openSourceButton: { click: 'openActiveSource' }, progressInput: { input: (controls, value) => controls.previewProgress(Number(value) / 1000), change: (controls, value) => controls.commitProgress(Number(value) / 1000, { final: true }), }, splitInput: { input: (controls, value) => controls.setSplit(Number(value) / 100) }, }; /** * The event panel as the layer presents it to the panel store, or undefined * while it is off. Controls read the latest presentation, and dispatching an * event calls the connected controls as the panel view does. */ function eventPanel() { const store = useBhoteKoshiPanelStore(applicationPinia()); if (!store.panel) return undefined; const control = (key) => { let typed = null; const record = () => store.panel.refs[key]; return { get textContent() { return record().textContent; }, get hidden() { return record().hidden; }, get disabled() { return record().disabled; }, get value() { return typed ?? record().value; }, set value(value) { typed = value; }, getAttribute: (name) => record().attributes[name] ?? null, dispatch(type) { const value = typed ?? record().value; typed = null; const handler = PANEL_EVENTS[key]?.[type]; return typeof handler === 'function' ? handler(store.controls, value) : store.controls[handler]?.(); }, }; }; const named = (name) => ({ dispatch: () => store.controls[name]() }); return { querySelector(selector) { if (selector === '.bhote-event-report-list') { return { children: store.panel.reports.map((_, index) => ({ dispatch: () => store.controls.openReport(index), })), }; } if (selector === '[data-action="geolocation-map"]') return named('openGeolocationMap'); if (selector === '[data-action="close"]') return named('close'); return control(PANEL_REFS[selector]); }, }; } class FakeElement { constructor(tagName = 'div') { this.tagName = tagName; this.children = []; this.removed = false; this._value = ''; this._textContent = ''; this._disabled = false; this._writeCount = 0; this._queryCount = 0; this._attributes = new Map(); this._selectors = new Map(); this._listeners = new Map(); } set value(value) { if (this._value === value) return; this._value = value; this._writeCount += 1; } get value() { return this._value; } set textContent(value) { if (this._textContent === value) return; this._textContent = value; this._writeCount += 1; } get textContent() { return this._textContent; } set disabled(value) { if (this._disabled === value) return; this._disabled = value; this._writeCount += 1; } get disabled() { return this._disabled; } setAttribute(name, value) { if (this._attributes.get(name) === value) return; this._attributes.set(name, value); this._writeCount += 1; } getAttribute(name) { return this._attributes.get(name) ?? null; } addEventListener(type, handler) { this._listeners.set(type, handler); } removeEventListener(type, handler) { if (this._listeners.get(type) === handler) this._listeners.delete(type); } dispatch(type, event = { target: this }) { return this._listeners.get(type)?.(event); } append(...children) { this.children.push(...children); } appendChild(child) { this.children.push(child); return child; } querySelector(selector) { this._queryCount += 1; if (!this._selectors.has(selector)) this._selectors.set(selector, new FakeElement()); return this._selectors.get(selector); } remove() { this.removed = true; } set innerHTML(value) { this._innerHTML = value; } get innerHTML() { return this._innerHTML || ''; } } function installEventDom() { const body = new FakeElement('body'); const removedProperties = []; globalThis.document = { body, createElement: (tagName) => new FakeElement(tagName), documentElement: { clientWidth: 1000, style: { setProperty() {}, removeProperty(name) { removedProperties.push(name); }, }, }, }; return { body, removedProperties }; } function eventViewer(splitPosition = 0.37) { const imagery = []; const removedImagery = []; const dataSources = []; const removedDataSources = []; const canvas = new FakeElement('canvas'); const cameraLooks = []; const cameraTransforms = []; return { scene: { splitPosition, canvas }, camera: { cancelFlight() {}, lookAt(target, offset) { cameraLooks.push({ target: { x: target.x, y: target.y, z: target.z }, offset: { heading: offset.heading, pitch: offset.pitch, range: offset.range }, }); }, lookAtTransform(transform) { cameraTransforms.push(transform); }, }, imageryLayers: { addImageryProvider(provider) { const layer = { provider }; imagery.push(layer); return layer; }, remove(layer) { removedImagery.push(layer); }, }, dataSources: { add(source) { dataSources.push(source); }, remove(source) { removedDataSources.push(source); }, }, _test: { imagery, removedImagery, dataSources, removedDataSources, canvas, cameraLooks, cameraTransforms, }, }; } test('public lifecycle restores Nepal scene ownership without forwarding origin to enable', async () => { const previous = { document: globalThis.document, window: globalThis.window }; const event = JSON.parse(await readFile(eventUrl, 'utf8')); try { for (const initialMap of ['photoreal', 'esri-imagery']) { for (const origin of ['scene', 'local-restore', 'share-restore']) { installEventDom(); const timers = []; globalThis.window = { setTimeout(handler, delay) { timers.push({ handler, delay }); return timers.length; }, clearTimeout() {}, }; const viewer = eventViewer(); viewer.camera.flyToBoundingSphere = () => viewer._test.cameraLooks.push('corridor'); let activeMap = initialMap; const mapCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })), mediaLoader: async () => { const error = new Error('No bundled clip'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => activeMap, getSwitchGeneration: () => 1, async setStack(id) { mapCalls.push(id); activeMap = id; }, }); const manager = new LayerLifecycle(viewer); manager.register(layer); manager.finalizeRegistrations([{ id: layer.id, disposition: 'enabled-only' }]); try { const result = await manager.restoreLayerState(layer.id, { enabled: true, params: { presentation: 'scene-beat', sceneSurface: 'panel-only', progress: 0.34 }, }, { origin }); assert.equal(result.succeeded, true); assert.equal(layer.getPlaybackState().enabled, true); assert.deepEqual(mapCalls, [], `${initialMap}/${origin}: preserve the shot map`); assert.equal(activeMap, initialMap); const floodSource = viewer._test.dataSources.at(-1); for (const id of ['bhote-koshi-flood-halo', 'bhote-koshi-flood-core']) { const line = floodSource.entities.getById(id).polyline; assert.equal(line.clampToGround.getValue(), true, 'dynamic trail follows the rendered surface'); assert.equal(line.classificationType.getValue(), initialMap === 'photoreal' ? Cesium.ClassificationType.BOTH : Cesium.ClassificationType.TERRAIN); assert.equal(line.depthFailMaterial, undefined, 'do not rely on unsupported dynamic depth-fail rendering'); } assert.equal(floodSource.entities.getById('bhote-koshi-surge-front').point.heightReference.getValue(), Cesium.HeightReference.CLAMP_TO_GROUND); assert.deepEqual(viewer._test.cameraLooks, [], 'enable must not focus the corridor'); assert.deepEqual(timers, [], 'enable must not schedule independent playback'); assert.equal(layer.getPlaybackState().playing, false); assert.equal(await manager.setEnabled(layer.id, false, { origin: 'user' }), true); assert.deepEqual(mapCalls, [], 'disable must not restore a map it never owned'); } finally { await manager.destroyLayer(layer.id); } } } } finally { Object.assign(globalThis, previous); } }); test('trimmed scene media holds through provider startup, playback and fade, then releases the camera', async () => { const previous = { document: globalThis.document, window: globalThis.window }; installEventDom(); const timers = new Map(); let nextId = 0; globalThis.window = { setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { fn, delay }); return id; }, clearTimeout(id) { timers.delete(id); }, }; let phase = 'starting'; let hidden = 0; 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 () => { const error = new Error('No bundled clip'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, embeddedMediaFactory: () => ({ warm: () => true, show: () => true, play() {}, pause() {}, getPlaybackState: () => ({ phase }), hide() { hidden++; }, destroy() {} }), }); const fire = (delay) => { const entry = [...timers].find(([, value]) => value.delay === delay); assert.ok(entry, `expected ${delay}ms owned timer`); timers.delete(entry[0]); entry[1].fn(); }; 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: { 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); assert.equal(layer.getSceneShotMediaHold('different-beat'), null); fire(100); assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true, 'startup is not treated as playback'); phase = 'playing'; fire(100); assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true); const hiddenBeforeEnd = hidden; phase = 'completed'; fire(100); assert.ok(hidden > hiddenBeforeEnd); assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true, 'the fade still owns the hold'); fire(650); assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, false); await layer.disable(); assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null); } finally { await layer.destroy(); globalThis.document = previous.document; globalThis.window = previous.window; } }); 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); assert.equal(clampUnit(3), 1); assert.equal(elapsedLabel(0.5, 4170), '34:45'); assert.equal(elapsedLabel(1, 4170), '69:30'); }); test('Bhote Koshi flood reveal is monotonic and bounded', () => { assert.equal(visibleSegmentCount(0, 144), 0); assert.equal(visibleSegmentCount(Number.NaN, 144), 0); assert.equal(visibleSegmentCount(0.001, 144), 1); assert.equal(visibleSegmentCount(0.5, 144), 72); assert.equal(visibleSegmentCount(2, 144), 144); }); test('Bhote Koshi flood corridor grows continuously to the animated surge head', () => { const terrainPositions = Array.from( { length: 11 }, (_, index) => new Cesium.Cartesian3(index, 0, 0), ); const cache = { visibleCount: -1, positions: [] }; assert.equal(updateCorridorPositionCache(0.201, terrainPositions, cache), true); const firstPositions = cache.positions; assert.equal(cache.visibleCount, 3); assert.equal(firstPositions.length, 4); assert.ok(Math.abs(cache.headPosition.x - 2.01) < 1e-9); assert.equal(firstPositions.at(-1).x, cache.headPosition.x); assert.equal(updateCorridorPositionCache(0.299, terrainPositions, cache), true); assert.notEqual(cache.positions, firstPositions, 'the trail advances within the segment'); assert.ok(Math.abs(cache.headPosition.x - 2.99) < 1e-9); assert.equal(cache.positions.at(-1).x, cache.headPosition.x); const repeatedPositions = cache.positions; assert.equal(updateCorridorPositionCache(0.299, terrainPositions, cache), false); assert.equal(cache.positions, repeatedPositions, 'an unchanged frame reuses its positions'); assert.equal(updateCorridorPositionCache(0.301, terrainPositions, cache), true); assert.equal(cache.visibleCount, 4); assert.equal(cache.positions.length, 5); assert.ok(Math.abs(cache.headPosition.x - 3.01) < 1e-9); assert.equal(cache.positions.at(-1).x, cache.headPosition.x); }); test('Bhote Koshi story clock holds the flood for the cause beats then completes the corridor', () => { assert.equal(floodProgressForStoryProgress(0), 0); assert.equal(floodProgressForStoryProgress(0.11), 0); assert.equal(floodProgressForStoryProgress(0.22), 0); assert.ok(floodProgressForStoryProgress(0.5) > 0.4); assert.equal(floodProgressForStoryProgress(0.84), 1); assert.equal(floodProgressForStoryProgress(1), 1); }); test('Bhote Koshi camera holds its evidence beat, then travels by the next activation', () => { assert.equal(interpolateHeadingDegrees(350, 10, 0.5), 0); const reusableTarget = { x: 99, y: 99, z: 99 }; const result = { target: reusableTarget }; const keyframes = [ { id: 'cause', progress: 0, target: { x: 0, y: 0, z: 0 }, headingDeg: 350, pitchDeg: -50, rangeM: 1000, }, { id: 'border', progress: 1, target: { x: 10, y: 20, z: 30 }, headingDeg: 10, pitchDeg: -30, rangeM: 3000, }, ]; const held = sampleCinematicPose(0.5, keyframes, result); assert.deepEqual(held.target, { x: 0, y: 0, z: 0 }); assert.equal(held.headingDeg, 350); assert.equal(held.pitchDeg, -50); assert.equal(held.rangeM, 1000); const sampled = sampleCinematicPose(0.81, keyframes, result); assert.equal(sampled, result); assert.equal(sampled.target, reusableTarget); assert.ok(Math.abs(sampled.target.x - 5) < 1e-9); assert.ok(Math.abs(sampled.target.y - 10) < 1e-9); assert.ok(Math.abs(sampled.target.z - 15) < 1e-9); assert.equal(sampled.headingDeg, 0); assert.equal(sampled.pitchDeg, -40); assert.equal(sampled.rangeM, 2000); }); test('Bhote Koshi card summaries wrap on words instead of clipping mid-sentence', () => { assert.deepEqual( evidenceSummaryLines('Five geolocated public viewpoints document the flood passage.', 34, 2), ['Five geolocated public viewpoints', 'document the flood passage.'], ); assert.deepEqual(evidenceSummaryLines('one two three four five', 9, 1), ['one two…']); }); test('Bhote Koshi evidence timeline is ordered by phase and corridor chainage with one active beat', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); assert.deepEqual( timeline.map((beat) => beat.observation.id), [ 'immediate-collapse-viewpoint', 'debris-dammed-lake', 'second-landslide', 'gyirong-border-gate', 'timure-cluster', 'syabru-besi', 'dhunche', 'mailung-upper-trishuli', 'mailung-bazzar', 'dandagaun', 'dandagaun-viewpoint', 'betrawati-bazaar', 'bhainse', 'bidur-trishuli-bridge', 'devighat-taadi-khola-bridge', 'charaudi', ], ); assert.equal(timeline[0].activationProgress, 0); assert.equal(timeline[1].activationProgress, 0.065); assert.equal(timeline[2].activationProgress, 0.13); assert.ok(timeline[3].activationProgress > timeline[2].activationProgress); assert.ok(timeline[4].activationProgress > timeline[3].activationProgress); assert.equal(timeline[5].activationProgress, 0.325); for (let index = 0; index < timeline.length; index += 1) { assert.equal(activeEvidenceIndex(timeline[index].activationProgress, timeline), index); const midpoint = ( timeline[index].activationProgress + Math.min(1, timeline[index].deactivationProgress) ) / 2; assert.equal(activeEvidenceIndex(midpoint, timeline), index); const dwellSeconds = ( Math.min(1, timeline[index].deactivationProgress) - timeline[index].activationProgress ) * 90; assert.ok(dwellSeconds >= 2, `${timeline[index].observation.id} dwell is too short`); } const anchors = timeline.map((_, index) => ({ x: index, y: index + 1, z: index + 2 })); const cameraFrames = buildCinematicKeyframes(timeline, anchors); assert.deepEqual(cameraFrames.slice(0, 16).map((frame) => frame.id), timeline.map((beat) => beat.observation.id)); assert.deepEqual(cameraFrames.slice(0, 16).map((frame) => frame.progress), timeline.map((beat) => beat.activationProgress)); assert.equal(cameraFrames.at(-1).id, 'charaudi'); assert.equal(cameraFrames.at(-1).progress, 1); }); test('Bhote Koshi scene beats resolve stable ids inside their reveal windows', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); for (const beat of timeline) { const progress = evidenceBeatProgress(beat.observation.id, timeline, 0.36); assert.ok(progress > beat.activationProgress || beat.deactivationProgress <= beat.activationProgress); assert.ok(progress < Math.min(1.000001, beat.deactivationProgress)); assert.equal(activeEvidenceIndex(progress, timeline), timeline.indexOf(beat)); } assert.equal(evidenceBeatProgress('missing-beat', timeline), null); }); test('Bhote Koshi scene shots continue the standalone evidence clock from Border Gate onward', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); const border = evidenceSequenceWindow('gyirong-border-gate', timeline); assert.deepEqual(border, { startProgress: timeline[3].activationProgress, targetProgress: timeline[3].deactivationProgress, }); for (const beatId of [ 'timure-cluster', 'syabru-besi', 'bidur-trishuli-bridge', ]) { const beat = timeline.find(({ observation }) => observation.id === beatId); const window = evidenceSequenceWindow(beatId, timeline); assert.equal(window.startProgress, beat.activationProgress); assert.equal(window.targetProgress, Math.min(1, beat.deactivationProgress)); assert.ok(window.targetProgress > window.startProgress); } assert.equal(evidenceSequenceWindow('missing-beat', timeline), null); }); test('Bhote Koshi evidence presentation reveals dot, leader, and card then tears down in reverse', () => { const beat = { activationProgress: 0.2, deactivationProgress: 0.4 }; assert.deepEqual(evidenceBeatPresentation(0.2, beat), { alpha: 0, scale: 0.96, leaderProgress: 0, contentAlpha: 0, localProgress: 0, }); const revealed = evidenceBeatPresentation(0.2075, beat); assert.equal(revealed.alpha, 1); assert.equal(revealed.leaderProgress, 1); assert.ok(revealed.contentAlpha > 0 && revealed.contentAlpha < 1); assert.ok(revealed.scale > 0.96 && revealed.scale < 1); const held = evidenceBeatPresentation(0.3, beat); assert.equal(held.alpha, 1); assert.equal(held.contentAlpha, 1); assert.equal(held.scale, 1); const receding = evidenceBeatPresentation(0.35, beat); assert.equal(receding.alpha, 1, 'the terrain dot remains after card recession starts'); assert.ok(receding.contentAlpha < receding.leaderProgress); assert.ok(receding.scale < held.scale); const late = evidenceBeatPresentation(0.375, beat); assert.equal(late.contentAlpha, 0, 'card content clears first'); assert.ok(late.leaderProgress > 0 && late.leaderProgress < late.alpha); assert.equal(evidenceBeatPresentation(0.4, beat).alpha, 0); }); test('Bhote Koshi evidence cards freeze useful landscape, portrait, and link-only footprints', () => { assert.equal(evidenceCardLayout({ media: { posterUrl: 'poster.jpg', width: 1920, height: 1080 }, }).kind, 'landscape'); assert.deepEqual( { kind: evidenceCardLayout({ media: { videoPath: 'portrait.mp4', width: 240, height: 422 }, }).kind, width: evidenceCardLayout({ media: { videoPath: 'portrait.mp4', width: 240, height: 422 }, }).thumbnailWidth, height: evidenceCardLayout({ media: { videoPath: 'portrait.mp4', width: 240, height: 422 }, }).thumbnailHeight, }, { kind: 'portrait', width: 168, height: 260 }, ); assert.equal(evidenceCardLayout({ media: { posterUrl: null } }).kind, 'link'); }); test('Bhote Koshi gives YouTube and Facebook players priority over local review clips', () => { assert.equal(usesProviderEmbeddedEvidence({ media: { sourceUrl: 'https://www.youtube.com/watch?v=DbqRexFxv3k', videoPath: 'evidence-01.mp4', }, }), true); assert.equal(usesProviderEmbeddedEvidence({ media: { sourceUrl: 'https://www.facebook.com/reel/1571491657757829', videoPath: 'evidence-15.mp4', }, }), true); assert.equal(usesProviderEmbeddedEvidence({ media: { sourceUrl: 'https://example.com/evidence', videoPath: 'evidence-01.mp4', }, }), false); }); test('Bhote Koshi beat navigation is deterministic in both directions and clamps at its ends', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); assert.equal(adjacentEvidenceIndex(0, timeline, -1), 0); assert.equal(adjacentEvidenceIndex(0, timeline, 1), 1); assert.equal(adjacentEvidenceIndex(timeline[3].activationProgress, timeline, -1), 2); assert.equal(adjacentEvidenceIndex(timeline[3].activationProgress, timeline, 1), 4); assert.equal(adjacentEvidenceIndex(1, timeline, 1), 15); assert.equal(adjacentEvidenceIndex(0.5, [], 1), -1); }); test('Bhote Koshi evidence fallback stays media-first and source-linked without card copy overload', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const timure = event.evidenceSpine.find((observation) => observation.id === 'timure-cluster'); const originalDocument = globalThis.document; const contexts = []; globalThis.document = { createElement() { const text = []; const context = { text, beginPath() {}, clearRect() {}, moveTo() {}, lineTo() {}, stroke() {}, fillRect() {}, fillText(value) { text.push(String(value)); }, drawImage() { throw new Error('fixture poster decode failure'); }, }; contexts.push(context); return { width: 0, height: 0, getContext: () => context }; }, }; try { const missingPosterFrame = createEvidenceFrame(timure); const failedPosterFrame = createEvidenceFrame(timure, { width: 640, height: 400 }); assert.ok(missingPosterFrame); assert.ok(failedPosterFrame); for (const context of contexts) { assert.ok(context.text.includes('NO PREVIEW')); assert.ok(context.text.includes('TIMURE')); assert.ok(context.text.includes('YOUTUBE SOURCE / OPEN')); assert.doesNotMatch(context.text.join(' '), /CAPTURE TIME|GEOLOCATED|public viewpoints/); } assert.match(timure.media.sourceUrl, /^https:\/\//); } finally { globalThis.document = originalDocument; } }); test('Bhote Koshi evidence compositor contains portrait video from its decoded dimensions', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const observation = event.evidenceSpine[0]; const drawCalls = []; const context = { beginPath() {}, clearRect() {}, moveTo() {}, lineTo() {}, stroke() {}, fillRect() {}, fillText() {}, drawImage(...args) { drawCalls.push(args); }, }; const layout = evidenceCardLayout(observation); const canvas = { width: layout.canvasWidth, height: layout.canvasHeight, _bhoteEvidenceLayout: layout, getContext: () => context, }; const video = { videoWidth: 240, videoHeight: 422, width: 0, height: 0 }; assert.equal(updateEvidenceFrame(canvas, observation, video, { videoActive: true }), true); assert.equal(drawCalls.length, 1); const [, destX, destY, destWidth, destHeight] = drawCalls[0]; assert.ok(destX > 0, 'portrait video keeps narrow side gutters instead of being cropped'); assert.equal(destY, 0); assert.ok(destWidth < layout.canvasWidth); assert.equal(destHeight, layout.mediaHeight); assert.equal(context.textAlign, 'left', 'repeat paints cannot inherit the outside badge alignment'); assert.equal(updateEvidenceFrame(canvas, observation, video, { videoActive: true }), true); assert.equal(context.textAlign, 'left'); }); test('Bhote Koshi failed direct enable restores the prior map and shared split', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const mapCalls = []; const overlayCalls = []; const originalDocument = globalThis.document; const originalWindow = globalThis.window; let activeMap = 'photoreal'; let generation = 7; const viewer = { scene: { splitPosition: 0.37 }, imageryLayers: { remove() {} }, dataSources: { remove() {} }, }; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async () => { throw new Error('fixture image failure'); }, overlayHost: { setEntries() {}, setVisible(id, visible) { overlayCalls.push(['visible', id, visible]); }, clearSource(id) { overlayCalls.push(['clear', id]); }, }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => activeMap, getSwitchGeneration: () => generation, async setStack(id) { activeMap = id; generation += 1; mapCalls.push(id); }, }); globalThis.document = { documentElement: { style: { removeProperty() {} } } }; globalThis.window = { clearTimeout() {} }; try { await assert.rejects( layer.enable(viewer, { origin: 'scene' }), /fixture image failure/, ); assert.deepEqual(mapCalls, ['esri-imagery', 'photoreal']); assert.equal(viewer.scene.splitPosition, 0.37); assert.deepEqual(layer.getPlaybackState(), { enabled: false, playing: false, progress: 0, split: 0.5, }); assert.ok(overlayCalls.some(([kind]) => kind === 'clear')); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Bhote Koshi cancellation during the map switch restores the prior stack', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const controller = new AbortController(); const viewer = eventViewer(); let activeMap = 'photoreal'; let generation = 12; let finishSwitch; const switchGate = new Promise((resolve) => { finishSwitch = resolve; }); const mapCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => activeMap, getSwitchGeneration: () => generation, async setStack(id) { activeMap = id; generation += 1; mapCalls.push(id); if (id === 'esri-imagery') await switchGate; }, }); installEventDom(); globalThis.window = { clearTimeout() {} }; try { const enabling = layer.enable(viewer, { origin: 'scene', signal: controller.signal }); await new Promise((resolve) => setTimeout(resolve, 0)); controller.abort(); finishSwitch(); await assert.rejects(enabling, (error) => error?.name === 'AbortError'); assert.deepEqual(mapCalls, ['esri-imagery', 'photoreal']); assert.equal(viewer.scene.splitPosition, 0.37); assert.equal(layer.getPlaybackState().enabled, false); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Bhote Koshi disable preserves an operator-selected map and tears down every surface', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const { body, removedProperties } = installEventDom(); assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test'); const clearedTimeouts = []; globalThis.window = { setTimeout: () => 71, clearTimeout: (id) => clearedTimeouts.push(id), }; const viewer = eventViewer(); let activeMap = 'photoreal'; let generation = 20; const mapCalls = []; const overlayCalls = []; const renderCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), overlayHost: { setEntries() {}, setVisible(id, visible) { overlayCalls.push(['visible', id, visible]); }, clearSource(id) { overlayCalls.push(['clear', id]); }, }, renderHost: { request(reason) { renderCalls.push(reason); }, hold() {}, release() {}, }, }); layer.attachMapStackController({ getActiveId: () => activeMap, getSwitchGeneration: () => generation, async setStack(id) { activeMap = id; generation += 1; mapCalls.push(id); }, }); try { assert.equal(await layer.enable(viewer, { origin: 'scene' }), true); assert.equal(viewer._test.imagery.length, 2); assert.equal(viewer._test.dataSources.length, 1); assert.ok(eventPanel()); assert.ok(eventSplit()); activeMap = 'operator-choice'; generation += 1; await layer.disable(); assert.deepEqual(mapCalls, ['esri-imagery']); assert.equal(viewer._test.removedImagery.length, 2); assert.equal(viewer._test.removedDataSources.length, 1); assert.equal(eventPanel(), undefined); assert.equal(eventSplit(), undefined); assert.equal(viewer.scene.splitPosition, 0.37); assert.deepEqual(clearedTimeouts, [71]); assert.ok(overlayCalls.some(([kind]) => kind === 'clear')); assert.ok(removedProperties.includes('--bhote-koshi-split')); assert.ok(renderCalls.includes('bhote-koshi-disable')); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Bhote Koshi disable stops active playback and releases its global render hold', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalPerformance = globalThis.performance; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; const { body } = installEventDom(); const holds = []; const releases = []; const cancelledFrames = []; globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; globalThis.performance = { now: () => 1000 }; globalThis.requestAnimationFrame = () => 33; globalThis.cancelAnimationFrame = (id) => cancelledFrames.push(id); const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold(id) { holds.push(id); }, release(id) { releases.push(id); }, }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); try { await layer.enable(viewer, { origin: 'scene' }); const panel = eventPanel(); assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true); assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false); panel.querySelector('[data-action="play"]').dispatch('click'); assert.equal(layer.getPlaybackState().playing, true); await layer.disable(); assert.deepEqual(holds, ['bhote-koshi-playback']); assert.deepEqual(releases, ['bhote-koshi-playback']); assert.deepEqual(cancelledFrames, [33]); assert.deepEqual(layer.getPlaybackState(), { enabled: false, playing: false, progress: 0, split: 0.5, }); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; globalThis.performance = originalPerformance; globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; } }); test('Bhote Koshi passive restores do not start off-screen playback', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; installEventDom(); const timers = []; globalThis.window = { setTimeout(handler, delay) { timers.push({ handler, delay }); return timers.length; }, clearTimeout() {}, }; const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), embeddedMediaFactory: () => ({ show() {}, hide() {}, pause() {}, destroy() {} }), overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); const manager = new LayerLifecycle(viewer); manager.register(layer); try { assert.equal(await manager.setEnabled(layer.id, true, { origin: 'local-restore' }), true); assert.deepEqual(timers, []); assert.deepEqual(viewer._test.cameraLooks, []); assert.equal(layer.getPlaybackState().playing, false); } finally { await manager.destroyLayer(layer.id); globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Bhote Koshi standalone Scene keeps its Esri cinematic contract', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalPerformance = globalThis.performance; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; installEventDom(); const timers = []; globalThis.window = { setTimeout(handler, delay) { timers.push({ handler, delay }); return timers.length; }, clearTimeout() {}, }; globalThis.performance = { now: () => 1000 }; globalThis.requestAnimationFrame = () => 44; globalThis.cancelAnimationFrame = () => {}; const viewer = eventViewer(); let activeMap = 'photoreal'; const mapCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => activeMap, getSwitchGeneration: () => mapCalls.length, async setStack(id) { activeMap = id; mapCalls.push(id); }, }); try { await layer.enable(viewer, { origin: 'scene' }); assert.deepEqual(mapCalls, ['esri-imagery']); assert.equal(viewer._test.imagery.length, 2); assert.equal(timers.length, 1); assert.equal(timers[0].delay, 2400); timers[0].handler(); assert.equal(layer.getPlaybackState().playing, true); assert.equal(layer.getCinematicState().active, true); assert.equal(viewer._test.cameraLooks.length, 1); await layer.disable(); assert.deepEqual(mapCalls, ['esri-imagery', 'photoreal']); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; globalThis.performance = originalPerformance; globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; } }); test('Bhote Koshi scene beats preserve photoreal and never take camera ownership', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalPerformance = globalThis.performance; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; const { body } = installEventDom(); assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test'); const timers = []; const openedUrls = []; const cancelledFrames = []; globalThis.window = { setTimeout(handler, delay) { timers.push({ handler, delay }); return timers.length; }, clearTimeout() {}, open(url) { openedUrls.push(url); return { opener: 'fixture' }; }, }; globalThis.performance = { now: () => 1000 }; globalThis.requestAnimationFrame = () => 91; globalThis.cancelAnimationFrame = (id) => cancelledFrames.push(id); const viewer = eventViewer(); let activeMap = 'photoreal'; const mapCalls = []; const overlayPublications = []; const overlayVisibility = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries(id, entries, options) { overlayPublications.push({ id, entries, options }); }, setVisible(id, visible) { overlayVisibility.push({ id, visible }); }, clearSource() {}, }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => activeMap, getSwitchGeneration: () => 1, async setStack(id) { activeMap = id; mapCalls.push(id); }, }); const sceneCalls = []; let finishScene; let publishSceneClock; layer.attachSceneController({ subscribeSceneClock(listener) { publishSceneClock = listener; return () => { publishSceneClock = null; }; }, continueScene(sceneId, shotId) { sceneCalls.push({ action: 'scene', sceneId, shotId }); return new Promise((resolve) => { finishScene = resolve; }); }, replayShot(sceneId, shotId) { sceneCalls.push({ action: 'play', sceneId, shotId }); return true; }, loadAdjacentShot(sceneId, shotId, direction) { sceneCalls.push({ action: 'adjacent', sceneId, shotId, direction }); return true; }, seekScene(sceneId, progress) { sceneCalls.push({ action: 'seek', sceneId, progress }); return true; }, }); try { assert.equal(layer.setParams({ presentation: 'scene-beat', sceneSurface: 'panel-only', progress: 0.34, split: 0.62, cinematic: true, autoPlay: true, sceneContext: { sceneId: 'nepal-scene', sceneTitle: 'Nepal Flood Incident', shotId: 'global-context', shotTitle: 'Global Incident Context', shotIndex: 0, shotCount: 14, durationSec: 4, holdSec: 0.9, sceneElapsedSec: 0, sceneDurationSec: 120, sceneProgress: 0, }, }, { origin: 'scene' }), true); await layer.enable(viewer, { origin: 'scene' }); assert.deepEqual(mapCalls, []); assert.equal(activeMap, 'photoreal'); assert.deepEqual(timers, []); assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair'); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); const panel = eventPanel(); assert.ok(panel); assert.ok(eventSplit()); assert.equal(eventSplit().view.hidden, true); assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true); assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i); assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i); assert.equal(panel.querySelector('[data-role="imagery-cloud-note"]').hidden, true); const sceneClock = panel.querySelector('[data-role="progress"]'); assert.equal(sceneClock.hidden, false); assert.equal(sceneClock.disabled, false); assert.equal(sceneClock.getAttribute('aria-label'), 'Seek Nepal scene clock'); assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'NEPAL SCENE CLOCK'); assert.equal(panel.querySelector('[data-role="time"]').textContent, '~0:00 / 2:00'); publishSceneClock({ sceneId: 'nepal-scene', shotId: 'global-context', shotIndex: 0, shotCount: 14, sceneElapsedSec: 30, sceneDurationSec: 120, sceneProgress: 0.25, running: true, }); assert.equal(sceneClock.value, '250'); assert.equal(panel.querySelector('[data-role="time"]').textContent, '~0:30 / 2:00'); assert.equal(panel.querySelector('[data-role="beat-index"]').textContent, '01 / 14'); assert.equal(panel.querySelector('[data-role="beat-title"]').textContent, 'Global Incident Context'); assert.equal(panel.querySelector('[data-action="play"]').textContent, '▶ PLAY SHOT'); const playScene = panel.querySelector('[data-action="play-scene"]'); assert.equal(playScene.hidden, false); assert.equal(playScene.disabled, false); assert.equal(playScene.textContent, '▶ PLAY SCENE'); assert.equal(playScene.getAttribute('aria-label'), 'Continue Nepal Flood Incident from next shot'); assert.equal(panel.querySelector('[data-action="cinematic"]').hidden, true); assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true); assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false); assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false); const fullStory = panel.querySelector('[data-action="story-replay"]'); assert.equal(fullStory.textContent, '↺ FULL STORY'); assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback'); assert.equal(fullStory.disabled, true); assert.equal(panel.querySelector('[data-action="open-source"]').hidden, true); assert.deepEqual(layer.getParams(), { presentation: 'scene-beat', sceneSurface: 'panel-only', progress: 0.34, split: 0.62, }); assert.equal(viewer.scene.splitPosition, 0.62); layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'timure-cluster', sceneControls: { imageryComparison: true }, }, { origin: 'scene' }); assert.equal(eventSplit().view.hidden, false); assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true)); assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle'); assert.equal(eventSplit().view.valueNow, 62); const splitHandle = eventSplit().controls; splitHandle.pointerdown({ button: 0, clientX: 250, pointerId: 4, preventDefault() {}, }); splitHandle.pointermove({ clientX: 750, pointerId: 4, preventDefault() {}, }); splitHandle.pointerup({ pointerId: 4 }); assert.equal(viewer.scene.splitPosition, 0.75); assert.equal(eventSplit().view.valueNow, 75); splitHandle.keydown({ key: 'ArrowLeft', shiftKey: true, preventDefault() {}, }); assert.equal(viewer.scene.splitPosition, 0.7); layer.setParams({ presentation: 'scene-beat', sceneSurface: 'panel-only', progress: 0.34, split: 0.62, sceneControls: {}, }, { origin: 'scene' }); assert.equal(eventSplit().view.hidden, true); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); assert.equal(viewer.scene.splitPosition, 0.62); assert.equal(overlayVisibility.at(-1).visible, false); for (const id of [ 'bhote-koshi-flood-halo', 'bhote-koshi-flood-core', 'bhote-koshi-surge-front', ]) { assert.equal(viewer._test.dataSources[0].entities.getById(id).show, false); } panel.querySelector('[data-action="play"]').dispatch('click'); await Promise.resolve(); panel.querySelector('[data-action="next-beat"]').dispatch('click'); await Promise.resolve(); assert.deepEqual(sceneCalls, [ { action: 'play', sceneId: 'nepal-scene', shotId: 'global-context' }, { action: 'adjacent', sceneId: 'nepal-scene', shotId: 'global-context', direction: 1 }, ]); const run = playScene.dispatch('click'); assert.equal(playScene.disabled, true); assert.equal(playScene.textContent, '… PLAYING SCENE'); assert.equal(panel.querySelector('[data-action="play"]').disabled, true); assert.equal(panel.querySelector('[data-action="play"]').textContent, '▶ PLAY SHOT'); await playScene.dispatch('click'); assert.deepEqual(sceneCalls.at(-1), { action: 'scene', sceneId: 'nepal-scene', shotId: 'global-context', }); assert.equal(sceneCalls.length, 3, 'duplicate clicks cannot start another scene run'); finishScene({ started: true }); await run; assert.equal(playScene.disabled, false); assert.equal(playScene.textContent, '▶ PLAY SCENE'); sceneClock.value = '500'; sceneClock.dispatch('input'); assert.equal(panel.querySelector('[data-role="time"]').textContent, '~1:00 / 2:00'); sceneClock.dispatch('change'); await Promise.resolve(); assert.deepEqual(sceneCalls.at(-1), { action: 'seek', sceneId: 'nepal-scene', progress: 0.5 }); assert.equal(sceneCalls.length, 4); layer.attachSceneController({ continueScene: async () => ({ started: false, reason: 'already-running' }) }); assert.deepEqual(await playScene.dispatch('click'), { started: false, reason: 'already-running' }); assert.equal(playScene.disabled, false, 'a refused start releases the button'); layer.attachSceneController({ continueScene: async () => { throw new Error('test scene failure'); } }); const originalWarn = console.warn; const warnings = []; try { console.warn = (...args) => warnings.push(args); assert.equal(await playScene.dispatch('click'), false); assert.equal(playScene.disabled, false, 'a rejected start releases the button'); assert.equal(warnings.length, 1); } finally { console.warn = originalWarn; } layer.attachSceneController(null); assert.equal(playScene.disabled, true); await playScene.dispatch('click'); assert.equal(sceneCalls.length, 4); assert.equal(layer.getPlaybackState().playing, false); for (const reportButton of panel.querySelector('.bhote-event-report-list').children) { reportButton.dispatch('click'); } panel.querySelector('[data-action="geolocation-map"]').dispatch('click'); assert.deepEqual(openedUrls, [ ...event.fieldReports.map(({ url }) => url), event.geolocationMap.sourceUrl, ]); assert.equal(layer.getPlaybackState().playing, false); assert.equal(layer.getCinematicState().active, false); assert.equal(viewer._test.cameraLooks.length, 0); assert.deepEqual(mapCalls, []); assert.ok(eventPanel()); assert.equal(viewer.scene.splitPosition, 0.62); for (const beatId of [ 'debris-dammed-lake', 'gyirong-border-gate', 'timure-cluster', 'syabru-besi', 'bidur-trishuli-bridge', ]) { layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId, beatReveal: 0.36, }, { origin: 'scene' }); assert.equal(layer.getParams().beatId, beatId); assert.equal(layer.getParams().sceneSurface, 'evidence-beat'); assert.equal(panel.querySelector('[data-action="open-source"]').hidden, false); assert.equal(overlayVisibility.at(-1).visible, true); const entries = overlayPublications.at(-1).entries; const visibleCards = entries.filter((entry) => ( entry.id.startsWith('evidence-card-') && entry.position() != null )); assert.deepEqual(visibleCards.map(({ id }) => id), [`evidence-card-${beatId}`]); assert.ok(entries .filter((entry) => entry.id.startsWith('evidence-breadcrumb-')) .every((entry) => entry.position() == null)); assert.equal(viewer._test.dataSources.filter((source) => ( !viewer._test.removedDataSources.includes(source) )).length, 1, 'surface transitions leave exactly one active event surface'); assert.equal(viewer._test.cameraLooks.length, 0, 'consecutive beats never write the camera'); } panel.querySelector('[data-action="open-source"]').dispatch('click'); assert.equal(openedUrls.at(-1), event.evidenceSpine.find(({ id }) => id === 'bidur-trishuli-bridge').media.sourceUrl); layer.setParams({ presentation: 'scene-beat', progress: 0.2, }, { origin: 'scene' }); assert.equal(layer.getParams().sceneSurface, 'panel-only', 'missing ownership fails closed'); assert.equal(panel.querySelector('[data-action="open-source"]').hidden, true); assert.equal(overlayVisibility.at(-1).visible, false); await layer.disable(); assert.deepEqual( cancelledFrames, [91], 'disable revokes the pending real-time scene-clock scrub frame', ); assert.deepEqual(mapCalls, []); assert.equal(viewer._test.removedDataSources.length, viewer._test.dataSources.length); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; globalThis.performance = originalPerformance; globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; } }); test('Nepal hybrid comparison preserves native split and terrain-clamped flood trail', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; installEventDom(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; const viewer = eventViewer(); const mapCalls = []; const comparisonCalls = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'photoreal', getSwitchGeneration: () => 1, async setStack(id) { mapCalls.push(id); }, async setTerrainComparison(rectangle) { comparisonCalls.push(rectangle); return true; }, clearTerrainComparison() { comparisonCalls.push(null); }, }); try { layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'gyirong-border-gate', sceneControls: { imageryComparison: true }, }, { origin: 'scene' }); await layer.enable(viewer, { origin: 'scene' }); assert.deepEqual(mapCalls, [], 'comparison does not switch the authored Google base'); assert.deepEqual(comparisonCalls, [event.imagery.rectangle]); assert.equal(viewer._test.imagery.length, 2); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true)); assert.equal(viewer.scene.splitPosition, 0.5); assert.equal(viewer._test.imagery[0].splitDirection, Cesium.SplitDirection.LEFT); assert.equal(viewer._test.imagery[1].splitDirection, Cesium.SplitDirection.RIGHT); layer.setParams({ split: 0.25 }); assert.equal(viewer.scene.splitPosition, 0.25); const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core'); const surge = viewer._test.dataSources[0].entities.getById('bhote-koshi-surge-front'); assert.equal(flood.polyline.clampToGround.getValue(), true); assert.equal( surge.point.heightReference.getValue(), Cesium.HeightReference.CLAMP_TO_GROUND, ); layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'timure', sceneControls: { imageryComparison: false }, }, { origin: 'scene' }); assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false)); assert.equal(comparisonCalls.at(-1), null); const tileFlood = viewer._test.dataSources.at(-1).entities.getById('bhote-koshi-flood-core'); assert.equal(tileFlood.polyline.clampToGround.getValue(), true); assert.equal(tileFlood.polyline.classificationType.getValue(), Cesium.ClassificationType.BOTH); await layer.disable(); assert.equal(viewer._test.removedImagery.length, 2); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Bhote Koshi event pack keeps observations separate from reconstruction', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); assert.equal(BHOTE_KOSHI_LAYER_ID, event.id); assert.equal(event.imagery.before.observedAt, '2021-10-16'); assert.equal(event.imagery.after.observedAt, '2026-08-27'); assert.equal( event.reconstruction.label, 'SCHEMATIC DOWNSTREAM PROGRESSION, NOT MODELED ARRIVAL TIME', ); assert.match(event.imagery.before.label, /^2021 HISTORICAL REFERENCE/); assert.match(event.imagery.after.label, /^2026 POST-EVENT/); assert.equal( event.geolocationMap.sourceUrl, 'https://x.com/geogeorgeology/status/2093632283442053371', ); assert.ok(event.reconstruction.corridor.length >= 100); assert.equal(event.witnessAnchors.length, 1); assert.ok(Number.isFinite(event.witnessAnchors[0].elevationM)); assert.match(event.witnessAnchors[0].title, /SOURCE GEO/); assert.equal(event.viewpoints.witness.imagery, 'before'); assert.equal( event.fieldReports.filter((report) => report.status.includes('WORLD-PINNED')).length, 1, ); assert.ok(event.fieldReports.some((report) => report.status.includes('UNVERIFIED'))); assert.equal(event.evidenceSpine.length, 16); assert.equal(new Set(event.evidenceSpine.map(({ id }) => id)).size, 16); assert.deepEqual(event.evidenceSpine.map(({ sequence }) => sequence), Array.from({ length: 16 }, (_, index) => index + 1)); assert.equal(floodProgressForStoryProgress(0.195, event.reconstruction.storyFloodWindow), 0); assert.equal(floodProgressForStoryProgress(0.39, event.reconstruction.storyFloodWindow), 1); assert.equal(floodProgressForStoryProgress(0.9, event.reconstruction.storyFloodWindow), 1); for (const { media } of event.evidenceSpine) { for (const asset of [media.videoPath, media.posterPath].filter(Boolean)) { assert.match(asset, /^evidence-\d+\.(mp4|jpg)$/); assert.ok((await readFile(new URL(asset, eventUrl))).length > 0, asset); } } assert.ok(event.evidenceSpine.every((observation) => observation.timing.capturedAt === null)); assert.ok(event.evidenceSpine.every((observation) => ( observation.timing.note === 'capture time unverified' ))); assert.ok(event.evidenceSpine.every((observation) => /^https:\/\//.test(observation.media.sourceUrl))); assert.ok(event.evidenceSpine.filter((observation) => observation.media.posterUrl).every((observation) => ( observation.media.posterUrl.startsWith('https://i.ytimg.com/') ))); assert.equal( event.evidenceSpine.find(({ id }) => id === 'gyirong-border-gate').media.clipInSec, 26, ); assert.ok(event.evidenceSpine.every(({ media }) => !media.videoPath && !media.posterPath), 'Public event records must not reference privately cached social clips'); assert.equal(event.evidenceSpine.filter((observation) => ( observation.imageryCoverage === 'outside' )).length, 13); const timure = event.evidenceSpine.find((observation) => observation.id === 'timure-cluster'); assert.equal(timure.corroboration.kind, 'multi-source geolocation cluster'); assert.equal(timure.corroboration.sourceCount, 5); assert.equal(timure.corroboration.displayedSourceCount, 1); assert.equal(evidenceCorroborationLabel(timure), '5 GEOLOCATED SOURCE-MAP PLACEMENTS'); assert.match(event.reconstruction.caveat, /2021 historical reference versus 2026 post-event/i); assert.match(event.reconstruction.caveat, /not modeled arrival time/i); assert.match(event.reconstruction.caveat, /2025 Rasuwagadhi flood/i); assert.equal(SCENE_RECIPES.some((recipe) => recipe.id === 'bhote-koshi-flood'), false); assert.ok(SCENE_RECIPES.some((recipe) => recipe.id === 'bhote-koshi-nepal-scene')); }); test('Bhote Koshi publishes the evidence spine once and scrubs through cards and breadcrumbs', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const { body } = installEventDom(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; const viewer = eventViewer(); const publications = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, evidenceFrameFactory: (observation) => ({ fixture: observation.id }), overlayHost: { setEntries(id, entries, options) { publications.push({ id, entries, options }); }, setVisible() {}, clearSource() {}, }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); try { await layer.enable(viewer, { origin: 'local-restore' }); assert.equal(publications.length, 1); assert.equal(publications[0].entries.length, 32); const cards = publications[0].entries.filter((entry) => entry.variant === 'thumbnail'); const breadcrumbs = publications[0].entries.filter((entry) => entry.variant === 'label'); assert.equal(cards.filter((entry) => entry.position()).length, 1); assert.match(cards.find((entry) => entry.position()).id, /immediate-collapse/); assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0); assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1)); const panel = eventPanel(); const progress = panel.querySelector('[data-role="progress"]'); progress.value = '285'; progress.dispatch('input'); assert.equal(publications.length, 1); assert.equal(cards.filter((entry) => entry.position()).length, 1); assert.match(cards.find((entry) => entry.position()).id, /timure-cluster/); assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 4); await layer.disable(); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Bhote Koshi keeps one local fallback video active and unloads it on beat change and disable', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); // Synthetic clips exercise the player; public event data never bundles these. event.evidenceSpine.find(({ id }) => id === 'immediate-collapse-viewpoint').media.videoPath = 'evidence-01.mp4'; event.evidenceSpine.find(({ id }) => id === 'timure-cluster').media.videoPath = 'evidence-04.mp4'; event.evidenceSpine.find(({ id }) => id === 'immediate-collapse-viewpoint').media.sourceUrl = 'https://example.com/immediate-collapse'; event.evidenceSpine.find(({ id }) => id === 'timure-cluster').media.sourceUrl = 'https://example.com/timure'; const originalDocument = globalThis.document; const originalWindow = globalThis.window; const { body } = installEventDom(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; const viewer = eventViewer(); const videos = []; const videoFactory = () => { const listeners = new Map(); const video = { muted: false, playsInline: false, preload: '', loop: false, paused: true, currentTime: 0, duration: 10, playbackRate: 1, pauseCalls: 0, playCalls: 0, loadCalls: 0, removedSources: 0, addEventListener(type, handler) { listeners.set(type, handler); }, load() { this.loadCalls += 1; }, pause() { this.pauseCalls += 1; this.paused = true; }, play() { this.playCalls += 1; this.paused = false; return Promise.resolve(); }, removeAttribute(name) { if (name === 'src') { this.src = ''; this.removedSources += 1; } }, dispatch(type) { listeners.get(type)?.(); }, }; videos.push(video); return video; }; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, evidenceFrameFactory: (observation) => ({ fixture: observation.id }), evidenceFrameUpdater: () => true, videoFactory, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); try { await layer.enable(viewer, { origin: 'local-restore' }); assert.equal(videos.length, 1); assert.match(videos[0].src, /evidence-01\.mp4$/); videos[0].dispatch('loadeddata'); assert.equal(layer.getCinematicState().mediaReady, true); const panel = eventPanel(); const progress = panel.querySelector('[data-role="progress"]'); progress.value = '285'; progress.dispatch('input'); assert.equal(videos.length, 2, 'the new beat replaces rather than stacks a decoder'); assert.equal(videos[0].removedSources, 1); assert.ok(videos[0].pauseCalls >= 1); assert.match(videos[1].src, /evidence-04\.mp4$/); await layer.disable(); assert.equal(videos[1].removedSources, 1); assert.ok(videos[1].pauseCalls >= 1); assert.equal(layer.getCinematicState().mediaEvidenceId, null); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Upper Valley Collapse autoplays its muted local fallback without starting the event clock', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); event.evidenceSpine.find(({ id }) => id === 'immediate-collapse-viewpoint').media.videoPath = 'evidence-01.mp4'; event.evidenceSpine.find(({ id }) => id === 'immediate-collapse-viewpoint').media.sourceUrl = 'https://example.com/immediate-collapse'; const originalDocument = globalThis.document; const originalWindow = globalThis.window; const { body } = installEventDom(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; const viewer = eventViewer(); const videos = []; let frameUpdates = 0; const videoFactory = () => { const listeners = new Map(); const frameCallbacks = new Map(); const video = { muted: false, playsInline: false, preload: '', loop: false, paused: true, currentTime: 0, duration: 8.6, playbackRate: 1, playCalls: 0, pauseCalls: 0, cancelledFrameHandles: [], addEventListener(type, handler) { listeners.set(type, handler); }, load() {}, pause() { this.pauseCalls += 1; this.paused = true; }, play() { this.playCalls += 1; this.paused = false; return Promise.resolve(); }, removeAttribute(name) { if (name === 'src') this.src = ''; }, requestVideoFrameCallback(callback) { const handle = frameCallbacks.size + 1; frameCallbacks.set(handle, callback); return handle; }, cancelVideoFrameCallback(handle) { this.cancelledFrameHandles.push(handle); frameCallbacks.delete(handle); }, dispatch(type) { listeners.get(type)?.(); }, drawFrame(handle = [...frameCallbacks.keys()][0]) { const callback = frameCallbacks.get(handle); frameCallbacks.delete(handle); callback?.(); }, }; videos.push(video); return video; }; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, evidenceFrameFactory: (observation) => ({ fixture: observation.id }), evidenceFrameUpdater: () => { frameUpdates += 1; return true; }, videoFactory, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'photoreal', getSwitchGeneration: () => 1, async setStack() {}, }); try { layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', 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]; assert.equal(video.muted, true); assert.equal(video.loop, true); assert.equal(video.playCalls, 0, 'play waits until decoded media is ready'); video.dispatch('loadeddata'); assert.equal(video.playCalls, 1); assert.equal(video.playbackRate, 1); assert.equal(layer.getPlaybackState().playing, false); const updatesBeforeFrame = frameUpdates; video.currentTime = 1.2; video.drawFrame(); assert.ok(frameUpdates > updatesBeforeFrame, 'decoded frames repaint the anchored callout'); layer.setParams({ presentation: 'scene-beat', progress: 0.2 }, { origin: 'scene' }); assert.ok(video.pauseCalls >= 1); assert.ok(video.cancelledFrameHandles.length >= 1); assert.equal(layer.getCinematicState().mediaEvidenceId, null); assert.ok(eventPanel()); await layer.disable(); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Debris-Dammed Lake card opens its source and is armed for approved local video autoplay', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; installEventDom(); const opened = []; globalThis.window = { setTimeout: () => 71, clearTimeout() {}, open(...args) { opened.push(args); return { opener: 'fixture' }; }, }; const viewer = eventViewer(); let activeCard = null; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries(_id, entries) { activeCard = entries.find((entry) => entry.id === 'evidence-card-debris-dammed-lake'); }, setVisible() {}, clearSource() {}, hitTest(_x, _y, options) { if (!activeCard || options?.filter?.(activeCard) === false) return null; return { entry: activeCard }; }, }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'photoreal', getSwitchGeneration: () => 1, async setStack() {}, }); try { layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', sceneControls: { evidenceMediaAutoplay: true }, beatId: 'debris-dammed-lake', beatReveal: 0.36, }, { origin: 'scene' }); await layer.enable(viewer, { origin: 'scene' }); const lease = claimPointer('draw'); try { viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 }); assert.equal(opened.length, 0, 'drawing owns the pointer over evidence cards'); } finally { releasePointer(lease); } viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 }); assert.deepEqual(opened, [[ event.evidenceSpine[1].media.sourceUrl, '_blank', 'noopener,noreferrer', ]]); assert.equal(opened.length, 1); await layer.disable(); viewer._test.canvas.dispatch('click', { offsetX: 640, offsetY: 420 }); assert.equal(opened.length, 1, 'disabled layer releases card click ownership'); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('Nepal evidence shots resume the standalone callout and flood sequence from Border Gate', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalPerformance = globalThis.performance; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; installEventDom(); let now = 1000; let nextFrameId = 0; const frames = new Map(); const cancelledFrames = []; globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; globalThis.performance = { now: () => now }; globalThis.requestAnimationFrame = (callback) => { const id = ++nextFrameId; frames.set(id, callback); return id; }; globalThis.cancelAnimationFrame = (id) => { cancelledFrames.push(id); frames.delete(id); }; const viewer = eventViewer(); let entries = []; const embeddedCalls = []; const holds = []; const releases = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries(_id, published) { entries = published; }, setVisible() {}, clearSource() {}, }, renderHost: { request() {}, hold(id) { holds.push(id); }, release(id) { releases.push(id); }, }, embeddedMediaFactory: () => ({ warm(options) { embeddedCalls.push(['warm', options?.observation?.id || null]); return Boolean(options?.observation); }, show(options) { embeddedCalls.push(['show', options?.observation?.id || null]); return false; }, hide() { embeddedCalls.push(['hide']); }, destroy() {}, }), }); layer.attachMapStackController({ getActiveId: () => 'photoreal', getSwitchGeneration: () => 1, async setStack() {}, }); const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'gyirong-border-gate', beatReveal: 0.36, sceneContext: { sceneId: 'scene', shotId: 'shot' }, sceneControls: { evidenceSequence: true, evidenceSequenceDurationSec: 0.9, deferEvidenceUntilCameraSettled: true, evidenceRevealDurationSec: 0.9, cameraSettled: false, }, }; 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'); const surge = viewer._test.dataSources[0].entities.getById('bhote-koshi-surge-front'); assert.equal(card.position(), null, 'the evidence card is absent during the camera flight'); assert.equal(flood.show, false); assert.equal(surge.show, false); assert.ok( embeddedCalls.some(([action, id]) => action === 'warm' && id === 'gyirong-border-gate'), 'the upcoming Border Gate provider player warms while the camera is travelling', ); assert.equal( embeddedCalls.some(([action]) => action === 'show'), false, 'warming does not expose or autoplay the embedded card before camera settle', ); layer.setParams({ ...params, sceneControls: { ...params.sceneControls, cameraSettled: true }, }, { origin: 'scene' }); assert.ok( embeddedCalls.some(([action, id]) => action === 'show' && id === 'gyirong-border-gate'), 'camera settle promotes the warmed source into the visible callout', ); assert.ok(card.position(), 'camera settle publishes the anchored card'); assert.equal(card.sourceAlpha(), 0, 'the card starts from a hidden reveal state'); assert.ok(holds.includes('bhote-koshi-scene-beat-reveal')); now = 1050; const earlyReveal = frames.get(Math.max(...frames.keys())); frames.delete(Math.max(...frames.keys())); earlyReveal(now); assert.ok(card.sourceAlpha() > 0 && card.sourceAlpha() < 1); assert.equal(flood.show, true, 'the amber corridor grows with the callout reveal'); assert.equal(surge.show, true, 'the amber surge circle advances with the corridor'); now = 1900; const finalFrameId = Math.max(...frames.keys()); const finalFrame = frames.get(finalFrameId); frames.delete(finalFrameId); finalFrame(now); assert.equal( layer.getPlaybackState().progress, timeline.find(({ observation }) => observation.id === 'timure-cluster').activationProgress, 'the Border Gate surge reaches the next evidence point', ); assert.equal(card.sourceAlpha(), 0, 'the Border Gate card recedes at the next beat'); assert.ok(releases.includes('bhote-koshi-scene-beat-reveal')); const timureBeat = timeline.find(({ observation }) => observation.id === 'timure-cluster'); layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'timure-cluster', beatReveal: 0.36, sceneControls: { imageryComparison: true, evidenceSequence: true, evidenceSequenceDurationSec: 4, }, }, { origin: 'scene' }); const timureCard = entries.find(({ id }) => id === 'evidence-card-timure-cluster'); assert.equal(layer.getPlaybackState().progress, timureBeat.activationProgress); assert.equal(timureCard.sourceAlpha(), 0, 'the next callout begins below its activation'); now = 3900; const timureReveal = frames.get(Math.max(...frames.keys())); frames.delete(Math.max(...frames.keys())); timureReveal(now); assert.ok(timureCard.sourceAlpha() > 0, 'the Timure callout enters during its authored shot'); now = 5900; const timureFinalId = Math.max(...frames.keys()); const timureFinal = frames.get(timureFinalId); frames.delete(timureFinalId); timureFinal(now); assert.equal(layer.getPlaybackState().progress, timureBeat.deactivationProgress); assert.equal(timureCard.sourceAlpha(), 0); await layer.disable(); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; globalThis.performance = originalPerformance; globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; } }); test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one clock', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const timeline = buildEvidenceTimeline(event.evidenceSpine, event.reconstruction.corridor); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalPerformance = globalThis.performance; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; const { body } = installEventDom(); const openedUrls = []; const cancelledFrames = []; globalThis.window = { setTimeout: () => 71, clearTimeout() {}, open(url) { openedUrls.push(url); return { opener: 'fixture' }; }, }; globalThis.performance = { now: () => 1000 }; globalThis.requestAnimationFrame = () => 91; globalThis.cancelAnimationFrame = (id) => cancelledFrames.push(id); const viewer = eventViewer(); const publications = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, evidenceFrameFactory: (observation) => ({ fixture: observation.id }), overlayHost: { setEntries(id, entries, options) { publications.push({ id, entries, options }); }, setVisible() {}, clearSource() {}, }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); try { await layer.enable(viewer, { origin: 'local-restore' }); const panel = eventPanel(); assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK'); const entries = publications[0].entries; const cards = entries.filter((entry) => entry.variant === 'thumbnail'); const breadcrumbs = entries.filter((entry) => entry.variant === 'label'); const next = panel.querySelector('[data-action="next-beat"]'); const previous = panel.querySelector('[data-action="previous-beat"]'); next.dispatch('click'); assert.equal(layer.getPlaybackState().progress, timeline[1].activationProgress); assert.equal(layer.getPlaybackState().playing, false); assert.equal(layer.getCinematicState().active, true); assert.match(cards.find((entry) => entry.position()).id, /debris-dammed-lake/); assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 1); assert.equal(panel.querySelector('[data-role="beat-index"]').textContent, '02 / 16'); assert.equal(panel.querySelector('[data-role="beat-title"]').textContent, 'Dammed lake'); next.dispatch('click'); next.dispatch('click'); next.dispatch('click'); assert.equal(layer.getPlaybackState().progress, timeline[4].activationProgress); assert.equal(layer.getCinematicState().evidenceId, 'timure-cluster'); assert.match(cards.find((entry) => entry.position()).id, /timure-cluster/); assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 4); assert.equal( panel.querySelector('[data-role="beat-meta"]').textContent, 'PROPAGATION / TIME UNVERIFIED / 5 SOURCES', ); const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core'); assert.ok(flood.polyline.positions.getValue().length > 1); assert.equal( body.children.some((element) => element.id === 'bhote-koshi-story-strap'), false, ); panel.querySelector('[data-action="open-source"]').dispatch('click'); assert.deepEqual(openedUrls, [timeline[4].observation.media.sourceUrl]); panel.querySelector('[data-action="geolocation-map"]').dispatch('click'); assert.deepEqual(openedUrls, [ timeline[4].observation.media.sourceUrl, event.geolocationMap.sourceUrl, ]); previous.dispatch('click'); assert.equal(layer.getPlaybackState().progress, timeline[3].activationProgress); assert.equal(layer.getCinematicState().evidenceId, 'gyirong-border-gate'); assert.equal(panel.querySelector('[data-role="beat-index"]').textContent, '04 / 16'); assert.equal(publications.length, 1, 'beat navigation never republishes overlay entries'); viewer._test.canvas.dispatch('pointerdown'); assert.equal(layer.getCinematicState().active, false); const cameraLookCount = viewer._test.cameraLooks.length; const progress = panel.querySelector('[data-role="progress"]'); progress.value = '500'; progress.dispatch('input'); assert.equal(layer.getCinematicState().active, false, 'manual release survives slider changes'); assert.equal(viewer._test.cameraLooks.length, cameraLookCount); previous.dispatch('click'); assert.equal(layer.getCinematicState().active, true, 'explicit beat navigation reclaims camera'); assert.equal(layer.getCinematicState().evidenceId, 'dhunche'); panel.querySelector('[data-action="story-replay"]').dispatch('click'); assert.equal(layer.getPlaybackState().progress, 0); assert.equal(layer.getPlaybackState().playing, true); assert.equal(layer.getCinematicState().active, true); assert.equal(layer.getCinematicState().evidenceId, 'immediate-collapse-viewpoint'); assert.equal(panel.querySelector('[data-role="beat-index"]').textContent, '01 / 16'); assert.equal(publications.length, 1); await layer.disable(); assert.deepEqual(cancelledFrames, [91]); assert.equal(viewer._test.canvas._listeners.size, 0); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; globalThis.performance = originalPerformance; globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; } }); test('Bhote Koshi repeated event frames republish no panel and churn no camera', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalPerformance = globalThis.performance; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; const { body } = installEventDom(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; globalThis.performance = { now: () => 1000 }; globalThis.requestAnimationFrame = () => 93; globalThis.cancelAnimationFrame = () => {}; const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); try { await layer.enable(viewer, { origin: 'local-restore' }); const panel = eventPanel(); const cinematic = panel.querySelector('[data-action="cinematic"]'); const progress = panel.querySelector('[data-role="progress"]'); cinematic.dispatch('click'); progress.value = '500'; progress.dispatch('input'); const panelStore = useBhoteKoshiPanelStore(applicationPinia()); const published = panelStore.panel; const cameraLookCount = viewer._test.cameraLooks.length; for (let index = 0; index < 20; index += 1) progress.dispatch('input'); assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel'); assert.equal( viewer._test.cameraLooks.length, cameraLookCount, 'unchanged progress does not reapply the cinematic camera object', ); await layer.disable(); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; globalThis.performance = originalPerformance; globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; } }); test('Bhote Koshi frame path keeps evidence lookup scalar and wrapper-free', async () => { const source = await readFile(eventModuleUrl, 'utf8'); assert.doesNotMatch(source, /#ffd166/i, 'event UI no longer publishes the prototype yellow'); assert.doesNotMatch( source, /function\s+activeEvidenceEntry\s*\(/, 'the event frame path must not restore an object-returning evidence wrapper', ); assert.doesNotMatch( source, /return\s*\{\s*(?:index\s*,\s*observation|observation\s*,\s*index)\s*\}/, 'evidence lookup must not allocate an index/observation pair', ); assert.match( source, /const beatIndex = currentEvidenceIndex\(\);\s*const sceneBeatIndex = sceneDirected\s*\? _evidenceTimeline\.findIndex\(\s*\(\{ observation: item \}\) => item\.id === _sceneBeatId,?\s*\)\s*:\s*beatIndex;\s*const observation =\s*_evidenceTimeline\[sceneBeatIndex\]\?\.observation \|\| null;/, 'syncPanel must resolve authored scene beats to one scalar index before reading the observation', ); }); test('Bhote Koshi cinematic camera stays synchronized through pause and deterministic scrubbing', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalPerformance = globalThis.performance; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; const { body } = installEventDom(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; globalThis.performance = { now: () => 1000 }; globalThis.requestAnimationFrame = () => 33; globalThis.cancelAnimationFrame = () => {}; const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); try { await layer.enable(viewer, { origin: 'local-restore' }); const panel = eventPanel(); panel.querySelector('[data-action="cinematic"]').dispatch('click'); assert.equal(layer.getCinematicState().active, true); assert.equal(layer.getPlaybackState().playing, true); assert.equal(viewer._test.cameraLooks.length, 1); assert.equal( body.children.some((element) => element.id === 'bhote-koshi-story-strap'), false, ); panel.querySelector('[data-action="play"]').dispatch('click'); assert.equal(layer.getPlaybackState().playing, false); const progress = panel.querySelector('[data-role="progress"]'); progress.value = '500'; progress.dispatch('input'); const firstPose = viewer._test.cameraLooks.at(-1); progress.dispatch('input'); assert.deepEqual(viewer._test.cameraLooks.at(-1), firstPose); assert.deepEqual(layer.getCinematicState(), { active: true, progress: 0.5, evidenceId: 'mailung-upper-trishuli', mediaReady: false, mediaEvidenceId: null, }); viewer._test.canvas.dispatch('pointerdown'); assert.equal(layer.getCinematicState().active, false); assert.equal(viewer._test.cameraTransforms.length, 1); await layer.disable(); assert.equal(viewer._test.canvas._listeners.size, 0); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; globalThis.performance = originalPerformance; globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; } }); test('Bhote Koshi replay restores cinematic ownership unless the operator released it', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalPerformance = globalThis.performance; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; const { body } = installEventDom(); const animationCallbacks = []; const cancelledFrames = []; const releases = []; globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; globalThis.performance = { now: () => 1000 }; globalThis.requestAnimationFrame = (callback) => { animationCallbacks.push(callback); return animationCallbacks.length; }; globalThis.cancelAnimationFrame = (id) => cancelledFrames.push(id); const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async (_viewer, observations) => ( observations.map((observation) => ({ height: observation.fallbackElevationM })) ), mediaLoader: async () => { const error = new Error('fixture media intentionally unavailable'); error.name = 'AbortError'; throw error; }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release(id) { releases.push(id); }, }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); try { await layer.enable(viewer, { origin: 'local-restore' }); const panel = eventPanel(); panel.querySelector('[data-action="cinematic"]').dispatch('click'); animationCallbacks[0](91001); assert.equal(layer.getPlaybackState().progress, 1); assert.equal(layer.getPlaybackState().playing, false); assert.equal(layer.getCinematicState().active, false); assert.equal(viewer._test.cameraTransforms.length, 1); assert.deepEqual(releases, ['bhote-koshi-playback']); const play = panel.querySelector('[data-action="play"]'); assert.equal(play.textContent, '↺ REPLAY CINEMATIC'); play.dispatch('click'); assert.equal(layer.getCinematicState().active, true); assert.equal(layer.getPlaybackState().progress, 0); assert.equal(layer.getPlaybackState().playing, true); assert.equal( body.children.some((element) => element.id === 'bhote-koshi-story-strap'), false, ); panel.querySelector('[data-action="cinematic"]').dispatch('click'); assert.equal(layer.getCinematicState().active, false); assert.equal(viewer._test.cameraTransforms.length, 2); animationCallbacks[1](91001); assert.equal(play.textContent, '↺ REPLAY'); const cameraLookCount = viewer._test.cameraLooks.length; play.dispatch('click'); assert.equal(layer.getPlaybackState().playing, true); assert.equal(layer.getCinematicState().active, false); assert.equal(viewer._test.cameraLooks.length, cameraLookCount); await layer.disable(); assert.equal(layer.getCinematicState().active, false); assert.equal(viewer._test.cameraTransforms.length, 2); assert.deepEqual(cancelledFrames, [1, 2, 3]); assert.equal(viewer._test.canvas._listeners.size, 0); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; globalThis.performance = originalPerformance; globalThis.requestAnimationFrame = originalRequestAnimationFrame; globalThis.cancelAnimationFrame = originalCancelAnimationFrame; } }); test('Bhote Koshi closes a poster that resolves after disable', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); event.evidenceSpine = event.evidenceSpine.slice(0, 1); event.evidenceSpine[0].media.posterPath = 'evidence-09.jpg'; const originalDocument = globalThis.document; const originalWindow = globalThis.window; installEventDom(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; const viewer = eventViewer(); let resolvePoster; const poster = { closed: false, close() { this.closed = true; } }; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async () => [{ height: 2800 }], mediaLoader: async (url) => { assert.equal(url, '/events/bhote-koshi-2026/evidence-09.jpg'); return new Promise((resolve) => { resolvePoster = resolve; }); }, evidenceFrameFactory: (observation) => ({ fixture: observation.id }), overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1, async setStack() {}, }); try { await layer.enable(viewer, { origin: 'local-restore' }); await layer.disable(); resolvePoster(poster); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(poster.closed, true); } finally { globalThis.document = originalDocument; globalThis.window = originalWindow; } }); test('every sourced shot grows its tail with its head, keeps its own card, and clears on exit', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originals = Object.fromEntries(['document', 'window', 'performance', 'requestAnimationFrame', 'cancelAnimationFrame'] .map((key) => [key, globalThis[key]])); installEventDom(); let now = 1000; let nextFrame = 0; const frames = new Map(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; globalThis.performance = { now: () => now }; globalThis.requestAnimationFrame = (callback) => { frames.set(++nextFrame, callback); return nextFrame; }; globalThis.cancelAnimationFrame = (id) => frames.delete(id); const viewer = eventViewer(); let heightSamples = 0; viewer.scene.sampleHeight = () => { heightSamples++; return 1500; }; let entries = []; const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async () => null, mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, overlayHost: { setEntries(_id, value) { entries = value; }, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'photoreal', getSwitchGeneration: () => 1 }); const params = (beatId, cameraSettled, evidencePath = 'source', evidencePathElevation = null) => ({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId, sceneControls: { cameraSettled, evidencePath, evidencePathElevation, evidencePathDurationSec: 4, deferEvidenceUntilCameraSettled: true, evidenceHoldCard: true }, }); const advance = (elapsed) => { const callback = frames.get(nextFrame); frames.delete(nextFrame); callback(now + elapsed); }; try { layer.setParams(params(SOURCE_PATH_BEAT_IDS[0], false)); await layer.enable(viewer, { origin: 'scene' }); const source = viewer._test.dataSources.at(-1); const flood = source.entities.getById('bhote-koshi-flood-core'); const surge = source.entities.getById('bhote-koshi-surge-front'); for (const id of SOURCE_PATH_BEAT_IDS) { layer.setParams(params(id, false)); assert.equal(flood.show, false, `${id}: hide the previous route during flight`); assert.equal(surge.show, false); layer.setParams(params(id, true)); const sampleCount = heightSamples; let previousLength = 0; for (const elapsed of [400, 1800, 4000]) { advance(elapsed); const points = flood.polyline.positions.getValue(); assert.ok(points.length >= previousLength); assert.equal(flood.show, true); assert.equal(surge.show, true); assert.ok(Cesium.Cartesian3.equals(points.at(-1), surge.position.getValue()), `${id}: tail must reach head in every frame`); previousLength = points.length; } assert.equal(heightSamples, sampleCount, 'do not resample geometry during motion'); const visibleCards = entries.filter((entry) => entry.id.startsWith('evidence-card-') && entry.position()); assert.deepEqual(visibleCards.map(({ id: cardId }) => cardId), [`evidence-card-${id}`]); assert.ok(visibleCards[0].sourceAlpha() > 0, 'keep the source readable after the path settles'); now += 5000; } // A settled-to-settled entry must not paint the last shot's completed // progress on the new route for a frame, then jump back to its start. layer.setParams(params('dhunche', true)); assert.equal(flood.show, false, 'new shot clears its tail before the first animation frame'); assert.equal(surge.show, false); advance(1800); const headBeforeSync = Cesium.Cartesian3.clone(surge.position.getValue()); const frameBeforeSync = nextFrame; layer.setParams(params('dhunche', true)); assert.equal(nextFrame, frameBeforeSync, 'repeated settled notification does not restart the reveal'); assert.ok(Cesium.Cartesian3.equals(headBeforeSync, surge.position.getValue())); advance(2200); assert.ok(!Cesium.Cartesian3.equals(headBeforeSync, surge.position.getValue())); // The comparison exit can expose coarse photogrammetry samples a kilometre // above the river. Dhunche's schematic profile must not dive through them. viewer.scene.sampleHeight = () => { heightSamples++; return 2703; }; viewer.scene.globe = { getHeight: () => 0 }; layer.setParams(params('dhunche', false, 'source', 'source')); const samplesBeforeProfile = heightSamples; layer.setParams(params('dhunche', true, 'source', 'source')); for (const elapsed of [40, 400, 1800, 4000]) { advance(elapsed); const points = flood.polyline.positions.getValue(); for (const position of points) { const height = Cesium.Cartographic.fromCartesian(position).height; assert.ok(height >= 1307 && height <= 1332, `source profile stays near river elevation, got ${height}`); } assert.ok(Cesium.Cartesian3.equals(points.at(-1), surge.position.getValue())); } assert.equal(heightSamples, samplesBeforeProfile, 'source elevation does not sample the changing mesh'); layer.setParams(params('charaudi', false, 'none')); layer.setParams(params('charaudi', true, 'none')); advance(1000); assert.equal(flood.show, false); assert.equal(surge.show, false); await layer.disable(); assert.equal(frames.size, 0); } finally { for (const [key, value] of Object.entries(originals)) globalThis[key] = value; } }); test('media-led upper-valley shots retain only their upstream prefix during camera travel', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originals = Object.fromEntries(['document', 'window', 'performance', 'requestAnimationFrame', 'cancelAnimationFrame'] .map((key) => [key, globalThis[key]])); installEventDom(); let now = 1000; let nextFrame = 0; const frames = new Map(); globalThis.window = { setTimeout: () => 71, clearTimeout() {} }; globalThis.performance = { now: () => now }; globalThis.requestAnimationFrame = callback => { frames.set(++nextFrame, callback); return nextFrame; }; globalThis.cancelAnimationFrame = id => frames.delete(id); const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async url => ({ url }), terrainSampler: async () => null, mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold() {}, release() {} }, }); layer.attachMapStackController({ getActiveId: () => 'esri-imagery', getSwitchGeneration: () => 1 }); const params = (beatId, cameraSettled, extras = {}) => ({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId, sceneControls: { evidencePath: 'source', evidencePathHistory: true, evidencePathPersistent: true, evidencePathDuringMedia: true, evidencePathDurationSec: 4.8, deferEvidenceUntilCameraSettled: true, evidenceHoldCard: true, cameraSettled, ...extras, }, }); const finishReveal = () => { now += 7000; const callback = frames.get(nextFrame); frames.delete(nextFrame); callback(now); }; try { layer.setParams(params('debris-dammed-lake', false)); await layer.enable(viewer, { origin: 'scene' }); const source = viewer._test.dataSources.at(-1); const flood = source.entities.getById('bhote-koshi-flood-core'); const surge = source.entities.getById('bhote-koshi-surge-front'); layer.setParams(params('debris-dammed-lake', true)); finishReveal(); const lakeTail = flood.polyline.positions.getValue().map(point => Cesium.Cartesian3.clone(point)); assert.ok(lakeTail.length > 1); const origin = Cesium.Cartographic.fromCartesian(lakeTail[0]); assert.ok(Math.abs(Cesium.Math.toDegrees(origin.longitude) - 85.48476) < 1e-6); assert.ok(Math.abs(Cesium.Math.toDegrees(origin.latitude) - 28.33255) < 1e-6, 'the rendered trail starts at the lake reach, not the earlier trigger reach'); layer.setParams(params('second-landslide', false)); assert.equal(flood.show, true, 'lake trail survives departure before the next camera settles'); assert.deepEqual(flood.polyline.positions.getValue(), lakeTail, 'retain the prefix without revealing the next segment'); assert.ok(Cesium.Cartesian3.equals(surge.position.getValue(), lakeTail.at(-1))); layer.setParams(params('second-landslide', true)); assert.deepEqual(flood.polyline.positions.getValue(), lakeTail, 'arrival does not clear the prefix'); finishReveal(); assert.ok(flood.polyline.positions.getValue().length > lakeTail.length); layer.setParams(params('debris-dammed-lake', false)); assert.equal(flood.show, false, 'backward replay trims future segments instead of retaining stale history'); layer.setParams(params('second-landslide', false)); assert.deepEqual(flood.polyline.positions.getValue(), lakeTail, 'direct load reconstructs the same prefix'); layer.setParams(params('second-landslide', false, { evidencePathPersistent: false })); assert.equal(flood.show, false, 'nonpersistent shots retain their arrival gate'); for (const beatId of ['devighat-taadi-khola-bridge', 'charaudi', 'final-view']) { const historyControls = { evidencePath: 'history', evidencePathHistoryBeatId: SOURCE_PATH_BEAT_IDS.at(-1), evidencePathDuringMedia: false, evidenceMediaAutoplay: beatId !== 'final-view', }; layer.setParams(params(beatId, false, historyControls)); const completedTail = [...flood.polyline.positions.getValue()]; assert.ok(completedTail.length > 100, `${beatId}: direct load reconstructs full sourced history`); layer.setParams(params(beatId, true, historyControls)); assert.deepEqual(flood.polyline.positions.getValue(), completedTail, `${beatId}: arrival must not reset the completed history`); finishReveal(); assert.deepEqual(flood.polyline.positions.getValue(), completedTail, `${beatId}: media/card reveal must not erase or regrow completed history`); } await layer.disable(); assert.equal(frames.size, 0); } finally { for (const [key, value] of Object.entries(originals)) globalThis[key] = value; } }); test('camera-led Nepal flood motion preserves its prefix and stays ahead of shot travel', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const originals = Object.fromEntries(['document', 'window', 'performance', 'requestAnimationFrame', 'cancelAnimationFrame'] .map((key) => [key, globalThis[key]])); installEventDom(); let now = 1000; let nextFrame = 0; let nextTimer = 0; const frames = new Map(); const timers = new Map(); const holds = []; const releases = []; globalThis.window = { setTimeout(callback, delay) { const id = ++nextTimer; timers.set(id, { callback, delay }); return id; }, clearTimeout(id) { timers.delete(id); }, }; globalThis.performance = { now: () => now }; globalThis.requestAnimationFrame = (callback) => { frames.set(++nextFrame, callback); return nextFrame; }; globalThis.cancelAnimationFrame = (id) => frames.delete(id); const viewer = eventViewer(); const layer = createBhoteKoshiEventLayer({ eventLoader: async () => event, imageryProviderFactory: async (url) => ({ url }), terrainSampler: async () => null, mediaLoader: async () => { throw Object.assign(new Error('fixture'), { name: 'AbortError' }); }, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, renderHost: { request() {}, hold(id) { holds.push(id); }, release(id) { releases.push(id); }, }, }); layer.attachMapStackController({ getActiveId: () => 'photoreal', getSwitchGeneration: () => 1 }); const travel = (id, durationSec = 4.2, overrides = {}) => ({ id, durationSec, active: true, completed: false, cancelled: false, ...overrides, }); const params = (beatId, evidencePath, cameraTravel, extras = {}) => ({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId, sceneControls: { evidencePath, evidencePathDuringCamera: true, evidencePathPersistent: true, deferEvidenceUntilCameraSettled: true, cameraSettled: false, cameraTravel, ...extras, }, }); const advance = (elapsed) => { now += elapsed; const id = Math.max(...frames.keys()); const callback = frames.get(id); frames.delete(id); assert.equal(typeof callback, 'function'); callback(now); }; try { layer.setParams(params('timure-cluster', 'comparison', travel(1), { evidenceSequence: true, })); await layer.enable(viewer, { origin: 'scene' }); let source = viewer._test.dataSources.at(-1); let flood = source.entities.getById('bhote-koshi-flood-core'); let surge = source.entities.getById('bhote-koshi-surge-front'); const timureStart = flood.polyline.positions.getValue().length; assert.ok(timureStart > 1, 'the completed Border Gate prefix is already visible during travel'); assert.equal(surge.show, true); advance(420); const earlyTimureLength = flood.polyline.positions.getValue().length; assert.ok(earlyTimureLength > timureStart, 'the trail leads during the opening tenth of camera travel instead of waiting for arrival'); advance(2880); const timureEnd = flood.polyline.positions.getValue().length; assert.ok(timureEnd > timureStart); assert.equal(flood.show, true, 'the completed tail remains visible at the next point'); assert.ok(Cesium.Cartesian3.equals(flood.polyline.positions.getValue().at(-1), surge.position.getValue())); layer.setParams(params('syabru-besi', 'comparison', travel(2), { evidenceSequence: true, })); assert.ok(flood.polyline.positions.getValue().length >= timureEnd, 'the Timure segment remains painted when Syabru travel starts'); advance(3300); assert.equal(surge.show, true, 'the amber head remains visible at comparison completion'); assert.ok(Cesium.Cartesian3.equals(flood.polyline.positions.getValue().at(-1), surge.position.getValue())); layer.setParams(params('dhunche', 'source', travel(3), { evidencePathHistory: true, evidencePathElevation: 'source', evidenceHoldCard: true, })); source = viewer._test.dataSources.at(-1); flood = source.entities.getById('bhote-koshi-flood-core'); surge = source.entities.getById('bhote-koshi-surge-front'); const dhuncheStart = flood.polyline.positions.getValue().length; assert.ok(dhuncheStart >= event.reconstruction.corridor.length, 'the comparison corridor survives the transition onto Google photogrammetry'); advance(3300); const dhuncheEnd = flood.polyline.positions.getValue().length; assert.ok(dhuncheEnd > dhuncheStart); assert.equal(surge.show, true); layer.setParams(params('mailung-upper-trishuli', 'source', travel(4), { evidencePathHistory: true, evidencePathElevation: 'source', evidenceHoldCard: true, })); assert.ok(flood.polyline.positions.getValue().length >= dhuncheEnd, 'the completed Dhunche segment never disappears'); advance(900); const frozenHead = Cesium.Cartesian3.clone(surge.position.getValue()); const frozenLength = flood.polyline.positions.getValue().length; layer.setParams(params('mailung-upper-trishuli', 'source', travel(4, 4.2, { active: false, cancelled: true, }), { evidencePathHistory: true, evidencePathElevation: 'source', evidenceHoldCard: true, })); assert.equal(frames.size, 0, 'cancelling camera travel revokes the path animation frame'); assert.equal(flood.polyline.positions.getValue().length, frozenLength); assert.ok(Cesium.Cartesian3.equals(surge.position.getValue(), frozenHead)); assert.ok(holds.includes('bhote-koshi-scene-path-travel')); assert.ok(releases.includes('bhote-koshi-scene-path-travel')); let previousCompleteLength = dhuncheEnd; let travelId = 5; for (const beatId of SOURCE_PATH_BEAT_IDS.slice(3)) { layer.setParams(params(beatId, 'source', travel(travelId++), { evidencePathHistory: true, evidencePathElevation: 'source', evidenceHoldCard: true, })); const startLength = flood.polyline.positions.getValue().length; assert.ok(startLength >= previousCompleteLength, `${beatId} retains every completed upstream segment while travel begins`); advance(3300); const completeLength = flood.polyline.positions.getValue().length; assert.ok(completeLength > startLength, `${beatId} grows its own segment during travel`); assert.equal(flood.show, true); assert.equal(surge.show, true); assert.ok(Cesium.Cartesian3.equals( flood.polyline.positions.getValue().at(-1), surge.position.getValue(), )); previousCompleteLength = completeLength; } const bidurCompleteLength = previousCompleteLength; // Direct entry must reconstruct its supported prefix without relying on // earlier playback, while backward navigation must trim later segments. layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'devighat-taadi-khola-bridge', sceneControls: { evidencePath: 'none', deferEvidenceUntilCameraSettled: true, cameraSettled: false }, }); assert.equal(flood.show, false, 'Devighat does not leak the supported route'); assert.equal(surge.show, false); layer.setParams(params('bidur-trishuli-bridge', 'source', travel(travelId++), { evidencePathHistory: true, evidencePathElevation: 'source', evidenceHoldCard: true, })); assert.ok(flood.polyline.positions.getValue().length > dhuncheEnd, 'a direct Bidur load reconstructs its complete upstream prefix'); advance(3300); assert.equal(flood.polyline.positions.getValue().length, bidurCompleteLength); layer.setParams(params('dhunche', 'source', travel(travelId++), { evidencePathHistory: true, evidencePathElevation: 'source', evidenceHoldCard: true, })); assert.ok(flood.polyline.positions.getValue().length < bidurCompleteLength, 'backward navigation trims unsupported downstream history immediately'); advance(3300); assert.equal(flood.polyline.positions.getValue().length, dhuncheEnd); // The deadline is independent of RAF, so a throttled render loop still // completes the active segment before the camera's longer flight ends. layer.setParams(params('mailung-upper-trishuli', 'source', travel(travelId++), { evidencePathHistory: true, evidencePathElevation: 'source', evidenceHoldCard: true, })); const deadlineEntry = [...timers.entries()].find(([, entry]) => entry.delay >= 2400); assert.ok(deadlineEntry, 'camera-led travel registers a pre-arrival completion deadline'); now += deadlineEntry[1].delay; deadlineEntry[1].callback(); assert.equal(frames.size, 0, 'the deadline cancels a stalled animation frame'); assert.ok(flood.polyline.positions.getValue().length > dhuncheEnd); layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'charaudi', sceneControls: { evidencePath: 'none', deferEvidenceUntilCameraSettled: true, cameraSettled: false }, }); assert.equal(flood.show, false, 'unsupported downstream shots do not invent or leak a route'); assert.equal(surge.show, false); await layer.disable(); } finally { for (const [key, value] of Object.entries(originals)) globalThis[key] = value; } }); test('Bhote Koshi corridor remains ordered downstream and inside the imagery rectangle', async () => { const event = JSON.parse(await readFile(eventUrl, 'utf8')); const { west, south, east, north } = event.imagery.rectangle; let priorChainage = Number.NEGATIVE_INFINITY; for (const point of event.reconstruction.corridor) { assert.ok(point.chainageM > priorChainage); assert.ok(point.lon >= west && point.lon <= east); assert.ok(point.lat >= south && point.lat <= north); priorChainage = point.chainageM; } });