Ctrl K
ComunidadGuías…

test/client-readaloud.test.js

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

Archivo original ↗Preguntar a KiBot
// @vitest-environment happy-dom
// Read aloud (public/modules/readaloud.js): the text a message is read as
// (markdown stripped, URLs as "link", Q51 as "question 51", a question's
// choices at the end), cutting it into pieces short enough for Chrome, the
// voice picked, and the speaker: one message at a time, tap again to stop,
// auto-read queued and waiting for a tap after a reload.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
  plainForSpeech, speakableText, chunkForSpeech, pickVoice, englishVoices,
  toggleSpeak, readNew, stopReading, setAutoRead, resumeReading, autoReadOn,
  needsResume, speakingMessage, queuedCount, _resetReadAloud, progressPhrase, progressSpeech, progressHeading,
} from '../public/modules/readaloud.js';

describe('plainForSpeech', () => {
  it('strips markdown emphasis, headings, bullets, quotes and inline code', () => {
    expect(plainForSpeech('## Status\n- **PR 12** is _green_\n> merged `main`'))
      .toBe('Status. PR 12 is green. merged main.');
  });

  it('reads URLs as "link" and markdown links as their text', () => {
    expect(plainForSpeech('See https://github.com/x/y/pull/3 and [the docs](https://example.com/a).'))
      .toBe('See link and the docs.');
    expect(plainForSpeech('<https://a.b/c> or www.example.com')).toBe('link or link.');
  });

  it('expands Q-numbers and #-numbers', () => {
    expect(plainForSpeech('Q51 is about #160.')).toBe('question 51 is about number 160.');
    expect(plainForSpeech('FAQ5 stays')).toBe('FAQ5 stays.');
  });

  it('replaces a fenced code block instead of reading it', () => {
    expect(plainForSpeech('Run this:\n```\nnpm test\n```\nthen tell me')).toBe('Run this: (code block). then tell me.');
  });

  it('leaves snake_case and file paths alone', () => {
    expect(plainForSpeech('set my_var_name in lib/jobs.js')).toBe('set my_var_name in lib/jobs.js.');
  });

  it('handles empty and missing text', () => {
    expect(plainForSpeech('')).toBe('');
    expect(plainForSpeech(undefined)).toBe('');
  });
});

describe('progressPhrase', () => {
  it('says the status line in a few words, without code, URLs, paths, flags or file names', () => {
    expect(progressPhrase({ text: 'Reading the talk code in public/modules/talk.js now' })).toBe('Reading the talk code in now');
    expect(progressPhrase({ text: 'Running `npm test -- --run` on the branch' })).toBe('Running on the branch');
    expect(progressPhrase({ text: 'Checking https://github.com/x/y/pull/3 for review comments' })).toBe('Checking for review comments');
    expect(progressPhrase({ text: 'Fixing the tests: two left, then shipping' })).toBe('Fixing the tests');
    expect(progressPhrase({ text: 'one two three four five six seven eight nine ten' })).toBe('one two three four five six seven eight');
  });

  it('falls back to the progress box line; a tool call becomes plain words or nothing', () => {
    const box = (line) => ({ text: '', currentRequest: 'm1', progress: { m1: ['older', line] } });
    expect(progressPhrase(box('Drafting the reply'))).toBe('Drafting the reply');
    expect(progressPhrase(box('Bash(git status)'))).toBe('Running a command');
    expect(progressPhrase(box('mcp__agent-007-board__list_jobs(...)'))).toBe('');
    expect(progressPhrase(box('`ls -la`'))).toBe('');
    expect(progressPhrase(null)).toBe('');
  });
});

describe('speakableText', () => {
  const q = (extra = {}) => ({ id: 'm1', from: 'billion', text: 'Ship **PR 3**?', q: { id: 'w1', n: 51, status: 'open', choices: ['Not yet', 'Done'], recommended: 'Done', ...extra } });

  it('reads a plain message as its text', () => {
    expect(speakableText({ from: 'billion', text: 'All green. See https://x.y' })).toBe('All green. See link.');
  });

  it('reads an open question with its number and its choices last', () => {
    expect(speakableText(q())).toBe('Question 51. Ship PR 3? Choices: Not yet, Done; recommended: Done.');
  });

  it('leaves the choices out once the question is closed, and the recommendation when there is none', () => {
    expect(speakableText(q({ status: 'answered' }))).toBe('Question 51. Ship PR 3?');
    expect(speakableText(q({ recommended: undefined }))).toBe('Question 51. Ship PR 3? Choices: Not yet, Done.');
  });
});

describe('chunkForSpeech', () => {
  it('keeps short text in one piece and drops blank text', () => {
    expect(chunkForSpeech('Hello there. How are you?')).toEqual(['Hello there. How are you?']);
    expect(chunkForSpeech('  ')).toEqual([]);
  });

  it('splits on sentence ends and packs sentences up to the limit', () => {
    const chunks = chunkForSpeech('One two. Three four. Five six.', 20);
    expect(chunks).toEqual(['One two. Three four.', 'Five six.']);
  });

  it('never returns a piece over the limit, splitting at commas, then spaces, then inside a word', () => {
    const long = `${'word, '.repeat(30)}${'x'.repeat(50)} end.`;
    const chunks = chunkForSpeech(long, 40);
    expect(chunks.every(c => c.length <= 40)).toBe(true);
    expect(chunks.join(' ').replace(/\s+/g, '')).toBe(long.replace(/\s+/g, ''));
  });

  it('cuts a long message into pieces under the default limit', () => {
    const text = 'This is a sentence that goes on for a while. '.repeat(20);
    const chunks = chunkForSpeech(text);
    expect(chunks.length).toBeGreaterThan(3);
    expect(chunks.every(c => c.length <= 180)).toBe(true);
  });
});

describe('pickVoice', () => {
  const v = (name, lang, extra = {}) => ({ name, lang, voiceURI: `uri:${name}`, ...extra });
  const voices = [v('Thomas', 'fr-FR'), v('Daniel', 'en-GB'), v('Fred', 'en-US', { default: true }), v('Ava (Premium)', 'en-US'), v('Google UK English', 'en-GB')];

  it('prefers en-US high-quality voices', () => {
    expect(pickVoice(voices).name).toBe('Ava (Premium)');
    expect(pickVoice([v('Daniel', 'en-GB'), v('Google US English', 'en-US'), v('Fred', 'en-US')]).name).toBe('Google US English');
  });

  it('takes the saved voice while it is installed', () => {
    expect(pickVoice(voices, 'uri:Daniel').name).toBe('Daniel');
    expect(pickVoice(voices, 'uri:gone').name).toBe('Ava (Premium)');
  });

  it('falls back to any voice when none is English, and null when there are none', () => {
    expect(pickVoice([v('Thomas', 'fr-FR')]).name).toBe('Thomas');
    expect(pickVoice([])).toBeNull();
  });

  it('lists only English voices, by name', () => {
    expect(englishVoices(voices).map(x => x.name)).toEqual(['Ava (Premium)', 'Daniel', 'Fred', 'Google UK English']);
  });
});

describe('the speaker', () => {
  let spoken;
  beforeEach(() => {
    spoken = [];
    localStorage.clear();
    window.SpeechSynthesisUtterance = class { constructor(text) { this.text = text; } };
    window.speechSynthesis = {
      speak: vi.fn(u => spoken.push(u)),
      cancel: vi.fn(() => { spoken = []; }),
      resume: vi.fn(),
      getVoices: () => [{ name: 'Ava (Premium)', lang: 'en-US', voiceURI: 'ava' }],
    };
    _resetReadAloud();
  });
  const endAll = () => { const last = spoken.at(-1); last.onend?.(); };

  it('speaks a message in pieces with the best voice, and a second tap stops it', () => {
    toggleSpeak('m1', 'First sentence. '.repeat(30));
    expect(speakingMessage()).toBe('m1');
    expect(spoken.length).toBeGreaterThan(1);
    expect(spoken[0].voice.name).toBe('Ava (Premium)');
    toggleSpeak('m1', 'ignored');
    expect(speakingMessage()).toBeNull();
    expect(window.speechSynthesis.cancel).toHaveBeenCalled();
  });

  it('tapping another message stops the first and speaks the second', () => {
    toggleSpeak('m1', 'One.');
    toggleSpeak('m2', 'Two.');
    expect(speakingMessage()).toBe('m2');
    expect(spoken.map(u => u.text)).toEqual(['Two.']);
  });

  it('is done when the last piece ends', () => {
    toggleSpeak('m1', 'One.');
    endAll();
    expect(speakingMessage()).toBeNull();
  });

  it('auto-read is off by default and new messages are not spoken', () => {
    expect(autoReadOn()).toBe(false);
    readNew('m1', 'Hello.');
    expect(spoken).toEqual([]);
  });

  it('with auto-read on, new messages are queued and spoken one after another', () => {
    setAutoRead(true);
    expect(localStorage.getItem('agent007-read-aloud')).toBe('1');
    expect(speakingMessage()).toBe('__auto__');   // the confirmation, spoken inside the tap
    readNew('m1', 'First.');
    readNew('m2', 'Second.');
    readNew('m2', 'Second.');                       // the same message is queued once
    expect(queuedCount()).toBe(2);
    endAll();
    expect(speakingMessage()).toBe('m1');
    endAll();
    expect(speakingMessage()).toBe('m2');
    endAll();
    expect([speakingMessage(), queuedCount()]).toEqual([null, 0]);
  });

  it('a speech error moves on to the next message', () => {
    setAutoRead(true);
    readNew('m1', 'First.');
    readNew('m2', 'Second.');
    endAll();
    const cancels = window.speechSynthesis.cancel.mock.calls.length;
    spoken.at(-1).onerror({ error: 'synthesis-failed' });
    expect(speakingMessage()).toBe('m2');
    // The failed message's queued pieces are dropped, not left to play over m2.
    expect(window.speechSynthesis.cancel.mock.calls.length).toBe(cancels + 1);
  });

  it('after a reload, auto-read waits for one tap on Resume reading', () => {
    localStorage.setItem('agent007-read-aloud', '1');
    expect(needsResume()).toBe(true);
    readNew('m1', 'First.');
    expect(spoken).toEqual([]);
    expect(queuedCount()).toBe(1);
    resumeReading();
    expect(needsResume()).toBe(false);
    expect(speakingMessage()).toBe('m1');
  });

  it('turning auto-read off stops reading and forgets the setting', () => {
    setAutoRead(true);
    readNew('m1', 'First.');
    setAutoRead(false);
    expect([speakingMessage(), queuedCount(), localStorage.getItem('agent007-read-aloud')]).toEqual([null, 0, null]);
  });

  it('stopReading drops the queue', () => {
    setAutoRead(true);
    readNew('m1', 'First.');
    stopReading();
    expect([speakingMessage(), queuedCount()]).toEqual([null, 0]);
  });
});

describe('progressSpeech', () => {
  it('says the status line, else the box heading of the current request, else nothing', () => {
    expect(progressSpeech({ text: 'Drafting the reply', working: true, currentRequest: 'a' })).toBe('Drafting the reply');
    expect(progressSpeech({ text: '', working: true, currentRequest: 'a' })).toBe('Billion is working on this');
    expect(progressSpeech({ text: '', working: false, currentRequest: 'a' })).toBe('Billion is working on this');
    expect(progressSpeech({ text: '', working: true, currentRequest: null })).toBe('');
    expect(progressSpeech({ text: '', currentRequest: 'm1', progress: { m1: [{ text: 'Checking the job board', at: 'x', kind: 'tool' }] } })).toBe('Checking the job board');
    expect(progressHeading({ working: true }, false)).toBe('Billion has this, and is answering your earlier message first');
    expect(progressHeading({ working: true }, true, true)).toBe('Waiting for Billion to finish its current step');
  });
});