diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d4f90ff..954779142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ takes `omitViews` (for example `['provider-settings']`) for views an application leaves out (samehkhamis). +- `./voice/control`'s `createVoiceControl({ panel })` returns the voice + control's presenter instead of element handles: owners write it by part + (`update('detail', { text })`) and listen with `on('button', fn)`, and the + command dock renders it from a store. `createVoiceCommands`' `createControl` + and `VoiceCardControls` (`{ ui }` instead of `{ root }`) take the same + presenter; the `reset` option is gone, since a newer control replaces the + previous one (samehkhamis). + - `./scenes` presents the scene sharing dialogs and the EDIT DETAILS / SHARE SCENE bar through a store that the scene panels view renders, so a page that omits `layer-panels` shows neither (samehkhamis). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7d6e12c2e..bc003a414 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -297,6 +297,8 @@ "src/ui/styles.js", "src/ui/shellLifecycle.test.mjs", "src/testSupport/readStylesheet.mjs", + "src/testSupport/voiceUi.mjs", + "src/testSupport/voiceView.mjs", "src/testSupport/streetLevelFakes.mjs", "src/splitFlap.js", "style.css", @@ -1102,12 +1104,13 @@ "src/hudSummaryGate.test.mjs", "src/voice/hudPlaceLabels.test.mjs", "src/voice/panel/preferences.test.mjs", + "src/voice/panel/preferencesView.test.mjs", + "src/voice/voiceControlView.test.mjs", "src/voice/panel/host.test.mjs", "src/voice/panel/manualPanel.test.mjs", "src/voice/panel/nativeActivity.test.mjs", "src/voice/panel/inactivity.test.mjs", "src/voice/panel/actionObserver.test.mjs", - "src/voice/panel/testDom.mjs", "src/voice/realtimeCostPanel.test.mjs", "docs/SHARE-LINK-FORMAT.md", "src/share/wire.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5afe95f18..ae2600e7c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -29,6 +29,9 @@ "src/ui/views/panels/RadioTuner.vue", "src/ui/views/panels/LocalePicker.vue", "src/ui/views/panels/SceneDialog.vue", + "src/ui/views/panels/VoiceControl.vue", + "src/ui/views/panels/VoiceCard.vue", + "src/ui/views/panels/VoiceVisualizer.vue", "src/ui/localePicker.js", "src/ui/views/panels/PhoneTabBar.vue", "src/ui/views/panels/CockpitBriefing.vue", @@ -78,6 +81,7 @@ "src/ui/stores/locale.js", "src/ui/stores/providerSettings.js", "src/ui/stores/sceneSharing.js", + "src/ui/stores/voice.js", "src/ui/stores/location.js", "src/ui/stores/streetLevel.js", "src/ui/rail/RailChips.vue", @@ -87,7 +91,8 @@ "src/ui/rail/classToggle.js", "src/ui/rail/focusSafeChildren.js", "src/ui/rail/fieldValue.js", - "src/ui/rail/statusText.js" + "src/ui/rail/statusText.js", + "src/voice/control.js" ], "external": ["vue", "pinia"] }, @@ -1003,6 +1008,7 @@ "src/ui/stores/locale.js", "src/ui/stores/providerSettings.js", "src/ui/stores/sceneSharing.js", + "src/ui/stores/voice.js", "src/ui/stores/location.js", "src/ui/stores/display.js" ], @@ -2042,9 +2048,11 @@ "src/voice/geminiCaptureWorklet.js", "src/sources/httpBody.js", "src/sources/requestDeadline.js", - "src/i18n/marker.js" + "src/i18n/marker.js", + "src/ui/stores/voice.js", + "src/ui/vueRuntime.js" ], - "external": [] + "external": ["vue", "pinia"] }, "application-components": { "exports": [ @@ -2982,6 +2990,7 @@ "src/ui/stores/locale.js", "src/ui/stores/providerSettings.js", "src/ui/stores/sceneSharing.js", + "src/ui/stores/voice.js", "src/ui/stores/location.js", "src/ui/stores/display.js" ], @@ -3944,9 +3953,11 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", - "src/voice/providerCapabilities.js" + "src/voice/providerCapabilities.js", + "src/ui/vueRuntime.js", + "src/ui/stores/voice.js" ], - "external": [] + "external": ["vue", "pinia"] }, "alpr-source": { "exports": ["./layers/alpr/source"], diff --git a/src/testSupport/geminiIntegrationFixture.mjs b/src/testSupport/geminiIntegrationFixture.mjs index ad84dcce6..6d91d3a45 100644 --- a/src/testSupport/geminiIntegrationFixture.mjs +++ b/src/testSupport/geminiIntegrationFixture.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { createVoiceSession } from '../voice/session.js'; import { createGevActionRunner } from '../voice/gevActions.js'; import { createGeminiSession } from '../voice/geminiSession.js'; +import { createTestVoiceUi } from './voiceUi.mjs'; import { createNarrationScheduler } from '../voice/narration.js'; export const turnOfEventLoop = () => @@ -158,7 +159,7 @@ export function nativeIntegrationFixture(t) { createAdapter: (hooks) => createGeminiSession({ ...hooks, - ui: { root: { remove() {} } }, + ui: createTestVoiceUi(), dataManager, now: clock.now, setTimer: clock.setTimer, diff --git a/src/testSupport/geminiNativeHarness.mjs b/src/testSupport/geminiNativeHarness.mjs index fab535027..1b3af0e18 100644 --- a/src/testSupport/geminiNativeHarness.mjs +++ b/src/testSupport/geminiNativeHarness.mjs @@ -1,4 +1,5 @@ import { GeminiSessionController } from '../voice/geminiSession.js'; +import { createTestVoiceUi } from './voiceUi.mjs'; import { createNarrationScheduler } from '../voice/narration.js'; const endpoint = @@ -121,7 +122,7 @@ function harness(overrides = {}) { calls.push({ name, args, options }); return { ok: true, action: name }; }, - ui: { root: { remove() {} } }, + ui: createTestVoiceUi(), radioLayer: radio, now: () => clock, setTimer: (fn, ms) => { diff --git a/src/testSupport/voiceUi.mjs b/src/testSupport/voiceUi.mjs new file mode 100644 index 000000000..552c87d3e --- /dev/null +++ b/src/testSupport/voiceUi.mjs @@ -0,0 +1,16 @@ +import { createPinia } from 'pinia'; +import { useVoiceStore } from '../ui/stores/voice.js'; +import { createVoiceControl } from '../voice/control.js'; + +/** + * A real voice control presenter on its own store, for owner tests that run + * without a page. Read what it shows from `ui.state`; report interactions + * with `ui.emit(name, ...args)`. + * @param {{panel?: boolean, pinia?: import('pinia').Pinia}} [options] + */ +export function createTestVoiceUi({ + panel = false, + pinia = createPinia(), +} = {}) { + return createVoiceControl({ panel, store: useVoiceStore(pinia) }); +} diff --git a/src/testSupport/voiceView.mjs b/src/testSupport/voiceView.mjs new file mode 100644 index 000000000..771a1a951 --- /dev/null +++ b/src/testSupport/voiceView.mjs @@ -0,0 +1,39 @@ +import { createApp, h, nextTick } from 'vue'; +import { createPinia, storeToRefs } from 'pinia'; +import { useVoiceStore } from '../ui/stores/voice.js'; +import VoiceControl from '../ui/views/panels/VoiceControl.vue'; + +/** + * Render whichever voice control the store presents, as the command dock + * does, into the current document. Import registerDom.mjs first. + * @param {import('node:test').TestContext} t + * @returns {{pinia: import('pinia').Pinia, settle: () => Promise}} + */ +export function mountVoiceView(t) { + const doc = globalThis.document; + // happy-dom lacks the browser's `[hidden]` rule, which visibility checks + // and focus moves rely on. + if (!doc.getElementById('test-ua-hidden')) { + const style = doc.head.appendChild(doc.createElement('style')); + style.id = 'test-ua-hidden'; + style.textContent = '[hidden] { display: none; }'; + } + doc.body.replaceChildren(); + const host = doc.body.appendChild(doc.createElement('div')); + const pinia = createPinia(); + const { view } = storeToRefs(useVoiceStore(pinia)); + const app = createApp({ + render: () => + view.value + ? h(VoiceControl, { key: view.value.id, view: view.value }) + : null, + }).use(pinia); + app.mount(host); + t.after(() => app.unmount()); + // Renders, then any focus the owners asked for after it. + const settle = async () => { + await nextTick(); + await nextTick(); + }; + return { pinia, settle }; +} diff --git a/src/ui/stores/voice.js b/src/ui/stores/voice.js new file mode 100644 index 000000000..86a8546e3 --- /dev/null +++ b/src/ui/stores/voice.js @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef } from 'vue'; + +/** + * The voice control as the command dock renders it. The voice control + * presenter (src/voice/control.js) holds the control's state for the session + * owners and publishes it here; the view sends clicks, keys and form changes + * back to the connected presenter. + */ +export const useVoiceStore = defineStore('gev-voice', () => { + /** The presented control; null while no voice session owns one. */ + const view = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + view.value = next && markRaw(next); + } + + /** The presenter the view calls, or null while none is wired. */ + const controls = shallowRef(null); + + /** @param {?object} next The presenter, or null to disconnect. */ + function connect(next) { + controls.value = next && markRaw(next); + } + + /** + * The waveform's bars, apart from the view: they change every animation + * frame while voice is live, and only the visualizer renders them. + */ + const bars = shallowRef(null); + + /** @param {?Array<{level: string, opacity: string}>} next */ + function publishBars(next) { + bars.value = next && markRaw(next); + } + + return { view, publish, controls, connect, bars, publishBars }; +}); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index 5dee8f769..56c1bde08 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -3,8 +3,11 @@ import { storeToRefs } from 'pinia'; import { t } from '../../i18n/index.js'; import { vClassToggle } from '../rail/classToggle.js'; import { useDisplayStore } from '../stores/display.js'; +import { computed } from 'vue'; import { useLocationStore } from '../stores/location.js'; +import { useVoiceStore } from '../stores/voice.js'; import PhoneTabBar from './panels/PhoneTabBar.vue'; +import VoiceControl from './panels/VoiceControl.vue'; // VisualSettings publishes the active style and the map source controls // their chip row; until they do, the dock shows its initial markup. The @@ -14,6 +17,21 @@ const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore()); // and the search field keeps their listeners. const { view: loc, controls: locationControls } = storeToRefs(useLocationStore()); +// The voice control, while a voice session presents one. It sits between the +// location bar and the visual presets; the trays keep their elements when the +// order changes. +const { view: voice } = storeToRefs(useVoiceStore()); +// Once voice has taken its place, the location bar stays first. +let voiceShown = false; +const dockOrder = computed(() => { + if (voice.value) voiceShown = true; + return voice.value + ? ['location', 'voice', 'control'] + : voiceShown + ? ['location', 'control'] + : ['control', 'location']; +}); + function selectMapSource(chip) { if (!chip.available) return; void mapSourceControls.value?.select(chip.id).catch(() => {}); @@ -21,10 +39,11 @@ function selectMapSource(chip) { diff --git a/src/ui/views/panels/VoiceCard.vue b/src/ui/views/panels/VoiceCard.vue new file mode 100644 index 000000000..23ff61e75 --- /dev/null +++ b/src/ui/views/panels/VoiceCard.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/ui/views/panels/VoiceControl.vue b/src/ui/views/panels/VoiceControl.vue new file mode 100644 index 000000000..a020ed4ae --- /dev/null +++ b/src/ui/views/panels/VoiceControl.vue @@ -0,0 +1,131 @@ + + + diff --git a/src/ui/views/panels/VoiceVisualizer.vue b/src/ui/views/panels/VoiceVisualizer.vue new file mode 100644 index 000000000..b29a7e7da --- /dev/null +++ b/src/ui/views/panels/VoiceVisualizer.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/voice/control.js b/src/voice/control.js index 2e3e7229b..5df953d39 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -1,7 +1,13 @@ +import { nextTick } from 'vue'; import { 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,247 @@ 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 = ` - - ${panel ? `` : ''} -
-
${t('AI AGENT')}
-
${voiceStatusLabel('idle')}
- ${ - panel - ? `` - : `
- - ~$0.00 -
` - } -
- - -
-
${t('VOICE STANDBY')}
-
- - ${ - panel - ? `` - : '' - } - - `; - 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(); + let state = initialState(Boolean(panel)); + // 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. */ + update(part, patch) { + if (removed) return; + state = { ...state, [part]: { ...state[part], ...patch } }; + publish(); + }, + /** Replace one part of the control. */ + replace(part, value) { + if (removed) return; + 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`. + * @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; + listeners.clear(); + }, + }; + store.connect(ui); + store.publishBars(null); + publish(); + return ui; +} diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index 041f1f643..4b0094233 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -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')); } diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index 541e2cc04..121d94a9b 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -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' }; diff --git a/src/voice/globeResetConfirmation.test.mjs b/src/voice/globeResetConfirmation.test.mjs index be2778e6d..af9bd1819 100644 --- a/src/voice/globeResetConfirmation.test.mjs +++ b/src/voice/globeResetConfirmation.test.mjs @@ -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' diff --git a/src/voice/integrationContract.test.mjs b/src/voice/integrationContract.test.mjs index 13a9932ed..faff3423a 100644 --- a/src/voice/integrationContract.test.mjs +++ b/src/voice/integrationContract.test.mjs @@ -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({ diff --git a/src/voice/panel/host.js b/src/voice/panel/host.js index 138b0a6c6..002f0a188 100644 --- a/src/voice/panel/host.js +++ b/src/voice/panel/host.js @@ -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; @@ -262,14 +263,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({ diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index b08871a19..dd3b25038 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -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); }); diff --git a/src/voice/panel/manualPanel.js b/src/voice/panel/manualPanel.js index 6d2f058d1..f611f9cbc 100644 --- a/src/voice/panel/manualPanel.js +++ b/src/voice/panel/manualPanel.js @@ -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,52 @@ 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?.(); + ui.update('previewEmpty', { + hidden: ui.state.card.visible, + text: enabled() ? copy.previewEmpty : copy.previewDisabled, + }); + if (restoreFocus) void ui.focus(['settingsButton', 'button']); }; const refuse = (reason) => ({ ok: false, @@ -90,16 +75,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 +97,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 +150,8 @@ export function createManualVoicePanel({ open = false; sync(); disposed = true; - lifetime.abort(); + for (const off of offs) off(); + endGrace(); }, }; } diff --git a/src/voice/panel/manualPanel.test.mjs b/src/voice/panel/manualPanel.test.mjs index 14476cb98..8c3ae9d71 100644 --- a/src/voice/panel/manualPanel.test.mjs +++ b/src/voice/panel/manualPanel.test.mjs @@ -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); }); diff --git a/src/voice/panel/nativeActivity.test.mjs b/src/voice/panel/nativeActivity.test.mjs index 81709ce53..8541d752a 100644 --- a/src/voice/panel/nativeActivity.test.mjs +++ b/src/voice/panel/nativeActivity.test.mjs @@ -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); diff --git a/src/voice/panel/preferences.js b/src/voice/panel/preferences.js index f01a6d59f..1c9c82cf3 100644 --- a/src/voice/panel/preferences.js +++ b/src/voice/panel/preferences.js @@ -102,93 +102,85 @@ 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, + 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(); }, }; } diff --git a/src/voice/panel/preferences.test.mjs b/src/voice/panel/preferences.test.mjs index 76ea3b904..4927d76bc 100644 --- a/src/voice/panel/preferences.test.mjs +++ b/src/voice/panel/preferences.test.mjs @@ -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'); }); diff --git a/src/voice/panel/preferencesView.test.mjs b/src/voice/panel/preferencesView.test.mjs new file mode 100644 index 000000000..b4906f282 --- /dev/null +++ b/src/voice/panel/preferencesView.test.mjs @@ -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'); +}); diff --git a/src/voice/panel/testDom.mjs b/src/voice/panel/testDom.mjs deleted file mode 100644 index fe7a002d4..000000000 --- a/src/voice/panel/testDom.mjs +++ /dev/null @@ -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(); - }, - }; -} diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 56918a515..10af85166 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -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>/); +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', () => { diff --git a/src/voice/providerCommands.js b/src/voice/providerCommands.js index 86855e3bc..cd89351fa 100644 --- a/src/voice/providerCommands.js +++ b/src/voice/providerCommands.js @@ -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; }; diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index 74d99699b..290b723a8 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -11,6 +11,7 @@ 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 +26,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 +62,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 +106,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 +151,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 +317,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 +410,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 +495,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 +576,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(), []); @@ -798,15 +775,14 @@ test('outer destruction disposes the runner once and removes each provider bindi 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' }); } diff --git a/src/voice/realtimeBackend.test.mjs b/src/voice/realtimeBackend.test.mjs index d179f5b6b..ac96e2374 100644 --- a/src/voice/realtimeBackend.test.mjs +++ b/src/voice/realtimeBackend.test.mjs @@ -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; diff --git a/src/voice/realtimeController.js b/src/voice/realtimeController.js index 06c504b20..36a399fdb 100644 --- a/src/voice/realtimeController.js +++ b/src/voice/realtimeController.js @@ -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,11 +409,13 @@ 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); + this.ui.update('status', { text: voiceStatusLabel(shownStatus) }); const resolvedDetail = status === 'listening' && this.pushToTalkMode ? this.pushToTalkKeyHeld @@ -432,14 +427,13 @@ export class GevRealtimeController extends RealtimeFacade { ? t('VOICE UNAVAILABLE') : 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.') - : ''; - } + : '', + }); if (status === 'idle' || status === 'connecting' || status === 'error') { this.setVoiceSpeaker('idle'); } diff --git a/src/voice/realtimeCost.js b/src/voice/realtimeCost.js index ce20e1fe2..5e1f6c6bb 100644 --- a/src/voice/realtimeCost.js +++ b/src/voice/realtimeCost.js @@ -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' diff --git a/src/voice/realtimeCostIncompleteNote.i18n.test.mjs b/src/voice/realtimeCostIncompleteNote.i18n.test.mjs index 0336971bb..5ec380ecf 100644 --- a/src/voice/realtimeCostIncompleteNote.i18n.test.mjs +++ b/src/voice/realtimeCostIncompleteNote.i18n.test.mjs @@ -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}`)); }); diff --git a/src/voice/realtimeCostPanel.test.mjs b/src/voice/realtimeCostPanel.test.mjs index 07622f84d..8754645af 100644 --- a/src/voice/realtimeCostPanel.test.mjs +++ b/src/voice/realtimeCostPanel.test.mjs @@ -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); }); diff --git a/src/voice/realtimeCostTierStatus.test.mjs b/src/voice/realtimeCostTierStatus.test.mjs index 3461edd34..c343acff1 100644 --- a/src/voice/realtimeCostTierStatus.test.mjs +++ b/src/voice/realtimeCostTierStatus.test.mjs @@ -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'), + ); } }); } diff --git a/src/voice/realtimeInput.js b/src/voice/realtimeInput.js index d82b9465d..b73bcfba1 100644 --- a/src/voice/realtimeInput.js +++ b/src/voice/realtimeInput.js @@ -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,15 @@ 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.ui.update('mic', { text: t('MIC') }); + this.ui.update('help', { + text: resolveVoiceControlHint( this.pushToTalkMode, this.pushToTalkKeyHeld, - ); - } + ), + }); } setVoiceSpeaker(speaker, { keepVisualizerSpeaker = false } = {}) { @@ -382,7 +380,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 +390,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() { diff --git a/src/voice/realtimeInputPolicy.js b/src/voice/realtimeInputPolicy.js index cb7661632..39bc29581 100644 --- a/src/voice/realtimeInputPolicy.js +++ b/src/voice/realtimeInputPolicy.js @@ -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|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'); - } -} diff --git a/src/voice/realtimeNarration.test.mjs b/src/voice/realtimeNarration.test.mjs index e47a888c8..677bf6b8f 100644 --- a/src/voice/realtimeNarration.test.mjs +++ b/src/voice/realtimeNarration.test.mjs @@ -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, }); diff --git a/src/voice/realtimeOwners.test.mjs b/src/voice/realtimeOwners.test.mjs index 1470790c2..2555ba1b4 100644 --- a/src/voice/realtimeOwners.test.mjs +++ b/src/voice/realtimeOwners.test.mjs @@ -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'); diff --git a/src/voice/realtimeRadio.test.mjs b/src/voice/realtimeRadio.test.mjs index 4e29325ee..acbd113e9 100644 --- a/src/voice/realtimeRadio.test.mjs +++ b/src/voice/realtimeRadio.test.mjs @@ -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), diff --git a/src/voice/realtimeSession.js b/src/voice/realtimeSession.js index 0d9eca872..5e507c3a1 100644 --- a/src/voice/realtimeSession.js +++ b/src/voice/realtimeSession.js @@ -22,13 +22,10 @@ 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. + controller.tierHandler = controller.ui.on('tier', () => + controller.toggleVoiceTier(), + ); controller.syncCostUi(); controller.bindPushToTalkShortcut(); }, diff --git a/src/voice/realtimeTypedSend.test.mjs b/src/voice/realtimeTypedSend.test.mjs index 6a32172c0..26027b4de 100644 --- a/src/voice/realtimeTypedSend.test.mjs +++ b/src/voice/realtimeTypedSend.test.mjs @@ -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', diff --git a/src/voice/session.test.mjs b/src/voice/session.test.mjs index 8e4d37385..8ea24b4d1 100644 --- a/src/voice/session.test.mjs +++ b/src/voice/session.test.mjs @@ -10,6 +10,7 @@ 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 +243,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 +286,31 @@ 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'); + 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'); // Switching language (a reload) asks first during a session. assert.deepEqual(describeUnsavedWork(), ['the active voice session']); 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 +590,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, diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 7451a4e5b..3edbadc36 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -40,10 +40,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 +64,37 @@ 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 +102,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, @@ -187,13 +183,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); + offButton(); + offProvider?.(); unregisterUnsavedWork(); annotationUnsubscribe?.(); updateStatus(); @@ -206,7 +202,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(); diff --git a/src/voice/voiceCard.js b/src/voice/voiceCard.js index 4e5be69b4..38a444ad6 100644 --- a/src/voice/voiceCard.js +++ b/src/voice/voiceCard.js @@ -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,16 @@ 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; + 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 +72,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 +83,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 +103,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 +118,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 +136,11 @@ 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?.(); } } diff --git a/src/voice/voiceCard.test.mjs b/src/voice/voiceCard.test.mjs index 64a72955b..7cd3f0a60 100644 --- a/src/voice/voiceCard.test.mjs +++ b/src/voice/voiceCard.test.mjs @@ -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); diff --git a/src/voice/voiceControlView.test.mjs b/src/voice/voiceControlView.test.mjs new file mode 100644 index 000000000..d49d85561 --- /dev/null +++ b/src/voice/voiceControlView.test.mjs @@ -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); +});