196 lines
6.4 KiB
TypeScript
196 lines
6.4 KiB
TypeScript
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)
|
||
})
|
||
})
|