import { describe, expect, it } from 'vitest' import { SUGGESTION_CONTEXT_MAX_CHARS, buildLocalSuggestionCandidates, decideSuggestionStep, matchesSessionPrefix, normalizeSessionPrefix, normalizeSuggestionText, parseSuggestionCandidates, sanitizeSuggestionLine, stripModelArtifacts, type SuggestionPolicyInput, type SuggestionStepInput } from '../src/input-intelligence' import * as suggestionText from '../src/suggestion-text' import * as terminalPrompt from '../src/terminal-prompt' import * as inputIntelligence from '../src/input-intelligence' const LONG_PREFIX = `${'가'.repeat(450)} 회의 결과를 정리해서` function policy(overrides: Partial = {}): SuggestionPolicyInput { return { enabled: true, modelAvailable: true, overlayVisible: false, composing: false, hasSelection: false, isPassword: false, isEditable: true, appName: 'Slack.exe', excludedApps: [], editedSinceFocus: true, typedRecently: true, prefix: '오늘 회의 결과를 정리해서', idleMs: 2000, triggerDelayMs: 600, minPrefixChars: 8, sinceLastRequestMs: 60_000, minIntervalMs: 5000, requestsThisMinute: 0, maxRequestsPerMinute: 6, requestsToday: 0, dailyBudget: 500, ...overrides } } function step(overrides: Partial = {}, policyOverrides: Partial = {}) { return decideSuggestionStep({ policy: policy(policyOverrides), sessionVisible: false, sessionPrefix: '', coolingDown: false, userDismissedQuiet: false, lastRequestedPrefix: '', modelWarm: true, warmUpBackoff: false, ...overrides }) } describe('세션 접두 정규화 (400자 초과 입력창)', () => { it('세션이 저장한 400자 창과 잘리지 않은 현재 접두를 같은 세션으로 본다', () => { const stored = normalizeSessionPrefix(LONG_PREFIX) expect(stored).toHaveLength(SUGGESTION_CONTEXT_MAX_CHARS) expect(matchesSessionPrefix(stored, LONG_PREFIX)).toBe(true) expect(matchesSessionPrefix(stored, `${LONG_PREFIX} `)).toBe(true) }) it('긴 입력에서도 이어 치기는 세션 종료, 마지막 글자 IME 조합은 유지', () => { const stored = normalizeSessionPrefix(LONG_PREFIX) expect(matchesSessionPrefix(stored, `${LONG_PREFIX} 추가`)).toBe(false) expect(matchesSessionPrefix(stored, `${LONG_PREFIX.slice(0, -1)}사`)).toBe(true) }) it('decideSuggestionStep 은 400자 넘는 같은 접두로 표시 중 세션을 끝내지 않는다', () => { const result = step( { sessionVisible: true, sessionPrefix: normalizeSessionPrefix(LONG_PREFIX), lastRequestedPrefix: LONG_PREFIX }, { prefix: LONG_PREFIX, overlayVisible: true } ) expect(result).not.toMatchObject({ action: 'end-session' }) expect(result).toMatchObject({ action: 'settle', then: 'record' }) }) }) describe('decideSuggestionStep — 쿨다운 중 표시된 세션', () => { const visible = { sessionVisible: true, sessionPrefix: '오늘 회의 결과를 정리해서', coolingDown: true } it('다음 후보 단축키(디바운스 구간)에서도 세션을 닫거나 다시 만들지 않는다', () => { expect(step(visible, { overlayVisible: true, idleMs: 100 })).toEqual({ action: 'settle', reason: 'cooldown', tryLocalMemory: false, then: 'record' }) }) it('멈춘 상태에서도 로컬 기억 세션을 다시 게시하지 않는다 (선택 초기화 · 이력 중복 방지)', () => { expect(step(visible, { overlayVisible: true, idleMs: 5000 })).toMatchObject({ tryLocalMemory: false, then: 'record' }) }) it('타이핑이 끊겨도(not-typing) 읽는 중인 세션을 닫지 않는다', () => { expect(step(visible, { overlayVisible: true, typedRecently: false })).toMatchObject({ then: 'record' }) }) it('빠른 실패 직후(최소 간격 안)에도 방금 게시한 폴백을 닫지 않는다', () => { expect(step(visible, { overlayVisible: true, sinceLastRequestMs: 100 })).toMatchObject({ then: 'record' }) }) it('비밀번호 칸·선택 같은 하드 차단은 쿨다운 중에도 세션을 닫는다', () => { expect(step(visible, { overlayVisible: true, isPassword: true })).toMatchObject({ reason: 'cooldown', tryLocalMemory: false, then: 'dismiss' }) }) it('표시 중인 것이 없으면 로컬 기억 게시를 시도한다', () => { expect(step({ coolingDown: true })).toEqual({ action: 'settle', reason: 'cooldown', tryLocalMemory: true, then: 'dismiss' }) }) }) describe('decideSuggestionStep — 순서와 모델 온기', () => { it('정상 경로는 생성한다', () => { expect(step()).toEqual({ action: 'generate', prefix: '오늘 회의 결과를 정리해서' }) }) it('콜드 모델은 워밍업, 워밍업 실패 직후에는 워밍업을 다시 쏘지 않는다', () => { expect(step({ modelWarm: false })).toMatchObject({ reason: 'model-unavailable', then: 'warm-up' }) expect(step({ modelWarm: false, warmUpBackoff: true })).toMatchObject({ reason: 'generation-failed', tryLocalMemory: true, then: 'record' }) }) it('사용자 닫기 직후는 조용하다', () => { expect(step({ userDismissedQuiet: true })).toMatchObject({ reason: 'cooldown', then: 'record' }) }) it('같은 텍스트로 이미 요청했으면 unchanged', () => { expect( step({ lastRequestedPrefix: '오늘 회의 결과를 정리해서' }, { overlayVisible: true }) ).toMatchObject({ reason: 'unchanged', then: 'record' }) }) it('모델 서버가 없으면 로컬 기억을 먼저 시도하고 준비 중을 보여준다', () => { expect(step({}, { modelAvailable: false })).toMatchObject({ reason: 'model-unavailable', tryLocalMemory: true, then: 'show-warming' }) }) it('표시 중 세션의 접두가 자라면 세션을 끝낸다', () => { expect( step({ sessionVisible: true, sessionPrefix: '오늘 회의 결과를' }, { prefix: '오늘 회의 결과를 정리해서' }) ).toEqual({ action: 'end-session', reason: 'stale' }) }) }) describe('후보 정제 — 문장 앞 숫자 보존', () => { it.each([ ['3 pm in the lobby', '3 pm in the lobby'], ['10 minutes later I will call', '10 minutes later I will call'], ['2026 roadmap is ready', '2026 roadmap is ready'], ['3 시에 로비에서 봬요', '3 시에 로비에서 봬요'] ])('숫자로 시작하는 문장 %s 을 지우지 않는다', (input, expected) => { expect(sanitizeSuggestionLine(input)).toBe(expected) }) it.each([ ['1. 첫 항목입니다', '첫 항목입니다'], ['10) 열 번째 항목', '열 번째 항목'], ['2] 괄호 번호', '괄호 번호'], ['- 불릿 항목', '불릿 항목'], ['• 점 불릿', '점 불릿'] ])('목록 표지 %s 는 걷어낸다', (input, expected) => { expect(sanitizeSuggestionLine(input)).toBe(expected) }) it('모델 출력 파싱도 앞 숫자를 보존한다', () => { expect(parseSuggestionCandidates('10 minutes later I will call', 'I will be there in')).toEqual([ '10 minutes later I will call' ]) }) it('로컬 기억 힌트(사용자 자신의 글)는 모델용 정제를 거치지 않는다', () => { expect( buildLocalSuggestionCandidates('Let us meet at', { continuationHints: ['3 pm in the lobby', '"quoted" plan: ready'], relatedHints: [], phraseHints: [] }) ).toEqual(['3 pm in the lobby', '"quoted" plan: ready']) }) it('stripModelArtifacts 는 지시문 되풀이를 버리고, normalizeSuggestionText 는 내용을 바꾸지 않는다', () => { expect(stripModelArtifacts('Suggestion: 이어집니다')).toBeNull() expect(stripModelArtifacts('{{prefix}}')).toBeNull() expect(normalizeSuggestionText(' 1. 그대로 둔다 ')).toBe('1. 그대로 둔다') expect(normalizeSuggestionText('a')).toBeNull() }) }) describe('모듈 분리 — 기존 import 경로 유지', () => { it('input-intelligence 가 분리된 모듈의 같은 함수를 다시 export 한다', () => { expect(inputIntelligence.sanitizeSuggestionLine).toBe(suggestionText.sanitizeSuggestionLine) expect(inputIntelligence.buildLocalSuggestionCandidates).toBe(suggestionText.buildLocalSuggestionCandidates) expect(inputIntelligence.endsSentence).toBe(suggestionText.endsSentence) expect(inputIntelligence.extractTerminalPromptPrefix).toBe(terminalPrompt.extractTerminalPromptPrefix) expect(inputIntelligence.SUGGESTION_MAX_OUTPUT_CHARS).toBe(160) }) })