Files
petal/web/src/audio/speech.test.ts
T
prosolis 466055020f The garden learns which language a card is in, and read-aloud stops guessing
Phase 28 (c), the last of the phase. A word met inside a Portuguese
document is a Portuguese card: migration 0018 mirrors documents.doc_lang
onto vocab_words, set server-side from the ownership lookup capture was
already making. Every card still reviews — filtering the queue to the
half she is learning would drop the words she actually met.

Read-aloud was the larger surprise. detectLang routed Han/kana to
Chinese and everything else to en-US, so the zh pair was accidentally
right and every Latin pair wrong. doc_lang now reaches the client
read-only on the document JSON, and docLang(text, verdict) answers for a
passage taken out of it — with the script test still winning, because
quoted Chinese must never be spelled out one "Chinese letter" at a time.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:45:26 -07:00

118 lines
4.1 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { docLang, nativeLang, speak, stopSpeech } from './speech'
import { resetPackForTests, setPackLang } from '../i18n'
// Read-aloud has two jobs beyond "make a sound": ask for the right pace, and ask
// in the right language. Both are decided at the call site and travel in the
// request body, so this checks the body — the part a component author can get
// wrong without anything failing loudly.
let bodies: Array<Record<string, unknown>>
beforeEach(() => {
bodies = []
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init: RequestInit) => {
bodies.push(JSON.parse(String(init.body)))
// Never resolves to audio: the fallback path needs no window.Audio here,
// and rejecting would run the Web Speech branch instead of the server one.
return new Promise(() => {})
}),
)
})
afterEach(() => {
stopSpeech()
vi.unstubAllGlobals()
resetPackForTests()
})
describe('speak', () => {
it('asks for the normal pace by default', () => {
speak('reception')
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: 'reception', lang: 'en-US', slow: false })
})
it('asks for the slow replay when the slow control is used', () => {
speak('reception', undefined, true)
expect(bodies[0]).toMatchObject({ text: 'reception', slow: true })
})
it('still detects Chinese by script, so a zh selection is never read in English', () => {
speak('你好世界')
expect(bodies[0]).toMatchObject({ lang: 'zh-CN' })
})
it('sends nothing for empty text', () => {
speak(' ')
expect(bodies).toHaveLength(0)
})
})
describe('nativeLang', () => {
// The voice for her own language comes from the pack, not from the letters.
// "comum" is spelled the same in both halves of the pt pair, so a detector
// would have to guess; the component that knows it is rendering her language
// says so instead.
it('follows the pair language', () => {
setPackLang('zh')
expect(nativeLang()).toBe('zh-CN')
setPackLang('pt-PT')
expect(nativeLang()).toBe('pt-PT')
setPackLang('fr')
expect(nativeLang()).toBe('fr-FR')
})
it('names a European Portuguese voice, never a Brazilian one', () => {
setPackLang('pt-PT')
expect(nativeLang()).not.toBe('pt-BR')
})
it('is what a Latin-pair lookup speaks the other reading in', () => {
setPackLang('pt-PT')
speak('comum', nativeLang())
expect(bodies[0]).toMatchObject({ text: 'comum', lang: 'pt-PT' })
})
it('speaks the French reading of a collision in French', () => {
// "chat" is the sharpest case in the fr pair: an English word, a French
// word, and the companion's own animal. Nothing about the letters says
// which — only the pack does.
setPackLang('fr')
speak('chat', nativeLang())
expect(bodies.at(-1)).toMatchObject({ text: 'chat', lang: 'fr-FR' })
})
})
describe('docLang', () => {
// The document's verdict is the answer for a passage lifted out of it, because
// for a Latin pair there is no other answer available: an English sentence and
// a Portuguese one are the same letters.
it('reads a flipped document in her own language', () => {
setPackLang('pt-PT')
expect(docLang('Ontem foi difícil.', 'pair')).toBe('pt-PT')
})
it('reads an English document in English, and treats the backfill as English', () => {
setPackLang('pt-PT')
expect(docLang('Yesterday was hard.', 'en')).toBe('en-US')
expect(docLang('Yesterday was hard.', '')).toBe('en-US')
})
it('lets the script win over the verdict, so quoted Chinese is never spelled out', () => {
// An English document quoting Chinese is 'en' by verdict, and the English
// voice reads Han characters one "Chinese letter" at a time — the one
// failure worse than silence.
setPackLang('zh')
expect(docLang('你好世界', 'en')).toBe('zh-CN')
})
it('is what the editor selection and the garden card ask for', () => {
setPackLang('fr')
speak('Le chat dort.', docLang('Le chat dort.', 'pair'))
expect(bodies.at(-1)).toMatchObject({ text: 'Le chat dort.', lang: 'fr-FR' })
})
})