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