// tests/unit/stt-model-download-redteam-r3-24.test.ts // STT 탭 모델 다운로드가 실패/취소로 끝나도 '다운로드 중 (x%)' 에 멈추지 않는지 회귀 검증. import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { ErrorCode, ipcError, ipcSuccess, type IPCResult } from '@d3ro/core/errors' import { classifyModelDownloadResult, releaseIfCurrent, runModelDownload, type ModelDownloadView, } from '../../src/renderer/components/stt/modelDownload' /** STTTab 의 downloadingModelId / downloadError 상태를 흉내 내는 가짜 view */ function fakeView(): ModelDownloadView & { downloadingModelId: string | null error: { modelId: string; message: string } | null events: string[] } { const state = { downloadingModelId: null as string | null, error: null as { modelId: string; message: string } | null, events: [] as string[], } return Object.assign(state, { started: (id: string) => { state.events.push(`started:${id}`) state.error = null state.downloadingModelId = id }, succeeded: (id: string) => { state.events.push(`succeeded:${id}`) state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id) }, cancelled: (id: string) => { state.events.push(`cancelled:${id}`) state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id) }, failed: (id: string, message: string) => { state.events.push(`failed:${id}`) state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id) state.error = { modelId: id, message } }, }) } describe('runModelDownload', () => { it('ipcError(사이드카 기동 실패 등)면 진행 표시를 풀고 오류 메시지를 노출한다', async () => { const view = fakeView() const outcome = await runModelDownload( 'large-v3-turbo', { download: async () => ipcError(ErrorCode.STTModelDownloadFailed, '다운로드 시작 실패 (HTTP 500): boom') }, view, ) expect(outcome).toEqual({ kind: 'failed', message: '다운로드 시작 실패 (HTTP 500): boom' }) expect(view.downloadingModelId).toBeNull() expect(view.error).toEqual({ modelId: 'large-v3-turbo', message: '다운로드 시작 실패 (HTTP 500): boom' }) }) it('취소 결과는 오류 없이 진행 표시만 푼다', async () => { const view = fakeView() const outcome = await runModelDownload( 'small', { download: async () => ipcError(ErrorCode.STTModelDownloadCancelled, '모델 다운로드 취소: small') }, view, ) expect(outcome.kind).toBe('cancelled') expect(view.downloadingModelId).toBeNull() expect(view.error).toBeNull() }) it('invoke 자체가 reject 되어도 진행 표시를 푼다', async () => { const view = fakeView() const outcome = await runModelDownload( 'small', { download: async () => { throw new Error('ipc channel closed') } }, view, ) expect(outcome).toEqual({ kind: 'failed', message: 'ipc channel closed' }) expect(view.downloadingModelId).toBeNull() }) it('성공하면 succeeded 로 끝나고 오류가 없다', async () => { const view = fakeView() const outcome = await runModelDownload('small', { download: async () => ipcSuccess(undefined) }, view) expect(outcome.kind).toBe('succeeded') expect(view.events).toEqual(['started:small', 'succeeded:small']) expect(view.downloadingModelId).toBeNull() expect(view.error).toBeNull() }) it('늦게 도착한 A 실패가 그 사이 시작한 B 의 진행 표시를 지우지 않는다', async () => { const view = fakeView() let failA: (r: IPCResult) => void = () => undefined const pendingA = runModelDownload( 'A', { download: () => new Promise>((resolve) => { failA = resolve }) }, view, ) view.started('B') failA(ipcError(ErrorCode.STTModelDownloadFailed, 'A failed')) await pendingA expect(view.downloadingModelId).toBe('B') }) }) describe('classifyModelDownloadResult', () => { it('실패 코드별 결말', () => { expect(classifyModelDownloadResult(ipcSuccess(undefined))).toEqual({ kind: 'succeeded' }) expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadCancelled, 'x'))).toEqual({ kind: 'cancelled' }) expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadFailed, 'y'))).toEqual({ kind: 'failed', message: 'y', }) expect(classifyModelDownloadResult({ thrown: 'raw' })).toEqual({ kind: 'failed', message: 'raw' }) }) }) describe('STTTab wiring', () => { it('handleDownloadModel 이 IPC 결과를 버리지 않고 runModelDownload 로 처리한다', () => { const src = readFileSync( join(__dirname, '../../src/renderer/components/STTTab.tsx'), 'utf8', ) expect(src).toContain('await runModelDownload(') expect(src).not.toMatch(/await window\.electronAPI\.stt\.downloadModel\(\{ modelId \}\)\s*\n\s*\}, \[\]\)/) }) })