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 activeFocusPoiId: string hasPendingTargetFocus: boolean 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 showOverview: () => Promise resetToViewBaseline: (options: { view: 'overview' | 'floor' floorId?: string reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' }) => Promise resetToInitialState: () => Promise getFloorBaseline: (floorId: string) => CameraReport | null getFloorPois: (floorId: string) => Promise focusTargetPoi: (request: { requestId: number | string poiId: string floorId: string name?: string positionGltf: [number, number, number] }) => Promise 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, 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') }) test('reset invalidates an in-flight cross-floor focus before it can overwrite the initial state', async ({ page }) => { 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))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0) const state = await getApiState(page) expect(state).not.toBeNull() const targetFloor = state!.floors.find((floor) => floor.floorId !== state!.report.floorId)?.floorId || state!.floors[0]!.floorId const targetPoi = (await getFloorPois(page, targetFloor))[0]! await 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') const focus = api.focusTargetPoi({ requestId: 'reset-race', ...request }) await api.resetToInitialState() await focus }, targetPoi) const report = await getReport(page) expect(report.activeView).toBe('overview') expect(report.activeFocusPoiId).toBe('') expect(report.hasPendingTargetFocus).toBe(false) expect(report.focus).toBeNull() }) test('floor view baseline is deterministic across repeated resets', async ({ page }) => { 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))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0) const state = await getApiState(page) expect(state).not.toBeNull() for (const floor of state!.floors) { await page.evaluate(async (floorId) => { 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.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' }) }, floor.floorId) const first = await getReport(page) const baseline = await page.evaluate((floorId) => { 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.getFloorBaseline(floorId) }, floor.floorId) expect(baseline, `missing floor baseline ${floor.floorId}`).not.toBeNull() expect(getMaximumCameraDelta(baseline!, first.camera)).toBeLessThan(1e-6) await page.evaluate(async (floorId) => { 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.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' }) }, floor.floorId) const second = await getReport(page) expect(second.activeView).toBe('floor') expect(second.floorId).toBe(floor.floorId) expect(getMaximumCameraDelta(first.camera, second.camera)).toBeLessThan(1e-6) expect(getMaximumCameraDelta(baseline!, second.camera)).toBeLessThan(1e-6) } })