import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PersonalPhrase } from '@d3ro/core/input-intelligence' 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 as boolean, 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 })), phrases: [] as PersonalPhrase[] })) 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: () => harness.phrases }) })) vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: () => ({ retrieveContext: () => ({ 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 PREFIX = '오늘 회의에서 논의한 내용을 정리해서' const SUGGESTION = '다음 단계도 확인하겠습니다.' function typingContext(overrides: Partial = {}): TypingContext { const prefix = overrides.prefix ?? PREFIX return { prefix, fullText: prefix, caretOffset: prefix.length, anchor: { x: 100, y: 100, width: 2, height: 20 }, anchorKind: 'caret', isPassword: false, isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'Slack.exe', windowTitle: 'general', windowHandle: 101, idleMs: 2000, capturedAt: Date.now(), editedSinceFocus: true, typedRecently: true, ...overrides } } interface Fakes { deps: SuggestionServiceDeps record: ReturnType markAccepted: ReturnType expectInsert: ReturnType recordAccepted: ReturnType } function fakes(): Fakes { const record = vi.fn() const markAccepted = vi.fn() const expectInsert = vi.fn(() => vi.fn()) const recordAccepted = vi.fn() return { record, markAccepted, expectInsert, recordAccepted, deps: { repository: { record, markAccepted, list: vi.fn(() => []) }, budget: new SuggestionBudget(), warmth: new ModelWarmTracker(), foreground: { currentWindowHandle: () => 101 }, learning: { expectProgrammaticInsert: expectInsert, recordAccepted } } } } function streamOf(text: string): AsyncGenerator { return (async function* () { yield text })() } /** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */ function gatedStream(signal: AbortSignal | undefined, gate: Promise): AsyncGenerator { return (async function* () { const text = await new Promise((resolve, reject) => { signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) void gate.then(resolve) }) yield text })() } interface Internal { _generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise _lastContext: TypingContext | null _inFlight: boolean _warmUpPromise: Promise | null } let services: SuggestionService[] = [] function create(deps: SuggestionServiceDeps, warm = true): SuggestionService { if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000) const service = new SuggestionService(deps) services.push(service) return service } beforeEach(() => { config.suggestionEnabled = true config.inputLearnTypedText = false harness.isAvailable.mockReset() harness.isAvailable.mockReturnValue(true) harness.streamGenerate.mockReset() harness.insertText.mockClear() harness.phrases = [] }) afterEach(() => { for (const service of services) service.dispose() services = [] }) describe('진행 중 생성 — 요청 뒤 이어 치면 즉시 취소한다 (세션 규칙과 동일)', () => { it('12자 미만 성장도 진행 중 요청을 취소하고 예산을 환급한다', async () => { let signal: AbortSignal | undefined let release: (text: string) => void = () => undefined const gate = new Promise((resolve) => { release = resolve }) harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => { signal = options.signal return gatedStream(options.signal, gate) }) const fake = fakes() const service = create(fake.deps) const internal = service as unknown as Internal service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘' })) expect(internal._inFlight).toBe(true) service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘 저녁까지', idleMs: 100 })) release(SUGGESTION) await Promise.resolve() await new Promise((resolve) => setTimeout(resolve, 0)) expect(signal?.aborted).toBe(true) expect(service.isVisible).toBe(false) expect(fake.deps.budget.minuteCount).toBe(0) expect(fake.record).not.toHaveBeenCalled() }) it('결과 게시 직전 최신 문맥이 자랐으면 옛 접두용 결과를 게시하지 않는다', async () => { let release: (text: string) => void = () => undefined const gate = new Promise((resolve) => { release = resolve }) harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => gatedStream(options.signal, gate) ) const fake = fakes() const service = create(fake.deps) const internal = service as unknown as Internal const context = typingContext({ prefix: '회의 자료는 오늘' }) const generation = internal._generate('회의 자료는 오늘', context, 3, 160) // 취소 경로를 거치지 않고 최신 문맥만 바뀐 경우 (게시 가드만 검증) internal._lastContext = typingContext({ prefix: '회의 자료는 오늘 저녁까지' }) release(SUGGESTION) await generation expect(service.isVisible).toBe(false) expect(service.getState().lastSkipReason).toBe('stale') expect(fake.record).not.toHaveBeenCalled() }) it('같은 접두(주기 스냅샷)면 그대로 게시한다', async () => { harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) const fake = fakes() const service = create(fake.deps) const internal = service as unknown as Internal internal._lastContext = typingContext() await internal._generate(PREFIX, typingContext(), 3, 160) expect(service.isVisible).toBe(true) }) }) describe('수락 — 구분 공백', () => { async function visibleModelSession(fake: Fakes): Promise { harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) const service = create(fake.deps) await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) expect(service.isVisible).toBe(true) return service } it('단어 바로 뒤에서 멈췄으면 공백 하나를 앞에 붙여 삽입한다', async () => { const fake = fakes() const service = await visibleModelSession(fake) expect(await service.accept()).toEqual({ ok: true }) expect(harness.insertText).toHaveBeenCalledWith(` ${SUGGESTION}`, 'clipboard') expect(fake.expectInsert).toHaveBeenCalledWith(` ${SUGGESTION}`) // 이력·학습은 후보 원문으로 — 이력 행을 찾을 수 있어야 한다. expect(fake.markAccepted).toHaveBeenCalledWith(SUGGESTION) expect(fake.recordAccepted).toHaveBeenCalledWith(SUGGESTION, expect.anything()) }) it('영어 문장 끝 마침표 뒤에도 떼어 쓴다', async () => { harness.streamGenerate.mockImplementation(() => streamOf('Let me know if you have questions.')) const fake = fakes() const service = create(fake.deps) const context = typingContext({ prefix: 'I sent the report.' }) await (service as unknown as Internal)._generate('I sent the report.', context, 3, 160) await service.accept() expect(harness.insertText).toHaveBeenCalledWith(' Let me know if you have questions.', 'clipboard') }) it('제안 뒤 사용자가 이미 공백을 쳤으면(최신 문맥) 공백을 더하지 않는다', async () => { const fake = fakes() const service = await visibleModelSession(fake) service.handleTypingContext(typingContext({ prefix: `${PREFIX} `, idleMs: 100 })) expect(service.isVisible).toBe(true) await service.accept() expect(harness.insertText).toHaveBeenCalledWith(SUGGESTION, 'clipboard') }) it('단어 중간에서 이어지는 로컬 기억 후보는 공백 없이 붙인다', async () => { harness.phrases = [ { id: 'p1', phrase: 'Thank you for the update', count: 3, source: 'typed', appName: 'Slack.exe', lastUsedAt: Date.now(), createdAt: Date.now() } ] const fake = fakes() // 쿨다운 중에는 로컬 기억만 게시한다. fake.deps.budget.noteFailure(Date.now()) fake.deps.budget.noteFailure(Date.now()) const service = create(fake.deps) service.handleTypingContext(typingContext({ prefix: 'Thank yo' })) expect(service.getState().candidates[0]?.text).toBe('u for the update') await service.accept() expect(harness.insertText).toHaveBeenCalledWith('u for the update', 'clipboard') }) }) describe('모델 서버가 없을 때 — 끝나지 않는 "준비 중" 을 띄우지 않는다', () => { it('준비 작업(워밍업)이 없으면 오버레이 없이 사유만 남긴다', () => { harness.isAvailable.mockReturnValue(false) const fake = fakes() const service = create(fake.deps) const updates: boolean[] = [] service.on('updated', (state) => updates.push(state.warmingUp)) service.handleTypingContext(typingContext()) expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'model-unavailable' }) expect(service.isPresentationActive).toBe(false) expect(updates).not.toContain(true) // 이어 쳐도 already-visible 로 굳지 않는다. service.handleTypingContext(typingContext({ prefix: `${PREFIX} 공유`, idleMs: 2000 })) expect(service.isPresentationActive).toBe(false) }) }) describe('제안 이력 — 입력 원문은 학습 동의 규칙을 따른다', () => { async function publish(fake: Fakes, context: TypingContext): Promise { harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) const service = create(fake.deps) await (service as unknown as Internal)._generate(context.prefix, context, 3, 160) expect(service.isVisible).toBe(true) } it('학습 동의가 없으면 접두 원문을 저장하지 않는다', async () => { const fake = fakes() await publish(fake, typingContext()) expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '', text: SUGGESTION, appName: 'Slack.exe' })) }) it('동의가 있어도 터미널(학습 제외 앱)의 명령 줄은 저장하지 않는다', async () => { config.inputLearnTypedText = true const fake = fakes() await publish(fake, typingContext({ prefix: 'mysql -u root -pS3cret prod', appName: 'WindowsTerminal.exe' })) expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '' })) }) it('동의가 있고 일반 앱이면 접두를 저장한다', async () => { config.inputLearnTypedText = true const fake = fakes() await publish(fake, typingContext()) expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: PREFIX })) }) })