d3ro-voice/packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

100 lines
4.2 KiB
TypeScript

// 제안 정책 회귀 (r3-1):
// - 케어렛을 모르는 필드에서 문서 중간을 고쳤으면 제안을 닫는다 (caret-unknown)
// - 단어를 끝낸 뒤(뒤 공백) 로컬 기억이 단어 조각을 붙이지 않는다
// - 학습 제외 앱은 출처와 무관하게 학습하지 않는다
import { describe, expect, it } from 'vitest'
import {
decideSuggestion,
isEditAtTextEnd,
shouldLearnFromApp,
shouldPersistSuggestionPrefix,
type SuggestionPolicyInput
} from '../src/input-intelligence'
import { buildLocalSuggestionCandidateEntries, joinSuggestion, normalizeSessionPrefix } from '../src/suggestion-text'
function policy(overrides: Partial<SuggestionPolicyInput> = {}): SuggestionPolicyInput {
return {
enabled: true,
modelAvailable: true,
overlayVisible: false,
composing: false,
hasSelection: false,
isPassword: false,
isEditable: true,
appName: 'notepad.exe',
excludedApps: [],
editedSinceFocus: true,
typedRecently: true,
prefix: '오늘 회의에서 논의한 내용을 정리해서',
idleMs: 2000,
triggerDelayMs: 600,
minPrefixChars: 8,
sinceLastRequestMs: 60_000,
minIntervalMs: 5000,
requestsThisMinute: 0,
maxRequestsPerMinute: 12,
requestsToday: 0,
dailyBudget: 500,
...overrides
}
}
describe('케어렛 신뢰도', () => {
it('문서 끝에서 쓰거나 지운 편집만 끝 편집으로 본다', () => {
expect(isEditAtTextEnd('abc', 'abcd')).toBe(true)
expect(isEditAtTextEnd('abc', 'ab')).toBe(true)
expect(isEditAtTextEnd('abc', 'abc')).toBe(true)
expect(isEditAtTextEnd('첫 문장. 둘째 문장.', '첫 문장이. 둘째 문장.')).toBe(false)
expect(isEditAtTextEnd('aXb', 'ab')).toBe(false)
})
it('caretReliable=false 면 요청하지 않고 떠 있는 세션도 닫는다', () => {
expect(decideSuggestion(policy({ caretReliable: false }))).toEqual({ action: 'clear', reason: 'caret-unknown' })
expect(decideSuggestion(policy({ caretReliable: false, overlayVisible: true }))).toEqual({
action: 'clear',
reason: 'caret-unknown'
})
})
it('생략하거나 true 면 기존처럼 요청한다', () => {
expect(decideSuggestion(policy()).action).toBe('request')
expect(decideSuggestion(policy({ caretReliable: true })).action).toBe('request')
})
})
describe('단어를 끝낸 뒤 로컬 기억 이음새', () => {
const hints = (phrase: string) => ({ continuationHints: [], relatedHints: [], phraseHints: [phrase] })
it("'내일 회의 ' 뒤에 '록 공유드립니다' 조각을 만들지 않는다", () => {
const raw = '내일 회의 '
const entries = buildLocalSuggestionCandidateEntries(normalizeSessionPrefix(raw), hints('회의록 공유드립니다'), 3, 160, {
wordFinished: true
})
expect(entries.map((entry) => entry.text)).not.toContain('록 공유드립니다')
})
it("'check the ' 뒤에 'n we can ship' 조각을 만들지 않는다 (원문을 넘기면 자동 판단)", () => {
const entries = buildLocalSuggestionCandidateEntries('check the ', hints('then we can ship'), 3, 160)
expect(entries.map((entry) => entry.text)).not.toContain('n we can ship')
})
it('단어 중간에서 멈췄으면 기존처럼 붙인다', () => {
const entries = buildLocalSuggestionCandidateEntries('내일 회의', hints('회의록 공유드립니다'), 3, 160)
expect(entries[0]).toEqual({ text: '록 공유드립니다', attachToPrefix: true })
expect('내일 회의' + joinSuggestion('내일 회의', entries[0].text, 'attach')).toBe('내일 회의록 공유드립니다')
})
})
describe('학습 제외 앱 정본', () => {
it('터미널·에디터는 출처와 무관하게 학습하지 않는다', () => {
expect(shouldLearnFromApp('WindowsTerminal.exe')).toBe(false)
expect(shouldLearnFromApp('Code.exe')).toBe(false)
expect(shouldLearnFromApp('KakaoTalk.exe')).toBe(true)
expect(shouldLearnFromApp(null)).toBe(true)
})
it('제안 접두 저장도 같은 규칙을 쓴다', () => {
expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'WindowsTerminal.exe' })).toBe(false)
expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'KakaoTalk.exe' })).toBe(true)
})
})