fix(tts): pass Windows TTS text via env and let stop() only cancel its own playback

This commit is contained in:
Yun Chan 2026-09-28 00:53:46 +09:00
parent 9cd81b48c1
commit 91d4b4974c
3 changed files with 339 additions and 95 deletions

View file

@ -0,0 +1,196 @@
import { EventEmitter } from 'events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
WINDOWS_TTS_TEXT_ENV,
buildMacSayCommand,
buildWindowsSapiCommand,
macSayRate,
windowsSapiRate,
} from '../../../src/main/services/tts/tts-command'
const childProcess = vi.hoisted(() => ({ spawn: vi.fn() }))
vi.mock('child_process', () => childProcess)
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn(() => undefined),
}))
type FakeProcess = EventEmitter & { kill: ReturnType<typeof vi.fn> }
interface SpawnCall {
command: string
args: string[]
options: { env?: NodeJS.ProcessEnv; windowsHide?: boolean }
proc: FakeProcess
}
let calls: SpawnCall[] = []
/** true 면 spawn 된 프로세스가 바로 exit 0 으로 닫힌다. */
let autoClose = true
function spokenText(call: SpawnCall): string | undefined {
return call.options.env?.[WINDOWS_TTS_TEXT_ENV]
}
const originalPlatform = process.platform
beforeEach(() => {
vi.resetModules()
calls = []
autoClose = true
Object.defineProperty(process, 'platform', { value: 'win32' })
childProcess.spawn.mockImplementation(
(command: string, args: string[], options: SpawnCall['options']) => {
const proc = Object.assign(new EventEmitter(), {
kill: vi.fn(() => {
queueMicrotask(() => proc.emit('close', null))
}),
})
calls.push({ command, args, options, proc })
if (autoClose) queueMicrotask(() => proc.emit('close', 0))
return proc
},
)
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
async function loadService() {
const mod = await import('../../../src/main/services/TTSPlaybackService')
mod.resetTTSPlaybackServiceForTests()
return mod.getTTSPlaybackService()
}
async function flush(): Promise<void> {
for (let i = 0; i < 5; i++) await Promise.resolve()
}
describe('tts-command (순수 정책)', () => {
it('Windows 스크립트에 텍스트를 보간하지 않고 환경변수로만 전달한다', () => {
const payload = "x’); Write-Output INJECTED; (’y"
const cmd = buildWindowsSapiCommand(payload, 1)
expect(cmd.command).toBe('powershell')
expect(cmd.windowsHide).toBe(true)
expect(cmd.args.join(' ')).not.toContain('INJECTED')
expect(cmd.args.join(' ')).toContain(`$env:${WINDOWS_TTS_TEXT_ENV}`)
expect(cmd.extraEnv).toEqual({ [WINDOWS_TTS_TEXT_ENV]: payload })
})
it.each(['I don’t know', 'It‘s', '‚odd‛', "plain 'ascii'", '줄1\n줄2'])(
'따옴표·개행이 섞인 텍스트(%s)도 원문 그대로 전달된다',
(text) => {
const cmd = buildWindowsSapiCommand(text, undefined)
expect(cmd.extraEnv?.[WINDOWS_TTS_TEXT_ENV]).toBe(text)
expect(cmd.args[3]).not.toContain(text)
},
)
it('macOS say 는 -- 뒤 argv 로 전달한다', () => {
const cmd = buildMacSayCommand('-v hi', 0.5)
expect(cmd).toEqual({ command: 'say', args: ['-r', '90', '--', '-v hi'], windowsHide: false })
})
it('rate 변환을 유지하고 SAPI 범위를 넘지 않는다', () => {
expect(macSayRate(undefined)).toBe(180)
expect(macSayRate(2)).toBe(360)
expect(windowsSapiRate(undefined)).toBe(0)
expect(windowsSapiRate(1)).toBe(0)
expect(windowsSapiRate(0.5)).toBe(-2)
expect(windowsSapiRate(2)).toBe(5)
expect(windowsSapiRate(10)).toBe(10)
expect(windowsSapiRate(Number.NaN)).toBe(0)
expect(windowsSapiRate(Number.POSITIVE_INFINITY)).toBe(0)
})
})
describe('TTSPlaybackService — Windows 텍스트 전달', () => {
it('타이포그래픽 따옴표가 든 문장을 스크립트가 아니라 환경변수로 넘긴다', async () => {
const tts = await loadService()
await tts.speak('I don’t know')
expect(calls).toHaveLength(1)
const [call] = calls
expect(call.command).toBe('powershell')
expect(call.options.windowsHide).toBe(true)
expect(call.args.join(' ')).not.toContain('don’t')
expect(spokenText(call)).toBe('I don’t know')
// 부모 환경은 유지된다
expect(call.options.env?.PATH ?? call.options.env?.Path).toBe(process.env.PATH ?? process.env.Path)
})
})
describe('TTSPlaybackService — stop() 이후 새 요청', () => {
it('stop() 직후 speakSentences 가 모든 문장을 재생한다', async () => {
const tts = await loadService()
const finished = vi.fn()
tts.on('finished', finished)
tts.stop()
await tts.speakSentences(['hello', 'world'])
expect(calls.map(spokenText)).toEqual(['hello', 'world'])
expect(finished).toHaveBeenCalledTimes(1)
})
it('빈 문장은 건너뛴다', async () => {
const tts = await loadService()
await tts.speakSentences([' ', 'a', ''])
expect(calls.map(spokenText)).toEqual(['a'])
})
it('재생 중 stop() 후 새 요청이 와도 이전 루프가 새 큐를 가로채지 않는다', async () => {
autoClose = false
const tts = await loadService()
const finished = vi.fn()
tts.on('finished', finished)
const first = tts.speakSentences(['a', 'b'])
await flush()
expect(calls.map(spokenText)).toEqual(['a'])
tts.stop()
expect(calls[0].proc.kill).toHaveBeenCalledTimes(1)
const second = tts.speakSentences(['c', 'd'])
await flush()
// 이전 프로세스(kill → close null)가 닫혀도 이전 루프는 종료돼야 한다
await first
expect(calls.map(spokenText)).toEqual(['a', 'c'])
expect(tts.isSpeaking).toBe(true)
calls[1].proc.emit('close', 0)
await flush()
expect(calls.map(spokenText)).toEqual(['a', 'c', 'd'])
calls[2].proc.emit('close', 0)
await second
expect(calls.map(spokenText)).toEqual(['a', 'c', 'd'])
expect(finished).toHaveBeenCalledTimes(1)
expect(tts.isSpeaking).toBe(false)
})
it('stop() 으로 끊긴 요청은 finished 를 내지 않는다', async () => {
autoClose = false
const tts = await loadService()
const finished = vi.fn()
const stopped = vi.fn()
tts.on('finished', finished)
tts.on('stopped', stopped)
const pending = tts.speakSentences(['a', 'b'])
await flush()
tts.stop()
await pending
expect(calls.map(spokenText)).toEqual(['a'])
expect(stopped).toHaveBeenCalledTimes(1)
expect(finished).not.toHaveBeenCalled()
expect(tts.isSpeaking).toBe(false)
})
})