fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions
This commit is contained in:
parent
75e053d72f
commit
9e5b94ced2
18 changed files with 2178 additions and 558 deletions
150
packages/core/__tests__/popup-placement.test.ts
Normal file
150
packages/core/__tests__/popup-placement.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
anchorFloatingPanel,
|
||||
asDipPoint,
|
||||
asDipRect,
|
||||
fitAnchoredPanel,
|
||||
placeAnchoredPanel,
|
||||
placeNearCursor,
|
||||
type DipPoint,
|
||||
type DipRect,
|
||||
type PhysicalRect,
|
||||
type ScreenGeometryPort,
|
||||
type UiRect
|
||||
} from '../src/popup-placement'
|
||||
|
||||
/**
|
||||
* 두 모니터 가짜 포트 — 물리 픽셀 좌표계에서 모니터마다 배율이 다르다.
|
||||
* A: 물리 (0,0) 1920x1080 @150% → DIP (0,0) 1280x720 (작업 영역 높이 680)
|
||||
* B: 물리 (1920,0) 1920x1080 @100% → DIP (1280,0) 1920x1080 (작업 영역 높이 1040)
|
||||
*/
|
||||
function twoMonitorPort(cursor: { x: number; y: number } = { x: 10, y: 10 }): ScreenGeometryPort {
|
||||
const monitors = [
|
||||
{ physical: { x: 0, y: 0, width: 1920, height: 1080 }, dip: { x: 0, y: 0, width: 1280, height: 720 }, scale: 1.5, workHeight: 680 },
|
||||
{ physical: { x: 1920, y: 0, width: 1920, height: 1080 }, dip: { x: 1280, y: 0, width: 1920, height: 1080 }, scale: 1, workHeight: 1040 }
|
||||
]
|
||||
const monitorOfPhysical = (rect: UiRect) =>
|
||||
monitors.find((m) => rect.x >= m.physical.x && rect.x < m.physical.x + m.physical.width) ?? monitors[0]
|
||||
const monitorOfDip = (point: { x: number; y: number }) =>
|
||||
monitors.find((m) => point.x >= m.dip.x && point.x < m.dip.x + m.dip.width) ?? monitors[0]
|
||||
return {
|
||||
toDip(rect: PhysicalRect): DipRect {
|
||||
const m = monitorOfPhysical(rect)
|
||||
return asDipRect({
|
||||
x: Math.round(m.dip.x + (rect.x - m.physical.x) / m.scale),
|
||||
y: Math.round(m.dip.y + (rect.y - m.physical.y) / m.scale),
|
||||
width: Math.round(rect.width / m.scale),
|
||||
height: Math.round(rect.height / m.scale)
|
||||
})
|
||||
},
|
||||
workAreaNear(point: DipPoint): DipRect {
|
||||
const m = monitorOfDip(point)
|
||||
return asDipRect({ x: m.dip.x, y: m.dip.y, width: m.dip.width, height: m.workHeight })
|
||||
},
|
||||
cursor: () => asDipPoint(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
const SIZE = { width: 460, height: 208 }
|
||||
|
||||
describe('placeAnchoredPanel — 물리 픽셀 앵커를 DIP 로 한 번만 바꾼다', () => {
|
||||
it('150% 모니터에서 캐럿 (600,400) 물리 → DIP (400,267) 바로 아래에 붙는다', () => {
|
||||
const caret: PhysicalRect = { x: 600, y: 400, width: 2, height: 30 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP 캐럿: x=400, y=267, h=20 → 아래 6px
|
||||
expect(placement).toMatchObject({ x: 400, y: 267 + 20 + 6, grow: 'down', maxHeight: 208, width: 460 })
|
||||
})
|
||||
|
||||
it('150% 모니터에서 캐럿 (1500,900) 물리는 작업 영역 밖으로 읽히지 않고 캐럿 위로 뒤집힌다', () => {
|
||||
const caret: PhysicalRect = { x: 1500, y: 900, width: 2, height: 30 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP 캐럿: x=1000, y=600, h=20 → 아래는 680 을 넘으므로 위(600-6-208=386)
|
||||
expect(placement.y).toBe(386)
|
||||
expect(placement.grow).toBe('up')
|
||||
// 가로는 작업 영역 오른쪽(1280-460=820)으로 클램프될 뿐 모서리 구석에 박히지 않는다
|
||||
expect(placement.x).toBe(820)
|
||||
})
|
||||
|
||||
it('두 번째(100%) 모니터의 캐럿은 그 모니터의 작업 영역을 쓴다', () => {
|
||||
const caret: PhysicalRect = { x: 2000, y: 900, width: 2, height: 20 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP: x = 1280 + 80 = 1360, y = 900 → 아래(926+208=1134 > 1040) 불가 → 위 686
|
||||
expect(placement).toMatchObject({ x: 1360, y: 900 - 6 - 208, grow: 'up' })
|
||||
})
|
||||
|
||||
it('앵커가 없으면 DIP 커서 기준이다', () => {
|
||||
const placement = placeAnchoredPanel(twoMonitorPort({ x: 100, y: 100 }), null, null, SIZE)
|
||||
expect(placement).toMatchObject({ x: 100, y: 106, grow: 'down' })
|
||||
})
|
||||
|
||||
it('100% 배율(항등 포트)에서는 기존 anchorFloatingPanel 결과와 같다', () => {
|
||||
const identity: ScreenGeometryPort = {
|
||||
toDip: (rect) => asDipRect(rect),
|
||||
workAreaNear: () => asDipRect({ x: 0, y: 0, width: 1920, height: 1040 }),
|
||||
cursor: () => asDipPoint({ x: 5, y: 5 })
|
||||
}
|
||||
const caret = { x: 700, y: 500, width: 2, height: 18 }
|
||||
const placement = placeAnchoredPanel(identity, caret, 'caret', SIZE)
|
||||
const legacy = anchorFloatingPanel(caret, 'caret', { x: 5, y: 5 }, SIZE, { x: 0, y: 0, width: 1920, height: 1040 })
|
||||
expect({ x: placement.x, y: placement.y }).toEqual(legacy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitAnchoredPanel — 창을 그려진 패널 높이로 줄인다', () => {
|
||||
it('아래로 붙은 패널은 위 모서리를 고정한다', () => {
|
||||
expect(fitAnchoredPanel({ x: 10, y: 100, width: 460, maxHeight: 208, grow: 'down' }, 54)).toEqual({
|
||||
x: 10,
|
||||
y: 100,
|
||||
width: 460,
|
||||
height: 54
|
||||
})
|
||||
})
|
||||
|
||||
it('위로 뒤집힌 패널은 앵커 쪽(아래) 모서리를 고정한다', () => {
|
||||
expect(fitAnchoredPanel({ x: 10, y: 386, width: 460, maxHeight: 208, grow: 'up' }, 54)).toEqual({
|
||||
x: 10,
|
||||
y: 386 + 208 - 54,
|
||||
width: 460,
|
||||
height: 54
|
||||
})
|
||||
})
|
||||
|
||||
it('최대 높이를 넘지 않는다', () => {
|
||||
expect(fitAnchoredPanel({ x: 0, y: 0, width: 460, maxHeight: 208, grow: 'down' }, 500).height).toBe(208)
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeNearCursor — 커서 기준 팝업 배치', () => {
|
||||
const workArea = { x: 0, y: 0, width: 1366, height: 728 }
|
||||
|
||||
it('커서 위 20px, 가로 가운데', () => {
|
||||
expect(placeNearCursor({ x: 600, y: 500 }, { width: 340, height: 200 }, workArea)).toEqual({ x: 430, y: 280 })
|
||||
})
|
||||
|
||||
it('위 공간이 없으면 아래로 뒤집는다', () => {
|
||||
expect(placeNearCursor({ x: 600, y: 100 }, { width: 340, height: 200 }, workArea)).toEqual({ x: 430, y: 120 })
|
||||
})
|
||||
|
||||
it('아래로 뒤집힌 큰 팝업도 작업 영역 아래로 잘리지 않는다 (기록/명령 팝업 회귀)', () => {
|
||||
const position = placeNearCursor({ x: 600, y: 300 }, { width: 380, height: 500 }, workArea)
|
||||
expect(position.y + 500).toBeLessThanOrEqual(728)
|
||||
expect(position.y).toBe(228)
|
||||
})
|
||||
|
||||
it('왼쪽/오른쪽 가장자리에서 클램프한다', () => {
|
||||
expect(placeNearCursor({ x: 5, y: 500 }, { width: 340, height: 100 }, workArea).x).toBe(0)
|
||||
expect(placeNearCursor({ x: 1360, y: 500 }, { width: 340, height: 100 }, workArea).x).toBe(1366 - 340)
|
||||
})
|
||||
|
||||
it('음수 좌표 모니터(주 모니터 왼쪽)에서도 그 작업 영역 안에 둔다', () => {
|
||||
const left = { x: -1920, y: -200, width: 1920, height: 1040 }
|
||||
expect(placeNearCursor({ x: -1910, y: -150 }, { width: 320, height: 110 }, left)).toEqual({ x: -1920, y: -130 })
|
||||
})
|
||||
|
||||
it('팝업이 작업 영역보다 크면 왼쪽 위 모서리에 둔다', () => {
|
||||
expect(placeNearCursor({ x: 50, y: 50 }, { width: 400, height: 300 }, { x: 0, y: 0, width: 300, height: 200 })).toEqual({
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
202
packages/core/__tests__/suggestion-redteam-r2-3.test.ts
Normal file
202
packages/core/__tests__/suggestion-redteam-r2-3.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue