import assert from 'node:assert/strict'; import test from 'node:test'; import { buildRadioTunerBand, buildRadioTunerTicks, buildRadioCategories, createRadioClusterOverlayEntry, createRadioSelectedOverlayEntry, createRadioSingletonOverlayEntry, confirmRadioPlayback, DEFAULT_RADIO_FILTER, filterRadioStations, getRadioAcceptedCatalogSnapshot, getRadioUIState, isEnglishRadioStation, normalizeRadioTag, radioCategoryColor, radioGlobeLabel, radioGlobeNeedsRecentering, radioGlobeRecenterHeight, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, RADIO_OVERLAY_COHORT_LIMIT, RADIO_OVERLAY_SOURCE_OPTIONS, RADIO_SINGLETON_GLOBAL_LIMIT, RADIO_SINGLETON_MID_LIMIT, RADIO_SINGLETON_NEAR_LIMIT, radioClusterCategoryId, radioCameraPositionChanged, radioCameraNavigationAllowed, radioClusterBadgeText, rankRadioStationsForViewport, rankRadioStationsForRequest, radioRequestIsCurrent, radioLayer, reconcileRadioClusterCandidates, retainRadioClusterIdentitiesForStations, radioStationIdFromPick, radioSelectionBracketSvg, selectRadioClusterCandidates, selectRadioSingletonCandidates, radioStationCameraPlan, radioTunerCommitSlot, radioTunerPointerPosition, radioTunerSlot, radioTuningStaticShouldPlay, radioStationCategoryId, setRadioParams, setRadioVolume, setRadioVoiceDucking, radioSingletonLabelLimit, radioViewIsGlobal, stationMatchesRadioCategory, } from './radio.js'; const stations = [ { id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] }, { id: 'safety', tags: ['police scanner', 'emergency'] }, { id: 'music', tags: ['jazz', 'soul'] }, { id: 'other', tags: ['community'] }, ]; test('station-level tags produce canonical and detected-genre categories', () => { const categories = buildRadioCategories(stations); assert.equal(categories.find((category) => category.id === 'all').count, 4); assert.equal(categories.find((category) => category.id === 'weather').count, 2); assert.equal(categories.find((category) => category.id === 'public-safety').count, 1); assert.equal(categories.find((category) => category.id === 'genre:jazz').count, 1); assert.equal(categories.find((category) => category.id === 'other').count, 1); assert.equal(categories.find((category) => category.id === 'music').color, '#54d17a'); assert.equal(categories.find((category) => category.id === 'genre:jazz').color, '#54d17a'); }); test('every generated Radio category is accepted by durable preferences', () => { const categories = buildRadioCategories([ ...stations, { id: 'hip-hop', tags: ['hip hop'] }, { id: 'r-and-b', tags: ['r&b'] }, ]); for (const category of categories) { assert.equal(setRadioParams({ filter: category.id }), true, category.id); } }); test('station marker categories share the dropdown palette with stable overlap priority', () => { assert.equal(radioStationCategoryId(stations[0]), 'news'); assert.equal(radioStationCategoryId(stations[1]), 'public-safety'); assert.equal(radioStationCategoryId(stations[2]), 'music'); assert.equal(radioStationCategoryId(stations[3]), 'other'); assert.equal(radioCategoryColor('news'), '#44adff'); assert.equal(radioCategoryColor('music'), '#54d17a'); assert.equal(radioCategoryColor('genre:jazz'), '#54d17a'); }); test('cluster badges lead with a readable count and concise dominant category', () => { assert.equal(radioClusterBadgeText('news', 12), '12 NEWS'); assert.equal(radioClusterBadgeText('public-safety', 7.9), '7 SAFETY'); assert.equal(radioClusterBadgeText('genre:jazz', 4), '4 MUSIC'); assert.equal(radioClusterBadgeText('unknown', -2), '0 OTHER'); }); test('Radio globe labels are compact and frequency-first without misreading names', () => { assert.equal( radioGlobeLabel({ name: 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio' }), '93.9 FM — La Zeta', ); assert.equal( radioGlobeLabel({ name: '100.3 The River - WQRV - Meridianville/Huntsville, AL' }), '100.3 FM — The River', ); assert.equal(radioGlobeLabel({ name: "80's New Wave Radio" }), "80's New Wave Radio"); assert.equal(radioGlobeLabel({ name: "100 GREATEST OF THE 80'S" }), "100 GREATEST OF THE 80'S"); assert.doesNotMatch(radioGlobeLabel({ name: 'Movie Soundtracks Hits Radio @ 1.fm' }), /^1 FM/); assert.equal( radioGlobeLabel({ name: ' La Zeta - 93.9 fm - XHHY ' }), '93.9 FM — La Zeta', ); assert.equal(radioGlobeLabel({ name: '93.9 FM ---' }), '93.9 FM'); assert.equal(radioGlobeLabel({ name: 'Radio One 93.9 FM / 94.1 FM' }), '93.9 FM — Radio One'); const truncated = radioGlobeLabel({ name: 'Christian Power Praise Dot Net Worldwide Service' }); assert.equal(truncated.length, 30); assert.ok(truncated.endsWith('…')); const unicodeTruncated = radioGlobeLabel({ name: `${'a'.repeat(28)}📻 more` }); assert.equal(unicodeTruncated, `${'a'.repeat(28)}📻…`); assert.doesNotMatch(unicodeTruncated, /�/); }); test('Radio ranks and caps cluster candidates before shared-host entry construction', () => { const candidates = Array.from({ length: 90 }, (_, index) => ({ id: `cluster-${String(index).padStart(2, '0')}`, stationCount: index % 17, })); const selected = selectRadioClusterCandidates(candidates); assert.equal(selected.length, RADIO_OVERLAY_COHORT_LIMIT); assert.ok(selected.every((candidate) => candidates.includes(candidate))); for (let index = 1; index < selected.length; index += 1) { assert.ok(selected[index - 1].stationCount >= selected[index].stationCount); } assert.deepEqual(selectRadioClusterCandidates([ { id: 'z', stationCount: 5 }, { id: 'a', stationCount: 5 }, ], 1).map(({ id }) => id), ['a']); }); test('Radio preserves one-to-one cluster identity across substantial membership churn', () => { const previous = [ { id: 'stable:north', identityId: 'stable:north', stationIds: ['a', 'b', 'c', 'd'] }, { id: 'stable:south', identityId: 'stable:south', stationIds: ['w', 'x', 'y', 'z'] }, ]; const generated = []; const reconciled = reconcileRadioClusterCandidates([ { id: 'b:e:4', stationIds: ['a', 'b', 'c', 'e'], text: '4 NEWS' }, { id: 'new:weak:3', stationIds: ['d', 'm', 'n'], text: '3 OTHER' }, { id: 'x:q:4', stationIds: ['w', 'x', 'y', 'q'], text: '4 MUSIC' }, ], previous, (candidate) => { const id = `new:${candidate.id}`; generated.push(id); return id; }); assert.equal(reconciled[0].id, 'stable:north'); assert.equal(reconciled[0].text, '4 NEWS'); assert.equal(reconciled[2].id, 'stable:south'); assert.equal(reconciled[2].text, '4 MUSIC'); assert.equal(reconciled[1].id, 'new:new:weak:3'); assert.deepEqual(generated, ['new:new:weak:3']); assert.deepEqual(previous[0].stationIds, ['a', 'b', 'c', 'd']); }); test('directory refresh retains only identities whose represented stations still exist', () => { const previous = [ { id: 'stable:unchanged', stationIds: ['a', 'b', 'c'] }, { id: 'stable:invalid', stationIds: ['x', 'y', 'z'] }, ]; const unchangedRefresh = retainRadioClusterIdentitiesForStations(previous, [ { id: 'c' }, { id: 'a' }, { id: 'b' }, { id: 'x' }, { id: 'y' }, { id: 'z' }, ]); assert.deepEqual(unchangedRefresh, previous); const changedRefresh = retainRadioClusterIdentitiesForStations(previous, [ { id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'x' }, { id: 'z' }, ]); assert.deepEqual(changedRefresh.map(({ id }) => id), ['stable:unchanged']); }); test('Radio cluster identity inheritance is deterministic for splits and disjoint replacements', () => { const previous = [{ id: 'stable:whole', identityId: 'stable:whole', stationIds: ['a', 'b', 'c', 'd', 'e', 'f'], }]; const reconciled = reconcileRadioClusterCandidates([ { id: 'a:c:3', stationIds: ['a', 'b', 'c'] }, { id: 'd:f:3', stationIds: ['d', 'e', 'f'] }, { id: 'x:z:3', stationIds: ['x', 'y', 'z'] }, ], previous, (candidate) => `new:${candidate.id}`); assert.deepEqual(reconciled.map(({ id }) => id), [ 'stable:whole', 'new:d:f:3', 'new:x:z:3', ]); assert.equal(new Set(reconciled.map(({ id }) => id)).size, reconciled.length); }); test('Radio allocates fresh disjoint cluster identities in canonical membership order', () => { const current = [ { id: 'z-membership', stationIds: ['z2', 'z1'] }, { id: 'a-membership', stationIds: ['a2', 'a1'] }, ]; const reconcile = (input) => { let sequence = 0; return reconcileRadioClusterCandidates(input, [], () => `stable:${++sequence}`); }; const identities = (result) => Object.fromEntries( result.map(({ membershipId, identityId }) => [membershipId, identityId]), ); assert.deepEqual(identities(reconcile(current)), { 'z-membership': 'stable:2', 'a-membership': 'stable:1', }); assert.deepEqual(identities(reconcile([...current].reverse())), identities(reconcile(current))); }); test('Radio split competition never falls through from a consumed majority to a historical minority', () => { const previous = [ { id: 'stable:majority', stationIds: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'] }, { id: 'stable:minority', stationIds: ['x'] }, ]; const current = [ { id: 'strong-child', stationIds: ['a', 'b', 'c', 'd', 'e'] }, { id: 'later-child', stationIds: ['f', 'g', 'h', 'i', 'x'] }, ]; const reconcile = (currentInput, previousInput) => reconcileRadioClusterCandidates( currentInput, previousInput, (candidate) => `new:${candidate.id}`, ); const forward = reconcile(current, previous); const permuted = reconcile([...current].reverse(), [...previous].reverse()); const identities = (result) => Object.fromEntries( result.map(({ membershipId, identityId }) => [membershipId, identityId]), ); assert.deepEqual(identities(forward), { 'strong-child': 'stable:majority', 'later-child': 'new:later-child', }); assert.deepEqual(identities(permuted), identities(forward)); }); test('Radio split identities cannot migrate to weaker children to increase inherited count', () => { const previous = [ { id: 'stable:alpha', stationIds: ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9'] }, { id: 'stable:beta', stationIds: ['b1', 'b2', 'b3', 'b4', 'b5'] }, ]; const reconciled = reconcileRadioClusterCandidates([ { id: 'strong-tie', stationIds: ['a1', 'a2', 'a3', 'a4', 'a5', 'b1', 'b2', 'b3', 'b4', 'b5'], }, { id: 'weaker-alpha-child', stationIds: ['a6', 'a7', 'a8', 'a9'] }, ], previous, (candidate) => `new:${candidate.id}`); assert.deepEqual(reconciled.map(({ id }) => id), [ 'stable:beta', 'new:weaker-alpha-child', ]); }); test('Radio duplicate prior records cannot assign one stable identity twice', () => { const previous = [ { id: 'stable:duplicate', stationIds: ['a', 'b'] }, { identityId: 'stable:duplicate', stationIds: ['c', 'd'] }, ]; const current = [ { id: 'a-child', stationIds: ['a', 'b'] }, { id: 'z-child', stationIds: ['c', 'd'] }, ]; const reconcile = (currentInput, previousInput) => reconcileRadioClusterCandidates( currentInput, previousInput, (candidate) => `new:${candidate.id}`, ); const forward = reconcile(current, previous); const permuted = reconcile([...current].reverse(), [...previous].reverse()); const identities = (result) => Object.fromEntries( result.map(({ membershipId, identityId }) => [membershipId, identityId]), ); assert.deepEqual(identities(forward), { 'a-child': 'stable:duplicate', 'z-child': 'new:z-child', }); assert.deepEqual(identities(permuted), identities(forward)); assert.equal(forward.filter(({ identityId }) => identityId === 'stable:duplicate').length, 1); }); test('Radio cluster merges preserve the larger contributor over a fully retained minority', () => { const previous = [ { id: 'stable:minority', stationIds: ['a', 'b'] }, { id: 'stable:majority', stationIds: ['c', 'd', 'e', 'f', 'g', 'h'] }, ]; const [merged] = reconcileRadioClusterCandidates([ { id: 'merged', stationIds: ['a', 'b', 'c', 'd', 'e'] }, ], previous, () => 'new:merged'); assert.equal(merged.id, 'stable:majority'); }); test('Radio cluster inheritance never discards the greatest low-ratio contributor', () => { const previous = [ { id: 'stable:four', stationIds: ['a', 'b', 'c', 'd', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w'] }, { id: 'stable:two', stationIds: ['e', 'f'] }, ]; const [merged] = reconcileRadioClusterCandidates([{ id: 'merged', stationIds: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], }], previous, () => 'new:merged'); assert.equal(merged.id, 'stable:four'); }); test('Radio cluster inheritance permits a unique one-member overlap', () => { const [reconciled] = reconcileRadioClusterCandidates([ { id: 'current', stationIds: ['a', 'x', 'y'] }, ], [ { id: 'stable:single-overlap', stationIds: ['a', 'b', 'c'] }, ], () => 'new:current'); assert.equal(reconciled.id, 'stable:single-overlap'); }); test('Radio cluster majority ties are deterministic across input permutations', () => { const previous = [ { id: 'stable:zulu', stationIds: ['a', 'b', 'x'] }, { id: 'stable:alpha', stationIds: ['c', 'd', 'y'] }, ]; const current = [{ id: 'merged', stationIds: ['a', 'b', 'c', 'd'] }]; const forward = reconcileRadioClusterCandidates(current, previous, () => 'new:merged'); const reversed = reconcileRadioClusterCandidates(current, [...previous].reverse(), () => 'new:merged'); assert.equal(forward[0].id, 'stable:alpha'); assert.equal(reversed[0].id, 'stable:alpha'); assert.equal(new Set(forward.map(({ id }) => id)).size, forward.length); }); test('active station tags own cluster labels even when overlapping tags have higher marker priority', () => { const overlapping = [ { id: 'weather-news', tags: ['weather', 'news'] }, { id: 'weather-only', tags: ['weather'] }, { id: 'talk', tags: ['talk'] }, ]; assert.equal(radioStationCategoryId(overlapping[0]), 'news'); assert.equal(radioClusterCategoryId(overlapping, 'weather'), 'weather'); assert.equal(radioClusterCategoryId(overlapping, 'genre:jazz'), 'genre:jazz'); assert.equal(radioClusterCategoryId(overlapping, 'all'), 'news'); }); test('filters do not rewrite inputs and overlapping operational tags remain discoverable', () => { const before = structuredClone(stations); assert.deepEqual(filterRadioStations(stations, 'public-safety').map((station) => station.id), ['safety']); assert.equal(stationMatchesRadioCategory(stations[0], 'aviation-marine'), true); assert.deepEqual(stations, before); }); test('tag normalization is bounded and stable', () => { assert.equal(normalizeRadioTag(' Hip-Hop__Music '), 'hip hop music'); assert.equal(normalizeRadioTag('x'.repeat(100)).length, 80); }); test('late directory responses cannot overwrite a disabled or newer generation', () => { assert.equal(radioRequestIsCurrent(4, 4, true), true); assert.equal(radioRequestIsCurrent(3, 4, true), false); assert.equal(radioRequestIsCurrent(4, 4, false), false); }); test('destroy and re-init reset Radio audio ownership, volume, filter, and telemetry', async () => { const originalAudio = globalThis.Audio; const audioInstances = []; globalThis.Audio = class FakeAudio { constructor() { this.volume = 0.8; this.listeners = new Map(); this.srcRemoved = false; audioInstances.push(this); } addEventListener(type, listener) { this.listeners.set(type, listener); } pause() {} play() { return Promise.resolve(); } removeAttribute(name) { if (name === 'src') this.srcRemoved = true; } load() {} }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; radioLayer.destroy(); try { radioLayer.init(viewer); radioLayer.enable(); radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, uncertain: false, }); assert.equal(audioInstances.length, 1); assert.equal(setRadioVolume(0.25), true); setRadioVoiceDucking(true, { restoreDelayMs: 5, restoreDurationMs: 5 }); assert.equal(getRadioUIState().voiceDucked, true); assert.equal(getRadioUIState().effectiveVolume, 0); const oldAudio = audioInstances[0]; radioLayer.destroy(); assert.equal(oldAudio.srcRemoved, true); assert.equal(getRadioUIState().loading, false); assert.equal(getRadioUIState().stale, false); assert.equal(getRadioUIState().degraded, false); assert.equal(getRadioUIState().error, null); assert.equal(getRadioUIState().updatedAt, null); assert.equal(getRadioUIState().filter, DEFAULT_RADIO_FILTER); assert.equal(getRadioUIState().voiceDucked, false); assert.equal(getRadioUIState().voiceRestoring, false); assert.equal(getRadioUIState().volume, 0.8); assert.equal(getRadioUIState().effectiveVolume, 0.8); radioLayer.init(viewer); assert.equal(audioInstances.length, 2, 're-init creates a clean Audio session'); const nextState = getRadioUIState(); for (const eventName of ['playing', 'pause', 'waiting', 'error']) { oldAudio.listeners.get(eventName)?.(); assert.deepEqual(getRadioUIState(), nextState, `discarded ${eventName} callback is inert`); } } finally { radioLayer.destroy(); if (originalAudio === undefined) delete globalThis.Audio; else globalThis.Audio = originalAudio; } }); test('late media errors after replacement or Pause cannot mutate the active station', async () => { const originalAudio = globalThis.Audio; const originalFetch = globalThis.fetch; const audioInstances = []; globalThis.Audio = class FakeAudio { constructor() { this.volume = 0.8; this.src = ''; this.currentSrc = ''; this.listeners = new Map(); this.playCalls = 0; audioInstances.push(this); } addEventListener(type, listener) { this.listeners.set(type, listener); } pause() {} play() { this.playCalls += 1; this.currentSrc = this.src; return new Promise(() => {}); } removeAttribute(name) { if (name === 'src') this.src = ''; } load() {} }; const stationRows = [ { id: '00000000-0000-4000-8000-000000000001', name: 'Primary station', lat: 30, lon: -97, streamUrl: 'https://radio.example.com/primary.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }, { id: '00000000-0000-4000-8000-000000000002', name: 'Fallback station', lat: 31, lon: -98, streamUrl: 'https://radio.example.com/fallback.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }, ]; globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/') ? { ok: true } : { ok: true, json: async () => ({ stations: stationRows, updatedAt: new Date().toISOString(), stale: false, degraded: false, acceptedGeneration: 1, catalogInstance: 'qa-instance-a', }), }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; radioLayer.destroy(); try { radioLayer.init(viewer); radioLayer.enable(); await radioLayer.update(); radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, uncertain: true, }); assert.equal(radioLayer.getUIState().presentationActive, false); assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: true, origin: 'voice', }), false, 'uncertain lifecycle blocks direct station selection'); assert.equal(radioLayer.cycleStation(1, { stationIds: stationRows.map(({ id }) => id), autoplay: false, rotate: true, }), false, 'uncertain lifecycle blocks cycling before camera or fallback mutation'); assert.equal(await radioLayer.togglePlayback({ origin: 'user' }), false); assert.equal(radioLayer.getUIState().selected, null); assert.equal(audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), 0); radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, uncertain: false, }); void radioLayer.togglePlayback({ origin: 'user' }); await Promise.resolve(); const primaryAudio = audioInstances.at(-1); const loadingState = radioLayer.getUIState(); assert.equal(loadingState.audioState, 'loading'); assert.equal(loadingState.selected?.id, stationRows[0].id); radioLayer.selectStation(stationRows[1].id, { autoplay: true, focus: false, origin: 'user', }); await Promise.resolve(); const replacementAudio = audioInstances.at(-1); assert.notEqual(replacementAudio, primaryAudio); assert.equal(radioLayer.getUIState().selected?.id, stationRows[1].id); assert.equal(audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), 2); primaryAudio.currentSrc = ''; primaryAudio.listeners.get('error')?.(); assert.equal(radioLayer.getUIState().audioState, 'loading'); assert.equal(radioLayer.getUIState().selected?.id, stationRows[1].id); assert.equal(audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), 2); const controlEvents = []; const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => { const observed = radioLayer.getUIState(); const cleanupResult = radioLayer.stopPlayback({ origin: 'voice-cleanup', attemptId: event.attemptId, }); controlEvents.push({ action: event.action, observedAudioState: observed.audioState, observedStationId: observed.playingStationId, cleanupResult, }); }); assert.equal(radioLayer.pause({ origin: 'user' }), true); replacementAudio.listeners.get('error')?.(); await Promise.resolve(); const state = radioLayer.getUIState(); assert.equal(state.audioState, 'paused'); assert.equal(state.selected?.id, stationRows[1].id); assert.equal(state.playingStationId, stationRows[1].id); assert.equal(replacementAudio.src, stationRows[1].streamUrl); assert.deepEqual(controlEvents, [{ action: 'pause', observedAudioState: 'paused', observedStationId: stationRows[1].id, cleanupResult: false, }]); void radioLayer.togglePlayback({ origin: 'user' }); await Promise.resolve(); const resumedAudio = audioInstances.at(-1); assert.equal(radioLayer.getUIState().audioState, 'loading'); assert.equal(radioLayer.stopPlayback({ origin: 'user' }), true); assert.deepEqual(controlEvents[1], { action: 'stop', observedAudioState: 'stopped', observedStationId: null, cleanupResult: false, }); assert.equal(radioLayer.getUIState().audioState, 'stopped'); assert.equal(resumedAudio.src, ''); unsubscribeControls(); assert.equal(audioInstances.reduce((sum, audio) => sum + audio.playCalls, 0), 3); } finally { radioLayer.destroy(); globalThis.fetch = originalFetch; if (originalAudio === undefined) delete globalThis.Audio; else globalThis.Audio = originalAudio; } }); test('unusable directory responses preserve warm client state atomically', async () => { const originalFetch = globalThis.fetch; const now = new Date().toISOString(); const station = { id: '00000000-0000-4000-8000-000000000001', name: 'Warm Station', lat: 30.2672, lon: -97.7431, streamUrl: 'https://stream.example.org/live.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; radioLayer.destroy(); try { radioLayer.init(viewer); globalThis.fetch = async () => new Response(JSON.stringify({ stations: [station], stale: false, degraded: false, acceptedGeneration: 1, catalogInstance: 'qa-instance-a', updatedAt: now, }), { status: 200, headers: { 'Content-Type': 'application/json' } }); radioLayer.enable(); await radioLayer.update(); assert.equal(getRadioUIState().stationCount, 1); assert.equal(getRadioUIState().updatedAt, now); const acceptedSnapshot = getRadioAcceptedCatalogSnapshot(); assert.equal(acceptedSnapshot.generation, 1); assert.equal(acceptedSnapshot.stations[0].id, station.id); assert.equal(acceptedSnapshot.stations[0].metadataTrust, 'untrusted-community'); assert.equal(Object.isFrozen(acceptedSnapshot), true); assert.equal(Object.isFrozen(acceptedSnapshot.stations), true); assert.equal(Object.isFrozen(acceptedSnapshot.stations[0]), true); assert.equal(Object.isFrozen(acceptedSnapshot.stations[0].tags), true); assert.equal(Object.isFrozen(acceptedSnapshot.stations[0].languages), true); assert.throws(() => { acceptedSnapshot.stations[0].name = 'mutated'; }, TypeError); radioLayer.selectStation(station.id, { autoplay: false, focus: false }); const replacement = { ...station, id: '00000000-0000-4000-8000-000000000002', name: 'Partial Replacement', }; for (const body of [ { stations: [replacement], stale: false, degraded: true, acceptedGeneration: 1, updatedAt: now }, { stations: [replacement], stale: true, degraded: true, acceptedGeneration: 1, updatedAt: now }, ]) { globalThis.fetch = async () => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, }); await radioLayer.update(); const preserved = getRadioUIState(); assert.equal(preserved.stationCount, 1); assert.equal(preserved.selected?.id, station.id); assert.equal(preserved.selected?.name, station.name); assert.equal(preserved.updatedAt, now); assert.equal(preserved.degraded, true); assert.equal(preserved.stale, body.stale); assert.equal(getRadioAcceptedCatalogSnapshot(), acceptedSnapshot); } for (const body of [ { stations: {} }, { stations: [station, { id: 'malformed' }], stale: false, degraded: false, updatedAt: now }, { stations: [station], stale: false, degraded: false, updatedAt: 'not-a-date' }, { stations: [station], stale: 'false', degraded: false, updatedAt: now }, ]) { globalThis.fetch = async () => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, }); await radioLayer.update(); assert.equal(getRadioUIState().stationCount, 1); assert.equal(getRadioUIState().updatedAt, now); assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().degraded, true); } globalThis.fetch = async () => new Response(JSON.stringify({ stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now, })); await radioLayer.update(); assert.equal(getRadioUIState().stationCount, 1); assert.equal(getRadioUIState().rejectedStationCount, 1); assert.equal(getRadioUIState().degraded, true); assert.equal(getRadioUIState().stale, false); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id); // Server-side structural rejections are combined with client rejections // after admission; the healthy generation is still accepted. globalThis.fetch = async () => new Response(JSON.stringify({ stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now, rejectedStationCount: 2, })); await radioLayer.update(); assert.equal(getRadioUIState().rejectedStationCount, 3); assert.equal(getRadioUIState().degraded, true); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3); globalThis.fetch = async () => new Response(JSON.stringify({ stations: [replacement], stale: false, degraded: false, acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now, rejectedStationCount: 10_000, })); await radioLayer.update(); assert.equal(getRadioUIState().rejectedStationCount, 10_000); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) { globalThis.fetch = async () => new Response(JSON.stringify({ stations: [replacement], stale: false, degraded: false, acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, rejectedStationCount, })); await radioLayer.update(); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); assert.equal(getRadioUIState().stale, true); } globalThis.fetch = async () => new Response(JSON.stringify({ stations: [{ id: 'malformed' }], stale: false, degraded: false, acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, })); await radioLayer.update(); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().rejectedStationCount, 10_000); radioLayer.destroy(); radioLayer.init(viewer); assert.equal(getRadioUIState().rejectedStationCount, 0); assert.equal(getRadioUIState().degraded, false); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); } }); test('tuner drag keeps one immutable catalog resolution through refresh and release', async () => { const originalFetch = globalThis.fetch; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; const stationA = { id: '00000000-0000-4000-8000-000000000041', name: 'Generation A', lat: 30, lon: -97, streamUrl: 'https://radio.example.com/a.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }; const stationB = { ...stationA, name: 'Generation B', streamUrl: 'https://radio.example.com/b.mp3', }; const stationC = { ...stationB, id: '00000000-0000-4000-8000-000000000042', name: 'Generation C', }; const publish = async (stationOrStations, acceptedGeneration) => { const stations = Array.isArray(stationOrStations) ? stationOrStations : [stationOrStations]; globalThis.fetch = async () => new Response(JSON.stringify({ stations, stale: false, degraded: false, acceptedGeneration, catalogInstance: 'qa-instance-a', updatedAt: new Date().toISOString(), }), { status: 200, headers: { 'Content-Type': 'application/json' } }); await radioLayer.update(); }; radioLayer.destroy(); try { radioLayer.init(viewer); radioLayer.enable(); await publish(stationA, 1); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationA.id, { rotate: false }), true); assert.equal(getRadioUIState().tuningCatalogGeneration, 1); await publish(stationB, 2); assert.equal(getRadioUIState().tuningPreviewStationId, stationA.id); const changed = radioLayer.commitTuningStation(stationA.id, { origin: 'user' }); assert.deepEqual(changed, { ok: false, reason: 'station-unavailable', stationId: stationA.id, generation: 1, }); assert.equal(getRadioUIState().selected, null); assert.equal(getRadioUIState().tuningUnavailableStationId, stationA.id); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true); await publish(stationB, 3); const unchanged = radioLayer.commitTuningStation(stationB.id, { origin: 'user' }); assert.equal(unchanged.ok, true); assert.equal(unchanged.generation, 2); assert.equal(getRadioUIState().selected?.name, 'Generation B'); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true); await publish(stationC, 4); const removed = radioLayer.commitTuningStation(stationB.id, { origin: 'user' }); assert.equal(removed.ok, false); assert.equal(removed.reason, 'station-unavailable'); assert.equal(removed.stationId, stationB.id); assert.notEqual(getRadioUIState().selected?.id, stationC.id); await publish([stationA, stationC], 5); assert.equal(radioLayer.selectStation(stationA.id, { autoplay: false, focus: false }), true); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationC.id, { rotate: false }), true); await publish([stationA, { ...stationC, name: 'Changed target' }], 6); const priorSelectionMismatch = radioLayer.commitTuningStation(stationC.id, { origin: 'user' }); assert.equal(priorSelectionMismatch.ok, false); assert.equal(priorSelectionMismatch.reason, 'station-unavailable'); assert.equal(getRadioUIState().selected, null); assert.equal(getRadioUIState().tuningUnavailableStationId, stationC.id); await publish([stationB, stationC], 7); assert.equal(radioLayer.selectStation(stationB.id, { autoplay: false, focus: false }), true); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true); await publish([{ ...stationB, name: 'Same ID replacement' }, stationC], 8); const sameIdMismatch = radioLayer.commitTuningStation(stationB.id, { origin: 'user' }); assert.equal(sameIdMismatch.ok, false); assert.equal(sameIdMismatch.reason, 'station-unavailable'); assert.equal(getRadioUIState().selected, null); assert.equal(getRadioUIState().tuningUnavailableStationId, stationB.id); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); } }); test('failed exact tuner release cannot consume a stale playback fallback', async () => { const originalFetch = globalThis.fetch; const originalAudio = globalThis.Audio; const playUrls = []; const stationRows = [ { id: '00000000-0000-4000-8000-000000000071', name: 'Exact target', lat: 30, lon: -97, streamUrl: 'https://radio.example.com/exact.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }, { id: '00000000-0000-4000-8000-000000000072', name: 'Stale fallback', lat: 31, lon: -98, streamUrl: 'https://radio.example.com/fallback.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }, ]; globalThis.Audio = class FakeAudio { constructor() { this.volume = 0.8; this.src = ''; } addEventListener() {} pause() {} play() { playUrls.push(this.src); if (this.src === stationRows[0].streamUrl) { return Promise.reject(new DOMException('Exact target unavailable', 'NotSupportedError')); } return Promise.resolve(); } removeAttribute() { this.src = ''; } load() {} }; globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/') ? { ok: true } : new Response(JSON.stringify({ stations: stationRows, stale: false, degraded: false, acceptedGeneration: 1, catalogInstance: 'qa-instance-a', updatedAt: new Date().toISOString(), }), { status: 200, headers: { 'Content-Type': 'application/json' } }); const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; radioLayer.destroy(); try { radioLayer.init(viewer); radioLayer.enable(); await radioLayer.update(); assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true); assert.equal(radioLayer.cycleStation(1, { stationIds: stationRows.map(({ id }) => id), autoplay: false, }), true, 'non-playing cycle creates the stale-fallback precondition'); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true); assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true); await new Promise((resolve) => setTimeout(resolve)); const state = radioLayer.getUIState(); assert.deepEqual(playUrls, [stationRows[0].streamUrl]); assert.equal(state.selected?.id, stationRows[0].id); assert.equal(state.audioState, 'error'); assert.equal(state.tuningStatic, true); assert.equal(state.tuningAwaitingStationId, stationRows[0].id); } finally { globalThis.fetch = originalFetch; if (originalAudio === undefined) delete globalThis.Audio; else globalThis.Audio = originalAudio; radioLayer.destroy(); } }); test('tuner cancellation restores the frozen start marker after catalog removal', async () => { const originalFetch = globalThis.fetch; const originalAudio = globalThis.Audio; const selectedEntities = new Map(); let playCalls = 0; let flyToCalls = 0; globalThis.Audio = class FakeAudio { constructor() { this.volume = 0.8; } addEventListener() {} pause() {} play() { playCalls += 1; return Promise.resolve(); } removeAttribute() {} load() {} }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 }, flyTo() { flyToCalls += 1; }, }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} }, requestRender() {}, }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { selectedEntities.set(entity.id, entity); return entity; }, remove(entity) { return selectedEntities.delete(entity?.id); }, }, }; const stationA = { id: '00000000-0000-4000-8000-000000000051', name: 'Frozen start', lat: 30, lon: -97, streamUrl: 'https://radio.example.com/start.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }; const stationB = { ...stationA, id: '00000000-0000-4000-8000-000000000052', name: 'Preview target', lat: 44, lon: 12, streamUrl: 'https://radio.example.com/preview.mp3', }; const publish = async (rows, acceptedGeneration) => { globalThis.fetch = async () => new Response(JSON.stringify({ stations: rows, stale: false, degraded: false, acceptedGeneration, catalogInstance: 'qa-instance-a', updatedAt: new Date().toISOString(), }), { status: 200, headers: { 'Content-Type': 'application/json' } }); await radioLayer.update(); }; radioLayer.destroy(); try { radioLayer.init(viewer); radioLayer.enable(); radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, uncertain: false, }); let nextGeneration = 1; const restoreRemovedStartMarker = async () => { await publish([stationA, stationB], nextGeneration); nextGeneration += 1; assert.equal(radioLayer.selectStation(stationA.id, { autoplay: false, focus: false }), true); const startMarker = selectedEntities.get(`radio:selected:${stationA.id}`); assert.ok(startMarker); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true); await publish([stationB], nextGeneration); nextGeneration += 1; assert.equal(getRadioUIState().selected, null); assert.equal(radioLayer.cancelTuning(), true); const restoredMarker = selectedEntities.get(`radio:selected:${stationA.id}`); assert.ok(restoredMarker, 'the frozen start marker remains visible after cancellation'); assert.deepEqual(restoredMarker.position, startMarker.position); assert.equal(getRadioUIState().tuningRestoredStationId, stationA.id); assert.equal(getRadioUIState().tuningActive, false); assert.equal(getRadioUIState().selected, null, 'stale marker never becomes playback authority'); assert.equal(playCalls, 0); assert.equal(flyToCalls, 0); return radioLayer.getAcceptedCatalogSnapshot(); }; const sameFilterSnapshot = await restoreRemovedStartMarker(); const sameFilter = getRadioUIState().filter; assert.equal(radioLayer.setFilter(sameFilter), true); assert.equal(getRadioUIState().filter, sameFilter); assert.equal(getRadioUIState().tuningRestoredStationId, null); assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false); assert.equal(radioLayer.getAcceptedCatalogSnapshot(), sameFilterSnapshot); assert.equal(getRadioUIState().selected, null); assert.equal(playCalls, 0); assert.equal(flyToCalls, 0); const changedFilterSnapshot = await restoreRemovedStartMarker(); assert.equal(radioLayer.setFilter('news'), true); assert.equal(getRadioUIState().filter, 'news'); assert.equal(getRadioUIState().tuningRestoredStationId, null); assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false); assert.equal(radioLayer.getAcceptedCatalogSnapshot(), changedFilterSnapshot); assert.equal(getRadioUIState().selected, null); assert.equal(playCalls, 0); assert.equal(flyToCalls, 0); const equalGenerationSnapshot = await restoreRemovedStartMarker(); await publish([stationB], equalGenerationSnapshot.generation); assert.equal( getRadioUIState().tuningRestoredStationId, null, 'the next accepted equal-generation refresh clears the presentation-only marker', ); assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false); assert.equal(radioLayer.getAcceptedCatalogSnapshot(), equalGenerationSnapshot); assert.equal(playCalls, 0); assert.equal(flyToCalls, 0); assert.equal(radioLayer.selectStation(stationB.id, { autoplay: false, focus: false }), true); assert.equal(getRadioUIState().tuningRestoredStationId, null); assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); if (originalAudio === undefined) delete globalThis.Audio; else globalThis.Audio = originalAudio; } }); test('Radio public tuner, filter, and resume mutations require certain enabled lifecycle authority', async () => { const originalFetch = globalThis.fetch; const stationRows = [{ id: '00000000-0000-4000-8000-000000000061', name: 'Lifecycle station', lat: 30, lon: -97, streamUrl: 'https://radio.example.com/lifecycle.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }]; globalThis.fetch = async () => new Response(JSON.stringify({ stations: stationRows, stale: false, degraded: false, acceptedGeneration: 1, catalogInstance: 'qa-instance-a', updatedAt: new Date().toISOString(), }), { status: 200, headers: { 'Content-Type': 'application/json' } }); const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; viewer.scene.requestRender = () => {}; radioLayer.destroy(); try { radioLayer.init(viewer); radioLayer.enable(); await radioLayer.update(); radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, uncertain: false, }); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.setTuningStatic(false), true); for (const presentation of [ { lifecycleState: 'disabled', enabled: false, uncertain: false }, { lifecycleState: 'enabling', enabled: false, uncertain: false }, { lifecycleState: 'disabling', enabled: true, uncertain: false }, { lifecycleState: 'enabled', enabled: true, uncertain: true }, ]) { radioLayer.setLifecyclePresentation(presentation); const before = getRadioUIState(); assert.equal(radioLayer.setTuningStatic(true), false, presentation.lifecycleState); assert.equal(radioLayer.setFilter('news'), false, presentation.lifecycleState); assert.equal(await radioLayer.togglePlayback({ origin: 'user' }), false, presentation.lifecycleState); const after = getRadioUIState(); assert.equal(after.tuningStatic, before.tuningStatic, presentation.lifecycleState); assert.equal(after.filter, before.filter, presentation.lifecycleState); assert.equal(after.selected, null, presentation.lifecycleState); assert.equal(after.audioState, 'stopped', presentation.lifecycleState); } radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, uncertain: false, }); assert.equal(radioLayer.setTuningStatic(true), true); assert.equal(getRadioUIState().tuningStatic, true); assert.equal(radioLayer.cancelTuning(), true); assert.equal(radioLayer.setFilter('news'), true); assert.equal(getRadioUIState().filter, 'news'); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); } }); test('tuner refuses a degraded fallback that has no accepted catalog generation', async () => { const originalFetch = globalThis.fetch; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; const station = { id: '00000000-0000-4000-8000-000000000043', name: 'Degraded Fallback', lat: 30, lon: -97, streamUrl: 'https://radio.example.com/degraded.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }; radioLayer.destroy(); try { globalThis.fetch = async () => new Response(JSON.stringify({ stations: [station], stale: false, degraded: true, acceptedGeneration: null, updatedAt: new Date().toISOString(), }), { status: 200, headers: { 'Content-Type': 'application/json' } }); radioLayer.init(viewer); radioLayer.enable(); await radioLayer.update(); assert.equal(getRadioUIState().stationCount, 1, 'fallback remains visible outside the tuner'); assert.equal(getRadioUIState().acceptedCatalogGeneration, null); assert.deepEqual(radioLayer.getTunerStations(), []); assert.equal(radioLayer.beginTuning(), false); assert.equal(getRadioUIState().tuningActive, false); assert.equal(getRadioUIState().tuningCatalogGeneration, null); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); } }); test('accepted snapshots allowlist station fields and never regress generation identity', async () => { const originalFetch = globalThis.fetch; const now = new Date().toISOString(); const station = { id: '00000000-0000-4000-8000-000000000011', name: 'Generation One', lat: 30.2672, lon: -97.7431, streamUrl: 'https://stream.example.org/one.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, extension: { mutable: true }, }; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; const serve = (stations, acceptedGeneration, catalogInstance = 'qa-instance-a') => { globalThis.fetch = async () => new Response(JSON.stringify({ stations, stale: false, degraded: false, acceptedGeneration, catalogInstance, updatedAt: now, }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; radioLayer.destroy(); try { radioLayer.init(viewer); serve([station], 1); radioLayer.enable(); await radioLayer.update(); const generationOne = getRadioAcceptedCatalogSnapshot(); assert.equal(generationOne.generation, 1); assert.equal('extension' in generationOne.stations[0], false); const forgedSameGeneration = { ...station, id: '00000000-0000-4000-8000-000000000012', name: 'Forged Same Generation', }; serve([forgedSameGeneration], 1); await radioLayer.update(); assert.strictEqual(getRadioAcceptedCatalogSnapshot(), generationOne); assert.equal(getRadioUIState().selected, null); assert.equal(getRadioUIState().stationCount, 1); const generationTwoStation = { ...station, id: '00000000-0000-4000-8000-000000000013', name: 'Generation Two', }; serve([generationTwoStation], 2); await radioLayer.update(); const generationTwo = getRadioAcceptedCatalogSnapshot(); assert.notStrictEqual(generationTwo, generationOne); assert.equal(generationTwo.generation, 2); assert.equal(generationTwo.stations[0].id, generationTwoStation.id); serve([station], 1); await radioLayer.update(); assert.strictEqual(getRadioAcceptedCatalogSnapshot(), generationTwo); assert.equal(getRadioUIState().stationCount, 1); assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().degraded, true); assert.match(getRadioUIState().error, /refresh failed/i); // Producer restart: a NEW instance token restarts the generation sequence. // Generation 1 from instance B is a fresh catalog, not a repeat of the old // instance's generation 1 and not a regression from its generation 2. const restartStation = { ...station, id: '00000000-0000-4000-8000-000000000021', name: 'Post Restart Station', }; serve([restartStation], 1, 'qa-instance-b'); await radioLayer.update(); const postRestart = getRadioAcceptedCatalogSnapshot(); assert.notStrictEqual(postRestart, generationTwo); assert.equal(postRestart.instance, 'qa-instance-b'); assert.equal(postRestart.generation, 1); assert.equal(postRestart.stations[0].id, restartStation.id); assert.equal(getRadioUIState().stationCount, 1); assert.equal(getRadioUIState().stale, false); assert.equal(getRadioUIState().degraded, false); assert.equal(getRadioUIState().error, null); // Same generation NUMBER as the pre-restart snapshot from yet another // instance must also fully reconcile — equal numbers across instances are // different catalogs, not "unchanged". const secondRestartStation = { ...station, id: '00000000-0000-4000-8000-000000000022', name: 'Second Restart Station', }; serve([secondRestartStation], 1, 'qa-instance-c'); await radioLayer.update(); const secondRestart = getRadioAcceptedCatalogSnapshot(); assert.equal(secondRestart.instance, 'qa-instance-c'); assert.equal(secondRestart.stations[0].id, secondRestartStation.id); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); } }); test('update abort, destroy, and re-init invalidate the prior session and reset catalog telemetry', async () => { const originalFetch = globalThis.fetch; let resolveFetch; let requestSignal = null; const viewer = { camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, dataSources: { add() {}, remove() {} }, entities: { add(entity) { return entity; }, remove() {} }, }; const lateStation = { id: '00000000-0000-4000-8000-000000000099', name: 'Late Station', lat: 30, lon: -97, streamUrl: 'https://radio.example.com/late.mp3', homepage: null, tags: ['news'], languages: ['English'], state: 'Texas', country: 'United States', countryCode: 'US', metadataTrust: 'untrusted-community', codec: 'MP3', bitrate: 128, }; radioLayer.destroy(); try { globalThis.fetch = (_url, options) => { requestSignal = options.signal; return new Promise((resolve) => { resolveFetch = resolve; }); }; radioLayer.init(viewer); radioLayer.enable(); const pending = radioLayer.update(); assert.equal(getRadioUIState().loading, true); radioLayer.disable(); assert.equal(requestSignal.aborted, true); assert.equal(getRadioUIState().loading, false); radioLayer.destroy(); radioLayer.init(viewer); const cleanState = getRadioUIState(); assert.deepEqual({ enabled: cleanState.enabled, loading: cleanState.loading, stale: cleanState.stale, degraded: cleanState.degraded, error: cleanState.error, updatedAt: cleanState.updatedAt, stationCount: cleanState.stationCount, selected: cleanState.selected, audioState: cleanState.audioState, voiceDucked: cleanState.voiceDucked, voiceRestoring: cleanState.voiceRestoring, acceptedCatalogGeneration: cleanState.acceptedCatalogGeneration, }, { enabled: false, loading: false, stale: false, degraded: false, error: null, updatedAt: null, stationCount: 0, selected: null, audioState: 'stopped', voiceDucked: false, voiceRestoring: false, acceptedCatalogGeneration: null, }); assert.equal(getRadioAcceptedCatalogSnapshot().generation, null); resolveFetch(new Response(JSON.stringify({ stations: [lateStation], stale: false, degraded: false, acceptedGeneration: 99, catalogInstance: 'qa-instance-a', updatedAt: new Date().toISOString(), }), { status: 200, headers: { 'Content-Type': 'application/json' } })); await pending; assert.deepEqual(getRadioUIState(), cleanState, 'retired update completion is inert after re-init'); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); } }); test('All is the initial Radio filter', () => { assert.equal(DEFAULT_RADIO_FILTER, 'all'); assert.equal(getRadioUIState().filter, 'all'); }); test('local viewport ranking chooses geographic distance without a language override', () => { const ranked = rankRadioStationsForViewport([ { id: 'english-far', lat: 30.5, lon: -97.7, languages: ['English'] }, { id: 'spanish-near', lat: 30.2673, lon: -97.7430, languages: ['Spanish'] }, { id: 'english-near', lat: 30.28, lon: -97.74, languages: ['en'] }, ], { lat: 30.2672, lon: -97.7431 }); assert.deepEqual(ranked.map((station) => station.id), ['spanish-near', 'english-near', 'english-far']); }); test('global viewport ranking prefers English stations, then distance', () => { const ranked = rankRadioStationsForViewport([ { id: 'spanish-nearest', lat: 0, lon: 0, languages: ['Spanish'] }, { id: 'english-far', lat: 15, lon: 0, languages: ['English'] }, { id: 'english-near', lat: 5, lon: 0, languages: ['eng'] }, ], { lat: 0, lon: 0 }, { preferEnglish: true }); assert.deepEqual(ranked.map((station) => station.id), ['english-near', 'english-far', 'spanish-nearest']); assert.equal(isEnglishRadioStation(ranked[0]), true); }); test('explicit Radio requests combine category, country, station name, and distance', () => { const rows = [ { id: 'music-austin', name: 'Austin Music', tags: ['music'], countryCode: 'US', country: 'United States', lat: 30.26, lon: -97.74 }, { id: 'news-dallas', name: 'Texas News', tags: ['news'], countryCode: 'US', country: 'United States', lat: 32.77, lon: -96.79 }, { id: 'news-austin', name: 'Austin Public News', tags: ['news'], countryCode: 'US', country: 'United States', lat: 30.27, lon: -97.75 }, { id: 'news-mexico', name: 'Noticias', tags: ['news'], countryCode: 'MX', country: 'Mexico', lat: 25.68, lon: -100.31 }, ]; const ranked = rankRadioStationsForRequest(rows, { categoryId: 'news', country: 'US', anchor: { lat: 30.2672, lon: -97.7431 }, }); assert.deepEqual(ranked.map((station) => station.id), ['news-austin', 'news-dallas']); assert.equal(rankRadioStationsForRequest(rows, { categoryId: 'news', stationQuery: 'public', })[0].id, 'news-austin'); }); test('viewport ranking treats the antimeridian as adjacent', () => { const ranked = rankRadioStationsForViewport([ { id: 'far', lat: 0, lon: 150, languages: ['English'] }, { id: 'across-dateline', lat: 0, lon: -179.9, languages: ['English'] }, ], { lat: 0, lon: 179.9 }); assert.equal(ranked[0].id, 'across-dateline'); }); test('global-view threshold tolerates Cesium altitude round-off without widening the boundary', () => { assert.equal(radioViewIsGlobal(1_999_999.6), true); assert.equal(radioViewIsGlobal(1_999_998.4), false); }); test('Radio picks resolve ordinary, selected, primitive, and clustered entity ids', () => { assert.equal(radioStationIdFromPick({ id: 'radio:alpha' }), 'alpha'); assert.equal(radioStationIdFromPick({ primitive: { id: 'radio:beta' } }), 'beta'); assert.equal(radioStationIdFromPick({ id: { id: 'radio:selected:gamma' } }), 'gamma'); assert.equal(radioStationIdFromPick({ id: [{ id: 'other' }, { id: 'radio:delta' }] }), 'delta'); assert.equal(radioStationIdFromPick({ primitive: { id: [{ id: 'radio:cluster-first' }, { id: 'radio:cluster-second' }] } }), 'cluster-first'); assert.equal(radioStationIdFromPick({ id: 'flights:abc' }), null); }); test('selected Radio station bracket is a transparent four-corner category-colored SVG', () => { const svg = radioSelectionBracketSvg('#44adff'); assert.match(svg, /viewBox="0 0 40 40"/); assert.match(svg, /M2 13V2H13 M27 2H38V13 M38 27V38H27 M13 38H2V27/); assert.match(svg, /stroke="#44adff"/); assert.match(radioSelectionBracketSvg('bad-color'), /stroke="#9aa7b3"/); }); test('Radio text uses protected selected and bounded ambient WorldOverlay entries', () => { const position = { x: 1, y: 2, z: 3 }; const selected = createRadioSelectedOverlayEntry({ id: 'station-a', name: 'Austin News 93.9 FM - KQA', tags: ['news'], }, position); assert.equal(selected.variant, 'selected'); assert.equal(selected.selected, true); assert.equal(selected.protected, true); assert.equal(selected.paintLane, 'selected'); assert.equal(selected.title, '93.9 FM — Austin News'); assert.equal(selected.position, position); assert.equal(selected.maxDistance, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); const cluster = createRadioClusterOverlayEntry({ id: 'a:z:12', position, text: '12 NEWS', accent: '#44adff', stationCount: 12, }); assert.equal(cluster.variant, 'label'); assert.equal(cluster.paintLane, 'ambient-label'); assert.equal(cluster.collisionGroup, 'ambient-label'); assert.equal(cluster.horizonCull, true); assert.equal(cluster.maxDistance, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); assert.equal(cluster.distanceScale.far, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); assert.equal(cluster.title, '12 NEWS'); assert.equal(cluster.priority, 12); assert.equal(cluster.stateless, true); assert.equal(cluster.edgeFade, 'none'); assert.equal(RADIO_OVERLAY_SOURCE_OPTIONS.cohortLimit, RADIO_OVERLAY_COHORT_LIMIT); assert.ok(RADIO_OVERLAY_COHORT_LIMIT < 750); const singleton = createRadioSingletonOverlayEntry({ station: { id: 'station-b', name: '100.3 The River - WQRV', tags: ['public safety'] }, position, priority: 1.5, }); assert.equal(singleton.id, 'station:station-b'); assert.equal(singleton.title, '100.3 FM — The River'); assert.equal(singleton.paintLane, 'ambient-label'); assert.equal(singleton.collisionGroup, 'ambient-label'); assert.equal(singleton.interactive, false); assert.equal(singleton.horizonCull, true); assert.equal(singleton.maxDistance, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); assert.equal(singleton.distanceScale.far, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M); assert.equal(singleton.priority, 1.5); assert.equal(singleton.stateless, true); }); test('Radio singleton labels are zoom-aware, nearest-first, stable, and bounded', () => { assert.equal(radioSingletonLabelLimit(25_000_000), RADIO_SINGLETON_GLOBAL_LIMIT); assert.equal(radioSingletonLabelLimit(2_000_000), RADIO_SINGLETON_GLOBAL_LIMIT); assert.equal(radioSingletonLabelLimit(1_999_999), RADIO_SINGLETON_MID_LIMIT); assert.equal(radioSingletonLabelLimit(250_000), RADIO_SINGLETON_MID_LIMIT); assert.equal(radioSingletonLabelLimit(249_999), RADIO_SINGLETON_NEAR_LIMIT); const ranked = selectRadioSingletonCandidates([ { station: { id: 'far' }, distanceM: 30 }, { station: { id: 'near-z' }, distanceM: 10 }, { station: { id: 'near-a' }, distanceM: 10 }, ], 2); assert.deepEqual(ranked.map(({ station }) => station.id), ['near-a', 'near-z']); }); test('directory tuner maps every absolute slot to an available station', () => { assert.deepEqual(radioTunerSlot(0, 4), { slot: 0, max: 3, locked: true, stationIndex: 0, leftIndex: 0, rightIndex: 0, }); assert.deepEqual(radioTunerSlot(1, 4), { slot: 1, max: 3, locked: true, stationIndex: 1, leftIndex: 1, rightIndex: 1, }); assert.deepEqual(radioTunerSlot(99, 4), { slot: 3, max: 3, locked: true, stationIndex: 3, leftIndex: 3, rightIndex: 3, }); assert.equal(radioTunerSlot(0, 0).locked, false); }); test('directory tuner pointer progress maps left, center, right, and midpoint ties exactly', () => { assert.deepEqual(radioTunerPointerPosition(7, 0, 114, 750), { ratio: 0, coordinate: 0, stationIndex: 0, }); assert.deepEqual(radioTunerPointerPosition(57, 0, 114, 750), { ratio: 0.5, coordinate: 374.5, stationIndex: 375, }); assert.deepEqual(radioTunerPointerPosition(107, 0, 114, 750), { ratio: 1, coordinate: 749, stationIndex: 749, }); assert.deepEqual(radioTunerPointerPosition(50, 10, 80, 1), { ratio: 0.5, coordinate: 0, stationIndex: 0, }); assert.equal(radioTunerPointerPosition(50, 10, 80, 0).stationIndex, -1); assert.deepEqual(radioTunerCommitSlot(2.5, 5), radioTunerSlot(3, 5)); }); test('directory tuner bands preserve stable filtered order without injecting outside selections', () => { const ranked = Array.from({ length: 800 }, (_, index) => ({ id: `station-${index}` })); const selected = { id: 'selected-outside-band' }; const band = buildRadioTunerBand(ranked, selected, 750); assert.equal(band.length, 750); assert.deepEqual(band, ranked.slice(0, 750)); assert.ok(!band.includes(selected)); assert.deepEqual(buildRadioTunerBand(ranked, ranked[5], 24), ranked.slice(0, 24)); }); test('directory tuner virtual tape is bounded and moves opposite the needle', () => { const at100 = buildRadioTunerTicks(100, 750, 300); const at101 = buildRadioTunerTicks(101, 750, 300); assert.ok(at100.ticks.length <= Math.ceil(286 / at100.pitchPx) + 6); assert.ok(at101.needleX > at100.needleX); const sharedIndex = at100.ticks.find((tick) => at101.ticks.some((next) => next.stationIndex === tick.stationIndex)); assert.ok(sharedIndex); const before = at100.ticks.find((tick) => tick.stationIndex === sharedIndex.stationIndex); const after = at101.ticks.find((tick) => tick.stationIndex === sharedIndex.stationIndex); assert.ok(after.xPx < before.xPx); assert.ok(Math.abs(after.xPx - before.xPx) >= 4 * (at101.needleX - at100.needleX) - 1e-9); const singleton = buildRadioTunerTicks(0, 1, 300); assert.equal(singleton.ratio, 0.5); assert.equal(singleton.ticks.length, 1); assert.equal(buildRadioTunerTicks(0, 0, 300).ticks.length, 0); }); test('station camera plans preserve altitude and viewing orientation without zooming', () => { const nadir = radioStationCameraPlan({ lat: 30, lon: -97 }, { height: 600, heading: 0, pitch: -Math.PI / 2, roll: 0, }); assert.ok(Math.abs(nadir.lat - 30) < 1e-9); assert.ok(Math.abs(nadir.lon + 97) < 1e-9); assert.equal(nadir.height, 600); assert.equal(nadir.heading, 0); assert.equal(nadir.pitch, -Math.PI / 2); assert.equal(nadir.roll, 0); const oblique = radioStationCameraPlan({ lat: 30, lon: -97 }, { height: 600, heading: 0, pitch: -Math.PI / 6, roll: 0.2, }); assert.equal(oblique.height, 600); assert.equal(oblique.heading, 0); assert.equal(oblique.pitch, -Math.PI / 6); assert.equal(oblique.roll, 0.2); assert.ok(oblique.lat < 30); assert.ok(Math.abs(oblique.lon + 97) < 0.001); }); test('Radio recenters clipped global and below-center closer Earth discs', () => { const centered = { earthCenterX: 600, earthCenterY: 400, earthRadius: 250, keyholeCenterX: 600, keyholeCenterY: 400, keyholeRadius: 420, }; assert.equal(radioGlobeNeedsRecentering(centered), false); assert.equal(radioGlobeNeedsRecentering({ ...centered, earthCenterY: 560 }), true); assert.equal(radioGlobeNeedsRecentering({ ...centered, earthCenterX: 755 }), true); assert.equal(radioGlobeNeedsRecentering({ ...centered, earthRadius: 405 }), false); const closer = { ...centered, earthRadius: 900 }; assert.equal(radioGlobeNeedsRecentering({ ...closer, earthCenterY: 800 }), false); assert.equal(radioGlobeNeedsRecentering({ ...closer, earthCenterY: 1_300 }), true); assert.equal(radioGlobeNeedsRecentering(null), false); }); test('Radio recovery preserves closer altitude and caps extreme full-globe zoom', () => { assert.equal(radioGlobeRecenterHeight(2_915_647, false), 2_915_647); assert.equal(radioGlobeRecenterHeight(9_748_850, true), 9_748_850); assert.equal(radioGlobeRecenterHeight(97_488_500, true), 13_000_000); assert.equal(radioGlobeRecenterHeight(Number.NaN, true), null); }); test('Radio camera navigation yields to every live tracked-entity owner', () => { const viewer = { camera: {} }; assert.equal(radioCameraNavigationAllowed(viewer), true); for (const gevTrackedId of ['flights:abc123', 'military:def456']) { const trackedEntity = { gevTrackedId }; viewer.trackedEntity = trackedEntity; assert.equal(radioCameraNavigationAllowed(viewer), false, gevTrackedId); assert.equal(viewer.trackedEntity, trackedEntity); } viewer.trackedEntity = undefined; assert.equal(radioCameraNavigationAllowed(viewer), true); assert.equal(radioCameraNavigationAllowed({ trackedEntity: {} }), false); assert.equal(radioCameraNavigationAllowed(null), false); }); test('tuner static spans drag and broadcaster handoff but voice ducking silences it', () => { assert.equal(radioTuningStaticShouldPlay({ tuningActive: true, tuningStatic: true }), true); assert.equal(radioTuningStaticShouldPlay({ tuningStatic: true, awaitingStationId: 'news' }), true); assert.equal(radioTuningStaticShouldPlay({ tuningStatic: true, awaitingStationId: 'news', voiceDucked: true, }), false); assert.equal(radioTuningStaticShouldPlay({ tuningStatic: false, awaitingStationId: 'news' }), false); }); test('station horizon scans ignore stationary camera noise but detect movement', () => { const prior = { x: 1_000, y: -2_000, z: 3_000 }; assert.equal(radioCameraPositionChanged(prior, { x: 1_000, y: -2_000, z: 3_000 }), false); assert.equal(radioCameraPositionChanged(prior, { x: 1_000.5, y: -2_000, z: 3_000 }), false); assert.equal(radioCameraPositionChanged(prior, { x: 1_002, y: -2_000, z: 3_000 }), true); assert.equal(radioCameraPositionChanged(null, prior), true); }); test('voice ducking preserves the user-owned Radio volume', async () => { radioLayer.destroy(); radioLayer.enable(); radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, uncertain: false, }); try { setRadioVoiceDucking(true); let state = getRadioUIState(); assert.equal(state.voiceDucked, true); assert.equal(state.volume, 0.8); assert.equal(state.effectiveVolume, 0); assert.equal(setRadioVolume(0.42), true); state = getRadioUIState(); assert.equal(state.voiceDucked, true); assert.equal(state.volume, 0.42); assert.equal(state.effectiveVolume, 0); setRadioVoiceDucking(false, { restoreDelayMs: 0, restoreDurationMs: 0 }); await new Promise((resolve) => setTimeout(resolve, 25)); state = getRadioUIState(); assert.equal(state.voiceDucked, false); assert.equal(state.voiceRestoring, false); assert.equal(state.volume, 0.42); assert.equal(state.effectiveVolume, 0.42); } finally { radioLayer.destroy(); } }); test('Radio volume requires certain enabled lifecycle authority', () => { radioLayer.destroy(); assert.equal(setRadioVolume(0.2), false); assert.equal(getRadioUIState().volume, 0.8); radioLayer.enable(); for (const presentation of [ { lifecycleState: 'enabling', enabled: false, uncertain: false }, { lifecycleState: 'disabling', enabled: true, uncertain: false }, { lifecycleState: 'enabled', enabled: true, uncertain: true }, ]) { radioLayer.setLifecyclePresentation(presentation); assert.equal(setRadioVolume(0.2), false, presentation.lifecycleState); assert.equal(getRadioUIState().volume, 0.8); } radioLayer.setLifecyclePresentation({ lifecycleState: 'enabled', enabled: true, uncertain: false, }); assert.equal(setRadioVolume(0.2), true); assert.equal(getRadioUIState().volume, 0.2); radioLayer.destroy(); }); test('voice playback confirmation waits for playing and requires a hard duck', async () => { let listener = () => {}; let state = { audioState: 'loading', playingStationId: 'news', voiceDucked: true, }; const confirmed = confirmRadioPlayback({ startPlayback: async () => { state = { ...state, audioState: 'playing' }; listener(state); return true; }, subscribe: (next) => { listener = next; next({ ...state, audioState: 'stopped' }); return () => { listener = () => {}; }; }, getState: () => state, timeoutMs: 50, }); assert.equal(await confirmed, true); state = { ...state, voiceDucked: false }; assert.equal(await confirmRadioPlayback({ startPlayback: async () => true, subscribe: (next) => { next(state); return () => {}; }, getState: () => state, timeoutMs: 5, }), false); }); test('voice playback confirmation accepts fallback buffering but times out safely', async () => { const state = { audioState: 'loading', playingStationId: 'fallback', voiceDucked: true, }; const startedAt = Date.now(); const confirmed = await confirmRadioPlayback({ startPlayback: async () => false, subscribe: (next) => { next(state); return () => {}; }, getState: () => state, timeoutMs: 10, }); assert.equal(confirmed, false); assert.ok(Date.now() - startedAt >= 8); });