Merge origin/main into fix/hal-722-radio-next-no-autoplay

main moved the Radio markup into Vue views and rebuilt the UI tests on
stores. Resolved:
- src/ui/templates/context.html: deleted in main; the new privacy note now
  lives in src/ui/views/panels/RadioPanel.vue.
- src/ui/radioControls.test.mjs: main's store fixture; the Next/Previous
  autoplay test drives store.act('cycleRadio').
- src/voice/gevRealtime.test.mjs: main's createTestVoiceUi; the stop and
  select handoff cases stay one table-driven test.
- src/data/radioNextState.test.mjs reads the published gev-radio view.
- src/i18n/locales/ar.json: the new note translated for the Arabic locale.
This commit is contained in:
German Khokhlov committed 2026-11-02 12:36:31 +04:00
commit a2be947aba
679 files changed
+45369 -19625

No files matched your search

+296 -170
View File
@@ -1,7 +1,13 @@
import { t } from '../i18n/index.js';
import { nextTick } from 'vue';
import { onLocaleChange, t } from '../i18n/index.js';
import { useVoiceStore } from '../ui/stores/voice.js';
import { applicationPinia } from '../ui/vueRuntime.js';
// The assistant's name on its card. i18n-ignore: product name
const ASSISTANT_NAME = 'GEV';
export const ASSISTANT_NAME = 'GEV';
/** Bars in the dock waveform. */
export const VOICE_VISUALIZER_BARS = 15;
/**
* Short state label for the narrow voice heading. The `voice` context lets
@@ -26,174 +32,294 @@ export function voiceStatusLabel(status) {
}
}
/** Build the voice control independently of its connection backend. */
export function createVoiceControl({ reset = false, panel = false } = {}) {
let root = document.getElementById('gev-voice-control');
if (root && reset) {
root.remove();
root = null;
}
if (!root) {
root = document.createElement('div');
root.id = 'gev-voice-control';
root.dataset.status = 'idle';
root.dataset.speaker = 'idle';
root.innerHTML = `
<label id="gev-voice-provider-field" class="gev-voice-provider-field" hidden>
<span>${t('Voice provider')}</span>
<select id="gev-voice-provider" aria-label="${t('Voice provider')}" title="${t('Changing provider reconnects voice if it is on.')}"></select>
</label>
${panel ? `<button id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" aria-label="${t('Voice Settings')}" aria-controls="gev-voice-panel" aria-expanded="false"><span class="material-symbols-outlined gev-voice-settings-icon" aria-hidden="true">settings</span></button>` : ''}
<div class="gev-voice-heading">
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
<div id="gev-voice-status">${voiceStatusLabel('idle')}</div>
${
panel
? `<span id="gev-voice-cost-warning" class="gev-voice-cost-value gev-voice-cost-warning" hidden role="status" aria-live="polite"></span>`
: `<div class="gev-voice-cost">
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>`
}
</div>
<button id="gev-voice-button" type="button" aria-label="${t('Voice control — activate to toggle voice; hold Space to speak')}" aria-describedby="gev-voice-help">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
<span class="gev-mic-label">${t('ON/OFF')}</span>
</button>
<div class="gev-voice-visualizer" aria-hidden="true">
${Array.from({ length: 15 }, (_, index) => `<span style="--bar:${index}"></span>`).join('')}
</div>
<div class="gev-voice-readout">
<div id="gev-voice-detail">${t('VOICE STANDBY')}</div>
</div>
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
<span class="gev-voice-help-kicker">${t('VOICE CONTROL')}</span>
<span class="gev-voice-help-detail">${t('Hold Space to speak · tap Space to activate focused controls')}</span>
</div>
${
panel
? `<section id="gev-voice-panel" class="gev-voice-panel" hidden aria-label="${t('Voice Settings')}">
<div class="gev-voice-panel-header">
<span class="gev-voice-panel-title">${t('VOICE')}</span>
<div class="gev-voice-panel-tabs" role="tablist" aria-label="${t('Voice Settings')}">
<button id="gev-voice-preview-tab" class="gev-voice-panel-tab" type="button" role="tab" aria-label="${t('Preview')}" title="${t('Preview')}" aria-selected="true" aria-controls="gev-voice-preview-panel"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">visibility</span></button>
<button id="gev-voice-settings-tab" class="gev-voice-panel-tab" type="button" role="tab" aria-label="${t('Settings')}" title="${t('Settings')}" aria-selected="false" tabindex="-1" aria-controls="gev-voice-settings-panel"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">settings</span></button>
</div>
<button id="gev-voice-settings-close" type="button" aria-label="${t('Close voice panel')}">×</button>
</div>
<div class="gev-voice-panel-body">
<section id="gev-voice-preview-panel" role="tabpanel" aria-labelledby="gev-voice-preview-tab">
<p id="gev-voice-preview-empty">${t('Voice replies appear here.')}</p>`
: ''
}
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="${t('Voice assistant')}">
<div class="gev-voice-card-header">
<span class="gev-voice-card-kicker">${t('VOICE')}</span>
<span id="gev-voice-card-phase" class="gev-voice-card-phase"></span>
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="${t('Dismiss voice card')}">×</button>
</div>
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span id="gev-voice-card-user-who" class="gev-voice-card-who">${t('YOU')}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">${t('THIS')}</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">${ASSISTANT_NAME}</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="${t('Plan')}" hidden></ol>
<div id="gev-voice-card-result" class="gev-voice-card-result" hidden>
<div id="gev-voice-card-result-title" class="gev-voice-card-result-title"></div>
<div id="gev-voice-card-chips" class="gev-voice-card-chips" hidden></div>
<ul id="gev-voice-card-lines" class="gev-voice-card-lines"></ul>
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" aria-label="${t('Referenced items')}" hidden></ol>
<details id="gev-voice-card-notes" class="gev-voice-card-notes" hidden>
<summary>${t('NOTES & SOURCES')}</summary>
<ul id="gev-voice-card-note-list" class="gev-voice-card-lines"></ul>
</details>
</div>
<div id="gev-voice-card-live" class="gev-voice-card-sr" aria-live="polite" aria-atomic="true"></div>
</section>
${
panel
? `</section>
<section id="gev-voice-settings-panel" role="tabpanel" aria-labelledby="gev-voice-settings-tab" hidden>
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-voice-preview-enabled" type="checkbox" checked aria-describedby="gev-voice-preview-note" /><span>${t('Show Voice Preview')}</span></label>
<p id="gev-voice-preview-note" class="gev-voice-settings-note"></p>
<div class="gev-voice-timeout-group">
<label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">${t('Voice inactivity timeout')}</label>
<div class="gev-voice-timeout-controls">
<select id="gev-voice-timeout-select" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status">
<option value="3">3 ${t('Minutes')}</option><option value="5" selected>5 ${t('Minutes')}</option><option value="10">10 ${t('Minutes')}</option><option value="15">15 ${t('Minutes')}</option>
<option value="custom">${t('Custom')}</option><option value="0">${t('None')}</option>
</select>
<label class="gev-voice-timeout-unit" for="gev-voice-timeout-input">${t('Minutes')}</label>
<input id="gev-voice-timeout-input" type="number" min="1" max="60" step="1" value="5" aria-describedby="gev-voice-timeout-note" />
</div>
<p id="gev-voice-timeout-note" class="gev-voice-settings-note">${t('These preferences stay in this browser.')}</p>
<p id="gev-voice-timeout-status" class="gev-voice-settings-note" role="status" aria-live="polite"></p>
</div>
<div class="gev-voice-cost-settings">
<div class="gev-voice-cost-setting-row">
<span>${t('Voice model tier — applies next session')}</span>
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
</div>
<div class="gev-voice-cost-setting-row">
<span>${t('Estimated session cost')}</span>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>
</div>
</section>
</div>
</section>`
: ''
}
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
<div class="gev-voice-error-header">
<span>${t('VOICE SYSTEM ERROR')}</span>
<button class="gev-voice-error-dismiss" type="button">${t('DISMISS')}</button>
</div>
<div id="gev-voice-error-detail"></div>
<div class="gev-voice-error-hint">${t('Check microphone permission and network access, then try again.')}</div>
</div>
`;
const commandDock = document.getElementById('command-dock');
if (commandDock) {
const locationBar = document.getElementById('location-bar');
const controlPanel = document.getElementById('control-panel');
commandDock.appendChild(root);
if (locationBar) commandDock.insertBefore(locationBar, root);
if (controlPanel) commandDock.appendChild(controlPanel);
} else {
document.body.appendChild(root);
}
root
.querySelector('.gev-voice-error-dismiss')
?.addEventListener('click', () => {
root.classList.add('error-dismissed');
});
}
/** The card before its first render: hidden, with its default labels. */
function initialCard() {
return {
root,
voicePanel: root.querySelector('#gev-voice-panel'),
previewPanel: root.querySelector('#gev-voice-preview-panel'),
voiceSettingsPanel: root.querySelector('#gev-voice-settings-panel'),
previewTab: root.querySelector('#gev-voice-preview-tab'),
settingsTab: root.querySelector('#gev-voice-settings-tab'),
voiceSettingsButton: root.querySelector('#gev-voice-settings-button'),
voiceSettingsClose: root.querySelector('#gev-voice-settings-close'),
previewEmpty: root.querySelector('#gev-voice-preview-empty'),
voicePreviewInput: root.querySelector('#gev-voice-preview-enabled'),
voicePreviewNote: root.querySelector('#gev-voice-preview-note'),
voiceTimeoutSelect: root.querySelector('#gev-voice-timeout-select'),
voiceTimeoutInput: root.querySelector('#gev-voice-timeout-input'),
voiceTimeoutNote: root.querySelector('#gev-voice-timeout-note'),
voiceTimeoutStatus: root.querySelector('#gev-voice-timeout-status'),
providerField: root.querySelector('#gev-voice-provider-field'),
providerSelect: root.querySelector('#gev-voice-provider'),
button: root.querySelector('#gev-voice-button'),
buttonLabel: root.querySelector('.gev-mic-label'),
status: root.querySelector('#gev-voice-status'),
detail: root.querySelector('#gev-voice-detail'),
helpDetail: root.querySelector('.gev-voice-help-detail'),
errorDetail: root.querySelector('#gev-voice-error-detail'),
tierButton: root.querySelector('#gev-voice-tier'),
costValue: root.querySelector('#gev-voice-cost-value'),
costWarning: root.querySelector('#gev-voice-cost-warning'),
card: root.querySelector('#gev-voice-card'),
visible: false,
busy: null,
phase: '',
user: { visible: false, text: '', label: t('YOU') },
pointer: { visible: false, kind: t('THIS'), text: '' },
assistant: { visible: false, text: '' },
steps: [],
result: {
visible: false,
title: '',
chips: [],
lines: [],
referents: [],
notes: [],
},
announce: '',
};
}
/** The control as it first renders, before any owner writes to it. */
function initialState(panel) {
return {
panel,
root: {
status: 'idle',
speaker: 'idle',
provider: null,
providerChoice: null,
pushToTalk: null,
microphone: null,
rowGrace: false,
errorDismissed: false,
},
// The provider field and its select.
provider: {
hidden: true,
options: [],
value: '',
title: () => t('Changing provider reconnects voice if it is on.'),
},
tier: {
hidden: false,
text: () => t('STD'),
pressed: false,
title: () => t('Voice model tier — applies next session'),
},
cost: {
hidden: false,
text: '~$0.00',
level: 'ok',
title: () => t('Estimated session cost'),
},
costWarning: { hidden: true, text: '', level: null, title: null },
button: {
label: () =>
t('Voice control — activate to toggle voice; hold Space to speak'),
pressed: null,
},
mic: { text: () => t('ON/OFF') },
status: { text: () => voiceStatusLabel('idle') },
detail: { text: () => t('VOICE STANDBY'), title: null },
help: {
text: () =>
t('Hold Space to speak · tap Space to activate focused controls'),
},
error: { text: '' },
voicePanel: {
hidden: true,
view: null,
previewHidden: false,
settingsHidden: true,
settingsExpanded: false,
selected: 'preview',
},
previewEmpty: {
hidden: false,
text: () => t('Voice replies appear here.'),
},
preferences: {
previewChecked: true,
previewNote: '',
timeoutChoice: '5',
timeoutMinutes: '5',
timeoutInputHidden: false,
timeoutNote: () => t('These preferences stay in this browser.'),
timeoutStatus: '',
// Bumped by every owner write of the fields, so the view restores a
// refused edit but leaves a draft alone across other renders.
revision: 0,
},
card: initialCard(),
};
}
// The rendered elements the presenter focuses or measures, by name.
const ELEMENT_IDS = Object.freeze({
root: 'gev-voice-control',
button: 'gev-voice-button',
provider: 'gev-voice-provider',
voicePanel: 'gev-voice-panel',
previewPanel: 'gev-voice-preview-panel',
settingsPanel: 'gev-voice-settings-panel',
previewTab: 'gev-voice-preview-tab',
settingsTab: 'gev-voice-settings-tab',
settingsButton: 'gev-voice-settings-button',
card: 'gev-voice-card',
});
let controlCount = 0;
const sameBars = (a, b) =>
a === b ||
(Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every(
(bar, index) =>
bar.level === b[index].level && bar.opacity === b[index].opacity,
));
/**
* The voice control, independent of its connection backend. Session owners
* write its state by part (`update('detail', {text})`) and listen for what
* the person does (`on('button', fn)`); the command dock's VoiceControl view
* renders it from the voice store. A newer control replaces this one there.
* @param {object} [options]
* @param {boolean} [options.panel] Show the Voice Settings panel and preview.
*/
export function createVoiceControl({
panel = false,
store = useVoiceStore(applicationPinia()),
} = {}) {
const id = ++controlCount;
const listeners = new Map();
// The fields given as functions, by part: they are called again when the
// locale changes, so the control's words follow the language.
const wording = new Map();
const word = (part, patch) => {
let fields = wording.get(part);
let worded = patch;
for (const [field, value] of Object.entries(patch)) {
if (typeof value !== 'function') {
fields?.delete(field);
continue;
}
if (!fields) wording.set(part, (fields = new Map()));
fields.set(field, value);
if (worded === patch) worded = { ...patch };
worded[field] = value();
}
return worded;
};
let state = initialState(Boolean(panel));
for (const [part, value] of Object.entries(state))
if (part !== 'card' && value && typeof value === 'object')
state[part] = word(part, value);
// The default card's labels follow the locale until a card replaces it.
let defaultCard = true;
// Each bar's `--audio-level` and `--audio-opacity`, or null at rest.
let bars = null;
let removed = false;
const owns = () => !removed && store.controls === ui;
const publish = () => {
if (owns()) store.publish({ ...state, id });
};
const element = (name) =>
owns()
? (globalThis.document?.getElementById(ELEMENT_IDS[name]) ?? null)
: null;
const shown = (node) =>
Boolean(
node?.checkVisibility?.({
opacityProperty: true,
visibilityProperty: true,
}) ?? node,
);
const ui = {
get panel() {
return state.panel;
},
/** The current state; read-only, replaced on every update. */
get state() {
return state;
},
/**
* Merge `patch` into one part of the control. A field given as a
* function is shown as what it returns, and called again when the
* locale changes; give translated words that way.
*/
update(part, patch) {
if (removed) return;
state = { ...state, [part]: { ...state[part], ...word(part, patch) } };
publish();
},
/** Replace one part of the control. */
replace(part, value) {
if (removed) return;
wording.delete(part);
if (part === 'card') defaultCard = false;
state = { ...state, [part]: value };
publish();
},
/**
* Listen for an interaction the view reports: `button`, `tier`,
* `provider-change`, `settings-button`, `settings-close`, `tab`,
* `tab-keydown`, `panel-keydown`, `pointerenter`, `pointerleave`,
* `preview-change`, `timeout-select`, `timeout-input`, `card-dismiss`;
* and `locale` after a locale change, for an owner's own words.
* @returns {() => void} Removes the listener.
*/
on(name, listener) {
if (!listeners.has(name)) listeners.set(name, new Set());
listeners.get(name).add(listener);
return () => listeners.get(name)?.delete(listener);
},
/** The view reports an interaction; listeners run in the order added. */
emit(name, ...args) {
if (removed) return;
for (const listener of [...(listeners.get(name) ?? [])])
listener(...args);
},
/** The waveform's bars, or null at rest. */
get bars() {
return bars;
},
/**
* Show the waveform's bars (null rests it). Published apart from the
* rest of the control, and only when they change.
* @param {?Array<{level: string, opacity: string}>} next
*/
setBars(next) {
if (removed || sameBars(bars, next)) return;
bars = next;
if (owns()) store.publishBars(bars);
},
/** The error tray's DISMISS. */
dismissError() {
ui.update('root', { errorDismissed: true });
},
/** The rendered element by name, or null before it renders. */
element,
/** Whether the named element is rendered and visible on the page. */
visible: (name) => shown(element(name)),
/**
* Once the view has rendered the latest state, focus the first of the
* named elements that is visible, or with `anyVisibility`, that is
* rendered (focus may be what reveals it).
* @param {string[]} names
* @param {{anyVisibility?: boolean}} [options]
*/
focus(names, { anyVisibility = false } = {}) {
return nextTick(() => {
const target = names
.map(element)
.find((node) => node && (anyVisibility || shown(node)));
target?.focus?.();
return target ?? null;
});
},
/** Remove the control from the dock, unless a newer one replaced it. */
remove() {
if (removed) return;
if (store.controls === ui) {
store.connect(null);
store.publish(null);
store.publishBars(null);
}
removed = true;
offLocale();
listeners.clear();
},
};
const offLocale = onLocaleChange(() => {
if (removed) return;
for (const [part, fields] of wording) {
const patch = {};
for (const [field, value] of fields) patch[field] = value();
state = { ...state, [part]: { ...state[part], ...patch } };
}
if (defaultCard) state = { ...state, card: initialCard() };
publish();
ui.emit('locale');
});
store.connect(ui);
store.publishBars(null);
publish();
return ui;
}
+1 -1
View File
@@ -2527,7 +2527,7 @@ export class GeminiSessionController {
this.input.detachBindings();
this.radio.detachObservers();
this.signal?.removeEventListener('abort', this.lifetimeAbort);
this.ui?.root?.remove();
this.ui?.remove();
this.emit({ type: 'disposed' });
} else if (!preserveStatus) this.setStatus('idle', msg('Voice off'));
}
+31 -22
View File
@@ -9,6 +9,8 @@ import { resolveDeicticArgs } from './deixis.js';
export { readLayerLifecycleSummary } from './layerSummary.js';
import { defaultGeospatial } from '../search/defaults.js';
import { msg } from '../i18n/index.js';
import { useProviderSettingsStore } from '../ui/stores/providerSettings.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import * as Cesium from 'cesium';
import { CITY_POIS } from '../cityPois.js';
import { greatCircleKm } from '../geoDistance.js';
@@ -97,6 +99,7 @@ const PANEL_ALIASES = new Map([
['global context', 'global-context-panel'],
['right context', 'global-context-panel'],
['context right panel', 'global-context-panel'],
['authoring', 'scene-panel'],
['scenes', 'scene-panel'],
['scene', 'scene-panel'],
['post processing', 'pp-toggles'],
@@ -771,10 +774,9 @@ export function createGevActionRunner({
cancelled: true,
};
const doc = globalThis.document;
const panel = doc?.getElementById?.('key-setup');
const button = open
? doc?.getElementById?.('key-setup-chip')
: panel?.querySelector?.('[data-key-setup-close]');
// The setup owner (src/keySetup.js) publishes the dialog and takes
// its open and close requests.
const settings = useProviderSettingsStore(applicationPinia());
const concealed =
open &&
[
@@ -783,11 +785,7 @@ export function createGevActionRunner({
'cockpit-mode',
'scene-playback-mode',
].some((mode) => doc?.body?.classList?.contains(mode));
if (
concealed ||
panel?.dataset?.ready !== 'true' ||
typeof button?.click !== 'function'
) {
if (concealed || settings.view?.ready !== true || !settings.controls) {
const error = concealed
? 'Provider Settings is hidden in the current view. Leave that view before opening it.'
: 'Provider Settings is unavailable in this runtime.';
@@ -801,11 +799,11 @@ export function createGevActionRunner({
say: error,
};
}
button.click();
// Opening unhides immediately; closing removes visible before its fade ends.
const applied = open
? !panel.hidden
: !panel.classList.contains('visible');
if (open) settings.controls.open();
else settings.controls.close();
// Opening unhides at once; closing drops visible before its fade ends.
const dialog = settings.view.dialog;
const applied = open ? !dialog.hidden : !dialog.visible;
const say = applied
? `Provider Settings is ${open ? 'open' : 'closed'}.`
: 'Provider Settings did not change.';
@@ -814,12 +812,22 @@ export function createGevActionRunner({
action: name,
panelId,
applied,
open: applied ? open : !panel.hidden,
open: applied ? open : !dialog.hidden,
say,
...(!applied ? { error: say } : {}),
};
}
setPanelOpen(styleManager, panelId, open);
if (!setPanelOpen(styleManager, panelId, open)) {
const say = 'There is no panel to open here.';
return {
ok: false,
action: 'set_panel_open',
panelId,
open,
say,
error: say,
};
}
return { ok: true, action: 'set_panel_open', panelId, open };
}
@@ -3150,13 +3158,14 @@ function normalizeAircraftClassFilter(value) {
return raw.replace(/[\s-]+/g, '') === TR3B_CLASS ? TR3B_CLASS : raw;
}
/**
* @returns {boolean} Whether a shell took the change: its panel chrome owns
* panel state, so without one there is no panel to open.
*/
function setPanelOpen(styleManager, panelId, open) {
if (styleManager && typeof styleManager.setPanelCollapsed === 'function') {
styleManager.setPanelCollapsed(panelId, !open, { explicit: true });
} else {
const panel = document.getElementById(panelId);
if (panel) panel.classList.toggle('collapsed', !open);
}
if (typeof styleManager?.setPanelCollapsed !== 'function') return false;
styleManager.setPanelCollapsed(panelId, !open, { explicit: true });
return true;
}
function normalizeContextMode(value) {
+23
View File
@@ -3931,3 +3931,26 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
assert.equal(plane.ok, true);
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
});
test('set_panel_open accepts the Authoring name for the scene panel', async () => {
const calls = [];
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000),
},
},
styleManager: {
setPanelCollapsed: (...args) => calls.push(args),
},
dataManager: { layers: new Map(), isEnabled: () => false, getAll: () => [] },
});
for (const panel of ['authoring', 'Authoring', 'scenes']) {
const result = await runner('set_panel_open', { panelId: panel, open: true });
assert.equal(result.panelId, 'scene-panel');
}
assert.deepEqual(calls.at(-1), ['scene-panel', false, { explicit: true }]);
});
+47 -212
View File
@@ -34,6 +34,7 @@ import {
withToolCatalog,
} from './gevRealtime.js';
import { createVoiceCostTracker } from './voiceCost.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
test('push-to-talk recognizes Space by code or key', () => {
assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500);
@@ -114,10 +115,7 @@ function createPushToTalkFixture(t) {
const makeController = () => {
const microphone = { enabled: false, stopped: false, stop() { this.stopped = true; } };
const controller = new GevRealtimeController({
ui: {
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
status: {}, detail: {},
},
ui: createTestVoiceUi(),
runner: async () => ({}),
radioLayer: {
pause: () => radio.push('pause'),
@@ -318,8 +316,8 @@ test('push-to-talk starts once at 500ms and repeat does not reset the threshold'
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.microphone.enabled, false);
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
assert.equal(f.controller.ui.detail.textContent, 'Hold Space to talk');
assert.equal(f.controller.ui.state.root.pushToTalk, null);
assert.equal(f.controller.ui.state.detail.text, 'Hold Space to talk');
});
test('push-to-talk treats a Space release before 500ms as a background tap', (t) => {
@@ -392,7 +390,7 @@ test('push-to-talk cancels pending and active holds on blur or hidden document',
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.microphone.enabled, false);
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
assert.equal(f.controller.ui.state.root.pushToTalk, null);
assert.equal(f.key('keyup', background).defaultPrevented, false);
f.document.visibilityState = 'visible';
}
@@ -579,7 +577,7 @@ test('failed or superseded Radio preflight keeps voice open and cancels audio',
test('replacement input invalidates an in-flight Radio preflight immediately', () => {
const order = [];
const controller = new GevRealtimeController({
ui: {},
ui: createTestVoiceUi(),
runner: async () => ({}),
radioLayer: { stopPlayback: () => order.push('radio-stop') },
});
@@ -604,12 +602,7 @@ for (const [name, control, message] of [
test(name, async () => {
const order = [];
let playbackControl = null;
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async () => ({ ok: true }),
@@ -646,12 +639,7 @@ for (const [name, control, message] of [
test('confirmed manual Radio playback closes active voice without stopping Radio', () => {
const order = [];
let playbackControl = null;
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async () => ({ ok: true }),
@@ -684,12 +672,7 @@ test('manual playback takeover survives stale voice preflight cleanup', async ()
let resolvePreflight;
let activeAttemptId = null;
const stopRequests = [];
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const radioLayer = {
subscribePlaybackControls(listener) {
playbackControl = listener;
@@ -741,7 +724,7 @@ test('manual playback takeover survives stale voice preflight cleanup', async ()
test('internal Radio cleanup controls do not masquerade as newer user input', () => {
let playbackControl = null;
const controller = new GevRealtimeController({
ui: {},
ui: createTestVoiceUi(),
runner: async () => ({}),
radioLayer: {
subscribePlaybackControls(listener) {
@@ -766,7 +749,7 @@ test('manual Radio playback does not close voice when voice is already idle', ()
let playbackControl = null;
let voiceStops = 0;
const controller = new GevRealtimeController({
ui: {},
ui: createTestVoiceUi(),
runner: async () => ({}),
radioLayer: {
subscribePlaybackControls(listener) {
@@ -786,12 +769,7 @@ test('manual Radio playback does not close voice when voice is already idle', ()
test('active-response Realtime errors invalidate pending and in-flight Radio handoffs', async () => {
const order = [];
let playbackControl = null;
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async () => ({ ok: true }),
@@ -839,16 +817,7 @@ test('voice ownership ducks tuner static before pausing broadcaster audio', () =
test('Radio handoff waits for response.done so later multi-intent tools execute before voice closes', async () => {
const order = [];
const confirmationInstructions = [];
const ui = {
root: {
dataset: {},
classList: { remove() {} },
querySelectorAll: () => [],
},
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const runner = async (name) => {
order.push(`tool:${name}`);
if (name === 'control_radio') {
@@ -933,12 +902,7 @@ test('Radio handoff waits for response.done so later multi-intent tools execute
test('Radio playback failure leaves voice connected and speaks a correction', async () => {
const sent = [];
const order = [];
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async () => ({
@@ -991,16 +955,7 @@ test('Radio playback failure leaves voice connected and speaks a correction', as
test('detected speech cancels a prepared Radio handoff before a cancelled response completes', async () => {
const order = [];
const ui = {
root: {
dataset: {},
classList: { remove() {} },
querySelectorAll: () => [],
},
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async () => ({
@@ -1059,12 +1014,7 @@ test('a Radio tool result that resolves after speech interruption cannot re-arm
let resolveRunner;
const runnerResult = new Promise((resolve) => { resolveRunner = resolve; });
let receivedSignal = null;
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async (_name, _args, options) => {
@@ -1119,12 +1069,7 @@ test('sibling tool calls in one response do not abort an in-flight Radio action'
let resolveRadio;
let radioSignal = null;
const radioResult = new Promise((resolve) => { resolveRadio = resolve; });
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async (name, _args, options) => {
@@ -1165,12 +1110,7 @@ test('Radio stop does not abort an unrelated sibling tool from the same response
let releaseStyle;
let styleSignal = null;
const styleResult = new Promise((resolve) => { releaseStyle = resolve; });
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async (name, _args, options) => {
@@ -1244,12 +1184,7 @@ test('real Radio Select cannot enable or play after a same-response Disable comp
return true;
},
};
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
globalThis.window = { __GOOGLE_MAPS_API_KEY__: 'test-key' };
globalThis.fetch = async () => {
announceLookupStarted();
@@ -1375,12 +1310,7 @@ test('generic same-response Radio visibility disable supersedes delayed Select',
camera: { moveEnd: { addEventListener() {} } },
};
const genericRunner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
globalThis.window = { __GOOGLE_MAPS_API_KEY__: 'test-key' };
globalThis.fetch = async () => {
announceLookupStarted();
@@ -1510,12 +1440,7 @@ test('same-response Pause aborts a real manager enable already in flight', async
dataManager.subscribe((change) => {
if (change.type === 'visibility') trace.push(`visibility:${change.enabled}`);
});
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
dataManager,
@@ -1641,12 +1566,7 @@ test('Pause and Stop preserve independent dedicated and generic Radio ON across
styleManager: {},
dataManager,
});
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
dataManager,
@@ -1775,12 +1695,7 @@ test('successful same- or newer-response Stop aborts Select across real manager
if (change.origin === 'voice') persistenceWrites.push(change.enabled);
}
});
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
radioLayer,
@@ -1846,12 +1761,7 @@ test('same-response pause and disable suppress play/select handoffs without canc
const playbackGate = new Promise((resolve) => { releasePlayback = resolve; });
const sent = [];
const radioState = { enabled: true, audioState: 'playing' };
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
radioLayer: { getUIState: () => ({ ...radioState }) },
@@ -1927,12 +1837,7 @@ test('Realtime Radio route exceptions preserve the authoritative lifecycle summa
for (const route of ['dedicated', 'generic']) {
const sent = [];
const lifecycle = { enabled: true, lifecycleState: 'disabling', uncertain: true };
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
dataManager: {
@@ -1979,12 +1884,7 @@ test('different-response Radio stop remains a cancellation authority for an olde
let releaseSelect;
const selectGate = new Promise((resolve) => { releaseSelect = resolve; });
const sent = [];
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async (_name, args) => {
@@ -2057,12 +1957,7 @@ test('failed same-response stop does not suppress a successful playback request'
}]]),
isEnabled: () => true,
};
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async (_name, args) => {
@@ -2165,12 +2060,7 @@ test('failed same-response Stop preserves Select auto-enable held inside real ma
};
dataManager = new DataLayerManager({});
dataManager.register(radioLayer);
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
dataManager,
@@ -2253,12 +2143,7 @@ test('failed same-response Pause and Disable do not suppress valid older playbac
return () => visibilityListeners.delete(listener);
},
};
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
dataManager,
@@ -2316,12 +2201,7 @@ test('failed generic same-response Radio OFF does not suppress valid older playb
let releaseSelect;
const selectGate = new Promise((resolve) => { releaseSelect = resolve; });
const sent = [];
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async (name, args) => {
@@ -2410,12 +2290,7 @@ test('direct user Radio OFF aborts an in-flight voice enable before settled publ
dataManager.subscribe((change) => {
if (change.type === 'visibility') visibility.push(`${change.origin}:${change.enabled}`);
});
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
dataManager,
@@ -2501,12 +2376,7 @@ test('direct user Radio OFF freezes a prepared handoff until disable success or
dataManager = new DataLayerManager({});
dataManager.register(radioLayer);
await dataManager.setEnabled('radio', true);
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
dataManager,
@@ -2580,12 +2450,7 @@ test('dedicated and generic Radio OFF reservations freeze an in-flight playback
},
getUIState: () => ({ enabled: true, audioState: 'stopped' }),
};
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
radioLayer,
@@ -2682,12 +2547,7 @@ test('delayed Stop reports its result before committing or resuming a prepared h
},
getUIState: () => ({ enabled: true, audioState: 'stopped' }),
};
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
radioLayer,
@@ -2765,12 +2625,7 @@ test('stale handoff cleanup cannot erase a resumed successor across repeated fai
return true;
},
};
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
radioLayer,
@@ -2817,12 +2672,7 @@ test('stale handoff cleanup cannot erase a resumed successor across repeated fai
});
test('a later same-response stop clears an already prepared playback result', async () => {
const ui = {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: async (_name, args) => args.action === 'select'
@@ -3116,22 +2966,7 @@ const usdUsage = (usd) => ({
/** A controller wired to inert UI stubs, with the cost surface present. */
function costControllerHarness({ runner } = {}) {
const toolCalls = [];
const ui = {
root: { dataset: {}, classList: { remove() {}, add() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
button: { dataset: {} },
buttonLabel: { textContent: '' },
tierButton: {
textContent: '',
title: '',
attrs: {},
setAttribute(key, value) { this.attrs[key] = value; },
getAttribute(key) { return this.attrs[key] ?? null; },
},
costValue: { textContent: '', title: '', dataset: {} },
};
const ui = createTestVoiceUi();
const controller = new GevRealtimeController({
ui,
runner: runner || (async (name) => { toolCalls.push(name); return { ok: true }; }),
@@ -3199,9 +3034,9 @@ test('F1: the toggle still records the next-session preference while live', () =
controller.status = 'listening';
controller.setVoiceTier('mini');
assert.equal(controller.voiceTier, 'mini');
assert.equal(ui.tierButton.textContent, 'MINI');
assert.equal(ui.state.tier.text, 'MINI');
// ...and says so, rather than implying the live session switched.
assert.match(ui.tierButton.title, /this session stays on/i);
assert.match(ui.state.tier.title, /this session stays on/i);
});
test('OAuth cloud sessions report unknown cost and usage without tripping the API spend guard', () => {
@@ -3211,8 +3046,8 @@ test('OAuth cloud sessions report unknown cost and usage without tripping the AP
controller._cost.sessionCloudVoiceAuth = 'oauth';
controller.syncCostUi();
const before = controller.costTracker.state();
assert.equal(ui.costValue.hidden, false);
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
assert.equal(ui.state.cost.hidden, false);
assert.equal(ui.state.cost.text, 'COST UNKNOWN');
assert.deepEqual(controller.recordUsage(usdUsage(100)), {
responses: 1,
input: 0,
@@ -3223,11 +3058,11 @@ test('OAuth cloud sessions report unknown cost and usage without tripping the AP
const after = controller.costTracker.state();
assert.equal(after.totalUsd, before.totalUsd);
assert.equal(controller.costCapStopped, false);
assert.match(ui.costValue.title, /1 response\(s\).*1562500 output tokens/);
assert.match(ui.state.cost.title, /1 response\(s\).*1562500 output tokens/);
controller.dc = {};
controller.responseActive = true;
controller.stop();
assert.match(ui.costValue.title, /Usage is incomplete/);
assert.match(ui.state.cost.title, /Usage is incomplete/);
});
test('OAuth cloud sessions count caption transcriptions without pricing them into the API cap', () => {
@@ -3248,8 +3083,8 @@ test('OAuth cloud sessions count caption transcriptions without pricing them int
assert.equal(after.totalUsd, before.totalUsd);
assert.equal(after.capReached, false);
assert.equal(controller.costCapStopped, false);
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
assert.match(ui.costValue.title, /1 caption transcription\(s\)/);
assert.equal(ui.state.cost.text, 'COST UNKNOWN');
assert.match(ui.state.cost.title, /1 caption transcription\(s\)/);
});
test('F1: when idle, toggling does re-price the preview meter', () => {
@@ -3453,7 +3288,7 @@ test('F4: two clicks during a live session return to the original preference', (
controller.toggleVoiceTier();
assert.equal(controller.voiceTier, 'standard', 'second click switches back');
assert.equal(ui.tierButton.textContent, 'STD');
assert.equal(ui.state.tier.text, 'STD');
});
test('F4: the toggle alternates across many clicks mid-session', () => {
@@ -3490,7 +3325,7 @@ test('F3: an unrecognised session model bills at the most expensive known rates'
function textCommandController() {
const sent = [];
const controller = new GevRealtimeController({
ui: {},
ui: createTestVoiceUi(),
runner: async () => ({}),
});
controller.dc = { readyState: 'open' };
+2 -1
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import test from 'node:test';
import { GevRealtimeController } from './realtimeController.js';
import {
@@ -16,7 +17,7 @@ function harness(result) {
const sent = [];
const calls = [];
const controller = new GevRealtimeController({
ui: {},
ui: createTestVoiceUi(),
runner: async (name, args, context) => {
calls.push(name);
return typeof result === 'function'
+2 -8
View File
@@ -5,6 +5,7 @@
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import {
initialVoiceCardState,
reduceVoiceCard,
@@ -206,14 +207,7 @@ test('the voice controls reset the runner on stop and dispose it on removal', as
globalThis.window = {};
try {
const h = harness();
const button = new EventTarget();
button.setAttribute = () => {};
const ui = {
button,
root: { dataset: {}, remove() {} },
status: {},
detail: {},
};
const ui = createTestVoiceUi();
const lifetime = new AbortController();
let emitState;
const controls = createVoiceCommands({
+26 -16
View File
@@ -32,7 +32,7 @@ export function createVoicePanelHost({
return observer;
}
function mount({ ui, card, session, observer, nativeProvider }) {
if (disposed || !ui.voicePanel || !card?.setEnabled || !card?.setPresented)
if (disposed || !ui.panel || !card?.setEnabled || !card?.setPresented)
return null;
current?.destroy();
let dead = false;
@@ -67,11 +67,12 @@ export function createVoicePanelHost({
}
};
const renderActivity = () => {
if (ui.voiceTimeoutStatus)
ui.voiceTimeoutStatus.textContent =
ui.update('preferences', {
timeoutStatus: () =>
nativeProvider === 'openai' || nativeProvider === 'gemini'
? ''
: t('Automatic stop is unavailable for this voice connection.');
: t('Automatic stop is unavailable for this voice connection.'),
});
};
const changed = () => {
if (!owns()) return;
@@ -233,9 +234,14 @@ export function createVoicePanelHost({
card,
getPreviewEnabled: () => preferences.snapshot().previewEnabled,
getCockpitActive: () => readCockpitState()?.active === true,
// Getters, so the panel words them in the current locale.
copy: {
previewEmpty: t('Voice replies appear here.'),
previewDisabled: t('Preview is off.'),
get previewEmpty() {
return t('Voice replies appear here.');
},
get previewDisabled() {
return t('Preview is off.');
},
},
onPresentationIntent: remember,
});
@@ -244,8 +250,12 @@ export function createVoicePanelHost({
preferences,
copy: {
previewNote: '',
timeoutNote: t('These preferences stay in this browser.'),
notSaved: t('Your choice applies now, but could not be saved.'),
get timeoutNote() {
return t('These preferences stay in this browser.');
},
get notSaved() {
return t('Your choice applies now, but could not be saved.');
},
},
onChange: (state) => {
if (!owns()) return;
@@ -262,14 +272,14 @@ export function createVoicePanelHost({
},
{ signal: presentationLifetime.signal },
);
ui.root?.querySelector('#gev-voice-card-dismiss')?.addEventListener(
'click',
() => {
remember();
if (panel.snapshot().panelOpen) ui.previewTab?.focus();
},
{ signal: presentationLifetime.signal },
);
const offDismiss = ui.on('card-dismiss', () => {
remember();
if (panel.snapshot().panelOpen)
void ui.focus(['previewTab'], { anyVisibility: true });
});
presentationLifetime.signal.addEventListener('abort', offDismiss, {
once: true,
});
card.setEnabled(preferences.snapshot().previewEnabled);
panel.sync();
idle = createVoiceInactivity({
+175 -103
View File
@@ -1,12 +1,54 @@
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createVoiceControl } from '../control.js';
import { useVoiceStore } from '../../ui/stores/voice.js';
import { createGevActionRunner } from '../gevActions.js';
import { createVoiceCommands as bind } from '../sessionCommands.js';
import { createVoiceCommands as compose } from '../commands.js';
import { createProviderCommands } from '../providerCommands.js';
import { createVoicePanelHost } from './host.js';
import { installPanelDom, controlledClock } from './testDom.mjs';
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
import { mountVoiceView } from '../../testSupport/voiceView.mjs';
const $ = (selector) => globalThis.document.querySelector(selector);
const exists = (selector) => $(selector) !== null;
const active = () => globalThis.document.activeElement?.id ?? null;
const key = (node, name) =>
node.dispatchEvent(
new KeyboardEvent('keydown', {
key: name,
bubbles: true,
cancelable: true,
}),
);
function controlledClock() {
let now = 0,
next = 0;
const tasks = new Map();
return {
now: () => now,
setTimer(fn, ms) {
const id = ++next;
tasks.set(id, { fn, at: now + ms });
return id;
},
clearTimer: (id) => tasks.delete(id),
advance(ms) {
now += ms;
for (const [id, task] of [...tasks])
if (task.at <= now && tasks.delete(id)) task.fn();
},
};
}
// The custom inactivity minutes, then the select's Custom choice.
function chooseCustomTimeout(ui, minutes) {
ui.update('preferences', { timeoutMinutes: minutes });
ui.emit('timeout-select', 'custom');
}
function factory(instances, { stopError = null, native = false } = {}) {
return (hooks) => {
@@ -78,8 +120,10 @@ function factory(instances, { stopError = null, native = false } = {}) {
return adapter;
};
}
// With `view`, the real VoiceControl renders the control from its store.
function mounted(t, options = {}) {
const doc = installPanelDom(t);
const view = options.view ? mountVoiceView(t) : null;
const pinia = view?.pinia ?? createPinia();
const clock = controlledClock();
const host = createVoicePanelHost({
storage: options.storage ?? null,
@@ -91,6 +135,7 @@ function mounted(t, options = {}) {
runner: options.runner ?? (async () => ({ ok: true })),
dataManager: { layers: new Map() },
createSession: factory(instances, options),
createControl: (control) => createTestVoiceUi({ ...control, pinia }),
panelHost: host,
nativePanelProvider: options.native
? (options.nativeProvider ?? 'gemini')
@@ -102,42 +147,66 @@ function mounted(t, options = {}) {
} catch {}
host.destroy();
});
return { doc, clock, host, instances, commands, ui: commands.ui };
return {
clock,
host,
instances,
commands,
ui: commands.ui,
settle: view?.settle,
};
}
test('actual control default remains opt-out; panel markup has unique controlled panes and an outside alert', (t) => {
const doc = installPanelDom(t);
const baseline = createVoiceControl();
assert.equal(baseline.voicePanel, null);
const ui = createVoiceControl({ reset: true, panel: true });
assert.equal(doc.getElementById('gev-voice-panel'), ui.voicePanel);
test('actual control default remains opt-out; panel markup has unique controlled panes and an outside alert', async (t) => {
const view = mountVoiceView(t);
const store = useVoiceStore(view.pinia);
const baseline = createVoiceControl({ store });
t.after(() => baseline.remove());
assert.equal(baseline.panel, false);
await view.settle();
assert.equal(exists('#gev-voice-control'), true);
assert.equal(exists('#gev-voice-panel'), false);
const ui = createVoiceControl({ panel: true, store });
t.after(() => ui.remove());
await view.settle();
const panel = $('#gev-voice-panel');
assert.equal(panel !== null, true);
assert.equal(
ui.voiceSettingsButton.getAttribute('aria-controls'),
ui.voicePanel.id,
$('#gev-voice-settings-button').getAttribute('aria-controls'),
panel.id,
);
assert.equal(ui.previewTab.getAttribute('aria-controls'), ui.previewPanel.id);
for (const [tab, pane] of [
['#gev-voice-preview-tab', 'gev-voice-preview-panel'],
['#gev-voice-settings-tab', 'gev-voice-settings-panel'],
]) {
assert.equal($(tab).getAttribute('aria-controls'), pane);
assert.equal(exists(`#${pane}`), true);
}
assert.equal(
ui.settingsTab.getAttribute('aria-controls'),
ui.voiceSettingsPanel.id,
$('#gev-voice-preview-panel').contains($('#gev-voice-card')),
true,
);
assert.equal(ui.previewPanel.contains(ui.card), true);
const tray = $('#gev-voice-control .gev-voice-error-tray');
assert.equal(tray !== null, true);
assert.equal(panel.contains(tray), false);
assert.equal(
ui.voicePanel.contains(ui.root.querySelector('.gev-voice-error-tray')),
false,
$('#gev-voice-control').querySelectorAll('#gev-voice-card').length,
1,
);
assert.equal(ui.root.querySelectorAll('#gev-voice-card').length, 1);
});
test('actual binder/card has launcher toggle, idempotent tabs, focus and disabled Preview', (t) => {
const { ui, commands, doc } = mounted(t);
ui.voiceSettingsButton.fire('click');
test('actual binder/card has launcher toggle, idempotent tabs, focus and disabled Preview', async (t) => {
const { ui, commands, settle } = mounted(t, { view: true });
await settle();
$('#gev-voice-settings-button').click();
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
ui.voiceSettingsButton.fire('click');
$('#gev-voice-settings-button').click();
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
ui.settingsTab.fire('click');
ui.settingsTab.fire('keydown', { key: 'End' });
await settle();
$('#gev-voice-settings-tab').click();
key($('#gev-voice-settings-tab'), 'End');
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
ui.settingsTab.fire('click');
$('#gev-voice-settings-tab').click();
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
commands.voicePanel.select('preview');
commands.voiceCard.handle({
@@ -146,17 +215,19 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
final: true,
text: 'A visible reply',
});
const dismiss = ui.root.querySelector('#gev-voice-card-dismiss');
await settle();
const dismiss = $('#gev-voice-card-dismiss');
dismiss.focus();
dismiss.fire('click');
assert.equal(ui.card.hidden, true);
dismiss.click();
await settle();
assert.equal(ui.state.card.visible, false);
assert.equal($('#gev-voice-card').hidden, true);
assert.equal(commands.voicePanel.snapshot().panelOpen, true);
assert.equal(doc.activeElement?.id, ui.previewTab.id);
ui.voicePanel.fire('keydown', { key: 'Escape', target: doc.activeElement });
assert.equal(active(), 'gev-voice-preview-tab');
key(globalThis.document.activeElement, 'Escape');
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
commands.voicePanel.select('settings');
ui.voicePreviewInput.checked = false;
ui.voicePreviewInput.fire('change');
ui.emit('preview-change', false);
commands.voicePanel.select('preview');
commands.voiceCard.handle({
type: 'transcript',
@@ -164,11 +235,13 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
final: true,
text: 'A hidden reply',
});
assert.equal(ui.card.hidden, true);
assert.equal(ui.previewEmpty.textContent, 'Preview is off.');
ui.previewTab.focus();
ui.voicePanel.fire('keydown', { key: 'Escape' });
assert.equal(doc.activeElement, ui.voiceSettingsButton);
assert.equal(ui.state.card.visible, false);
assert.equal(ui.state.previewEmpty.text, 'Preview is off.');
await settle();
$('#gev-voice-preview-tab').focus();
key(globalThis.document.activeElement, 'Escape');
await settle();
assert.equal(active(), 'gev-voice-settings-button');
commands.voiceCard.handle({
type: 'transcript',
role: 'assistant',
@@ -179,7 +252,7 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
});
test('actual facade remount keeps failed-save live preferences/view and manual close, with no restore writes or focus theft', async (t) => {
const doc = installPanelDom(t);
const view = mountVoiceView(t);
const writes = [];
const storage = {
getItem: () => null,
@@ -197,7 +270,13 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
supportedProviders: ['openai', 'gemini'],
},
{
bind: (options) => bind({ ...options, panelHost: host }),
bind: (options) =>
bind({
...options,
panelHost: host,
createControl: (control) =>
createTestVoiceUi({ ...control, pinia: view.pinia }),
}),
factories: { openai: factory(instances), gemini: factory(instances) },
storage,
},
@@ -208,18 +287,17 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
});
const old = commands.ui;
commands.voicePanel.select('settings');
old.voicePreviewInput.checked = false;
old.voicePreviewInput.fire('change');
old.voiceTimeoutInput.value = '7';
old.voiceTimeoutSelect.value = 'custom';
old.voiceTimeoutSelect.fire('change');
old.emit('preview-change', false);
chooseCustomTimeout(old, '7');
const preferenceWrites = () =>
writes.filter(([key]) => key.startsWith('godsEyeView.voice.')).length;
assert.equal(preferenceWrites(), 2);
old.providerSelect.value = 'gemini';
old.providerSelect.fire('change');
assert.notEqual(commands.ui, old);
assert.equal(old.root.isConnected, false);
await view.settle();
const oldRoot = $('#gev-voice-control');
old.emit('provider-change', 'gemini');
assert.equal(commands.ui !== old, true);
await view.settle();
assert.equal(oldRoot.isConnected, false);
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
assert.deepEqual(commands.getVoicePreferences(), {
previewEnabled: false,
@@ -227,17 +305,16 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
previewPersisted: false,
timeoutPersisted: false,
});
assert.equal(commands.ui.voicePreviewInput.checked, false);
assert.match(commands.ui.voiceTimeoutNote.textContent, /could not be saved/);
assert.equal(commands.ui.state.preferences.previewChecked, false);
assert.equal($('#gev-voice-preview-enabled').checked, false);
assert.match(commands.ui.state.preferences.timeoutNote, /could not be saved/);
assert.equal(preferenceWrites(), 2);
assert.equal(doc.activeElement, commands.ui.providerSelect);
commands.ui.voicePreviewInput.checked = true;
commands.ui.voicePreviewInput.fire('change');
assert.equal(active(), 'gev-voice-provider');
commands.ui.emit('preview-change', true);
commands.voicePanel.close();
await commands.start();
const retired = instances[1];
commands.ui.providerSelect.value = 'openai';
commands.ui.providerSelect.fire('change');
commands.ui.emit('provider-change', 'openai');
await commands.start();
assert.equal(commands.session.isActive(), true);
retired.hooks.emit({
@@ -268,14 +345,14 @@ test('explicit successful mic Stop closes; failed Stop idle and startup error do
const good = mounted(t);
await good.commands.session.start();
good.commands.voicePanel.select('settings');
good.ui.button.fire('click');
good.ui.emit('button');
assert.equal(good.commands.voicePanel.snapshot().panelOpen, false);
const error = Error('stop failed');
const bad = mounted(t, { stopError: error });
await bad.commands.session.start();
bad.commands.voicePanel.select('settings');
assert.throws(
() => bad.ui.button.fire('click'),
() => bad.ui.emit('button'),
(value) => value === error,
);
assert.equal(bad.commands.session.state, 'idle');
@@ -296,7 +373,7 @@ test('reentrant manual choice/new start during Stop cannot close the successor c
if (event.type === 'state' && event.state === 'idle')
f.commands.voicePanel.select('preview');
});
f.ui.button.fire('click');
f.ui.emit('button');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
unsubscribe();
@@ -306,9 +383,7 @@ test('integrated inactivity rechecks held PTT at expiry, then normal stop closes
const f = mounted(t, { native: true });
await f.commands.session.start();
f.commands.voicePanel.select('settings');
f.ui.voiceTimeoutInput.value = '1';
f.ui.voiceTimeoutSelect.value = 'custom';
f.ui.voiceTimeoutSelect.fire('change');
chooseCustomTimeout(f.ui, '1');
f.instances[0].input.pushToTalkKeyHeld = true;
f.clock.advance(60000);
assert.equal(f.instances[0].stops, 0);
@@ -321,8 +396,7 @@ test('integrated inactivity rechecks held PTT at expiry, then normal stop closes
test('None cancels integrated app timer and unknown custom hosts never guess expiry', async (t) => {
const native = mounted(t, { native: true });
await native.commands.session.start();
native.ui.voiceTimeoutSelect.value = '0';
native.ui.voiceTimeoutSelect.fire('change');
native.ui.emit('timeout-select', '0');
native.clock.advance(600000);
assert.equal(native.instances[0].stops, 0);
assert.equal(native.commands.getVoicePreferences().minutes, 0);
@@ -330,17 +404,15 @@ test('None cancels integrated app timer and unknown custom hosts never guess exp
await unknown.commands.session.start();
unknown.clock.advance(600000);
assert.equal(unknown.instances[0].stops, 0);
assert.match(unknown.ui.voiceTimeoutStatus.textContent, /unavailable/);
assert.match(unknown.ui.state.preferences.timeoutStatus, /unavailable/);
});
test('transient native OpenAI response activity does not announce connection unavailability', async (t) => {
const f = mounted(t, { native: true, nativeProvider: 'openai' });
await f.commands.session.start();
f.ui.voiceTimeoutInput.value = '1';
f.ui.voiceTimeoutSelect.value = 'custom';
f.ui.voiceTimeoutSelect.fire('change');
chooseCustomTimeout(f.ui, '1');
f.instances[0].controller._turns.observeSpeech({ type: 'response.created' });
assert.equal(f.ui.voiceTimeoutStatus.textContent, '');
assert.equal(f.ui.state.preferences.timeoutStatus, '');
f.clock.advance(60000);
assert.equal(f.instances[0].stops, 0, 'unknown output still prevents expiry');
f.instances[0].controller._turns.observeSpeech({
@@ -351,7 +423,6 @@ test('transient native OpenAI response activity does not announce connection una
});
test('native factory with custom controller remains uninstrumented despite panel opt-in', (t) => {
installPanelDom(t);
let inspected = 0;
const commands = compose({
panel: true,
@@ -404,7 +475,6 @@ async function directActions(
t,
{ storage = null, delayExit = false, exposePanel = true } = {},
) {
installPanelDom(t);
let f, releaseExit;
const state = { active: true, exits: 0, visual: [] };
const styleManager = {
@@ -479,8 +549,7 @@ test('named voice settings exits Cockpit directly; Preview stays disabled and vi
});
assert.equal(f.state.exits, 1);
assert.equal(result.settingsOpen, true);
f.ui.voicePreviewInput.checked = false;
f.ui.voicePreviewInput.fire('change');
f.ui.emit('preview-change', false);
const preview = await f.run('set_panel_open', {
panelId: 'voice control',
open: true,
@@ -575,7 +644,7 @@ test('voice timeout change preserves a pending voice reveal', async (t) => {
});
test('active provider output reveals Preview after done; manual choice wins until genuine input, replays do not reset idle', async (t) => {
const f = mounted(t, { native: true, nativeProvider: 'openai' });
const f = mounted(t, { native: true, nativeProvider: 'openai', view: true });
await f.commands.session.start();
const emit = f.instances[0].hooks.emit;
const reply = (text, responseId = 'r1', extra = {}) =>
@@ -638,6 +707,7 @@ test('Preview off and actual Cockpit state suppress automatic presentation witho
let cockpit = false;
const f = mounted(t, {
native: true,
view: true,
readCockpitState: () => ({ active: cockpit }),
});
await f.commands.session.start();
@@ -645,32 +715,37 @@ test('Preview off and actual Cockpit state suppress automatic presentation witho
const reply = (text) =>
emit({ type: 'transcript', role: 'assistant', text, final: true });
reply('Visible');
f.ui.previewTab.focus();
await f.settle();
$('#gev-voice-preview-tab').focus();
cockpit = true;
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
assert.equal(f.ui.state.voicePanel.hidden, true);
await f.settle();
assert.equal(active(), 'gev-voice-settings-button');
f.commands.voicePanel.select('settings', { focus: true });
assert.equal(f.doc.activeElement, f.ui.settingsTab);
await f.settle();
assert.equal(active(), 'gev-voice-settings-tab');
f.commands.voicePanel.select('preview', { focus: true });
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
const outside = f.doc.createElement('button');
f.doc.body.appendChild(outside);
await f.settle();
assert.equal(active(), 'gev-voice-settings-button');
const outside = document.createElement('button');
outside.id = 'unrelated';
document.body.appendChild(outside);
outside.focus();
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.doc.activeElement, outside);
await f.settle();
assert.equal(active(), 'unrelated');
assert.equal(f.commands.voicePanel.snapshot().previewVisible, false);
reply('Suppressed in Cockpit');
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.ui.state.voicePanel.hidden, true);
f.commands.voicePanel.close();
cockpit = false;
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.ui.voicePanel.hidden, true);
f.ui.voicePreviewInput.checked = false;
f.ui.voicePreviewInput.fire('change');
assert.equal(f.ui.state.voicePanel.hidden, true);
f.ui.emit('preview-change', false);
emit({ type: 'interruption', reason: 'user-speech' });
reply('Preference stays off');
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.ui.state.voicePanel.hidden, true);
assert.equal(f.commands.getVoicePreferences().previewEnabled, false);
});
@@ -681,19 +756,12 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o
f.run('set_panel_open', { panelId: 'provider-settings', open }, options);
assert.equal((await request(true)).unavailable, true);
const doc = globalThis.document;
const chip = doc.createElement('button');
chip.id = 'key-setup-chip';
// The setup owner needs only its dialog element; the view is not mounted,
// so the published dialog state stands in for it.
const panel = doc.createElement('aside');
panel.id = 'key-setup';
panel.hidden = true;
const close = doc.createElement('button');
panel.appendChild(close);
doc.body.append(chip, panel);
const query = panel.querySelector.bind(panel);
panel.querySelector = (selector) =>
selector === '[data-key-setup-close]' ? close : query(selector);
chip.click = () => chip.fire('click');
close.click = () => close.fire('click');
doc.body.append(panel);
const dialog = () => settings.view?.dialog;
let frames = 0;
const previousFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => {
@@ -705,6 +773,10 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o
else globalThis.requestAnimationFrame = previousFrame;
});
const { initKeySetup } = await import('../../keySetup.js');
const { useProviderSettingsStore } =
await import('../../ui/stores/providerSettings.js');
const { applicationPinia } = await import('../../ui/vueRuntime.js');
const settings = useProviderSettingsStore(applicationPinia());
let finishStatus;
const pending = initKeySetup({
documentRef: doc,
@@ -727,7 +799,7 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o
doc.body.classList.add('cockpit-mode');
assert.equal((await request(true)).unavailable, true);
assert.equal(
panel.hidden,
dialog().hidden,
true,
'a concealed view cannot schedule a later surprise reveal',
);
@@ -737,16 +809,16 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o
await assert.rejects(() => request(true, { signal: abort.signal }), {
name: 'AbortError',
});
assert.equal(panel.hidden, true);
assert.equal(dialog().hidden, true);
assert.equal((await request(true)).ok, true);
assert.equal(panel.hidden, false);
assert.equal(panel.classList.contains('visible'), true);
assert.equal(dialog().hidden, false);
assert.equal(dialog().visible, true);
assert.equal((await request(true)).ok, true);
assert.equal(frames, 1, 'repeated open reuses the dialog');
assert.equal((await request(false)).ok, true);
assert.equal(panel.classList.contains('visible'), false);
assert.equal(dialog().visible, false);
t.mock.timers.tick(400);
assert.equal(panel.hidden, true);
assert.equal(dialog().hidden, true);
assert.equal((await request(false)).ok, true);
assert.equal(
f.state.visual.length,
+65 -89
View File
@@ -9,7 +9,7 @@ export function createManualVoicePanel({
copy,
onPresentationIntent,
}) {
if (!ui?.voicePanel || typeof getPreviewEnabled !== 'function')
if (!ui?.panel || typeof getPreviewEnabled !== 'function')
throw new TypeError(
'A voice panel and Preview preference reader are required.',
);
@@ -18,67 +18,53 @@ export function createManualVoicePanel({
typeof copy?.previewDisabled !== 'string'
)
throw new TypeError('Localized Preview messages are required.');
const lifetime = new AbortController();
const panel = ui.voicePanel;
const tabs = [ui.previewTab, ui.settingsTab];
const doc = panel.ownerDocument;
const offs = [];
const tabs = ['previewTab', 'settingsTab'];
const panes = { preview: 'previewPanel', settings: 'settingsPanel' };
let disposed = false;
let open = false;
let view = 'preview';
let graceTimer;
const enabled = () => getPreviewEnabled() === true;
const suppressed = () => view === 'preview' && getCockpitActive() === true;
const visible = (element) =>
Boolean(
element &&
!element.hidden &&
element.isConnected !== false &&
element.checkVisibility?.({
opacityProperty: true,
visibilityProperty: true,
}) !== false,
);
const holdsFocus = (name) =>
Boolean(ui.element(name)?.contains?.(globalThis.document?.activeElement));
const snapshot = () => ({
view,
panelOpen: !disposed && open,
settingsOpen: !disposed && open && view === 'settings',
previewEnabled: enabled(),
// The card as presented, on a control the page shows.
previewVisible:
!disposed &&
open &&
view === 'preview' &&
!suppressed() &&
enabled() &&
visible(ui.card),
ui.state.card.visible &&
// The error tray takes the card's place.
ui.state.root.status !== 'error' &&
ui.visible('root'),
});
const sync = () => {
if (disposed) return;
const hidePanel = !open || suppressed();
const restoreFocus = hidePanel && panel.contains?.(doc?.activeElement);
panel.hidden = hidePanel;
panel.dataset.view = view;
if (ui.previewPanel)
ui.previewPanel.hidden = !open || view !== 'preview' || suppressed();
if (ui.voiceSettingsPanel)
ui.voiceSettingsPanel.hidden = !open || view !== 'settings';
ui.voiceSettingsButton?.setAttribute(
'aria-expanded',
String(open && view === 'settings'),
);
tabs.forEach((tab, index) => {
if (!tab) return;
const selected = view === (index === 0 ? 'preview' : 'settings');
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
const restoreFocus = hidePanel && holdsFocus('voicePanel');
ui.update('voicePanel', {
hidden: hidePanel,
view,
previewHidden: !open || view !== 'preview' || suppressed(),
settingsHidden: !open || view !== 'settings',
settingsExpanded: open && view === 'settings',
selected: view,
});
card?.setPresented?.(open && view === 'preview' && !suppressed());
if (ui.previewEmpty) {
ui.previewEmpty.hidden = Boolean(ui.card && !ui.card.hidden);
ui.previewEmpty.textContent = enabled()
? copy.previewEmpty
: copy.previewDisabled;
}
if (restoreFocus)
[ui.voiceSettingsButton, ui.button].find(visible)?.focus?.();
const previewOn = enabled();
ui.update('previewEmpty', {
hidden: ui.state.card.visible,
text: () => (previewOn ? copy.previewEmpty : copy.previewDisabled),
});
if (restoreFocus) void ui.focus(['settingsButton', 'button']);
};
const refuse = (reason) => ({
ok: false,
@@ -90,16 +76,14 @@ export function createManualVoicePanel({
if (disposed) return refuse('disposed');
if (!['preview', 'settings'].includes(next)) return refuse('unknown-view');
const changed = !open || view !== next;
const outgoing =
view === 'preview' ? ui.previewPanel : ui.voiceSettingsPanel;
const transferFocus = changed && outgoing?.contains?.(doc?.activeElement);
const transferFocus = changed && holdsFocus(panes[view]);
open = true;
view = next;
onPresentationIntent?.({ automatic });
sync();
const tab = tabs[next === 'preview' ? 0 : 1];
if ((focus || transferFocus) && visible(panel) && visible(tab))
tab.focus?.();
// The tab takes focus once it shows, inside a shown panel.
if (focus || transferFocus)
void ui.focus([tabs[next === 'preview' ? 0 : 1]]);
return { ok: true, changed, ...snapshot() };
};
const close = (onlyView) => {
@@ -114,58 +98,49 @@ export function createManualVoicePanel({
sync();
return { ok: true, changed, ...snapshot() };
};
const listen = (element, type, handler) =>
element?.addEventListener(type, handler, { signal: lifetime.signal });
const toggleSettings = () =>
open && view === 'settings'
? close('settings')
: select('settings', { focus: true });
try {
listen(ui.voiceSettingsButton, 'click', toggleSettings);
tabs.forEach((tab, index) => {
listen(tab, 'click', () =>
select(index === 0 ? 'preview' : 'settings', { focus: true }),
);
listen(tab, 'keydown', (event) => {
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? 1
: ['ArrowLeft', 'ArrowRight'].includes(event.key)
? 1 - index
: null;
if (next === null) return;
event.preventDefault();
select(next === 0 ? 'preview' : 'settings', { focus: true });
});
});
listen(ui.voiceSettingsClose, 'click', () => close());
listen(panel, 'keydown', (event) => {
// Keep the raised row reachable across pinned trays without intercepting
// their controls. The same lifetime owns the listeners and pending timer.
const endGrace = () => {
clearTimeout(graceTimer);
if (ui.state.root.rowGrace) ui.update('root', { rowGrace: false });
};
offs.push(
ui.on('settings-button', toggleSettings),
ui.on('tab', (index) =>
select(index === 0 ? 'preview' : 'settings', { focus: true }),
),
ui.on('tab-keydown', (index, event) => {
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? 1
: ['ArrowLeft', 'ArrowRight'].includes(event.key)
? 1 - index
: null;
if (next === null) return;
event.preventDefault();
select(next === 0 ? 'preview' : 'settings', { focus: true });
}),
ui.on('settings-close', () => close()),
ui.on('panel-keydown', (event) => {
if (event.key !== 'Escape' || !open) return;
event.preventDefault();
close();
});
// Keep the raised row reachable across pinned trays without intercepting
// their controls. The same lifetime owns the listeners and pending timer.
let graceTimer;
const endGrace = () => {
clearTimeout(graceTimer);
if (ui.root) delete ui.root.dataset.rowGrace;
};
listen(ui.root, 'pointerleave', (event) => {
}),
ui.on('pointerleave', (event) => {
if (event.pointerType === 'touch') return;
endGrace();
ui.root.dataset.rowGrace = 'true';
ui.update('root', { rowGrace: true });
graceTimer = setTimeout(endGrace, ROW_GRACE_MS);
});
listen(ui.root, 'pointerenter', endGrace);
lifetime.signal.addEventListener('abort', endGrace, { once: true });
sync();
} catch (error) {
lifetime.abort();
throw error;
}
}),
ui.on('pointerenter', endGrace),
);
sync();
return {
select,
close,
@@ -176,7 +151,8 @@ export function createManualVoicePanel({
open = false;
sync();
disposed = true;
lifetime.abort();
for (const off of offs) off();
endGrace();
},
};
}
+97 -86
View File
@@ -1,54 +1,25 @@
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createManualVoicePanel } from './manualPanel.js';
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
import { mountVoiceView } from '../../testSupport/voiceView.mjs';
function fixture(t) {
const doc = { activeElement: null };
const node = () =>
Object.assign(new EventTarget(), {
hidden: false,
isConnected: true,
dataset: {},
attributes: {},
ownerDocument: doc,
setAttribute(name, value) {
this.attributes[name] = value;
},
focus() {
doc.activeElement = this;
},
contains(other) {
return other === this;
},
checkVisibility() {
return !this.hidden;
},
});
const ui = Object.fromEntries(
[
'root',
'voicePanel',
'previewPanel',
'voiceSettingsPanel',
'previewTab',
'settingsTab',
'voiceSettingsButton',
'voiceSettingsClose',
'previewEmpty',
'card',
'button',
].map((key) => [key, node()]),
);
ui.voicePanel.contains = (element) =>
Object.values(ui).includes(element) &&
![ui.root, ui.voiceSettingsButton, ui.button].includes(element);
const { pinia, settle } = mountVoiceView(t);
const ui = createTestVoiceUi({ panel: true, pinia });
t.after(() => ui.remove());
let enabled = true,
content = false,
intents = 0;
// The card shows when presented, with Preview on and something to show.
const card = {
setPresented(value) {
this.presented = value;
ui.card.hidden = !(value && enabled && content);
ui.replace('card', {
...ui.state.card,
visible: Boolean(value && enabled && content),
});
},
};
const copy = {
@@ -63,12 +34,15 @@ function fixture(t) {
onPresentationIntent: () => intents++,
});
t.after(() => panel.destroy());
const $ = (id) => document.getElementById(id);
return {
ui,
panel,
doc,
card,
copy,
settle,
$,
active: () => document.activeElement?.id ?? null,
intents: () => intents,
enabled(value) {
enabled = value;
@@ -80,119 +54,156 @@ function fixture(t) {
},
};
}
const click = (element) => element.dispatchEvent(new Event('click'));
const click = (element) => element.click();
function key(element, value) {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value });
const event = new KeyboardEvent('keydown', {
key: value,
bubbles: true,
cancelable: true,
});
element.dispatchEvent(event);
return event;
}
test('Settings launcher toggles while tab and programmatic selection remain idempotent', (t) => {
test('Settings launcher toggles while tab and programmatic selection remain idempotent', async (t) => {
const f = fixture(t);
await f.settle();
assert.equal(f.panel.snapshot().panelOpen, false);
click(f.ui.voiceSettingsButton);
click(f.$('gev-voice-settings-button'));
await f.settle();
assert.equal(f.panel.snapshot().settingsOpen, true);
assert.equal(f.doc.activeElement, f.ui.settingsTab);
assert.equal(f.ui.voiceSettingsButton.attributes['aria-expanded'], 'true');
click(f.ui.voiceSettingsButton);
assert.equal(f.active(), 'gev-voice-settings-tab');
assert.equal(
f.$('gev-voice-settings-button').getAttribute('aria-expanded'),
'true',
);
click(f.$('gev-voice-settings-button'));
await f.settle();
assert.equal(f.panel.snapshot().panelOpen, false);
click(f.ui.settingsTab);
click(f.$('gev-voice-settings-tab'));
assert.equal(f.panel.snapshot().settingsOpen, true);
assert.equal(f.panel.select('settings').changed, false);
assert.equal(f.panel.snapshot().settingsOpen, true);
click(f.ui.settingsTab);
click(f.$('gev-voice-settings-tab'));
assert.equal(f.panel.snapshot().settingsOpen, true);
assert.equal(f.panel.select('unknown').reason, 'unknown-view');
});
test('tab keyboard navigation and Escape preserve focus and accessibility state', (t) => {
test('tab keyboard navigation and Escape preserve focus and accessibility state', async (t) => {
const f = fixture(t);
click(f.ui.voiceSettingsButton);
assert.equal(key(f.ui.settingsTab, 'ArrowLeft').defaultPrevented, true);
await f.settle();
click(f.$('gev-voice-settings-button'));
await f.settle();
assert.equal(
key(f.$('gev-voice-settings-tab'), 'ArrowLeft').defaultPrevented,
true,
);
await f.settle();
assert.equal(f.panel.snapshot().view, 'preview');
assert.equal(f.doc.activeElement, f.ui.previewTab);
assert.equal(f.ui.previewTab.tabIndex, 0);
assert.equal(f.ui.settingsTab.tabIndex, -1);
assert.equal(f.ui.previewTab.attributes['aria-selected'], 'true');
key(f.ui.previewTab, 'End');
assert.equal(f.active(), 'gev-voice-preview-tab');
assert.equal(f.$('gev-voice-preview-tab').tabIndex, 0);
assert.equal(f.$('gev-voice-settings-tab').tabIndex, -1);
assert.equal(
f.$('gev-voice-preview-tab').getAttribute('aria-selected'),
'true',
);
key(f.$('gev-voice-preview-tab'), 'End');
assert.equal(f.panel.snapshot().view, 'settings');
key(f.ui.settingsTab, 'Home');
key(f.$('gev-voice-settings-tab'), 'Home');
await f.settle();
assert.equal(f.panel.snapshot().view, 'preview');
assert.equal(key(f.ui.voicePanel, 'Escape').defaultPrevented, true);
assert.equal(key(f.$('gev-voice-panel'), 'Escape').defaultPrevented, true);
await f.settle();
assert.equal(f.panel.snapshot().panelOpen, false);
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
assert.equal(key(f.ui.voicePanel, 'Escape').defaultPrevented, false);
assert.equal(f.active(), 'gev-voice-settings-button');
assert.equal(key(f.$('gev-voice-panel'), 'Escape').defaultPrevented, false);
});
test('manual switching transfers hidden-panel focus without stealing unrelated focus', (t) => {
test('manual switching transfers hidden-panel focus without stealing unrelated focus', async (t) => {
const f = fixture(t);
f.panel.select('settings');
f.ui.voiceSettingsPanel.focus();
await f.settle();
// Focus inside the Settings pane moves to the Preview tab with the switch.
f.$('gev-voice-timeout-select').focus();
f.panel.select('preview');
assert.equal(f.doc.activeElement, f.ui.previewTab);
const unrelated = (f.doc.activeElement = {});
await f.settle();
assert.equal(f.active(), 'gev-voice-preview-tab');
const unrelated = document.body.appendChild(document.createElement('button'));
unrelated.id = 'unrelated';
unrelated.focus();
f.panel.select('settings');
assert.equal(f.doc.activeElement, unrelated);
await f.settle();
assert.equal(f.active(), 'unrelated');
assert.equal(f.panel.close('preview').changed, false);
assert.equal(f.panel.snapshot().settingsOpen, true);
});
test('manual Preview selection never enables a disabled preference and uses injected copy', (t) => {
test('manual Preview selection never enables a disabled preference and uses injected copy', async (t) => {
const f = fixture(t);
await f.settle();
f.enabled(false);
f.content(true);
f.panel.select('preview');
await f.settle();
const empty = f.$('gev-voice-preview-empty');
assert.equal(f.panel.snapshot().previewEnabled, false);
assert.equal(f.panel.snapshot().previewVisible, false);
assert.equal(f.ui.previewEmpty.textContent, f.copy.previewDisabled);
assert.equal(f.ui.previewEmpty.hidden, false);
assert.equal(empty.textContent, f.copy.previewDisabled);
assert.equal(empty.hidden, false);
f.enabled(true);
await f.settle();
assert.equal(f.panel.snapshot().previewVisible, true);
assert.equal(f.ui.previewEmpty.hidden, true);
assert.equal(empty.hidden, true);
assert.equal(f.$('gev-voice-card').hidden, false);
f.content(false);
assert.equal(f.ui.previewEmpty.textContent, f.copy.previewEmpty);
await f.settle();
assert.equal(empty.textContent, f.copy.previewEmpty);
});
test('disposal hides owned presentation and removes listeners without destroying the card', (t) => {
test('disposal hides owned presentation and removes listeners without destroying the card', async (t) => {
const f = fixture(t);
f.panel.select('settings');
await f.settle();
const count = f.intents();
f.panel.destroy();
f.panel.destroy();
click(f.ui.voiceSettingsButton);
click(f.ui.settingsTab);
click(f.$('gev-voice-settings-button'));
click(f.$('gev-voice-settings-tab'));
await f.settle();
assert.equal(f.intents(), count);
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.$('gev-voice-panel').hidden, true);
assert.equal(f.card.presented, false);
assert.equal(f.panel.select('preview').reason, 'disposed');
assert.equal(f.panel.close().reason, 'disposed');
});
test('pointer grace expires, resets on reentry, ignores touch and clears on disposal', (t) => {
test('pointer grace expires, resets on reentry, ignores touch and clears on disposal', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const f = fixture(t);
await f.settle();
const root = f.$('gev-voice-control');
const pointer = (type, pointerType = 'mouse') => {
const event = new Event(type);
Object.defineProperty(event, 'pointerType', { value: pointerType });
f.ui.root.dispatchEvent(event);
root.dispatchEvent(event);
};
pointer('pointerleave');
t.mock.timers.tick(699);
assert.equal(f.ui.root.dataset.rowGrace, 'true');
await f.settle();
assert.equal(root.dataset.rowGrace, 'true');
pointer('pointerenter');
assert.equal(f.ui.root.dataset.rowGrace, undefined);
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
pointer('pointerleave');
t.mock.timers.tick(699);
assert.equal(f.ui.root.dataset.rowGrace, 'true');
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, 'true');
t.mock.timers.tick(1);
assert.equal(f.ui.root.dataset.rowGrace, undefined);
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
pointer('pointerleave', 'touch');
assert.equal(f.ui.root.dataset.rowGrace, undefined);
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
pointer('pointerleave');
f.panel.destroy();
assert.equal(f.ui.root.dataset.rowGrace, undefined);
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
pointer('pointerleave');
t.mock.timers.tick(700);
assert.equal(f.ui.root.dataset.rowGrace, undefined);
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
});
+2 -9
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createVoiceInactivity } from './inactivity.js';
import { RealtimeTurns } from '../realtimeTurns.js';
import { observeNativeActivity } from './nativeActivity.js';
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
// Deliberately small native-shaped fixtures exercise wrapper contracts, not devices.
function gemini(onChange = () => {}) {
const callbacks = [];
@@ -686,15 +687,7 @@ test('actual OpenAI failed-response error/listening recovery preserves only prov
runner: async () => ({ ok: true }),
backend: {},
debugSink: null,
ui: {
root: {
dataset: {},
classList: { remove() {} },
querySelectorAll: () => [],
},
status: {},
detail: {},
},
ui: createTestVoiceUi(),
onSessionEvent(event) {
if (event.type === 'state') states.push(event.state);
observer?.handle(event);
+51 -56
View File
@@ -102,93 +102,88 @@ export function createVoicePreferences({ storage } = {}) {
/** Mount-local controls, backed by an independently owned preference model. */
export function bindPreferenceControls({ ui, preferences, copy, onChange }) {
const {
voicePreviewInput: preview,
voicePreviewNote: previewNote,
voiceTimeoutSelect: select,
voiceTimeoutInput: input,
voiceTimeoutNote: timeoutNote,
} = ui;
let disposed = false;
const initial = preferences.snapshot();
const displayed = parseMinutes(input?.value);
const displayed = parseMinutes(ui.state.preferences.timeoutMinutes);
let custom =
!PRESETS.includes(String(initial.minutes)) && initial.minutes > 0
? initial.minutes
: displayed > 0
? displayed
: 5;
// Every render republishes the fields, so a refused change shows the
// applied value again.
function render(state) {
if (disposed) return;
if (preview) preview.checked = state.previewEnabled;
if (previewNote)
previewNote.textContent = `${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`;
if (timeoutNote)
timeoutNote.textContent = `${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`;
const value = String(state.minutes);
const customSelected = state.minutes > 0 && !PRESETS.includes(value);
if (customSelected) custom = state.minutes;
if (select)
select.value =
state.minutes === 0 ? '0' : customSelected ? 'custom' : value;
if (input) {
input.value = String(custom);
input.hidden = !customSelected;
}
ui.update('preferences', {
previewChecked: state.previewEnabled,
// Functions, so a locale change words the notes again.
previewNote: () =>
`${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`,
timeoutNote: () =>
`${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`,
timeoutChoice:
state.minutes === 0 ? '0' : customSelected ? 'custom' : value,
timeoutMinutes: String(custom),
timeoutInputHidden: !customSelected,
revision: ui.state.preferences.revision + 1,
});
}
const unsubscribe = preferences.subscribe((state) => {
render(state);
onChange?.(state);
});
const previewChange = () => {
if (!disposed && !preferences.setPreviewEnabled(preview.checked).ok)
render(preferences.snapshot());
};
const selectChange = () => {
if (disposed) return;
const value = select.value;
if (value === 'custom') {
const parsed = parseMinutes(input?.value);
// A custom value may equal a preset: keep the person's explicit editor
// choice.
const showCustom = () =>
ui.update('preferences', {
timeoutChoice: 'custom',
timeoutMinutes: String(custom),
timeoutInputHidden: false,
revision: ui.state.preferences.revision + 1,
});
const offs = [
ui.on('preview-change', (checked) => {
if (!disposed && !preferences.setPreviewEnabled(checked).ok)
render(preferences.snapshot());
}),
ui.on('timeout-select', (value) => {
if (disposed) return;
if (value === 'custom') {
const parsed = parseMinutes(ui.state.preferences.timeoutMinutes);
if (parsed === undefined || parsed === 0) {
render(preferences.snapshot());
return;
}
custom = parsed;
preferences.setMinutes(parsed);
showCustom();
} else if (value === '0' || PRESETS.includes(value))
preferences.setMinutes(value);
else render(preferences.snapshot());
}),
ui.on('timeout-input', (value) => {
if (disposed) return;
const parsed = parseMinutes(value);
if (parsed === undefined || parsed === 0) {
render(preferences.snapshot());
return;
}
custom = parsed;
preferences.setMinutes(parsed);
// A custom value may equal a preset: retain the user's explicit editor choice.
select.value = 'custom';
if (input) input.hidden = false;
} else if (value === '0' || PRESETS.includes(value))
preferences.setMinutes(value);
else render(preferences.snapshot());
};
const inputChange = () => {
if (disposed) return;
const parsed = parseMinutes(input.value);
if (parsed === undefined || parsed === 0) {
render(preferences.snapshot());
return;
}
custom = parsed;
preferences.setMinutes(parsed);
if (select) select.value = 'custom';
input.hidden = false;
};
const bindings = [
[preview, previewChange],
[select, selectChange],
[input, inputChange],
showCustom();
}),
];
for (const [element, listener] of bindings)
element?.addEventListener('change', listener);
render(initial);
return {
dispose() {
if (disposed) return;
disposed = true;
unsubscribe();
for (const [element, listener] of bindings)
element?.removeEventListener('change', listener);
for (const off of offs) off();
},
};
}
+68 -64
View File
@@ -6,6 +6,7 @@ import {
VOICE_PREVIEW_STORAGE_KEY as P,
VOICE_TIMEOUT_STORAGE_KEY as T,
} from './preferences.js';
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
function storage(entries = {}, mode = 'normal') {
const values = new Map(Object.entries(entries));
@@ -25,39 +26,14 @@ function storage(entries = {}, mode = 'normal') {
},
};
}
class Element {
value = '';
checked = false;
textContent = '';
hidden = false;
listeners = new Set();
addEventListener(type, listener) {
assert.equal(type, 'change');
this.listeners.add(listener);
}
removeEventListener(type, listener) {
assert.equal(type, 'change');
this.listeners.delete(listener);
}
change(value) {
if (typeof value === 'boolean') this.checked = value;
else this.value = value;
for (const listener of [...this.listeners]) listener();
}
}
// A panel control whose timeout field shows `custom` minutes, as a view
// would display before the binder renders.
function controls(custom = '7') {
const ui = Object.fromEntries(
[
'voicePreviewInput',
'voicePreviewNote',
'voiceTimeoutSelect',
'voiceTimeoutInput',
'voiceTimeoutNote',
].map((key) => [key, new Element()]),
);
ui.voiceTimeoutInput.value = custom;
const ui = createTestVoiceUi({ panel: true });
ui.update('preferences', { timeoutMinutes: custom });
return ui;
}
const fields = (ui) => ui.state.preferences;
const copy = {
previewNote: 'Preview local',
timeoutNote: 'Provider limits still apply',
@@ -150,9 +126,9 @@ for (const mode of ['throw', 'noop', 'mismatch', 'readthrow', 'missing']) {
const prefs = createVoicePreferences({ storage: disk });
const a = controls();
const bindingA = bind(a, prefs);
a.voicePreviewInput.change(false);
a.voiceTimeoutSelect.change('custom');
a.voiceTimeoutInput.change('19');
a.emit('preview-change', false);
a.emit('timeout-select', 'custom');
a.emit('timeout-input', '19');
assert.deepEqual(prefs.snapshot(), {
previewEnabled: false,
minutes: 19,
@@ -163,13 +139,13 @@ for (const mode of ['throw', 'noop', 'mismatch', 'readthrow', 'missing']) {
const writeCount = disk?.writes.length;
const b = controls();
const bindingB = bind(b, prefs);
assert.equal(b.voicePreviewInput.checked, false);
assert.equal(b.voiceTimeoutInput.value, '19');
assert.match(b.voicePreviewNote.textContent, /Not saved/);
assert.match(b.voiceTimeoutNote.textContent, /Not saved/);
assert.equal(fields(b).previewChecked, false);
assert.equal(fields(b).timeoutMinutes, '19');
assert.match(fields(b).previewNote, /Not saved/);
assert.match(fields(b).timeoutNote, /Not saved/);
assert.equal(disk?.writes.length, writeCount);
a.voicePreviewInput.change(true);
a.voiceTimeoutSelect.change('3');
a.emit('preview-change', true);
a.emit('timeout-select', '3');
assert.equal(prefs.snapshot().minutes, 19);
assert.equal(prefs.snapshot().previewEnabled, false);
bindingB.dispose();
@@ -187,7 +163,7 @@ test('same-value retry really writes and clears warnings after storage recovers'
bind(ui, prefs);
prefs.setPreviewEnabled(false);
prefs.setMinutes(12);
assert.match(ui.voiceTimeoutNote.textContent, /Not saved/);
assert.match(fields(ui).timeoutNote, /Not saved/);
disk.setItem = original;
const timeout = prefs.setMinutes(12);
const preview = prefs.setPreviewEnabled(false);
@@ -199,8 +175,8 @@ test('same-value retry really writes and clears warnings after storage recovers'
[T, '12'],
[P, 'false'],
]);
assert.equal(ui.voiceTimeoutNote.textContent, copy.timeoutNote);
assert.equal(ui.voicePreviewNote.textContent, copy.previewNote);
assert.equal(fields(ui).timeoutNote, copy.timeoutNote);
assert.equal(fields(ui).previewNote, copy.previewNote);
});
test('Custom commits displayed valid value immediately, even when equal to preset', () => {
@@ -209,18 +185,18 @@ test('Custom commits displayed valid value immediately, even when equal to prese
const ui = controls('10');
bind(ui, prefs);
assert.equal(disk.writes.length, 0);
ui.voiceTimeoutSelect.change('custom');
ui.emit('timeout-select', 'custom');
assert.equal(prefs.snapshot().minutes, 10);
assert.equal(ui.voiceTimeoutSelect.value, 'custom');
assert.equal(ui.voiceTimeoutInput.hidden, false);
assert.equal(fields(ui).timeoutChoice, 'custom');
assert.equal(fields(ui).timeoutInputHidden, false);
assert.deepEqual(disk.writes, [[T, '10']]);
ui.voiceTimeoutInput.change('23');
ui.voiceTimeoutSelect.change('5');
assert.equal(ui.voiceTimeoutInput.value, '23');
assert.equal(ui.voiceTimeoutInput.hidden, true);
ui.voiceTimeoutSelect.change('custom');
ui.emit('timeout-input', '23');
ui.emit('timeout-select', '5');
assert.equal(fields(ui).timeoutMinutes, '23');
assert.equal(fields(ui).timeoutInputHidden, true);
ui.emit('timeout-select', 'custom');
assert.equal(prefs.snapshot().minutes, 23);
assert.equal(ui.voiceTimeoutInput.value, '23');
assert.equal(fields(ui).timeoutMinutes, '23');
});
test('invalid control edits restore valid state without writes; None remains distinct', () => {
@@ -228,21 +204,26 @@ test('invalid control edits restore valid state without writes; None remains dis
const prefs = createVoicePreferences({ storage: disk });
const ui = controls();
bind(ui, prefs);
assert.equal(ui.voiceTimeoutInput.value, '17');
assert.equal(fields(ui).timeoutMinutes, '17');
for (const value of ['1.5', '', '61', '0']) {
ui.voiceTimeoutInput.change(value);
assert.equal(ui.voiceTimeoutInput.value, '17');
// Each refused edit republishes the applied value, so the view writes it
// back over what was typed.
const revision = fields(ui).revision;
ui.emit('timeout-input', value);
assert.equal(fields(ui).timeoutMinutes, '17');
assert.ok(fields(ui).revision > revision);
assert.equal(prefs.snapshot().minutes, 17);
}
ui.voiceTimeoutSelect.change('malformed');
assert.equal(ui.voiceTimeoutSelect.value, 'custom');
ui.emit('timeout-select', 'malformed');
assert.equal(fields(ui).timeoutChoice, 'custom');
assert.equal(disk.writes.length, 0);
ui.voiceTimeoutSelect.change('0');
ui.emit('timeout-select', '0');
assert.equal(prefs.snapshot().minutes, 0);
assert.equal(ui.voiceTimeoutInput.hidden, true);
ui.voiceTimeoutInput.value = 'bad';
ui.voiceTimeoutSelect.change('custom');
assert.equal(ui.voiceTimeoutSelect.value, '0');
assert.equal(fields(ui).timeoutInputHidden, true);
// The field shows an invalid value when Custom is chosen.
ui.update('preferences', { timeoutMinutes: 'bad' });
ui.emit('timeout-select', 'custom');
assert.equal(fields(ui).timeoutChoice, '0');
assert.equal(prefs.snapshot().minutes, 0);
assert.deepEqual(disk.writes, [[T, 'none']]);
});
@@ -250,8 +231,25 @@ test('invalid control edits restore valid state without writes; None remains dis
test('model snapshots cannot mutate state; unsubscribe and binder disposal release ownership', () => {
const prefs = createVoicePreferences({ storage: storage() });
const ui = controls();
// Count the binder's field listeners still attached.
const attached = new Set();
const on = ui.on;
ui.on = (name, listener) => {
const off = on(name, listener);
const entry = { name };
attached.add(entry);
return () => {
attached.delete(entry);
off();
};
};
let calls = 0;
const binding = bind(ui, prefs, () => calls++);
assert.deepEqual([...attached].map((entry) => entry.name).sort(), [
'preview-change',
'timeout-input',
'timeout-select',
]);
const snap = prefs.snapshot();
snap.minutes = 41;
assert.equal(prefs.snapshot().minutes, 5);
@@ -264,9 +262,15 @@ test('model snapshots cannot mutate state; unsubscribe and binder disposal relea
unsub();
binding.dispose();
binding.dispose();
assert.equal(attached.size, 0);
prefs.setMinutes(12);
assert.equal(calls, 1);
assert.equal(ui.voiceTimeoutInput.value, '11');
for (const element of Object.values(ui))
assert.equal(element.listeners.size, 0);
assert.equal(fields(ui).timeoutMinutes, '11');
// A disposed binder no longer answers its fields.
ui.emit('timeout-select', '3');
ui.emit('timeout-input', '9');
ui.emit('preview-change', false);
assert.equal(prefs.snapshot().minutes, 12);
assert.equal(prefs.snapshot().previewEnabled, true);
assert.equal(fields(ui).timeoutMinutes, '11');
});
+38
View File
@@ -0,0 +1,38 @@
// src/voice/panel/preferencesView.test.mjs — the rendered minutes field keeps
// a draft across renders and shows a refused edit's applied value again.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
bindPreferenceControls,
createVoicePreferences,
VOICE_TIMEOUT_STORAGE_KEY as T,
} from './preferences.js';
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
import { mountVoiceView } from '../../testSupport/voiceView.mjs';
test('a minutes draft survives unrelated renders; a refused edit is written back', async (t) => {
const { pinia, settle } = mountVoiceView(t);
const ui = createTestVoiceUi({ panel: true, pinia });
ui.update('preferences', { timeoutMinutes: '17' });
const prefs = createVoicePreferences({
storage: { getItem: (key) => (key === T ? '17' : null), setItem() {} },
});
bindPreferenceControls({
ui,
preferences: prefs,
copy: { previewNote: '', timeoutNote: 'Local', notSaved: 'Not saved' },
});
await settle();
const input = document.getElementById('gev-voice-timeout-input');
assert.equal(input.value, '17');
// Typing, before the field commits: the waveform renders every frame.
input.value = '2';
ui.update('status', { text: 'LISTENING' });
await settle();
assert.equal(input.value, '2');
input.value = '0';
input.dispatchEvent(new Event('change', { bubbles: true }));
await settle();
assert.equal(input.value, '17');
});
-204
View File
@@ -1,204 +0,0 @@
// Minimal DOM fixture for actual control/card/binder composition, not layout proof.
class Element {
constructor(doc, tag = 'div') {
Object.assign(this, {
ownerDocument: doc,
tagName: tag.toUpperCase(),
children: [],
attributes: new Map(),
dataset: {},
hidden: false,
value: '',
checked: false,
listeners: new Map(),
style: { setProperty() {}, removeProperty() {} },
});
this.classList = {
add: (...names) => names.forEach((n) => this.classes.add(n)),
remove: (...names) => names.forEach((n) => this.classes.delete(n)),
contains: (name) => this.classes.has(name),
};
this.classes = new Set();
}
set id(value) {
this.attributes.set('id', value);
}
get id() {
return this.attributes.get('id') || '';
}
set className(value) {
this.classes = new Set(value.split(/\s+/).filter(Boolean));
}
get className() {
return [...this.classes].join(' ');
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'class') this.className = value;
if (name === 'hidden') this.hidden = true;
if (name === 'value') this.value = value;
if (name.startsWith('data-'))
this.dataset[
name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())
] = value;
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
get isConnected() {
return (
this === this.ownerDocument.body || Boolean(this.parentNode?.isConnected)
);
}
get parentElement() {
return this.parentNode;
}
set innerHTML(html) {
this.replaceChildren();
const stack = [this];
const voids = new Set(['INPUT', 'IMG', 'BR', 'HR']);
for (const match of html.matchAll(/<(\/?)([a-z][\w-]*)\b([^>]*)>/gi)) {
if (match[1]) {
if (stack.length > 1) stack.pop();
continue;
}
const node = new Element(this.ownerDocument, match[2]);
for (const attr of match[3].matchAll(/([^\s=\/]+)(?:="([^"]*)")?/g))
node.setAttribute(attr[1], attr[2] ?? '');
stack.at(-1).appendChild(node);
if (!voids.has(node.tagName)) stack.push(node);
}
}
set textContent(value) {
this.text = String(value);
}
get textContent() {
return this.text ?? '';
}
appendChild(node) {
node.remove();
this.children.push(node);
node.parentNode = this;
return node;
}
append(...nodes) {
nodes.forEach((node) => this.appendChild(node));
}
insertBefore(node, before) {
node.remove();
const at = this.children.indexOf(before);
this.children.splice(at < 0 ? this.children.length : at, 0, node);
node.parentNode = this;
}
replaceChildren(...nodes) {
for (const child of this.children) child.parentNode = null;
this.children = [];
this.append(...nodes);
}
remove() {
if (this.parentNode)
this.parentNode.children = this.parentNode.children.filter(
(n) => n !== this,
);
this.parentNode = null;
}
contains(node) {
return node === this || this.children.some((child) => child.contains(node));
}
matches(selector) {
if (selector.startsWith('#')) return this.id === selector.slice(1);
if (selector.startsWith('.')) return this.classes.has(selector.slice(1));
return this.tagName === selector.toUpperCase();
}
querySelectorAll(selector) {
return this.children.flatMap((n) => [
...(n.matches(selector) ? [n] : []),
...n.querySelectorAll(selector),
]);
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
checkVisibility() {
return (
this.isConnected &&
!this.hidden &&
(!this.parentNode || this.parentNode.checkVisibility())
);
}
focus() {
this.ownerDocument.activeElement = this;
}
addEventListener(type, listener, options = {}) {
if (options.signal?.aborted) return;
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(listener);
if (options.signal)
options.signal.addEventListener(
'abort',
() => this.removeEventListener(type, listener),
{ once: true },
);
}
removeEventListener(type, listener) {
this.listeners.get(type)?.delete(listener);
}
fire(type, fields = {}) {
const event = {
type,
target: this,
preventDefault() {
this.defaultPrevented = true;
},
...fields,
};
for (const listener of [...(this.listeners.get(type) || [])])
listener.call(this, event);
return event;
}
}
const installed = new WeakMap();
export function installPanelDom(t) {
if (installed.has(t)) return installed.get(t);
const previous = {
window: globalThis.window,
document: globalThis.document,
Element: globalThis.Element,
};
const doc = new EventTarget();
doc.body = new Element(doc, 'body');
doc.documentElement = new Element(doc, 'html');
doc.documentElement.lang = 'en';
doc.activeElement = doc.body;
doc.createElement = (tag) => new Element(doc, tag);
doc.getElementById = (id) => doc.body.querySelector(`#${id}`);
doc.querySelector = (selector) => doc.body.querySelector(selector);
const win = new EventTarget();
globalThis.window = win;
globalThis.document = doc;
globalThis.Element = Element;
installed.set(t, doc);
t.after(() => Object.assign(globalThis, previous));
return doc;
}
export function controlledClock() {
let now = 0,
next = 0;
const tasks = new Map();
return {
now: () => now,
setTimer(fn, ms) {
const id = ++next;
tasks.set(id, { fn, at: now + ms });
return id;
},
clearTimer: (id) => tasks.delete(id),
advance(ms) {
now += ms;
for (const [id, task] of [...tasks])
if (task.at <= now && tasks.delete(id)) task.fn();
},
};
}
+22 -25
View File
@@ -1,6 +1,9 @@
import '../testSupport/registerDom.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { nextTick } from 'vue';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import { mountVoiceView } from '../testSupport/voiceView.mjs';
import { GevRealtimeController } from './realtimeController.js';
import {
POINTER_OPEN_MIC_RECENT_MS,
@@ -63,16 +66,7 @@ function controllerFixture(pointer, { pushToTalk = false } = {}) {
const sent = [];
const events = [];
const controller = new GevRealtimeController({
ui: {
root: {
dataset: {},
classList: { remove() {} },
querySelectorAll: () => [],
},
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
},
ui: createTestVoiceUi(),
runner: async () => ({ ok: true }),
debugSink: null,
pointer,
@@ -299,20 +293,23 @@ test('card: the chip says what "this" or "here" resolved to and resets per turn'
assert.equal(unchanged, state, 'a stale pointer on an empty chip is a no-op');
});
test('markup: the chip sits under the user caption with a screen-reader separator', () => {
const control = readFileSync(
new URL('./control.js', import.meta.url),
'utf8',
);
const user = control.indexOf('id="gev-voice-card-user"');
const chip = control.indexOf('id="gev-voice-card-pointer"');
const assistant = control.indexOf('id="gev-voice-card-assistant"');
assert.ok(user < chip && chip < assistant);
assert.match(
control,
/id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden/,
);
assert.match(control, /<span class="gev-voice-card-sr">: <\/span>/);
test('markup: the chip sits under the user caption with a screen-reader separator', async (t) => {
const { pinia } = mountVoiceView(t);
const ui = createTestVoiceUi({ pinia });
t.after(() => ui.remove());
await nextTick();
const card = document.getElementById('gev-voice-card');
const order = [...card.children].map((node) => node.id);
const user = order.indexOf('gev-voice-card-user');
const chip = order.indexOf('gev-voice-card-pointer');
const assistant = order.indexOf('gev-voice-card-assistant');
assert.ok(user >= 0 && user < chip && chip < assistant);
const pointer = document.getElementById('gev-voice-card-pointer');
assert.equal(pointer.className, 'gev-voice-card-pointer');
assert.equal(pointer.hidden, true);
const separator = pointer.querySelector('.gev-voice-card-sr');
assert.equal(separator.textContent, ': ');
assert.equal(separator.previousElementSibling.id, 'gev-voice-card-pointer-kind');
});
test('deixis prompt lint: one short rule, and every sentinel it names exists in the schema', () => {
+30 -29
View File
@@ -3,13 +3,17 @@
* live so the user sees what was understood before the answer.
*
* It is a fixed-position overlay (pointer-events: none) placed from the
* snapshot's canvas pixels, so it never touches the Cesium scene. The reticle
* marks where the user pointed, not a tracked world position: it leaves as
* soon as a tool moves the camera, when the reply completes, when the session
* ends, and after {@link RETICLE_MAX_MS} at most. Clean-UI and recording mode
* hide it through CSS.
* snapshot's canvas pixels, so it never touches the Cesium scene. It is
* presented to the pointer reticle store, which the command dock renders.
* The reticle marks where the user pointed, not a tracked world position: it
* leaves as soon as a tool moves the camera, when the reply completes, when
* the session ends, and after {@link RETICLE_MAX_MS} at most. Clean-UI and
* recording mode hide it through CSS.
*/
import { usePointerReticleStore } from '../ui/stores/pointerReticle.js';
import { applicationPinia } from '../ui/vueRuntime.js';
export const RETICLE_MAX_MS = 10000;
export const RETICLE_LINGER_MS = 1500;
@@ -26,23 +30,28 @@ const CAMERA_TOOLS = new Set([
'control_cockpit',
]);
// Keys each reticle's element in the view.
let reticleKeys = 0;
export class PointerReticle {
/**
* @param {object} options
* @param {Document} options.doc Document that owns the overlay.
* @param {() => {left: number, top: number}|null} options.rectOf Canvas
* client rect, read at show time.
* @param {object} [options.store] The pointer reticle store.
* @param {Function} [options.setTimer]
* @param {Function} [options.clearTimer]
*/
constructor({
doc,
rectOf,
store = usePointerReticleStore(applicationPinia()),
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (id) => clearTimeout(id),
}) {
Object.assign(this, { doc, rectOf, setTimer, clearTimer });
this.node = null;
Object.assign(this, { rectOf, store, setTimer, clearTimer });
this.key = ++reticleKeys;
// What this reticle last presented; null until it first shows.
this.view = null;
this.timer = null;
// Tools running in this turn, keyed by call id (name when absent).
this.running = new Set();
@@ -50,17 +59,6 @@ export class PointerReticle {
this.destroyed = false;
}
ensureNode() {
if (this.node || !this.doc?.createElement) return this.node;
const node = this.doc.createElement('div');
node.className = 'gev-pointer-reticle';
node.setAttribute?.('aria-hidden', 'true');
node.hidden = true;
this.doc.body?.appendChild?.(node);
this.node = node;
return node;
}
schedule(ms) {
this.cancelTimer();
this.timer = this.setTimer(() => {
@@ -79,12 +77,14 @@ export class PointerReticle {
show(pointer) {
if (this.destroyed || !pointer?.screenPx) return;
const rect = this.rectOf?.();
const node = this.ensureNode();
if (!rect || !node) return;
node.style.left = `${Math.round(rect.left + pointer.screenPx.x)}px`;
node.style.top = `${Math.round(rect.top + pointer.screenPx.y)}px`;
node.dataset.target = pointer.kind === 'this' ? 'entity' : 'ground';
node.hidden = false;
if (!rect) return;
this.view = {
visible: true,
left: Math.round(rect.left + pointer.screenPx.x),
top: Math.round(rect.top + pointer.screenPx.y),
target: pointer.kind === 'this' ? 'entity' : 'ground',
};
this.store.publish(this, this.view);
this.visible = true;
this.running.clear();
this.schedule(RETICLE_MAX_MS);
@@ -95,7 +95,8 @@ export class PointerReticle {
this.running.clear();
if (!this.visible) return;
this.visible = false;
if (this.node) this.node.hidden = true;
this.view = { ...this.view, visible: false };
this.store.publish(this, this.view);
}
/** Follow provider-neutral session events. */
@@ -129,7 +130,7 @@ export class PointerReticle {
if (this.destroyed) return;
this.hide();
this.destroyed = true;
this.node?.remove?.();
this.node = null;
if (this.view) this.store.publish(this, null);
this.view = null;
}
}
+43 -38
View File
@@ -5,34 +5,27 @@ import { PointerReticle, RETICLE_LINGER_MS, RETICLE_MAX_MS } from './pointerReti
const css = readStylesheet(new URL('../../style.css', import.meta.url));
function fakeDoc() {
const appended = [];
function fakeStore() {
return {
appended,
body: { appendChild: (node) => appended.push(node) },
createElement: (tag) => ({
tag,
hidden: false,
style: {},
dataset: {},
attributes: {},
setAttribute(name, value) {
this.attributes[name] = value;
},
removed: false,
remove() {
this.removed = true;
},
}),
reticles: new Map(),
publishes: 0,
get reticle() {
return [...this.reticles.values()].at(-1) ?? null;
},
publish(owner, view) {
if (view) this.reticles.set(owner, view);
else this.reticles.delete(owner);
this.publishes += 1;
},
};
}
function harness() {
const doc = fakeDoc();
const store = fakeStore();
const timers = new Map();
let next = 1;
const reticle = new PointerReticle({
doc,
store,
rectOf: () => ({ left: 10, top: 20 }),
setTimer: (fn, ms) => {
const id = next++;
@@ -46,31 +39,26 @@ function harness() {
timers.delete(id);
timer.fn();
};
return { doc, timers, reticle, fire };
return { store, timers, reticle, fire };
}
const pointer = { kind: 'this', label: 'UPS793', screenPx: { x: 600, y: 200 } };
test('reticle: shows at the pointed pixel and caps its lifetime', () => {
const { doc, timers, reticle, fire } = harness();
const { store, timers, reticle, fire } = harness();
assert.equal(store.reticle, null);
reticle.handle({ type: 'pointer', pointer });
const node = doc.appended[0];
assert.equal(node.className, 'gev-pointer-reticle');
assert.equal(node.attributes['aria-hidden'], 'true');
assert.equal(node.hidden, false);
assert.equal(node.style.left, '610px');
assert.equal(node.style.top, '220px');
assert.equal(node.dataset.target, 'entity');
assert.deepEqual(store.reticle, { visible: true, left: 610, top: 220, target: 'entity' });
assert.equal([...timers.values()][0].ms, RETICLE_MAX_MS);
fire();
assert.equal(node.hidden, true);
assert.deepEqual(store.reticle, { visible: false, left: 610, top: 220, target: 'entity' });
});
test('reticle: leaves when a tool moves the camera, lingers after a reply', () => {
const { doc, timers, reticle, fire } = harness();
const { store, timers, reticle, fire } = harness();
reticle.handle({ type: 'pointer', pointer: { ...pointer, kind: 'here' } });
const node = doc.appended[0];
assert.equal(node.dataset.target, 'ground');
const hidden = () => !store.reticle.visible;
assert.equal(store.reticle.target, 'ground');
reticle.handle({ type: 'action-call', name: 'analyst_query', callId: 'c1' });
reticle.handle({ type: 'action-call', name: 'analyst_query', callId: 'c2' });
reticle.handle({ type: 'action-result', name: 'analyst_query', callId: 'c1' });
@@ -80,19 +68,36 @@ test('reticle: leaves when a tool moves the camera, lingers after a reply', () =
reticle.handle({ type: 'completion' });
assert.equal([...timers.values()][0].ms, RETICLE_LINGER_MS);
fire();
assert.equal(node.hidden, true);
assert.equal(hidden(), true);
reticle.handle({ type: 'pointer', pointer });
reticle.handle({ type: 'action-call', name: 'track_entity' });
assert.equal(node.hidden, true, 'camera tools hide it at once');
assert.equal(hidden(), true, 'camera tools hide it at once');
reticle.handle({ type: 'pointer', pointer });
reticle.handle({ type: 'state', state: 'idle' });
assert.equal(node.hidden, true, 'session end hides it');
assert.equal(hidden(), true, 'session end hides it');
assert.equal(timers.size, 0);
reticle.destroy();
reticle.destroy();
assert.equal(node.removed, true);
assert.equal(store.reticle, null, 'destroy withdraws the reticle');
const publishes = store.publishes;
reticle.handle({ type: 'pointer', pointer });
assert.equal(doc.appended.length, 1, 'destroyed reticle ignores events');
assert.equal(store.publishes, publishes, 'destroyed reticle ignores events');
});
test('reticle: each reticle keeps its own point', () => {
const store = fakeStore();
const rectOf = () => ({ left: 0, top: 0 });
const older = new PointerReticle({ store, rectOf, setTimer: () => 1, clearTimer() {} });
const newer = new PointerReticle({ store, rectOf, setTimer: () => 2, clearTimer() {} });
older.handle({ type: 'pointer', pointer });
newer.handle({ type: 'pointer', pointer: { ...pointer, screenPx: { x: 5, y: 6 } } });
newer.handle({ type: 'state', state: 'idle' });
assert.equal(store.reticles.get(older).visible, true, 'hiding the newer one leaves the older one');
newer.destroy();
assert.deepEqual([...store.reticles.keys()], [older]);
assert.notEqual(older.key, newer.key);
older.destroy();
assert.equal(store.reticles.size, 0);
});
test('reticle css: overlay is inert and hidden in Clean-UI and recording', () => {
+83
View File
@@ -0,0 +1,83 @@
// src/voice/pointerReticleView.test.mjs — the command dock renders each
// voice pointer reticle where its PointerReticle presents it.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { usePointerReticleStore } from '../ui/stores/pointerReticle.js';
import CommandDockView from '../ui/views/CommandDockView.vue';
import { PointerReticle } from './pointerReticle.js';
test('the reticle shows at the pointed pixel, hides, and leaves on destroy', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(CommandDockView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const node = () => document.querySelector('.gev-pointer-reticle');
const reticle = new PointerReticle({
rectOf: () => ({ left: 10, top: 20 }),
store: usePointerReticleStore(pinia),
});
await nextTick();
assert.equal(node(), null, 'nothing until the first point');
reticle.handle({
type: 'pointer',
pointer: { kind: 'this', screenPx: { x: 600, y: 200 } },
});
await nextTick();
const element = node();
assert.ok(element.parentElement === document.body);
assert.equal(element.getAttribute('aria-hidden'), 'true');
assert.equal(element.hidden, false);
assert.equal(element.style.left, '610px');
assert.equal(element.style.top, '220px');
assert.equal(element.dataset.target, 'entity');
reticle.handle({ type: 'state', state: 'idle' });
await nextTick();
assert.ok(node() === element, 'hiding keeps the element');
assert.equal(element.hidden, true);
reticle.handle({
type: 'pointer',
pointer: { kind: 'here', screenPx: { x: 1, y: 2 } },
});
await nextTick();
assert.equal(element.hidden, false);
assert.equal(element.dataset.target, 'ground');
reticle.destroy();
await nextTick();
assert.equal(node(), null);
});
test('two reticles show two elements', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(CommandDockView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const store = usePointerReticleStore(pinia);
const rectOf = () => ({ left: 0, top: 0 });
const one = new PointerReticle({ rectOf, store });
const two = new PointerReticle({ rectOf, store });
t.after(() => {
one.destroy();
two.destroy();
});
const at = (x) => ({
type: 'pointer',
pointer: { kind: 'this', screenPx: { x, y: 0 } },
});
one.handle(at(1));
two.handle(at(2));
await nextTick();
const lefts = () =>
[...document.querySelectorAll('.gev-pointer-reticle')].map(
(node) => node.style.left,
);
assert.deepEqual(lefts(), ['1px', '2px']);
two.destroy();
await nextTick();
assert.deepEqual(lefts(), ['1px']);
});
+2 -1
View File
@@ -80,7 +80,8 @@ export function createProviderCommands(options, { bind, factories, storage }) {
mount();
if (resume && !disposed && provider === next)
void current.session.start({ pushToTalk });
globalThis.document?.getElementById('gev-voice-provider')?.focus();
// The new control renders first; focus stays on the provider choice.
void current.ui?.focus?.(['provider'], { anyVisibility: true });
return true;
};
+53 -83
View File
@@ -10,7 +10,7 @@ import { createVoiceCommands as createCommands } from './commands.js';
import { createGevActionRunner } from './gevActions.js';
import { createPointerTracker } from './pointerContext.js';
import { createReferentRegistry } from './referents.js';
import { describeUnsavedWork, t as translate } from '../i18n/index.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
const PROVIDER_KEY = 'gev.voice.provider';
const settle = () => new Promise((resolve) => setImmediate(resolve));
@@ -25,28 +25,31 @@ function deferred() {
return { promise, resolve, reject };
}
// Listener counts observe the actual binder's registrations/removals.
class Element extends EventTarget {
listeners = new Map();
attributes = new Map();
addEventListener(type, handler, options) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(handler);
super.addEventListener(type, handler, options);
}
removeEventListener(type, handler, options) {
this.listeners.get(type)?.delete(handler);
super.removeEventListener(type, handler, options);
}
listenerCount(type) {
return this.listeners.get(type)?.size || 0;
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
focus() {
this.focusCount = (this.focusCount || 0) + 1;
}
// A real control whose listeners, removals and provider focus are counted.
// Removal is counted but keeps the control, so an interaction after teardown
// still reaches any listener the binder failed to release.
function countedControl(options, onRemove = () => {}) {
const ui = createTestVoiceUi(options);
const counts = new Map();
const on = ui.on;
ui.on = (name, listener) => {
counts.set(name, (counts.get(name) || 0) + 1);
const off = on(name, listener);
let live = true;
return () => {
if (live) counts.set(name, counts.get(name) - 1);
live = false;
off();
};
};
ui.listenerCount = (name) => counts.get(name) || 0;
ui.removeCount = 0;
ui.remove = () => {
ui.removeCount++;
onRemove();
};
ui.focusCount = 0;
return ui;
}
for (const custom of [false, true]) {
@@ -58,13 +61,7 @@ for (const custom of [false, true]) {
controls?.session.destroy();
globalThis.window = prior;
});
const ui = {
root: { dataset: {}, remove() {} },
button: new Element(),
status: {},
detail: {},
providerField: {},
};
const ui = countedControl();
let defaultCreated = 0;
let customCreated = 0;
const options = {
@@ -108,8 +105,8 @@ for (const custom of [false, true]) {
controls.session.sendText('test'),
`${custom ? 'custom' : 'default'}:test`,
);
assert.equal(ui.providerField.hidden, true);
assert.equal(ui.root.dataset.providerChoice, 'false');
assert.equal(ui.state.provider.hidden, true);
assert.equal(ui.state.root.providerChoice, 'false');
if (!custom)
assert.deepEqual(controls.session.adapter.getStartOptions(), {
pushToTalk: true,
@@ -153,7 +150,13 @@ function compositionFixture(
globalThis.window = new EventTarget();
globalThis.document = {
getElementById: (id) =>
id === 'gev-voice-provider' ? views.at(-1)?.providerSelect : null,
id === 'gev-voice-provider'
? {
focus() {
views.at(-1).focusCount++;
},
}
: null,
};
t.after(async () => {
try {
@@ -313,35 +316,10 @@ function compositionFixture(
]),
);
function createControl() {
function createControl(options) {
const id = views.length;
order.push(`control:${id}`);
const providerSelect = new Element();
providerSelect.ownerDocument = { createElement: () => ({}) };
providerSelect.replaceChildren = (...options) => {
providerSelect.options = options;
};
const view = {
root: {
dataset: {},
classList: { remove() {} },
removeCount: 0,
remove() {
this.removeCount++;
order.push(`remove:${id}`);
},
},
button: new Element(),
providerSelect,
providerField: {},
status: {},
detail: {},
errorDetail: {},
tierButton: {},
costValue: {},
helpDetail: {},
card: {},
};
const view = countedControl(options, () => order.push(`remove:${id}`));
views.push(view);
return view;
}
@@ -431,9 +409,7 @@ function compositionFixture(
storageWrites,
state,
select(provider) {
const select = views.at(-1).providerSelect;
select.value = provider;
select.dispatchEvent(new Event('change'));
views.at(-1).emit('provider-change', provider);
},
seedConversation() {
pointer.beginTurn('text', {
@@ -518,15 +494,15 @@ for (const scenario of [
assert.equal(Object.isFrozen(f.facade.supportedProviders), true);
assert.equal(f.facade.provider, scenario.provider);
assert.deepEqual(
f.views[0].providerSelect.options.map((option) => option.value),
f.views[0].state.provider.options.map((option) => option.value),
scenario.expected,
);
assert.equal(
f.views[0].providerField.hidden,
f.views[0].state.provider.hidden,
scenario.expected.length === 1,
);
assert.equal(
f.views[0].root.dataset.providerChoice,
f.views[0].state.root.providerChoice,
String(scenario.expected.length > 1),
);
assert.equal(f.instances.length, 1);
@@ -599,12 +575,12 @@ test('active switch keeps PTT mode, retires the held key, and disposes before mo
assert.ok(f.order.indexOf('stop:0') < f.order.indexOf('control:1'));
assert.ok(f.order.indexOf('remove:0') < f.order.indexOf('control:1'));
assert.ok(f.order.indexOf('stop:0') < f.order.indexOf('factory:1'));
assert.equal(f.views[0].button.listenerCount('click'), 0);
assert.equal(f.views[0].providerSelect.listenerCount('change'), 0);
assert.equal(f.views[1].providerSelect.listenerCount('change'), 1);
assert.equal(f.views[1].providerSelect.focusCount, 1);
assert.equal(f.views[1].tierButton.hidden, true);
assert.equal(f.views[1].costValue.hidden, true);
assert.equal(f.views[0].listenerCount('button'), 0);
assert.equal(f.views[0].listenerCount('provider-change'), 0);
assert.equal(f.views[1].listenerCount('provider-change'), 1);
assert.equal(f.views[1].focusCount, 1);
assert.equal(f.views[1].state.tier.hidden, true);
assert.equal(f.views[1].state.cost.hidden, true);
assert.equal(f.facade.providerDiagnostic(), 'gemini:1');
assert.equal(f.pointer.activeSnapshot(), null);
assert.deepEqual(f.referents.list(), []);
@@ -772,15 +748,11 @@ for (const emitsIdle of [false, true]) {
}
test('outer destruction disposes the runner once and removes each provider binding', async (t) => {
const priorUnsaved = describeUnsavedWork();
const f = compositionFixture(t);
await f.facade.start();
f.select('gemini');
await settle();
assert.equal(f.annotations.size, 1);
assert.ok(
describeUnsavedWork().includes(translate('the active voice session')),
);
const oldCardEvents = f.views[0].cardView.events.length;
for (const callback of f.annotations) callback({ id: 'fixture-outline' });
assert.equal(f.views[0].cardView.outlines.length, 0);
@@ -794,19 +766,17 @@ test('outer destruction disposes the runner once and removes each provider bindi
assert.equal(f.facade.signal.aborted, true);
assert.equal(f.facade.disposed, true);
assert.equal(f.annotations.size, 0);
assert.deepEqual(describeUnsavedWork(), priorUnsaved);
const finalEvents = f.events.length;
const starts = f.instances.map((instance) => instance.starts.length);
for (const [index, view] of f.views.entries()) {
assert.equal(view.root.removeCount, 1);
assert.equal(view.removeCount, 1);
assert.equal(view.cardView.destroyed, 1);
assert.equal(view.reticle.destroyed, 1);
assert.equal(view.button.listenerCount('click'), 0);
assert.equal(view.providerSelect.listenerCount('change'), 0);
assert.equal(view.listenerCount('button'), 0);
assert.equal(view.listenerCount('provider-change'), 0);
assert.equal(f.instances[index].hooks.signal.aborted, true);
view.button.dispatchEvent(new Event('click'));
view.providerSelect.value = 'openai';
view.providerSelect.dispatchEvent(new Event('change'));
view.emit('button');
view.emit('provider-change', 'openai');
f.instances[index].hooks.emit({ type: 'state', state: 'connecting' });
f.mounts[index].callback({ type: 'state', state: 'connecting' });
}
+2 -5
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import { createRealtimeBackend } from './realtimeBackend.js';
import { GevRealtimeController } from './realtimeController.js';
@@ -131,11 +132,7 @@ test('controller lifetime stops pending transport and releases resources through
runner: async () => ({}),
signal: lifetime.signal,
debugSink: null,
ui: {
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
status: {},
detail: {},
},
ui: createTestVoiceUi(),
});
controller.connectionAbort = new AbortController();
const connection = controller.connectionAbort.signal;
+22 -25
View File
@@ -157,7 +157,6 @@ export class GevRealtimeController extends RealtimeFacade {
},
});
this.buttonHandler = null;
this.tierHandler = null;
this.annotationEventUnsubscribe = null;
@@ -379,12 +378,8 @@ export class GevRealtimeController extends RealtimeFacade {
this._radio.clearPendingPlayback();
this._viewport.reset();
this._input.resetSession();
if (removeUi && this.ui?.button && this.buttonHandler) {
this.ui.button.removeEventListener('click', this.buttonHandler);
this.buttonHandler = null;
}
if (removeUi && this.ui?.tierButton && this.tierHandler) {
this.ui.tierButton.removeEventListener('click', this.tierHandler);
if (removeUi && this.tierHandler) {
this.tierHandler();
this.tierHandler = null;
}
if (removeUi) this._input.detachBindings();
@@ -395,9 +390,7 @@ export class GevRealtimeController extends RealtimeFacade {
this.annotationEventUnsubscribe = null;
}
if (removeUi) this._radio.detachObservers();
if (removeUi && this.ui?.root) {
this.ui.root.remove();
}
if (removeUi) this.ui?.remove();
if (!preserveStatus && !removeUi) {
this.setStatus('idle', t('Voice off'));
}
@@ -416,30 +409,34 @@ export class GevRealtimeController extends RealtimeFacade {
setStatus(status, detail) {
this.status = status;
this.emitSessionEvent({ type: 'state', state: status, detail });
this.ui.root.dataset.status = status;
if (status === 'error') this.ui.root.classList.remove('error-dismissed');
this.ui.update('root', {
status,
...(status === 'error' && { errorDismissed: false }),
});
this.updateVoiceButtonLabel();
const shownStatus = STATUS_STATES.has(status) ? status : 'idle';
this.ui.status.textContent = voiceStatusLabel(shownStatus);
const resolvedDetail =
status === 'listening' && this.pushToTalkMode
? this.pushToTalkKeyHeld
this.ui.update('status', { text: () => voiceStatusLabel(shownStatus) });
// Words are given as functions, so a locale change words them again.
const holdToTalk = status === 'listening' && this.pushToTalkMode;
const held = this.pushToTalkKeyHeld;
const resolvedDetail = () =>
holdToTalk
? held
? t('Release Space to send')
: t('Hold Space to talk')
: detail;
const primaryDetail =
const primaryDetail = () =>
status === 'error'
? t('VOICE UNAVAILABLE')
: resolvedDetail ||
: resolvedDetail() ||
(status === 'idle' ? t('VOICE STANDBY') : t('VOICE ACTIVE'));
this.ui.detail.textContent = primaryDetail;
this.ui.detail.title = primaryDetail;
if (this.ui.errorDetail) {
this.ui.errorDetail.textContent =
this.ui.update('detail', { text: primaryDetail, title: primaryDetail });
this.ui.update('error', {
text: () =>
status === 'error'
? resolvedDetail || t('Voice session could not be started.')
: '';
}
? resolvedDetail() || t('Voice session could not be started.')
: '',
});
if (status === 'idle' || status === 'connecting' || status === 'error') {
this.setVoiceSpeaker('idle');
}
+36 -40
View File
@@ -46,27 +46,25 @@ export class RealtimeCost {
*/
syncCostUi() {
const state = this.costTracker.state();
const syncWarning = () => {
const warning = this.ui?.costWarning;
const value = this.ui?.costValue;
if (!warning || !value) return;
warning.hidden = !['warn', 'cap', 'unknown'].includes(
value.dataset.level,
);
warning.textContent = warning.hidden ? '' : value.textContent;
warning.title = value.title;
warning.dataset.level = value.dataset.level;
// The panel's warning outside the closed panel mirrors the readout.
const showCost = (cost) => {
this.ui.update('cost', { hidden: false, ...cost });
const hidden = !['warn', 'cap', 'unknown'].includes(cost.level);
this.ui.update('costWarning', {
hidden,
text: hidden ? '' : cost.text,
title: cost.title,
level: cost.level,
});
};
const pendingTier = resolveVoiceModel(this.voiceTier).tier;
const isMini = pendingTier === 'mini';
if (this.ui?.tierButton) {
this.ui.tierButton.textContent = isMini ? t('MINI') : t('STD');
this.ui.tierButton.setAttribute(
'aria-pressed',
isMini ? 'true' : 'false',
);
const pendingId = resolveVoiceModel(pendingTier).id;
this.ui.tierButton.title =
if (!this.ui) return;
const pendingId = resolveVoiceModel(pendingTier).id;
this.ui.update('tier', {
text: isMini ? t('MINI') : t('STD'),
pressed: isMini,
title:
this.isActive() && state.modelId !== pendingId
? t('Next session: {pendingId} — this session stays on {modelId}', {
pendingId,
@@ -78,16 +76,15 @@ export class RealtimeCost {
pendingId,
target: isMini ? t('standard') : t('mini'),
},
);
}
if (this.ui?.costValue) {
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
if (liveAuth === 'oauth') {
const { responses, input, output, captions } = this.oauthUsage;
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = t('COST UNKNOWN');
this.ui.costValue.dataset.level = 'unknown';
this.ui.costValue.title =
),
});
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
if (liveAuth === 'oauth') {
const { responses, input, output, captions } = this.oauthUsage;
showCost({
text: t('COST UNKNOWN'),
level: 'unknown',
title:
t(
'ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.',
{ responses, input, output, captions },
@@ -101,14 +98,14 @@ export class RealtimeCost {
t(
'Usage is incomplete because a response was still in flight when the session ended.',
)
: '');
syncWarning();
return;
}
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = state.display;
this.ui.costValue.dataset.level = state.level;
this.ui.costValue.title =
: ''),
});
return;
}
showCost({
text: state.display,
level: state.level,
title:
t(
'Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.',
{
@@ -125,9 +122,8 @@ export class RealtimeCost {
t(
'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.',
)
: '');
syncWarning();
}
: ''),
});
}
/**
@@ -166,7 +162,7 @@ export class RealtimeCost {
});
}
this.syncCostUi();
if (this.isActive() && this.ui?.detail) {
if (this.isActive() && this.ui) {
// The tier as the interface names it; the stored value stays English.
const tier =
resolveVoiceModel(this.voiceTier).tier === 'mini'
@@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { RealtimeCost } from './realtimeCost.js';
import { createVoiceCostTracker } from './voiceCost.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
for (const locale of ['es', 'ru']) {
test(`API-key cost tooltip for an incomplete session has no English (${locale})`, () => {
@@ -18,12 +19,10 @@ for (const locale of ['es', 'ru']) {
cloudVoiceAuth: 'api-key',
sessionCloudVoiceAuth: 'api-key',
isActive: () => false,
ui: {
costValue: { hidden: true, dataset: {}, title: '', textContent: '' },
},
ui: createTestVoiceUi(),
};
RealtimeCost.prototype.syncCostUi.call(host);
const title = host.ui.costValue.title;
const title = host.ui.state.cost.title;
assert.ok(title.length > 0);
assert.doesNotMatch(
title,
@@ -45,10 +44,8 @@ test('API-key cost tooltip in English still ends with the tracker note', () => {
cloudVoiceAuth: 'api-key',
sessionCloudVoiceAuth: 'api-key',
isActive: () => false,
ui: {
costValue: { hidden: true, dataset: {}, title: '', textContent: '' },
},
ui: createTestVoiceUi(),
};
RealtimeCost.prototype.syncCostUi.call(host);
assert.ok(host.ui.costValue.title.endsWith(` ${costTracker.state().note}`));
assert.ok(host.ui.state.cost.title.endsWith(` ${costTracker.state().note}`));
});
+65 -33
View File
@@ -1,13 +1,32 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVoiceControl } from './control.js';
import { createApp, h, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { RealtimeCost } from './realtimeCost.js';
import { createVoiceCostTracker, resolveVoiceModel } from './voiceCost.js';
import { installPanelDom } from './panel/testDom.mjs';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import { useVoiceStore } from '../ui/stores/voice.js';
import VoiceControl from '../ui/views/panels/VoiceControl.vue';
const $ = (selector) => document.querySelector(selector);
// Shown unless it or an ancestor is hidden.
const visible = (selector) => {
const node = $(selector);
return Boolean(node) && !node.closest('[hidden]');
};
function harness(t) {
installPanelDom(t);
const ui = createVoiceControl({ panel: true });
document.body.replaceChildren();
const pinia = createPinia();
const ui = createTestVoiceUi({ panel: true, pinia });
const store = useVoiceStore(pinia);
// The dock renders the presented control; this renders only that.
const app = createApp({
render: () => store.view && h(VoiceControl, { view: store.view }),
}).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const stops = [];
const statuses = [];
const cost = new RealtimeCost({
@@ -39,62 +58,75 @@ function harness(t) {
return { cost, ui, stops, statuses, recordThreeDollars };
}
test('soft cost warning is visible outside closed Settings, while ordinary spend stays inside', (t) => {
test('soft cost warning is visible outside closed Settings, while ordinary spend stays inside', async (t) => {
const { cost, ui, recordThreeDollars } = harness(t);
cost.syncCostUi();
assert.equal(ui.voicePanel.hidden, true);
assert.equal(ui.costValue.checkVisibility(), false);
assert.equal(ui.costWarning.hidden, true);
assert.equal(ui.voicePanel.contains(ui.costWarning), false);
assert.equal(ui.root.querySelectorAll('#gev-voice-cost-value').length, 1);
await nextTick();
assert.equal(ui.state.voicePanel.hidden, true);
assert.equal($('#gev-voice-panel').hidden, true);
assert.equal(visible('#gev-voice-cost-value'), false);
assert.equal($('#gev-voice-cost-warning').hidden, true);
assert.equal(
$('#gev-voice-panel').contains($('#gev-voice-cost-warning')),
false,
);
assert.equal(
$('#gev-voice-control').querySelectorAll('#gev-voice-cost-value').length,
1,
);
recordThreeDollars();
assert.equal(ui.costWarning.checkVisibility(), true);
assert.equal(ui.costWarning.dataset.level, 'warn');
assert.equal(ui.costWarning.textContent, ui.costValue.textContent);
assert.equal(ui.costWarning.title, ui.costValue.title);
assert.match(ui.costWarning.title, /Warns at/);
await nextTick();
const warning = $('#gev-voice-cost-warning');
const value = $('#gev-voice-cost-value');
assert.equal(visible('#gev-voice-cost-warning'), true);
assert.equal(warning.dataset.level, 'warn');
assert.equal(warning.textContent, value.textContent);
assert.equal(warning.title, value.title);
assert.match(warning.title, /Warns at/);
assert.equal(
ui.voicePanel.hidden,
$('#gev-voice-panel').hidden,
true,
'warning never forces Settings open',
);
cost.prepareSession();
cost.syncCostUi();
await nextTick();
assert.equal(
ui.costWarning.hidden,
$('#gev-voice-cost-warning').hidden,
true,
'new session clears the old warning',
);
assert.equal(ui.costWarning.textContent, '');
assert.equal($('#gev-voice-cost-warning').textContent, '');
});
test('closed Settings cannot hide the hard cap or suppress its ordinary stop and status', (t) => {
const { ui, stops, statuses, recordThreeDollars } = harness(t);
test('closed Settings cannot hide the hard cap or suppress its ordinary stop and status', async (t) => {
const { stops, statuses, recordThreeDollars } = harness(t);
recordThreeDollars();
assert.equal(stops.length, 0);
recordThreeDollars();
assert.equal(ui.costWarning.checkVisibility(), true);
assert.equal(ui.costWarning.dataset.level, 'cap');
await nextTick();
assert.equal(visible('#gev-voice-cost-warning'), true);
assert.equal($('#gev-voice-cost-warning').dataset.level, 'cap');
assert.deepEqual(stops, [{ preserveStatus: true }]);
assert.equal(statuses.at(-1)[0], 'idle');
assert.match(statuses.at(-1)[1], /Session ended — cost cap/);
});
test('unknown OAuth cost remains visible outside Settings and clears on a fresh API meter', (t) => {
const { cost, ui } = harness(t);
test('unknown OAuth cost remains visible outside Settings and clears on a fresh API meter', async (t) => {
const { cost } = harness(t);
cost.sessionCloudVoiceAuth = 'oauth';
cost.syncCostUi();
assert.equal(ui.costWarning.checkVisibility(), true);
assert.equal(ui.costWarning.dataset.level, 'unknown');
assert.equal(ui.costWarning.textContent, 'COST UNKNOWN');
assert.match(
ui.costWarning.title,
/USD cost and the API spend cap are unavailable/,
);
assert.equal(ui.voicePanel.hidden, true);
await nextTick();
const warning = $('#gev-voice-cost-warning');
assert.equal(visible('#gev-voice-cost-warning'), true);
assert.equal(warning.dataset.level, 'unknown');
assert.equal(warning.textContent, 'COST UNKNOWN');
assert.match(warning.title, /USD cost and the API spend cap are unavailable/);
assert.equal($('#gev-voice-panel').hidden, true);
cost.sessionCloudVoiceAuth = 'api-key';
cost.syncCostUi();
assert.equal(ui.costWarning.hidden, true);
await nextTick();
assert.equal($('#gev-voice-cost-warning').hidden, true);
});
+8 -5
View File
@@ -4,10 +4,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../i18n/index.js';
import { RealtimeCost } from './realtimeCost.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
function harness() {
const button = { textContent: '', title: '', setAttribute() {} };
const ui = { tierButton: button, detail: {} };
const ui = createTestVoiceUi();
const statusCalls = [];
const cost = new RealtimeCost({
readUi: () => ui,
@@ -18,7 +18,7 @@ function harness() {
setStatus: (status, detail) => statusCalls.push(detail),
},
});
return { cost, button, statusCalls };
return { cost, ui, statusCalls };
}
const EXPECTED = {
@@ -41,10 +41,13 @@ for (const locale of Object.keys(EXPECTED)) {
setLocale(locale);
ctx.after(() => setLocale('en'));
for (const tier of ['mini', 'standard']) {
const { cost, button, statusCalls } = harness();
const { cost, ui, statusCalls } = harness();
assert.equal(cost.setVoiceTier(tier), tier, 'stored tier stays English');
assert.equal(statusCalls.at(-1), EXPECTED[locale][tier]);
assert.equal(button.textContent, tier === 'mini' ? t('MINI') : t('STD'));
assert.equal(
ui.state.tier.text,
tier === 'mini' ? t('MINI') : t('STD'),
);
}
});
}
+31 -38
View File
@@ -11,8 +11,8 @@ import {
resolveVoiceVisualizerSpeaker,
resolveVoiceControlHint,
gateVoiceVisualizerLevel,
resetVoiceVisualizerBars,
} from './realtimeInputPolicy.js';
import { VOICE_VISUALIZER_BARS } from './control.js';
import { shouldPauseRadioForVoice } from './realtimeProtocol.js';
/** Own physical push-to-talk gestures, microphone controls and audio meters. */
@@ -116,7 +116,7 @@ export class RealtimeInput {
// off if it is talking. Short taps never reach this point.
if (mayInterrupt) this.bargeIn?.();
this.beginPointerTurn?.('keydown');
this.ui.root.dataset.pushToTalk = 'held';
this.ui.update('root', { pushToTalk: 'held' });
this.setMicrophoneEnabled(true);
if (this.status === 'listening')
this.setStatus('listening', t('Release Space to send'));
@@ -124,7 +124,8 @@ export class RealtimeInput {
this.start({ pushToTalk: true });
// start() performs a controlled stop() before connecting. Restore the
// marker it clears so the UI reflects this still-held gesture.
if (this.pushToTalkKeyHeld) this.ui.root.dataset.pushToTalk = 'held';
if (this.pushToTalkKeyHeld)
this.ui.update('root', { pushToTalk: 'held' });
}
}, PUSH_TO_TALK_HOLD_DELAY_MS);
};
@@ -191,7 +192,7 @@ export class RealtimeInput {
this.releasePointerHold?.();
if (!this.pushToTalkKeyHeld) return;
this.pushToTalkKeyHeld = false;
delete this.ui.root.dataset.pushToTalk;
this.ui.update('root', { pushToTalk: null });
if (!this.pushToTalkMode) return;
this.setMicrophoneEnabled(false);
if (this.status === 'listening')
@@ -205,8 +206,7 @@ export class RealtimeInput {
* @returns {void}
*/
setMicrophoneEnabled(enabled) {
if (this.ui?.root)
this.ui.root.dataset.microphone = enabled ? 'active' : 'muted';
this.ui?.update('root', { microphone: enabled ? 'active' : 'muted' });
this.stream?.getAudioTracks?.().forEach((track) => {
track.enabled = Boolean(enabled);
});
@@ -220,10 +220,7 @@ export class RealtimeInput {
startVoiceVisualizer(stream) {
this.stopVoiceVisualizer();
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
const bars = Array.from(
this.ui.root.querySelectorAll('.gev-voice-visualizer span'),
);
if (!AudioContextClass || !stream || !bars.length) return;
if (!AudioContextClass || !stream || !this.ui) return;
try {
const context = new AudioContextClass();
this.visualizerAudioContext = context;
@@ -252,17 +249,18 @@ export class RealtimeInput {
},
);
if (!signal) {
resetVoiceVisualizerBars(bars);
this.restVoiceVisualizer();
this.visualizerFrame = requestAnimationFrame(render);
return;
}
signal.analyser.getByteFrequencyData(signal.data);
const binCount = signal.data.length;
bars.forEach((bar, index) => {
const start = Math.floor((index / bars.length) * binCount);
const count = VOICE_VISUALIZER_BARS;
const bars = Array.from({ length: count }, (_, index) => {
const start = Math.floor((index / count) * binCount);
const end = Math.max(
start + 1,
Math.floor(((index + 1) / bars.length) * binCount),
Math.floor(((index + 1) / count) * binCount),
);
let energy = 0;
for (let bin = start; bin < end; bin++) energy += signal.data[bin];
@@ -275,15 +273,12 @@ export class RealtimeInput {
gateVoiceVisualizerLevel(normalized, gate),
0.72,
);
bar.style.setProperty(
'--audio-level',
`${Math.round(5 + shaped * 29)}px`,
);
bar.style.setProperty(
'--audio-opacity',
`${(0.5 + shaped * 0.5).toFixed(2)}`,
);
return {
level: `${Math.round(5 + shaped * 29)}px`,
opacity: `${(0.5 + shaped * 0.5).toFixed(2)}`,
};
});
this.ui?.setBars(bars);
this.visualizerFrame = requestAnimationFrame(render);
};
render();
@@ -353,9 +348,12 @@ export class RealtimeInput {
this.visualizerAudioContext.close().catch(() => {});
this.visualizerAudioContext = null;
}
resetVoiceVisualizerBars(
this.ui?.root?.querySelectorAll('.gev-voice-visualizer span'),
);
this.restVoiceVisualizer();
}
/** Return the waveform to its CSS standby baseline. */
restVoiceVisualizer() {
this.ui?.setBars(null);
}
/**
@@ -363,15 +361,13 @@ export class RealtimeInput {
* @returns {void}
*/
updateVoiceButtonLabel() {
if (!this.ui.buttonLabel) return;
if (!this.ui) return;
// The mic button's caption; the first-run tip names it the same way.
this.ui.buttonLabel.textContent = t('MIC');
if (this.ui.helpDetail) {
this.ui.helpDetail.textContent = resolveVoiceControlHint(
this.pushToTalkMode,
this.pushToTalkKeyHeld,
);
}
this.ui.update('mic', { text: () => t('MIC') });
const { pushToTalkMode, pushToTalkKeyHeld } = this;
this.ui.update('help', {
text: () => resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld),
});
}
setVoiceSpeaker(speaker, { keepVisualizerSpeaker = false } = {}) {
@@ -382,7 +378,7 @@ export class RealtimeInput {
nextSpeaker,
keepVisualizerSpeaker,
);
this.ui.root.dataset.speaker = nextSpeaker;
this.ui.update('root', { speaker: nextSpeaker });
if (shouldPauseRadioForVoice({ speaker: nextSpeaker }))
this.pauseRadioForVoice();
}
@@ -392,10 +388,7 @@ export class RealtimeInput {
this.pushToTalkKeyHeld = false;
this.spaceKeyHeld = false;
this.resetPushToTalkGesture();
if (this.ui?.root) {
delete this.ui.root.dataset.pushToTalk;
delete this.ui.root.dataset.microphone;
}
this.ui?.update('root', { pushToTalk: null, microphone: null });
}
detachBindings() {
-13
View File
@@ -172,16 +172,3 @@ export function gateVoiceVisualizerLevel(level, threshold) {
if (cleanLevel <= cleanThreshold) return 0;
return (cleanLevel - cleanThreshold) / (1 - cleanThreshold);
}
/**
* Restores the CSS-owned standby baseline for every visualizer bar.
* @param {Iterable<HTMLElement>|null|undefined} bars
* @returns {void}
*/
export function resetVoiceVisualizerBars(bars) {
if (!bars) return;
for (const bar of bars) {
bar.style.removeProperty('--audio-level');
bar.style.removeProperty('--audio-opacity');
}
}
+2 -6
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import test from 'node:test';
import { GevRealtimeController } from './realtimeController.js';
import { PROGRESS_MAX_OUTPUT_TOKENS } from './realtimeTurns.js';
@@ -8,12 +9,7 @@ function controllerFixture({ runner = async () => ({ ok: true }) } = {}) {
const statuses = [];
const usage = [];
const controller = new GevRealtimeController({
ui: {
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
status: { textContent: '' },
detail: { textContent: '', title: '' },
errorDetail: { textContent: '' },
},
ui: createTestVoiceUi(),
runner,
debugSink: null,
});
+13 -8
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import test from 'node:test';
import { GevRealtimeController } from './realtimeController.js';
import { RealtimeInput } from './realtimeInput.js';
@@ -60,7 +61,7 @@ test('retained peer and channel callbacks cannot act after stop or enter a repla
async negotiate() { return 'answer'; },
},
debugSink: null,
ui: { root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, status: {}, detail: {} },
ui: createTestVoiceUi(),
});
await controller.start();
const old = peers[0];
@@ -106,7 +107,7 @@ test('an offer resolved after restart cannot change the replacement peer descrip
async negotiate() { return 'answer'; },
},
debugSink: null,
ui: { root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, status: {}, detail: {} },
ui: createTestVoiceUi(),
});
const firstStart = controller.start();
while (!finishOldOffer) await new Promise(resolve => setImmediate(resolve));
@@ -170,8 +171,9 @@ test('audio meter initialization failure releases its newly acquired AudioContex
close() { closes++; return Promise.resolve(); }
}
installGlobals(t, { window: { AudioContext } });
const ui = createTestVoiceUi();
const owner = new RealtimeInput({
readUi: () => ({ root: { querySelectorAll: () => [{ style: { setProperty() {}, removeProperty() {} } }] } }),
readUi: () => ui,
readStatus: () => 'listening', readStream: () => null, operations: {},
});
owner.startVoiceVisualizer({});
@@ -199,10 +201,13 @@ test('stopping one audio meter revokes retained frames without stopping another
requestAnimationFrame: callback => { frames.push(callback); return frames.length; },
cancelAnimationFrame() {},
});
const create = () => new RealtimeInput({
readUi: () => ({ root: { querySelectorAll: () => [{ style: { setProperty() {}, removeProperty() {} } }] } }),
readStatus: () => 'listening', readStream: () => null, operations: {},
});
const create = () => {
const ui = createTestVoiceUi();
return new RealtimeInput({
readUi: () => ui,
readStatus: () => 'listening', readStream: () => null, operations: {},
});
};
const first = create();
const second = create();
first.startVoiceVisualizer({}); second.startVoiceVisualizer({});
@@ -236,7 +241,7 @@ test('late action or viewport completion cannot resume a stopped or replacement
async negotiate() { return 'answer'; },
},
debugSink: null,
ui: { root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, status: {}, detail: {} },
ui: createTestVoiceUi(),
});
if (phase === 'viewport') controller._viewport.capture = () => new Promise(resolve => {
finish = () => resolve('data:image/jpeg;base64,abc');
+2 -11
View File
@@ -1,3 +1,4 @@
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { GevRealtimeController } from './realtimeController.js';
@@ -17,17 +18,7 @@ function fixture(t) {
corrections = [],
states = [];
const controller = new GevRealtimeController({
ui: {
root: {
dataset: {},
classList: { remove() {} },
querySelectorAll: () => [],
remove() {},
},
status: {},
detail: {},
errorDetail: {},
},
ui: createTestVoiceUi(),
runner: async () => ({ ok: false }),
debugSink: null,
onSessionEvent: (event) => states.push(event),
+12 -7
View File
@@ -22,13 +22,18 @@ export function createRealtimeSession({
sendMapEvent: (event) => controller.notifyMapEvent(event),
ignoreButtonClick: () => Boolean(controller.spaceKeyHeld),
bindControls() {
if (controller.ui.tierButton) {
controller.tierHandler = () => controller.toggleVoiceTier();
controller.ui.tierButton.addEventListener(
'click',
controller.tierHandler,
);
}
// Kept as the remover; a full stop releases it. The cost readout and
// tier toggle are worded again in a new locale.
const offTier = controller.ui.on('tier', () =>
controller.toggleVoiceTier(),
);
const offLocale = controller.ui.on('locale', () =>
controller.syncCostUi(),
);
controller.tierHandler = () => {
offTier();
offLocale();
};
controller.syncCostUi();
controller.bindPushToTalkShortcut();
},
+2 -9
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import test from 'node:test';
import { GevRealtimeController } from './realtimeController.js';
@@ -15,15 +16,7 @@ function fixture({ pointer = null, onUserSend = () => {} } = {}) {
backend: {},
debugSink: null,
pointer,
ui: {
root: {
dataset: {},
classList: { remove() {} },
querySelectorAll: () => [],
},
status: {},
detail: {},
},
ui: createTestVoiceUi(),
});
controller.dc = {
readyState: 'open',
+17 -40
View File
@@ -9,7 +9,7 @@ import { createVoiceSession } from './session.js';
import { createRealtimeSession } from './realtimeSession.js';
import { realtimeSessionEvent } from './realtimeEvents.js';
import { createVoiceCommands } from './sessionCommands.js';
import { describeUnsavedWork } from '../i18n/index.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
function deferred() {
let resolve;
@@ -242,26 +242,17 @@ test('common button and annotation bindings work with an alternate adapter and c
const previous = globalThis.window;
globalThis.window = {};
try {
const button = new EventTarget();
button.setAttribute = () => {};
let removed = 0;
let subscribed = null;
let calls = 0;
const events = [];
const ui = {
button,
root: {
dataset: {},
remove() {
removed++;
},
},
status: {},
detail: {},
tierButton: {},
costValue: {},
helpDetail: {},
const ui = createTestVoiceUi();
// Count removals but keep the control, so a click after cleanup still
// reaches any listener the binder failed to release.
ui.remove = () => {
removed++;
};
const click = () => ui.emit('button');
const lifetime = new AbortController();
const controls = createVoiceCommands({
runner: async () => {
@@ -294,31 +285,26 @@ test('common button and annotation bindings work with an alternate adapter and c
};
},
});
button.dispatchEvent(new Event('click'));
click();
await new Promise((done) => setTimeout(done, 0));
assert.equal(calls, 1);
assert.equal(ui.tierButton.hidden, true);
assert.equal(ui.costValue.hidden, true);
assert.equal(ui.helpDetail.textContent, 'Activate to toggle voice');
assert.equal(ui.status.textContent, 'LISTENING');
assert.equal(ui.detail.textContent, 'Ready');
// Switching language (a reload) asks first during a session.
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
assert.equal(ui.state.tier.hidden, true);
assert.equal(ui.state.cost.hidden, true);
assert.equal(ui.state.help.text, 'Activate to toggle voice');
assert.equal(ui.state.status.text, 'LISTENING');
assert.equal(ui.state.detail.text, 'Ready');
subscribed({ status: 'resolved' });
assert.deepEqual(events, [
{ type: 'map_annotation_outline', status: 'resolved' },
]);
button.dispatchEvent(new Event('click'));
click();
assert.equal(controls.state, 'idle');
assert.deepEqual(describeUnsavedWork(), []);
button.dispatchEvent(new Event('click'));
click();
await new Promise((done) => setTimeout(done, 0));
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
lifetime.abort();
assert.deepEqual(describeUnsavedWork(), [], 'disposal unregisters');
assert.equal(subscribed, null);
assert.equal(removed, 1);
button.dispatchEvent(new Event('click'));
click();
assert.equal(calls, 2);
} finally {
globalThis.window = previous;
@@ -598,16 +584,7 @@ for (const emitsIdle of [false, true]) {
test('actual default OpenAI adapter stop publishes one common idle', (t) => {
const events = [];
const ui = {
root: {
dataset: {},
querySelectorAll: () => [],
classList: { remove() {} },
remove() {},
},
status: {},
detail: {},
};
const ui = createTestVoiceUi();
const runner = async () => ({});
const session = createVoiceSession({
runner,
+51 -61
View File
@@ -3,14 +3,12 @@ import { createVoiceSession } from './session.js';
import { VoiceCardControls } from './voiceCard.js';
import { PointerReticle } from './pointerReticle.js';
import { VOICE_PROVIDERS } from './providerCapabilities.js';
import { msg, registerUnsavedWork, t } from '../i18n/index.js';
import { t } from '../i18n/index.js';
function createDefaultReticle(pointer) {
const element = pointer?.element;
const doc = element?.ownerDocument;
if (!doc) return null;
if (!element?.ownerDocument) return null;
return new PointerReticle({
doc,
rectOf: () => element.getBoundingClientRect?.() || null,
});
}
@@ -40,10 +38,7 @@ export function createVoiceCommands({
nativePanelProvider = null,
}) {
if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true });
const ui = createControl({
reset: true,
...(panelHost ? { panel: true } : {}),
});
const ui = createControl(panelHost ? { panel: true } : {});
let actionObserver;
let panelMount;
const session = createVoiceSession({
@@ -67,40 +62,38 @@ export function createVoiceCommands({
});
const adapter = session.adapter;
const capabilities = adapter.capabilities || {};
const providerHandler = () => onProviderChange?.(ui.providerSelect.value);
if (ui.providerSelect && onProviderChange) {
if (supportedProviders && ui.providerSelect.replaceChildren) {
const doc = ui.providerSelect.ownerDocument;
ui.providerSelect.replaceChildren(
...supportedProviders.map((name) => {
const option = doc.createElement('option');
option.value = name;
option.textContent = VOICE_PROVIDERS[name].label;
return option;
}),
);
}
ui.providerSelect.value = provider;
ui.providerSelect.title = [
t('Changing provider reconnects voice if it is on.'),
capabilities.advisoryLimit,
]
.filter(Boolean)
.join(' ');
ui.providerSelect.addEventListener('change', providerHandler);
ui.root.dataset.provider = provider;
const offProvider = onProviderChange
? ui.on('provider-change', (value) => onProviderChange(value))
: null;
if (onProviderChange) {
ui.update('provider', {
...(supportedProviders && {
options: supportedProviders.map((name) => ({
value: name,
label: VOICE_PROVIDERS[name].label,
})),
}),
value: provider,
title: () =>
[
t('Changing provider reconnects voice if it is on.'),
capabilities.advisoryLimit,
]
.filter(Boolean)
.join(' '),
});
ui.update('root', { provider });
}
const hasProviderChoice = Boolean(
onProviderChange && supportedProviders?.length > 1,
);
if (ui.providerField) ui.providerField.hidden = !hasProviderChoice;
ui.root.dataset.providerChoice = String(hasProviderChoice);
if (ui.tierButton) ui.tierButton.hidden = !capabilities.costControls;
if (ui.costValue) ui.costValue.hidden = !capabilities.costControls;
ui.update('provider', { hidden: !hasProviderChoice });
ui.update('root', { providerChoice: String(hasProviderChoice) });
ui.update('tier', { hidden: !capabilities.costControls });
ui.update('cost', { hidden: !capabilities.costControls });
if (!capabilities.pushToTalk) {
ui.button.setAttribute('aria-label', t('Toggle voice control'));
if (ui.helpDetail)
ui.helpDetail.textContent = t('Activate to toggle voice');
ui.update('button', { label: () => t('Toggle voice control') });
ui.update('help', { text: () => t('Activate to toggle voice') });
}
// Retain the existing controller's inspection surface for browser tools.
const controls = adapter.controller || session;
@@ -108,31 +101,33 @@ export function createVoiceCommands({
controls.ui = ui;
const updateStatus = session.subscribe((event) => {
if (event.type !== 'state') return;
ui.root.dataset.status = event.state;
ui.update('root', {
status: event.state,
...(event.state === 'error' && { errorDismissed: false }),
});
// States are machine values; only the label is translated.
ui.status.textContent = voiceStatusLabel(event.state);
ui.update('status', { text: () => voiceStatusLabel(event.state) });
// The portable session reports 'Voice off' in English: show the idle
// label instead. Other details are provider messages, shown as they come.
const idle = event.state === 'idle';
const detail = event.detail === 'Voice off' ? '' : event.detail;
ui.detail.textContent =
detail || (idle ? t('Voice off') : t('Voice active'));
ui.button.setAttribute('aria-pressed', String(session.isActive()));
if (ui.errorDetail)
ui.errorDetail.textContent =
ui.update('detail', {
text: () => detail || (idle ? t('Voice off') : t('Voice active')),
});
ui.update('button', { pressed: session.isActive() });
ui.update('error', {
text: () =>
event.state === 'error'
? event.detail || t('Voice could not be started.')
: '';
if (event.state === 'error') ui.root.classList?.remove('error-dismissed');
: '',
});
});
// Captions, plan and result card nested in the control; it only reads
// provider-neutral session events.
const card = ui.card
? createCard({
root: ui.root,
...(panelHost ? { onRender: () => panelMount?.panel.sync() } : {}),
})
: null;
const card = createCard({
ui,
...(panelHost ? { onRender: () => panelMount?.panel.sync() } : {}),
});
panelMount = panelHost?.mount({
ui,
card,
@@ -175,10 +170,6 @@ export function createVoiceCommands({
card?.outline(event);
session.sendMapEvent({ type: 'map_annotation_outline', ...event });
});
// A reload (the language switch) ends the conversation: ask first.
const unregisterUnsavedWork = registerUnsavedWork(() =>
session.isActive() ? msg('the active voice session') : null,
);
const buttonHandler = () => {
if (adapter.ignoreButtonClick?.()) return;
if (session.isActive()) {
@@ -187,14 +178,13 @@ export function createVoiceCommands({
panelMount?.closeAfterStop(ticket);
} else void session.start({ pushToTalk: false });
};
ui.button.addEventListener('click', buttonHandler);
const offButton = ui.on('button', buttonHandler);
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
ui.button.removeEventListener('click', buttonHandler);
ui.providerSelect?.removeEventListener('change', providerHandler);
unregisterUnsavedWork();
offButton();
offProvider?.();
annotationUnsubscribe?.();
updateStatus();
updateCard?.();
@@ -206,7 +196,7 @@ export function createVoiceCommands({
reticle?.destroy();
card?.destroy();
if (disposeRunner) runner.dispose?.();
ui.root.remove();
ui.remove();
};
session.signal.addEventListener('abort', cleanup, { once: true });
if (session.disposed) cleanup();
+3 -3
View File
@@ -74,9 +74,9 @@ function listPhrase(items) {
}
function feetRounded(meters) {
const value = Number(meters);
return Number.isFinite(value)
? Math.round((value * 3.28084) / 100) * 100
// A missing altitude is unknown, not sea level: Number(null) is 0.
return Number.isFinite(meters)
? Math.round((meters * 3.28084) / 100) * 100
: null;
}
+28
View File
@@ -218,6 +218,34 @@ test('nearest-aircraft results name the aircraft, distance and altitude briefly'
assert.equal(stale.say, 'Selected AAL1, 3.3 km from Austin, stale.');
});
test('nearest-aircraft results leave out an altitude the feed did not report', () => {
// gevActions sends `altitudeM: null` for an airborne military contact
// without a barometric altitude; that is unknown, not sea level.
const unknown = envelope('select_nearest_aircraft', {
ok: true,
location: 'Ramstein',
layerId: 'military',
feed: { state: 'nominal' },
aircraft: { callsign: 'RCH123', altitudeM: null, distanceKm: 8.2 },
});
assert.equal(unknown.say, 'Selected RCH123, 8.2 km from Ramstein.');
assert.deepEqual(unknown.display.lines, ['8.2\u00a0km from Ramstein']);
// A reported sea-level altitude is still spoken.
const seaLevel = envelope('select_nearest_aircraft', {
ok: true,
location: 'Ramstein',
layerId: 'military',
feed: { state: 'nominal' },
aircraft: { callsign: 'RCH123', altitudeM: 0, distanceKm: 8.2 },
});
assert.equal(seaLevel.say, 'Selected RCH123, 8.2 km from Ramstein, 0 feet.');
assert.deepEqual(seaLevel.display.lines, [
'8.2\u00a0km from Ramstein',
'Altitude 0 feet',
]);
});
test('frame_overhead keeps the essential scope audible and lists referents', () => {
const framed = envelope('frame_overhead', {
ok: true,
+24 -129
View File
@@ -7,79 +7,31 @@ import {
/** Hide an idle card this long after its last update. */
const VOICE_CARD_IDLE_MS = 14000;
const ELEMENT_IDS = Object.freeze({
card: 'gev-voice-card',
phase: 'gev-voice-card-phase',
dismiss: 'gev-voice-card-dismiss',
user: 'gev-voice-card-user',
userText: 'gev-voice-card-user-text',
userWho: 'gev-voice-card-user-who',
pointer: 'gev-voice-card-pointer',
pointerKind: 'gev-voice-card-pointer-kind',
pointerText: 'gev-voice-card-pointer-text',
assistant: 'gev-voice-card-assistant',
assistantText: 'gev-voice-card-assistant-text',
plan: 'gev-voice-card-plan',
result: 'gev-voice-card-result',
resultTitle: 'gev-voice-card-result-title',
chips: 'gev-voice-card-chips',
lines: 'gev-voice-card-lines',
referents: 'gev-voice-card-referents',
notes: 'gev-voice-card-notes',
noteList: 'gev-voice-card-note-list',
live: 'gev-voice-card-live',
});
function setText(element, text) {
if (element && element.textContent !== text) element.textContent = text;
}
function setHidden(element, hidden) {
if (element && element.hidden !== hidden) element.hidden = hidden;
}
function fillList(list, items, render) {
if (!list) return;
const doc = list.ownerDocument;
list.replaceChildren(
...items.map((item) => {
const node = doc.createElement('li');
render(node, item, doc);
return node;
}),
);
}
/**
* Voice card controls, nested in the voice control's markup (`control.js`)
* Voice card controls. The card is part of the voice control (`control.js`),
* beside its help and error trays, so Clean-UI and recording mode hide it with
* the control. Receives provider-neutral session events through `handle` and
* annotation outline outcomes through `outline`; owns only its listeners and
* idle timer. All text is set through textContent. Captions update silently;
* the polite live region carries final replies and result titles only.
* annotation outline outcomes through `outline`, and presents the card's view
* on the control; owns only its listener and idle timer. Captions update
* silently; the polite live region carries final replies and result titles
* only.
*/
export class VoiceCardControls {
/**
* @param {object} options
* @param {HTMLElement} options.root Voice control root containing the card.
* @param {object} options.ui The voice control presenter.
* @param {number} [options.idleMs]
* @param {Function} [options.setTimer]
* @param {Function} [options.clearTimer]
*/
constructor({
root,
ui,
idleMs = VOICE_CARD_IDLE_MS,
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (id) => clearTimeout(id),
onRender,
}) {
Object.assign(this, { idleMs, setTimer, clearTimer, onRender });
this.elements = Object.fromEntries(
Object.entries(ELEMENT_IDS).map(([key, id]) => [
key,
root?.querySelector?.(`#${id}`) || null,
]),
);
Object.assign(this, { ui, idleMs, setTimer, clearTimer, onRender });
this.state = initialVoiceCardState();
this.enabled = true;
this.presented = true;
@@ -87,19 +39,20 @@ export class VoiceCardControls {
this.consumedPresentationAnnouncement = -1;
this.hideTimer = null;
this.destroyed = false;
this.listeners = new AbortController();
if (!this.elements.card) return;
this.elements.dismiss?.addEventListener(
'click',
() => this.handle({ type: 'dismiss' }),
{ signal: this.listeners.signal },
if (!ui) return;
// The card's labels are worded again in a new locale.
this.offLocale = ui.on('locale', () => {
if (!this.destroyed) this.render();
});
this.offDismiss = ui.on('card-dismiss', () =>
this.handle({ type: 'dismiss' }),
);
this.render();
}
/** Apply one session event (or `outline`/`dismiss`). */
handle(event) {
if (this.destroyed || !this.elements.card) return;
if (this.destroyed || !this.ui) return;
const next = reduceVoiceCard(this.state, event);
if (next === this.state) return;
if (
@@ -123,7 +76,7 @@ export class VoiceCardControls {
/** A local submission allows future output after dismissal; keep its content. */
rearm() {
if (this.destroyed || !this.elements.card) return;
if (this.destroyed || !this.ui) return;
this.state = { ...this.state, dismissed: false };
}
@@ -134,15 +87,14 @@ export class VoiceCardControls {
/** Change presentation only; session events and the idle deadline continue. */
setEnabled(enabled) {
if (this.destroyed || !this.elements.card) return;
if (this.destroyed || !this.ui) return;
this.enabled = Boolean(enabled);
this.render();
}
/** View selection changes presentation without changing the saved preference. */
setPresented(presented) {
if (this.destroyed || !this.elements.card || this.presented === presented)
return;
if (this.destroyed || !this.ui || this.presented === presented) return;
if (!presented)
this.consumedPresentationAnnouncement = this.announcementRevision;
this.presented = presented;
@@ -155,7 +107,7 @@ export class VoiceCardControls {
this.hideTimer = null;
if (this.destroyed) return;
// Keep the card while the pointer or focus is inside it.
if (this.elements.card.matches?.(':hover, :focus-within')) {
if (this.ui.element('card')?.matches?.(':hover, :focus-within')) {
this.scheduleIdle();
return;
}
@@ -170,65 +122,7 @@ export class VoiceCardControls {
}
render() {
const el = this.elements;
const view = this.snapshot();
setHidden(el.card, !view.visible);
el.card.dataset.busy = String(view.busy);
setText(el.phase, view.phase);
setHidden(el.user, !view.user.visible);
setText(el.userText, view.user.text);
setText(el.userWho, view.user.label);
setHidden(el.pointer, !view.pointer.visible);
setText(el.pointerKind, view.pointer.kind);
setText(el.pointerText, view.pointer.text);
setHidden(el.assistant, !view.assistant.visible);
setText(el.assistantText, view.assistant.text);
setHidden(el.plan, view.steps.length === 0);
fillList(el.plan, view.steps, (node, step, doc) => {
node.className = 'gev-voice-card-step';
node.dataset.status = step.status;
const label = doc.createElement('span');
label.className = 'gev-voice-card-step-label';
label.textContent = step.label;
const status = doc.createElement('span');
status.className = 'gev-voice-card-sr';
status.textContent = step.statusText ? `, ${step.statusText}` : '';
node.append(label, status);
if (step.detail) {
const detail = doc.createElement('span');
detail.className = 'gev-voice-card-step-detail';
detail.textContent = step.detail;
node.append(detail);
}
});
setHidden(el.result, !view.result.visible);
setText(el.resultTitle, view.result.title);
setHidden(el.chips, view.result.chips.length === 0);
if (el.chips) {
const doc = el.chips.ownerDocument;
el.chips.replaceChildren(
...view.result.chips.map((text) => {
const chip = doc.createElement('span');
chip.className = 'gev-voice-card-chip';
chip.dataset.tone = text;
chip.textContent = text;
return chip;
}),
);
}
fillList(el.lines, view.result.lines, (node, line) => {
node.textContent = line;
});
setHidden(el.referents, view.result.referents.length === 0);
fillList(el.referents, view.result.referents, (node, ref) => {
node.value = ref.n;
node.textContent = ref.label;
});
setHidden(el.notes, view.result.notes.length === 0);
fillList(el.noteList, view.result.notes, (node, note) => {
node.textContent = note;
});
setText(el.live, view.announce);
this.ui.replace('card', this.snapshot());
this.onRender?.();
}
@@ -246,11 +140,12 @@ export class VoiceCardControls {
: { ...view, visible: false, announce: '' };
}
/** Idempotent: removes listeners and the idle timer; markup stays owned by the control. */
/** Idempotent: removes the listener and the idle timer; the control stays. */
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.cancelIdle();
this.listeners.abort();
this.offDismiss?.();
this.offLocale?.();
}
}
+85 -119
View File
@@ -1,6 +1,9 @@
import '../testSupport/registerDom.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { nextTick } from 'vue';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import { mountVoiceView } from '../testSupport/voiceView.mjs';
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import { VoiceCardControls } from './voiceCard.js';
import { getLocale, setLocale } from '../i18n/index.js';
@@ -10,10 +13,6 @@ import {
voiceCardView,
} from './voiceCardPresentation.js';
const controlSource = readFileSync(
new URL('./control.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../../style.css', import.meta.url));
function fold(events) {
@@ -157,33 +156,46 @@ test('presentation: silent lookups, dismissal and session end', () => {
);
});
test('markup: the card nests inside the voice control with every element and a polite live region', () => {
const root = controlSource.indexOf("root.id = 'gev-voice-control'");
const card = controlSource.indexOf('id="gev-voice-card"');
assert.ok(
root >= 0 && card > root,
'the card is part of the voice control markup',
);
for (const id of [
'gev-voice-card-phase',
'gev-voice-card-dismiss',
'gev-voice-card-user-text',
'gev-voice-card-user-who',
'gev-voice-card-assistant-text',
'gev-voice-card-plan',
'gev-voice-card-result-title',
'gev-voice-card-chips',
'gev-voice-card-lines',
'gev-voice-card-referents',
'gev-voice-card-note-list',
]) {
assert.match(controlSource, new RegExp(`id="${id}"`), `${id} is missing`);
test('markup: the card nests inside the voice control with every element and a polite live region', async (t) => {
const { pinia } = mountVoiceView(t);
for (const panel of [false, true]) {
const ui = createTestVoiceUi({ panel, pinia });
await nextTick();
const card = document.getElementById('gev-voice-card');
assert.equal(
document.getElementById('gev-voice-control').contains(card),
true,
'the card is part of the voice control',
);
assert.equal(document.querySelectorAll('#gev-voice-card').length, 1);
if (panel)
assert.equal(card.parentElement.id, 'gev-voice-preview-panel');
for (const id of [
'gev-voice-card-phase',
'gev-voice-card-dismiss',
'gev-voice-card-user-text',
'gev-voice-card-user-who',
'gev-voice-card-assistant-text',
'gev-voice-card-plan',
'gev-voice-card-result-title',
'gev-voice-card-chips',
'gev-voice-card-lines',
'gev-voice-card-referents',
'gev-voice-card-note-list',
])
assert.equal(card.contains(document.getElementById(id)), true, id);
assert.equal(
document.getElementById('gev-voice-card-live').getAttribute('aria-live'),
'polite',
);
assert.equal(
document
.getElementById('gev-voice-card-dismiss')
.getAttribute('aria-label'),
'Dismiss voice card',
);
ui.remove();
}
assert.match(
controlSource,
/id="gev-voice-card-live"[^>]*aria-live="polite"/,
);
assert.match(controlSource, /aria-label="\$\{t\('Dismiss voice card'\)\}"/);
assert.match(css, /#gev-voice-control \.gev-voice-card \{/);
assert.match(
css,
@@ -197,60 +209,31 @@ test('markup: the card nests inside the voice control with every element and a p
);
});
function fakeRoot() {
const make = (id) => {
const listeners = new Map();
const node = {
id,
hidden: false,
textContent: '',
dataset: {},
children: [],
ownerDocument: null,
addEventListener(type, handler, options) {
listeners.set(type, handler);
options?.signal?.addEventListener('abort', () =>
listeners.delete(type),
);
},
removeEventListener(type, handler) {
if (listeners.get(type) === handler) listeners.delete(type);
},
fire: (type) => listeners.get(type)?.({}),
listenerCount: () => listeners.size,
replaceChildren(...nodes) {
this.children = nodes;
},
append(...nodes) {
this.children.push(...nodes);
},
matches: () => false,
// A real control whose listeners are counted, so a test sees teardown
// release them.
function countedUi(options) {
const ui = createTestVoiceUi(options);
const on = ui.on;
const live = new Set();
ui.on = (name, listener) => {
const entry = { name };
live.add(entry);
const off = on(name, listener);
return () => {
live.delete(entry);
off();
};
return node;
};
const nodes = new Map();
const doc = { createElement: (tag) => ({ ...make(tag), tag }) };
const root = {
...make('gev-voice-control'),
querySelector(selector) {
const id = selector.slice(1);
if (!nodes.has(id)) {
const node = make(id);
node.ownerDocument = doc;
nodes.set(id, node);
}
return nodes.get(id);
},
};
return { root, nodes };
return { ui, listenerCount: () => live.size };
}
test('controls: render, idle hide, dismiss and idempotent teardown', () => {
const { root, nodes } = fakeRoot();
const { ui, listenerCount } = countedUi();
const shown = () => ui.state.card.visible;
const timers = new Map();
let nextId = 1;
const card = new VoiceCardControls({
root,
ui,
idleMs: 1000,
setTimer: (fn) => {
const id = nextId++;
@@ -262,7 +245,7 @@ test('controls: render, idle hide, dismiss and idempotent teardown', () => {
},
clearTimer: (id) => timers.delete(id),
});
assert.equal(nodes.get('gev-voice-card').hidden, true, 'starts hidden');
assert.equal(shown(), false, 'starts hidden');
card.handle({ type: 'interruption' });
card.handle({
type: 'action-call',
@@ -277,33 +260,26 @@ test('controls: render, idle hide, dismiss and idempotent teardown', () => {
display: { title: 'Tokyo', lines: [], chips: [{ label: 'stale' }] },
},
});
assert.equal(nodes.get('gev-voice-card').hidden, false);
assert.equal(nodes.get('gev-voice-card-result-title').textContent, 'Tokyo');
assert.equal(nodes.get('gev-voice-card-plan').children.length, 1);
assert.equal(
nodes.get('gev-voice-card-chips').children[0].textContent,
'stale',
);
assert.equal(shown(), true);
assert.equal(ui.state.card.result.title, 'Tokyo');
assert.equal(ui.state.card.steps.length, 1);
assert.equal(ui.state.card.result.chips[0], 'stale');
assert.equal(timers.size, 1, 'one idle timer at a time');
[...timers.values()][0]();
assert.equal(nodes.get('gev-voice-card').hidden, true, 'hides when idle');
assert.equal(shown(), false, 'hides when idle');
card.handle({ type: 'interruption' });
nodes.get('gev-voice-card-dismiss').fire('click');
assert.equal(nodes.get('gev-voice-card').hidden, true, 'dismiss hides it');
ui.emit('card-dismiss');
assert.equal(shown(), false, 'dismiss hides it');
card.destroy();
card.destroy();
assert.equal(timers.size, 0);
assert.equal(nodes.get('gev-voice-card-dismiss').listenerCount(), 0);
assert.equal(listenerCount(), 0);
card.handle({ type: 'interruption' });
assert.equal(
nodes.get('gev-voice-card').hidden,
true,
'a destroyed card ignores late events',
);
assert.equal(shown(), false, 'a destroyed card ignores late events');
});
test('controls: a control without card markup is inert', () => {
const card = new VoiceCardControls({ root: { querySelector: () => null } });
test('controls: card controls without a voice control are inert', () => {
const card = new VoiceCardControls({});
assert.doesNotThrow(() => card.handle({ type: 'interruption' }));
card.destroy();
});
@@ -433,12 +409,12 @@ test('presentation: stale and unmatched events never reopen the card', () => {
});
test('presentation controls preserve real reducer content and the existing 14-second idle deadline', () => {
const { root, nodes } = fakeRoot();
const ui = createTestVoiceUi();
const timers = new Map();
let next = 0,
renders = 0;
const card = new VoiceCardControls({
root,
ui,
setTimer(fn, ms) {
const id = ++next;
timers.set(id, { fn, ms });
@@ -464,7 +440,7 @@ test('presentation controls preserve real reducer content and the existing 14-se
assert.equal(timers.get(timer).ms, 14000);
card.setPresented(false);
assert.equal(card.snapshot().visible, false);
assert.equal(nodes.get('gev-voice-card-live').textContent, '');
assert.equal(ui.state.card.announce, '');
card.setPresented(true);
assert.equal(card.snapshot().visible, true);
assert.equal(card.snapshot().assistant.text, 'A current answer');
@@ -507,9 +483,9 @@ test('presentation controls preserve real reducer content and the existing 14-se
});
test('a new hidden reply with identical words is still a fresh announcement', () => {
const { root } = fakeRoot();
const ui = createTestVoiceUi();
const card = new VoiceCardControls({
root,
ui,
setTimer: () => 1,
clearTimer() {},
});
@@ -538,9 +514,9 @@ test('additive presentation gates retain the current localized reducer labels',
const previous = getLocale();
try {
setLocale('es');
const { root } = fakeRoot();
const ui = createTestVoiceUi();
const card = new VoiceCardControls({
root,
ui,
setTimer: () => 1,
clearTimer() {},
});
@@ -554,15 +530,15 @@ test('additive presentation gates retain the current localized reducer labels',
assert.equal(card.snapshot().phase, 'EN CURSO');
const steps = card.state.steps;
const result = card.state.result;
const who = root.querySelector('#gev-voice-card-user-who');
const who = () => ui.state.card.user.label;
card.handle({ type: 'transcript', role: 'user', text: 'Same words', attribution: 'unverified' });
assert.equal(card.state.userAttribution, 'unverified');
assert.equal(card.snapshot().user.label, 'Habla reciente');
assert.equal(who.textContent, 'Habla reciente');
assert.equal(who(), 'Habla reciente');
card.handle({ type: 'transcript', role: 'user', text: 'Same words' });
assert.equal(card.state.userAttribution, null);
assert.equal(card.snapshot().user.label, 'TÚ');
assert.equal(who.textContent, 'TÚ');
assert.equal(who(), 'TÚ');
assert.equal(card.state.steps, steps);
assert.equal(card.state.result, result);
@@ -582,24 +558,14 @@ test('manual panel and preference compose with the actual card without changing
const { createManualVoicePanel } = await import('./panel/manualPanel.js');
const { createVoicePreferences, bindPreferenceControls } =
await import('./panel/preferences.js');
const { root } = fakeRoot();
const ui = createTestVoiceUi({ panel: true });
let panel;
const card = new VoiceCardControls({
root,
ui,
setTimer: () => 1,
clearTimer() {},
onRender: () => panel?.sync(),
});
const ui = {
root,
card: root.querySelector('#gev-voice-card'),
voicePanel: root.querySelector('#panel'),
previewPanel: root.querySelector('#preview-panel'),
voiceSettingsPanel: root.querySelector('#settings-panel'),
previewEmpty: root.querySelector('#empty'),
voicePreviewInput: root.querySelector('#preview-input'),
voicePreviewNote: root.querySelector('#preview-note'),
};
const preference = createVoicePreferences({ storage: null });
const fields = bindPreferenceControls({
ui,
@@ -639,7 +605,7 @@ test('manual panel and preference compose with the actual card without changing
assert.equal(card.snapshot().visible, true);
preference.setPreviewEnabled(false);
assert.equal(card.snapshot().visible, false);
assert.equal(ui.previewEmpty.textContent, 'Desactivada.');
assert.equal(ui.state.previewEmpty.text, 'Desactivada.');
panel.select('settings');
panel.select('preview');
assert.equal(preference.snapshot().previewEnabled, false);
+132
View File
@@ -0,0 +1,132 @@
// src/voice/voiceControlView.test.mjs — the voice control renders the card's
// lists and the waveform from its presenter, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
import { mountVoiceView } from '../testSupport/voiceView.mjs';
import { useVoiceStore } from '../ui/stores/voice.js';
import { createManualVoicePanel } from './panel/manualPanel.js';
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
test('the card renders its plan, chips, lines, referents and notes', async (t) => {
const { pinia, settle } = mountVoiceView(t);
const ui = createTestVoiceUi({ pinia });
ui.replace('card', {
...ui.state.card,
visible: true,
busy: true,
phase: 'WORKING',
steps: [
{
key: 'a',
label: 'Fly to Austin',
status: 'done',
statusText: 'done',
detail: '',
},
{
key: 'b',
label: 'Show traffic',
status: 'running',
statusText: '',
detail: 'Loading',
},
],
result: {
visible: true,
title: 'Austin',
chips: ['live'],
lines: ['Line one', 'Line two'],
referents: [{ n: 3, label: 'Tower' }],
notes: ['Source: Example'],
},
announce: 'Austin',
});
await settle();
assert.equal($('#gev-voice-card').hidden, false);
assert.equal($('#gev-voice-card').dataset.busy, 'true');
assert.equal($('#gev-voice-card-phase').textContent, 'WORKING');
const steps = $$('#gev-voice-card-plan .gev-voice-card-step');
assert.deepEqual(
steps.map((step) => [step.dataset.status, step.textContent]),
[
['done', 'Fly to Austin, done'],
['running', 'Show trafficLoading'],
],
);
assert.equal($('#gev-voice-card-plan').hidden, false);
const chip = $('#gev-voice-card-chips .gev-voice-card-chip');
assert.equal(chip.dataset.tone, 'live');
assert.equal(chip.textContent, 'live');
assert.deepEqual(
$$('#gev-voice-card-lines li').map((li) => li.textContent),
['Line one', 'Line two'],
);
const referent = $('#gev-voice-card-referents li');
assert.equal(referent.value, 3);
assert.equal(referent.textContent, 'Tower');
assert.equal($('#gev-voice-card-notes').hidden, false);
assert.equal($('#gev-voice-card-live').textContent, 'Austin');
ui.replace('card', {
...ui.state.card,
steps: [],
result: { ...ui.state.card.result, chips: [], referents: [], notes: [] },
});
await settle();
assert.equal($('#gev-voice-card-plan').hidden, true);
assert.equal($('#gev-voice-card-chips').hidden, true);
assert.equal($('#gev-voice-card-referents').hidden, true);
assert.equal($('#gev-voice-card-notes').hidden, true);
});
test('the waveform shows bars as they change and rests to the CSS baseline', async (t) => {
const { pinia, settle } = mountVoiceView(t);
const ui = createTestVoiceUi({ pinia });
await settle();
const bars = () => $$('.gev-voice-visualizer span');
assert.equal(bars().length, 15);
assert.equal(bars()[4].style.getPropertyValue('--bar'), '4');
assert.equal(bars()[4].style.getPropertyValue('--audio-level'), '');
const frame = Array.from({ length: 15 }, (_, i) => ({
level: `${5 + i}px`,
opacity: '0.80',
}));
ui.setBars(frame);
await settle();
assert.equal(bars()[4].style.getPropertyValue('--audio-level'), '9px');
assert.equal(bars()[4].style.getPropertyValue('--audio-opacity'), '0.80');
// An identical frame publishes nothing, and bars never replace the
// control's view, so the rest of the dock does not re-render for them.
const store = useVoiceStore(pinia);
const view = store.view;
const published = store.bars;
ui.setBars(frame.map((bar) => ({ ...bar })));
assert.equal(store.bars === published, true);
assert.equal(store.view === view, true);
ui.setBars(null);
await settle();
assert.equal(bars()[4].style.getPropertyValue('--audio-level'), '');
assert.equal(bars()[4].style.getPropertyValue('--bar'), '4');
});
test('Preview is not reported visible while the error tray replaces the card', async (t) => {
const { pinia, settle } = mountVoiceView(t);
const ui = createTestVoiceUi({ panel: true, pinia });
ui.replace('card', { ...ui.state.card, visible: true });
const panel = createManualVoicePanel({
ui,
card: { setPresented() {} },
getPreviewEnabled: () => true,
copy: { previewEmpty: 'Empty', previewDisabled: 'Off' },
});
t.after(() => panel.destroy());
panel.select('preview');
await settle();
assert.equal(panel.snapshot().previewVisible, true);
ui.update('root', { status: 'error' });
assert.equal(panel.snapshot().previewVisible, false);
});