mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
add the shared Voice Preview and Settings panel with browser-local preview and inactivity preferences, keyboard controls and responsive positioning preserve main’s Rotation and Context behavior, clarify panel routing and refresh schema pins within the unchanged prompt limit
419 lines
26 KiB
JavaScript
419 lines
26 KiB
JavaScript
import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs';
|
|
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
|
|
import { realtimeInstructions } from '../server/providers/openai/instructions.js';
|
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
|
import { expandApplicationHtml } from '../build/application-html.js';
|
|
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
|
import { readFileSync as readRadioSource } from 'node:fs';
|
|
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
|
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
|
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
|
|
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
|
const ui = readShellSource();
|
|
const radio = ['playback', 'interaction'].map(name =>
|
|
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
|
|
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
|
|
const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
|
|
const realtime = readRealtimeSource();
|
|
const voice = ['./voice/actionSchemas.js', '../server/providers/openai/toolDescriptions.js', '../server/providers/openai/instructions.js'].map(file => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n');
|
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
|
|
|
function realtimeTools() { return GEV_REALTIME_TOOLS; }
|
|
|
|
test('Realtime schema exposes the authoritative 31-tool inventory', () => {
|
|
const tools = realtimeTools();
|
|
assert.equal(tools.length, 31);
|
|
const names = tools.map((tool) => tool.name);
|
|
assert.equal(new Set(names).size, 31, 'tool names are unique');
|
|
assert.ok(names.includes('set_context_mode'));
|
|
assert.ok(names.includes('control_cockpit'));
|
|
assert.ok(names.includes('set_voice_inactivity_timeout'));
|
|
assert.ok(names.includes('select_nearest_aircraft'));
|
|
assert.ok(names.includes('control_radio'));
|
|
assert.ok(names.includes('next_satellite_pass'));
|
|
assert.ok(names.includes('next_iss_pass'));
|
|
// Every tool closes its parameter object: an open schema lets the model
|
|
// invent arguments the runner silently drops.
|
|
for (const tool of tools) {
|
|
assert.equal(tool.type, 'function', `${tool.name} is not a function tool`);
|
|
assert.equal(
|
|
tool.parameters?.additionalProperties,
|
|
false,
|
|
`${tool.name} does not close additionalProperties`,
|
|
);
|
|
assert.ok(tool.description, `${tool.name} has no description`);
|
|
}
|
|
});
|
|
|
|
test('the counting contract is stated in the Realtime instructions', () => {
|
|
// Owner ruling: "near" has one meaning per state, and every count names its
|
|
// scope. Instruction text is the only place the narration rules can live, so
|
|
// it is pinned — a silent trim here is a silent behaviour change.
|
|
const start = voice.indexOf("'COUNTING CONTRACT");
|
|
assert.ok(start >= 0, 'the counting contract instruction is missing');
|
|
// One instruction per source line; the string carries escaped quotes, so take
|
|
// the line rather than trying to match a quoted literal.
|
|
const text = voice.slice(start, voice.indexOf('\n', start));
|
|
assert.match(text, /Contacts is ACTIVE/, 'rule 1: active means the Contacts window');
|
|
assert.match(text, /contactsWindow/, 'rule 1 names its mechanism');
|
|
assert.match(text, /call set_context_mode\{mode:"contacts"\} first/);
|
|
assert.match(text, /contactsWindow\.aircraft/);
|
|
assert.match(text, /Contacts OFF, "nearby" means in view/, 'rule 2: off means in view');
|
|
assert.match(text, /EVERY count names its scope in words/, 'rule 3');
|
|
assert.match(text, /scopeLabel/, 'rule 3 names its mechanism');
|
|
assert.match(text, /never a bare number/, 'rule 3 is stated as a prohibition too');
|
|
assert.match(text, /VERBATIM/, 'rule 4: no estimating');
|
|
// Rule 5, the loaded-data caveat, is now one word inside the spoken scope
|
|
// ("37 loaded flights over Texas"); the longer note stays on screen.
|
|
const analyst = voice.slice(
|
|
voice.indexOf("'For ANALYTICAL questions"),
|
|
voice.indexOf('\n', voice.indexOf("'For ANALYTICAL questions")),
|
|
);
|
|
assert.match(analyst, /"37 loaded flights over Texas"/, 'rule 5: loaded scope stays audible');
|
|
});
|
|
|
|
test('Context panel opening stays distinct from Contacts activation', () => {
|
|
const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:'));
|
|
assert.ok(text, 'panel-routing instruction is missing');
|
|
assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/);
|
|
assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/);
|
|
assert.match(text, /"Open Contacts" is set_context_mode contacts/);
|
|
assert.match(text, /expands Context itself/);
|
|
});
|
|
|
|
test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
|
|
const start = voice.indexOf("'For a request to enable an aircraft layer and SELECT or FIND");
|
|
assert.ok(start >= 0, 'nearest-aircraft selection routing instruction is missing');
|
|
const text = voice.slice(start, voice.indexOf('\n', start));
|
|
assert.match(text, /Turn on flights and select the closest aircraft to Austin/);
|
|
assert.match(text, /call select_nearest_aircraft once/);
|
|
assert.match(text, /atomically turns on the requested aircraft layer first/);
|
|
assert.match(text, /waits for location arrival/);
|
|
assert.match(text, /refreshes that layer for the destination viewport/);
|
|
assert.match(text, /filters out landed\/on-ground records/);
|
|
assert.match(text, /nearest airborne result/);
|
|
assert.match(text, /healthy fallback feed is valid data/i);
|
|
assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/);
|
|
const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:'));
|
|
assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/);
|
|
assert.match(text, /set_context_mode, or control_cockpit/);
|
|
|
|
const byName = new Map(realtimeTools().map((tool) => [tool.name, tool]));
|
|
assert.match(byName.get('set_context_mode').description, /only when explicitly requested/);
|
|
assert.match(routing, /selecting\/viewing an aircraft never enters them/);
|
|
assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/);
|
|
assert.equal(
|
|
byName.get('fly_to_location').parameters.properties.waitForArrival.type,
|
|
'boolean',
|
|
);
|
|
const nearest = byName.get('select_nearest_aircraft');
|
|
assert.deepEqual(nearest.parameters.required, ['layerId']);
|
|
assert.deepEqual(nearest.parameters.properties.layerId.enum, ['flights', 'military']);
|
|
assert.match(nearest.description, /Atomically/);
|
|
assert.match(nearest.description, /exclude on-ground records/);
|
|
assert.match(nearest.description, /fallback feeds remain usable/);
|
|
});
|
|
|
|
test('the two Context/Cockpit tools pin their enums and required arguments', () => {
|
|
const byName = new Map(realtimeTools().map((tool) => [tool.name, tool]));
|
|
|
|
const contextMode = byName.get('set_context_mode');
|
|
assert.deepEqual(contextMode.parameters.required, ['mode']);
|
|
assert.deepEqual(
|
|
contextMode.parameters.properties.mode.enum,
|
|
['off', 'contacts', 'flights', 'space-missions', 'missions'],
|
|
);
|
|
|
|
const cockpit = byName.get('control_cockpit');
|
|
assert.deepEqual(cockpit.parameters.required, ['action']);
|
|
assert.deepEqual(
|
|
cockpit.parameters.properties.action.enum,
|
|
['enter', 'exit', 'previous', 'next', 'prev', 'status'],
|
|
);
|
|
assert.deepEqual(
|
|
cockpit.parameters.properties.targetLayer.enum,
|
|
['flights', 'military', 'ais-live-vessels', 'military-installations'],
|
|
'the layer filter must match the four Context cohorts exactly',
|
|
);
|
|
// aircraftClass is deliberately open (free-form class names), but still typed.
|
|
assert.equal(cockpit.parameters.properties.aircraftClass.type, 'string');
|
|
assert.equal(cockpit.parameters.properties.aircraftClass.enum, undefined);
|
|
});
|
|
|
|
test('the edited existing tools changed exactly as intended', () => {
|
|
const byName = new Map(realtimeTools().map((tool) => [tool.name, tool]));
|
|
|
|
// Edit 1: the Context panel became voice-addressable alongside set_context_mode.
|
|
const panel = byName.get('set_panel_open');
|
|
assert.deepEqual(
|
|
panel.parameters.properties.panelId.enum,
|
|
['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'],
|
|
);
|
|
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
|
|
|
|
// Edit 2: description only — the view state now reports Context and Cockpit.
|
|
const viewState = byName.get('get_current_view_state');
|
|
assert.match(viewState.description, /Context, Cockpit/);
|
|
assert.deepEqual(viewState.parameters.properties, {});
|
|
|
|
// Edit 3: dependent multi-tool navigation can wait for the destination view.
|
|
const location = byName.get('fly_to_location');
|
|
assert.equal(location.parameters.properties.waitForArrival.type, 'boolean');
|
|
assert.match(location.parameters.properties.waitForArrival.description, /arrived=true/);
|
|
});
|
|
|
|
test('no unchanged Realtime tool definition drifts silently', () => {
|
|
// Context/Cockpit parity, the dependent-location wait edit, and the retired
|
|
// `bing-road` stack leaving `set_map_stack`'s enum are the known schema
|
|
// changes. Everything else must be byte-identical: an unnoticed edit
|
|
// to a shipped tool changes
|
|
// model behavior in production with nothing in review to catch it.
|
|
//
|
|
// If this fails and the change was deliberate, re-derive the digest and say
|
|
// in the mic-test brief which tools moved — the session cache busts on any
|
|
// schema change.
|
|
const TOUCHED = new Set([
|
|
'set_cyber_sonar',
|
|
'set_context_mode',
|
|
'control_cockpit',
|
|
'set_panel_open',
|
|
'set_voice_inactivity_timeout',
|
|
'get_current_view_state',
|
|
'fly_to_location',
|
|
'select_nearest_aircraft',
|
|
'set_map_stack',
|
|
'analyst_query',
|
|
'next_iss_pass',
|
|
'next_satellite_pass',
|
|
// Local ADS-B adds one layer enum value and its common-name mapping.
|
|
'set_layer_visibility',
|
|
// Layer enums generated from the voice layer manifest, and the corrected
|
|
// track_entity layer hint.
|
|
'show_data_layers_menu',
|
|
'get_entity_context',
|
|
'track_entity',
|
|
// Point-and-ask: 'pointer' sentinels and referent arguments.
|
|
'annotate_map',
|
|
]);
|
|
const unchanged = realtimeTools()
|
|
.filter((tool) => !TOUCHED.has(tool.name))
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map((tool) => structuredClone(tool))
|
|
.filter((tool) => tool.name !== 'set_cyber_sonar');
|
|
// Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field.
|
|
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
|
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
|
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
|
// Globe rotation adds one motion and clarifies the two affected descriptions.
|
|
// Check those edits explicitly, then keep the legacy pin for every other field.
|
|
const camera = unchanged.find((tool) => tool.name === 'move_camera');
|
|
assert.deepEqual(camera.parameters.properties.motion.enum, [
|
|
'orbit',
|
|
'pan',
|
|
'tilt',
|
|
'rotate',
|
|
'rotate_globe',
|
|
'stop',
|
|
]);
|
|
assert.equal(
|
|
camera.description,
|
|
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.',
|
|
);
|
|
assert.equal(
|
|
camera.parameters.properties.direction.description,
|
|
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
|
|
);
|
|
assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.');
|
|
camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.';
|
|
camera.parameters.properties.motion.enum =
|
|
camera.parameters.properties.motion.enum.filter(
|
|
(motion) => motion !== 'rotate_globe',
|
|
);
|
|
camera.description =
|
|
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").';
|
|
camera.parameters.properties.direction.description =
|
|
'Required except for orbit (defaults right/clockwise) and stop.';
|
|
assert.equal(unchanged.length, 14);
|
|
const digest = createHash('sha256')
|
|
.update(JSON.stringify(unchanged))
|
|
.digest('hex')
|
|
.slice(0, 16);
|
|
// Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin.
|
|
assert.equal(digest, '25d1b902ecaa22fb', 'an unchanged Realtime tool definition drifted');
|
|
});
|
|
|
|
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
|
for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) {
|
|
assert.match(
|
|
html,
|
|
new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`),
|
|
);
|
|
}
|
|
assert.match(
|
|
rocketLaunches,
|
|
/id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0\.25" max="4" step="0\.25" value="1"/,
|
|
);
|
|
assert.match(rocketLaunches, /class="gev-slider-value"[^>]*data-mission-replay-speed-output/);
|
|
assert.match(css, /\.gev-quantitative-slider\s*\{[\s\S]*?min-width: 0;[\s\S]*?height: 18px;/);
|
|
assert.match(css, /\.gev-quantitative-slider::-webkit-slider-runnable-track\s*\{[\s\S]*?height: 3px;[\s\S]*?background: rgba\(255, 255, 255, 0\.08\);/);
|
|
assert.match(css, /\.gev-quantitative-slider::-webkit-slider-thumb\s*\{[\s\S]*?width: 10px;[\s\S]*?height: 10px;[\s\S]*?border-radius: 50%;[\s\S]*?background: var\(--accent\);/);
|
|
assert.match(css, /\.gev-quantitative-slider:focus-visible\s*\{[\s\S]*?outline: 1px solid/);
|
|
assert.match(css, /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: 0\.42;[\s\S]*?cursor: not-allowed;/);
|
|
assert.match(css, /\.gev-slider-value\s*\{[\s\S]*?color: var\(--accent\);[\s\S]*?font-size: 9px;/);
|
|
assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/);
|
|
});
|
|
|
|
test('Radio is nested inside Context with separate disclosure and power controls', () => {
|
|
const contextStart = html.indexOf('id="global-context-panel"');
|
|
const radioStart = html.indexOf('id="radio-panel"');
|
|
const contextEnd = html.indexOf('\n </aside>', contextStart);
|
|
assert.ok(contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd);
|
|
assert.match(html, /id="radio-panel"[^>]*data-panel-id="radio-panel"/);
|
|
assert.match(html, /aria-label="Radio playback"/);
|
|
assert.match(html, /id="context-radio-toggle-btn"[^>]*aria-expanded="false"[^>]*aria-controls="context-radio-mini"/);
|
|
assert.doesNotMatch(html, /id="context-radio-toggle-btn"[^>]*aria-pressed=/);
|
|
assert.match(html, /id="context-radio-mini"[^>]*aria-label="Compact Radio controls"[^>]*hidden/);
|
|
assert.match(html, /id="context-radio-mini-enable-btn"[^>]*aria-pressed="false"/);
|
|
assert.match(html, /id="context-radio-details-btn"[^>]*aria-expanded="false"[^>]*aria-controls="radio-panel"/);
|
|
assert.match(html, /id="context-radio-details-btn"[\s\S]*?<span class="material-symbols-outlined" aria-hidden="true">open_in_full<\/span>/);
|
|
assert.match(html, /id="context-radio-mini-close-btn"[^>]*aria-label="Close compact Radio controls"/);
|
|
assert.match(html, /id="context-radio-mini-(?:prev|play|next)-btn"/);
|
|
assert.match(html, /id="context-radio-mini-volume"/);
|
|
assert.match(html, /id="cockpit-radio-panel"[^>]*aria-label="Cockpit compact Radio controls"[^>]*hidden/);
|
|
assert.match(html, /id="cockpit-radio-enable-btn"[^>]*aria-pressed="false"/);
|
|
assert.match(html, /id="cockpit-radio-(?:prev|play|next)-btn"/);
|
|
assert.match(html, /id="cockpit-radio-volume"/);
|
|
assert.match(html, /id="radio-tuner"[^>]*hidden/);
|
|
assert.match(html, /id="radio-tuner-band-label">DIRECTORY BAND/);
|
|
assert.match(html, /id="radio-tuner-slider"[^>]*type="range"/);
|
|
assert.match(html, /id="radio-tuner-needle"[^>]*aria-hidden="true"/);
|
|
assert.match(html, /SNAPS TO AVAILABLE STATIONS/);
|
|
assert.match(html, /class="radio-tuner-scale" aria-hidden="true"><\/div>/);
|
|
assert.match(html, /DIRECTORY: RADIO BROWSER/);
|
|
assert.match(html, /Audio connects directly to the broadcaster/);
|
|
assert.doesNotMatch(html, /radio-(?:favicon|visualizer|spectrum)/i);
|
|
assert.match(css, /#radio-tuner-slider::-(?:webkit-slider-thumb|moz-range-thumb)/);
|
|
assert.match(css, /\.radio-tuner\.is-static/);
|
|
assert.match(css, /\.radio-tuner-tick\s*\{/);
|
|
assert.doesNotMatch(css, /radio-tuner-scale-(?:left|right)/);
|
|
assert.match(css, /\.radio-tuner-needle\s*\{[\s\S]*?transition: left 0\.18s ease-out;/);
|
|
assert.match(css, /\.radio-tuner\.is-dragging \.radio-tuner-needle,[\s\S]*?\.radio-tuner\.is-dragging \.radio-tuner-tick\s*\{\s*transition: none;/);
|
|
assert.match(css, /\.radio-tuner\s*\{[\s\S]*?max-width: 100%;[\s\S]*?overflow: hidden;/);
|
|
assert.match(css, /#radio-tuner-slider\s*\{[\s\S]*?max-width: 100%;[\s\S]*?touch-action: none;/);
|
|
assert.match(css, /#title-bar\.radio-broadcasting \.title-logo::before/);
|
|
assert.match(css, /#title-bar\.radio-broadcasting \.title-logo::after/);
|
|
assert.match(css, /--radio-broadcast-opacity: 0\.17/);
|
|
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/);
|
|
assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/);
|
|
assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
|
|
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
|
|
const cycleStart = radioBindings.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
|
|
const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart));
|
|
assert.doesNotMatch(cycleMethod, /refreshTunerBand/);
|
|
assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/);
|
|
assert.match(radioBindings, /listen\(\s*this\.canvas,\s*'pointerdown',\s*releaseNavigationBand/);
|
|
assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
|
|
assert.match(radioBindings, /tunerPreview\(\{\s*coordinate: this\._radioTunerCoordinate,\s*rotate: commit,?\s*\}\)/);
|
|
assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/);
|
|
assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/);
|
|
assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/);
|
|
assert.match(radioBindings, /radioTunerPointerPosition\(/);
|
|
// Only the Cyber skin promotes Radio to a peer panel. Other themes retain
|
|
// the embedded Context layout; theme round-trip behavior has separate tests.
|
|
const baseCss = css.replace(readFileSync(new URL('./ui/styles/cyber.css', import.meta.url), 'utf8'), '');
|
|
assert.doesNotMatch(baseCss, /#right-context-rail\s*>\s*#radio-panel/);
|
|
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
|
|
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
|
|
assert.doesNotMatch(css, /\.context-radio-dock\.active:focus-within \.context-radio-mini/);
|
|
assert.match(css, /#right-context-rail #global-context-panel:not\(\.collapsed\) \.context-mode-view,[\s\S]*?#right-context-rail #global-context-panel:not\(\.collapsed\) #radio-panel\s*\{[\s\S]*?flex: 0 0 auto;/);
|
|
});
|
|
|
|
test('panel collapse is presentation-only and Radio exposes explicit voice playback controls', () => {
|
|
const start = ui.lastIndexOf('\n setPanelCollapsed(panelId');
|
|
const method = ui.slice(start, ui.indexOf('toggleCleanView(forceEnabled)', start));
|
|
assert.doesNotMatch(method, /stopRadio|stopPlayback|setEnabled\('radio'/);
|
|
assert.match(voice, /'radio-panel'/);
|
|
assert.ok(
|
|
realtimeTools()
|
|
.find((tool) => tool.name === 'set_layer_visibility')
|
|
.parameters.properties.layerId.enum.includes('radio'),
|
|
);
|
|
assert.match(voice, /name:\s*'control_radio'/);
|
|
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
|
|
const enableMethod = radioBindings;
|
|
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
|
|
assert.match(enableMethod, /contextRadioToggleBtn/);
|
|
assert.match(enableMethod, /contextRadioMiniEnableBtn/);
|
|
assert.match(enableMethod, /contextRadioDetailsBtn/);
|
|
assert.match(enableMethod, /contextRadioMiniCloseBtn/);
|
|
assert.match(enableMethod, /contextRadioMiniPlayBtn/);
|
|
const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,');
|
|
const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart);
|
|
const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd);
|
|
assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing');
|
|
assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/);
|
|
assert.match(enableMethod, /toggleRadio\(this\._contextRadioMiniEnableBtn\)/);
|
|
assert.match(disclosureBindings, /contextRadioMiniCloseBtn[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: true \}\)/);
|
|
assert.match(disclosureBindings, /contextRadioDetailsBtn[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)/);
|
|
assert.match(
|
|
disclosureBindings,
|
|
/contextRadioToggleBtn[\s\S]*?!contextPanel\.classList\.contains\('collapsed'\)[\s\S]*?setRadioDisclosure\(false\)[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)[\s\S]*?_revealRadioPanelInsideContext/,
|
|
);
|
|
assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/);
|
|
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
|
|
assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
|
|
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
|
|
});
|
|
|
|
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
|
|
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
|
|
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
|
|
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
|
|
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
|
|
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
|
|
assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/);
|
|
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
|
|
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
|
|
|
|
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
|
|
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
|
|
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
|
|
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
|
|
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/);
|
|
assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/);
|
|
const renderMethod = radioPresentation;
|
|
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
|
|
assert.doesNotMatch(renderMethod, /compact Radio controls/);
|
|
assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/);
|
|
});
|
|
|
|
test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => {
|
|
const renderMethod = radioPresentation;
|
|
assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s);
|
|
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
|
|
assert.match(ui, /contextRadioDetailsBtn/);
|
|
const syncStart = ui.indexOf('\n _syncPanelCollapseButton(panelEl)');
|
|
const syncMethod = ui.slice(syncStart, ui.indexOf('\n }', syncStart + 10));
|
|
assert.doesNotMatch(syncMethod, /contextRadioDetailsBtn[\s\S]*?(?:aria-label|textContent|\.title)/);
|
|
});
|
|
|
|
test('successful explicit user playback hands the speaker from voice to Radio', () => {
|
|
const playStart = radio.indexOf('async function playSelectedRadio');
|
|
const playMethod = radio.slice(playStart, radio.indexOf('function confirmRadioPlayback', playStart + 10)).replace(/\s+/g, ' ');
|
|
const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'");
|
|
const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)");
|
|
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
|
|
assert.match(radio, /startPlayback: \(\) =>\s*playSelectedRadio\(\{\s*origin: 'voice',\s*attemptId: options\.attemptId,?\s*\}\)/);
|
|
assert.match(radio, /selectRadioStation\(stationId, \{\s*autoplay: true,\s*origin: 'user',?\s*\}\)/);
|
|
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
|
|
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
|
|
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
|
|
assert.match(realtime, /event\.origin === 'user' &&\s*event\.action === 'play' &&\s*this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/);
|
|
});
|