d3ro-voice/apps/desktop/tests/main/services/tts-redteam-r1-14.test.ts

196 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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