d3ro-voice/apps/desktop/tests/main/services/input-intelligence.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

470 lines
18 KiB
TypeScript

// 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)
})
})