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