1256 lines
53 KiB
TypeScript
1256 lines
53 KiB
TypeScript
import { expect, test, type Page, type Route, type TestInfo } from '@playwright/test'
|
|
|
|
interface ToggleCameraReport {
|
|
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 ExteriorLabelPosition {
|
|
label: string
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
interface ThreeDimensionalSceneState {
|
|
camera: ToggleCameraReport
|
|
labels: ExteriorLabelPosition[]
|
|
}
|
|
|
|
interface TwoDimensionalViewportState {
|
|
scene: 'overview' | 'floor'
|
|
floorId: string
|
|
centerX: number
|
|
centerZ: number
|
|
visibleWorldSpan: number
|
|
referenceVisibleWorldSpan: number
|
|
visibilityTier: 'tight' | 'balanced' | 'full'
|
|
revision: number
|
|
}
|
|
|
|
interface WeakMapDensityState {
|
|
tier: 'tight' | 'balanced' | 'full'
|
|
markerCount: number
|
|
labelCount: number
|
|
facilityMarkerCount: number
|
|
overlappingLabels: string[]
|
|
}
|
|
|
|
interface LiveGlbDiagnosticsReport {
|
|
camera: ToggleCameraReport
|
|
actualRendererPath: 'three-d' | 'live-glb-top' | 'webp-fallback'
|
|
renderLoopRunning: boolean
|
|
sceneUuid: string | null
|
|
activeModelUuid: string | null
|
|
controls: {
|
|
enableRotate: boolean
|
|
enablePan: boolean
|
|
enableZoom: boolean
|
|
} | null
|
|
activeView: 'overview' | 'floor' | 'multi'
|
|
floorId: string
|
|
isCameraTweening: boolean
|
|
}
|
|
|
|
interface LiveGlbDiagnostics {
|
|
isInitialModelReady: () => boolean
|
|
getReport: () => LiveGlbDiagnosticsReport
|
|
getFloors: () => Array<{ floorId: string; label: string }>
|
|
getFloorBaseline: (floorId: string) => ToggleCameraReport | null
|
|
getAmbientPoiLabelStates: () => Array<{
|
|
poiId: string
|
|
floorId: string
|
|
text: string
|
|
visible: boolean
|
|
}>
|
|
switchFloor: (floorId: string) => Promise<void>
|
|
}
|
|
|
|
interface WeakCompositionFloor {
|
|
floorId: string
|
|
floorCode: string
|
|
projection: {
|
|
screenRight: { x: number; z: number }
|
|
}
|
|
}
|
|
|
|
const EXPECTED_EXTERIOR_LABELS = [
|
|
'停车场入口',
|
|
'停车场出口',
|
|
'停车场',
|
|
'室外下沉广场',
|
|
'红花潭路',
|
|
'文祥路',
|
|
'大巴区'
|
|
].sort()
|
|
|
|
const waitForThreeDimensionalScene = async (page: Page, expectOverviewLabels = true) => {
|
|
await expect(page.locator('.three-canvas-wrapper canvas')).toBeVisible({ timeout: 120_000 })
|
|
await expect.poll(async () => page.evaluate(() => (
|
|
(window as Window & {
|
|
__GUIDE_3D_VISUAL_STABILITY__?: { isInitialModelReady: () => boolean }
|
|
}).__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
|
)), { timeout: 120_000 }).toBe(true)
|
|
if (expectOverviewLabels) {
|
|
await expect(page.locator('.three-poi-dom-label--overview').first()).toBeAttached({ timeout: 60_000 })
|
|
}
|
|
}
|
|
|
|
const getThreeDimensionalSceneState = async (page: Page): Promise<ThreeDimensionalSceneState> => page.evaluate(() => {
|
|
const api = (window as Window & {
|
|
__GUIDE_3D_VISUAL_STABILITY__?: { getReport: () => { camera: ToggleCameraReport } }
|
|
}).__GUIDE_3D_VISUAL_STABILITY__
|
|
if (!api) throw new Error('Three-dimensional guide diagnostics unavailable')
|
|
|
|
return {
|
|
camera: api.getReport().camera,
|
|
labels: [...document.querySelectorAll<HTMLElement>('.three-poi-dom-label--overview')]
|
|
.map((element) => {
|
|
const bounds = element.getBoundingClientRect()
|
|
return {
|
|
label: element.querySelector('.three-poi-dom-label__title')?.textContent?.trim() || '',
|
|
x: bounds.left + bounds.width / 2,
|
|
y: bounds.bottom
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
const getCameraDelta = (before: ToggleCameraReport, after: ToggleCameraReport) => 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.abs(before.distance - after.distance)
|
|
)
|
|
|
|
const getFullCameraDelta = (before: ToggleCameraReport, after: ToggleCameraReport) => Math.max(
|
|
getCameraDelta(before, after),
|
|
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)
|
|
)
|
|
|
|
const getLiveGlbState = async (page: Page) => page.evaluate(() => {
|
|
const diagnostics = (window as Window & {
|
|
__GUIDE_3D_VISUAL_STABILITY__?: LiveGlbDiagnostics
|
|
__LIVE_GLB_TOP_TEST_CANVAS__?: HTMLCanvasElement
|
|
}).__GUIDE_3D_VISUAL_STABILITY__
|
|
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
|
const canvas = document.querySelector<HTMLCanvasElement>('.three-canvas-wrapper canvas')
|
|
if (!canvas) throw new Error('ThreeMap canvas unavailable')
|
|
const diagnosticsWindow = window as Window & {
|
|
__LIVE_GLB_TOP_TEST_CANVAS__?: HTMLCanvasElement
|
|
}
|
|
diagnosticsWindow.__LIVE_GLB_TOP_TEST_CANVAS__ ||= canvas
|
|
return {
|
|
report: diagnostics.getReport(),
|
|
floors: diagnostics.getFloors(),
|
|
labels: diagnostics.getAmbientPoiLabelStates(),
|
|
sameCanvas: diagnosticsWindow.__LIVE_GLB_TOP_TEST_CANVAS__ === canvas
|
|
}
|
|
})
|
|
|
|
const waitForLiveGlbGuide = async (page: Page) => {
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await page.goto('/?guide-render=3d#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/guide-render=3d.*tab=guide/, { timeout: 30_000 })
|
|
await expect.poll(async () => page.evaluate(() => (
|
|
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: LiveGlbDiagnostics })
|
|
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
|
)), { timeout: 180_000 }).toBe(true)
|
|
await expect(page.locator('.three-canvas-wrapper canvas')).toBeVisible({ timeout: 30_000 })
|
|
}
|
|
|
|
const switchLiveGlbFloor = async (page: Page, label: '1F' | '3F') => {
|
|
const floorId = await page.evaluate(async (floorLabel) => {
|
|
const diagnostics = (window as Window & {
|
|
__GUIDE_3D_VISUAL_STABILITY__?: LiveGlbDiagnostics
|
|
}).__GUIDE_3D_VISUAL_STABILITY__
|
|
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
|
const floor = diagnostics.getFloors().find((candidate) => candidate.label === floorLabel)
|
|
if (!floor) throw new Error(`${floorLabel} is unavailable`)
|
|
await diagnostics.switchFloor(floor.floorId)
|
|
return floor.floorId
|
|
}, label)
|
|
await expect.poll(async () => {
|
|
const { report } = await getLiveGlbState(page)
|
|
return `${report.activeView}:${report.floorId}:${report.isCameraTweening}`
|
|
}, { timeout: 180_000 }).toBe(`floor:${floorId}:false`)
|
|
return floorId
|
|
}
|
|
|
|
const expectLabelsOnlyFromFloor = (state: Awaited<ReturnType<typeof getLiveGlbState>>, floorId: string) => {
|
|
expect(state.labels.length).toBeGreaterThan(0)
|
|
expect(new Set(state.labels.map((label) => label.floorId))).toEqual(new Set([floorId]))
|
|
expect(state.labels.some((label) => label.visible && Boolean(label.text.trim()))).toBe(true)
|
|
}
|
|
|
|
const waitForLabelsOnlyFromFloor = async (page: Page, floorId: string) => {
|
|
await expect.poll(async () => {
|
|
const state = await getLiveGlbState(page)
|
|
return (
|
|
state.labels.length > 0
|
|
&& state.labels.every((label) => label.floorId === floorId)
|
|
&& state.labels.some((label) => label.visible && Boolean(label.text.trim()))
|
|
)
|
|
}, { timeout: 60_000 }).toBe(true)
|
|
|
|
return getLiveGlbState(page)
|
|
}
|
|
|
|
const dragLiveGlbCanvas = async (page: Page, deltaX = 48, deltaY = 32) => {
|
|
const canvas = page.locator('.three-canvas-wrapper canvas')
|
|
const bounds = await canvas.boundingBox()
|
|
expect(bounds, 'live GLB canvas must be available for panning').not.toBeNull()
|
|
const x = bounds!.x + bounds!.width * 0.45
|
|
const y = bounds!.y + bounds!.height * 0.45
|
|
await page.mouse.move(x, y)
|
|
await page.mouse.down()
|
|
await page.mouse.move(x + deltaX, y + deltaY, { steps: 8 })
|
|
await page.mouse.up()
|
|
}
|
|
|
|
const getLiveTopScreenRight = (camera: ToggleCameraReport) => {
|
|
const { x, y, z, w } = camera.quaternion
|
|
const right = {
|
|
x: 1 - 2 * (y * y + z * z),
|
|
z: 2 * (x * z - w * y)
|
|
}
|
|
const length = Math.hypot(right.x, right.z)
|
|
return { x: right.x / length, z: right.z / length }
|
|
}
|
|
|
|
const getWeakCompositionMetrics = (options: {
|
|
live: LiveGlbDiagnosticsReport
|
|
weak: TwoDimensionalViewportState
|
|
floor: WeakCompositionFloor
|
|
width: number
|
|
height: number
|
|
liveAspect: number
|
|
}) => {
|
|
const liveRight = getLiveTopScreenRight(options.live.camera)
|
|
const weakRight = options.floor.projection.screenRight
|
|
const dot = Math.max(-1, Math.min(1, liveRight.x * weakRight.x + liveRight.z * weakRight.z))
|
|
const directionDegrees = Math.acos(dot) * 180 / Math.PI
|
|
const deltaX = options.live.camera.target.x - options.weak.centerX
|
|
const deltaZ = options.live.camera.target.z - options.weak.centerZ
|
|
const weakDown = { x: -weakRight.z, z: weakRight.x }
|
|
const centerOffsetPixels = Math.hypot(
|
|
(deltaX * weakRight.x + deltaZ * weakRight.z) / options.weak.visibleWorldSpan * options.width,
|
|
(deltaX * weakDown.x + deltaZ * weakDown.z) / options.weak.visibleWorldSpan * options.width
|
|
)
|
|
const liveVisibleWorldSpan = 2
|
|
* options.live.camera.distance
|
|
* Math.tan(options.live.camera.fov * Math.PI / 360)
|
|
* options.liveAspect
|
|
return {
|
|
centerOffsetPixels,
|
|
centerOffsetRatio: centerOffsetPixels / Math.min(options.width, options.height),
|
|
directionDegrees,
|
|
representativeScaleRatio: liveVisibleWorldSpan / options.weak.visibleWorldSpan
|
|
}
|
|
}
|
|
|
|
const expectSameThreeDimensionalLabelLayout = (
|
|
expected: ExteriorLabelPosition[],
|
|
actual: ExteriorLabelPosition[]
|
|
) => {
|
|
const expectedByLabel = new Map(expected.map((label) => [label.label, label]))
|
|
const actualByLabel = new Map(actual.map((label) => [label.label, label]))
|
|
expect([...actualByLabel.keys()].sort()).toEqual([...expectedByLabel.keys()].sort())
|
|
|
|
for (const [label, expectedPosition] of expectedByLabel) {
|
|
const actualPosition = actualByLabel.get(label)
|
|
expect(actualPosition, `${label} must exist in both render modes`).toBeDefined()
|
|
expect(Math.hypot(
|
|
expectedPosition.x - actualPosition!.x,
|
|
expectedPosition.y - actualPosition!.y
|
|
), `${label} anchor moved after restoring 3D`).toBeLessThan(2.5)
|
|
}
|
|
}
|
|
|
|
const getTwoDimensionalViewport = async (page: Page): Promise<TwoDimensionalViewportState> => (
|
|
page.getByTestId('weak-network-map').evaluate((element) => {
|
|
const value = (name: string) => Number(element.getAttribute(name))
|
|
return {
|
|
scene: element.getAttribute('data-viewport-scene') as 'overview' | 'floor',
|
|
floorId: element.getAttribute('data-viewport-floor-id') || '',
|
|
centerX: value('data-viewport-center-x'),
|
|
centerZ: value('data-viewport-center-z'),
|
|
visibleWorldSpan: value('data-viewport-visible-world-span'),
|
|
referenceVisibleWorldSpan: value('data-reference-visible-world-span'),
|
|
visibilityTier: element.getAttribute('data-visibility-tier') as 'tight' | 'balanced' | 'full',
|
|
revision: value('data-viewport-revision')
|
|
}
|
|
})
|
|
)
|
|
|
|
const expectValidTwoDimensionalViewport = (viewport: TwoDimensionalViewportState) => {
|
|
expect(['overview', 'floor']).toContain(viewport.scene)
|
|
expect(Number.isFinite(viewport.centerX)).toBe(true)
|
|
expect(Number.isFinite(viewport.centerZ)).toBe(true)
|
|
expect(Number.isFinite(viewport.visibleWorldSpan)).toBe(true)
|
|
expect(viewport.visibleWorldSpan).toBeGreaterThan(0)
|
|
expect(viewport.referenceVisibleWorldSpan).toBeGreaterThan(0)
|
|
expect(['tight', 'balanced', 'full']).toContain(viewport.visibilityTier)
|
|
}
|
|
|
|
const getWeakMapDensityState = async (page: Page): Promise<WeakMapDensityState> => page.evaluate(() => {
|
|
const map = document.querySelector<HTMLElement>('[data-testid="weak-network-map"]')
|
|
if (!map) throw new Error('Weak network map is unavailable')
|
|
const markers = [...document.querySelectorAll<HTMLElement>('.weak-network-marker')]
|
|
const labels = [...document.querySelectorAll<HTMLElement>('.weak-network-marker-label')]
|
|
const facilityIcons = new Set(['restroom', 'elevator', 'escalator', 'stairs'])
|
|
const overlappingLabels: string[] = []
|
|
|
|
for (let leftIndex = 0; leftIndex < labels.length; leftIndex += 1) {
|
|
const left = labels[leftIndex].getBoundingClientRect()
|
|
for (let rightIndex = leftIndex + 1; rightIndex < labels.length; rightIndex += 1) {
|
|
const right = labels[rightIndex].getBoundingClientRect()
|
|
if (
|
|
left.left < right.right - 1
|
|
&& left.right > right.left + 1
|
|
&& left.top < right.bottom - 1
|
|
&& left.bottom > right.top + 1
|
|
) {
|
|
overlappingLabels.push(
|
|
`${labels[leftIndex].textContent?.trim() || leftIndex}/${labels[rightIndex].textContent?.trim() || rightIndex}`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
tier: map.dataset.visibilityTier as 'tight' | 'balanced' | 'full',
|
|
markerCount: markers.length,
|
|
labelCount: labels.length,
|
|
facilityMarkerCount: markers.filter((marker) => facilityIcons.has(marker.dataset.poiIcon || '')).length,
|
|
overlappingLabels
|
|
}
|
|
})
|
|
|
|
const expectMarkersInsideBasemap = async (page: Page) => {
|
|
const result = await page.evaluate(() => {
|
|
const image = document.querySelector<HTMLElement>('[data-testid="weak-network-basemap"]')
|
|
if (!image) return { outside: ['missing-basemap'], markerCount: 0 }
|
|
const imageBounds = image.getBoundingClientRect()
|
|
const markers = [...document.querySelectorAll<HTMLElement>('.weak-network-marker')]
|
|
const outside = markers
|
|
.filter((marker) => {
|
|
const bounds = marker.getBoundingClientRect()
|
|
const x = bounds.left + bounds.width / 2
|
|
const y = bounds.top + bounds.height / 2
|
|
return x < imageBounds.left - 1
|
|
|| x > imageBounds.right + 1
|
|
|| y < imageBounds.top - 1
|
|
|| y > imageBounds.bottom + 1
|
|
})
|
|
.map((marker) => marker.dataset.label || marker.dataset.poiId || 'unknown')
|
|
return { outside, markerCount: markers.length }
|
|
})
|
|
expect(result.markerCount).toBeGreaterThan(0)
|
|
expect(result.outside).toEqual([])
|
|
}
|
|
|
|
const screenshot = async (page: Page, testInfo: TestInfo, name: string) => {
|
|
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) })
|
|
}
|
|
|
|
const clearGuideModelCacheForTest = async (page: Page) => {
|
|
const clearPath = `/__guide-cache-clear-${Date.now()}`
|
|
await page.route(`**${clearPath}`, async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/html',
|
|
body: '<!doctype html><title>clear guide cache</title>'
|
|
})
|
|
})
|
|
await page.goto(clearPath)
|
|
await page.evaluate(async () => {
|
|
localStorage.removeItem('sgs.guide.model-cache-index.v1')
|
|
if ('caches' in window) {
|
|
await caches.delete('sgs-guide-model-v1')
|
|
}
|
|
})
|
|
await page.unroute(`**${clearPath}`)
|
|
}
|
|
|
|
const clickGuideZoom = async (page: Page, direction: 'in' | 'out') => {
|
|
await page.getByTestId(direction === 'in' ? 'guide-zoom-in' : 'guide-zoom-out').click()
|
|
await page.waitForTimeout(130)
|
|
return getTwoDimensionalViewport(page)
|
|
}
|
|
|
|
const reachTwoDimensionalBoundary = async (
|
|
page: Page,
|
|
direction: 'in' | 'out',
|
|
expectedScene: 'overview' | 'floor'
|
|
) => {
|
|
const start = await getTwoDimensionalViewport(page)
|
|
let changedSteps = 0
|
|
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
const before = await getTwoDimensionalViewport(page)
|
|
const after = await clickGuideZoom(page, direction)
|
|
expect(after.scene).toBe(expectedScene)
|
|
if (Math.abs(after.visibleWorldSpan - before.visibleWorldSpan) < 1e-9) {
|
|
return { start, boundary: after, changedSteps }
|
|
}
|
|
changedSteps += 1
|
|
}
|
|
|
|
throw new Error(`2D ${expectedScene} viewport did not reach its ${direction} boundary`)
|
|
}
|
|
|
|
const enterDefaultFloorFromOverview = async (page: Page) => {
|
|
const overview = await getTwoDimensionalViewport(page)
|
|
expect(overview.scene).toBe('overview')
|
|
expect(overview.visibleWorldSpan / 1.25).toBeLessThanOrEqual(
|
|
overview.referenceVisibleWorldSpan * 0.85
|
|
)
|
|
await clickGuideZoom(page, 'in')
|
|
await expect.poll(async () => (await getTwoDimensionalViewport(page)).scene, {
|
|
timeout: 60_000
|
|
}).toBe('floor')
|
|
await expect(page.locator('.floor-switcher')).toBeVisible({ timeout: 30_000 })
|
|
return getTwoDimensionalViewport(page)
|
|
}
|
|
|
|
const dragTwoDimensionalMap = async (page: Page, deltaX = 42, deltaY = 28) => {
|
|
const surfaceBox = await page.getByTestId('weak-network-surface').boundingBox()
|
|
expect(surfaceBox).not.toBeNull()
|
|
if (!surfaceBox) return getTwoDimensionalViewport(page)
|
|
|
|
const startX = surfaceBox.x + surfaceBox.width * 0.25
|
|
const startY = surfaceBox.y + surfaceBox.height * 0.35
|
|
await page.mouse.move(startX, startY)
|
|
await page.mouse.down()
|
|
await page.mouse.move(
|
|
startX + deltaX,
|
|
startY + deltaY,
|
|
{ steps: 3 }
|
|
)
|
|
await page.mouse.up()
|
|
await page.waitForTimeout(100)
|
|
return getTwoDimensionalViewport(page)
|
|
}
|
|
|
|
const selectFloor = async (page: Page, label: '1F' | '2F' | '3F') => {
|
|
const floor = page.locator('.floor-item').filter({ hasText: label }).first()
|
|
await expect(floor).toBeVisible({ timeout: 30_000 })
|
|
await floor.click()
|
|
await expect(floor).toHaveClass(/active/, { timeout: 60_000 })
|
|
await expect(page.getByTestId('weak-network-basemap').locator('img')).toHaveAttribute(
|
|
'src',
|
|
new RegExp(`/${label.replace('F', '') === '1' ? 'L1' : `L${label.replace('F', '')}`}\\.[^/]+\\.ortho-yaw54-v2\\.compact-floor-center-v1\\.webp$`, 'i'),
|
|
{ timeout: 60_000 }
|
|
)
|
|
const viewport = await getTwoDimensionalViewport(page)
|
|
expect(viewport.scene).toBe('floor')
|
|
expect(viewport.floorId).not.toBe('')
|
|
const markerFloorIds = await page.locator('.weak-network-marker').evaluateAll((markers) => (
|
|
markers.map((marker) => marker.getAttribute('data-floor-id'))
|
|
))
|
|
expect(markerFloorIds.length).toBeGreaterThan(0)
|
|
expect(new Set(markerFloorIds)).toEqual(new Set([viewport.floorId]))
|
|
await expectMarkersInsideBasemap(page)
|
|
return viewport
|
|
}
|
|
|
|
const captureFloorScreenshots = async (
|
|
page: Page,
|
|
testInfo: TestInfo,
|
|
viewport: { width: number; height: number },
|
|
prefix: string
|
|
) => {
|
|
await page.setViewportSize(viewport)
|
|
await page.goto('/?weak-map-preview=1#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
await enterDefaultFloorFromOverview(page)
|
|
|
|
for (const floor of ['1F', '2F', '3F'] as const) {
|
|
await selectFloor(page, floor)
|
|
await screenshot(page, testInfo, `${prefix}-${floor.toLowerCase()}`)
|
|
}
|
|
}
|
|
|
|
const verifyExteriorRenderModeToggle = async (
|
|
page: Page,
|
|
testInfo: TestInfo,
|
|
viewport: { width: number; height: number },
|
|
artifactPrefix: string
|
|
) => {
|
|
await page.setViewportSize(viewport)
|
|
await page.goto('/')
|
|
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('3D')
|
|
await waitForThreeDimensionalScene(page)
|
|
const before = await getThreeDimensionalSceneState(page)
|
|
expect(before.labels.map((label) => label.label).sort()).toEqual(EXPECTED_EXTERIOR_LABELS)
|
|
await screenshot(page, testInfo, `${artifactPrefix}-3d-before`)
|
|
|
|
await page.getByTestId('indoor-render-mode-toggle').click()
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('2D')
|
|
await expect(page.getByTestId('weak-network-map')).toHaveCount(0)
|
|
await expect(page.locator('.three-canvas-wrapper canvas')).toBeVisible()
|
|
await expect(page.locator('.three-canvas-wrapper')).toHaveAttribute('data-actual-renderer', 'live-glb-top')
|
|
await expect(page.locator('.three-canvas-wrapper')).toHaveAttribute('data-render-loop-state', 'active-3d')
|
|
const topView = await getThreeDimensionalSceneState(page)
|
|
expect(topView.labels.map((label) => label.label).sort()).toEqual(EXPECTED_EXTERIOR_LABELS)
|
|
await screenshot(page, testInfo, `${artifactPrefix}-2d`)
|
|
|
|
await page.waitForTimeout(120)
|
|
await page.getByTestId('indoor-render-mode-toggle').click()
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('3D')
|
|
await waitForThreeDimensionalScene(page)
|
|
await expect(page.getByTestId('weak-network-map')).toHaveCount(0)
|
|
await expect(page.locator('.three-poi-dom-label--overview:visible').first()).toBeVisible({ timeout: 60_000 })
|
|
const after = await getThreeDimensionalSceneState(page)
|
|
expect(getCameraDelta(before.camera, after.camera)).toBeLessThan(1e-6)
|
|
expectSameThreeDimensionalLabelLayout(before.labels, after.labels)
|
|
await screenshot(page, testInfo, `${artifactPrefix}-3d-after`)
|
|
}
|
|
|
|
test('opens the developer two-dimensional basemap preview directly', async ({ page }) => {
|
|
await page.goto('/?weak-map-preview=1#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-map')).toBeVisible({ timeout: 60_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 30_000 })
|
|
await expect(page.locator('.app-launch-overlay')).toHaveCount(0, { timeout: 30_000 })
|
|
})
|
|
|
|
test('chooses the two-dimensional guide before requesting GLB assets on a constrained network', async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(Navigator.prototype, 'connection', {
|
|
configurable: true,
|
|
value: { effectiveType: '4g', saveData: false, downlink: 0.8, rtt: 650 }
|
|
})
|
|
})
|
|
|
|
let glbRequested = false
|
|
page.on('request', (request) => {
|
|
if (/\.glb(?:$|\?)/i.test(request.url())) glbRequested = true
|
|
})
|
|
|
|
await page.goto('/')
|
|
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('2D')
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveCount(1)
|
|
expect(glbRequested).toBe(false)
|
|
})
|
|
|
|
test('preserves exterior labels and camera context across a normal 3D/2D toggle', async ({ page }, testInfo) => {
|
|
await verifyExteriorRenderModeToggle(
|
|
page,
|
|
testInfo,
|
|
{ width: 430, height: 932 },
|
|
'exterior-430x932'
|
|
)
|
|
})
|
|
|
|
test('live GLB indoor L1 top view preserves renderer identity and supports zoom and pan', async ({ page }) => {
|
|
await waitForLiveGlbGuide(page)
|
|
const l1FloorId = await switchLiveGlbFloor(page, '1F')
|
|
const threeDimensional = await getLiveGlbState(page)
|
|
expect(threeDimensional.report.actualRendererPath).toBe('three-d')
|
|
expect(threeDimensional.report.sceneUuid).not.toBeNull()
|
|
expect(threeDimensional.report.activeModelUuid).not.toBeNull()
|
|
|
|
await page.getByTestId('indoor-render-mode-toggle').click()
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('2D')
|
|
await expect.poll(async () => (await getLiveGlbState(page)).report.actualRendererPath, {
|
|
timeout: 30_000
|
|
}).toBe('live-glb-top')
|
|
|
|
const topView = await getLiveGlbState(page)
|
|
await expect(page.getByTestId('weak-network-map')).toHaveCount(0)
|
|
expect(topView.sameCanvas).toBe(true)
|
|
expect(topView.report.sceneUuid).toBe(threeDimensional.report.sceneUuid)
|
|
expect(topView.report.activeModelUuid).toBe(threeDimensional.report.activeModelUuid)
|
|
expect(topView.report.renderLoopRunning).toBe(true)
|
|
expect(topView.report.controls).toMatchObject({
|
|
enableRotate: false,
|
|
enablePan: true,
|
|
enableZoom: true
|
|
})
|
|
expect(topView.report.camera.target.x).toBeCloseTo(threeDimensional.report.camera.target.x, 6)
|
|
expect(topView.report.camera.target.z).toBeCloseTo(threeDimensional.report.camera.target.z, 6)
|
|
expect(topView.report.camera.distance).toBeCloseTo(threeDimensional.report.camera.distance, 6)
|
|
expect(Math.hypot(
|
|
topView.report.camera.position.x - topView.report.camera.target.x,
|
|
topView.report.camera.position.z - topView.report.camera.target.z
|
|
)).toBeLessThan(0.01)
|
|
expect(topView.report.camera.position.y).toBeGreaterThan(topView.report.camera.target.y)
|
|
expectLabelsOnlyFromFloor(topView, l1FloorId)
|
|
|
|
await page.getByTestId('guide-zoom-in').click()
|
|
await expect.poll(async () => (await getLiveGlbState(page)).report.camera.distance, {
|
|
timeout: 15_000
|
|
}).toBeLessThan(topView.report.camera.distance - 0.1)
|
|
const zoomed = await getLiveGlbState(page)
|
|
|
|
await dragLiveGlbCanvas(page)
|
|
await expect.poll(async () => {
|
|
const current = (await getLiveGlbState(page)).report.camera.target
|
|
return Math.hypot(
|
|
current.x - zoomed.report.camera.target.x,
|
|
current.z - zoomed.report.camera.target.z
|
|
)
|
|
}, { timeout: 15_000 }).toBeGreaterThan(0.1)
|
|
|
|
const panned = await getLiveGlbState(page)
|
|
expect(panned.report.actualRendererPath).toBe('live-glb-top')
|
|
expect(panned.sameCanvas).toBe(true)
|
|
expectLabelsOnlyFromFloor(panned, l1FloorId)
|
|
})
|
|
|
|
test('live GLB indoor L1 to L3 keeps top view and restores the L3 baseline', async ({ page }, testInfo) => {
|
|
await waitForLiveGlbGuide(page)
|
|
await switchLiveGlbFloor(page, '1F')
|
|
const l1ThreeDimensional = await getLiveGlbState(page)
|
|
|
|
await page.getByTestId('indoor-render-mode-toggle').click()
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('2D')
|
|
await expect.poll(async () => (await getLiveGlbState(page)).report.actualRendererPath, {
|
|
timeout: 30_000
|
|
}).toBe('live-glb-top')
|
|
const l1Top = await getLiveGlbState(page)
|
|
expect(l1Top.sameCanvas).toBe(true)
|
|
expect(l1Top.report.sceneUuid).toBe(l1ThreeDimensional.report.sceneUuid)
|
|
await screenshot(page, testInfo, 'live-glb-indoor-l1-top-390x844')
|
|
|
|
const l3FloorId = await switchLiveGlbFloor(page, '3F')
|
|
await expect.poll(async () => (await getLiveGlbState(page)).report.actualRendererPath, {
|
|
timeout: 30_000
|
|
}).toBe('live-glb-top')
|
|
const l3Top = await getLiveGlbState(page)
|
|
await expect(page.getByTestId('weak-network-map')).toHaveCount(0)
|
|
expect(l3Top.sameCanvas).toBe(true)
|
|
expect(l3Top.report.sceneUuid).toBe(l1ThreeDimensional.report.sceneUuid)
|
|
expect(l3Top.report.activeModelUuid).not.toBe(l1ThreeDimensional.report.activeModelUuid)
|
|
expect(l3Top.report.floorId).toBe(l3FloorId)
|
|
expect(l3Top.report.controls).toMatchObject({
|
|
enableRotate: false,
|
|
enablePan: true,
|
|
enableZoom: true
|
|
})
|
|
expect(Math.hypot(
|
|
l3Top.report.camera.position.x - l3Top.report.camera.target.x,
|
|
l3Top.report.camera.position.z - l3Top.report.camera.target.z
|
|
)).toBeLessThan(0.01)
|
|
const l3TopWithLabels = await waitForLabelsOnlyFromFloor(page, l3FloorId)
|
|
expectLabelsOnlyFromFloor(l3TopWithLabels, l3FloorId)
|
|
await screenshot(page, testInfo, 'live-glb-indoor-l3-top-390x844')
|
|
|
|
const l3Baseline = await page.evaluate((floorId) => {
|
|
const diagnostics = (window as Window & {
|
|
__GUIDE_3D_VISUAL_STABILITY__?: LiveGlbDiagnostics
|
|
}).__GUIDE_3D_VISUAL_STABILITY__
|
|
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
|
return diagnostics.getFloorBaseline(floorId)
|
|
}, l3FloorId)
|
|
expect(l3Baseline, 'L3 baseline must exist before restoring 3D').not.toBeNull()
|
|
|
|
await page.getByTestId('indoor-render-mode-toggle').click()
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('3D')
|
|
await expect.poll(async () => {
|
|
const { report } = await getLiveGlbState(page)
|
|
return `${report.actualRendererPath}:${report.floorId}:${report.isCameraTweening}`
|
|
}, { timeout: 30_000 }).toBe(`three-d:${l3FloorId}:false`)
|
|
|
|
const restored = await getLiveGlbState(page)
|
|
expect(restored.sameCanvas).toBe(true)
|
|
expect(restored.report.sceneUuid).toBe(l1ThreeDimensional.report.sceneUuid)
|
|
expect(restored.report.floorId).toBe(l3FloorId)
|
|
expect(getFullCameraDelta(l3Baseline!, restored.report.camera)).toBeLessThan(1e-6)
|
|
expectLabelsOnlyFromFloor(restored, l3FloorId)
|
|
})
|
|
|
|
test('weak L1 and L3 composition matches the live GLB top view at 390x844', async ({ page }, testInfo) => {
|
|
await waitForLiveGlbGuide(page)
|
|
const l1FloorId = await switchLiveGlbFloor(page, '1F')
|
|
await page.getByTestId('indoor-render-mode-toggle').click()
|
|
await expect.poll(async () => (await getLiveGlbState(page)).report.actualRendererPath, {
|
|
timeout: 30_000
|
|
}).toBe('live-glb-top')
|
|
const l1Live = await getLiveGlbState(page)
|
|
const liveCanvas = await page.locator('.three-canvas-wrapper canvas').boundingBox()
|
|
expect(liveCanvas).not.toBeNull()
|
|
await screenshot(page, testInfo, 'composition-live-l1-390x844')
|
|
|
|
const l3FloorId = await switchLiveGlbFloor(page, '3F')
|
|
await expect.poll(async () => (await getLiveGlbState(page)).report.actualRendererPath, {
|
|
timeout: 30_000
|
|
}).toBe('live-glb-top')
|
|
const l3Live = await getLiveGlbState(page)
|
|
await screenshot(page, testInfo, 'composition-live-l3-390x844')
|
|
|
|
await page.goto('/?weak-map-preview=1#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
await enterDefaultFloorFromOverview(page)
|
|
const l1Weak = await selectFloor(page, '1F')
|
|
const weakSurface = await page.getByTestId('weak-network-surface').boundingBox()
|
|
expect(weakSurface).not.toBeNull()
|
|
await screenshot(page, testInfo, 'composition-weak-l1-390x844')
|
|
|
|
const manifest = await page.evaluate(async () => {
|
|
const response = await fetch('/static/guide-floor-basemaps/manifest.json', { cache: 'no-cache' })
|
|
if (!response.ok) throw new Error(`Basemap manifest failed: ${response.status}`)
|
|
return response.json() as Promise<{ floors: WeakCompositionFloor[] }>
|
|
})
|
|
const l1Manifest = manifest.floors.find((floor) => floor.floorId === l1FloorId)
|
|
expect(l1Manifest, 'L1 weak basemap entry must exist').toBeDefined()
|
|
const l1Metrics = getWeakCompositionMetrics({
|
|
live: l1Live.report,
|
|
weak: l1Weak,
|
|
floor: l1Manifest!,
|
|
width: weakSurface!.width,
|
|
height: weakSurface!.height,
|
|
liveAspect: liveCanvas!.width / liveCanvas!.height
|
|
})
|
|
|
|
const l3Weak = await selectFloor(page, '3F')
|
|
await screenshot(page, testInfo, 'composition-weak-l3-390x844')
|
|
const l3Manifest = manifest.floors.find((floor) => floor.floorId === l3FloorId)
|
|
expect(l3Manifest, 'L3 weak basemap entry must exist').toBeDefined()
|
|
const l3Metrics = getWeakCompositionMetrics({
|
|
live: l3Live.report,
|
|
weak: l3Weak,
|
|
floor: l3Manifest!,
|
|
width: weakSurface!.width,
|
|
height: weakSurface!.height,
|
|
liveAspect: liveCanvas!.width / liveCanvas!.height
|
|
})
|
|
|
|
await testInfo.attach('weak-live-composition-metrics.json', {
|
|
body: Buffer.from(JSON.stringify({ L1: l1Metrics, L3: l3Metrics }, null, 2)),
|
|
contentType: 'application/json'
|
|
})
|
|
console.info(`weak/live composition metrics ${JSON.stringify({ L1: l1Metrics, L3: l3Metrics })}`)
|
|
|
|
for (const [floor, metrics] of [['L1', l1Metrics], ['L3', l3Metrics]] as const) {
|
|
expect(metrics.centerOffsetRatio, `${floor} center deviation must be under 5% of the short side`)
|
|
.toBeLessThan(0.05)
|
|
expect(metrics.directionDegrees, `${floor} direction delta must be under 8 degrees`)
|
|
.toBeLessThan(8)
|
|
expect(metrics.representativeScaleRatio, `${floor} representative scale must stay comparable`)
|
|
.toBeGreaterThanOrEqual(0.8)
|
|
expect(metrics.representativeScaleRatio, `${floor} representative scale must stay comparable`)
|
|
.toBeLessThanOrEqual(1.2)
|
|
}
|
|
|
|
})
|
|
|
|
test('keeps the final 3D renderer and camera after rapid render-mode changes', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
|
await waitForThreeDimensionalScene(page)
|
|
const before = await getThreeDimensionalSceneState(page)
|
|
const toggle = page.getByTestId('indoor-render-mode-toggle')
|
|
|
|
for (const expectedMode of ['2D', '3D', '2D', '3D']) {
|
|
await toggle.click()
|
|
await expect(toggle).toHaveText(expectedMode)
|
|
await page.waitForTimeout(110)
|
|
}
|
|
|
|
await waitForThreeDimensionalScene(page)
|
|
await expect(page.getByTestId('weak-network-map')).toHaveCount(0)
|
|
await expect(page.locator('.three-canvas-wrapper')).toHaveAttribute('data-render-loop-state', 'active-3d')
|
|
const after = await getThreeDimensionalSceneState(page)
|
|
expect(getCameraDelta(before.camera, after.camera)).toBeLessThan(1e-6)
|
|
})
|
|
|
|
test('keeps the exterior projection aligned on a compact mobile viewport', async ({ page }, testInfo) => {
|
|
await verifyExteriorRenderModeToggle(
|
|
page,
|
|
testInfo,
|
|
{ width: 390, height: 844 },
|
|
'exterior-390x844'
|
|
)
|
|
})
|
|
|
|
test('supports semantic 2D zoom, drag, floor switching, and a shared 3D target', async ({ page }) => {
|
|
const abortGlbRequest = async (route: Route) => {
|
|
await route.abort('failed')
|
|
}
|
|
await page.route(/\.glb(?:$|\?)/i, abortGlbRequest)
|
|
await page.goto('/?guide-render=2d#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/guide-render=2d.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-map')).toBeVisible({ timeout: 60_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 30_000 })
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('2D')
|
|
await expect(page.locator('.floor-switcher')).toHaveCount(0)
|
|
|
|
await enterDefaultFloorFromOverview(page)
|
|
await selectFloor(page, '2F')
|
|
|
|
const beforeButtonZoom = await getTwoDimensionalViewport(page)
|
|
const afterButtonZoom = await clickGuideZoom(page, 'in')
|
|
expect(afterButtonZoom.visibleWorldSpan).toBeLessThan(beforeButtonZoom.visibleWorldSpan)
|
|
|
|
const surface = page.getByTestId('weak-network-surface')
|
|
const surfaceBox = await surface.boundingBox()
|
|
expect(surfaceBox).not.toBeNull()
|
|
if (surfaceBox) {
|
|
await surface.hover({ position: { x: surfaceBox.width / 2, y: surfaceBox.height / 2 } })
|
|
await page.mouse.wheel(0, -480)
|
|
}
|
|
await expect.poll(async () => (await getTwoDimensionalViewport(page)).visibleWorldSpan)
|
|
.toBeLessThan(afterButtonZoom.visibleWorldSpan)
|
|
|
|
const beforeDrag = await getTwoDimensionalViewport(page)
|
|
const afterDrag = await dragTwoDimensionalMap(page)
|
|
expect(Math.hypot(
|
|
afterDrag.centerX - beforeDrag.centerX,
|
|
afterDrag.centerZ - beforeDrag.centerZ
|
|
)).toBeGreaterThan(0.1)
|
|
await expectMarkersInsideBasemap(page)
|
|
|
|
await page.unroute(/\.glb(?:$|\?)/i, abortGlbRequest)
|
|
await page.getByTestId('indoor-render-mode-toggle').click()
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('3D')
|
|
await waitForThreeDimensionalScene(page, false)
|
|
await expect(page.getByTestId('weak-network-map')).toHaveCount(0)
|
|
const threeDimensional = await getThreeDimensionalSceneState(page)
|
|
expect(Math.hypot(
|
|
threeDimensional.camera.target.x - afterDrag.centerX,
|
|
threeDimensional.camera.target.z - afterDrag.centerZ
|
|
)).toBeLessThan(2)
|
|
})
|
|
|
|
test('uses tight, balanced, and full WebP label density at 390x844', async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await page.goto('/?guide-render=2d#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/guide-render=2d.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
await enterDefaultFloorFromOverview(page)
|
|
|
|
const tight = await getWeakMapDensityState(page)
|
|
expect(tight.tier).toBe('tight')
|
|
expect(tight.facilityMarkerCount).toBe(0)
|
|
expect(tight.overlappingLabels).toEqual([])
|
|
await screenshot(page, testInfo, 'webp-label-lod-tight-390x844')
|
|
|
|
await clickGuideZoom(page, 'in')
|
|
await clickGuideZoom(page, 'in')
|
|
const balanced = await getWeakMapDensityState(page)
|
|
expect(balanced.tier).toBe('balanced')
|
|
expect(balanced.markerCount).toBeGreaterThan(tight.markerCount)
|
|
expect(balanced.labelCount).toBeGreaterThan(tight.labelCount)
|
|
expect(balanced.facilityMarkerCount).toBeGreaterThan(0)
|
|
expect(balanced.overlappingLabels).toEqual([])
|
|
await screenshot(page, testInfo, 'webp-label-lod-balanced-390x844')
|
|
|
|
await clickGuideZoom(page, 'in')
|
|
await clickGuideZoom(page, 'in')
|
|
const full = await getWeakMapDensityState(page)
|
|
expect(full.tier).toBe('full')
|
|
expect(full.markerCount).toBeGreaterThan(balanced.markerCount)
|
|
expect(full.labelCount).toBeGreaterThan(balanced.labelCount)
|
|
expect(full.facilityMarkerCount).toBeGreaterThanOrEqual(balanced.facilityMarkerCount)
|
|
expect(full.overlappingLabels).toEqual([])
|
|
await screenshot(page, testInfo, 'webp-label-lod-full-390x844')
|
|
})
|
|
|
|
test('renders a same-floor route in 2D and keeps route navigation on its floor', async ({ page }) => {
|
|
test.setTimeout(240_000)
|
|
await page.goto('/?weak-map-preview=1#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
await enterDefaultFloorFromOverview(page)
|
|
const floorViewport = await selectFloor(page, '1F')
|
|
|
|
const destination = page.locator('.weak-network-marker[data-label="售票机"]').first()
|
|
const start = page.locator('.weak-network-marker[data-label="影院服务台"]').first()
|
|
await expect(destination).toBeVisible({ timeout: 30_000 })
|
|
await expect(start).toBeVisible({ timeout: 30_000 })
|
|
|
|
await destination.dispatchEvent('click')
|
|
await expect(page.locator('.guide-poi-card')).toBeVisible({ timeout: 30_000 })
|
|
await page.locator('.poi-action[data-action-id="navigate"]').click()
|
|
await expect(page.locator('.route-start-card')).toBeVisible({ timeout: 30_000 })
|
|
await start.dispatchEvent('click')
|
|
await expect(page.locator('.route-confirm-mask')).toBeVisible({ timeout: 30_000 })
|
|
await page.locator('.route-confirm-action.primary').click()
|
|
|
|
await expect(page.locator('.route-ready-card'), '2D same-floor route should be generated')
|
|
.toBeVisible({ timeout: 120_000 })
|
|
await expect(page.locator('.route-ready-summary')).toContainText('米')
|
|
await expect(page.locator('.weak-network-route-line').first()).toBeVisible({ timeout: 30_000 })
|
|
const routeViewport = await getTwoDimensionalViewport(page)
|
|
expect(routeViewport.scene).toBe('floor')
|
|
expect(routeViewport.floorId).toBe(floorViewport.floorId)
|
|
expect(new Set(await page.locator('.weak-network-marker').evaluateAll((markers) => (
|
|
markers.map((marker) => marker.getAttribute('data-floor-id'))
|
|
)))).toEqual(new Set([floorViewport.floorId]))
|
|
|
|
await page.locator('.route-primary-action').click()
|
|
await expect(page.locator('.route-sim-step-bar')).toBeVisible({ timeout: 30_000 })
|
|
await reachTwoDimensionalBoundary(page, 'out', 'floor')
|
|
await clickGuideZoom(page, 'out')
|
|
await clickGuideZoom(page, 'out')
|
|
expect((await getTwoDimensionalViewport(page)).scene).toBe('floor')
|
|
})
|
|
|
|
test('renders the real L1 exterior L1 route on the EXTERIOR_L1 WebP without fake floor transfers', async ({ page }, testInfo) => {
|
|
test.setTimeout(300_000)
|
|
const l1FloorId = '2065808921272119298'
|
|
const exteriorFloorId = '2065808919904776194'
|
|
const realRouteResponse = await page.request.post(
|
|
'http://127.0.0.1:48080/app-api/gis/sdk/routes/plan',
|
|
{
|
|
data: {
|
|
startFloorId: l1FloorId,
|
|
startX: -59.617386,
|
|
startY: 16.816814,
|
|
endFloorId: l1FloorId,
|
|
endX: 61.8,
|
|
endY: 45.3,
|
|
wheelchair: 0,
|
|
verticalTransferPolicy: 'PREFER_ELEVATOR_ESCALATOR'
|
|
}
|
|
}
|
|
)
|
|
expect(realRouteResponse.ok()).toBe(true)
|
|
const routeEnvelope = await realRouteResponse.json() as {
|
|
code: number
|
|
data: {
|
|
distance: number
|
|
segments: Array<{
|
|
floorId: string
|
|
transferType: string
|
|
pathPoints?: unknown[] | null
|
|
}>
|
|
}
|
|
}
|
|
expect(routeEnvelope.code).toBe(0)
|
|
expect(routeEnvelope.data.distance).toBeCloseTo(177.9, 1)
|
|
expect(routeEnvelope.data.segments.filter((segment) => segment.pathPoints?.length).map((segment) => segment.floorId))
|
|
.toEqual([l1FloorId, exteriorFloorId, l1FloorId])
|
|
expect(routeEnvelope.data.segments.filter((segment) => segment.transferType === 'ENTRANCE_EXIT'))
|
|
.toHaveLength(2)
|
|
|
|
let uiRouteRequestCount = 0
|
|
await page.route('**/app-api/gis/sdk/routes/plan', async (route) => {
|
|
uiRouteRequestCount += 1
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(routeEnvelope)
|
|
})
|
|
})
|
|
|
|
await page.goto('/?weak-map-preview=1#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
await enterDefaultFloorFromOverview(page)
|
|
await selectFloor(page, '1F')
|
|
|
|
const destination = page.locator('.weak-network-marker[data-label="文创盖章打卡"]').first()
|
|
const start = page.locator('.weak-network-marker[data-label="展厅售票点"]').first()
|
|
await expect(destination).toBeVisible({ timeout: 30_000 })
|
|
await expect(start).toBeVisible({ timeout: 30_000 })
|
|
await destination.dispatchEvent('click')
|
|
await expect(page.locator('.guide-poi-card')).toBeVisible({ timeout: 30_000 })
|
|
await page.locator('.poi-action[data-action-id="navigate"]').click()
|
|
await expect(page.locator('.route-start-card')).toBeVisible({ timeout: 30_000 })
|
|
await start.dispatchEvent('click')
|
|
await expect(page.locator('.route-confirm-mask')).toBeVisible({ timeout: 30_000 })
|
|
await page.locator('.route-confirm-action.primary').click()
|
|
|
|
await expect(page.locator('.route-ready-card')).toBeVisible({ timeout: 120_000 })
|
|
expect(uiRouteRequestCount).toBe(1)
|
|
const weakMap = page.getByTestId('weak-network-map')
|
|
await expect(weakMap).toHaveAttribute('data-active-basemap-floor-code', 'EXTERIOR_L1')
|
|
await expect(weakMap).toHaveAttribute('data-active-basemap-role', 'SAME_GROUND_COMPOSITE')
|
|
await expect(weakMap).toHaveAttribute('data-viewport-scene', 'floor')
|
|
await expect(weakMap).toHaveAttribute('data-viewport-floor-id', 'EXTERIOR_L1')
|
|
await expect(weakMap).toHaveAttribute(
|
|
'data-active-basemap-coverage',
|
|
`${exteriorFloorId},${l1FloorId}`
|
|
)
|
|
await expect(page.getByTestId('weak-network-basemap').locator('img')).toHaveAttribute(
|
|
'src',
|
|
/\/EXTERIOR_L1\.[^/]+\.ortho-yaw54-v2\.compact-floor-center-v1\.webp$/i
|
|
)
|
|
await expect(page.locator('.weak-network-route-line')).toHaveCount(22)
|
|
await expect(page.getByTestId('weak-network-floor')).toHaveCount(0)
|
|
|
|
const geometry = await page.evaluate(() => {
|
|
const surface = document.querySelector<HTMLElement>('[data-testid="weak-network-surface"]')
|
|
const image = document.querySelector<HTMLElement>('[data-testid="weak-network-basemap"]')
|
|
if (!surface || !image) throw new Error('Composite WebP surface is unavailable')
|
|
const lines = [...document.querySelectorAll<HTMLElement>('.weak-network-route-line')]
|
|
const points = lines.flatMap((line) => [
|
|
{
|
|
x: Number(line.dataset.routeStartX),
|
|
y: Number(line.dataset.routeStartY),
|
|
floors: line.dataset.routeFloorIds || ''
|
|
},
|
|
{
|
|
x: Number(line.dataset.routeEndX),
|
|
y: Number(line.dataset.routeEndY),
|
|
floors: line.dataset.routeFloorIds || ''
|
|
}
|
|
])
|
|
const xs = points.map((point) => point.x)
|
|
const ys = points.map((point) => point.y)
|
|
return {
|
|
width: surface.clientWidth,
|
|
height: surface.clientHeight,
|
|
imageBounds: image.getBoundingClientRect().toJSON(),
|
|
points,
|
|
routeBounds: {
|
|
width: Math.max(...xs) - Math.min(...xs),
|
|
height: Math.max(...ys) - Math.min(...ys)
|
|
}
|
|
}
|
|
})
|
|
expect(geometry.width).toBeGreaterThan(0)
|
|
expect(geometry.height).toBeGreaterThan(0)
|
|
expect(geometry.routeBounds.width).toBeGreaterThan(40)
|
|
expect(geometry.routeBounds.height).toBeGreaterThan(80)
|
|
expect(new Set(geometry.points.map((point) => point.floors)))
|
|
.toEqual(new Set([`${l1FloorId},${exteriorFloorId}`]))
|
|
for (const point of geometry.points) {
|
|
expect(Number.isFinite(point.x)).toBe(true)
|
|
expect(Number.isFinite(point.y)).toBe(true)
|
|
expect(point.x).toBeGreaterThanOrEqual(0)
|
|
expect(point.x).toBeLessThanOrEqual(geometry.width)
|
|
expect(point.y).toBeGreaterThanOrEqual(0)
|
|
expect(point.y).toBeLessThanOrEqual(geometry.height)
|
|
}
|
|
await expectMarkersInsideBasemap(page)
|
|
await screenshot(page, testInfo, 'webp-composite-route-preview-390x844')
|
|
|
|
const beforeZoom = await getTwoDimensionalViewport(page)
|
|
const expanded = await clickGuideZoom(page, 'out')
|
|
expect(expanded.visibleWorldSpan).toBeGreaterThan(beforeZoom.visibleWorldSpan)
|
|
const restoredZoom = await clickGuideZoom(page, 'in')
|
|
expect(restoredZoom.visibleWorldSpan).toBeLessThan(expanded.visibleWorldSpan)
|
|
await expect(weakMap).toHaveAttribute('data-active-basemap-floor-code', 'EXTERIOR_L1')
|
|
|
|
const beforeDrag = await getTwoDimensionalViewport(page)
|
|
const afterDrag = await dragTwoDimensionalMap(page)
|
|
expect(Math.hypot(
|
|
afterDrag.centerX - beforeDrag.centerX,
|
|
afterDrag.centerZ - beforeDrag.centerZ
|
|
)).toBeGreaterThan(0.1)
|
|
await expect(weakMap).toHaveAttribute('data-active-basemap-floor-code', 'EXTERIOR_L1')
|
|
await expectMarkersInsideBasemap(page)
|
|
|
|
await page.locator('.route-primary-action').click()
|
|
await expect(page.locator('.route-sim-step-bar')).toBeVisible({ timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-route-progress')).toBeVisible({ timeout: 30_000 })
|
|
await page.waitForTimeout(1_200)
|
|
await expect(weakMap).toHaveAttribute('data-active-basemap-floor-code', 'EXTERIOR_L1')
|
|
await expect(weakMap).toHaveAttribute('data-viewport-floor-id', 'EXTERIOR_L1')
|
|
const progress = await page.getByTestId('weak-network-route-progress').evaluate((element) => {
|
|
const surface = document.querySelector<HTMLElement>('[data-testid="weak-network-surface"]')!
|
|
const bounds = element.getBoundingClientRect()
|
|
const surfaceBounds = surface.getBoundingClientRect()
|
|
return {
|
|
x: bounds.left + bounds.width / 2 - surfaceBounds.left,
|
|
y: bounds.top + bounds.height / 2 - surfaceBounds.top,
|
|
width: surfaceBounds.width,
|
|
height: surfaceBounds.height
|
|
}
|
|
})
|
|
expect(progress.x).toBeGreaterThanOrEqual(0)
|
|
expect(progress.x).toBeLessThanOrEqual(progress.width)
|
|
expect(progress.y).toBeGreaterThanOrEqual(0)
|
|
expect(progress.y).toBeLessThanOrEqual(progress.height)
|
|
await screenshot(page, testInfo, 'webp-composite-route-simulation-390x844')
|
|
|
|
await page.locator('.route-sim-close').click()
|
|
await expect(page.locator('.route-sim-step-bar')).toHaveCount(0)
|
|
await expect(page.locator('.weak-network-route-line')).toHaveCount(0)
|
|
})
|
|
|
|
test('enters at the shared threshold, applies cooldown, and blocks selected-floor auto exit', async ({ page }) => {
|
|
await page.goto('/?weak-map-preview=1#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
await enterDefaultFloorFromOverview(page)
|
|
|
|
const floorAtEntry = await getTwoDimensionalViewport(page)
|
|
expect(floorAtEntry.scene).toBe('floor')
|
|
await clickGuideZoom(page, 'out')
|
|
await clickGuideZoom(page, 'out')
|
|
expect((await getTwoDimensionalViewport(page)).scene).toBe('floor')
|
|
|
|
await page.waitForTimeout(1600)
|
|
await clickGuideZoom(page, 'out')
|
|
await clickGuideZoom(page, 'out')
|
|
await expect.poll(async () => (await getTwoDimensionalViewport(page)).scene, {
|
|
timeout: 60_000
|
|
}).toBe('overview')
|
|
|
|
await page.waitForTimeout(1600)
|
|
await enterDefaultFloorFromOverview(page)
|
|
const marker = page.locator('.weak-network-marker').first()
|
|
await expect(marker).toBeVisible({ timeout: 30_000 })
|
|
await marker.click()
|
|
await expect(marker).toHaveClass(/weak-network-marker--selected/)
|
|
await page.waitForTimeout(1600)
|
|
await clickGuideZoom(page, 'out')
|
|
await clickGuideZoom(page, 'out')
|
|
expect((await getTwoDimensionalViewport(page)).scene).toBe('floor')
|
|
})
|
|
|
|
test('captures L1, L2, and L3 top-view basemaps at 390x844', async ({ page }, testInfo) => {
|
|
await captureFloorScreenshots(page, testInfo, { width: 390, height: 844 }, 'floors-390x844')
|
|
})
|
|
|
|
test('captures L1, L2, and L3 top-view basemaps at 430x932', async ({ page }, testInfo) => {
|
|
await captureFloorScreenshots(page, testInfo, { width: 430, height: 932 }, 'floors-430x932')
|
|
})
|
|
|
|
test('keeps indoor floor labels usable when every GLB request fails', async ({ page }) => {
|
|
await clearGuideModelCacheForTest(page)
|
|
await page.route(/\.glb(?:$|\?)/i, async (route) => {
|
|
await route.abort('failed')
|
|
})
|
|
|
|
await page.goto('/')
|
|
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-map')).toBeVisible({ timeout: 180_000 })
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('2D')
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-neutral-grid')).toHaveCount(0)
|
|
await expect(page.locator('.floor-switcher')).toHaveCount(0)
|
|
await expect(page.locator('.weak-network-marker').first()).toBeVisible({ timeout: 30_000 })
|
|
await expect(page.locator('.weak-network-empty')).toHaveCount(0)
|
|
|
|
await enterDefaultFloorFromOverview(page)
|
|
await expect(page.locator('.weak-network-marker').first()).toBeVisible({ timeout: 60_000 })
|
|
|
|
const activeFloor = page.locator('.floor-item.active').first()
|
|
const initialFloor = await activeFloor.innerText()
|
|
const nextFloor = page.locator('.floor-item').filter({ hasNotText: initialFloor }).first()
|
|
await expect(nextFloor).toBeVisible({ timeout: 30_000 })
|
|
await nextFloor.click()
|
|
await expect(nextFloor).toHaveClass(/active/, { timeout: 60_000 })
|
|
})
|
|
|
|
test('keeps the 2D basemap interactive when every floor POI request fails', async ({ page }) => {
|
|
let failedPoiRequests = 0
|
|
await page.route(/\/app-api\/gis\/sdk\/floors\/[^/?]+\/(?:bundle|pois|spaces|business-pois|navigable-places)(?:\?|$)/, async (route) => {
|
|
failedPoiRequests += 1
|
|
await route.fulfill({
|
|
status: 503,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ code: 503, msg: 'simulated POI outage' })
|
|
})
|
|
})
|
|
|
|
await page.goto('/?weak-map-preview=1#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
const beforeZoom = await getTwoDimensionalViewport(page)
|
|
expect(beforeZoom.scene).toBe('overview')
|
|
await enterDefaultFloorFromOverview(page)
|
|
await expect(page.locator('.weak-network-empty')).toBeVisible({ timeout: 30_000 })
|
|
const secondFloor = page.locator('.floor-item').filter({ hasText: '2F' }).first()
|
|
await secondFloor.click()
|
|
await expect(secondFloor).toHaveClass(/active/, { timeout: 60_000 })
|
|
await expect(page.getByTestId('weak-network-basemap').locator('img')).toHaveAttribute(
|
|
'src',
|
|
/\/L2\.[^/]+\.ortho-yaw54-v2\.compact-floor-center-v1\.webp$/i,
|
|
{ timeout: 60_000 }
|
|
)
|
|
await expect(page.locator('.weak-network-empty')).toBeVisible({ timeout: 30_000 })
|
|
expect(failedPoiRequests).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('rejects an obsolete basemap projection and recovers after a valid reload', async ({ page }) => {
|
|
const manifestPattern = '**/static/guide-floor-basemaps/manifest.json'
|
|
await page.route(manifestPattern, async (route) => {
|
|
const response = await route.fetch()
|
|
const manifest = await response.json()
|
|
await route.fulfill({
|
|
response,
|
|
json: {
|
|
...manifest,
|
|
render: {
|
|
...manifest.render,
|
|
projectionVersion: 'oblique-v0'
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
await page.goto('/?weak-map-preview=1#/pages/index/index?tab=guide')
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-neutral-grid')).toBeVisible({ timeout: 60_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toHaveCount(0)
|
|
|
|
await page.unroute(manifestPattern)
|
|
await page.reload()
|
|
await page.waitForURL(/weak-map-preview=1.*tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 60_000 })
|
|
await expect(page.getByTestId('weak-network-neutral-grid')).toHaveCount(0)
|
|
})
|
|
|
|
test('falls back to the two-dimensional guide when a GLB request stops making progress', async ({ page }) => {
|
|
await clearGuideModelCacheForTest(page)
|
|
await page.route(/\.glb(?:$|\?)/i, async (route) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20_000))
|
|
await route.continue()
|
|
})
|
|
|
|
const startedAt = Date.now()
|
|
await page.goto('/')
|
|
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-map')).toBeVisible({ timeout: 20_000 })
|
|
expect(Date.now() - startedAt).toBeLessThan(9_000)
|
|
await expect(page.getByTestId('indoor-render-mode-toggle')).toHaveText('2D')
|
|
await expect(page.getByTestId('weak-network-basemap')).toBeVisible({ timeout: 30_000 })
|
|
})
|
|
|
|
test('reuses a persisted model after the next page load loses network access', async ({ page }) => {
|
|
await page.goto('/')
|
|
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
|
await expect.poll(async () => page.evaluate(async () => {
|
|
const cache = await caches.open('sgs-guide-model-v1')
|
|
return (await cache.keys()).length
|
|
}), { timeout: 180_000 }).toBeGreaterThan(0)
|
|
|
|
await page.route(/\.glb(?:$|\?)/i, async (route) => {
|
|
await route.abort('failed')
|
|
})
|
|
await page.reload()
|
|
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
|
await expect(page.getByTestId('weak-network-map')).toHaveCount(0)
|
|
await expect.poll(async () => page.evaluate(() => (
|
|
(window as Window & {
|
|
__GUIDE_3D_VISUAL_STABILITY__?: { isInitialModelReady: () => boolean }
|
|
}).__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
|
)), { timeout: 180_000 }).toBe(true)
|
|
})
|