test/client-readaloud.test.js
Rama main · réplica de lectura del repositorio original.
// @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');
});
});