import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const config = vi.hoisted(() => ({ suggestionEnabled: true as boolean, suggestionModelId: 'model-a' as string | null, llmModelId: 'model-a' as string | null, inputExcludedApps: [] as string[], suggestionTriggerDelayMs: 600, suggestionMinPrefixChars: 8, suggestionMaxRequestsPerMinute: 12, suggestionDailyBudget: 500, suggestionRequestTimeoutMs: 8000, inputLearnTypedText: false, inputTelemetryEnabled: false, suggestionOverlayInteractive: true, insertMethod: 'clipboard' })) const harness = vi.hoisted(() => ({ isAvailable: vi.fn(() => true), streamGenerate: vi.fn(), insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })), continuations: [] as string[] })) 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: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate }) })) vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => ({ listPhrases: () => [] }) })) vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: harness.continuations, related: [] }) }) })) vi.mock('../../../src/main/services/TextInsertService', () => ({ getTextInsertService: () => ({ insertText: harness.insertText }) })) vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true })) vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null })) vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() })) vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} })) import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService' import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget' import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker' import type { TypingContext } from '../../../src/main/services/InputTelemetryService' const SHORT_PREFIX = '오늘 회의에서 논의한 내용을 정리해서' const LONG_PREFIX = `${'가나다라 '.repeat(100)}${SHORT_PREFIX}` function typingContext(overrides: Partial = {}): TypingContext { const prefix = overrides.prefix ?? SHORT_PREFIX return { prefix, fullText: prefix, caretOffset: prefix.length, anchor: null, anchorKind: null, isPassword: false, isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'chrome.exe', windowTitle: 'Gmail', windowHandle: 101, idleMs: 2000, capturedAt: Date.now(), editedSinceFocus: true, typedRecently: true, ...overrides } } interface Fakes { deps: SuggestionServiceDeps record: ReturnType foregroundHandle: { value: number | null } expectInsert: ReturnType cancelInsert: ReturnType recordAccepted: ReturnType } function fakes(): Fakes { const record = vi.fn() const foregroundHandle = { value: 101 as number | null } const cancelInsert = vi.fn() const expectInsert = vi.fn(() => cancelInsert) const recordAccepted = vi.fn() return { record, foregroundHandle, expectInsert, cancelInsert, recordAccepted, deps: { repository: { record, markAccepted: vi.fn(), list: vi.fn(() => []) }, budget: new SuggestionBudget(), warmth: new ModelWarmTracker(), foreground: { currentWindowHandle: () => foregroundHandle.value }, learning: { expectProgrammaticInsert: expectInsert, recordAccepted } } } } function streamOf(text: string) { return (async function* () { yield text })() } let services: SuggestionService[] = [] function create(deps: SuggestionServiceDeps): SuggestionService { const service = new SuggestionService(deps) services.push(service) return service } interface Internal { _generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise _warmUpPromise: Promise | null _lastSkipReason: string | null } beforeEach(() => { config.suggestionEnabled = true config.suggestionModelId = 'model-a' config.llmModelId = 'model-a' harness.isAvailable.mockReset() harness.isAvailable.mockReturnValue(true) harness.streamGenerate.mockReset() harness.insertText.mockClear() harness.continuations = [] }) afterEach(() => { for (const service of services) service.dispose() services = [] vi.useRealTimers() }) describe('버그 1: 400자 넘는 접두에서 세션이 stale 로 닫히지 않는다', () => { it('같은 긴 접두의 다음 스냅샷에서도 세션이 유지되고 재생성하지 않는다', async () => { harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.')) const { deps } = fakes() deps.warmth.noteWarm('model-a', Date.now() + 600_000) const service = create(deps) const internal = service as unknown as Internal const context = typingContext({ prefix: LONG_PREFIX }) expect(LONG_PREFIX.length).toBeGreaterThan(400) await internal._generate(LONG_PREFIX, context, 3, 160) expect(service.isVisible).toBe(true) service.handleTypingContext(context) expect(service.isVisible).toBe(true) expect(service.getState().lastSkipReason).not.toBe('stale') // 세션이 유지되므로 새 첫 요청(예산 소비)이 일어나지 않는다. expect(deps.budget.minuteCount).toBe(1) }) it('긴 접두에서도 이어 치면 세션을 끝낸다', async () => { harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.')) const { deps } = fakes() deps.warmth.noteWarm('model-a', Date.now() + 600_000) const service = create(deps) await (service as unknown as Internal)._generate(LONG_PREFIX, typingContext({ prefix: LONG_PREFIX }), 3, 160) service.handleTypingContext(typingContext({ prefix: `${LONG_PREFIX} 그리고`, idleMs: 100 })) expect(service.isVisible).toBe(false) expect(service.getState().lastSkipReason).toBe('stale') }) }) describe('버그 2: 쿨다운 중 표시된 로컬 기억 세션', () => { function coolingService(): { service: SuggestionService; fake: Fakes } { const fake = fakes() fake.deps.budget.noteFailure(Date.now()) fake.deps.budget.noteFailure(Date.now()) expect(fake.deps.budget.isCoolingDown(Date.now())).toBe(true) harness.continuations = ['첫 번째 이어쓰기 문장입니다', '두 번째 이어쓰기 문장입니다'] const service = create(fake.deps) service.handleTypingContext(typingContext()) expect(service.getState()).toMatchObject({ visible: true, provenance: { mode: 'local-memory' } }) expect(fake.record).toHaveBeenCalledTimes(1) return { service, fake } } it('다음 후보 단축키(키 입력 직후 스냅샷)로 세션이 닫히지 않는다', () => { const { service } = coolingService() service.next() service.handleTypingContext(typingContext({ idleMs: 100 })) expect(service.getState()).toMatchObject({ visible: true, activeIndex: 1 }) }) it('멈춘 상태의 주기 스냅샷이 선택을 초기화하거나 이력을 중복 기록하지 않는다', () => { const { service, fake } = coolingService() service.next() service.handleTypingContext(typingContext({ idleMs: 2000 })) service.handleTypingContext(typingContext({ idleMs: 2800 })) expect(service.getState().activeIndex).toBe(1) expect(fake.record).toHaveBeenCalledTimes(1) }) it('타이핑이 끊긴 뒤(not-typing)에도 읽는 중인 세션을 닫지 않는다', () => { const { service } = coolingService() service.handleTypingContext(typingContext({ typedRecently: false, idleMs: 9000 })) expect(service.isVisible).toBe(true) }) it('비밀번호 칸으로 옮기면 쿨다운 중에도 닫는다', () => { const { service } = coolingService() service.handleTypingContext(typingContext({ isPassword: true })) expect(service.isVisible).toBe(false) }) }) describe('버그 4: 모델 변경 뒤 콜드 모델로 바로 생성하지 않는다', () => { it('이전 모델이 warm 이어도 새 모델은 워밍업부터 한다', async () => { harness.streamGenerate.mockImplementation(() => streamOf('ok')) const { deps } = fakes() deps.warmth.noteWarm('model-a', Date.now() + 600_000) const service = create(deps) const internal = service as unknown as Internal const generateSpy = vi.spyOn(internal, '_generate') config.suggestionModelId = 'model-b' service.handleTypingContext(typingContext()) expect(generateSpy).not.toHaveBeenCalled() expect(harness.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ model: 'model-b', maxTokens: 1 })) expect(service.getState().lastSkipReason).toBe('model-unavailable') await internal._warmUpPromise expect(deps.warmth.isWarm('model-b', Date.now())).toBe(true) }) it('handleModelChanged 는 새 콜드 모델을 미리 워밍업한다', async () => { harness.streamGenerate.mockImplementation(() => streamOf('ok')) const { deps } = fakes() deps.warmth.noteWarm('model-a', Date.now() + 600_000) const service = create(deps) service.handleModelChanged() expect(harness.streamGenerate).not.toHaveBeenCalled() config.suggestionModelId = 'model-b' service.handleModelChanged() await (service as unknown as Internal)._warmUpPromise expect(harness.streamGenerate).toHaveBeenCalledTimes(1) expect(harness.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ model: 'model-b' })) }) }) describe('버그 5: 실패한 워밍업은 실패로 센다', () => { it('워밍업 실패 뒤 다음 멈춤에서 워밍업을 바로 다시 쏘지 않고, 두 번째 실패로 쿨다운에 들어간다', async () => { vi.useFakeTimers() harness.streamGenerate.mockImplementation(() => (async function* () { throw new Error('Ollama responded with 404') })() ) const { deps } = fakes() const service = create(deps) const internal = service as unknown as Internal service.handleTypingContext(typingContext()) await internal._warmUpPromise expect(harness.streamGenerate).toHaveBeenCalledTimes(1) expect(deps.budget.consecutiveFailures).toBe(1) expect(service.getState().lastSkipReason).toBe('generation-failed') // 800ms 뒤 샘플 — 실패 직후라 워밍업을 다시 쏘지 않는다. vi.advanceTimersByTime(800) service.handleTypingContext(typingContext()) expect(harness.streamGenerate).toHaveBeenCalledTimes(1) expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'generation-failed' }) // 물러서기 시간이 지나면 한 번 더 시도하고, 그 실패로 쿨다운에 들어간다. vi.advanceTimersByTime(5000) service.handleTypingContext(typingContext()) await internal._warmUpPromise expect(harness.streamGenerate).toHaveBeenCalledTimes(2) expect(deps.budget.isCoolingDown(Date.now())).toBe(true) expect(service.getState().coolingDown).toBe(true) }) }) describe('버그 6: 수락 직전 대상 창 확인', () => { async function visibleService(fake: Fakes): Promise { harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.')) fake.deps.warmth.noteWarm('model-a', Date.now() + 600_000) const service = create(fake.deps) await (service as unknown as Internal)._generate(SHORT_PREFIX, typingContext({ windowHandle: 101 }), 3, 160) expect(service.isVisible).toBe(true) return service } it('포커스가 다른 창으로 옮겨 갔으면 붙여넣지 않는다', async () => { const fake = fakes() const service = await visibleService(fake) fake.foregroundHandle.value = 202 const result = await service.accept() expect(result).toEqual({ ok: false, reason: 'stale' }) expect(harness.insertText).not.toHaveBeenCalled() expect(fake.expectInsert).not.toHaveBeenCalled() }) it('같은 창이면 삽입하고, 삽입 예고와 수락 학습을 한 번씩 알린다', async () => { const fake = fakes() const service = await visibleService(fake) const result = await service.accept() expect(result).toEqual({ ok: true }) expect(harness.insertText).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.', 'clipboard') expect(fake.expectInsert).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.') expect(fake.recordAccepted).toHaveBeenCalledTimes(1) expect(fake.cancelInsert).not.toHaveBeenCalled() }) it('삽입이 실패하면 삽입 예고를 취소한다', async () => { const fake = fakes() const service = await visibleService(fake) harness.insertText.mockResolvedValueOnce({ success: false, method: 'clipboard', textLength: 0 }) const result = await service.accept() expect(result.ok).toBe(false) expect(fake.cancelInsert).toHaveBeenCalledTimes(1) expect(fake.recordAccepted).not.toHaveBeenCalled() }) it('창을 알 수 없으면(포그라운드 조회 실패) 기존처럼 삽입한다', async () => { const fake = fakes() const service = await visibleService(fake) fake.foregroundHandle.value = null expect(await service.accept()).toEqual({ ok: true }) }) }) describe('SuggestionBudget', () => { it('consume 은 창을 먼저 굴린다 — 오래 쉰 뒤의 요청도 예산에 잡힌다', () => { const budget = new SuggestionBudget() const t0 = new Date(2026, 8, 28, 10, 0, 0).getTime() budget.consume(t0) const later = t0 + 120_000 budget.consume(later) // 직후의 상태 조회(창 굴리기)가 방금 소비를 지우면 안 된다. expect(budget.snapshot(later + 10)).toMatchObject({ requestsThisMinute: 1, requestsToday: 2 }) }) it('창이 바뀐 뒤의 환급은 새 창의 카운트를 깎지 않는다', () => { const budget = new SuggestionBudget() const t0 = new Date(2026, 8, 28, 10, 0, 0).getTime() budget.consume(t0) budget.consume(t0 + 61_000) budget.snapshot(t0 + 61_000) budget.consume(t0 + 62_000) budget.refund() expect(budget.snapshot(t0 + 62_000)).toMatchObject({ requestsThisMinute: 1 }) expect(budget.lastRequestAt).toBe(t0 + 61_000) const other = new SuggestionBudget() other.consume(t0) other.snapshot(t0 + 61_000) other.refund() expect(other.minuteCount).toBe(0) expect(other.dayCount).toBe(0) }) it('연속 실패 임계에서 쿨다운, 성공이면 초기화, relaxInterval 은 최소 간격만 푼다', () => { const budget = new SuggestionBudget({ failureCooldownThreshold: 2, failureCooldownMs: 60_000 }) expect(budget.noteFailure(1000)).toBe(false) expect(budget.noteFailure(2000)).toBe(true) expect(budget.isCoolingDown(61_999)).toBe(true) expect(budget.isCoolingDown(62_000)).toBe(false) budget.noteSuccess() expect(budget.isCoolingDown(3000)).toBe(false) budget.consume(100_000) budget.relaxInterval(101_000, 5000) expect(budget.snapshot(101_000)).toMatchObject({ sinceLastRequestMs: 5000, requestsThisMinute: 1 }) }) }) describe('ModelWarmTracker', () => { it('온기와 워밍업 실패를 모델별로 기억한다', () => { const tracker = new ModelWarmTracker() tracker.noteWarm('a', 10_000) expect(tracker.isWarm('a', 9_999)).toBe(true) expect(tracker.isWarm('a', 10_000)).toBe(false) expect(tracker.isWarm('b', 5_000)).toBe(false) expect(tracker.isWarm(null, 5_000)).toBe(false) tracker.noteWarmUpFailure('b', 1_000) expect(tracker.isBackingOff('b', 5_999, 5_000)).toBe(true) expect(tracker.isBackingOff('b', 6_000, 5_000)).toBe(false) expect(tracker.isBackingOff('a', 2_000, 5_000)).toBe(false) tracker.noteWarm('b', 20_000) expect(tracker.isBackingOff('b', 2_000, 5_000)).toBe(false) }) })