import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const config = vi.hoisted(() => ({ suggestionEnabled: true, suggestionModelId: 'gemma4:e4b', llmModelId: 'gemma4:e4b', inputExcludedApps: [], suggestionTriggerDelayMs: 600, suggestionMinPrefixChars: 8, suggestionMaxRequestsPerMinute: 6, suggestionDailyBudget: 500, suggestionRequestTimeoutMs: 8000, inputLearnTypedText: false, inputTelemetryEnabled: false, suggestionOverlayInteractive: true })) const localLlm = vi.hoisted(() => ({ isAvailable: vi.fn(() => true), streamGenerate: vi.fn() })) vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn((key: keyof typeof config) => config[key]), configSet: 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/LocalLLMService', () => ({ getLocalLLMService: () => localLlm })) vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => ({ listPhrases: vi.fn(() => []), recordExternalText: vi.fn() }) })) vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: () => ({ retrieveContext: vi.fn(() => ({ continuations: [], related: [] })) }) })) vi.mock('../../../src/main/services/TextInsertService', () => ({ getTextInsertService: () => ({ insertText: vi.fn() }) })) vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() })) vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} })) const PREFIX = '오늘 회의에서 논의한 내용을 정리해서' const context = { prefix: PREFIX, fullText: PREFIX, caretOffset: PREFIX.length, anchor: null, isPassword: false, isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'notepad.exe', windowTitle: 'notes', idleMs: 1000, capturedAt: Date.now() } interface InternalSuggestionService { _abort: AbortController | null _consecutiveFailures: number _cooldownUntil: number _inFlight: boolean _lastRequestAt: number _generate(prefix: string, currentContext: typeof context, maxCandidates: number, maxChars: number): Promise _abortStaleGeneration(currentPrefix: string): void _watchdog(): void _generationToken: number } function waitForAbort(signal: AbortSignal | undefined): AsyncGenerator { return (async function* () { await new Promise((_resolve, reject) => { if (!signal) { reject(new Error('missing abort signal')) return } signal.addEventListener('abort', () => reject(new Error('intentional abort')), { once: true }) }) yield 'unreachable' })() } beforeEach(() => { Object.assign(config, { suggestionEnabled: true, suggestionModelId: 'gemma4:e4b', llmModelId: 'gemma4:e4b', inputExcludedApps: [], suggestionTriggerDelayMs: 600, suggestionMinPrefixChars: 8, suggestionMaxRequestsPerMinute: 6, suggestionDailyBudget: 500, suggestionRequestTimeoutMs: 8000, inputLearnTypedText: false, inputTelemetryEnabled: false, suggestionOverlayInteractive: true }) localLlm.isAvailable.mockReset() localLlm.isAvailable.mockReturnValue(true) localLlm.streamGenerate.mockReset() }) afterEach(async () => { const { resetSuggestionServiceForTests } = await import('../../../src/main/services/SuggestionService') resetSuggestionServiceForTests() config.suggestionEnabled = true vi.useRealTimers() vi.clearAllMocks() }) describe('SuggestionService warm-up', () => { it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 2분만 유지한다', async () => { localLlm.streamGenerate.mockImplementation(async function* () { yield 'ok' }) const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const service = getSuggestionService() const warmingStates: boolean[] = [] service.on('state-changed', (state) => warmingStates.push(state.warmingUp)) await Promise.all([service.warmUp(), service.warmUp()]) expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1) expect(localLlm.streamGenerate).toHaveBeenCalledWith( 'hi', expect.objectContaining({ maxTokens: 1, keepAlive: '2m' }) ) expect(warmingStates).toEqual([true, false]) }) it('비활성화하면 가용성 재시도 타이머와 warm-up을 취소한다', async () => { vi.useFakeTimers() localLlm.isAvailable.mockReturnValue(false) const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const service = getSuggestionService() const warmUp = service.warmUp() config.suggestionEnabled = false service.applyConfig() await warmUp expect(vi.getTimerCount()).toBe(0) expect(localLlm.streamGenerate).not.toHaveBeenCalled() }) it('dismiss 취소는 실패 쿨다운을 올리거나 재귀 생성하지 않는다', async () => { localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => waitForAbort(options.signal) ) const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const service = getSuggestionService() const internal = service as unknown as InternalSuggestionService const cleared: string[] = [] service.on('cleared', ({ reason }) => cleared.push(reason)) const generation = internal._generate(PREFIX, context, 3, 240) await Promise.resolve() service.dismiss('dismissed') await generation expect(internal._consecutiveFailures).toBe(0) expect(internal._cooldownUntil).toBe(0) expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1) expect(cleared).toEqual(['dismissed']) }) it('생성 중 동일 접두의 주기 스냅샷은 요청을 취소하지 않는다', async () => { let signal: AbortSignal | undefined localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => { signal = options.signal return waitForAbort(options.signal) }) const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const service = getSuggestionService() const internal = service as unknown as InternalSuggestionService const generation = internal._generate(PREFIX, context, 3, 240) await Promise.resolve() service.handleTypingContext(context) expect(signal?.aborted).toBe(false) expect(internal._inFlight).toBe(true) service.dismiss('dismissed') await generation }) it.each([ ['선택', { hasSelection: true }, 'selection-active'], ['포커스 이탈', { isEditable: false }, 'not-editable'] ])('생성 중 %s은 후보가 없어도 취소하고 정확한 cleared 사유를 낸다', async (_case, update, reason) => { let signal: AbortSignal | undefined localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => { signal = options.signal return waitForAbort(options.signal) }) const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const service = getSuggestionService() const cleared: string[] = [] service.on('cleared', ({ reason: clearedReason }) => cleared.push(clearedReason)) const generation = (service as unknown as InternalSuggestionService)._generate(PREFIX, context, 3, 240) await Promise.resolve() expect(service.getState()).toMatchObject({ candidates: [], generating: true }) service.handleTypingContext({ ...context, ...update }) await generation expect(signal?.aborted).toBe(true) expect(cleared).toEqual([reason]) expect(service.getState()).toMatchObject({ candidates: [], generating: false, partialText: null }) }) it('후보 게시 updated 상태에는 생성 플래그와 부분 텍스트가 남지 않는다', async () => { localLlm.streamGenerate.mockImplementation(async function* () { yield '다음 단계도 확인하겠습니다.' }) const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const service = getSuggestionService() const states: Array<{ candidates: unknown[]; generating: boolean; partialText: string | null }> = [] service.on('updated', (state) => states.push(state)) await (service as unknown as InternalSuggestionService)._generate(PREFIX, context, 3, 240) const published = states.find((state) => state.candidates.length > 0) expect(published).toMatchObject({ generating: false, partialText: null }) }) it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않는다', async () => { localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => waitForAbort(options.signal) ) const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const service = getSuggestionService() const internal = service as unknown as InternalSuggestionService const cleared: string[] = [] service.on('cleared', ({ reason }) => cleared.push(reason)) const generation = internal._generate(PREFIX, context, 3, 240) await Promise.resolve() internal._abortStaleGeneration('완전히 다른 문맥으로 바뀐 입력입니다') await generation expect(internal._consecutiveFailures).toBe(0) expect(internal._cooldownUntil).toBe(0) expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1) expect(cleared).toEqual([]) }) it('watchdog는 실제 요청 signal을 abort하고 세대를 무효화한다', async () => { let signal: AbortSignal | undefined localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => { signal = options.signal return waitForAbort(options.signal) }) const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const service = getSuggestionService() const internal = service as unknown as InternalSuggestionService const cleared: string[] = [] service.on('cleared', ({ reason }) => cleared.push(reason)) const generation = internal._generate(PREFIX, context, 3, 240) await Promise.resolve() internal._lastRequestAt = Date.now() - 13001 internal._watchdog() await generation expect(signal?.aborted).toBe(true) expect(internal._inFlight).toBe(false) expect(internal._consecutiveFailures).toBe(0) expect(cleared).toEqual([]) }) })