release: ship v1.5.0 with on-device writing suggestions
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

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.
This commit is contained in:
Yun Chan 2026-09-23 16:04:27 +09:00
parent 99f06c253c
commit 5c11ee2fde
104 changed files with 14410 additions and 174 deletions

View file

@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
const handlers = vi.hoisted(
() => new Map<string, (event: unknown, params: unknown) => Promise<unknown>>()
)
const eventHandlers = vi.hoisted(() => new Map<string, (...args: unknown[]) => void>())
const configSet = vi.hoisted(() => vi.fn())
const suggestion = vi.hoisted(() => ({
setEnabled: vi.fn(),
applyConfig: vi.fn(),
dismiss: vi.fn(),
getState: vi.fn(() => ({ enabled: true }))
}))
const hideSuggestionOverlay = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, params: unknown) => Promise<unknown>) => {
handlers.set(channel, handler)
}),
on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => {
eventHandlers.set(channel, handler)
})
}
}))
vi.mock('../../../src/main/services/ConfigService', () => ({ configSet }))
vi.mock('../../../src/main/services/SuggestionService', () => ({
getSuggestionService: () => suggestion
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({
applySuggestionOverlayConfig: vi.fn(),
hideSuggestionOverlay
}))
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
handlers.clear()
eventHandlers.clear()
const mod = await import('../../../src/main/ipc/suggestion-handlers')
mod.registerSuggestionHandlers()
})
describe('SUGGESTION.SET_CONFIG', () => {
it('명시적으로 활성화할 때 서비스의 warm-up 경로를 사용한다', async () => {
const handler = handlers.get(IPC_CHANNELS.SUGGESTION.SET_CONFIG)
if (!handler) throw new Error('suggestion config handler was not registered')
await handler({}, { enabled: true })
expect(suggestion.setEnabled).toHaveBeenCalledWith(true)
expect(configSet).not.toHaveBeenCalledWith('suggestionEnabled', true)
})
it('분당 요청 상한을 12로 강제한다', async () => {
const handler = handlers.get(IPC_CHANNELS.SUGGESTION.SET_CONFIG)
if (!handler) throw new Error('suggestion config handler was not registered')
await handler({}, { maxRequestsPerMinute: 99 })
expect(configSet).toHaveBeenCalledWith('suggestionMaxRequestsPerMinute', 12)
})
})
describe('POPUP_SUGGESTION.DISMISS', () => {
it('서비스 이벤트를 기다리지 않고 창을 숨긴 뒤 제안을 취소한다', () => {
const handler = eventHandlers.get(IPC_CHANNELS.POPUP_SUGGESTION.DISMISS)
if (!handler) throw new Error('suggestion dismiss handler was not registered')
handler({})
expect(hideSuggestionOverlay).toHaveBeenCalledTimes(1)
expect(suggestion.dismiss).toHaveBeenCalledWith('dismissed')
expect(hideSuggestionOverlay.mock.invocationCallOrder[0]).toBeLessThan(
suggestion.dismiss.mock.invocationCallOrder[0]
)
})
})

View file

@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
describe('ConfigService suggestion tuning migration', () => {
afterEach(() => {
vi.doUnmock('electron-store')
vi.resetModules()
})
it('revision 3은 새 delay와 분당 상한만 바꾸고 기존 개인 설정을 보존한다', async () => {
const savedBindings = {
'suggestion-accept': [
{ device: 'keyboard' as const, code: 65, ctrl: true, alt: false, shift: false, meta: false }
]
}
const persisted = {
suggestionTuningRevision: 3,
suggestionTriggerDelayMs: 300,
suggestionMaxRequestsPerMinute: 12,
suggestionMinPrefixChars: 17,
suggestionDailyBudget: 777,
suggestionRequestTimeoutMs: 23000,
keyBindings: savedBindings
}
class TestStore<T extends Record<string, unknown>> {
store: T
constructor(options: { defaults: T }) {
this.store = { ...options.defaults, ...persisted } as T
}
get<K extends keyof T>(key: K): T[K] {
return this.store[key]
}
set<K extends keyof T>(key: K, value: T[K]): void {
this.store[key] = value
}
delete(key: string): void {
delete this.store[key as keyof T]
}
}
vi.doMock('electron-store', () => ({ default: TestStore }))
const { configGet, initConfigService, resetInMemoryConfig } = await import(
'../../../src/main/services/ConfigService'
)
await initConfigService()
expect(configGet('suggestionTuningRevision')).toBe(4)
expect(configGet('suggestionTriggerDelayMs')).toBe(600)
expect(configGet('suggestionMaxRequestsPerMinute')).toBe(6)
expect(configGet('suggestionMinPrefixChars')).toBe(17)
expect(configGet('suggestionDailyBudget')).toBe(777)
expect(configGet('suggestionRequestTimeoutMs')).toBe(23000)
expect(configGet('keyBindings')['suggestion-accept']).toEqual(savedBindings['suggestion-accept'])
resetInMemoryConfig()
})
})

View file

@ -0,0 +1,274 @@
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([])
})
})

View file

@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode } from '@d3ro/core/errors'
import {
getVoiceConversationService,
resetVoiceConversationServiceForTests,
} from '../../../src/main/services/VoiceConversationService'
type ChatOptions = {
signal?: AbortSignal
maxTokens?: number
timeoutMs?: number
keepAlive?: string
}
const mocks = vi.hoisted(() => ({
audioStart: vi.fn(async () => undefined),
audioStop: vi.fn(async () => undefined),
chatStream: vi.fn(),
premiumCancel: vi.fn(),
speakSentences: vi.fn(async () => undefined),
ttsStop: vi.fn(),
send: vi.fn(),
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}))
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({
start: mocks.audioStart,
stop: mocks.audioStop,
on: vi.fn(),
off: vi.fn(),
}),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => ({ initialize: vi.fn(async () => undefined) }),
}))
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => ({
cancelGeneration: mocks.premiumCancel,
isAvailable: () => false,
}),
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: () => true,
chatStream: mocks.chatStream,
}),
}))
vi.mock('../../../src/main/services/TTSPlaybackService', () => ({
getTTSPlaybackService: () => ({
speakSentences: mocks.speakSentences,
stop: mocks.ttsStop,
}),
}))
vi.mock('../../../src/main/services/SoundEffectService', () => ({
getSoundEffectService: () => ({ play: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: () => undefined,
}))
vi.mock('../../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({
canUse: () => ({ allowed: true }),
promptUpgrade: vi.fn(),
}),
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({
getMainWindow: () => ({
isDestroyed: () => false,
webContents: { send: mocks.send },
}),
}))
function waitForAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
}
async function* waitUntilAborted(options?: ChatOptions): AsyncGenerator<string, string> {
if (!options?.signal) throw new Error('Expected caller-owned abort signal')
await waitForAbort(options.signal)
return ''
}
describe('VoiceConversationService local response ownership', () => {
beforeEach(() => {
resetVoiceConversationServiceForTests()
vi.clearAllMocks()
mocks.chatStream.mockImplementation((_messages: unknown, options?: ChatOptions) => waitUntilAborted(options))
})
afterEach(() => {
resetVoiceConversationServiceForTests()
})
it('rejects a second send without starting a second local chat or adding duplicate history', async () => {
const service = getVoiceConversationService()
await service.startSession()
const firstResponse = service.sendTextMessage('first')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
await expect(service.sendTextMessage('second')).rejects.toMatchObject({
code: ErrorCode.ConversationLLMFailed,
})
expect(mocks.chatStream).toHaveBeenCalledTimes(1)
expect(service.getHistory().map((message) => message.content)).toEqual(['first'])
service.stopSession()
await firstResponse
})
it.each([
['stopSession', (service: ReturnType<typeof getVoiceConversationService>) => service.stopSession()],
['cancelResponse', (service: ReturnType<typeof getVoiceConversationService>) => service.cancelResponse()],
])('aborts the local stream signal when %s is called', async (_action, cancel) => {
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('cancel me')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
const options = mocks.chatStream.mock.calls[0][1] as ChatOptions
cancel(service)
expect(options.signal?.aborted).toBe(true)
await response
})
it('sets bounded local chat options for voice responses', async () => {
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('options')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
expect(mocks.chatStream).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
signal: expect.any(AbortSignal),
maxTokens: 512,
timeoutMs: 60_000,
keepAlive: '2m',
}),
)
service.stopSession()
await response
})
it('does not publish late assistant output or start TTS after cancellation', async () => {
let releaseLateToken: (() => void) | undefined
const lateToken = new Promise<void>((resolve) => {
releaseLateToken = resolve
})
mocks.chatStream.mockImplementation(() => (async function* (): AsyncGenerator<string, string> {
await lateToken
yield 'late answer.'
return 'late answer.'
})())
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('cancel before response')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
service.cancelResponse()
mocks.send.mockClear()
releaseLateToken?.()
await response
const lateConversationEvents = mocks.send.mock.calls.filter(([channel]) => (
channel === IPC_CHANNELS.VOICE_CONVERSATION.ASSISTANT_DELTA
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.ASSISTANT_MESSAGE
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.TTS_STARTED
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.TTS_FINISHED
))
expect(lateConversationEvents).toEqual([])
expect(mocks.speakSentences).not.toHaveBeenCalled()
expect(service.getHistory().map((message) => message.content)).toEqual(['cancel before response'])
})
})

View file

@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest'
import {
buildLocalSuggestionCandidates,
calculateFrictionInsight,
rankFlowWindows,
recommendAppExclusion,
selectPhraseHints,
type InputAppSuggestionStat,
type InputHourlyStat,
type InputPrivacyReceipt,
type PersonalPhrase
} from '@d3ro/core/input-intelligence'
import {
auditKeyBindingMap,
createDefaultBindingMap,
type KeyBinding
} from '@d3ro/core/keybinding'
const HOUR = 60 * 60 * 1000
const DAY = 24 * HOUR
function binding(code: number, modifiers: Partial<KeyBinding> = {}): KeyBinding {
return {
device: 'keyboard',
code,
ctrl: false,
alt: false,
shift: false,
meta: false,
...modifiers
}
}
describe('로컬 플로우 도메인', () => {
it('편집 마찰을 0~1 비율과 경계 등급으로 계산한다', () => {
expect(calculateFrictionInsight(0, 0)).toEqual({ rate: 0, editsPer100Chars: 0, band: 'steady' })
expect(calculateFrictionInsight(92, 8)).toMatchObject({ rate: 0.08, editsPer100Chars: 8.7, band: 'watch' })
expect(calculateFrictionInsight(82, 18)).toMatchObject({ rate: 0.18, editsPer100Chars: 22, band: 'high' })
})
it('플로우 시간대는 밀도·안정성 순으로 고르고 입력 없는 시간은 빼며 원본을 바꾸지 않는다', () => {
const hourly: InputHourlyStat[] = [
{ hour: 11, keystrokes: 1400, clicks: 2, chars: 1200, backspaces: 0, activeMs: HOUR },
{ hour: 9, keystrokes: 1400, clicks: 2, chars: 1200, backspaces: 0, activeMs: HOUR },
{ hour: 13, keystrokes: 200, clicks: 0, chars: 300, backspaces: 100, activeMs: HOUR / 2 },
{ hour: 3, keystrokes: 0, clicks: 0, chars: 0, backspaces: 0, activeMs: 0 }
]
const before = structuredClone(hourly)
expect(rankFlowWindows(hourly, 1, 3)).toEqual([
{ hour: 9, score: 100, activeMinutes: 60, chars: 1200, frictionRate: 0 },
{ hour: 11, score: 100, activeMinutes: 60, chars: 1200, frictionRate: 0 },
{ hour: 13, score: 44, activeMinutes: 30, chars: 300, frictionRate: 0.25 }
])
expect(hourly).toEqual(before)
})
it('활성 일수로 나눠 같은 시간대라도 하루 밀도를 낮춘다', () => {
const [window] = rankFlowWindows(
[{ hour: 9, keystrokes: 1400, clicks: 0, chars: 1200, backspaces: 0, activeMs: HOUR }],
2
)
expect(window).toMatchObject({ hour: 9, score: 58, activeMinutes: 30, chars: 1200 })
})
it('앱 문체 보너스와 반감기로 개인 문구를 정렬하며 원본 배열을 바꾸지 않는다', () => {
const now = 1_800_000_000_000
const phrases: PersonalPhrase[] = [
{
id: 'z-old-frequent',
phrase: '배포 일정을 공유합니다',
count: 16,
source: 'typed',
appName: 'Slack.exe',
lastUsedAt: now - DAY * 90,
createdAt: now - DAY * 90
},
{
id: 'b-current-app',
phrase: '검토 결과를 남깁니다',
count: 1,
source: 'typed',
appName: 'Notion.exe',
lastUsedAt: now,
createdAt: now
},
{
id: 'a-current-app-tie',
phrase: '다음 조치를 확인합니다',
count: 1,
source: 'typed',
appName: 'Notion.exe',
lastUsedAt: now,
createdAt: now
}
]
const before = structuredClone(phrases)
expect(selectPhraseHints(phrases, '', 3, { appName: 'notion.exe', now })).toEqual([
'다음 조치를 확인합니다',
'검토 결과를 남깁니다',
'배포 일정을 공유합니다'
])
expect(phrases).toEqual(before)
})
it('반복적인 비가독 앱만 제외를 권하고 읽힌 기록이 하나라도 있으면 멈춘다', () => {
expect(
recommendAppExclusion({ appName: 'Legacy.exe', samples: 4, readable: 0, unreadable: 3, empty: 1 })
).toEqual({ appName: 'Legacy.exe', reason: 'repeated-unreadable', samples: 4 })
expect(
recommendAppExclusion({ appName: 'Mixed.exe', samples: 6, readable: 1, unreadable: 5, empty: 0 })
).toBeNull()
expect(
recommendAppExclusion({ appName: ' ', samples: 6, readable: 0, unreadable: 6, empty: 0 })
).toBeNull()
})
it('로컬 제안은 출처 우선순위·접미 중첩 제거·중복 제거를 지킨다', () => {
expect(
buildLocalSuggestionCandidates(
'회의 결과',
{
continuationHints: ['를 공유합니다.'],
relatedHints: ['결과를 정리합니다.', '무관한 전체 문장'],
phraseHints: ['결과를 정리합니다.', '결과를 검토합니다.']
}
)
).toEqual(['를 공유합니다.', '를 정리합니다.', '를 검토합니다.'])
})
it('문장이 끝난 뒤에만 겹침 없는 전체 문구를 로컬 제안으로 허용한다', () => {
const hints = { continuationHints: [], relatedHints: ['다음 안건을 정리합니다.'], phraseHints: [] }
expect(buildLocalSuggestionCandidates('회의를 마쳤다', hints)).toEqual([])
expect(buildLocalSuggestionCandidates('회의를 마쳤다.', hints)).toEqual(['다음 안건을 정리합니다.'])
})
it('프라이버시·앱 품질 계약은 수량과 로컬 경계를 명시한다', () => {
const receipt: InputPrivacyReceipt = {
localOnly: true,
rawKeyContentStored: false,
retention: {
activityDays: 30,
typingSamplesDays: 30,
suggestionDays: 30,
personalPhrases: 'until-deleted'
},
counts: { activityBuckets: 3, typingSamples: 2, personalPhrases: 4, suggestions: 5 }
}
const appStat: InputAppSuggestionStat = {
appName: 'notion.exe',
total: 4,
accepted: 2,
acceptRate: 0.5,
avgLatencyMs: 310
}
expect(receipt.counts.personalPhrases).toBe(4)
expect(receipt.retention).toEqual({
activityDays: 30,
typingSamplesDays: 30,
suggestionDays: 30,
personalPhrases: 'until-deleted'
})
expect(appStat.acceptRate).toBe(0.5)
})
})
describe('단축키 안전 감사', () => {
it('유효하지 않은 바인딩과 충돌을 한 번씩 보고하고 홀드·더블프레스 기본 예외는 유지한다', () => {
const map = createDefaultBindingMap()
map.caption = [binding(0x56, { ctrl: true, shift: true })]
map.dictation = [binding(999)]
const issues = auditKeyBindingMap(map)
expect(issues).toContainEqual({
kind: 'invalid',
actionId: 'dictation',
bindingIndex: 0,
binding: binding(999),
reasonKey: 'keybinding.reject.unknownKey',
conflictActionIds: []
})
expect(issues).toContainEqual({
kind: 'conflict',
actionId: 'caption',
bindingIndex: 0,
binding: binding(0x56, { ctrl: true, shift: true }),
reasonKey: null,
conflictActionIds: ['history-popup']
})
expect(issues.filter((issue) => issue.kind === 'conflict')).toHaveLength(1)
})
})

View file

@ -0,0 +1,581 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { inputActivity, personalPhrases, suggestions, typingSamples } from '../../../src/main/db/schema'
type ActivityRow = {
id: string
date: string
hour: number
appName: string
keystrokes: number
shortcuts: number
backspaces: number
clicks: number
doubleClicks: number
scrollTicks: number
mouseDistancePx: number
chars: number
words: number
sentences: number
activeMs: number
updatedAt: number
}
type PhraseRow = {
id: string
phrase: string
count: number
source: 'typed' | 'voice' | 'suggestion' | 'clipboard'
appName: string | null
lastUsedAt: number | null
createdAt: number
}
type SuggestionRow = {
id: string
appName: string | null
prefixText: string
suggestionText: string
candidateCount: number
model: string | null
latencyMs: number | null
accepted: boolean
createdAt: number
}
type SampleRow = {
id: string
text: string
wordCount: number
charCount: number
appName: string | null
windowTitle: string | null
source: 'typed' | 'voice' | 'suggestion' | 'clipboard'
createdAt: number
}
type StreamOptions = { signal?: AbortSignal }
type StreamFactory = (prompt: string, options: StreamOptions) => AsyncIterable<string>
const harness = vi.hoisted(() => {
const config = new Map<string, unknown>()
const failure = { read: false, delete: false }
const model: { available: boolean; streamGenerate: StreamFactory } = {
available: false,
streamGenerate: async function* () {
return
}
}
const rows: {
activity: ActivityRow[]
phrases: PhraseRow[]
samples: SampleRow[]
suggestions: SuggestionRow[]
} = { activity: [], phrases: [], samples: [], suggestions: [] }
const rowsFor = (table: unknown): ActivityRow[] | PhraseRow[] | SampleRow[] | SuggestionRow[] => {
if (table === inputActivity) return rows.activity
if (table === personalPhrases) return rows.phrases
if (table === typingSamples) return rows.samples
return rows.suggestions
}
const database = {
select(selection?: Record<string, unknown>) {
if (failure.read) throw new Error('receipt read failed')
return {
from(table: unknown) {
const selectedRows = rowsFor(table)
const chain = {
where: () => chain,
orderBy: () => chain,
limit: () => chain,
all: () => {
const keys = Object.keys(selection ?? {})
if (table === suggestions && keys.includes('appName')) {
return rows.suggestions.map(({ appName, accepted, latencyMs }) => ({ appName, accepted, latencyMs }))
}
return [...selectedRows]
},
get: () => {
const keys = Object.keys(selection ?? {})
if (keys.includes('total')) {
const suggestionRows = rows.suggestions
const latencyRows = suggestionRows.filter((row) => row.latencyMs !== null)
return {
total: suggestionRows.length,
accepted: suggestionRows.filter((row) => row.accepted).length,
avgLatency:
latencyRows.length === 0
? null
: latencyRows.reduce((total, row) => total + (row.latencyMs ?? 0), 0) /
latencyRows.length
}
}
if (keys.includes('value')) return { value: selectedRows.length }
return selectedRows[0]
}
}
return chain
}
}
},
insert(table: unknown) {
return {
values(value: Record<string, unknown>) {
const selectedRows = rowsFor(table)
const apply = () => {
if (table === personalPhrases) {
const phrase = value.phrase as string
const existing = rows.phrases.find((row) => row.phrase === phrase)
if (existing) {
existing.count += 1
existing.lastUsedAt = value.lastUsedAt as number
existing.appName = value.appName as string | null
return
}
}
selectedRows.push(value as never)
}
return {
run: () => apply(),
onConflictDoUpdate: () => ({ run: () => apply() })
}
}
}
},
delete(table: unknown) {
const selectedRows = rowsFor(table)
const chain = {
where: () => chain,
run: () => {
if (failure.delete) throw new Error('delete failed')
selectedRows.splice(0, selectedRows.length)
return { changes: 1 }
}
}
return chain
},
update: () => ({ set: () => ({ where: () => ({ run: () => ({ changes: 1 }) }) }) })
}
const uia = {
isAvailable: () => false,
lastReason: 'test',
lastSuccessAt: 0,
getSnapshot: vi.fn(async () => ({
available: false,
reason: 'test',
isPassword: false,
isEditable: false,
isComposing: false,
hasSelection: false,
textSource: 'none' as const,
text: '',
caretOffset: null,
caretRect: null,
elementRect: null,
windowTitle: null,
appName: null,
processId: null,
capturedAt: Date.now()
}))
}
return {
config,
failure,
model,
rows,
database,
uia,
graph: { continuations: [] as string[], related: [] as string[] }
}
})
vi.mock('electron', () => ({ screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }) } }))
vi.mock('uiohook-napi', () => ({ uIOhook: { on: vi.fn(), removeListener: vi.fn() } }))
vi.mock('../../../src/main/db', () => ({ getDatabase: () => harness.database }))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: (key: string) => harness.config.get(key),
configSet: (key: string, value: unknown) => harness.config.set(key, value)
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })
}))
vi.mock('../../../src/main/services/global-input-hook', () => ({ acquireGlobalInputHook: () => () => undefined }))
vi.mock('../../../src/main/services/KeyBindingService', () => ({ uiohookCodeToVk: () => null }))
vi.mock('../../../src/main/services/UiaContextService', () => ({
getUiaContextService: () => harness.uia
}))
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
getPersonalGraphService: () => ({
clearAll: vi.fn(),
runMaintenance: vi.fn(),
indexText: vi.fn(),
retrieveContext: () => harness.graph
})
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: () => harness.model.available,
streamGenerate: (prompt: string, options: StreamOptions) => harness.model.streamGenerate(prompt, options)
})
}))
vi.mock('../../../src/main/services/TextInsertService', () => ({ getTextInsertService: () => ({ insertText: vi.fn() }) }))
import {
getInputTelemetryService,
resetInputTelemetryServiceForTests
} from '../../../src/main/services/InputTelemetryService'
import {
getSuggestionService,
resetSuggestionServiceForTests
} from '../../../src/main/services/SuggestionService'
const now = Date.now()
function activity(overrides: Partial<ActivityRow> = {}): ActivityRow {
return {
id: crypto.randomUUID(),
date: new Date(now).toISOString().slice(0, 10),
hour: 9,
appName: 'Notion.exe',
keystrokes: 0,
shortcuts: 0,
backspaces: 0,
clicks: 0,
doubleClicks: 0,
scrollTicks: 0,
mouseDistancePx: 0,
chars: 0,
words: 0,
sentences: 0,
activeMs: 0,
updatedAt: now,
...overrides
}
}
function typingContext(overrides: Partial<Parameters<ReturnType<typeof getSuggestionService>['handleTypingContext']>[0]> = {}) {
return {
prefix: '오늘 회의 결과를',
fullText: '오늘 회의 결과를',
caretOffset: 9,
anchor: { x: 1, y: 1, width: 1, height: 1 },
isPassword: false,
isEditable: true,
isComposing: false,
hasSelection: false,
available: true,
appName: 'Notion.exe',
windowTitle: '회의록',
idleMs: 300,
capturedAt: now,
...overrides
}
}
beforeEach(() => {
harness.config.clear()
harness.config.set('inputExcludedApps', [])
harness.config.set('inputLearnTypedText', true)
harness.config.set('suggestionEnabled', true)
harness.config.set('suggestionMinPrefixChars', 4)
harness.config.set('suggestionTriggerDelayMs', 100)
harness.config.set('suggestionMaxRequestsPerMinute', 20)
harness.config.set('suggestionDailyBudget', 100)
harness.failure.read = false
harness.failure.delete = false
harness.model.available = false
harness.model.streamGenerate = async function* () {
return
}
harness.rows.activity.splice(0)
harness.rows.phrases.splice(0)
harness.rows.samples.splice(0)
harness.rows.suggestions.splice(0)
harness.graph.continuations = []
harness.graph.related = []
resetInputTelemetryServiceForTests()
resetSuggestionServiceForTests()
harness.uia.getSnapshot.mockClear()
})
describe('입력 플로우 서비스', () => {
it('제안 표시 중에만 오래된 키 입력과 mouse-up 후 UIA 포커스를 다시 확인한다', async () => {
const telemetry = getInputTelemetryService() as unknown as {
_running: boolean
_lastKeyAt: number
_sampleWhileTyping: () => Promise<void>
_handleMouseUp: (event: { x: number; y: number }) => void
setSuggestionPresentationActive: (active: boolean) => void
}
telemetry._running = true
telemetry._lastKeyAt = Date.now() - 6000
await telemetry._sampleWhileTyping()
expect(harness.uia.getSnapshot).not.toHaveBeenCalled()
telemetry.setSuggestionPresentationActive(true)
await telemetry._sampleWhileTyping()
expect(harness.uia.getSnapshot).toHaveBeenCalledTimes(1)
vi.useFakeTimers()
telemetry._handleMouseUp({ x: 1, y: 1 })
await vi.advanceTimersByTimeAsync(120)
expect(harness.uia.getSnapshot).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('시간대 집계에 플로우·마찰과 앱별 제안 품질을 함께 반환한다', () => {
harness.rows.activity.push(
activity({ hour: 9, chars: 1200, backspaces: 30, activeMs: 60 * 60 * 1000, keystrokes: 1300 }),
activity({ id: crypto.randomUUID(), hour: 14, chars: 120, backspaces: 60, activeMs: 20 * 60 * 1000 })
)
harness.rows.suggestions.push(
{ id: 's1', appName: 'Notion.exe', prefixText: 'a', suggestionText: 'b', candidateCount: 1, model: 'm', latencyMs: 100, accepted: true, createdAt: now },
{ id: 's2', appName: 'Notion.exe', prefixText: 'a', suggestionText: 'c', candidateCount: 1, model: 'm', latencyMs: 300, accepted: false, createdAt: now },
{ id: 's3', appName: 'Slack.exe', prefixText: 'a', suggestionText: 'd', candidateCount: 1, model: 'm', latencyMs: null, accepted: true, createdAt: now }
)
const summary = getInputTelemetryService().getSummary(1)
expect(summary.hourly[9]).toMatchObject({ chars: 1200, backspaces: 30, activeMs: 3600000 })
expect(summary.friction).toMatchObject({ band: 'steady', editsPer100Chars: 6.8 })
expect(summary.flowWindows[0]).toMatchObject({ hour: 9 })
expect(summary.suggestionApps).toEqual([
{ appName: 'Notion.exe', total: 2, accepted: 1, acceptRate: 0.5, avgLatencyMs: 200 },
{ appName: 'Slack.exe', total: 1, accepted: 1, acceptRate: 1, avgLatencyMs: null }
])
})
it('개인정보 영수증은 로컬 보존 경계와 실제 행 수를 반환한다', () => {
harness.rows.activity.push(activity())
harness.rows.samples.push({ id: 'sample', text: '학습 문장입니다', wordCount: 2, charCount: 7, appName: null, windowTitle: null, source: 'typed', createdAt: now })
harness.rows.phrases.push({ id: 'phrase', phrase: '학습 문장입니다', count: 1, source: 'typed', appName: null, lastUsedAt: now, createdAt: now })
harness.rows.suggestions.push({ id: 's', appName: null, prefixText: '', suggestionText: '후보', candidateCount: 1, model: 'm', latencyMs: 10, accepted: false, createdAt: now })
expect(getInputTelemetryService().getPrivacyReceipt()).toMatchObject({
localOnly: true,
rawKeyContentStored: false,
retention: {
activityDays: 30,
typingSamplesDays: 30,
suggestionDays: 30,
personalPhrases: 'until-deleted'
},
counts: { activityBuckets: 1, typingSamples: 1, personalPhrases: 1, suggestions: 1 }
})
})
it('개인정보 영수증 조회 실패를 빈 영수증으로 위장하지 않는다', () => {
harness.failure.read = true
expect(() => getInputTelemetryService().getPrivacyReceipt()).toThrow('receipt read failed')
})
it('전체 삭제는 제안 이력까지 제거한다', () => {
harness.rows.activity.push(activity())
harness.rows.suggestions.push({ id: 's', appName: null, prefixText: '', suggestionText: '후보', candidateCount: 1, model: 'm', latencyMs: 10, accepted: false, createdAt: now })
getInputTelemetryService().clearAll()
expect(getInputTelemetryService().getPrivacyReceipt().counts).toEqual({
activityBuckets: 0,
typingSamples: 0,
personalPhrases: 0,
suggestions: 0
})
})
it('전체 삭제 실패를 성공처럼 반환하지 않고 메모리 초기화도 건너뛴다', () => {
harness.rows.activity.push(activity())
const service = getInputTelemetryService() as unknown as {
_pending: Map<string, unknown>
clearAll: () => void
}
service._pending.set('pending', {})
harness.failure.delete = true
expect(() => service.clearAll()).toThrow('delete failed')
expect(harness.rows.activity).toHaveLength(1)
expect(service._pending.size).toBe(1)
})
it('보존 정리는 만료 제안 이력도 같은 경계로 정리한다', () => {
harness.rows.suggestions.push({
id: 'expired',
appName: null,
prefixText: '',
suggestionText: '만료 후보',
candidateCount: 1,
model: 'm',
latencyMs: 10,
accepted: false,
createdAt: now - 31 * 24 * 60 * 60 * 1000
})
const service = getInputTelemetryService() as unknown as {
_pruneOldData: (at: number) => void
}
service._pruneOldData(now)
expect(getInputTelemetryService().getPrivacyReceipt().counts.suggestions).toBe(0)
})
it('학습 문구에 마지막 앱을 저장하고 목록 계약에 매핑한다', () => {
const service = getInputTelemetryService() as unknown as {
_learnText: (text: string, meta: { appName: string | null; windowTitle: string | null; source: 'typed' }) => void
}
service._learnText('배포 일정을 공유합니다. 다음 조치를 확인합니다.', {
appName: 'Slack.exe',
windowTitle: '채널',
source: 'typed'
})
expect(getInputTelemetryService().listPhrases()).toEqual(
expect.arrayContaining([expect.objectContaining({ appName: 'Slack.exe' })])
)
})
it('비밀번호 스냅샷은 제외 추천 증거에 넣지 않고 반복 비가독 앱만 추천한다', () => {
const service = getInputTelemetryService() as unknown as {
_foreground: { appName: string | null }
_recordReadabilityEvidence: (snapshot: { isPassword: boolean; isEditable: boolean; textSource: 'none' | 'value'; text: string }) => void
}
service._foreground.appName = 'Legacy.exe'
for (let index = 0; index < 4; index += 1) {
service._recordReadabilityEvidence({ isPassword: false, isEditable: false, textSource: 'none', text: '' })
}
service._recordReadabilityEvidence({ isPassword: true, isEditable: false, textSource: 'none', text: '' })
expect(getInputTelemetryService().getState().exclusionRecommendation).toEqual({
appName: 'Legacy.exe',
reason: 'repeated-unreadable',
samples: 4
})
})
it('모델이 없을 때 정책을 지키며 로컬 기억 후보와 출처를 게시한다', () => {
harness.rows.phrases.push({ id: 'phrase', phrase: '검토 결과를 공유합니다', count: 4, source: 'typed', appName: 'Notion.exe', lastUsedAt: now, createdAt: now })
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
getSuggestionService().handleTypingContext({
prefix: '오늘 회의 결과를',
fullText: '오늘 회의 결과를',
caretOffset: 9,
anchor: { x: 1, y: 1, width: 1, height: 1 },
isPassword: false,
isEditable: true,
isComposing: false,
available: true,
appName: 'Notion.exe',
windowTitle: '회의록',
idleMs: 300,
capturedAt: now
})
expect(getSuggestionService().getState()).toMatchObject({
visible: true,
provenance: { mode: 'local-memory', continuationCount: 1, appPhraseCount: 1 }
})
expect(harness.rows.suggestions[0]).toMatchObject({ model: 'local-memory' })
})
it('timeout abort가 스트림에서 throw되어도 로컬 기억 fallback을 한 번 게시한다', async () => {
const context = typingContext()
const service = getSuggestionService() as unknown as {
_lastContext: typeof context | null
_lastContextAt: number
_generate: (prefix: string, context: typeof context, maxCandidates: number, maxChars: number) => Promise<void>
}
harness.config.set('llmModelId', 'local-model')
harness.config.set('suggestionRequestTimeoutMs', 10)
harness.model.available = true
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
harness.model.streamGenerate = (_prompt, options) => ({
async *[Symbol.asyncIterator]() {
await new Promise<void>((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(new Error('timeout abort')), { once: true })
})
}
})
service._lastContext = context
service._lastContextAt = Date.now()
await service._generate(context.prefix, context, 3, 160)
expect(getSuggestionService().getState()).toMatchObject({
visible: true,
provenance: { mode: 'local-memory', continuationCount: 1 }
})
expect(harness.rows.suggestions).toHaveLength(1)
})
it('timeout 중 현재 문맥과 세대가 바뀌면 로컬 기억 fallback을 게시하지 않는다', async () => {
const context = typingContext()
const service = getSuggestionService() as unknown as {
_generationToken: number
_lastContext: typeof context | null
_lastContextAt: number
_generate: (prefix: string, context: typeof context, maxCandidates: number, maxChars: number) => Promise<void>
}
harness.config.set('llmModelId', 'local-model')
harness.config.set('suggestionRequestTimeoutMs', 20)
harness.model.available = true
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
harness.model.streamGenerate = (_prompt, options) => ({
async *[Symbol.asyncIterator]() {
await new Promise<void>((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(new Error('timeout abort')), { once: true })
})
}
})
service._lastContext = context
service._lastContextAt = Date.now()
const changed = typingContext({ prefix: '다른 문맥입니다', appName: 'Slack.exe', windowTitle: '채널' })
const invalidate = setTimeout(() => {
service._generationToken += 1
service._lastContext = changed
service._lastContextAt = Date.now()
}, 1)
await service._generate(context.prefix, context, 3, 160)
clearTimeout(invalidate)
expect(getSuggestionService().getState().visible).toBe(false)
expect(harness.rows.suggestions).toHaveLength(0)
})
it('세대가 달라진 로컬 후보는 게시하지 않아 명시 취소 뒤 되살아나지 않는다', () => {
const service = getSuggestionService() as unknown as {
_generationToken: number
_publishLocalMemory: (
prefix: string,
context: Parameters<ReturnType<typeof getSuggestionService>['handleTypingContext']>[0],
maxCandidates: number,
maxChars: number,
latencyMs: number,
token: number
) => boolean
}
service._generationToken = 2
expect(
service._publishLocalMemory(
'오늘 회의 결과를',
{
prefix: '오늘 회의 결과를', fullText: '', caretOffset: null, anchor: null, isPassword: false,
isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'Notion.exe', windowTitle: null,
idleMs: 300, capturedAt: now
},
3,
160,
0,
1
)
).toBe(false)
expect(getSuggestionService().getState().visible).toBe(false)
})
})

View file

@ -0,0 +1,470 @@
// tests/main/services/input-intelligence.test.ts
// 입력 인텔리전스 정책/집계 순수 함수 테스트.
//
// 여기 있는 함수들이 서비스의 판단 근거다:
// - 어떤 키를 어떤 종류로 세는가 (내용은 저장하지 않는다)
// - 케어앞 텍스트 diff 로 얼마나 셌는가
// - 언제 제안을 요청/스킵/삭제하는가
// - 오버레이를 어디에 붙이는가
// 따라서 LLM 없이도 전부 검증할 수 있어야 한다.
import { describe, it, expect } from 'vitest'
import {
INPUT_TELEMETRY_DEFAULTS,
PASTE_INSERTION_THRESHOLD_CHARS,
SUGGESTION_DEFAULTS,
anchorFloatingPanel,
classifyKeyStroke,
computeTypedDelta,
countSentences,
countWords,
decideSuggestion,
decideSuggestionRefresh,
emptyActivityBucket,
endsSentence,
extractPhrases,
isAppExcluded,
isWordBoundaryKey,
manhattanDistance,
mergeActivityBucket,
parseSuggestionCandidates,
pixelsToMeters,
sanitizeSuggestionLine,
selectPhraseHints,
summarizeActivity,
prefixTail,
textBeforeCaret,
type PersonalPhrase,
type SuggestionPolicyInput
} from '@d3ro/core/input-intelligence'
const NO_MODS = { ctrl: false, alt: false, shift: false, meta: false }
describe('classifyKeyStroke', () => {
it('문자/숫자/기능 키를 종류로 나눈다', () => {
expect(classifyKeyStroke(0x41, NO_MODS)).toBe('letter') // A
expect(classifyKeyStroke(0x5a, NO_MODS)).toBe('letter') // Z
expect(classifyKeyStroke(0x30, NO_MODS)).toBe('digit') // 0
expect(classifyKeyStroke(0x60, NO_MODS)).toBe('digit') // numpad 0
expect(classifyKeyStroke(0x20, NO_MODS)).toBe('space')
expect(classifyKeyStroke(0x0d, NO_MODS)).toBe('enter')
expect(classifyKeyStroke(0x08, NO_MODS)).toBe('backspace')
expect(classifyKeyStroke(0x2e, NO_MODS)).toBe('delete')
expect(classifyKeyStroke(0x27, NO_MODS)).toBe('arrow')
expect(classifyKeyStroke(0x70, NO_MODS)).toBe('function')
expect(classifyKeyStroke(0x11, NO_MODS)).toBe('modifier')
expect(classifyKeyStroke(0xe5, NO_MODS)).toBe('ime')
})
it('수정자 조합은 단축키로 다', () => {
expect(classifyKeyStroke(0x41, { ...NO_MODS, ctrl: true })).toBe('shortcut')
expect(classifyKeyStroke(0x41, { ...NO_MODS, alt: true })).toBe('shortcut')
expect(classifyKeyStroke(0x41, { ...NO_MODS, meta: true })).toBe('shortcut')
})
it('알 수 없는 키는 other 이며 키 내용을 담지 않는다', () => {
expect(classifyKeyStroke(0x0000, NO_MODS)).toBe('other')
})
it('단어 경계 키 판정', () => {
expect(isWordBoundaryKey('space')).toBe(true)
expect(isWordBoundaryKey('enter')).toBe(true)
expect(isWordBoundaryKey('letter')).toBe(false)
})
})
describe('텍스트 지표', () => {
it('단어 수 — 공백 분리 토큰 중 문자/숫자가 있는 것만', () => {
expect(countWords('')).toBe(0)
expect(countWords('hello world')).toBe(2)
expect(countWords('안녕하세요 오늘 날씨')).toBe(3)
expect(countWords(' , . ! ')).toBe(0)
expect(countWords('v1.2 배포 준비')).toBe(3)
})
it('문장 수 — 종결 부호와 개행', () => {
expect(countSentences('하나. 둘! 셋?')).toBe(3)
expect(countSentences('no terminator')).toBe(0)
expect(countSentences('첫 줄\n둘째 줄')).toBe(1)
expect(countSentences('첫 줄\n둘째 줄\n')).toBe(2)
})
it('문장 종결 판정', () => {
expect(endsSentence('났습니다.')).toBe(true)
expect(endsSentence('아직 쓰는 중')).toBe(false)
expect(endsSentence('')).toBe(false)
})
it('케어 앞 텍스트만 어낸다', () => {
expect(textBeforeCaret('hello world', 5)).toBe('hello')
expect(textBeforeCaret('hello', null)).toBe('hello')
expect(textBeforeCaret('hello', 99)).toBe('hello')
})
})
describe('computeTypedDelta', () => {
it('끝에 이어 쓰면 삽입 구간만 센다', () => {
const delta = computeTypedDelta('안녕하세요', '안녕하세요 반갑', { keepText: true })
expect(delta.insertedChars).toBe(3)
expect(delta.insertedText).toBe(' 반갑')
expect(delta.replaced).toBe(false)
})
it('IME 커밋 텍스트(한글)도 그대로 계산된다', () => {
const delta = computeTypedDelta('', '오늘 회의는', { keepText: true })
expect(delta.insertedChars).toBe(6)
expect(delta.insertedWords).toBe(2)
expect(delta.replaced).toBe(false)
})
it('중간 삽입도 찾아낸다', () => {
const delta = computeTypedDelta('abc', 'abXc', { keepText: true })
expect(delta.insertedText).toBe('X')
})
it('제만 있으면 0 이다', () => {
const delta = computeTypedDelta('abcdef', 'abc', { keepText: true })
expect(delta.insertedChars).toBe(0)
expect(delta.insertedText).toBe('')
})
it('여넣기 크기 삽입은 통계에서 제외한다', () => {
const pasted = 'x'.repeat(PASTE_INSERTION_THRESHOLD_CHARS + 1)
const delta = computeTypedDelta('', pasted, { keepText: true })
expect(delta.replaced).toBe(true)
expect(delta.insertedChars).toBe(0)
expect(delta.insertedText).toBe('')
})
it('학습이 꺼져 있으면 텍스트를 담지 않는다', () => {
const delta = computeTypedDelta('', 'hello', { keepText: false })
expect(delta.insertedChars).toBe(5)
expect(delta.insertedText).toBe('')
})
})
describe('decideSuggestion', () => {
function policy(overrides: Partial<SuggestionPolicyInput> = {}): SuggestionPolicyInput {
return {
enabled: true,
modelAvailable: true,
overlayVisible: false,
composing: false,
hasSelection: false,
isPassword: false,
isEditable: true,
appName: 'chrome.exe',
excludedApps: [],
prefix: '오늘 회의에서 논의한 내용을 정리해서',
idleMs: SUGGESTION_DEFAULTS.triggerDelayMs + 50,
triggerDelayMs: SUGGESTION_DEFAULTS.triggerDelayMs,
minPrefixChars: SUGGESTION_DEFAULTS.minPrefixChars,
sinceLastRequestMs: SUGGESTION_DEFAULTS.minIntervalMs + 1,
minIntervalMs: SUGGESTION_DEFAULTS.minIntervalMs,
requestsThisMinute: 0,
maxRequestsPerMinute: SUGGESTION_DEFAULTS.maxRequestsPerMinute,
requestsToday: 0,
dailyBudget: SUGGESTION_DEFAULTS.dailyBudget,
...overrides
}
}
it('조건이 모두 맞으면 요청한다', () => {
const decision = decideSuggestion(policy())
expect(decision).toEqual({ action: 'request', prefix: '오늘 회의에서 논의한 내용을 정리해서' })
})
it('비밀번호 필드는 다른 조건보다 먼저 차단한다', () => {
expect(decideSuggestion(policy({ isPassword: true }))).toEqual({
action: 'clear',
reason: 'password-field'
})
})
it('IME 조합 중에도 손을 멈추면 제안을 만든다 (한국어 필수)', () => {
// 조합을 하드 차단하면 한국어에서 "한 번 뜨고 이후 안 뜨는" 증상이 된다.
const idleEnough = SUGGESTION_DEFAULTS.triggerDelayMs * 2 + 100
expect(decideSuggestion(policy({ composing: true, idleMs: idleEnough })).action).toBe('request')
})
it('IME 조합 중 타이핑이 이어지면 조용히 넘어간다', () => {
const decision = decideSuggestion(
policy({ composing: true, idleMs: SUGGESTION_DEFAULTS.triggerDelayMs - 50 })
)
expect(decision).toEqual({ action: 'skip', reason: 'composing' })
})
it('비활성/편집 불가/제외 앱은 clear', () => {
expect(decideSuggestion(policy({ enabled: false }))).toEqual({ action: 'clear', reason: 'disabled' })
expect(decideSuggestion(policy({ isEditable: false }))).toEqual({
action: 'clear',
reason: 'not-editable'
})
expect(decideSuggestion(policy({ excludedApps: ['Chrome'] }))).toEqual({
action: 'clear',
reason: 'excluded-app'
})
})
it('비축소 선택 중에는 제안을 즉시 지운다', () => {
expect(decideSuggestion(policy({ hasSelection: true }))).toEqual({
action: 'clear',
reason: 'selection-active'
})
})
it('문장이 마침표로 끝나도 다음 문장을 제안한다 (기능 목적)', () => {
// "다음 문장 제안" 이므로 종결 부호 뒤가 오히려 제안이 필요한 지점이다.
expect(decideSuggestion(policy({ prefix: '오늘 회의는 여기서 끝났습니다.' }))).toEqual({
action: 'request',
prefix: '오늘 회의는 여기서 끝났습니다.'
})
})
it('접두가 짧으면 지운다', () => {
expect(decideSuggestion(policy({ prefix: '짧음' }))).toEqual({
action: 'clear',
reason: 'prefix-too-short'
})
expect(decideSuggestion(policy({ prefix: ' ' }))).toEqual({
action: 'clear',
reason: 'empty-prefix'
})
})
it('디바운스/모델 없음/레이트 리밋/예산 초과는 skip', () => {
expect(decideSuggestion(policy({ idleMs: 10 }))).toEqual({ action: 'skip', reason: 'debounce' })
expect(decideSuggestion(policy({ modelAvailable: false }))).toEqual({
action: 'skip',
reason: 'model-unavailable'
})
expect(decideSuggestion(policy({ requestsThisMinute: 99 }))).toEqual({
action: 'skip',
reason: 'rate-limited'
})
expect(decideSuggestion(policy({ sinceLastRequestMs: 10 }))).toEqual({
action: 'skip',
reason: 'rate-limited'
})
expect(decideSuggestion(policy({ requestsToday: 999, dailyBudget: 500 }))).toEqual({
action: 'skip',
reason: 'budget-exhausted'
})
})
it('이미 떠 있는 제안은 다시 요청하지 않는다', () => {
expect(decideSuggestion(policy({ overlayVisible: true }))).toEqual({
action: 'skip',
reason: 'already-visible'
})
})
})
describe('표시 중 제안 재생성 정책', () => {
it('연속 접두가 12자 미만 성장하면 기존 제안을 유지한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '회의 결과를 공유합니다 내일')).toBe('keep')
})
it('연속 접두가 12자 이상 성장하면 재생성한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '회의 결과를 공유합니다 다음 안건도 검토해 주세요')).toBe(
'regenerate'
)
})
it('생성 접두의 앞부분이 바뀌면 stale 로 처리한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe('stale')
})
})
describe('isAppExcluded', () => {
it('대소문자와 .exe 를 무시하고 비교한다', () => {
expect(isAppExcluded('KeePassXC.exe', ['keepassxc.exe'])).toBe(true)
expect(isAppExcluded('KeePassXC', ['keepassxc.exe'])).toBe(true)
expect(isAppExcluded('chrome.exe', ['keepassxc.exe'])).toBe(false)
})
it(' 목록/빈 이름은 제외하지 않는다', () => {
expect(isAppExcluded('chrome.exe', [])).toBe(false)
expect(isAppExcluded('', ['chrome.exe'])).toBe(false)
})
})
describe('후보 정제', () => {
it('번호/따옴표/불릿을 걷어낸다', () => {
expect(sanitizeSuggestionLine('1. 다음 문장입니다.')).toBe('다음 문장입니다.')
expect(sanitizeSuggestionLine('- "quoted line"')).toBe('quoted line')
expect(sanitizeSuggestionLine('* 별표 항목')).toBe('별표 항목')
})
it('지시문을 되풀이한 줄은 버린다 (프롬프트 누출 방어)', () => {
expect(sanitizeSuggestionLine('Suggestion: ...')).toBeNull()
expect(sanitizeSuggestionLine('다음 문장: 이어집니다')).toBeNull()
expect(sanitizeSuggestionLine('{{prefix}}')).toBeNull()
})
it('너무 짧은 줄은 버린다', () => {
expect(sanitizeSuggestionLine('a')).toBeNull()
expect(sanitizeSuggestionLine(' ')).toBeNull()
})
it('길이를 제한한다', () => {
const long = 'word '.repeat(80)
const trimmed = sanitizeSuggestionLine(long, 40)
expect(trimmed).not.toBeNull()
expect((trimmed ?? '').length).toBeLessThanOrEqual(40)
})
it('접두를 되풀이하는 후보는 제외하고 중복도 제거한다', () => {
const prefix = '오늘 회의에서 논의한'
const raw = [
'오늘 회의에서 논의한 내용을 정리합니다.',
'내용을 정리합니다.',
'내용을 정리합니다.',
'결론부터 공유드립니다.'
].join('\n')
const candidates = parseSuggestionCandidates(raw, prefix, 3)
expect(candidates).toEqual(['내용을 정리합니다.', '결론부터 공유드립니다.'])
})
it('후보 개수 상한을 지킨다', () => {
const raw = '첫 번째 후보입니다.\n두 번째 후보입니다.\n세 번째 후보입니다.'
expect(parseSuggestionCandidates(raw, '', 2)).toHaveLength(2)
})
})
describe('anchorFloatingPanel', () => {
const workArea = { x: 0, y: 0, width: 1920, height: 1080 }
const size = { width: 460, height: 96 }
it('케어 아래에 붙인다', () => {
const position = anchorFloatingPanel({ x: 400, y: 300, width: 2, height: 20 }, { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: 400, y: 326 })
})
it('아래 공간이 없으면 위로 뒤집는다', () => {
const position = anchorFloatingPanel(
{ x: 400, y: 1000, width: 2, height: 20 },
{ x: 0, y: 0 },
size,
workArea
)
expect(position.y).toBe(1000 - 6 - 96)
})
it('작업영역 밖으로 나가지 않는다', () => {
const position = anchorFloatingPanel(
{ x: 1900, y: 10, width: 2, height: 20 },
{ x: 0, y: 0 },
size,
workArea
)
expect(position.x).toBe(workArea.width - size.width)
expect(position.x + size.width).toBeLessThanOrEqual(workArea.width)
})
it('앵커가 없으면 커서를 쓴다', () => {
const position = anchorFloatingPanel(null, { x: 200, y: 500 }, size, workArea)
expect(position).toEqual({ x: 200, y: 506 })
})
})
describe('마우스 이동', () => {
it('맨해튼 거리 — 축별 절대값 합 (ActivityWatch 방식)', () => {
expect(manhattanDistance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(7)
expect(manhattanDistance({ x: 10, y: 10 }, { x: 10, y: 12 })).toBe(2)
})
it('셀을 미터로 환산한다', () => {
// 96 DPI 에서 1인치 = 96px = 0.0254m
expect(pixelsToMeters(96, 1)).toBeCloseTo(0.0254, 6)
expect(pixelsToMeters(192, 2)).toBeCloseTo(0.0254, 6)
})
})
describe('집계 버킷', () => {
it('빈 버킷은 모든 카운터가 0 이다', () => {
const bucket = emptyActivityBucket()
expect(Object.values(bucket).every((value) => value === 0)).toBe(true)
})
it('부분 delta 를 누적한다', () => {
const bucket = emptyActivityBucket()
mergeActivityBucket(bucket, { keystrokes: 3, clicks: 1, mouseDistancePx: 120 })
mergeActivityBucket(bucket, { keystrokes: 2 })
expect(bucket.keystrokes).toBe(5)
expect(bucket.clicks).toBe(1)
expect(bucket.mouseDistancePx).toBe(120)
expect(bucket.words).toBe(0)
})
it('일 평균을 낸다', () => {
const totals = emptyActivityBucket()
mergeActivityBucket(totals, {
keystrokes: 7000,
clicks: 140,
words: 1400,
mouseDistancePx: 96 * 3
})
const averages = summarizeActivity(totals, 7)
expect(averages.keystrokes).toBe(1000)
expect(averages.clicks).toBe(20)
expect(averages.words).toBe(200)
expect(averages.mouseDistanceMeters).toBeGreaterThan(0)
})
it('0 일 구간에도 0 나눗셈을 하지 않는다', () => {
const averages = summarizeActivity(emptyActivityBucket(), 0)
expect(averages.keystrokes).toBe(0)
expect(averages.mouseDistanceMeters).toBe(0)
})
})
describe('개인 문구', () => {
it('문장 종결 단위로 나누고 짧은 조각은 버린다', () => {
const phrases = extractPhrases('오늘 회의는 짧았습니다. 내일 일정을 공유드릴게요. 짧음.')
expect(phrases).toContain('오늘 회의는 짧았습니다')
expect(phrases).toContain('내일 일정을 공유드릴게요')
expect(phrases).not.toContain('짧음')
})
it('프롬프트 힌트는 사용 빈도 순으로 뽑고 접두 중복을 피한다', () => {
const now = Date.now()
const phrases: PersonalPhrase[] = [
{ id: 'a', phrase: '자주 쓰는 문장입니다', count: 5, source: 'typed', appName: null, lastUsedAt: now, createdAt: now },
{ id: 'b', phrase: '가 쓰는 문장입니다', count: 1, source: 'typed', appName: null, lastUsedAt: now, createdAt: now },
{ id: 'c', phrase: '자주 쓰는 문장입니다', count: 9, source: 'suggestion', lastUsedAt: now, createdAt: now }
]
const hints = selectPhraseHints(phrases, '', 2)
expect(hints[0]).toBe('자주 쓰는 문장입니다')
expect(hints).toHaveLength(2)
})
})
describe('기본값 (실측 근거)', () => {
it('자동 제안의 요청 상한은 보수적 기본 정책을 지킨다', () => {
expect(SUGGESTION_DEFAULTS.triggerDelayMs).toBe(600)
expect(SUGGESTION_DEFAULTS.minIntervalMs).toBe(5000)
expect(SUGGESTION_DEFAULTS.maxRequestsPerMinute).toBe(6)
expect(SUGGESTION_DEFAULTS.dailyBudget).toBe(500)
expect(SUGGESTION_DEFAULTS.maxCandidates).toBe(3)
expect(SUGGESTION_DEFAULTS.maxOutputTokens).toBe(64)
expect(SUGGESTION_DEFAULTS.regenerateAfterChars).toBe(12)
})
it('prefixTail 은 공백을 정규화하고 마지막 n자를 취한다', () => {
expect(prefixTail('오늘 회의에서 논의한 내용')).toBe('오늘 회의에서 논의한 내용')
expect(prefixTail('12345678901234567890')).toBe('78901234567890')
expect(prefixTail('짧음')).toBe('짧음')
expect(prefixTail('가나다라마바사아자차카타파하', 4)).toBe('카타파하')
})
it('flush 주기는 ActivityWatch heartbeat(5초)와 같다', () => {
expect(INPUT_TELEMETRY_DEFAULTS.flushIntervalMs).toBe(5000)
})
it('응답 제한은 콜드 로딩(실측 24.7초)을 오래 붙잡지 않는다', () => {
// gemma4:e4b 실측: 콜드 24.7초 / 워밍업 후 4.9초(32토큰). 상한이 없으면 그 시간 동안 붙잡힌다.
expect(SUGGESTION_DEFAULTS.requestTimeoutMs).toBeGreaterThanOrEqual(5000)
expect(SUGGESTION_DEFAULTS.requestTimeoutMs).toBeLessThanOrEqual(30000)
})
})

View file

@ -20,8 +20,10 @@ import {
renderInstructionPrompt,
buildInstructionInvocation,
resolveSystemPrompt,
buildSuggestionPrompt,
DEFAULT_TARGET_LANGUAGE,
BASE_SYSTEM_PROMPTS,
SUGGESTION_NO_THINK_PREFIX,
} from '../../../src/main/services/llm-prompts'
beforeEach(() => {
@ -167,3 +169,47 @@ describe('resolveSystemPrompt', () => {
}
})
})
describe('buildSuggestionPrompt', () => {
it('지시문은 시스템 프롬프트에만 있고 사용자 텍스트에는 들어가지 않는다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: '오늘 회의에서 논의한 내용을 정리해서',
appName: 'chrome.exe',
windowTitle: '회의록 - Chrome',
phraseHints: ['지난번에는 이렇게 정리했습니다'],
candidates: 3,
maxChars: 120
})
// 지시문이 사용자 텍스트 자리로 새면 모델이 지시문 자체를 다듬어 돌려준다
// (commit 9c2b4d4 회와 같은 부류).
expect(systemPrompt).toContain('다음 문장')
expect(text).not.toContain('규칙:')
expect(text).toContain('오늘 회의에서 논의한 내용을 정리해서')
expect(text).toContain('지난번에는 이렇게 정리했습니다')
expect(text).toContain('chrome.exe')
})
it('후보 수와 길이 상한을 프롬프트에 반영한다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({ prefix: 'abc', candidates: 2, maxChars: 60 })
expect(systemPrompt).toContain('60')
expect(text).toContain('2')
})
it('추론 모델용 no_think prefix 를 붙인다', () => {
const { systemPrompt } = buildSuggestionPrompt({ prefix: 'abc' })
expect(systemPrompt.startsWith(SUGGESTION_NO_THINK_PREFIX)).toBe(true)
})
it('후보 수/길이는 안전 범위로 클램프한다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: 'abc',
candidates: 99,
maxChars: 99999
})
expect(systemPrompt).toContain('400')
expect(text).toContain('5')
})
})

View file

@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getLocalLLMService,
resetLocalLLMServiceForTests,
} from '../../../src/main/services/LocalLLMService'
const encoder = new TextEncoder()
function completedStream(lines: string[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(lines.join('\n')))
controller.close()
},
})
}
function completedGenerateResponse(): Response {
return new Response(JSON.stringify({
model: 'gemma4:e4b',
response: 'ok',
done: true,
}), { status: 200 })
}
function markAvailable(): void {
const service = getLocalLLMService() as unknown as { _available: boolean }
service._available = true
}
describe('LocalLLMService request lifecycle', () => {
beforeEach(() => {
initInMemoryConfig()
resetLocalLLMServiceForTests()
markAvailable()
})
afterEach(() => {
resetLocalLLMServiceForTests()
resetInMemoryConfig()
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('sends the bounded default num_predict to chat requests', async () => {
const cancel = vi.fn()
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { options: { num_predict: number } }
expect(body.options.num_predict).toBe(512)
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode([
JSON.stringify({ message: { content: 'hello' }, done: false }),
JSON.stringify({ message: { content: '' }, done: true }),
].join('\n') + '\n'))
},
cancel,
}), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)
const output: string[] = []
for await (const token of getLocalLLMService().chatStream([{ role: 'user', content: 'hello' }])) {
output.push(token)
}
expect(output).toEqual(['hello'])
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(cancel).toHaveBeenCalledTimes(1)
})
it('propagates external cancellation and request deadlines to fetch', async () => {
const signals: AbortSignal[] = []
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal as AbortSignal
signals.push(signal)
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
}))
vi.stubGlobal('fetch', fetchMock)
const external = new AbortController()
const externalRequest = getLocalLLMService().generate('first', { signal: external.signal })
const externalExpectation = expect(externalRequest).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
external.abort()
await externalExpectation
expect(signals[0].aborted).toBe(true)
vi.useFakeTimers()
const timeoutRequest = getLocalLLMService().generate('second', { timeoutMs: 25 })
const timeoutExpectation = expect(timeoutRequest).rejects.toMatchObject({ code: ErrorCode.LLMProcessingTimeout })
await vi.advanceTimersByTimeAsync(25)
await timeoutExpectation
expect(signals[1].aborted).toBe(true)
})
it('keeps other active requests cancellable after one request completes', async () => {
const signals: AbortSignal[] = []
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const signal = init?.signal as AbortSignal
signals.push(signal)
if (signals.length === 1) return Promise.resolve(completedGenerateResponse())
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
})
})
vi.stubGlobal('fetch', fetchMock)
const first = getLocalLLMService().generate('first')
const second = getLocalLLMService().generate('second')
const third = getLocalLLMService().generate('third')
await expect(first).resolves.toMatchObject({ text: 'ok' })
const secondExpectation = expect(second).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
const thirdExpectation = expect(third).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
getLocalLLMService().cancelGeneration()
await secondExpectation
await thirdExpectation
expect(signals[1].aborted).toBe(true)
expect(signals[2].aborted).toBe(true)
})
it('rejects a stream that reaches EOF without a done frame', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(completedStream([
JSON.stringify({ model: 'gemma4:e4b', response: 'partial', done: false }),
]), { status: 200 })))
const stream = getLocalLLMService().streamGenerate('hello')
await expect(stream.next()).resolves.toMatchObject({ value: 'partial', done: false })
await expect(stream.next()).rejects.toMatchObject({ code: ErrorCode.LLMProcessingFailed })
})
it('finishes on a done frame without waiting for the server to close the stream', async () => {
const cancel = vi.fn()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode([
JSON.stringify({ model: 'gemma4:e4b', response: 'partial', done: false }),
JSON.stringify({ model: 'gemma4:e4b', response: '', done: true }),
].join('\n') + '\n'))
},
cancel,
})
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 200 })))
const stream = getLocalLLMService().streamGenerate('hello', { timeoutMs: 500 })
await expect(stream.next()).resolves.toMatchObject({ value: 'partial', done: false })
await expect(stream.next()).resolves.toMatchObject({ value: expect.objectContaining({ text: 'partial' }), done: true })
expect(cancel).toHaveBeenCalledTimes(1)
})
it('deduplicates concurrent ensureRunning calls and polling startup', async () => {
const service = getLocalLLMService()
const privateService = service as unknown as {
_ensureRunning: () => Promise<'running' | 'starting' | 'not-installed' | 'failed'>
_checkAvailability: () => Promise<void>
}
let release: ((value: 'running') => void) | undefined
const pending = new Promise<'running'>((resolve) => {
release = resolve
})
const ensure = vi.spyOn(privateService, '_ensureRunning').mockReturnValue(pending)
const first = service.ensureRunning()
const second = service.ensureRunning()
expect(ensure).toHaveBeenCalledTimes(1)
release?.('running')
await expect(Promise.all([first, second])).resolves.toEqual(['running', 'running'])
vi.useFakeTimers()
const availability = vi.spyOn(privateService, '_checkAvailability').mockResolvedValue()
service.startPolling()
service.startPolling()
expect(availability).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(5000)
expect(availability).toHaveBeenCalledTimes(2)
service.stopPolling()
})
})

View file

@ -0,0 +1,164 @@
import { EventEmitter } from 'events'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { promisify } from 'util'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const childProcess = vi.hoisted(() => ({
exec: vi.fn(),
execFile: vi.fn(),
execFileAsync: vi.fn(),
spawn: vi.fn(),
}))
vi.mock('child_process', () => childProcess)
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn(),
configSet: vi.fn(),
}))
vi.mock('../../../src/main/services/stt/STTManager', () => ({
getSTTManager: vi.fn(),
}))
vi.mock('../../../src/main/services/HistoryService', () => ({
getHistoryService: vi.fn(),
}))
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({
getRuntimeProvisioner: vi.fn(),
}))
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: vi.fn(),
}))
function createProcess(args: unknown[]): EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
kill: ReturnType<typeof vi.fn>
} {
const process = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
kill: vi.fn(),
})
queueMicrotask(() => {
if (args.includes('null')) {
process.stderr.emit('data', Buffer.from('Duration: 00:00:01.00'))
}
if (args.includes('pipe:1')) {
process.stdout.emit('data', Buffer.from('pcm'))
}
process.emit('close', 0)
})
return process
}
function mockSuccessfulExec(): void {
childProcess.exec.mockImplementation((...args: unknown[]) => {
const callback = args.find((arg): arg is (error: Error | null, stdout: string) => void => typeof arg === 'function')
callback?.(null, '[{"InstanceId":"device-1","FriendlyName":"Mic"}]')
})
}
function expectWindowsHideOnAllCalls(mock: { mock: { calls: unknown[][] } }): void {
for (const call of mock.mock.calls) {
const options = call.find(
(arg): arg is { windowsHide?: boolean } => typeof arg === 'object' && arg !== null && !Array.isArray(arg),
)
expect(options?.windowsHide).toBe(true)
}
}
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
mockSuccessfulExec()
childProcess.spawn.mockImplementation((_command: unknown, args: unknown[]) => createProcess(args))
Reflect.set(
childProcess.execFile,
promisify.custom,
childProcess.execFileAsync,
)
childProcess.execFileAsync.mockResolvedValue({ stdout: 'TestApp\nTest window', stderr: '' })
})
describe('Windows child process visibility', () => {
it('hides the PowerShell SAPI process used for TTS playback', async () => {
const serviceModule = await import('../../../src/main/services/TTSPlaybackService')
serviceModule.resetTTSPlaybackServiceForTests()
const service = serviceModule.getTTSPlaybackService() as unknown as {
_speakOneWindows(text: string): Promise<void>
}
await service._speakOneWindows('테스트')
expect(childProcess.spawn).toHaveBeenCalledTimes(1)
expect(childProcess.spawn.mock.calls[0]?.[0]).toBe('powershell')
expect(childProcess.spawn.mock.calls[0]?.[2]).toMatchObject({ stdio: 'pipe', windowsHide: true })
})
it('hides cmd and PowerShell commands invoked by voice actions', async () => {
const serviceModule = await import('../../../src/main/services/VoiceActionService')
serviceModule.resetVoiceActionServiceForTests()
const service = serviceModule.getVoiceActionService() as unknown as {
_openApp(appName: string): Promise<void>
_simulateKeyboard(combo: string): Promise<void>
_runCommand(command: string): Promise<void>
}
await service._openApp('notepad')
await service._simulateKeyboard('volumeup')
await service._simulateKeyboard('volumedown')
await service._simulateKeyboard('volumemute')
await service._simulateKeyboard('ctrl+c')
await service._runCommand('echo test')
expect(childProcess.exec).toHaveBeenCalledTimes(6)
expect(childProcess.exec.mock.calls[0]?.[1]).toMatchObject({ shell: 'cmd.exe', windowsHide: true })
expect(childProcess.exec.mock.calls[5]?.[1]).toMatchObject({ timeout: 10000, windowsHide: true })
expectWindowsHideOnAllCalls(childProcess.exec)
})
it('hides Windows device discovery and active-window PowerShell calls', async () => {
const audioModule = await import('../../../src/main/services/AudioCaptureService')
const audioService = audioModule.getAudioCaptureService() as unknown as {
_getDevicesWindows(): Promise<unknown>
}
await audioService._getDevicesWindows()
const contextModule = await import('../../../src/main/services/ScreenContextService')
const contextService = contextModule.getScreenContextService()
await contextService.captureContext(false)
expect(childProcess.exec.mock.calls[0]?.[1]).toMatchObject({
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
})
expect(childProcess.execFileAsync.mock.calls[0]?.[2]).toMatchObject({
timeout: 3000,
windowsHide: true,
})
})
it('declares hidden ffmpeg processes for conversion, duration, and chunk extraction', () => {
const source = readFileSync(
resolve(process.cwd(), 'src/main/services/FileTranscriptionService.ts'),
'utf8',
)
const ffmpegSpawns = source.match(
/spawn\(ffmpegPath, args, \{ stdio: \['pipe', 'pipe', 'pipe'\], windowsHide: true \}\)/g,
)
expect(ffmpegSpawns).toHaveLength(3)
})
})