d3ro-voice/apps/desktop/tests/main/services/SuggestionService.test.ts
Yun Chan 5c11ee2fde
Some checks failed
deploy-site / deploy (push) Failing after 33s
portable-unsigned / portable-windows (push) Failing after 4m7s
release / release-windows (push) Failing after 3m16s
release: ship v1.5.0 with on-device writing suggestions
Adds next-sentence suggestions while typing, weekly input insights and a
personal phrase memory to the desktop app, and fixes custom instructions so
they process the text instead of inserting the instruction's own wording.
Local model requests are now bounded and individually cancellable.

Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the
landing and web download links, and records the new INPUT feature rows and the
open verification gaps in the infrastructure map.
2026-09-23 16:04:27 +09:00

274 lines
10 KiB
TypeScript

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<void>
_abortStaleGeneration(currentPrefix: string): void
_watchdog(): void
_generationToken: number
}
function waitForAbort(signal: AbortSignal | undefined): AsyncGenerator<string> {
return (async function* () {
await new Promise<void>((_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([])
})
})