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.
This commit is contained in:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
113
packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts
Normal file
113
packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts
|
||||
// 사전 쓰기 정책이 서버 트리거(normalize_dictionary_word, 마이그레이션 20260821000005)의
|
||||
// 길이 제한(단어 120·발음 200 코드 포인트)을 로컬에서 먼저 거부하는지 검증한다.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { D3ROError, ErrorCode } from '../src/errors'
|
||||
import {
|
||||
DICTIONARY_LIMITS,
|
||||
buildDictionaryUpdatePatch,
|
||||
dictionaryWriteProblemError,
|
||||
validateDictionaryDraft
|
||||
} from '../src/dictionary-policy'
|
||||
|
||||
describe('DICTIONARY_LIMITS', () => {
|
||||
it('서버 트리거와 같은 값이다', () => {
|
||||
expect(DICTIONARY_LIMITS).toEqual({ word: 120, pronunciation: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDictionaryDraft', () => {
|
||||
it('정상 입력을 정규화한다', () => {
|
||||
expect(validateDictionaryDraft({ word: ' GPT ', pronunciation: ' 지피티 ' })).toEqual({
|
||||
ok: true,
|
||||
draft: { word: 'GPT', pronunciation: '지피티' }
|
||||
})
|
||||
expect(validateDictionaryDraft({ word: 'GPT', pronunciation: ' ' })).toEqual({
|
||||
ok: true,
|
||||
draft: { word: 'GPT', pronunciation: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('빈 단어를 거부한다', () => {
|
||||
expect(validateDictionaryDraft({ word: ' ' })).toEqual({ ok: false, reason: 'empty-word' })
|
||||
})
|
||||
|
||||
it('정확히 120자 단어·200자 발음은 받아들인다 (앞뒤 공백 제외)', () => {
|
||||
const result = validateDictionaryDraft({
|
||||
word: ` ${'가'.repeat(120)} `,
|
||||
pronunciation: ` ${'a'.repeat(200)} `
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('121자 단어를 too-long 으로 거부한다', () => {
|
||||
expect(validateDictionaryDraft({ word: 'a'.repeat(121) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-long',
|
||||
field: 'word',
|
||||
max: 120
|
||||
})
|
||||
})
|
||||
|
||||
it('201자 발음을 too-long 으로 거부한다', () => {
|
||||
expect(validateDictionaryDraft({ word: 'ok', pronunciation: 'b'.repeat(201) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-long',
|
||||
field: 'pronunciation',
|
||||
max: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('코드 포인트로 센다 — 서로게이트 쌍 이모지 120개는 허용', () => {
|
||||
const emoji = '\u{1F600}'.repeat(120) // UTF-16 길이 240
|
||||
expect(validateDictionaryDraft({ word: emoji }).ok).toBe(true)
|
||||
expect(validateDictionaryDraft({ word: `${emoji}\u{1F600}` }).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDictionaryUpdatePatch 길이 제한', () => {
|
||||
it('121자 단어로 바꾸는 편집을 거부한다', () => {
|
||||
expect(buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(121) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-long',
|
||||
field: 'word',
|
||||
max: 120
|
||||
})
|
||||
})
|
||||
|
||||
it('201자 발음으로 바꾸는 편집을 거부한다', () => {
|
||||
expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: 'p'.repeat(201) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-long',
|
||||
field: 'pronunciation',
|
||||
max: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('제한 이내 편집과 발음 지우기는 그대로 통과한다', () => {
|
||||
expect(
|
||||
buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) })
|
||||
).toEqual({ ok: true, patch: { word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) } })
|
||||
expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: null })).toEqual({
|
||||
ok: true,
|
||||
patch: { pronunciation: null }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('dictionaryWriteProblemError', () => {
|
||||
it('too-long 은 field·max 를 details 로 담은 DictionaryImportInvalidFormat 이다', () => {
|
||||
const err = dictionaryWriteProblemError({ reason: 'too-long', field: 'pronunciation', max: 200 })
|
||||
expect(err).toBeInstanceOf(D3ROError)
|
||||
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
|
||||
expect(err.details).toEqual({ reason: 'too-long', field: 'pronunciation', max: 200 })
|
||||
})
|
||||
|
||||
it('empty-word 는 기존 메시지를 유지한다', () => {
|
||||
const err = dictionaryWriteProblemError({ reason: 'empty-word' })
|
||||
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
|
||||
expect(err.message).toBe('Dictionary word is empty')
|
||||
expect(err.details).toEqual({ reason: 'empty-word', field: 'word' })
|
||||
})
|
||||
})
|
||||
100
packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts
Normal file
100
packages/core/__tests__/input-suggestion-redteam-r3-1.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// 제안 정책 회귀 (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)
|
||||
})
|
||||
})
|
||||
286
packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts
Normal file
286
packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts
|
||||
// 레드팀 r3-8 회귀 테스트 — AltGr 배열(독일어·프랑스어·폴란드어·북유럽).
|
||||
//
|
||||
// Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 타임스탬프)으로 보내고, libuiohook 은
|
||||
// 가짜 LCtrl 로 Ctrl 수정자를 켠다. 그래서
|
||||
// 1) '@'(AltGr+Q) 를 칠 때마다 Ctrl+RightAlt('command') 가 발동해 음성 세션이 열렸고,
|
||||
// 2) 기본 dictation(RightAlt 단독) · hands-free(더블탭) 는 영영 발동하지 않았다.
|
||||
// 데스크톱 어댑터와 같은 순서(AltGrPressDetector → noteKeyDown → keyDown)로 흘려 검증한다.
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
ALTGR_CHORD_GRACE_MS,
|
||||
AltGrPressDetector,
|
||||
ChordStateMachine
|
||||
} from '../src/keybinding-runtime'
|
||||
import type { ChordBindingEntry, ChordClock, ChordTriggerEvent } from '../src/keybinding-runtime'
|
||||
import { KEYBINDING_ACTIONS, VK, bindingKey, createDefaultBindingMap } from '../src/keybinding'
|
||||
|
||||
const VK_Q = 0x51
|
||||
|
||||
// ============================================================
|
||||
// 가짜 시계
|
||||
// ============================================================
|
||||
|
||||
class FakeClock implements ChordClock {
|
||||
private _now = 1_000
|
||||
private _timers: { at: number; callback: () => void; active: boolean }[] = []
|
||||
|
||||
now(): number {
|
||||
return this._now
|
||||
}
|
||||
|
||||
schedule(callback: () => void, delayMs: number): () => void {
|
||||
const timer = { at: this._now + delayMs, callback, active: true }
|
||||
this._timers.push(timer)
|
||||
return () => {
|
||||
timer.active = false
|
||||
}
|
||||
}
|
||||
|
||||
advance(ms: number): void {
|
||||
const target = this._now + ms
|
||||
for (;;) {
|
||||
const due = this._timers
|
||||
.filter((t) => t.active && t.at <= target)
|
||||
.sort((a, b) => a.at - b.at)[0]
|
||||
if (due === undefined) break
|
||||
this._now = due.at
|
||||
due.active = false
|
||||
due.callback()
|
||||
}
|
||||
this._timers = this._timers.filter((t) => t.active)
|
||||
this._now = target
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 어댑터 흉내 (KeyBindingService._handleKeyDown / _handleKeyUp 와 같은 순서)
|
||||
// ============================================================
|
||||
|
||||
interface Mods {
|
||||
ctrl?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
function defaultEntries(): ChordBindingEntry[] {
|
||||
const map = createDefaultBindingMap()
|
||||
return KEYBINDING_ACTIONS.flatMap((spec) =>
|
||||
map[spec.id].map((binding) => ({
|
||||
actionId: spec.id,
|
||||
holdMode: spec.holdMode,
|
||||
doublePress: spec.doublePress,
|
||||
binding
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
class Harness {
|
||||
readonly clock = new FakeClock()
|
||||
readonly events: string[] = []
|
||||
readonly detector = new AltGrPressDetector()
|
||||
readonly machine = new ChordStateMachine({
|
||||
clock: this.clock,
|
||||
onTrigger: (e: ChordTriggerEvent) => this.events.push(`${e.actionId}:${e.type}`)
|
||||
})
|
||||
|
||||
constructor() {
|
||||
this.machine.setBindings(defaultEntries())
|
||||
}
|
||||
|
||||
private _key(vk: number, mods: Mods, isAltGr: boolean): string {
|
||||
return bindingKey({
|
||||
device: 'keyboard',
|
||||
code: vk,
|
||||
ctrl: (mods.ctrl ?? false) && !isAltGr,
|
||||
alt: mods.alt ?? false,
|
||||
shift: false,
|
||||
meta: false
|
||||
})
|
||||
}
|
||||
|
||||
down(vk: number, mods: Mods = {}): void {
|
||||
const isAltGr = this.detector.keyDown(vk, this.clock.now())
|
||||
this.machine.noteKeyDown(vk)
|
||||
this.machine.keyDown(this._key(vk, mods, isAltGr), { altGr: isAltGr })
|
||||
}
|
||||
|
||||
up(vk: number, mods: Mods = {}): void {
|
||||
this.detector.keyUp(vk)
|
||||
this.machine.keyUp(this._key(vk, mods, false), vk)
|
||||
}
|
||||
|
||||
/** AltGr 누름: 가짜 LCtrl → RAlt (같은 타임스탬프, RAlt 에는 Ctrl+Alt 가 실린다) */
|
||||
altGrDown(): void {
|
||||
this.down(VK.CtrlLeft, { ctrl: true })
|
||||
this.down(VK.AltRight, { ctrl: true, alt: true })
|
||||
}
|
||||
|
||||
/** AltGr 놓음: 가짜 LCtrl ↑ → RAlt ↑ */
|
||||
altGrUp(): void {
|
||||
this.up(VK.CtrlLeft, { alt: true })
|
||||
this.up(VK.AltRight)
|
||||
}
|
||||
|
||||
wait(ms: number): void {
|
||||
this.clock.advance(ms)
|
||||
}
|
||||
}
|
||||
|
||||
describe('AltGr 배열 — 문자 입력은 음성 트리거를 발동하지 않는다', () => {
|
||||
let h: Harness
|
||||
|
||||
beforeEach(() => {
|
||||
h = new Harness()
|
||||
})
|
||||
|
||||
it("AltGr+Q('@') 를 치면 어떤 트리거도 나가지 않는다", () => {
|
||||
h.altGrDown()
|
||||
h.wait(40)
|
||||
h.down(VK_Q, { ctrl: true, alt: true })
|
||||
h.up(VK_Q, { ctrl: true, alt: true })
|
||||
h.wait(20)
|
||||
h.altGrUp()
|
||||
h.wait(1_000)
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
|
||||
it("AltGr 를 700ms 넘게 누른 채 '{' '}' 를 쳐도 트리거가 나가지 않는다", () => {
|
||||
h.altGrDown()
|
||||
h.wait(100)
|
||||
h.down(0x37, { ctrl: true, alt: true }) // 7 → '{'
|
||||
h.up(0x37, { ctrl: true, alt: true })
|
||||
h.wait(300)
|
||||
h.down(0x30, { ctrl: true, alt: true }) // 0 → '}'
|
||||
h.up(0x30, { ctrl: true, alt: true })
|
||||
h.wait(400)
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
|
||||
it('좌표계로 옮기지 못한 문자 키(vk=null, 예: VK_OEM_102 "<")도 보류를 취소한다', () => {
|
||||
h.altGrDown()
|
||||
h.wait(30)
|
||||
h.detector.keyDown(null, h.clock.now())
|
||||
h.machine.noteKeyDown(null)
|
||||
h.wait(30)
|
||||
h.altGrUp()
|
||||
h.wait(1_000)
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
|
||||
it('판별기가 AltGr 를 놓쳐도(타임스탬프 불일치) 문자 키가 Ctrl+RightAlt 보류를 취소한다', () => {
|
||||
h.down(VK.CtrlLeft, { ctrl: true })
|
||||
h.wait(5)
|
||||
h.down(VK.AltRight, { ctrl: true, alt: true }) // 'command' 로 보임 → AltGr 형태 보류
|
||||
h.down(VK_Q, { ctrl: true, alt: true })
|
||||
h.up(VK_Q, { ctrl: true, alt: true })
|
||||
h.up(VK.CtrlLeft, { alt: true })
|
||||
h.up(VK.AltRight)
|
||||
h.wait(1_000)
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AltGr 배열 — 기본 dictation · hands-free 가 발동한다', () => {
|
||||
let h: Harness
|
||||
|
||||
beforeEach(() => {
|
||||
h = new Harness()
|
||||
})
|
||||
|
||||
it('AltGr 를 누르고 있으면 유예 뒤 dictation 이 pressed, 놓으면 released', () => {
|
||||
h.altGrDown()
|
||||
expect(h.events).toEqual([])
|
||||
h.wait(ALTGR_CHORD_GRACE_MS)
|
||||
expect(h.events).toEqual(['dictation:pressed'])
|
||||
h.wait(1_500)
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
|
||||
it('AltGr auto-repeat(가짜 LCtrl + RAlt 반복)는 누름을 다시 트리거하지 않는다', () => {
|
||||
h.altGrDown()
|
||||
h.wait(ALTGR_CHORD_GRACE_MS)
|
||||
h.wait(500)
|
||||
h.altGrDown()
|
||||
h.wait(33)
|
||||
h.altGrDown()
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
|
||||
it('AltGr 더블탭은 hands-free 를 켠다', () => {
|
||||
h.altGrDown()
|
||||
h.wait(60)
|
||||
h.altGrUp()
|
||||
h.wait(120)
|
||||
h.altGrDown()
|
||||
h.wait(60)
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual([
|
||||
'dictation:pressed',
|
||||
'dictation:released',
|
||||
'hands-free:pressed',
|
||||
'hands-free:released'
|
||||
])
|
||||
})
|
||||
|
||||
it("'@' 직후 AltGr 를 탭해도 hands-free 로 세지 않는다 (취소된 누름은 첫 탭이 아니다)", () => {
|
||||
h.altGrDown()
|
||||
h.down(VK_Q, { ctrl: true, alt: true })
|
||||
h.up(VK_Q, { ctrl: true, alt: true })
|
||||
h.altGrUp()
|
||||
h.wait(100)
|
||||
h.altGrDown()
|
||||
h.wait(60)
|
||||
h.altGrUp()
|
||||
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
|
||||
it('진짜 LCtrl 을 먼저 누르고 AltGr 를 누르면 command(Ctrl+RightAlt) 다', () => {
|
||||
h.down(VK.CtrlLeft, { ctrl: true })
|
||||
h.wait(80)
|
||||
h.altGrDown() // Windows 는 LCtrl 이 눌려 있어도 가짜 LCtrl 을 보낸다
|
||||
h.wait(ALTGR_CHORD_GRACE_MS)
|
||||
expect(h.events).toEqual(['command:pressed'])
|
||||
h.up(VK.AltRight, { ctrl: true })
|
||||
h.up(VK.CtrlLeft)
|
||||
expect(h.events).toEqual(['command:pressed', 'command:released'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AltGrPressDetector', () => {
|
||||
it('같은 타임스탬프의 LCtrl 직후 RAlt 만 AltGr 다', () => {
|
||||
const d = new AltGrPressDetector()
|
||||
expect(d.keyDown(VK.CtrlLeft, 10)).toBe(false)
|
||||
expect(d.keyDown(VK.AltRight, 10)).toBe(true)
|
||||
})
|
||||
|
||||
it('타임스탬프가 다르거나 사이에 다른 키가 끼면 AltGr 가 아니다', () => {
|
||||
const a = new AltGrPressDetector()
|
||||
a.keyDown(VK.CtrlLeft, 10)
|
||||
expect(a.keyDown(VK.AltRight, 26)).toBe(false)
|
||||
|
||||
const b = new AltGrPressDetector()
|
||||
b.keyDown(VK.CtrlLeft, 10)
|
||||
b.keyDown(VK_Q, 10)
|
||||
expect(b.keyDown(VK.AltRight, 10)).toBe(false)
|
||||
})
|
||||
|
||||
it('RAlt 를 놓으면 다음 누름은 새로 판정한다', () => {
|
||||
const d = new AltGrPressDetector()
|
||||
d.keyDown(VK.CtrlLeft, 10)
|
||||
expect(d.keyDown(VK.AltRight, 10)).toBe(true)
|
||||
d.keyUp(VK.CtrlLeft)
|
||||
d.keyUp(VK.AltRight)
|
||||
expect(d.keyDown(VK.AltRight, 500)).toBe(false)
|
||||
})
|
||||
|
||||
it('reset 은 상태를 비운다', () => {
|
||||
const d = new AltGrPressDetector()
|
||||
d.keyDown(VK.CtrlLeft, 10)
|
||||
d.reset()
|
||||
expect(d.keyDown(VK.AltRight, 10)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// packages/core/__tests__/meeting-markdown-inline-redteam-r3-27.test.ts
|
||||
// 회귀: DOCX 내보내기가 인라인 강조(**x**)를 별표째 찍어 PDF 와 결과가 달랐다.
|
||||
// 이제 두 경로가 parseInlineRuns 하나로 굵기를 해석해야 한다.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import JSZip from 'jszip'
|
||||
import { markdownToDocx } from '../src/utils/markdown-to-docx'
|
||||
import { markdownToSimpleHtml, parseInlineRuns } from '../src/utils/meeting-markdown'
|
||||
|
||||
interface RunSpan {
|
||||
text: string
|
||||
bold: boolean
|
||||
}
|
||||
|
||||
/** document.xml 에서 본문 run(텍스트·굵기)만 뽑는다. */
|
||||
async function docxRuns(markdown: string): Promise<RunSpan[]> {
|
||||
const buffer = await markdownToDocx(markdown, 'T')
|
||||
const zip = await JSZip.loadAsync(buffer)
|
||||
const file = zip.file('word/document.xml')
|
||||
if (!file) throw new Error('document.xml missing')
|
||||
const xml = await file.async('string')
|
||||
const runs: RunSpan[] = []
|
||||
for (const match of xml.matchAll(/<w:r>([\s\S]*?)<\/w:r>/g)) {
|
||||
const body = match[1]
|
||||
const text = [...body.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g)].map((m) => m[1]).join('')
|
||||
const bold = /<w:b\/>|<w:b w:val="(?:true|1|on)"\/>/.test(body)
|
||||
runs.push({ text, bold })
|
||||
}
|
||||
// 첫 run 은 문서 제목('T')
|
||||
return runs.slice(1)
|
||||
}
|
||||
|
||||
function boldTexts(runs: readonly RunSpan[]): string[] {
|
||||
return runs.filter((r) => r.bold).map((r) => r.text)
|
||||
}
|
||||
|
||||
function htmlBoldTexts(html: string): string[] {
|
||||
return [...html.matchAll(/<strong>([\s\S]*?)<\/strong>/g)].map((m) => m[1])
|
||||
}
|
||||
|
||||
describe('parseInlineRuns', () => {
|
||||
it('강조 표기를 벗기고 굵은 조각으로 나눈다', () => {
|
||||
expect(parseInlineRuns('**결정**: 3월 출시')).toEqual([
|
||||
{ text: '결정', bold: true },
|
||||
{ text: ': 3월 출시', bold: false },
|
||||
])
|
||||
expect(parseInlineRuns('a **b** c **d**')).toEqual([
|
||||
{ text: 'a ', bold: false },
|
||||
{ text: 'b', bold: true },
|
||||
{ text: ' c ', bold: false },
|
||||
{ text: 'd', bold: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('짝이 없는 별표·빈 문자열은 그대로 둔다', () => {
|
||||
expect(parseInlineRuns('2 ** 3')).toEqual([{ text: '2 ** 3', bold: false }])
|
||||
expect(parseInlineRuns('****')).toEqual([{ text: '****', bold: false }])
|
||||
expect(parseInlineRuns('')).toEqual([])
|
||||
})
|
||||
|
||||
it('HTML 렌더링은 기존 출력과 같다 (이스케이프 후 <strong>)', () => {
|
||||
expect(markdownToSimpleHtml('- **A** <b> & "c"')).toBe(
|
||||
'<ul>\n<li><strong>A</strong> <b> & "c"</li>\n</ul>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOCX 인라인 강조 (PDF 와 동일)', () => {
|
||||
it("'- **A** b' 는 DOCX 와 HTML 에서 같은 굵은 조각을 만든다", async () => {
|
||||
const md = '- **A** b'
|
||||
const runs = await docxRuns(md)
|
||||
expect(runs.map((r) => r.text).join('')).toBe('A b')
|
||||
expect(boldTexts(runs)).toEqual(htmlBoldTexts(markdownToSimpleHtml(md)))
|
||||
expect(boldTexts(runs)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('제목·체크박스·문단·표 셀에 별표가 남지 않는다', async () => {
|
||||
const md = [
|
||||
'## **결정** 사항',
|
||||
'- [x] **담당**: 윤',
|
||||
'**요약** 줄',
|
||||
'',
|
||||
'| **담당** | 기한 |',
|
||||
'|---|---|',
|
||||
'| **윤** | 3월 |',
|
||||
].join('\n')
|
||||
const runs = await docxRuns(md)
|
||||
const allText = runs.map((r) => r.text).join('')
|
||||
expect(allText).not.toContain('**')
|
||||
expect(allText).toContain('☑ 담당: 윤')
|
||||
// 표 머리글은 전체가 굵다 — 기한도 굵음
|
||||
expect(boldTexts(runs)).toEqual(['결정', '담당', '요약', '담당', '기한', '윤'])
|
||||
expect(htmlBoldTexts(markdownToSimpleHtml(md))).toEqual(['결정', '담당', '요약', '담당', '윤'])
|
||||
})
|
||||
})
|
||||
|
|
@ -83,9 +83,13 @@ describe('chunkTranscriptByLines / looksTruncatedRewrite', () => {
|
|||
expect(chunkTranscriptByLines(text, 26)).toEqual(['[00:01] aaaa\n[00:02] bbbb', '[00:03] cccc'])
|
||||
})
|
||||
|
||||
it('한도보다 긴 한 줄은 그 줄 하나로 한 조각이 된다', () => {
|
||||
// redteam r3-3: 줄바꿈 없는 긴 전사도 한도 이하 조각으로 나눈다(이전: 긴 한 줄을 통째로 한 조각 — 한도를 넘겼다).
|
||||
it('한도보다 긴 한 줄도 한도 이하 조각으로 나뉘고, 내용은 빠지지 않는다', () => {
|
||||
const long = `[00:01] ${'x'.repeat(50)}`
|
||||
expect(chunkTranscriptByLines(`${long}\n[00:02] y`, 20)).toEqual([long, '[00:02] y'])
|
||||
const chunks = chunkTranscriptByLines(`${long}\n[00:02] y`, 20)
|
||||
expect(chunks.every((c) => c.length <= 20)).toBe(true)
|
||||
expect(chunks.join('').split('x').length - 1).toBe(50)
|
||||
expect(chunks[chunks.length - 1]).toBe('[00:02] y')
|
||||
})
|
||||
|
||||
it('시각 줄이 크게 줄었거나 비었으면 잘린 것으로 본다', () => {
|
||||
|
|
|
|||
59
packages/core/__tests__/template-field-policy.test.ts
Normal file
59
packages/core/__tests__/template-field-policy.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findTemplateFieldIdProblem,
|
||||
nextTemplateFieldId,
|
||||
nextTemplateFieldNumber,
|
||||
templateFieldIdProblems,
|
||||
} from '../src/template-field-policy'
|
||||
|
||||
const ids = (...values: string[]) => values.map((id) => ({ id }))
|
||||
|
||||
describe('templateFieldIdProblems', () => {
|
||||
it('유효한 id는 문제가 없다', () => {
|
||||
expect(templateFieldIdProblems(ids('a', 'b', 'c'))).toEqual([null, null, null])
|
||||
expect(findTemplateFieldIdProblem(ids('a', 'b'))).toBeNull()
|
||||
})
|
||||
|
||||
it('빈 id와 공백뿐인 id는 empty', () => {
|
||||
expect(templateFieldIdProblems(ids('a', '', ' '))).toEqual([null, 'empty', 'empty'])
|
||||
})
|
||||
|
||||
it('겹치는 id는 모든 행이 duplicate', () => {
|
||||
expect(templateFieldIdProblems(ids('field3', 'x', 'field3'))).toEqual(['duplicate', null, 'duplicate'])
|
||||
})
|
||||
|
||||
it('id는 정확히 비교한다(자리표시자 키가 정확 일치이므로)', () => {
|
||||
expect(templateFieldIdProblems(ids('a', ' a', 'A'))).toEqual([null, null, null])
|
||||
})
|
||||
|
||||
it('findTemplateFieldIdProblem은 첫 문제의 위치와 id를 돌려준다', () => {
|
||||
expect(findTemplateFieldIdProblem(ids('a', 'b', 'b'))).toEqual({ index: 1, id: 'b', problem: 'duplicate' })
|
||||
expect(findTemplateFieldIdProblem(ids('a', ''))).toEqual({ index: 1, id: '', problem: 'empty' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextTemplateFieldId', () => {
|
||||
it('빈 목록이면 field1', () => {
|
||||
expect(nextTemplateFieldId([])).toBe('field1')
|
||||
})
|
||||
|
||||
it('중간 필드를 지운 뒤에도 기존 id를 다시 만들지 않는다 (회귀: 개수 기반 번호)', () => {
|
||||
// [field1, field2, field3]에서 field1을 지움 → 개수+1 = field3 (중복)
|
||||
expect(nextTemplateFieldId(ids('field2', 'field3'))).toBe('field4')
|
||||
})
|
||||
|
||||
it('사용자가 이름을 바꾼 필드도 번호 계산에 넣는다', () => {
|
||||
expect(nextTemplateFieldNumber(ids('recipient', 'field7', 'body'))).toBe(8)
|
||||
expect(nextTemplateFieldId(ids('recipient', 'body'))).toBe('field1')
|
||||
})
|
||||
|
||||
it('추가·삭제를 섞어 반복해도 id가 겹치지 않는다', () => {
|
||||
const fields = ids('field1')
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
fields.push({ id: nextTemplateFieldId(fields) })
|
||||
fields.push({ id: nextTemplateFieldId(fields) })
|
||||
fields.splice(i % fields.length, 1)
|
||||
expect(findTemplateFieldIdProblem(fields)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
107
packages/core/__tests__/transcript-chunking-redteam-r3-3.test.ts
Normal file
107
packages/core/__tests__/transcript-chunking-redteam-r3-3.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// 줄바꿈 없는 긴 전사(자막·파일 전사 히스토리, 폰 회의 raw_transcript)도 한도 이하 조각으로 나뉘어야 한다.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { splitTextToFit } from '../src/text-chunking'
|
||||
import { chunkTranscriptByLines, looksTruncatedRewrite } from '../src/meeting-transcript'
|
||||
import {
|
||||
MEETING_MAP_CHUNK_CHARS,
|
||||
planTranscriptCondense,
|
||||
selectTranscriptExcerpts,
|
||||
} from '../src/meeting-llm-input'
|
||||
|
||||
/** 자막 세션 fullText 처럼 세그먼트를 공백으로 이어 붙인 한 줄 전사 (약 20,000자) */
|
||||
function singleLineTranscript(targetChars: number): string {
|
||||
const sentences: string[] = []
|
||||
let length = 0
|
||||
for (let i = 0; length < targetChars; i++) {
|
||||
const sentence =
|
||||
i === 437 ? '출시 담당자는 김민수로 결정했습니다.' : `오늘 회의의 ${i}번째 안건에 대해 이야기를 나눴습니다.`
|
||||
sentences.push(sentence)
|
||||
length += sentence.length + 1
|
||||
}
|
||||
return sentences.join(' ')
|
||||
}
|
||||
|
||||
describe('splitTextToFit', () => {
|
||||
it('들어가면 그대로 둔다', () => {
|
||||
expect(splitTextToFit(' 짧은 문장. ', 100)).toEqual(['짧은 문장.'])
|
||||
expect(splitTextToFit(' ', 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('문장 경계를 먼저 쓴다', () => {
|
||||
expect(splitTextToFit('첫 문장입니다. 둘째 문장입니다. 셋째', 20)).toEqual(['첫 문장입니다. 둘째 문장입니다.', '셋째'])
|
||||
})
|
||||
|
||||
it('소수점은 문장 경계가 아니다', () => {
|
||||
const pieces = splitTextToFit('매출은 3.5 억 원 증가 예상입니다', 12)
|
||||
expect(pieces.every((p) => p.length <= 12)).toBe(true)
|
||||
expect(pieces.join(' ')).toBe('매출은 3.5 억 원 증가 예상입니다')
|
||||
expect(pieces.some((p) => p.endsWith('3.'))).toBe(false)
|
||||
})
|
||||
|
||||
it('문장 경계가 너무 앞에 있으면 공백에서 자른다', () => {
|
||||
expect(splitTextToFit('네. 그러면 다음 주 월요일까지 초안을 보내겠습니다', 20)).toEqual([
|
||||
'네. 그러면 다음 주 월요일까지',
|
||||
'초안을 보내겠습니다',
|
||||
])
|
||||
})
|
||||
|
||||
it('전각 문장 부호는 뒤에 공백이 없어도 경계다', () => {
|
||||
expect(splitTextToFit('今日は晴れです。明日は雨です。', 9)).toEqual(['今日は晴れです。', '明日は雨です。'])
|
||||
})
|
||||
|
||||
it('경계가 없으면 글자 수로 자르고 서로게이트 쌍은 가르지 않는다', () => {
|
||||
expect(splitTextToFit('x'.repeat(25), 10)).toEqual(['x'.repeat(10), 'x'.repeat(10), 'x'.repeat(5)])
|
||||
const emoji = `${'a'.repeat(4)}😀${'b'.repeat(4)}`
|
||||
const pieces = splitTextToFit(emoji, 5)
|
||||
expect(pieces.join('')).toBe(emoji)
|
||||
expect(pieces.every((p) => p.length <= 5)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunkTranscriptByLines — 줄바꿈 없는 긴 줄', () => {
|
||||
it('단일 줄 20,000자 전사도 한도 이하 조각 여러 개로 나뉜다', () => {
|
||||
const transcript = singleLineTranscript(20_000)
|
||||
expect(transcript.includes('\n')).toBe(false)
|
||||
const chunks = chunkTranscriptByLines(transcript, 1_500)
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(14)
|
||||
expect(chunks.every((c) => c.length <= 1_500)).toBe(true)
|
||||
// 내용은 잃지 않는다(조각 경계의 공백만 줄바꿈으로 바뀐다)
|
||||
expect(chunks.join(' ').replace(/\s+/g, ' ')).toBe(transcript)
|
||||
// 문장 경계에서 끊었다
|
||||
expect(chunks.every((c) => c.endsWith('.'))).toBe(true)
|
||||
})
|
||||
|
||||
it('긴 시각 줄을 자르면 조각마다 [MM:SS] [화자] 머리를 붙인다', () => {
|
||||
const line = `[12:34] [화자 1] ${'가나다라 마바사. '.repeat(40).trim()}`
|
||||
const chunks = chunkTranscriptByLines(`[00:01] 짧은 줄\n${line}`, 120)
|
||||
expect(chunks.every((c) => c.length <= 120)).toBe(true)
|
||||
const pieces = chunks.flatMap((c) => c.split('\n')).slice(1)
|
||||
expect(pieces.length).toBeGreaterThan(1)
|
||||
expect(pieces.every((p) => p.startsWith('[12:34] [화자 1] '))).toBe(true)
|
||||
})
|
||||
|
||||
it('다시 쓰기 조각 하나를 그대로 돌려받으면 잘린 것으로 보지 않는다', () => {
|
||||
const chunks = chunkTranscriptByLines(singleLineTranscript(20_000), 1_500)
|
||||
for (const chunk of chunks) expect(looksTruncatedRewrite(chunk, chunk)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meeting-llm-input — 단일 줄 전사', () => {
|
||||
it('planTranscriptCondense 는 프록시 한도 안쪽 조각 여러 개를 낸다', () => {
|
||||
const transcript = singleLineTranscript(20_000)
|
||||
const chunks = planTranscriptCondense(transcript, 8_000)
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(4)
|
||||
expect(chunks.every((c) => c.length <= MEETING_MAP_CHUNK_CHARS)).toBe(true)
|
||||
})
|
||||
|
||||
it('selectTranscriptExcerpts 는 질문과 관련된 구간을 실제로 넣는다(빈 발췌 금지)', () => {
|
||||
const transcript = singleLineTranscript(20_000)
|
||||
const excerpt = selectTranscriptExcerpts(transcript, '출시 담당자는 누구로 결정했나요?', 7_000)
|
||||
expect(excerpt.complete).toBe(false)
|
||||
expect(excerpt.totalChunks).toBeGreaterThan(1)
|
||||
expect(excerpt.includedChunks).toBeGreaterThan(0)
|
||||
expect(excerpt.text.length).toBeGreaterThan(0)
|
||||
expect(excerpt.text.length).toBeLessThanOrEqual(7_000)
|
||||
expect(excerpt.text).toContain('출시 담당자는 김민수로 결정했습니다.')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue