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 showOverview: () => Promise } 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, 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 } 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 exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-button-exit', 'button') await switchFloor(page, testInfo, l1, 'exterior-l1-wheel-exit') await switchFloor(page, testInfo, l2, 'l1-l2-wheel-exit') await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-wheel-exit', 'wheel') await switchFloor(page, testInfo, l1, 'exterior-l1-touch-exit') await switchFloor(page, testInfo, l2, 'l1-l2-touch-exit') await exitOverviewWithZoomInput(page, testInfo, 'l2-exterior-touch-exit', 'touch') })