165 lines
6.7 KiB
TypeScript
165 lines
6.7 KiB
TypeScript
// tests/main/services/file-transcription-cancel-redteam-r2-1.test.ts
|
|
// 파일 전사 취소가 실행 중인 ffmpeg 를 곧바로 종료하고 서비스를 idle 로 돌린다.
|
|
// 취소된 작업의 늦은 정리(상태 되돌리기·임시 폴더 삭제)가 다음 작업을 덮지 않는다.
|
|
// + 드롭 경로·시작 실패 분류·늦은 응답 무시(렌더러 순수 모델)
|
|
|
|
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 { ErrorCode } from '@d3ro/core/errors'
|
|
import {
|
|
StartRequestTracker,
|
|
classifyStartFailure,
|
|
resolveDroppedFilePath,
|
|
} from '../../../src/renderer/components/file-drop-model'
|
|
|
|
type FakeProcess = EventEmitter & {
|
|
stdout: EventEmitter
|
|
stderr: EventEmitter
|
|
exitCode: number | null
|
|
killed: boolean
|
|
kill: () => boolean
|
|
args: string[]
|
|
}
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
spawn: vi.fn(),
|
|
procs: [] as unknown[],
|
|
/** true 면 WAV 변환(ffmpeg -i ... out.wav)이 끝나지 않고 매달린다 */
|
|
hangConvert: true,
|
|
}))
|
|
|
|
vi.mock('child_process', () => ({ spawn: hoisted.spawn }))
|
|
vi.mock('../../../src/main/utils/paths', () => ({ getFfmpegPath: () => '/bundled/ffmpeg.exe' }))
|
|
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ ensure: vi.fn() }) }))
|
|
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: vi.fn(async () => ({ text: 'hello', segments: [] })) }),
|
|
}))
|
|
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 }))
|
|
|
|
function fakeSpawn(_command: string, args: string[]): FakeProcess {
|
|
const proc = Object.assign(new EventEmitter(), {
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
exitCode: null as number | null,
|
|
killed: false,
|
|
args,
|
|
kill(): boolean {
|
|
proc.killed = true
|
|
queueMicrotask(() => proc.emit('close', null))
|
|
return true
|
|
},
|
|
})
|
|
hoisted.procs.push(proc)
|
|
const isConvert = args[args.length - 1].endsWith('.wav') && !args.includes('null')
|
|
queueMicrotask(() => {
|
|
if (isConvert && hoisted.hangConvert) return
|
|
if (args.includes('null')) proc.stderr.emit('data', Buffer.from('Duration: 00:00:10.00'))
|
|
if (args.includes('pipe:1')) proc.stdout.emit('data', Buffer.from('pcm'))
|
|
proc.exitCode = 0
|
|
proc.emit('close', 0)
|
|
})
|
|
return proc
|
|
}
|
|
|
|
describe('FileTranscriptionService 취소', () => {
|
|
let inputFile: string
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
hoisted.procs = []
|
|
hoisted.hangConvert = true
|
|
hoisted.spawn.mockImplementation(fakeSpawn)
|
|
inputFile = path.join(os.tmpdir(), `d3ro-ft-cancel-${Date.now()}.mp4`)
|
|
fs.writeFileSync(inputFile, 'x')
|
|
})
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(inputFile, { force: true })
|
|
})
|
|
|
|
it('변환 중 취소하면 ffmpeg 를 종료하고, 곧바로 idle 이 되어 새 작업을 받는다', async () => {
|
|
const mod = await import('../../../src/main/services/FileTranscriptionService')
|
|
mod.resetFileTranscriptionServiceForTests()
|
|
const service = mod.getFileTranscriptionService()
|
|
|
|
const first = service.startTranscription(inputFile, 'en')
|
|
await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1))
|
|
const convert = hoisted.procs[0] as FakeProcess
|
|
const firstTempDir = path.dirname(convert.args[convert.args.length - 1])
|
|
|
|
service.cancel()
|
|
|
|
expect(convert.killed).toBe(true)
|
|
expect(service.state).toBe('idle')
|
|
await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
|
|
expect(fs.existsSync(firstTempDir)).toBe(false)
|
|
|
|
// 새 작업은 거부되지 않고 끝까지 간다
|
|
hoisted.hangConvert = false
|
|
const result = await service.startTranscription(inputFile, 'en')
|
|
expect(result.fullText).toBe('hello')
|
|
})
|
|
|
|
it('취소된 작업의 늦은 정리가 다음 작업의 상태·jobId 를 지우지 않는다', async () => {
|
|
const mod = await import('../../../src/main/services/FileTranscriptionService')
|
|
mod.resetFileTranscriptionServiceForTests()
|
|
const service = mod.getFileTranscriptionService()
|
|
|
|
const first = service.startTranscription(inputFile, 'en')
|
|
await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1))
|
|
service.cancel()
|
|
|
|
// 이전 작업이 정리되기 전에 새 작업을 시작한다(여전히 변환 중에 매달림)
|
|
const second = service.startTranscription(inputFile, 'en')
|
|
await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
|
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
|
|
const info = service.getStateInfo()
|
|
expect(info.state).toBe('converting')
|
|
expect(info.jobId).not.toBeNull()
|
|
|
|
service.cancel()
|
|
await expect(second).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
|
|
expect(service.getStateInfo()).toMatchObject({ state: 'idle', jobId: null })
|
|
})
|
|
})
|
|
|
|
describe('FileDropZone 모델', () => {
|
|
it('드롭한 파일 경로는 getPathForFile 로 얻고, 못 얻으면 null(대화상자로 떨어지지 않는다)', () => {
|
|
const file = { name: 'meeting.mp3' } as File
|
|
expect(resolveDroppedFilePath(file, () => 'C:\\rec\\meeting.mp3')).toBe('C:\\rec\\meeting.mp3')
|
|
expect(resolveDroppedFilePath(file, () => '')).toBeNull()
|
|
expect(
|
|
resolveDroppedFilePath(file, () => {
|
|
throw new Error('not a real file')
|
|
}),
|
|
).toBeNull()
|
|
})
|
|
|
|
it('취소·대화상자 닫기는 오류로 보이지 않는다', () => {
|
|
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'Transcription cancelled' })).toBe('cancelled')
|
|
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'File selection cancelled' })).toBe('cancelled')
|
|
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionFFmpegFailed, message: 'ffmpeg exited' })).toBe('error')
|
|
})
|
|
|
|
it('취소·새 시작 뒤에는 이전 요청의 늦은 응답을 무시한다', () => {
|
|
const tracker = new StartRequestTracker()
|
|
const first = tracker.begin()
|
|
tracker.invalidate()
|
|
const second = tracker.begin()
|
|
expect(tracker.isCurrent(first)).toBe(false)
|
|
expect(tracker.isCurrent(second)).toBe(true)
|
|
})
|
|
})
|