// tests/unit/ollama-model-pull-redteam-r2-34.test.ts // Ollama 모델 받기: 실패 시 영구 '다운로드 중' + 받지 못한 모델 활성화 회귀 테스트. // node 환경에서 가짜 포트(electronAPI 대역)로 성공·실패·진행 이벤트를 검증한다. import { describe, it, expect, vi } from 'vitest' import { ipcError, ipcSuccess, ErrorCode, type IPCResult } from '@d3ro/core/errors' import { EMPTY_PULL_PROGRESS, OllamaModelPullController, type OllamaModelPullPort, type OllamaPullProgressEvent, } from '../../src/renderer/services/ollamaModelPullController' interface Deferred { promise: Promise resolve: (value: T) => void reject: (err: Error) => void } function deferred(): Deferred { let resolve!: (value: T) => void let reject!: (err: Error) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } interface FakePort extends OllamaModelPullPort { emit: (e: Partial & { modelId: string }) => void pullResult: Deferred> pullCalls: string[] setModelCalls: string[] persistCalls: string[] listenerCount: () => number } function makePort(overrides: Partial = {}): FakePort { const listeners = new Set<(e: OllamaPullProgressEvent) => void>() const port: FakePort = { pullResult: deferred>(), pullCalls: [], setModelCalls: [], persistCalls: [], pullModel: (modelId) => { port.pullCalls.push(modelId) return port.pullResult.promise }, setModel: async (modelId) => { port.setModelCalls.push(modelId) return ipcSuccess(undefined) }, persistModelId: async (modelId) => { port.persistCalls.push(modelId) return ipcSuccess(undefined) }, onPullProgress: (cb) => { listeners.add(cb) return () => { listeners.delete(cb) } }, emit: (e) => { const full: OllamaPullProgressEvent = { status: 'pulling', digest: 'sha256:x', total: 100, completed: 0, percent: 0, ...e, } for (const cb of listeners) cb(full) }, listenerCount: () => listeners.size, ...overrides, } return port } /** 즉시 실행 스케줄러 — 표시 지연을 건너뛴다 */ const immediate = (fn: () => void): void => fn() describe('OllamaModelPullController — pull 실패 처리', () => { it('main이 {success:false}로 resolve하면 진행 상태를 풀고 오류를 남긴다 (영구 스피너 방지)', async () => { const port = makePort() const c = new OllamaModelPullController(port, { schedule: immediate }) const detach = c.attach() const p = c.pull('gemma4:e4b') expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b') port.emit({ modelId: 'gemma4:e4b', percent: 42, completed: 42 }) expect(c.getSnapshot().progress.percent).toBe(42) port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'Pull failed: HTTP 500')) await expect(p).resolves.toBe('failed') const snap = c.getSnapshot() expect(snap.pullingModelId).toBeNull() expect(snap.progress).toEqual(EMPTY_PULL_PROGRESS) expect(snap.error).toEqual({ modelId: 'gemma4:e4b', stage: 'pull', message: 'Pull failed: HTTP 500' }) detach() }) it('autoSelect여도 받기에 실패하면 setModel/llmModelId 저장을 하지 않는다', async () => { const port = makePort() const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate }) const p = c.pull('gemma4:e4b') port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'Pull failed: disk full')) await expect(p).resolves.toBe('failed') expect(port.setModelCalls).toEqual([]) expect(port.persistCalls).toEqual([]) }) it('IPC 호출이 reject되어도 실패로 처리하고 진행 상태를 푼다', async () => { const port = makePort() const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate }) const p = c.pull('llama3.2:3b') port.pullResult.reject(new Error('ipc gone')) await expect(p).resolves.toBe('failed') expect(c.getSnapshot().pullingModelId).toBeNull() expect(c.getSnapshot().error?.message).toBe('ipc gone') expect(port.setModelCalls).toEqual([]) }) it('실패 후 다시 받기를 시작할 수 있고, 새 시도는 이전 오류를 지운다', async () => { const port = makePort() const c = new OllamaModelPullController(port, { schedule: immediate }) const first = c.pull('phi4') port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'boom')) await first port.pullResult = deferred>() const second = c.pull('phi4') expect(c.getSnapshot().error).toBeNull() expect(c.getSnapshot().pullingModelId).toBe('phi4') port.pullResult.resolve(ipcSuccess(undefined)) await expect(second).resolves.toBe('pulled') expect(port.pullCalls).toEqual(['phi4', 'phi4']) }) }) describe('OllamaModelPullController — 성공/진행', () => { it('autoSelect면 받기 성공 후에만 setModel → llmModelId 저장 순으로 선택한다', async () => { const port = makePort() const onPulled = vi.fn() const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate }) c.setOnPulled(onPulled) const p = c.pull('gemma4:e4b') expect(port.setModelCalls).toEqual([]) port.pullResult.resolve(ipcSuccess(undefined)) await expect(p).resolves.toBe('selected') expect(port.setModelCalls).toEqual(['gemma4:e4b']) expect(port.persistCalls).toEqual(['gemma4:e4b']) expect(c.getSnapshot().pullingModelId).toBeNull() expect(onPulled).toHaveBeenCalledWith('gemma4:e4b') }) it('autoSelect=false면 받기만 하고 선택하지 않는다', async () => { const port = makePort() const c = new OllamaModelPullController(port, { schedule: immediate }) const p = c.pull('qwen2.5:3b') port.pullResult.resolve(ipcSuccess(undefined)) await expect(p).resolves.toBe('pulled') expect(port.setModelCalls).toEqual([]) expect(port.persistCalls).toEqual([]) }) it('레이어 하나가 percent 100에 도달해도 완료로 보지 않는다 (IPC 결과로만 판정)', async () => { const port = makePort() const schedule = vi.fn() const c = new OllamaModelPullController(port, { schedule }) c.attach() const p = c.pull('gemma4:e4b') port.emit({ modelId: 'gemma4:e4b', digest: 'sha256:small', percent: 100, completed: 100 }) port.emit({ modelId: 'gemma4:e4b', status: 'success', percent: 100 }) expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b') expect(schedule).not.toHaveBeenCalled() port.emit({ modelId: 'gemma4:e4b', digest: 'sha256:big', percent: 3, completed: 3 }) expect(c.getSnapshot().progress.percent).toBe(3) port.pullResult.resolve(ipcSuccess(undefined)) await p // 성공 후 100%를 잠시 보여 주고 지연 뒤 해제 expect(c.getSnapshot().progress.percent).toBe(100) expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b') expect(schedule).toHaveBeenCalledTimes(1) const [fn, ms] = schedule.mock.calls[0] as [() => void, number] expect(ms).toBe(1200) fn() expect(c.getSnapshot().pullingModelId).toBeNull() }) it('다른 모델이나 결과가 난 뒤의 늦은 진행 이벤트는 무시한다', async () => { const port = makePort() const c = new OllamaModelPullController(port, { schedule: immediate }) c.attach() port.emit({ modelId: 'phi4', percent: 50 }) expect(c.getSnapshot().pullingModelId).toBeNull() const p = c.pull('gemma4:e4b') port.emit({ modelId: 'phi4', percent: 77 }) expect(c.getSnapshot().progress.percent).toBe(0) port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'x')) await p port.emit({ modelId: 'gemma4:e4b', percent: 60 }) expect(c.getSnapshot().pullingModelId).toBeNull() expect(c.getSnapshot().progress).toEqual(EMPTY_PULL_PROGRESS) }) it('진행 중에 또 받기를 요청하면 busy로 무시한다', async () => { const port = makePort() const c = new OllamaModelPullController(port, { schedule: immediate }) const first = c.pull('gemma4:e4b') await expect(c.pull('phi4')).resolves.toBe('busy') expect(port.pullCalls).toEqual(['gemma4:e4b']) port.pullResult.resolve(ipcSuccess(undefined)) await first }) it('attach 해제 후에는 구독이 남지 않는다', () => { const port = makePort() const c = new OllamaModelPullController(port) const detach = c.attach() expect(port.listenerCount()).toBe(1) detach() expect(port.listenerCount()).toBe(0) }) }) describe('OllamaModelPullController — select', () => { it('setModel이 실패하면 llmModelId를 저장하지 않고 false + select 오류', async () => { const port = makePort({ setModel: async () => ipcError(ErrorCode.LLMServerUnreachable, 'set failed'), }) const c = new OllamaModelPullController(port) await expect(c.select('phi4')).resolves.toBe(false) expect(port.persistCalls).toEqual([]) expect(c.getSnapshot().error).toEqual({ modelId: 'phi4', stage: 'select', message: 'set failed' }) }) it('autoSelect 선택 단계가 실패하면 pull 결과는 failed이고 진행 상태는 풀린다', async () => { const port = makePort({ persistModelId: async () => ipcError(ErrorCode.LLMServerUnreachable, 'config write failed'), }) const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate }) const p = c.pull('gemma4:e4b') port.pullResult.resolve(ipcSuccess(undefined)) await expect(p).resolves.toBe('failed') expect(c.getSnapshot().pullingModelId).toBeNull() expect(c.getSnapshot().error?.stage).toBe('select') }) it('구독자에게 상태 변경을 알린다', async () => { const port = makePort() const c = new OllamaModelPullController(port, { schedule: immediate }) const listener = vi.fn() const unsub = c.subscribe(listener) const p = c.pull('gemma4:e4b') expect(listener).toHaveBeenCalled() port.pullResult.resolve(ipcSuccess(undefined)) await p unsub() const calls = listener.mock.calls.length c.clearError() await c.select('gemma4:e4b') expect(listener.mock.calls.length).toBe(calls) }) })