fix(tts): pass Windows TTS text via env and let stop() only cancel its own playback
This commit is contained in:
parent
9cd81b48c1
commit
91d4b4974c
3 changed files with 339 additions and 95 deletions
196
apps/desktop/tests/main/services/tts-redteam-r1-14.test.ts
Normal file
196
apps/desktop/tests/main/services/tts-redteam-r1-14.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue