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 } 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 { 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) }) })