mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
A reply stopped partway through a round now keeps the actions that ran, answering the calls it never reached as skipped, so the history stays paired for both providers. A failed typed turn no longer resets the pointer, referents and follow-up memory, since its conversation continues. The server now tells a rejected key (401, or Gemini's 400 with reason API_KEY_INVALID, matched exactly), denied access (403) and an account out of credit (OpenAI's 429 insufficient_quota) apart; each cools down for a minute and gets its own message in the chat.
1223 lines
42 KiB
JavaScript
1223 lines
42 KiB
JavaScript
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 { 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) => {
|
|
const input = {
|
|
pushToTalkKeyHeld: false,
|
|
setMicrophoneEnabled() {},
|
|
releasePushToTalkKey() {
|
|
this.pushToTalkKeyHeld = false;
|
|
},
|
|
resetSession() {},
|
|
};
|
|
const controller = native
|
|
? {
|
|
input,
|
|
_input: input,
|
|
_turns: { observeSpeech() {} },
|
|
status: 'idle',
|
|
dc: null,
|
|
sendTextCommand(text) {
|
|
if (!text) return false;
|
|
hooks.emit({
|
|
type: 'pointer',
|
|
pointer: { kind: 'here', label: 'Typed target' },
|
|
});
|
|
},
|
|
createAudio(options) {
|
|
this.audioCallbacks = options;
|
|
return {
|
|
pending: false,
|
|
play() {},
|
|
clearPlayback() {},
|
|
close() {},
|
|
};
|
|
},
|
|
handleMessage() {},
|
|
userActivityOpen: false,
|
|
pendingTools: 0,
|
|
isActive: () => true,
|
|
}
|
|
: {};
|
|
const adapter = {
|
|
controller,
|
|
capabilities: { pushToTalk: true },
|
|
starts: 0,
|
|
stops: 0,
|
|
bindControls() {},
|
|
start() {
|
|
this.starts++;
|
|
if (native) {
|
|
hooks.emit({ type: 'state', state: 'connecting' });
|
|
controller.audio = controller.createAudio({ pushToTalk: false });
|
|
hooks.emit({ type: 'state', state: 'connecting' });
|
|
// Native open-mic startup marks presentation pending before speech.
|
|
controller.userTurnPending = true;
|
|
}
|
|
hooks.emit({ type: 'state', state: 'listening' });
|
|
},
|
|
stop() {
|
|
this.stops++;
|
|
if (stopError) throw stopError;
|
|
},
|
|
sendText: () => false,
|
|
sendMapEvent() {},
|
|
getStartOptions: () => ({ pushToTalk: false }),
|
|
hooks,
|
|
input,
|
|
};
|
|
instances.push(adapter);
|
|
return adapter;
|
|
};
|
|
}
|
|
// With `view`, the real VoiceControl renders the control from its store.
|
|
function mounted(t, options = {}) {
|
|
const view = options.view ? mountVoiceView(t) : null;
|
|
const pinia = view?.pinia ?? createPinia();
|
|
const clock = controlledClock();
|
|
const host = createVoicePanelHost({
|
|
storage: options.storage ?? null,
|
|
clock,
|
|
readCockpitState: options.readCockpitState,
|
|
});
|
|
const instances = [];
|
|
const commands = bind({
|
|
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')
|
|
: null,
|
|
});
|
|
t.after(() => {
|
|
try {
|
|
commands.session.destroy();
|
|
} catch {}
|
|
host.destroy();
|
|
});
|
|
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', 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(
|
|
$('#gev-voice-settings-button').getAttribute('aria-controls'),
|
|
panel.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(
|
|
$('#gev-voice-preview-panel').contains($('#gev-voice-card')),
|
|
true,
|
|
);
|
|
const tray = $('#gev-voice-control .gev-voice-error-tray');
|
|
assert.equal(tray !== null, true);
|
|
assert.equal(panel.contains(tray), false);
|
|
assert.equal(
|
|
$('#gev-voice-control').querySelectorAll('#gev-voice-card').length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test('actual binder/card has launcher toggle, Settings tab toggle, 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);
|
|
$('#gev-voice-settings-button').click();
|
|
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
|
|
await settle();
|
|
$('#gev-voice-settings-tab').click();
|
|
key($('#gev-voice-settings-tab'), 'End');
|
|
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
|
|
// Preview has no visible tab, so the Settings tab toggles back to it.
|
|
$('#gev-voice-settings-tab').click();
|
|
assert.equal(commands.voicePanel.snapshot().view, 'preview');
|
|
commands.voicePanel.select('preview');
|
|
commands.voiceCard.handle({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
final: true,
|
|
text: 'A visible reply',
|
|
});
|
|
await settle();
|
|
const dismiss = $('#gev-voice-card-dismiss');
|
|
dismiss.focus();
|
|
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(active(), 'gev-voice-preview-tab');
|
|
key(globalThis.document.activeElement, 'Escape');
|
|
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
|
|
commands.voicePanel.select('settings');
|
|
ui.emit('preview-change', false);
|
|
commands.voicePanel.select('preview');
|
|
commands.voiceCard.handle({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
final: true,
|
|
text: 'A hidden reply',
|
|
});
|
|
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-chat-button');
|
|
commands.voiceCard.handle({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
final: true,
|
|
text: 'Later reply',
|
|
});
|
|
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
|
|
});
|
|
|
|
test('actual facade remount keeps failed-save live preferences/view and manual close, with no restore writes or focus theft', async (t) => {
|
|
const view = mountVoiceView(t);
|
|
const writes = [];
|
|
const storage = {
|
|
getItem: () => null,
|
|
setItem(key, value) {
|
|
writes.push([key, value]);
|
|
throw Error('blocked');
|
|
},
|
|
};
|
|
const host = createVoicePanelHost({ storage });
|
|
const instances = [];
|
|
const commands = createProviderCommands(
|
|
{
|
|
runner: async () => ({}),
|
|
dataManager: { layers: new Map() },
|
|
supportedProviders: ['openai', 'gemini'],
|
|
},
|
|
{
|
|
bind: (options) =>
|
|
bind({
|
|
...options,
|
|
panelHost: host,
|
|
createControl: (control) =>
|
|
createTestVoiceUi({ ...control, pinia: view.pinia }),
|
|
}),
|
|
factories: { openai: factory(instances), gemini: factory(instances) },
|
|
storage,
|
|
},
|
|
);
|
|
t.after(() => {
|
|
commands.destroy();
|
|
host.destroy();
|
|
});
|
|
const old = commands.ui;
|
|
commands.voicePanel.select('settings');
|
|
old.emit('preview-change', false);
|
|
chooseCustomTimeout(old, '7');
|
|
const preferenceWrites = () =>
|
|
writes.filter(([key]) => key.startsWith('godsEyeView.voice.')).length;
|
|
assert.equal(preferenceWrites(), 2);
|
|
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,
|
|
minutes: 7,
|
|
previewPersisted: false,
|
|
timeoutPersisted: false,
|
|
});
|
|
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(active(), 'gev-voice-provider');
|
|
commands.ui.emit('preview-change', true);
|
|
commands.voicePanel.close();
|
|
await commands.start();
|
|
const retired = instances[1];
|
|
commands.ui.emit('provider-change', 'openai');
|
|
await commands.start();
|
|
assert.equal(commands.session.isActive(), true);
|
|
retired.hooks.emit({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
text: 'Retired output',
|
|
final: true,
|
|
});
|
|
const emit = instances[2].hooks.emit;
|
|
emit({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
text: 'Current output after remount',
|
|
final: true,
|
|
});
|
|
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
|
|
emit({ type: 'interruption', reason: 'user-speech' });
|
|
emit({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
text: 'Reply after genuine input',
|
|
final: true,
|
|
});
|
|
assert.equal(commands.voicePanel.snapshot().previewVisible, true);
|
|
});
|
|
|
|
test('explicit successful mic Stop closes; failed Stop idle and startup error do not', async (t) => {
|
|
const good = mounted(t);
|
|
await good.commands.session.start();
|
|
good.commands.voicePanel.select('settings');
|
|
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.emit('button'),
|
|
(value) => value === error,
|
|
);
|
|
assert.equal(bad.commands.session.state, 'idle');
|
|
assert.equal(bad.commands.voicePanel.snapshot().settingsOpen, true);
|
|
bad.instances[0].hooks.emit({
|
|
type: 'state',
|
|
state: 'error',
|
|
detail: 'startup failed',
|
|
});
|
|
assert.equal(bad.commands.voicePanel.snapshot().settingsOpen, true);
|
|
});
|
|
|
|
test('reentrant manual choice/new start during Stop cannot close the successor choice', async (t) => {
|
|
const f = mounted(t);
|
|
await f.commands.session.start();
|
|
f.commands.voicePanel.select('settings');
|
|
const unsubscribe = f.commands.session.subscribe((event) => {
|
|
if (event.type === 'state' && event.state === 'idle')
|
|
f.commands.voicePanel.select('preview');
|
|
});
|
|
f.ui.emit('button');
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
|
|
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
|
|
unsubscribe();
|
|
});
|
|
|
|
test('integrated inactivity rechecks held PTT at expiry, then normal stop closes on later quiet expiry', async (t) => {
|
|
const f = mounted(t, { native: true });
|
|
await f.commands.session.start();
|
|
f.commands.voicePanel.select('settings');
|
|
chooseCustomTimeout(f.ui, '1');
|
|
f.instances[0].input.pushToTalkKeyHeld = true;
|
|
f.clock.advance(60000);
|
|
assert.equal(f.instances[0].stops, 0);
|
|
f.instances[0].input.releasePushToTalkKey();
|
|
f.clock.advance(60000);
|
|
assert.equal(f.instances[0].stops, 1);
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
|
|
});
|
|
|
|
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.emit('timeout-select', '0');
|
|
native.clock.advance(600000);
|
|
assert.equal(native.instances[0].stops, 0);
|
|
assert.equal(native.commands.getVoicePreferences().minutes, 0);
|
|
const unknown = mounted(t);
|
|
await unknown.commands.session.start();
|
|
unknown.clock.advance(600000);
|
|
assert.equal(unknown.instances[0].stops, 0);
|
|
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();
|
|
chooseCustomTimeout(f.ui, '1');
|
|
f.instances[0].controller._turns.observeSpeech({ type: 'response.created' });
|
|
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({
|
|
type: 'output_audio_buffer.cleared',
|
|
});
|
|
f.clock.advance(60000);
|
|
assert.equal(f.instances[0].stops, 1, 'known quiet output permits expiry');
|
|
});
|
|
|
|
test('native factory with custom controller remains uninstrumented despite panel opt-in', (t) => {
|
|
let inspected = 0;
|
|
const commands = compose({
|
|
panel: true,
|
|
panelStorage: null,
|
|
runner: async () => ({}),
|
|
dataManager: { layers: new Map() },
|
|
createController: (options) => ({
|
|
ui: options.ui,
|
|
get _turns() {
|
|
inspected++;
|
|
throw Error('custom internal getter');
|
|
},
|
|
start() {},
|
|
stop() {},
|
|
sendTextCommand() {},
|
|
notifyMapEvent() {},
|
|
syncCostUi() {},
|
|
bindPushToTalkShortcut() {},
|
|
toggleVoiceTier() {},
|
|
}),
|
|
});
|
|
t.after(() => commands.session.destroy());
|
|
assert.equal(inspected, 0);
|
|
assert.equal(commands.getVoicePanelState().activity.known, false);
|
|
assert.equal(inspected, 0);
|
|
});
|
|
|
|
test('native connecting phases retain output observation; idle starts before first speech and waits for drain', async (t) => {
|
|
const f = mounted(t, { native: true });
|
|
await f.commands.session.start();
|
|
const adapter = f.instances[0];
|
|
const controller = adapter.controller;
|
|
assert.equal(
|
|
f.commands.getVoicePanelState().inactivity.eligible,
|
|
true,
|
|
'open microphone presentation-pending is not admitted speech',
|
|
);
|
|
controller.audio.pending = true;
|
|
controller.handleMessage({});
|
|
f.clock.advance(300000);
|
|
assert.equal(adapter.stops, 0);
|
|
assert.equal(f.commands.getVoicePanelState().activity.outputBusy, true);
|
|
controller.audio.pending = false;
|
|
controller.audioCallbacks.onDrain();
|
|
f.clock.advance(300000);
|
|
assert.equal(adapter.stops, 1);
|
|
});
|
|
|
|
test('a failed typed turn keeps follow-up memory; a stop or voice error clears it', async (t) => {
|
|
let resets = 0;
|
|
const runner = async () => ({ ok: true });
|
|
runner.resetConversation = () => resets++;
|
|
const f = mounted(t, { runner });
|
|
const adapter = f.instances[0];
|
|
adapter.getStartOptions = () => ({ textOnly: true });
|
|
await f.commands.session.start({ textOnly: true });
|
|
adapter.hooks.emit({ type: 'state', state: 'error', detail: 'Busy' });
|
|
assert.equal(resets, 0, 'the typed conversation continues');
|
|
f.commands.session.stop();
|
|
assert.equal(resets, 1);
|
|
adapter.getStartOptions = () => ({});
|
|
await f.commands.session.start();
|
|
adapter.hooks.emit({ type: 'state', state: 'error', detail: 'Lost' });
|
|
assert.equal(resets, 2);
|
|
});
|
|
|
|
test('the voice inactivity timeout never ends a typed chat session', async (t) => {
|
|
const f = mounted(t, { native: true });
|
|
const adapter = f.instances[0];
|
|
adapter.getStartOptions = () => ({ textOnly: true });
|
|
await f.commands.session.start({ textOnly: true });
|
|
adapter.controller.handleMessage({});
|
|
f.clock.advance(600000);
|
|
assert.equal(adapter.stops, 0);
|
|
assert.equal(f.commands.session.isActive(), true);
|
|
// A quiet voice session started afterwards still times out.
|
|
f.commands.session.stop();
|
|
const stops = adapter.stops;
|
|
adapter.getStartOptions = () => ({});
|
|
await f.commands.session.start();
|
|
adapter.controller.handleMessage({});
|
|
f.clock.advance(600000);
|
|
assert.equal(adapter.stops, stops + 1);
|
|
});
|
|
|
|
async function directActions(
|
|
t,
|
|
{ storage = null, delayExit = false, exposePanel = true } = {},
|
|
) {
|
|
let f, releaseExit;
|
|
const state = { active: true, exits: 0, visual: [] };
|
|
const styleManager = {
|
|
getCockpitState: () => ({ active: state.active }),
|
|
controlCockpit(action) {
|
|
assert.equal(action, 'exit');
|
|
state.exits++;
|
|
state.active = false;
|
|
const result = { ok: true, state: { active: false } };
|
|
return delayExit
|
|
? new Promise((resolve) => {
|
|
releaseExit = () => resolve(result);
|
|
})
|
|
: result;
|
|
},
|
|
setPanelCollapsed(...args) {
|
|
state.visual.push(args);
|
|
},
|
|
};
|
|
const runner = createGevActionRunner({
|
|
viewer: {
|
|
clock: { onTick: { addEventListener: () => () => {} } },
|
|
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
|
camera: { moveEnd: { addEventListener() {} } },
|
|
},
|
|
styleManager,
|
|
dataManager: { layers: new Map(), getAll: () => [] },
|
|
readVoicePanelOperation: () =>
|
|
exposePanel ? f?.commands.captureVoicePanelOperation() : undefined,
|
|
});
|
|
f = mounted(t, { native: true, runner, storage });
|
|
await f.commands.session.start();
|
|
return {
|
|
...f,
|
|
runner,
|
|
state,
|
|
run: f.instances[0].hooks.runAction,
|
|
releaseExit: () => releaseExit(),
|
|
};
|
|
}
|
|
|
|
test('actual runner applies exact timeout directly and reports live-unsaved truth, invalid/cancelled attempts stay inert', async (t) => {
|
|
const f = await directActions(t);
|
|
const result = await f.run('set_voice_inactivity_timeout', { minutes: 7 });
|
|
assert.equal(result.applied, true);
|
|
assert.equal(result.persisted, false);
|
|
assert.equal(result.minutes, 7);
|
|
assert.match(result.say, /could not be saved/);
|
|
assert.equal(f.commands.getVoicePreferences().minutes, 7);
|
|
const invalid = await f.run('set_voice_inactivity_timeout', { minutes: 7.5 });
|
|
assert.equal(invalid.applied, false);
|
|
assert.equal(f.commands.getVoicePreferences().minutes, 7);
|
|
const abort = new AbortController();
|
|
abort.abort();
|
|
const cancelled = await f.runner(
|
|
'set_voice_inactivity_timeout',
|
|
{ minutes: 2 },
|
|
{ signal: abort.signal },
|
|
);
|
|
assert.equal(cancelled.cancelled, true);
|
|
assert.equal(f.commands.getVoicePreferences().minutes, 7);
|
|
const operation = f.commands.captureVoicePanelOperation();
|
|
f.commands.session.stop();
|
|
assert.equal(operation.setMinutes(2).cancelled, true);
|
|
});
|
|
|
|
test('named voice settings exits Cockpit directly; Preview stays disabled and visual presets stay separate from Scenes', async (t) => {
|
|
const f = await directActions(t);
|
|
const result = await f.run('set_panel_open', {
|
|
panelId: 'voice-settings',
|
|
open: true,
|
|
});
|
|
assert.equal(f.state.exits, 1);
|
|
assert.equal(result.settingsOpen, true);
|
|
f.ui.emit('preview-change', false);
|
|
const preview = await f.run('set_panel_open', {
|
|
panelId: 'voice control',
|
|
open: true,
|
|
});
|
|
assert.equal(preview.view, 'preview');
|
|
assert.equal(preview.previewEnabled, false);
|
|
assert.equal(f.commands.getVoicePreferences().previewEnabled, false);
|
|
for (const panelId of [
|
|
'visual styles',
|
|
'visual preset',
|
|
'visual presets',
|
|
'scenes',
|
|
])
|
|
await f.run('set_panel_open', { panelId, open: true });
|
|
assert.equal(f.state.exits, 1);
|
|
assert.deepEqual(
|
|
f.state.visual.map(([id]) => id),
|
|
['control-panel', 'control-panel', 'control-panel', 'scene-panel'],
|
|
);
|
|
await assert.rejects(
|
|
() => f.run('set_panel_open', { panelId: 'settings', open: true }),
|
|
/Unknown panel: settings/,
|
|
);
|
|
assert.equal(
|
|
f.state.visual.length,
|
|
4,
|
|
'an unnamed settings target never guesses a panel',
|
|
);
|
|
});
|
|
|
|
test('canonical exit reveals requested voice UI; newer manual choice or Stop defeats pending reveal', async (t) => {
|
|
const f = await directActions(t, { delayExit: true });
|
|
const pending = f.run('control_cockpit', {
|
|
action: 'exit',
|
|
voicePanel: 'settings',
|
|
});
|
|
f.commands.voicePanel.select('preview');
|
|
f.releaseExit();
|
|
const result = await pending;
|
|
assert.equal(result.cancelled, true);
|
|
assert.equal(f.state.active, false);
|
|
assert.equal(result.exit.ok, true);
|
|
assert.equal(result.cockpitExited, true);
|
|
assert.match(result.say, /Cockpit exited/);
|
|
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
|
|
f.state.active = true;
|
|
const alias = f.run('control_cockpit', {
|
|
action: 'leave cockpit',
|
|
voicePanel: 'settings',
|
|
});
|
|
f.releaseExit();
|
|
assert.equal((await alias).settingsOpen, true);
|
|
f.commands.voicePanel.select('preview');
|
|
f.state.active = true;
|
|
const stopped = f.run('control_cockpit', {
|
|
action: 'quit cockpit',
|
|
voicePanel: 'settings',
|
|
});
|
|
f.commands.session.stop();
|
|
f.releaseExit();
|
|
await assert.rejects(stopped, { name: 'AbortError' });
|
|
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
|
|
});
|
|
|
|
test('unavailable host refuses direct voice operations before Cockpit or preference effects', async (t) => {
|
|
const f = await directActions(t, { exposePanel: false });
|
|
const timeout = await f.run('set_voice_inactivity_timeout', { minutes: 1 });
|
|
assert.equal(timeout.unavailable, true);
|
|
assert.equal(timeout.applied, false);
|
|
const panel = await f.run('set_panel_open', {
|
|
panelId: 'voice-settings',
|
|
open: true,
|
|
});
|
|
assert.equal(panel.unavailable, true);
|
|
assert.equal(f.state.exits, 0);
|
|
assert.equal(f.commands.getVoicePreferences().minutes, 5);
|
|
});
|
|
|
|
test('voice timeout change preserves a pending voice reveal', async (t) => {
|
|
const f = await directActions(t, { delayExit: true });
|
|
const pending = f.run('control_cockpit', {
|
|
action: 'exit',
|
|
voicePanel: 'settings',
|
|
});
|
|
const timeout = await f.run('set_voice_inactivity_timeout', { minutes: 0 });
|
|
assert.equal(timeout.minutes, 0);
|
|
assert.equal(timeout.applied, true);
|
|
f.releaseExit();
|
|
const result = await pending;
|
|
assert.equal(result.settingsOpen, true);
|
|
assert.equal(f.commands.getVoicePreferences().minutes, 0);
|
|
});
|
|
|
|
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', view: true });
|
|
await f.commands.session.start();
|
|
const emit = f.instances[0].hooks.emit;
|
|
const reply = (text, responseId = 'r1', extra = {}) =>
|
|
emit({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
text,
|
|
responseId,
|
|
final: true,
|
|
...extra,
|
|
});
|
|
emit({ type: 'completion', responseId: 'r1' });
|
|
reply('Late answer');
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
|
|
const timer = f.commands.voiceCard.hideTimer;
|
|
reply('Late answer');
|
|
reply('Late', 'r1', { final: false });
|
|
assert.equal(f.commands.voiceCard.hideTimer, timer);
|
|
assert.equal(f.commands.voiceCard.state.assistant, 'Late answer');
|
|
f.commands.voicePanel.close();
|
|
emit({
|
|
type: 'transcript',
|
|
role: 'user',
|
|
text: 'advisory caption',
|
|
final: false,
|
|
});
|
|
f.commands.voiceCard.handle({ type: 'dismiss' });
|
|
const advisory = f.commands.voiceCard.state.user;
|
|
emit({
|
|
type: 'interruption',
|
|
reason: 'user-speech',
|
|
preserveAdvisoryCaption: true,
|
|
});
|
|
assert.equal(f.commands.voiceCard.state.dismissed, true);
|
|
assert.equal(f.commands.voiceCard.state.user, advisory);
|
|
reply('Older reply', 'r2');
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
|
|
emit({ type: 'interruption', reason: 'user-speech' });
|
|
reply('A reply with no newest-request attribution', 'older-id');
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
|
|
f.commands.voicePanel.select('settings');
|
|
reply('Another reply', 'r3');
|
|
assert.equal(f.commands.voicePanel.snapshot().settingsOpen, true);
|
|
f.commands.voicePanel.close();
|
|
f.commands.voiceCard.handle({ type: 'dismiss' });
|
|
f.commands.sendTextCommand('Show the typed target');
|
|
reply('Typed reply', 'typed-id');
|
|
assert.equal(f.commands.voicePanel.snapshot().previewVisible, true);
|
|
assert.equal(f.commands.voiceCard.state.dismissed, false);
|
|
assert.equal(f.commands.voiceCard.state.pointer.label, 'Typed target');
|
|
f.commands.session.stop();
|
|
reply('Stopped stale output', 'r4');
|
|
assert.equal(f.commands.voiceCard.state.assistant, null);
|
|
f.commands.session.destroy();
|
|
reply('Disposed stale output', 'r5');
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
|
|
});
|
|
|
|
test('Preview off and actual Cockpit state suppress automatic presentation without enabling preference', async (t) => {
|
|
let cockpit = false;
|
|
const f = mounted(t, {
|
|
native: true,
|
|
view: true,
|
|
readCockpitState: () => ({ active: cockpit }),
|
|
});
|
|
await f.commands.session.start();
|
|
const emit = f.instances[0].hooks.emit;
|
|
const reply = (text) =>
|
|
emit({ type: 'transcript', role: 'assistant', text, final: true });
|
|
reply('Visible');
|
|
await f.settle();
|
|
$('#gev-voice-preview-tab').focus();
|
|
cockpit = true;
|
|
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
|
|
assert.equal(f.ui.state.voicePanel.hidden, true);
|
|
await f.settle();
|
|
assert.equal(active(), 'gev-chat-button');
|
|
f.commands.voicePanel.select('settings', { focus: true });
|
|
await f.settle();
|
|
assert.equal(active(), 'gev-voice-settings-tab');
|
|
f.commands.voicePanel.select('preview', { focus: true });
|
|
await f.settle();
|
|
assert.equal(active(), 'gev-chat-button');
|
|
const outside = document.createElement('button');
|
|
outside.id = 'unrelated';
|
|
document.body.appendChild(outside);
|
|
outside.focus();
|
|
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
|
|
await f.settle();
|
|
assert.equal(active(), 'unrelated');
|
|
assert.equal(f.commands.voicePanel.snapshot().previewVisible, false);
|
|
reply('Suppressed in Cockpit');
|
|
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.state.voicePanel.hidden, true);
|
|
f.ui.emit('preview-change', false);
|
|
emit({ type: 'interruption', reason: 'user-speech' });
|
|
reply('Preference stays off');
|
|
assert.equal(f.ui.state.voicePanel.hidden, true);
|
|
assert.equal(f.commands.getVoicePreferences().previewEnabled, false);
|
|
});
|
|
|
|
test('Provider Settings uses its ready dialog handlers and refuses unavailable or cancelled requests', async (t) => {
|
|
const f = await directActions(t);
|
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
const request = (open, options) =>
|
|
f.run('set_panel_open', { panelId: 'provider-settings', open }, options);
|
|
assert.equal((await request(true)).unavailable, true);
|
|
const doc = globalThis.document;
|
|
// The setup owner needs only its dialog element; the view is not mounted,
|
|
// so the published dialog state stands in for it.
|
|
const panel = doc.createElement('aside');
|
|
panel.id = 'key-setup';
|
|
doc.body.append(panel);
|
|
const dialog = () => settings.view?.dialog;
|
|
let frames = 0;
|
|
const previousFrame = globalThis.requestAnimationFrame;
|
|
globalThis.requestAnimationFrame = (callback) => {
|
|
frames++;
|
|
callback();
|
|
};
|
|
t.after(() => {
|
|
if (previousFrame === undefined) delete globalThis.requestAnimationFrame;
|
|
else globalThis.requestAnimationFrame = previousFrame;
|
|
});
|
|
const { initKeySetup } = await import('../../keySetup.js');
|
|
const { useProviderSettingsStore } =
|
|
await import('../../ui/stores/providerSettings.js');
|
|
const { applicationPinia } = await import('../../ui/vueRuntime.js');
|
|
const settings = useProviderSettingsStore(applicationPinia());
|
|
let finishStatus;
|
|
const pending = initKeySetup({
|
|
documentRef: doc,
|
|
fetchImpl: () =>
|
|
new Promise((resolve) => {
|
|
finishStatus = resolve;
|
|
}),
|
|
});
|
|
assert.equal(
|
|
(await request(true)).unavailable,
|
|
true,
|
|
'initializing is not ready',
|
|
);
|
|
finishStatus({
|
|
ok: true,
|
|
json: async () => ({ keys: [], setCount: 0, total: 1 }),
|
|
});
|
|
const setup = await pending;
|
|
t.after(() => setup.destroy());
|
|
doc.body.classList.add('cockpit-mode');
|
|
assert.equal((await request(true)).unavailable, true);
|
|
assert.equal(
|
|
dialog().hidden,
|
|
true,
|
|
'a concealed view cannot schedule a later surprise reveal',
|
|
);
|
|
doc.body.classList.remove('cockpit-mode');
|
|
const abort = new AbortController();
|
|
abort.abort();
|
|
await assert.rejects(() => request(true, { signal: abort.signal }), {
|
|
name: 'AbortError',
|
|
});
|
|
assert.equal(dialog().hidden, true);
|
|
assert.equal((await request(true)).ok, true);
|
|
assert.equal(dialog().hidden, false);
|
|
assert.equal(dialog().visible, true);
|
|
assert.equal((await request(true)).ok, true);
|
|
assert.equal(frames, 1, 'repeated open reuses the dialog');
|
|
assert.equal((await request(false)).ok, true);
|
|
assert.equal(dialog().visible, false);
|
|
t.mock.timers.tick(400);
|
|
assert.equal(dialog().hidden, true);
|
|
assert.equal((await request(false)).ok, true);
|
|
assert.equal(
|
|
f.state.visual.length,
|
|
0,
|
|
'provider setup never opens a visual or scene panel',
|
|
);
|
|
});
|
|
|
|
// Typed chat, rendered by the real VoiceControl.
|
|
const typedChat = (f) => {
|
|
f.instances[0].getStartOptions = () => ({ textOnly: true });
|
|
f.instances[0].sendText = () => true;
|
|
};
|
|
const type = (text) => {
|
|
const input = $('#gev-chat-input');
|
|
input.value = text;
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
};
|
|
|
|
test('the chat button opens, minimizes and closes chat', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').hidden, false);
|
|
assert.equal($('#gev-voice-control').dataset.assistantMode, 'chat');
|
|
assert.equal(active(), 'gev-chat-input');
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
|
|
$('#gev-chat-restore').click();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
|
|
$('#gev-chat-close').click();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').hidden, true);
|
|
assert.equal($('#gev-voice-control').dataset.assistantMode, 'idle');
|
|
});
|
|
|
|
test('a typed message sends, shows the reply and is recalled with Up', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
typedChat(f);
|
|
const reduce = globalThis.matchMedia;
|
|
globalThis.matchMedia = () => ({ matches: true });
|
|
t.after(() => (globalThis.matchMedia = reduce));
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
type('Fly to Tokyo');
|
|
$('#gev-chat-form').dispatchEvent(new Event('submit', { cancelable: true }));
|
|
await f.commands.session.start({ textOnly: true });
|
|
for (let i = 0; i < 5; i++) await f.settle();
|
|
const messages = () =>
|
|
[...globalThis.document.querySelectorAll('.gev-chat-message')].map(
|
|
(row) => `${row.dataset.role}:${row.querySelector('span').textContent}`,
|
|
);
|
|
assert.deepEqual(messages(), ['user:Fly to Tokyo']);
|
|
assert.equal($('#gev-chat-input').value, '');
|
|
f.instances[0].hooks.emit({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
text: 'Flying to Tokyo.',
|
|
final: true,
|
|
});
|
|
await f.settle();
|
|
assert.deepEqual(messages(), [
|
|
'user:Fly to Tokyo',
|
|
'assistant:Flying to Tokyo.',
|
|
]);
|
|
assert.equal(f.ui.state.voicePanel.hidden, true);
|
|
key($('#gev-chat-input'), 'ArrowUp');
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-input').value, 'Fly to Tokyo');
|
|
});
|
|
|
|
test('chat Settings keep chat context until the panel closes', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
$('#gev-voice-settings-button').click();
|
|
await f.settle();
|
|
assert.equal(f.ui.state.voicePanel.context, 'chat');
|
|
assert.equal($('#gev-chat-panel').hidden, true);
|
|
assert.equal($('#gev-voice-control').dataset.assistantMode, 'chat');
|
|
assert.equal(active(), 'gev-chat-back');
|
|
key($('#gev-chat-back'), 'Escape');
|
|
await f.settle();
|
|
assert.equal(f.ui.state.voicePanel.context, null);
|
|
assert.equal($('#gev-chat-panel').hidden, false);
|
|
$('#gev-voice-settings-button').click();
|
|
await f.settle();
|
|
$('#gev-chat-back').click();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').hidden, false);
|
|
assert.equal(f.ui.state.chat.backVisible, false);
|
|
});
|
|
|
|
test('chat Settings keep only the quick commands preference', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.settle();
|
|
for (const id of [
|
|
'typewriter',
|
|
'text-color',
|
|
'text-size',
|
|
'autocomplete',
|
|
'recent',
|
|
])
|
|
assert.equal(exists(`#gev-chat-${id}`), false);
|
|
assert.equal(exists('#gev-panel-default-position'), false);
|
|
assert.equal(exists('#gev-chat-quick'), true);
|
|
});
|
|
|
|
test('voice Settings step back to Preview with Back and Escape', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.settle();
|
|
f.commands.voicePanel.select('settings', { focus: true });
|
|
await f.settle();
|
|
assert.equal(active(), 'gev-voice-settings-tab');
|
|
$('#gev-voice-back').click();
|
|
await f.settle();
|
|
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
|
|
f.commands.voicePanel.select('settings');
|
|
key($('#gev-voice-panel'), 'Escape');
|
|
await f.settle();
|
|
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
|
|
key($('#gev-voice-panel'), 'Escape');
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
|
|
});
|
|
|
|
test('Escape does not minimize chat', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
key($('#gev-chat-input'), 'Escape');
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
|
|
assert.equal($('#gev-chat-panel').hidden, false);
|
|
});
|
|
|
|
function dockWing(t) {
|
|
const doc = globalThis.document;
|
|
const wing = doc.createElement('div');
|
|
wing.id = 'control-panel';
|
|
wing.className = 'panel-collapsible collapsed';
|
|
const toggle = doc.createElement('button');
|
|
toggle.className = 'dock-tray-toggle';
|
|
const tray = doc.createElement('div');
|
|
tray.className = 'dock-popover-content';
|
|
wing.append(toggle, tray);
|
|
doc.body.append(wing);
|
|
t.after(() => wing.remove());
|
|
return {
|
|
wing,
|
|
toggle,
|
|
open: () => wing.classList.remove('collapsed'),
|
|
close: () => wing.classList.add('collapsed'),
|
|
};
|
|
}
|
|
|
|
test('reaching for a dock wing minimizes a centred chat or voice panel', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.settle();
|
|
// The dock can mount after the layouts bind; they find it on use.
|
|
const wing = dockWing(t);
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
wing.toggle.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
|
|
// Leaving the wing with no tray open ends the yield.
|
|
wing.wing.dispatchEvent(new PointerEvent('pointerleave'));
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
f.commands.voicePanel.select('preview');
|
|
await f.settle();
|
|
assert.equal($('#gev-voice-panel').dataset.minimized, undefined);
|
|
wing.toggle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
await f.settle();
|
|
assert.equal($('#gev-voice-panel').dataset.minimized, 'true');
|
|
});
|
|
|
|
test('expanding a panel over an open tray docks it right without saving', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
// The view mount resets the body; the layouts bind on the next tick.
|
|
const wing = dockWing(t);
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
wing.toggle.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
|
|
wing.open();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
|
|
$('#gev-chat-restore').click();
|
|
await f.settle();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
|
|
assert.equal($('#gev-chat-panel').dataset.placement, 'docked');
|
|
assert.notEqual(globalThis.localStorage.getItem('gev.panel.docked'), 'true');
|
|
// The dock was only for the tray; it ends once the tray closes.
|
|
wing.close();
|
|
await f.settle();
|
|
await f.settle();
|
|
assert.equal(f.ui.state.layout.docked, false);
|
|
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
|
|
});
|
|
|
|
test('yielding to a dock tray leaves keyboard focus where it is', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
const wing = dockWing(t);
|
|
const field = globalThis.document.createElement('input');
|
|
globalThis.document.body.append(field);
|
|
t.after(() => field.remove());
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
for (const target of [$('#gev-chat-input'), field]) {
|
|
target.focus();
|
|
wing.toggle.dispatchEvent(
|
|
new PointerEvent('pointerover', { bubbles: true }),
|
|
);
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
|
|
assert.equal(globalThis.document.activeElement, target);
|
|
wing.wing.dispatchEvent(new PointerEvent('pointerleave'));
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
|
|
assert.equal(globalThis.document.activeElement, target);
|
|
}
|
|
});
|
|
|
|
test('a panel that yielded to a tray expands in the centre once it closes', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
const wing = dockWing(t);
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
wing.toggle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
wing.open();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
|
|
wing.close();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, undefined);
|
|
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
|
|
assert.equal(f.ui.state.layout.yielded, null);
|
|
});
|
|
|
|
test('switching to voice while chat yields to a tray starts voice as its bar', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
const wing = dockWing(t);
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
wing.toggle.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
f.commands.voicePanel.select('preview');
|
|
await f.settle();
|
|
assert.equal($('#gev-voice-panel').dataset.minimized, 'true');
|
|
assert.equal(f.ui.state.layout.yielded, 'voicePanel');
|
|
});
|
|
|
|
test('undocking while a tray is open brings the panel back as its bar', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
const wing = dockWing(t);
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
$('#gev-chat-panel [data-layout-dock]').click();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.placement, 'docked');
|
|
wing.open();
|
|
await f.settle();
|
|
$('#gev-chat-panel [data-layout-dock]').click();
|
|
await f.settle();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').dataset.minimized, 'true');
|
|
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
|
|
});
|
|
|
|
test('the voice panel close turns voice off', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.commands.session.start();
|
|
f.commands.voicePanel.select('preview');
|
|
await f.settle();
|
|
$('#gev-voice-settings-close').click();
|
|
assert.equal(f.commands.session.isActive(), false);
|
|
assert.equal(f.commands.voicePanel.snapshot().panelOpen, false);
|
|
});
|
|
|
|
test('closing chat Settings ends a typed session', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
typedChat(f);
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.commands.session.start({ textOnly: true });
|
|
await f.settle();
|
|
$('#gev-voice-settings-button').click();
|
|
await f.settle();
|
|
$('#gev-voice-settings-close').click();
|
|
await f.settle();
|
|
assert.equal(f.commands.session.isActive(), false);
|
|
assert.equal($('#gev-chat-panel').hidden, true);
|
|
});
|
|
|
|
test('opening chat stops a live voice session', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.commands.session.start();
|
|
await f.settle();
|
|
assert.equal(f.commands.session.isActive(), true);
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
assert.equal(f.commands.session.isActive(), false);
|
|
assert.equal($('#gev-chat-panel').hidden, false);
|
|
});
|
|
|
|
test('the mic button closes chat and minimize collapses voice to a bar', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
$('#gev-voice-button').click();
|
|
await f.settle();
|
|
assert.equal($('#gev-chat-panel').hidden, true);
|
|
assert.equal(f.commands.session.isActive(), true);
|
|
f.commands.voicePanel.select('preview');
|
|
await f.settle();
|
|
$('#gev-voice-panel [data-layout-minimize]').click();
|
|
await f.settle();
|
|
assert.equal($('#gev-voice-panel').dataset.minimized, 'true');
|
|
f.commands.voicePanel.select('preview');
|
|
await f.settle();
|
|
assert.equal($('#gev-voice-panel').dataset.minimized, undefined);
|
|
});
|
|
|
|
test('teardown inside sendText prevents appending the submitted message', async (t) => {
|
|
const f = mounted(t, { view: true });
|
|
f.instances[0].getStartOptions = () => ({ textOnly: true });
|
|
f.instances[0].sendText = () => {
|
|
f.commands.session.destroy();
|
|
return true;
|
|
};
|
|
await f.settle();
|
|
$('#gev-chat-button').click();
|
|
await f.settle();
|
|
type('Hello');
|
|
$('#gev-chat-form').dispatchEvent(new Event('submit', { cancelable: true }));
|
|
await f.commands.session.start({ textOnly: true });
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
assert.equal(
|
|
globalThis.document.querySelectorAll('.gev-chat-message').length,
|
|
0,
|
|
);
|
|
});
|