150 lines
6.6 KiB
TypeScript
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
|
|
})
|
|
})
|
|
})
|