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