d3ro-voice/apps/desktop/tests/main/services/input-intelligence.test.ts
Yun Chan 2fe20fa7b5
Some checks failed
deploy-site / deploy (push) Failing after 39s
release / release-windows (push) Failing after 3m41s
portable-unsigned / portable-windows (push) Failing after 12m23s
release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Next-sentence suggestions now arrive one at a time up to twelve, shown three
per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept
and Esc to close; old default bindings migrate and the panel guide follows the
live bindings. The overlay is redesigned, stays put while candidates stream
and sits outside the input box when no caret is reported.

The personal phrase memory stops learning from terminals, code editors and
the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders,
and prunes existing entries that break those rules.

Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0
speech engine without the focus endpoint, Ollama runner windows flashing
while typing, the speech engine starting twice, and cold-model timeouts.
Live captions can be dragged to a remembered position and show a waiting
notice until the first line arrives.

Bumps the product version to 1.6.0 (Android/iOS build 1060000).
2026-09-24 19:56:28 +09:00

608 lines
24 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,
extendsPrefix,
extractPhrases,
isAppExcluded,
isWordBoundaryKey,
manhattanDistance,
mergeActivityBucket,
parseSuggestionCandidates,
pixelsToMeters,
sanitizeSuggestionLine,
selectPhraseHints,
summarizeActivity,
prefixTail,
textBeforeCaret,
type PersonalPhrase,
type SuggestionPolicyInput
} from '@d3ro/core/input-intelligence'
import {
LEARNING_EXCLUDED_APPS,
TERMINAL_APPS,
isAppExcluded,
isLearnablePhrase,
withoutPlaceholderText,
emptyFocusSnapshot
} from '@d3ro/core/input-intelligence'
const NO_MODS = { ctrl: false, alt: false, shift: false, meta: false }
describe('개인 코퍼스 학습 규칙', () => {
it('실측된 터미널 상태줄·타임스탬프는 문장으로 치지 않는다', () => {
for (const junk of [
'◑ OPUS 5',
'00 ◷9',
'5 medium │ CTX ▕░░░░░░░░░░░░▏ 0% 0K/1M',
'0 tokens ─────────────',
'5 분 5',
'7 분 12'
]) {
expect(isLearnablePhrase(junk), junk).toBe(false)
}
})
it('일상 문장은 언어와 무관하게 통과한다', () => {
for (const prose of ['하람이랑 열심히 놀고 있어요', 'Thank you', '달빛에 비치는 캐릭터', 'api 목록에도 안']) {
expect(isLearnablePhrase(prose), prose).toBe(true)
}
})
it('터미널·에디터·코딩 에이전트는 학습에서 빠지고, 터미널만 제안에서도 빠진다', () => {
expect(isAppExcluded('WindowsTerminal.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
expect(isAppExcluded('Agent Switchboard.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
expect(isAppExcluded('Code.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
expect(isAppExcluded('KakaoTalk.exe', LEARNING_EXCLUDED_APPS)).toBe(false)
expect(isAppExcluded('WindowsTerminal.exe', TERMINAL_APPS)).toBe(true)
expect(isAppExcluded('Agent Switchboard.exe', TERMINAL_APPS)).toBe(false)
})
it('입력창 이름과 같은 텍스트(안내 문구)는 빈 칸으로 본다', () => {
const base = { ...emptyFocusSnapshot('-', 0), available: true, isEditable: true, caretOffset: 6 }
const placeholder = withoutPlaceholderText({ ...base, controlName: '메시지 입력', text: '메시지 입력' })
expect(placeholder.text).toBe('')
expect(placeholder.caretOffset).toBeNull()
const typed = withoutPlaceholderText({ ...base, controlName: '메시지 입력', text: '안녕하세요' })
expect(typed.text).toBe('안녕하세요')
})
})
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: [],
editedSinceFocus: true,
typedRecently: true,
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'
})
})
it('포커스만 옮겨 왔을 뿐(편집 없음) 이면 지운다 (마우스 클릭만으로 옛 텍스트가 제안되던 문제)', () => {
// 실측: YouTube 검색창(이미 19자 옛 검색어가 있는)을 클릭만 했는데 제안이 떴다.
expect(decideSuggestion(policy({ editedSinceFocus: false }))).toEqual({
action: 'clear',
reason: 'not-typing'
})
})
it('편집은 했지만 최근에 실제로 타이핑한 적이 없으면 지운다', () => {
expect(decideSuggestion(policy({ typedRecently: false }))).toEqual({
action: 'clear',
reason: 'not-typing'
})
})
it('편집도 했고 최근 타이핑도 있으면 통과한다', () => {
expect(decideSuggestion(policy({ editedSinceFocus: true, typedRecently: true })).action).toBe('request')
})
})
describe('표시 중 제안 재생성 정책', () => {
it('연속 접두가 12자 미만 성장하면 기존 제안을 유지한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '회의 결과를 공유합니다 내일')).toBe('keep')
})
it('연속 접두가 12자 이상 성장하면 재생성한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '회의 결과를 공유합니다 다음 안건도 검토해 주세요')).toBe(
'regenerate'
)
})
it('생성 접두의 앞부분이 바뀌면 stale 로 처리한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe('stale')
})
it('IME 조합으로 마지막 글자만 바뀌면 stale 이 아니라 keep 이다', () => {
// 생성 시점엔 "하" 로 끝났는데, 조합이 이어져 지금은 "한" 으로 끝난 경우.
expect(decideSuggestionRefresh('회의록을 정리하고 공유하려고 하', '회의록을 정리하고 공유하려고 한')).toBe(
'keep'
)
})
})
describe('extendsPrefix — IME 마지막 글자 조합 보정', () => {
it('마지막 글자만 조합 중 문자로 바뀌어도 연속 확장으로 본다', () => {
expect(extendsPrefix('회의록을 정리하고 공유하려고 하', '회의록을 정리하고 공유하려고 한')).toBe(true)
})
it('마지막 글자를 빼도 접두가 다르면 확장이 아니다', () => {
expect(extendsPrefix('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe(false)
})
it('완전히 같은 접두는 확장이다', () => {
expect(extendsPrefix('회의 결과를 공유합니다', '회의 결과를 공유합니다')).toBe(true)
})
it('접두 뒤로 자란 경우도 확장이다', () => {
expect(extendsPrefix('회의 결과를 공유합니다', '회의 결과를 공유합니다 내일')).toBe(true)
})
})
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 },
'caret',
{ 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 },
'caret',
{ 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 },
'caret',
{ 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, null, { x: 200, y: 500 }, size, workArea)
expect(position).toEqual({ x: 200, y: 506 })
})
describe('요소 앵커 (케어렛을 못 얻어 elementRect 로 폴백한 경우)', () => {
it('아래에 맞으면 요소 바깥 아래에 붙인다', () => {
const element = { x: 400, y: 300, width: 300, height: 40 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: 400, y: 300 + 40 + 6 })
})
it('아래가 안 맞고 위가 맞으면 요소 바깥 위에 붙인다', () => {
const element = { x: 400, y: 1080 - 50, width: 300, height: 40 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: 400, y: element.y - 6 - size.height })
})
it('위아래 다 안 맞으면 오른쪽 바깥에 붙인다', () => {
const element = { x: 0, y: 0, width: 1400, height: 1076 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: element.x + element.width + 6, y: element.y })
})
it('위아래오른쪽 다 안 맞으면 왼쪽 바깥에 붙인다', () => {
const element = { x: 1820, y: 0, width: 100, height: 1080 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({ x: element.x - 6 - size.width, y: element.y })
})
it('네 방향 다 안 맞으면 요소 안쪽 우하단 모서리로 물러난다', () => {
const element = { x: 0, y: 0, width: 1920, height: 1080 }
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
expect(position).toEqual({
x: element.x + element.width - size.width - 6,
y: element.y + element.height - size.height - 6
})
})
})
})
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)
})
})