202 lines
8.9 KiB
TypeScript
202 lines
8.9 KiB
TypeScript
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> = {}): 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<SuggestionStepInput> = {}, policyOverrides: Partial<SuggestionPolicyInput> = {}) {
|
|
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)
|
|
})
|
|
})
|