test/client-voice.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../public/modules/ws.js', () => ({ send: vi.fn(() => true) }));
import { send } from '../public/modules/ws.js';
import { agents, setActiveSession, setSelf } from '../public/modules/state.js';
import {
normalizeTranscript, deliverToActivePty,
collectResults, interimTail, recognitionErrorMessage, onEndAction,
mediaErrorMessage, appendTranscript, stopVoice, voiceTarget,
} from '../public/modules/voice.js';
describe('normalizeTranscript (voice input)', () => {
it('trims and appends a single separating space', () => {
expect(normalizeTranscript('fix the login bug')).toBe('fix the login bug ');
expect(normalizeTranscript(' fix the login bug ')).toBe('fix the login bug ');
});
it('collapses internal whitespace and newlines', () => {
expect(normalizeTranscript('run the\n tests')).toBe('run the tests ');
});
it('returns empty string for blank input (nothing sent to the pty)', () => {
expect(normalizeTranscript('')).toBe('');
expect(normalizeTranscript(' \n ')).toBe('');
});
});
describe('collectResults (recognition result batch)', () => {
const final = (t) => ({ isFinal: true, 0: { transcript: t } });
const partial = (t) => ({ isFinal: false, 0: { transcript: t } });
it('separates normalized finals from concatenated interim text', () => {
const { finals, interim } = collectResults([final(' fix the bug '), partial('and then'), partial(' run tests')], 0);
expect(finals).toEqual(['fix the bug ']);
expect(interim).toBe('and then run tests');
});
it('respects the start index (already-processed results are skipped)', () => {
const { finals, interim } = collectResults([final('old'), final('new')], 1);
expect(finals).toEqual(['new ']);
expect(interim).toBe('');
});
it('drops finals that normalize to nothing', () => {
const { finals } = collectResults([final(' \n ')], 0);
expect(finals).toEqual([]);
});
});
describe('interimTail (indicator shows the words being spoken now)', () => {
it('passes short interim text through unchanged', () => {
expect(interimTail('hello world')).toBe('hello world');
});
it('keeps only the tail of long interim text, prefixed with an ellipsis', () => {
const long = 'x'.repeat(100) + 'the end';
const tail = interimTail(long);
expect(tail.startsWith('…')).toBe(true);
expect(tail.endsWith('the end')).toBe(true);
expect(tail.length).toBe(81); // '…' + last 80 chars
});
});
describe('recognitionErrorMessage (error code → user message)', () => {
it('ignores normal-operation codes (silence timeout, own abort)', () => {
expect(recognitionErrorMessage('no-speech')).toBeNull();
expect(recognitionErrorMessage('aborted')).toBeNull();
});
it('maps permission and hardware failures to specific messages', () => {
expect(recognitionErrorMessage('not-allowed')).toMatch(/access denied/);
expect(recognitionErrorMessage('service-not-allowed')).toMatch(/access denied/);
expect(recognitionErrorMessage('audio-capture')).toMatch(/No microphone/);
});
it('falls back to a generic message naming the code', () => {
expect(recognitionErrorMessage('network')).toBe('Voice input error: network');
});
});
describe('mediaErrorMessage (getUserMedia rejection → guidance)', () => {
it('maps missing hardware to "no microphone"', () => {
expect(mediaErrorMessage('NotFoundError')).toBe('No microphone found');
expect(mediaErrorMessage('DevicesNotFoundError')).toBe('No microphone found');
});
it('maps a busy device to "in use", not a permission complaint', () => {
expect(mediaErrorMessage('NotReadableError')).toMatch(/in use by another app/);
expect(mediaErrorMessage('TrackStartError')).toMatch(/in use by another app/);
});
it('maps transient and policy failures away from the settings advice', () => {
expect(mediaErrorMessage('AbortError')).toMatch(/try again/);
expect(mediaErrorMessage('SecurityError')).toMatch(/policy/);
});
it('treats true denials — including nameless rejections — as denial', () => {
expect(mediaErrorMessage('NotAllowedError')).toMatch(/access denied/);
expect(mediaErrorMessage(undefined)).toMatch(/access denied/);
});
});
describe('onEndAction (bounded-mic bookkeeping)', () => {
it('restarts while the silence budget and session cap allow', () => {
expect(onEndAction(1, 1000)).toBe('restart');
expect(onEndAction(7, 1000)).toBe('restart');
});
it('pauses once the silence budget (8 restarts) is spent', () => {
expect(onEndAction(8, 1000)).toBe('pause');
expect(onEndAction(9, 1000)).toBe('pause');
});
it('expires past the 5-minute session cap', () => {
expect(onEndAction(1, 5 * 60 * 1000 + 1)).toBe('expire');
expect(onEndAction(1, 5 * 60 * 1000)).toBe('restart'); // boundary: cap is exclusive
});
it('spent silence budget wins over an expired session', () => {
expect(onEndAction(8, 10 * 60 * 1000)).toBe('pause');
});
});
describe('deliverToActivePty (voice → pty wiring)', () => {
beforeEach(() => {
agents.clear();
setActiveSession(null);
setSelf(null, false);
send.mockClear();
send.mockReturnValue(true);
});
it('sends the finalized transcript as pty-input to the active session', () => {
agents.set('s1', { ownerId: null });
setActiveSession('s1');
expect(deliverToActivePty('fix the bug ')).toBe(true);
expect(send).toHaveBeenCalledWith({ type: 'pty-input', sessionId: 's1', data: 'fix the bug ' });
});
it('sends nothing when no session is active', () => {
expect(deliverToActivePty('hello ')).toBe(false);
expect(send).not.toHaveBeenCalled();
});
it('sends nothing when the active session has no agent entry', () => {
setActiveSession('gone');
expect(deliverToActivePty('hello ')).toBe(false);
expect(send).not.toHaveBeenCalled();
});
it('sends nothing for a view-only agent (auth on, owned by someone else)', () => {
setSelf('u_a', true);
agents.set('s2', { ownerId: 'u_b' });
setActiveSession('s2');
expect(deliverToActivePty('rm -rf tmp ')).toBe(false);
expect(send).not.toHaveBeenCalled();
});
it('sends nothing to an ended (DISCONNECTED) session — mirrors the server exited-drop', () => {
agents.set('s1', { ownerId: null, state: 'DISCONNECTED' });
setActiveSession('s1');
expect(deliverToActivePty('hello ')).toBe(false);
expect(send).not.toHaveBeenCalled();
});
it('reports delivery failure when the socket is down', () => {
agents.set('s1', { ownerId: null });
setActiveSession('s1');
send.mockReturnValue(false);
expect(deliverToActivePty('hello ')).toBe(false);
expect(send).toHaveBeenCalledTimes(1);
});
});
describe('appendTranscript (dictation into a text box)', () => {
it('appends to an empty box as is', () => {
expect(appendTranscript('', 'yes ship it ')).toBe('yes ship it ');
});
it('puts one space between typed text and the dictated chunk', () => {
expect(appendTranscript('Done', 'and merge ')).toBe('Done and merge ');
expect(appendTranscript('Done ', 'and merge ')).toBe('Done and merge ');
expect(appendTranscript('Done\n', 'and merge ')).toBe('Done\nand merge ');
});
it('leaves the box alone for an empty chunk', () => {
expect(appendTranscript('Done', '')).toBe('Done');
expect(appendTranscript(undefined, 'hi ')).toBe('hi ');
});
});
describe('stopVoice({ only }) (one mic, several targets)', () => {
it('is a no-op when the mic is off, whatever the target', () => {
expect(voiceTarget()).toBeNull();
expect(() => stopVoice({ only: 'terminal' })).not.toThrow();
expect(() => stopVoice({ only: 'chat' })).not.toThrow();
expect(voiceTarget()).toBeNull();
});
});