import { describe, expect, it } from 'vitest' import { buildLocalSuggestionCandidates, calculateFrictionInsight, rankFlowWindows, recommendAppExclusion, selectPhraseHints, type InputAppSuggestionStat, type InputHourlyStat, type InputPrivacyReceipt, type PersonalPhrase } from '@d3ro/core/input-intelligence' import { auditKeyBindingMap, createDefaultBindingMap, type KeyBinding } from '@d3ro/core/keybinding' const HOUR = 60 * 60 * 1000 const DAY = 24 * HOUR function binding(code: number, modifiers: Partial = {}): KeyBinding { return { device: 'keyboard', code, ctrl: false, alt: false, shift: false, meta: false, ...modifiers } } describe('로컬 플로우 도메인', () => { it('편집 마찰을 0~1 비율과 경계 등급으로 계산한다', () => { expect(calculateFrictionInsight(0, 0)).toEqual({ rate: 0, editsPer100Chars: 0, band: 'steady' }) expect(calculateFrictionInsight(92, 8)).toMatchObject({ rate: 0.08, editsPer100Chars: 8.7, band: 'watch' }) expect(calculateFrictionInsight(82, 18)).toMatchObject({ rate: 0.18, editsPer100Chars: 22, band: 'high' }) }) it('플로우 시간대는 밀도·안정성 순으로 고르고 입력 없는 시간은 빼며 원본을 바꾸지 않는다', () => { const hourly: InputHourlyStat[] = [ { hour: 11, keystrokes: 1400, clicks: 2, chars: 1200, backspaces: 0, activeMs: HOUR }, { hour: 9, keystrokes: 1400, clicks: 2, chars: 1200, backspaces: 0, activeMs: HOUR }, { hour: 13, keystrokes: 200, clicks: 0, chars: 300, backspaces: 100, activeMs: HOUR / 2 }, { hour: 3, keystrokes: 0, clicks: 0, chars: 0, backspaces: 0, activeMs: 0 } ] const before = structuredClone(hourly) expect(rankFlowWindows(hourly, 1, 3)).toEqual([ { hour: 9, score: 100, activeMinutes: 60, chars: 1200, frictionRate: 0 }, { hour: 11, score: 100, activeMinutes: 60, chars: 1200, frictionRate: 0 }, { hour: 13, score: 44, activeMinutes: 30, chars: 300, frictionRate: 0.25 } ]) expect(hourly).toEqual(before) }) it('활성 일수로 나눠 같은 시간대라도 하루 밀도를 낮춘다', () => { const [window] = rankFlowWindows( [{ hour: 9, keystrokes: 1400, clicks: 0, chars: 1200, backspaces: 0, activeMs: HOUR }], 2 ) expect(window).toMatchObject({ hour: 9, score: 58, activeMinutes: 30, chars: 1200 }) }) it('앱 문체 보너스와 반감기로 개인 문구를 정렬하며 원본 배열을 바꾸지 않는다', () => { const now = 1_800_000_000_000 const phrases: PersonalPhrase[] = [ { id: 'z-old-frequent', phrase: '배포 일정을 공유합니다', count: 16, source: 'typed', appName: 'Slack.exe', lastUsedAt: now - DAY * 90, createdAt: now - DAY * 90 }, { id: 'b-current-app', phrase: '검토 결과를 남깁니다', count: 1, source: 'typed', appName: 'Notion.exe', lastUsedAt: now, createdAt: now }, { id: 'a-current-app-tie', phrase: '다음 조치를 확인합니다', count: 1, source: 'typed', appName: 'Notion.exe', lastUsedAt: now, createdAt: now } ] const before = structuredClone(phrases) expect(selectPhraseHints(phrases, '', 3, { appName: 'notion.exe', now })).toEqual([ '다음 조치를 확인합니다', '검토 결과를 남깁니다', '배포 일정을 공유합니다' ]) expect(phrases).toEqual(before) }) it('반복적인 비가독 앱만 제외를 권하고 읽힌 기록이 하나라도 있으면 멈춘다', () => { expect( recommendAppExclusion({ appName: 'Legacy.exe', samples: 4, readable: 0, unreadable: 3, empty: 1 }) ).toEqual({ appName: 'Legacy.exe', reason: 'repeated-unreadable', samples: 4 }) expect( recommendAppExclusion({ appName: 'Mixed.exe', samples: 6, readable: 1, unreadable: 5, empty: 0 }) ).toBeNull() expect( recommendAppExclusion({ appName: ' ', samples: 6, readable: 0, unreadable: 6, empty: 0 }) ).toBeNull() }) it('로컬 제안은 출처 우선순위·접미 중첩 제거·중복 제거를 지킨다', () => { expect( buildLocalSuggestionCandidates( '회의 결과', { continuationHints: ['를 공유합니다.'], relatedHints: ['결과를 정리합니다.', '무관한 전체 문장'], phraseHints: ['결과를 정리합니다.', '결과를 검토합니다.'] } ) ).toEqual(['를 공유합니다.', '를 정리합니다.', '를 검토합니다.']) }) it('문장이 끝난 뒤에만 겹침 없는 전체 문구를 로컬 제안으로 허용한다', () => { const hints = { continuationHints: [], relatedHints: ['다음 안건을 정리합니다.'], phraseHints: [] } expect(buildLocalSuggestionCandidates('회의를 마쳤다', hints)).toEqual([]) expect(buildLocalSuggestionCandidates('회의를 마쳤다.', hints)).toEqual(['다음 안건을 정리합니다.']) }) it('프라이버시·앱 품질 계약은 수량과 로컬 경계를 명시한다', () => { const receipt: InputPrivacyReceipt = { localOnly: true, rawKeyContentStored: false, retention: { activityDays: 30, typingSamplesDays: 30, suggestionDays: 30, personalPhrases: 'until-deleted' }, counts: { activityBuckets: 3, typingSamples: 2, personalPhrases: 4, suggestions: 5 } } const appStat: InputAppSuggestionStat = { appName: 'notion.exe', total: 4, accepted: 2, acceptRate: 0.5, avgLatencyMs: 310 } expect(receipt.counts.personalPhrases).toBe(4) expect(receipt.retention).toEqual({ activityDays: 30, typingSamplesDays: 30, suggestionDays: 30, personalPhrases: 'until-deleted' }) expect(appStat.acceptRate).toBe(0.5) }) }) describe('단축키 안전 감사', () => { it('유효하지 않은 바인딩과 충돌을 한 번씩 보고하고 홀드·더블프레스 기본 예외는 유지한다', () => { const map = createDefaultBindingMap() map.caption = [binding(0x56, { ctrl: true, shift: true })] map.dictation = [binding(999)] const issues = auditKeyBindingMap(map) expect(issues).toContainEqual({ kind: 'invalid', actionId: 'dictation', bindingIndex: 0, binding: binding(999), reasonKey: 'keybinding.reject.unknownKey', conflictActionIds: [] }) expect(issues).toContainEqual({ kind: 'conflict', actionId: 'caption', bindingIndex: 0, binding: binding(0x56, { ctrl: true, shift: true }), reasonKey: null, conflictActionIds: ['history-popup'] }) expect(issues.filter((issue) => issue.kind === 'conflict')).toHaveLength(1) }) })