Adds next-sentence suggestions while typing, weekly input insights and a personal phrase memory to the desktop app, and fixes custom instructions so they process the text instead of inserting the instruction's own wording. Local model requests are now bounded and individually cancellable. Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the landing and web download links, and records the new INPUT feature rows and the open verification gaps in the infrastructure map.
197 lines
6.8 KiB
TypeScript
197 lines
6.8 KiB
TypeScript
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> = {}): 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)
|
|
})
|
|
})
|