d3ro-voice/apps/desktop/tests/main/services/file-transcription-redteam-r1-5.test.ts

175 lines
6.4 KiB
TypeScript

// 회귀: 설치본에는 ffmpeg이 없으므로 파일 전사는 시작 시 런타임 ffmpeg을 확보해야 한다.
// 이전에는 _ensureFfmpeg()가 호출되지 않아 bare 'ffmpeg'을 스폰 → spawn ENOENT 로 전사가 전부 실패했다.
import { EventEmitter } from 'events'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
SYSTEM_FFMPEG,
buildConvertToWavArgs,
buildExtractChunkArgs,
buildProbeDurationArgs,
countChunks,
estimateWavDurationSec,
parseFfmpegDuration,
resolveFfmpegExecutable,
} from '../../../src/main/services/file-transcription/ffmpeg-media'
const hoisted = vi.hoisted(() => ({
spawn: vi.fn(),
getFfmpegPath: vi.fn(),
ensure: vi.fn(),
transcribe: vi.fn(),
}))
vi.mock('child_process', () => ({ spawn: hoisted.spawn }))
vi.mock('../../../src/main/utils/paths', () => ({
getFfmpegPath: hoisted.getFfmpegPath,
}))
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({
getRuntimeProvisioner: () => ({ ensure: hoisted.ensure }),
}))
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),
}))
vi.mock('../../../src/main/services/stt/STTManager', () => ({
getSTTManager: () => ({ transcribe: hoisted.transcribe }),
}))
vi.mock('../../../src/main/services/HistoryService', () => ({
getHistoryService: () => ({ create: vi.fn() }),
}))
vi.mock('../../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }),
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({
getMainWindow: () => null,
}))
type FakeProcess = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }
/** 실행 파일이 'ffmpeg'(PATH 폴백)이면 ENOENT, 그 밖에는 정상 ffmpeg 처럼 동작하는 가짜 프로세스 */
function fakeSpawn(command: string, args: string[]): FakeProcess {
const proc = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
})
queueMicrotask(() => {
if (command === SYSTEM_FFMPEG) {
proc.emit('error', new Error('spawn ffmpeg ENOENT'))
return
}
if (args.includes('null')) proc.stderr.emit('data', Buffer.from('Duration: 00:00:45.00'))
if (args.includes('pipe:1')) proc.stdout.emit('data', Buffer.from('pcm'))
proc.emit('close', 0)
})
return proc
}
describe('FileTranscriptionService ffmpeg provisioning', () => {
let inputFile: string
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
hoisted.spawn.mockImplementation(fakeSpawn)
hoisted.transcribe.mockResolvedValue({ text: 'hello', segments: [] })
inputFile = path.join(os.tmpdir(), `d3ro-ft-redteam-${Date.now()}.mp3`)
fs.writeFileSync(inputFile, 'x')
})
afterEach(() => {
fs.rmSync(inputFile, { force: true })
})
it('downloads ffmpeg on a clean install and spawns the provisioned binary', async () => {
hoisted.getFfmpegPath.mockReturnValue(SYSTEM_FFMPEG)
hoisted.ensure.mockResolvedValue('/runtime/ffmpeg/ffmpeg.exe')
const mod = await import('../../../src/main/services/FileTranscriptionService')
mod.resetFileTranscriptionServiceForTests()
const result = await mod.getFileTranscriptionService().startTranscription(inputFile, 'en')
expect(hoisted.ensure).toHaveBeenCalledTimes(1)
expect(hoisted.ensure).toHaveBeenCalledWith('ffmpeg')
const commands = hoisted.spawn.mock.calls.map((call) => call[0])
// convert + probe + 2 chunks(45초 / 30초)
expect(commands).toEqual([
'/runtime/ffmpeg/ffmpeg.exe',
'/runtime/ffmpeg/ffmpeg.exe',
'/runtime/ffmpeg/ffmpeg.exe',
'/runtime/ffmpeg/ffmpeg.exe',
])
expect(result.fullText).toBe('hello hello')
expect(result.totalDurationSec).toBe(45)
})
it('uses a locally available ffmpeg without downloading', async () => {
hoisted.getFfmpegPath.mockReturnValue('/bundled/ffmpeg.exe')
const mod = await import('../../../src/main/services/FileTranscriptionService')
mod.resetFileTranscriptionServiceForTests()
await mod.getFileTranscriptionService().startTranscription(inputFile, 'en')
expect(hoisted.ensure).not.toHaveBeenCalled()
expect(hoisted.spawn.mock.calls.every((call) => call[0] === '/bundled/ffmpeg.exe')).toBe(true)
})
})
describe('ffmpeg-media policy', () => {
it('resolveFfmpegExecutable prefers the local copy', async () => {
const provision = vi.fn()
await expect(resolveFfmpegExecutable({ locate: () => '/a/ffmpeg', provision })).resolves.toBe('/a/ffmpeg')
expect(provision).not.toHaveBeenCalled()
})
it('resolveFfmpegExecutable provisions when only the PATH fallback is known', async () => {
const provision = vi.fn().mockResolvedValue('/rt/ffmpeg')
await expect(resolveFfmpegExecutable({ locate: () => SYSTEM_FFMPEG, provision })).resolves.toBe('/rt/ffmpeg')
})
it('resolveFfmpegExecutable falls back to the system ffmpeg when the download fails', async () => {
const failure = new Error('offline')
const onFail = vi.fn()
const provision = vi.fn().mockRejectedValue(failure)
await expect(
resolveFfmpegExecutable({ locate: () => SYSTEM_FFMPEG, provision }, onFail),
).resolves.toBe(SYSTEM_FFMPEG)
expect(onFail).toHaveBeenCalledWith(failure)
})
it('builds the same ffmpeg arguments as before', () => {
expect(buildConvertToWavArgs('in.mp4', 'out.wav')).toEqual([
'-i', 'in.mp4', '-ar', '16000', '-ac', '1', '-sample_fmt', 's16', '-y', 'out.wav',
])
expect(buildProbeDurationArgs('a.wav')).toEqual(['-i', 'a.wav', '-f', 'null', '-'])
expect(buildExtractChunkArgs('a.wav', 30, 30)).toEqual([
'-ss', '30', '-t', '30', '-i', 'a.wav', '-ar', '16000', '-ac', '1',
'-f', 's16le', '-acodec', 'pcm_s16le', 'pipe:1',
])
})
it('parses ffmpeg duration and estimates WAV length', () => {
expect(parseFfmpegDuration(' Duration: 01:02:03.50, start')).toBe(3723.5)
expect(parseFfmpegDuration('no duration here')).toBeNull()
expect(estimateWavDurationSec(44 + 32000 * 2)).toBe(2)
expect(estimateWavDurationSec(10)).toBe(0)
})
it('counts chunks by rounding up', () => {
expect(countChunks(45, 30)).toBe(2)
expect(countChunks(60, 30)).toBe(2)
expect(countChunks(0, 30)).toBe(0)
})
})