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 kind?: string primaryCategory: string positionGltf: [number, number, number] } interface VisiblePoiScreenPosition { poiId: string floorId: string name: string kind?: string primaryCategory: string screen: { x: number; y: number } } interface VisualStabilityApi { getReport: () => VisualReport isInitialModelReady: () => boolean getFloors: () => FloorReport[] switchFloor: (floorId: string) => Promise showOverview: () => Promise resetToViewBaseline: (options: { view: 'overview' | 'floor' floorId?: string reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' }) => Promise<'applied' | 'not-ready' | 'invalid-target' | 'failed' | 'stale'> resetToInitialState: () => Promise<'applied' | 'not-ready' | 'invalid-target' | 'failed' | 'stale'> getFloorBaseline: (floorId: string) => CameraReport | null getFloorPois: (floorId: string) => Promise getVisiblePoiScreenPositions: () => VisiblePoiScreenPosition[] 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 FLOOR_CAMERA_BASELINE = { position: [315.2911, 385.8819, 234.6114] as const, target: [19.7236, -65.382, 42.1148] as const, zoom: 1, distance: 572.7601 } const assertFloorCameraBaseline = (camera: CameraReport) => { expect(getVectorDelta(camera.position, FLOOR_CAMERA_BASELINE.position)).toBeLessThan(1e-4) expect(getVectorDelta(camera.target, FLOOR_CAMERA_BASELINE.target)).toBeLessThan(1e-4) expect(camera.zoom).toBe(FLOOR_CAMERA_BASELINE.zoom) expect(Math.abs(camera.distance - FLOOR_CAMERA_BASELINE.distance)).toBeLessThan(1e-3) } const openGuide = async (page: Page) => { await page.goto('/') await page.waitForURL(/tab=guide/, { timeout: 30_000 }) 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) await expect.poll(async () => page.evaluate(() => ( (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi }) .__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false )), { timeout: 180_000 }).toBe(true) await expect.poll(async () => (await getApiState(page))?.report.modelRoot !== null, { timeout: 180_000 }).toBe(true) await expect.poll(async () => (await getApiState(page))?.report.anchors.length ?? 0, { timeout: 180_000 }).toBe(3) } 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 getVisiblePoiScreenPositions = async (page: Page) => 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') return api.getVisiblePoiScreenPositions() }) 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 openGuide(page) 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 openGuide(page) 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 openGuide(page) 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 openGuide(page) 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() assertFloorCameraBaseline(baseline!) assertFloorCameraBaseline(first.camera) expect(first.modelRoot!.maxError).toBeLessThan(1e-12) 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) assertFloorCameraBaseline(second.camera) expect(getMaximumCameraDelta(first.camera, second.camera)).toBeLessThan(1e-6) expect(getMaximumCameraDelta(baseline!, second.camera)).toBeLessThan(1e-6) } }) test.describe('mobile POI card close restores the floor baseline', () => { test.use({ viewport: { width: 390, height: 844 } }) test('real canvas clicks on a facility and hall close back to their floor baselines', async ({ page }) => { await openGuide(page) const state = await getApiState(page) expect(state).not.toBeNull() const floorId = state!.report.floorId await page.evaluate(async (targetFloorId) => { 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: targetFloorId, reason: 'floor-reset' }) }, floorId) await expect.poll(async () => getVisiblePoiScreenPositions(page), { timeout: 30_000 }) .not.toHaveLength(0) const visibleMarkers = await getVisiblePoiScreenPositions(page) const facility = visibleMarkers.find((poi) => ( poi.kind !== 'hall' && !poi.primaryCategory.startsWith('exhibition_hall') )) const hall = visibleMarkers.find((poi) => ( poi.kind === 'hall' || poi.primaryCategory.startsWith('exhibition_hall') )) expect(facility, 'expected a visible facility POI from the active guide data').toBeDefined() expect(hall, 'expected a visible hall POI from the active guide data').toBeDefined() for (const [kind, marker] of [['facility', facility!], ['hall', hall!]] as const) { const baseline = await page.evaluate((floorId) => { const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi }) .__GUIDE_3D_VISUAL_STABILITY__ return api?.getFloorBaseline(floorId) || null }, marker.floorId) expect(baseline).not.toBeNull() let modelRequestsAfterBaseline = 0 page.on('request', (request) => { if (/\.(glb|gltf)(?:$|\?)/i.test(request.url())) modelRequestsAfterBaseline += 1 }) await page.mouse.click(marker.screen.x, marker.screen.y) await expect.poll(async () => (await getReport(page)).activeFocusPoiId, { timeout: 10_000 }).toBe(marker.poiId) await expect(page.locator('.guide-poi-card')).toBeVisible() if (kind === 'facility') { await page.locator('.poi-card-collapsed').click() } await page.locator('.poi-card-close').click() await expect(page.locator('.guide-poi-card')).toBeHidden() await expect.poll(async () => (await getReport(page)).activeFocusPoiId, { timeout: 10_000 }).toBe('') await expect.poll(async () => (await getReport(page)).focus, { timeout: 10_000 }).toBeNull() await expect.poll(async () => (await getReport(page)).hasPendingTargetFocus, { timeout: 10_000 }).toBe(false) await expect.poll(async () => (await getReport(page)).isCameraTweening, { timeout: 10_000 }).toBe(false) const afterClose = await getReport(page) expect(afterClose.activeView).toBe('floor') expect(afterClose.floorId).toBe(marker.floorId) expect(getMaximumCameraDelta(baseline!, afterClose.camera)).toBeLessThan(1e-6) expect(modelRequestsAfterBaseline).toBe(0) await page.waitForTimeout(300) const afterIdle = await getReport(page) expect(getMaximumCameraDelta(afterClose.camera, afterIdle.camera)).toBeLessThan(1e-6) } }) })