Files
frontend-miniapp/tests/e2e/three-map-camera-stability.spec.ts
lyf f2ee41fe44
Some checks failed
CI / verify (push) Has been cancelled
修复楼层切换后无法返回建筑外观
2026-07-13 19:51:58 +08:00

236 lines
8.3 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
}
interface FloorReport {
floorId: string
label: string
modelMatchKeys: string[]
}
interface VisualStabilityApi {
getReport: () => VisualReport
getFloors: () => FloorReport[]
switchFloor: (floorId: string) => Promise<void>
showOverview: () => Promise<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 exitOverviewWithZoomControl = async (page: Page, testInfo: TestInfo, name: string) => {
const before = await getReport(page)
await screenshot(page, testInfo, `${name}-before`)
await page.getByText('', { exact: true }).click()
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
}
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 l2 = resolveRequiredFloorId(state!.floors, 'L2')
const l3 = resolveRequiredFloorId(state!.floors, 'L3')
const l5 = resolveRequiredFloorId(state!.floors, 'L5')
await switchFloor(page, testInfo, l1, 'exterior-l1')
await switchFloor(page, testInfo, l2, 'l1-l2')
await switchFloor(page, testInfo, l3, 'l2-l3')
await switchFloor(page, testInfo, l1, 'l3-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, l2, 'l1-l2-auto-exit')
await exitOverviewWithZoomControl(page, testInfo, 'l2-exterior-auto-exit')
})