mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 09:48:57 +00:00
317 lines
9.9 KiB
TypeScript
317 lines
9.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
normalizePhoneViewportRect,
|
|
phoneViewportRectContainsPoint,
|
|
readPhoneViewportGeometry,
|
|
type PhoneViewportRect,
|
|
} from '@/utils/phoneViewportGeometry'
|
|
|
|
type RectMeasurement = Pick<
|
|
DOMRectReadOnly,
|
|
'height' | 'left' | 'top' | 'width'
|
|
>
|
|
|
|
function measuredRect(
|
|
left: number,
|
|
top: number,
|
|
width: number,
|
|
height: number,
|
|
): RectMeasurement {
|
|
return { height, left, top, width }
|
|
}
|
|
|
|
function expectRectClose(
|
|
actual: PhoneViewportRect,
|
|
expected: PhoneViewportRect,
|
|
): void {
|
|
for (const key of [
|
|
'bottom',
|
|
'height',
|
|
'left',
|
|
'right',
|
|
'top',
|
|
'width',
|
|
] as const) {
|
|
expect(actual[key]).toBeCloseTo(expected[key], 6)
|
|
}
|
|
}
|
|
|
|
function createGeometryFixture(options: {
|
|
canvasOffsetHeight: number
|
|
canvasOffsetWidth: number
|
|
rawCanvasRect: RectMeasurement
|
|
wrapperRect: RectMeasurement
|
|
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
|
|
const wrapper = {
|
|
getBoundingClientRect: () => options.wrapperRect,
|
|
} as unknown as HTMLElement
|
|
const canvas = {
|
|
closest: (selector: string) =>
|
|
selector === '.phone-resolution-wrapper' ? wrapper : null,
|
|
getBoundingClientRect: () => options.rawCanvasRect,
|
|
offsetHeight: options.canvasOffsetHeight,
|
|
offsetWidth: options.canvasOffsetWidth,
|
|
} as unknown as HTMLElement
|
|
|
|
return {
|
|
anchor: {
|
|
closest: (selector: string) =>
|
|
selector === '.phone-resolution-canvas' ? canvas : null,
|
|
} as unknown as Element,
|
|
element: (rect) =>
|
|
({ getBoundingClientRect: () => rect }) as unknown as Element,
|
|
}
|
|
}
|
|
|
|
const PHONE_BASE_ZOOM = 0.69 * 1.2
|
|
const PHONE_HEIGHT = 844
|
|
const PHONE_WIDTH = 390
|
|
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
|
const REFERENCE_VIEWPORT_WIDTH = 1920
|
|
|
|
const displayCases = [
|
|
{ height: 1080, label: '1080p 16:9', width: 1920 },
|
|
{ height: 2160, label: '4K 16:9', width: 3840 },
|
|
{ height: 1440, label: '21:9', width: 3440 },
|
|
{ height: 1440, label: '32:9', width: 5120 },
|
|
{ height: 1600, label: '16:10', width: 2560 },
|
|
] as const
|
|
|
|
const phoneScales = [80, 100, 120] as const
|
|
|
|
function productionPhoneZoom(
|
|
viewportWidth: number,
|
|
viewportHeight: number,
|
|
phoneScale: number,
|
|
): number {
|
|
const viewportScale = Math.min(
|
|
viewportWidth / REFERENCE_VIEWPORT_WIDTH,
|
|
viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
|
|
)
|
|
const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
|
|
const edgeGap = 24 * viewportScale
|
|
const viewportMaximum = Math.max(
|
|
0,
|
|
Math.min(
|
|
(viewportWidth - edgeGap) / PHONE_WIDTH,
|
|
(viewportHeight - edgeGap) / PHONE_HEIGHT,
|
|
),
|
|
)
|
|
|
|
return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
|
|
}
|
|
|
|
const cefDisplayMatrix = displayCases.flatMap((display) =>
|
|
phoneScales.map((phoneScale) => ({ display, phoneScale })),
|
|
)
|
|
|
|
describe('phone viewport geometry', () => {
|
|
it('treats every rectangle boundary as inside and rejects points beyond it', () => {
|
|
const rect: PhoneViewportRect = {
|
|
bottom: 260,
|
|
height: 160,
|
|
left: 120,
|
|
right: 360,
|
|
top: 100,
|
|
width: 240,
|
|
}
|
|
|
|
expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
|
|
expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
|
|
expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
|
|
true,
|
|
)
|
|
expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
|
|
false,
|
|
)
|
|
expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
|
|
false,
|
|
)
|
|
expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
|
|
false,
|
|
)
|
|
expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
|
|
false,
|
|
)
|
|
})
|
|
|
|
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
|
|
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
|
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
|
|
|
|
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
|
|
bottom: layer.top + layer.height,
|
|
height: layer.height,
|
|
left: layer.left,
|
|
right: layer.left + layer.width,
|
|
top: layer.top,
|
|
width: layer.width,
|
|
})
|
|
})
|
|
|
|
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
|
|
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
|
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
|
|
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
|
|
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
|
|
|
|
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
|
|
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
|
|
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
|
|
expect(corrected.left).toBeGreaterThan(wrapper.left)
|
|
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
|
|
})
|
|
|
|
it.each([
|
|
['80%', 0.6624],
|
|
['100%', 0.828],
|
|
['120%', 0.9936],
|
|
])(
|
|
'normalizes fractional positions, sizes, and deltas at %s scaling',
|
|
(_label, zoom) => {
|
|
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
|
|
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
|
|
const rawStart = measuredRect(
|
|
rawCanvas.left + 20.125,
|
|
rawCanvas.top + 50.375,
|
|
349.75,
|
|
73.125,
|
|
)
|
|
const rawEnd = measuredRect(
|
|
rawStart.left + 73.25,
|
|
rawStart.top - 41.75,
|
|
rawStart.width,
|
|
rawStart.height,
|
|
)
|
|
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
|
|
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
|
|
|
|
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
|
|
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
|
|
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
|
|
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
|
|
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
|
|
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
|
|
},
|
|
)
|
|
|
|
it.each(cefDisplayMatrix)(
|
|
'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
|
|
({ display, phoneScale }) => {
|
|
const viewportScale = Math.min(
|
|
display.width / REFERENCE_VIEWPORT_WIDTH,
|
|
display.height / REFERENCE_VIEWPORT_HEIGHT,
|
|
)
|
|
const zoom = productionPhoneZoom(
|
|
display.width,
|
|
display.height,
|
|
phoneScale,
|
|
)
|
|
const edgeGap = 24 * viewportScale
|
|
const wrapper = measuredRect(
|
|
display.width - edgeGap - PHONE_WIDTH * zoom,
|
|
display.height - edgeGap - PHONE_HEIGHT * zoom,
|
|
PHONE_WIDTH * zoom,
|
|
PHONE_HEIGHT * zoom,
|
|
)
|
|
// Live CEF 103 can report the zoomed canvas in a different coordinate
|
|
// space from pointer events. Preserve that mismatch in this fixture.
|
|
const rawCanvas = measuredRect(
|
|
display.width + 117.375,
|
|
83.625,
|
|
PHONE_WIDTH,
|
|
PHONE_HEIGHT,
|
|
)
|
|
const rawPanel = measuredRect(
|
|
rawCanvas.left + 23.25,
|
|
rawCanvas.top + 246.75,
|
|
343.5,
|
|
324.25,
|
|
)
|
|
const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
|
|
const expected = {
|
|
bottom: wrapper.top + (246.75 + 324.25) * zoom,
|
|
height: 324.25 * zoom,
|
|
left: wrapper.left + 23.25 * zoom,
|
|
right: wrapper.left + (23.25 + 343.5) * zoom,
|
|
top: wrapper.top + 246.75 * zoom,
|
|
width: 343.5 * zoom,
|
|
}
|
|
|
|
expectRectClose(panel, expected)
|
|
expect(
|
|
phoneViewportRectContainsPoint(
|
|
panel,
|
|
panel.left + panel.width / 2,
|
|
panel.top + panel.height / 2,
|
|
),
|
|
).toBe(true)
|
|
expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
|
|
true,
|
|
)
|
|
expect(
|
|
phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
|
|
).toBe(true)
|
|
expect(
|
|
phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
|
|
).toBe(false)
|
|
expect(
|
|
phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
|
|
).toBe(false)
|
|
expect(panel.left).toBeGreaterThanOrEqual(0)
|
|
expect(panel.right).toBeLessThanOrEqual(display.width)
|
|
expect(panel.top).toBeGreaterThanOrEqual(0)
|
|
expect(panel.bottom).toBeLessThanOrEqual(display.height)
|
|
},
|
|
)
|
|
|
|
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
|
const fixture = createGeometryFixture({
|
|
canvasOffsetHeight: 844,
|
|
canvasOffsetWidth: 390,
|
|
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
|
|
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
|
|
})
|
|
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
|
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
|
|
|
|
expect(geometry).not.toBeNull()
|
|
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
|
|
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
|
|
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
|
|
})
|
|
|
|
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
|
|
const rawCanvas = measuredRect(100, 50, 0, 0)
|
|
const corrected = normalizePhoneViewportRect(
|
|
measuredRect(112.5, 58.25, 40, 20),
|
|
rawCanvas,
|
|
measuredRect(500, 300, 0, 0),
|
|
)
|
|
const fixture = createGeometryFixture({
|
|
canvasOffsetHeight: 0,
|
|
canvasOffsetWidth: 0,
|
|
rawCanvasRect: rawCanvas,
|
|
wrapperRect: measuredRect(500, 300, 0, 0),
|
|
})
|
|
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
|
|
|
expect(corrected).toEqual({
|
|
bottom: 328.25,
|
|
height: 20,
|
|
left: 512.5,
|
|
right: 552.5,
|
|
top: 308.25,
|
|
width: 40,
|
|
})
|
|
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
|
|
expect(geometry?.scaleX).toBe(1)
|
|
expect(geometry?.scaleY).toBe(1)
|
|
expect(readPhoneViewportGeometry(null)).toBeNull()
|
|
expect(
|
|
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
|
|
).toBeNull()
|
|
})
|
|
})
|