This commit is contained in:
189
tests/e2e/three-map-camera-stability.spec.ts
Normal file
189
tests/e2e/three-map-camera-stability.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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 VisualStabilityApi {
|
||||
getReport: () => VisualReport
|
||||
getFloorIds: () => string[]
|
||||
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
|
||||
? { floorIds: api.getFloorIds(), 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)
|
||||
}
|
||||
|
||||
test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => {
|
||||
await page.goto('/')
|
||||
await 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?.floorIds).toEqual(expect.arrayContaining(['L-2', 'L1', 'L1.5', 'L2', 'L3', '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, 'L-2', 'exterior-lminus2')
|
||||
await switchFloor(page, testInfo, 'L5', 'lminus2-l5')
|
||||
await switchFloor(page, testInfo, 'L-2', 'l5-lminus2')
|
||||
await showOverview(page, testInfo, 'lminus2-exterior')
|
||||
|
||||
await switchFloor(page, testInfo, 'L1.5', 'exterior-l1point5')
|
||||
await showOverview(page, testInfo, 'l1point5-exterior')
|
||||
})
|
||||
Reference in New Issue
Block a user