mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge main into the Utilities panel, rebuilt on the Vue panel rail
main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.
- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
item (handyman). LayerPanel renders the Directions rows into its list with
LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
which render what the draw tool publishes to the display store and call
its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
drawMarkList.js is gone. A row's fly-to is an immediate navigation like
Import's ('drawing' gets its Cockpit refusal), the name field keeps a
draft through v-field-value, and the colours are one radio group with a
single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
buttons.
- The catalogs take main's languages with this branch's strings, Arabic
included, and the tests follow the views to their new homes.
This commit is contained in:
commit
4ebb287d36
699 files changed
+46673
-20476
No files matched your search
@@ -47,7 +47,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// is consolidated without changing either feature's arguments.
|
||||
// Snow leaves the set_visual_style enum.
|
||||
// The Operator HUD layout leaves set_hud's layout enum.
|
||||
'f47a7f78f13b1f79978fa524fad2618e32bd813e1a5dfd746bf35d29909fc91b',
|
||||
// The layer alias hint leaves out aliases the layer id already spells.
|
||||
'e65812db60a2702cebb496fca257cb04a28f1bbbf250cb9ab65a3db33005e0c8',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+296
-170
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -3148,13 +3156,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) {
|
||||
|
||||
@@ -3978,3 +3978,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
@@ -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') },
|
||||
});
|
||||
@@ -596,12 +594,7 @@ test('replacement input invalidates an in-flight Radio preflight immediately', (
|
||||
test('direct Radio pause or stop cancels a pending voice handoff', 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 }),
|
||||
@@ -637,12 +630,7 @@ test('direct Radio pause or stop cancels a pending voice handoff', async () => {
|
||||
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 }),
|
||||
@@ -675,12 +663,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;
|
||||
@@ -732,7 +715,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) {
|
||||
@@ -757,7 +740,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) {
|
||||
@@ -777,12 +760,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 }),
|
||||
@@ -830,16 +808,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') {
|
||||
@@ -924,12 +893,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 () => ({
|
||||
@@ -982,16 +946,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 () => ({
|
||||
@@ -1050,12 +1005,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) => {
|
||||
@@ -1110,12 +1060,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) => {
|
||||
@@ -1156,12 +1101,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) => {
|
||||
@@ -1235,12 +1175,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();
|
||||
@@ -1366,12 +1301,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();
|
||||
@@ -1501,12 +1431,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,
|
||||
@@ -1632,12 +1557,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,
|
||||
@@ -1766,12 +1686,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,
|
||||
@@ -1837,12 +1752,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 }) },
|
||||
@@ -1918,12 +1828,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: {
|
||||
@@ -1970,12 +1875,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) => {
|
||||
@@ -2048,12 +1948,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) => {
|
||||
@@ -2156,12 +2051,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,
|
||||
@@ -2244,12 +2134,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,
|
||||
@@ -2307,12 +2192,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) => {
|
||||
@@ -2401,12 +2281,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,
|
||||
@@ -2492,12 +2367,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,
|
||||
@@ -2571,12 +2441,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,
|
||||
@@ -2673,12 +2538,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,
|
||||
@@ -2756,12 +2616,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,
|
||||
@@ -2808,12 +2663,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'
|
||||
@@ -3107,22 +2957,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 }; }),
|
||||
@@ -3190,9 +3025,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', () => {
|
||||
@@ -3202,8 +3037,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,
|
||||
@@ -3214,11 +3049,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', () => {
|
||||
@@ -3239,8 +3074,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', () => {
|
||||
@@ -3444,7 +3279,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', () => {
|
||||
@@ -3481,7 +3316,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' };
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -525,22 +525,31 @@ const SELF_EVIDENT_IDS = new Set([
|
||||
'radio',
|
||||
'wind',
|
||||
'directions',
|
||||
// The brand is already the layer id; all spoken aliases remain accepted.
|
||||
's2-ghostmaps',
|
||||
]);
|
||||
|
||||
/** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */
|
||||
const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
|
||||
|
||||
/**
|
||||
* Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers
|
||||
* whose id is not already the spoken word. At most two aliases per layer keep
|
||||
* the tool JSON small; the runner accepts the full alias list.
|
||||
* the tool JSON small; the runner accepts the full alias list. Of those two,
|
||||
* an alias the id already spells ("dams"→local-dams, "weather radar"→
|
||||
* weather-radar) is left out, and a layer with neither left gets no hint.
|
||||
* @returns {string} One line for the layerId description.
|
||||
*/
|
||||
export function voiceLayerAliasHint() {
|
||||
return VOICE_LAYER_MANIFEST.filter(
|
||||
(entry) =>
|
||||
entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id),
|
||||
(entry) => entry.toggle && !SELF_EVIDENT_IDS.has(entry.id),
|
||||
)
|
||||
.map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
aliases: entry.aliases
|
||||
.slice(0, 2)
|
||||
.filter((alias) => !spelled(entry.id).includes(spelled(alias))),
|
||||
}))
|
||||
.filter((entry) => entry.aliases.length)
|
||||
.map((entry) => `${entry.aliases.join('/')}→${entry.id}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
VOICE_TOGGLE_LAYER_IDS,
|
||||
VOICE_CONTEXT_LAYER_IDS,
|
||||
VOICE_QUERY_LAYER_IDS,
|
||||
voiceLayerAliasHint,
|
||||
voiceLayerAliasMap,
|
||||
} from './layerManifest.js';
|
||||
|
||||
@@ -78,6 +79,23 @@ test('tool enums are generated from the manifest', () => {
|
||||
assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel'));
|
||||
});
|
||||
|
||||
test('the alias hint leaves out names the layer id already spells', () => {
|
||||
const hint = voiceLayerAliasHint();
|
||||
const entries = hint.split(';').map((entry) => entry.split('→'));
|
||||
const flat = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
|
||||
for (const [aliases, id] of entries) {
|
||||
for (const alias of aliases.split('/')) {
|
||||
assert.ok(!flat(id).includes(flat(alias)), `"${alias}" is already spelled by ${id}`);
|
||||
}
|
||||
}
|
||||
const ids = entries.map(([, id]) => id);
|
||||
assert.ok(!ids.includes('local-dams'), 'a layer left with no alias gets no entry');
|
||||
assert.ok(!ids.includes('weather-radar'));
|
||||
assert.match(hint, /(^|;)aqhi→air-quality(;|$)/, 'an alias the id does not spell stays');
|
||||
assert.match(hint, /(^|;)hurricanes→weather-cyclones(;|$)/);
|
||||
assert.match(hint, /(^|;)fires\/wildfires→local-firms(;|$)/);
|
||||
});
|
||||
|
||||
test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => {
|
||||
const aliases = voiceLayerAliasMap();
|
||||
for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id);
|
||||
|
||||
+26
-16
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}`));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user