// src/voice/chatControlView.test.mjs — the typed chat's owner and its view: // the reply reveal, Stop, the attach menu's layer choice, the conversation's // scrolling and what screen readers are told. import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { bindChatControl } from './chatControl.js'; import { CLOUD_VOICE_AUTH_STORAGE_KEY } from './cloudVoiceAuth.js'; import { createTestVoiceUi } from '../testSupport/voiceUi.mjs'; import { mountVoiceView } from '../testSupport/voiceView.mjs'; const $ = (selector) => document.querySelector(selector); const flush = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); }; // A typed session that connects at once and sends what it is given. function typedSession({ cancelTurn = false } = {}) { const listeners = new Set(); const session = { state: 'idle', sent: [], stops: 0, cancels: 0, adapter: { getStartOptions: () => ({ textOnly: true }), ...(cancelTurn && { cancelTurn() { session.cancels++; }, }), }, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, emit(event) { for (const fn of [...listeners]) fn(event); }, setState(state) { session.state = state; session.emit({ type: 'state', state }); }, isActive: () => !['idle', 'error'].includes(session.state), start() { session.setState('listening'); }, stop() { session.stops++; session.setState('idle'); }, sendText(text) { session.sent.push(text); return true; }, }; return session; } // Animation frames run only when the test steps them. function manualFrames(t) { const original = { request: globalThis.requestAnimationFrame, cancel: globalThis.cancelAnimationFrame, }; const queue = new Map(); let next = 0; let time = 0; globalThis.requestAnimationFrame = (fn) => { queue.set(++next, fn); return next; }; globalThis.cancelAnimationFrame = (id) => queue.delete(id); t.after(() => { globalThis.requestAnimationFrame = original.request; globalThis.cancelAnimationFrame = original.cancel; }); return { get pending() { return queue.size; }, step() { time += 16; const due = [...queue.values()]; queue.clear(); for (const fn of due) fn(time); }, }; } function chat(t, { pinia, session = typedSession(), dataManager } = {}) { const ui = createTestVoiceUi({ panel: true, pinia }); const control = bindChatControl({ ui, session, dataManager }); t.after(() => control.destroy()); const send = async (text) => { ui.emit('chat-input', text); ui.emit('chat-submit'); await flush(); }; const reply = (text, extra = {}) => session.emit({ type: 'transcript', role: 'assistant', itemId: 'reply-1', text, ...extra, }); const texts = () => ui.chatMessages.map((m) => `${m.role}:${m.text}`); return { ui, session, control, send, reply, texts }; } test('a revealed reply writes only the message list each frame', async (t) => { const frames = manualFrames(t); const { ui, send, reply, texts } = chat(t); ui.emit('chat-button'); await send('Fly to Tokyo'); assert.equal(ui.state.chat.miniReply, 'Thinking…'); const text = 'Flying to Tokyo now. '.repeat(8).trim(); reply(text, { final: true }); assert.equal(ui.state.chat.revealing, true); const control = ui.state; const seen = new Set(); for (let guard = 0; frames.pending; guard++) { if (guard > 200) assert.fail('the reveal did not finish'); frames.step(); if (!frames.pending) break; seen.add(texts().at(-1)); // Mid-reveal the control itself is not republished. assert.equal(ui.state, control); } assert.ok(seen.size > 1, 'the reply revealed over several frames'); assert.equal(texts().at(-1), `assistant:${text}`); // The minimized bar's reply and the log's busy state change once, at the end. assert.equal(ui.state.chat.revealing, false); assert.equal(ui.state.chat.miniReply, text); }); test('Stop cancels only the reply in flight and keeps the conversation', async (t) => { manualFrames(t); const session = typedSession({ cancelTurn: true }); const { ui, send, reply, texts } = chat(t, { session }); ui.emit('chat-button'); await send('Fly to Tokyo'); reply('Flying'); assert.equal(ui.state.chat.busy, true); ui.emit('chat-stop'); assert.equal(session.cancels, 1); assert.equal(session.stops, 0); assert.equal(session.isActive(), true); assert.equal(ui.state.chat.busy, false); assert.equal(ui.state.chat.status, 'Reply stopped.'); assert.equal(ui.state.chat.miniReply, 'Reply stopped.'); assert.deepEqual(texts(), ['user:Fly to Tokyo', 'assistant:Flying']); // The cancelled turn's own end and late text keep the stop as it was. reply(' to Tokyo.'); session.emit({ type: 'completion', status: 'cancelled' }); assert.equal(ui.state.chat.status, 'Reply stopped.'); assert.deepEqual(texts(), ['user:Fly to Tokyo', 'assistant:Flying']); // The same session takes the next message. await send('Show live flights'); assert.equal(session.sent.length, 2); assert.equal(ui.state.chat.busy, true); }); test('Stop ends the session when its adapter cannot cancel a turn', async (t) => { manualFrames(t); const { ui, session, send } = chat(t); ui.emit('chat-button'); await send('Fly to Tokyo'); ui.emit('chat-stop'); assert.equal(session.stops, 1); assert.equal(ui.state.chat.busy, false); assert.equal(ui.state.chat.status, 'Reply stopped.'); }); test('with ChatGPT sign-in chosen for OpenAI voice, chat says it uses the API key', (t) => { t.after(() => localStorage.removeItem(CLOUD_VOICE_AUTH_STORAGE_KEY)); const { ui } = chat(t); const note = 'Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.'; ui.update('root', { provider: 'openai' }); ui.emit('chat-button'); assert.equal(ui.state.chat.authNote, ''); ui.emit('chat-close'); localStorage.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, 'oauth'); ui.emit('chat-button'); assert.equal(ui.state.chat.authNote, note); ui.emit('chat-close'); ui.update('root', { provider: 'gemini' }); ui.emit('chat-button'); assert.equal(ui.state.chat.authNote, ''); }); test('the conversation is busy for screen readers while a reply types out', async (t) => { const frames = manualFrames(t); const { pinia, settle } = mountVoiceView(t); const { ui, session, send, reply } = chat(t, { pinia }); ui.emit('chat-button'); await settle(); const log = $('#gev-chat-messages'); const phase = $('#gev-assistant-phase'); assert.equal(log.getAttribute('aria-busy'), null); // In chat, the chat's own status line is the one status region. assert.equal(phase.getAttribute('aria-hidden'), 'true'); assert.equal(phase.getAttribute('aria-live'), 'off'); await send('Fly to Tokyo'); await settle(); assert.equal(log.getAttribute('aria-busy'), 'true'); reply('Flying to Tokyo now.', { final: true }); session.emit({ type: 'completion', status: 'completed' }); frames.step(); await settle(); // Done thinking, still typing out. assert.equal(ui.state.chat.busy, false); assert.equal(log.getAttribute('aria-busy'), 'true'); while (frames.pending) frames.step(); await settle(); assert.equal(log.getAttribute('aria-busy'), null); ui.emit('chat-close'); await settle(); assert.equal(phase.getAttribute('aria-hidden'), null); assert.equal(phase.getAttribute('aria-live'), 'polite'); }); test('a layer attaches with the Attach button or Enter, not as the choice moves', async (t) => { const { pinia, settle } = mountVoiceView(t); const dataManager = { layers: new Map([ ['flights', { module: { name: 'Live flights' } }], ['quakes', { module: { name: 'Earthquakes' } }], ]), isEnabled: () => true, }; const { ui } = chat(t, { pinia, dataManager }); ui.emit('chat-button'); await settle(); $('#gev-chat-attach').click(); await settle(); const select = $('#gev-chat-layer'); const attach = $('#gev-chat-layer-attach'); assert.equal(attach.disabled, true); // Arrow keys on a closed select change it once per press. for (const value of ['flights', 'quakes']) { select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); await settle(); } assert.deepEqual(ui.state.chat.attachments, []); assert.equal(attach.disabled, false); attach.click(); await settle(); assert.deepEqual( ui.state.chat.attachments.map((item) => item.name), ['Earthquakes'], ); $('#gev-chat-attach').click(); await settle(); select.value = 'flights'; select.dispatchEvent(new Event('change', { bubbles: true })); const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, }); select.dispatchEvent(enter); await settle(); assert.equal(enter.defaultPrevented, true); assert.deepEqual( ui.state.chat.attachments.map((item) => item.name), ['Earthquakes', 'Live flights'], ); }); test('the conversation follows new messages only while read at the bottom', async (t) => { const { pinia, settle } = mountVoiceView(t); const { ui } = chat(t, { pinia }); ui.emit('chat-button'); await settle(); const list = $('#gev-chat-messages'); let top = 0; Object.defineProperties(list, { scrollHeight: { configurable: true, get: () => 1000 }, clientHeight: { configurable: true, get: () => 200 }, scrollTop: { configurable: true, get: () => top, set: (value) => { top = Math.min(value, 800); }, }, }); const show = async (...messages) => { ui.setChatMessages( messages.map(([role, text], index) => ({ id: index + 1, role, text })), ); await settle(); }; await show(['user', 'Fly to Tokyo'], ['assistant', 'Fly']); assert.equal(top, 800); // The reader scrolls up; the reply keeps typing out below them. top = 300; list.dispatchEvent(new Event('scroll')); await show(['user', 'Fly to Tokyo'], ['assistant', 'Flying to Tokyo.']); assert.equal(top, 300); // Sending brings them back to the newest message. await show( ['user', 'Fly to Tokyo'], ['assistant', 'Flying to Tokyo.'], ['user', 'Show live flights'], ); assert.equal(top, 800); list.dispatchEvent(new Event('scroll')); await show( ['user', 'Fly to Tokyo'], ['assistant', 'Flying to Tokyo.'], ['user', 'Show live flights'], ['assistant', 'Showing'], ); assert.equal(top, 800); }); test('a message sent from higher up a full, capped log is still followed', async (t) => { manualFrames(t); const { pinia, settle } = mountVoiceView(t); const { ui, session, send, reply } = chat(t, { pinia }); ui.emit('chat-button'); for (let turn = 1; turn <= 30; turn++) { await send(`Request ${turn}`); reply(`Answer ${turn}`, { itemId: `reply-${turn}`, final: true }); session.emit({ type: 'completion', status: 'completed' }); } await settle(); // The log keeps its newest 60 messages, so sending no longer lengthens it. assert.equal(ui.chatMessages.length, 60); const list = $('#gev-chat-messages'); let top = 800; Object.defineProperties(list, { scrollHeight: { configurable: true, get: () => 1000 }, clientHeight: { configurable: true, get: () => 200 }, scrollTop: { configurable: true, get: () => top, set: (value) => { top = Math.min(value, 800); }, }, }); top = 300; list.dispatchEvent(new Event('scroll')); await send('Show live flights'); await settle(); assert.equal(ui.chatMessages.length, 60); assert.equal(ui.chatMessages.at(-1).text, 'Show live flights'); assert.equal(top, 800, 'the new request is brought into view'); }); test('the chat gear is named for the settings it opens', async (t) => { const { pinia, settle } = mountVoiceView(t); const { ui } = chat(t, { pinia }); ui.emit('chat-button'); await settle(); assert.equal( $('#gev-voice-settings-button').getAttribute('aria-label'), 'Settings', ); });