Ctrl K
ComunidadGuías…

test/client-voice.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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();
  });
});