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