d3ro-voice/packages/core/__tests__/popup-placement.test.ts

150 lines
6.6 KiB
TypeScript

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
})
})
})