import { afterEach, describe, expect, it, vi } from 'vitest' import { buildLocalSuggestionCandidateEntries, buildLocalSuggestionCandidates, decideInFlightGeneration, decideSuggestionStep, extractPhrases, joinSuggestion, shouldPersistSuggestionPrefix, type SuggestionPolicyInput, type SuggestionStepInput } from '../src/input-intelligence' import { splitSentences } from '../src/personal-graph' import { countLexicalWords } from '../src/text-segmentation' 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('joinSuggestion — 수락한 후보가 앞 글자에 들러붙지 않는다', () => { it.each([ ['I sent the report.', 'Let me know if you have questions.', ' Let me know if you have questions.'], ['Let us meet at', '3 pm in the lobby', ' 3 pm in the lobby'], ['오늘 회의에서', '논의한 내용을 공유드립니다', ' 논의한 내용을 공유드립니다'], ['회의 끝났습니다.', '다음 주에 뵙겠습니다', ' 다음 주에 뵙겠습니다'] ])('%s + %s', (prefix, candidate, expected) => { expect(joinSuggestion(prefix, candidate)).toBe(expected) }) it.each([ ['이미 공백을 쳤으면 그대로', 'I sent the report. ', 'Let me know'], ['개행 뒤도 그대로', 'Hello\n', 'World'], ['빈 접두', '', 'Hello there'], ['여는 괄호 뒤', 'see (', 'attached file)'], ['경로 구분자 뒤', 'cd src/', 'main'], ['닫는 구두점으로 시작', 'Thanks', ', see you soon'], ['일본어 (띄어 쓰지 않음)', '今日は会議があります。', '明日の予定を確認してください'], ['중국어', '我们明天', '开会讨论项目进度'], ['태국어', 'สวัสดี', 'ครับ'] ])('%s — 공백을 넣지 않는다', (_case, prefix, candidate) => { expect(joinSuggestion(prefix, candidate)).toBe(candidate) }) it('단어 중간에서 이어지는 나머지(attach)는 붙인다', () => { expect(joinSuggestion('Thank yo', 'u for the update', 'attach')).toBe('u for the update') }) }) describe('buildLocalSuggestionCandidateEntries — 이음새 정보를 잃지 않는다', () => { const hints = (phraseHints: string[]) => ({ continuationHints: [], relatedHints: [], phraseHints }) it('단어 경계에서 겹친 나머지는 separate, 단어 중간이면 attach', () => { expect(buildLocalSuggestionCandidateEntries('Thank you', hints(['Thank you for the update']))).toEqual([ { text: 'for the update', attachToPrefix: false } ]) expect(buildLocalSuggestionCandidateEntries('Thank yo', hints(['Thank you for the update']))).toEqual([ { text: 'u for the update', attachToPrefix: true } ]) }) it('기존 문자열 API 는 그대로다', () => { expect(buildLocalSuggestionCandidates('Thank you', hints(['Thank you for the update']))).toEqual(['for the update']) }) it('이음새까지 합치면 올바른 문장이 된다', () => { const [separate] = buildLocalSuggestionCandidateEntries('Thank you', hints(['Thank you for the update'])) expect(`Thank you${joinSuggestion('Thank you', separate.text, separate.attachToPrefix ? 'attach' : 'separate')}`).toBe( 'Thank you for the update' ) const [attach] = buildLocalSuggestionCandidateEntries('Thank yo', hints(['Thank you for the update'])) expect(`Thank yo${joinSuggestion('Thank yo', attach.text, attach.attachToPrefix ? 'attach' : 'separate')}`).toBe( 'Thank you for the update' ) }) }) describe('decideInFlightGeneration — 진행 중 생성도 세션 규칙을 따른다', () => { it('요청 뒤 몇 글자라도 자라면 stale (12자 미만 성장도)', () => { expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘 저녁까지')).toBe('stale') }) it('같은 접두 · 뒤 공백만 추가 · IME 마지막 글자 조합은 keep', () => { expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘')).toBe('keep') expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘 ')).toBe('keep') expect(decideInFlightGeneration('회의 자료는 하', '회의 자료는 한')).toBe('keep') }) }) describe('decideSuggestionStep — 서버가 없을 때 끝나지 않는 "준비 중" 을 띄우지 않는다', () => { it('준비 작업이 없으면(modelStarting=false) record', () => { expect(step({ modelStarting: false }, { modelAvailable: false })).toMatchObject({ reason: 'model-unavailable', tryLocalMemory: true, then: 'record' }) }) it('워밍업 루프가 서버를 기다리는 중이면 "준비 중" 을 보여준다', () => { expect(step({ modelStarting: true }, { modelAvailable: false })).toMatchObject({ reason: 'model-unavailable', then: 'show-warming' }) }) }) describe('shouldPersistSuggestionPrefix — 입력 원문 저장은 학습 동의 규칙을 따른다', () => { it('학습 동의가 없으면 저장하지 않는다', () => { expect(shouldPersistSuggestionPrefix({ learnTypedText: false, appName: 'Slack.exe' })).toBe(false) }) it('학습 제외 앱(터미널·에디터)은 동의가 있어도 저장하지 않는다', () => { expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'WindowsTerminal.exe' })).toBe(false) expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'Code.exe' })).toBe(false) }) it('동의가 있고 일반 앱이면 저장한다', () => { expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'Slack.exe' })).toBe(true) expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: null })).toBe(true) }) }) describe('띄어쓰기 없는 언어도 학습된다', () => { const ja = '今日は会議があります。明日の予定を確認してください。' const zh = '我们明天开会讨论项目进度。请确认你的日程安排。' const th = 'วันนี้เรามีประชุมที่สำนักงาน\nพรุ่งนี้ต้องส่งรายงานให้หัวหน้า' it('splitSentences 가 일본어·중국어·태국어 문장을 버리지 않는다', () => { expect(splitSentences(ja)).toEqual(['今日は会議があります', '明日の予定を確認してください']) expect(splitSentences(zh)).toEqual(['我们明天开会讨论项目进度', '请确认你的日程安排']) expect(splitSentences(th)).toHaveLength(2) }) it('extractPhrases 도 같은 문장을 문구로 뽑는다', () => { expect(extractPhrases(ja)).toEqual(['今日は会議があります', '明日の予定を確認してください']) expect(extractPhrases(zh, 4, 6)).toEqual(['我们明天开会讨论项目进度', '请确认你的日程安排']) }) it('띄어 쓰는 언어의 기존 단어 수는 그대로다', () => { expect(countLexicalWords('Hello there friend')).toBe(3) expect(countLexicalWords('안녕 하세요')).toBe(2) expect(countLexicalWords('짧음')).toBe(1) expect(splitSentences('오늘 회의는 여기서 끝. 짧음. 내일 일정을 공유드릴게요!')).toEqual([ '오늘 회의는 여기서 끝', '내일 일정을 공유드릴게요' ]) }) }) describe('Intl.Segmenter 가 없는 런타임(Hermes 등)', () => { afterEach(() => { vi.unstubAllGlobals() vi.resetModules() }) it('글자 수로 어림해 문장을 버리지 않는다', async () => { vi.resetModules() vi.stubGlobal('Intl', { ...Intl, Segmenter: undefined }) const segmentation = await import('../src/text-segmentation') const graph = await import('../src/personal-graph') expect(segmentation.getWordSegmenter()).toBeNull() expect(segmentation.countLexicalWords('今日は会議があります')).toBeGreaterThanOrEqual(2) expect(segmentation.countLexicalWords('短い')).toBe(1) expect(graph.splitSentences('今日は会議があります。明日の予定を確認してください。')).toHaveLength(2) }) })