Files
frontend-miniapp/tests/e2e/three-map-camera-stability.spec.ts
lyf abaeda189e
Some checks failed
CI / verify (push) Has been cancelled
修复跨楼层点位聚焦视觉跳动
2026-07-14 01:16:31 +08:00

402 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page, type TestInfo } from '@playwright/test'
interface CameraReport {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
quaternion: { x: number; y: number; z: number; w: number }
fov: number
zoom: number
near: number
far: number
distance: number
}
interface AnchorReport {
id: string
world: { x: number; y: number; z: number }
screen: { x: number; y: number; z: number; visible: boolean }
}
interface VisualReport {
camera: CameraReport
modelRoot: {
positionError: number
rotationError: number
scaleError: number
maxError: number
} | null
anchors: AnchorReport[]
activeView: string
floorId: string
isCameraTweening: boolean
poiFocusCameraAnimationCount: number
cameraSnapshotRestoreCount: number
focus: {
poiId: string
floorId: string
positionGltf: [number, number, number]
markerPosition: { x: number; y: number; z: number } | null
} | null
}
interface FloorReport {
floorId: string
label: string
modelMatchKeys: string[]
}
interface PoiFocusCandidate {
poiId: string
floorId: string
name: string
positionGltf: [number, number, number]
}
interface VisualStabilityApi {
getReport: () => VisualReport
getFloors: () => FloorReport[]
switchFloor: (floorId: string) => Promise<void>
showOverview: () => Promise<void>
getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]>
focusTargetPoi: (request: {
requestId: number | string
poiId: string
floorId: string
name?: string
positionGltf: [number, number, number]
}) => Promise<unknown>
clearTargetFocus: () => void
}
const getApiState = async (page: Page) => {
try {
return await page.evaluate(() => {
const windowWithDiagnostics = window as Window & {
__GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi
}
const api = windowWithDiagnostics.__GUIDE_3D_VISUAL_STABILITY__
return api
? { floors: api.getFloors(), report: api.getReport() }
: null
})
} catch (error) {
if (error instanceof Error && error.message.includes('Execution context was destroyed')) {
return null
}
throw error
}
}
const getReport = async (page: Page) => {
const state = await getApiState(page)
expect(state).not.toBeNull()
return state!.report
}
const getMaximumCameraDelta = (before: CameraReport, after: CameraReport) => Math.max(
Math.hypot(
before.position.x - after.position.x,
before.position.y - after.position.y,
before.position.z - after.position.z
),
Math.hypot(
before.target.x - after.target.x,
before.target.y - after.target.y,
before.target.z - after.target.z
),
Math.hypot(before.up.x - after.up.x, before.up.y - after.up.y, before.up.z - after.up.z),
Math.hypot(
before.quaternion.x - after.quaternion.x,
before.quaternion.y - after.quaternion.y,
before.quaternion.z - after.quaternion.z,
before.quaternion.w - after.quaternion.w
),
Math.abs(before.fov - after.fov),
Math.abs(before.zoom - after.zoom),
Math.abs(before.near - after.near),
Math.abs(before.far - after.far),
Math.abs(before.distance - after.distance)
)
const getMaximumAnchorDeltaPx = (before: AnchorReport[], after: AnchorReport[]) => Math.max(
...before.map((anchor) => {
const next = after.find((candidate) => candidate.id === anchor.id)
expect(next, `missing ${anchor.id}`).toBeDefined()
return Math.hypot(anchor.screen.x - next!.screen.x, anchor.screen.y - next!.screen.y)
})
)
const assertStableTransition = (before: VisualReport, after: VisualReport) => {
expect(after.isCameraTweening).toBe(false)
expect(after.modelRoot, 'the active model must stay committed').not.toBeNull()
expect(after.modelRoot!.maxError).toBeLessThan(1e-12)
expect(getMaximumCameraDelta(before.camera, after.camera)).toBeLessThan(1e-6)
expect(after.anchors).toHaveLength(3)
expect(getMaximumAnchorDeltaPx(before.anchors, after.anchors)).toBeLessThanOrEqual(2)
}
const screenshot = async (page: Page, testInfo: TestInfo, name: string) => {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) })
}
const waitForTransition = async (
page: Page,
transition: Promise<void>,
testInfo: TestInfo,
name: string
) => {
let complete = false
void transition.then(() => { complete = true })
for (let frame = 0; !complete && frame < 180; frame += 1) {
await page.waitForTimeout(80)
const report = await getReport(page)
expect(report.modelRoot, `blank model frame ${frame}`).not.toBeNull()
if (frame === 0) {
await screenshot(page, testInfo, `${name}-during`)
}
}
await transition
expect(complete, `${name} exceeded the sampling window`).toBe(true)
}
const switchFloor = async (page: Page, testInfo: TestInfo, floorId: string, name: string) => {
const before = await getReport(page)
await screenshot(page, testInfo, `${name}-before`)
const transition = page.evaluate(async (id) => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
await api.switchFloor(id)
}, floorId)
await waitForTransition(page, transition, testInfo, name)
const after = await getReport(page)
await screenshot(page, testInfo, `${name}-after`)
assertStableTransition(before, after)
}
const showOverview = async (page: Page, testInfo: TestInfo, name: string) => {
const before = await getReport(page)
await screenshot(page, testInfo, `${name}-before`)
const transition = page.evaluate(async () => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
await api.showOverview()
})
await waitForTransition(page, transition, testInfo, name)
const after = await getReport(page)
await screenshot(page, testInfo, `${name}-after`)
assertStableTransition(before, after)
}
const exitOverviewWithZoomInput = async (
page: Page,
testInfo: TestInfo,
name: string,
input: 'button' | 'wheel' | 'touch'
) => {
const before = await getReport(page)
await screenshot(page, testInfo, `${name}-before`)
if (input === 'button') {
await page.getByText('', { exact: true }).click()
} else if (input === 'wheel') {
await page.locator('canvas').hover()
await page.mouse.wheel(0, 3000)
} else {
const canvas = page.locator('canvas')
const box = await canvas.boundingBox()
expect(box, 'the touch zoom target must be visible').not.toBeNull()
const session = await page.context().newCDPSession(page)
await session.send('Input.synthesizePinchGesture', {
x: Math.round(box!.x + box!.width / 2),
y: Math.round(box!.y + box!.height / 2),
scaleFactor: 0.5,
relativeSpeed: 800,
gestureSourceType: 'touch'
})
}
await expect.poll(async () => (await getReport(page)).activeView, { timeout: 10_000 }).toBe('overview')
const after = await getReport(page)
await screenshot(page, testInfo, `${name}-after`)
expect(after.isCameraTweening).toBe(false)
expect(after.modelRoot, 'the overview model must stay committed').not.toBeNull()
expect(after.modelRoot!.maxError).toBeLessThan(1e-12)
expect(after.camera.distance).toBeGreaterThanOrEqual(before.camera.distance * 1.2)
}
const normalizeFloorKey = (value: string) => value.trim().toLowerCase().replace(/[^a-z0-9.-]/g, '')
const resolveRequiredFloorId = (floors: FloorReport[], requested: string) => {
const normalizedRequested = normalizeFloorKey(requested)
const floor = floors.find((candidate) => (
[candidate.floorId, candidate.label, ...candidate.modelMatchKeys]
.some((value) => normalizeFloorKey(value) === normalizedRequested)
))
expect(floor, `missing requested floor ${requested}`).toBeDefined()
return floor!.floorId
}
const getFloorPois = async (page: Page, floorId: string) => {
const pois = await page.evaluate(async (id) => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
return api.getFloorPois(id)
}, floorId)
expect(pois.length, `floor ${floorId} must contain a positioned POI`).toBeGreaterThan(0)
return pois
}
const getVectorDelta = (left: { x: number; y: number; z: number }, right: [number, number, number]) => (
Math.hypot(left.x - right[0], left.y - right[1], left.z - right[2])
)
const waitForPoiFocusToSettle = async (page: Page, testInfo: TestInfo, name: string) => {
let sawTween = false
let stableFrames = 0
for (let frame = 0; frame < 180; frame += 1) {
const report = await getReport(page)
expect(report.modelRoot, `blank POI focus frame ${frame}`).not.toBeNull()
expect(report.modelRoot!.maxError).toBeLessThan(1e-12)
sawTween ||= report.isCameraTweening
stableFrames = report.isCameraTweening ? 0 : stableFrames + 1
if (frame === 0) {
await screenshot(page, testInfo, `${name}-during`)
}
if (sawTween && stableFrames >= 3) return report
await page.waitForTimeout(40)
}
throw new Error(`${name} did not settle after one POI focus animation`)
}
test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => {
await page.goto('/')
await Promise.all([
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
page.getByText('馆内', { exact: true }).click()
])
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
await expect.poll(async () => (await getApiState(page))?.report.anchors.length ?? 0, {
timeout: 180_000
}).toBe(3)
const state = await getApiState(page)
expect(state).not.toBeNull()
const lMinus2 = resolveRequiredFloorId(state!.floors, 'L-2')
const l1 = resolveRequiredFloorId(state!.floors, 'L1')
const l1Point5 = resolveRequiredFloorId(state!.floors, 'L1.5')
const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1')
const l4 = resolveRequiredFloorId(state!.floors, 'L4')
const l5 = resolveRequiredFloorId(state!.floors, 'L5')
await switchFloor(page, testInfo, l1, 'exterior-l1')
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1')
await switchFloor(page, testInfo, l4, 'lminus1-l4')
await switchFloor(page, testInfo, l1, 'l4-l1')
await showOverview(page, testInfo, 'l1-exterior')
await switchFloor(page, testInfo, lMinus2, 'exterior-lminus2')
await switchFloor(page, testInfo, l5, 'lminus2-l5')
await switchFloor(page, testInfo, lMinus2, 'l5-lminus2')
await showOverview(page, testInfo, 'lminus2-exterior')
await switchFloor(page, testInfo, l1Point5, 'exterior-l1point5')
await showOverview(page, testInfo, 'l1point5-exterior')
await switchFloor(page, testInfo, l1, 'exterior-l1-auto-exit')
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-auto-exit')
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-button-exit', 'button')
await switchFloor(page, testInfo, l1, 'exterior-l1-wheel-exit')
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-wheel-exit')
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-wheel-exit', 'wheel')
await switchFloor(page, testInfo, l1, 'exterior-l1-touch-exit')
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-touch-exit')
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-touch-exit', 'touch')
})
test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and does not rebound', async ({ page }, testInfo) => {
await page.goto('/')
await Promise.all([
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
page.getByText('馆内', { exact: true }).click()
])
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
const state = await getApiState(page)
expect(state).not.toBeNull()
const l1 = resolveRequiredFloorId(state!.floors, 'L1')
const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1')
const [targetPoi] = await getFloorPois(page, lMinus1)
await switchFloor(page, testInfo, l1, 'search-focus-exterior-l1')
const beforeFocus = await getReport(page)
await screenshot(page, testInfo, 'search-focus-before')
const focusTransition = page.evaluate(async (request) => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
await api.focusTargetPoi(request)
}, {
requestId: 'e2e-cross-floor-search-focus',
poiId: targetPoi.poiId,
floorId: targetPoi.floorId,
name: targetPoi.name,
positionGltf: targetPoi.positionGltf
})
let focusRequestCompleted = false
void focusTransition.then(() => { focusRequestCompleted = true })
for (let frame = 0; !focusRequestCompleted && frame < 180; frame += 1) {
const report = await getReport(page)
expect(report.modelRoot, `blank cross-floor POI load frame ${frame}`).not.toBeNull()
expect(report.modelRoot!.maxError).toBeLessThan(1e-12)
await page.waitForTimeout(40)
}
expect(focusRequestCompleted, 'cross-floor POI focus exceeded the sampling window').toBe(true)
await focusTransition
const afterFocus = await waitForPoiFocusToSettle(page, testInfo, 'search-focus')
await screenshot(page, testInfo, 'search-focus-after')
expect(afterFocus.activeView).toBe('floor')
expect(afterFocus.floorId).toBe(lMinus1)
expect(afterFocus.focus).not.toBeNull()
expect(afterFocus.focus!.poiId).toBe(targetPoi.poiId)
expect(afterFocus.focus!.positionGltf).toEqual(targetPoi.positionGltf)
expect(getVectorDelta(afterFocus.camera.target, targetPoi.positionGltf)).toBeLessThan(1e-6)
expect(afterFocus.focus!.markerPosition).not.toBeNull()
expect(getVectorDelta(afterFocus.focus!.markerPosition!, targetPoi.positionGltf)).toBeLessThan(1e-6)
expect(afterFocus.cameraSnapshotRestoreCount - beforeFocus.cameraSnapshotRestoreCount).toBe(1)
expect(afterFocus.poiFocusCameraAnimationCount - beforeFocus.poiFocusCameraAnimationCount).toBe(1)
await page.waitForTimeout(240)
const afterIdle = await getReport(page)
expect(afterIdle.isCameraTweening).toBe(false)
expect(afterIdle.poiFocusCameraAnimationCount).toBe(afterFocus.poiFocusCameraAnimationCount)
expect(getMaximumCameraDelta(afterFocus.camera, afterIdle.camera)).toBeLessThan(1e-6)
await page.evaluate(() => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
api.clearTargetFocus()
})
await switchFloor(page, testInfo, l1, 'search-focus-clear-lminus1-l1')
await showOverview(page, testInfo, 'search-focus-clear-l1-exterior')
})