d3ro-voice/packages/core/__tests__/suggestion-redteam-r2-3.test.ts

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