This commit is contained in:
151
tests/e2e/camera-calibration.spec.ts
Normal file
151
tests/e2e/camera-calibration.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
interface CameraReport {
|
||||
position: { x: number; y: number; z: number }
|
||||
target: { x: number; y: number; z: number }
|
||||
fov: number
|
||||
distance: number
|
||||
}
|
||||
|
||||
interface GuideDiagnostics {
|
||||
isInitialModelReady: () => boolean
|
||||
getReport: () => { camera: CameraReport; activeView: string; isCameraTweening: boolean }
|
||||
setCameraCalibration: (values: { yaw: number }) => void
|
||||
getFloors: () => Array<{ floorId: string; label: string }>
|
||||
switchFloor: (floorId: string) => Promise<void>
|
||||
zoomCamera: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
|
||||
}
|
||||
|
||||
const getCameraReport = (page: Page) => page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return diagnostics.getReport().camera
|
||||
})
|
||||
|
||||
const getYawDegrees = (camera: CameraReport) => (
|
||||
Math.atan2(
|
||||
camera.position.x - camera.target.x,
|
||||
camera.position.z - camera.target.z
|
||||
) * 180 / Math.PI
|
||||
)
|
||||
|
||||
const getElevationDegrees = (camera: CameraReport) => {
|
||||
const x = camera.position.x - camera.target.x
|
||||
const y = camera.position.y - camera.target.y
|
||||
const z = camera.position.z - camera.target.z
|
||||
const distance = Math.hypot(x, y, z)
|
||||
return Math.asin(y / distance) * 180 / Math.PI
|
||||
}
|
||||
|
||||
test('development camera calibration panel applies and resets exterior camera values', async ({ page }) => {
|
||||
await page.goto('/?camera-calibration=1')
|
||||
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
||||
await expect(page.locator('.camera-calibration-panel')).toBeVisible({ timeout: 30_000 })
|
||||
await page.locator('[data-camera-calibration-action="open"]').click()
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
||||
)), { timeout: 180_000 }).toBe(true)
|
||||
|
||||
const before = await getCameraReport(page)
|
||||
const yawSlider = page.locator('[data-camera-calibration="yaw"]')
|
||||
await expect(yawSlider).toBeVisible()
|
||||
await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
diagnostics.setCameraCalibration({ yaw: 35 })
|
||||
})
|
||||
|
||||
await expect.poll(async () => Math.abs(getYawDegrees(await getCameraReport(page)) - 35), {
|
||||
timeout: 10_000
|
||||
}).toBeLessThan(0.01)
|
||||
|
||||
await page.locator('[data-camera-calibration-action="reset"]').click()
|
||||
await expect.poll(async () => Math.abs(getYawDegrees(await getCameraReport(page)) - 54), {
|
||||
timeout: 10_000
|
||||
}).toBeLessThan(0.01)
|
||||
|
||||
const after = await getCameraReport(page)
|
||||
expect(after.fov).toBeCloseTo(before.fov, 6)
|
||||
expect(after.distance).toBeGreaterThan(0)
|
||||
|
||||
const floorId = await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
const firstFloor = diagnostics?.getFloors().find((floor) => floor.label === '1F')
|
||||
|| diagnostics?.getFloors()[0]
|
||||
if (!diagnostics || !firstFloor) throw new Error('Floor diagnostics unavailable')
|
||||
void diagnostics.switchFloor(firstFloor.floorId)
|
||||
return firstFloor.floorId
|
||||
})
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
return diagnostics?.getReport() || null
|
||||
}), { timeout: 90_000 }).toMatchObject({
|
||||
activeView: 'floor',
|
||||
isCameraTweening: false
|
||||
})
|
||||
|
||||
const indoor = await getCameraReport(page)
|
||||
expect(getYawDegrees(indoor)).toBeCloseTo(54, 6)
|
||||
expect(getElevationDegrees(indoor)).toBeCloseTo(42, 6)
|
||||
expect(indoor.fov).toBeCloseTo(42, 6)
|
||||
// Interior uses the exterior's visual target instead of each floor model's
|
||||
// bounding-box center, so the building cannot drift on the handoff.
|
||||
expect(indoor.target.x).toBeCloseTo(after.target.x, 6)
|
||||
expect(indoor.target.y).toBeCloseTo(after.target.y, 6)
|
||||
expect(indoor.target.z).toBeCloseTo(after.target.z, 6)
|
||||
expect(floorId).toBeTruthy()
|
||||
|
||||
const floorTargets = await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return diagnostics.getFloors().filter((floor) => ['1F', 'MF', '3F', '4F', '5F'].includes(floor.label))
|
||||
})
|
||||
expect(floorTargets.map((floor) => floor.label).sort()).toEqual(['1F', '3F', '4F', '5F', 'MF'])
|
||||
|
||||
for (const targetFloor of floorTargets) {
|
||||
await page.evaluate(async (targetFloorId) => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await diagnostics.switchFloor(targetFloorId)
|
||||
}, targetFloor.floorId)
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
return diagnostics?.getReport().isCameraTweening || false
|
||||
}), { timeout: 90_000 }).toBe(false)
|
||||
|
||||
const currentFloorCamera = await getCameraReport(page)
|
||||
expect(currentFloorCamera.distance).toBeCloseTo(indoor.distance, 6)
|
||||
expect(getYawDegrees(currentFloorCamera)).toBeCloseTo(54, 6)
|
||||
expect(getElevationDegrees(currentFloorCamera)).toBeCloseTo(42, 6)
|
||||
}
|
||||
|
||||
let expectedZoomDistance = indoor.distance
|
||||
for (let count = 0; count < 5; count += 1) {
|
||||
expectedZoomDistance = Math.max(indoor.distance * 0.2, expectedZoomDistance * 0.72)
|
||||
await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
diagnostics.zoomCamera('in', { source: 'button' })
|
||||
})
|
||||
await expect.poll(async () => (await getCameraReport(page)).distance, { timeout: 10_000 })
|
||||
.toBeCloseTo(expectedZoomDistance, 6)
|
||||
}
|
||||
|
||||
})
|
||||
244
tests/e2e/device-label-highlight.spec.ts
Normal file
244
tests/e2e/device-label-highlight.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
interface PoiCandidate {
|
||||
poiId: string
|
||||
floorId: string
|
||||
name: string
|
||||
primaryCategory: string
|
||||
iconType: string
|
||||
sourceObjectName?: string
|
||||
mergedSourceObjectNames: string[]
|
||||
positionGltf: [number, number, number]
|
||||
}
|
||||
|
||||
interface GuideDiagnostics {
|
||||
isInitialModelReady: () => boolean
|
||||
getReport: () => {
|
||||
activeView: 'overview' | 'floor' | 'multi'
|
||||
floorId: string
|
||||
visibleVisualMarkerWithoutTextCount: number
|
||||
}
|
||||
getFloors: () => Array<{ floorId: string; label: string }>
|
||||
switchFloor: (floorId: string) => Promise<unknown>
|
||||
getFloorPois: (floorId: string) => Promise<PoiCandidate[]>
|
||||
resetToViewBaseline: (options: { view: 'floor'; floorId: string; reason: 'floor-reset' }) => Promise<unknown>
|
||||
showOverview: () => Promise<unknown>
|
||||
focusTargetPoi: (request: PoiCandidate & { requestId: string }) => Promise<unknown>
|
||||
clearTargetFocus: () => void
|
||||
getPoiFocusState: (poiId?: string) => {
|
||||
selectedPoiId: string
|
||||
modelHighlightRootCount: number
|
||||
modelHighlightRootNames: string[]
|
||||
modelHighlightMaterials: Array<{
|
||||
attached: boolean
|
||||
color: string | null
|
||||
hasMap: boolean
|
||||
}>
|
||||
baseAffordanceCount: number
|
||||
pulseAffordanceCount: number
|
||||
glowAffordanceCount: number
|
||||
}
|
||||
}
|
||||
|
||||
const openFirstFloor = async (page: Page) => {
|
||||
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__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
||||
)), {
|
||||
timeout: 180_000
|
||||
}).toBe(true)
|
||||
|
||||
const floorId = await page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
const floor = api.getFloors().find((candidate) => candidate.label === '1F')
|
||||
if (!floor) throw new Error('1F is unavailable')
|
||||
await api.switchFloor(floor.floorId)
|
||||
return floor.floorId
|
||||
})
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const report = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport()
|
||||
return report ? `${report.activeView}:${report.floorId}` : ''
|
||||
}), { timeout: 60_000 }).toBe(`floor:${floorId}`)
|
||||
return floorId
|
||||
}
|
||||
|
||||
test('1F devices render text labels and highlight only their exact model roots', async ({ page }) => {
|
||||
const floorId = await openFirstFloor(page)
|
||||
const pois = await page.evaluate(async (requestedFloorId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return api.getFloorPois(requestedFloorId)
|
||||
}, floorId)
|
||||
|
||||
const cases = [
|
||||
{ poiId: '5686', label: '闸机', root: 'L1_闸机_01' },
|
||||
{ poiId: '5694', label: '导览屏', root: 'L1_导览屏_02' },
|
||||
{ poiId: '5692', label: '安检机', root: 'L1_安检机_02' },
|
||||
{ poiId: '5611', label: '电梯', root: 'L1_H_电梯16' },
|
||||
{ poiId: '352502409710427180', label: '自助售卖机', root: 'L1_自助售卖机' },
|
||||
{ poiId: '352502410238908344', label: '影院服务台', root: 'L1_影院服务台', icon: 'service-center' },
|
||||
{ poiId: '5591', label: '售票机', root: 'L1_售票机' },
|
||||
{ poiId: 'space-2066935374889922562', label: '茶水间', root: 'L1_茶水间' }
|
||||
] as const
|
||||
|
||||
for (const target of cases) {
|
||||
const poi = pois.find((candidate) => candidate.poiId === target.poiId)
|
||||
expect(poi, `missing 1F POI ${target.poiId}`).toBeTruthy()
|
||||
expect(poi?.sourceObjectName).toBe(target.root)
|
||||
|
||||
const label = page.locator(`[data-poi-label-kind="ambient"][data-poi-id="${target.poiId}"]`)
|
||||
await expect(label).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(label.locator('.three-poi-dom-label__title')).toHaveText(target.label)
|
||||
if ('icon' in target) await expect(label).toHaveAttribute('data-poi-icon', target.icon)
|
||||
|
||||
await page.evaluate(async (candidate) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await api.focusTargetPoi({ ...candidate, requestId: `device-focus-${candidate.poiId}` })
|
||||
}, poi!)
|
||||
|
||||
await expect.poll(() => page.evaluate((poiId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
const state = api?.getPoiFocusState(poiId)
|
||||
return state ? {
|
||||
selectedPoiId: state.selectedPoiId,
|
||||
roots: state.modelHighlightRootNames,
|
||||
rootCount: state.modelHighlightRootCount,
|
||||
base: state.baseAffordanceCount,
|
||||
pulse: state.pulseAffordanceCount,
|
||||
glow: state.glowAffordanceCount
|
||||
} : null
|
||||
}, target.poiId), { timeout: 30_000 }).toEqual({
|
||||
selectedPoiId: target.poiId,
|
||||
roots: [target.root],
|
||||
rootCount: 1,
|
||||
base: 0,
|
||||
pulse: 0,
|
||||
glow: 0
|
||||
})
|
||||
|
||||
if (target.poiId === '5591') {
|
||||
await expect.poll(() => page.evaluate((poiId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
return api?.getPoiFocusState(poiId).modelHighlightMaterials || []
|
||||
}, target.poiId), { timeout: 30_000 }).toEqual([
|
||||
expect.objectContaining({
|
||||
attached: true,
|
||||
color: 'f2e600',
|
||||
hasMap: false
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
if (target.poiId === '352502410238908344') {
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
return api?.getReport().visibleVisualMarkerWithoutTextCount ?? -1
|
||||
}), { timeout: 30_000 }).toBe(0)
|
||||
}
|
||||
}
|
||||
|
||||
await page.locator('.search-box').click()
|
||||
await page.locator('.search-input input').fill('导览屏')
|
||||
await page.locator('.search-input input').press('Enter')
|
||||
await expect(page.getByTestId('poi-result-5693')).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(page.getByTestId('poi-result-5694')).toHaveCount(1, { timeout: 30_000 })
|
||||
|
||||
await page.evaluate(() => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
api?.clearTargetFocus()
|
||||
})
|
||||
})
|
||||
|
||||
test('clicking a visible POI label selects that label instead of an underlying hit target', async ({ page }) => {
|
||||
const floorId = await openFirstFloor(page)
|
||||
const target = await page.evaluate(async (requestedFloorId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return (await api.getFloorPois(requestedFloorId)).find((poi) => poi.name === '文创_01') || null
|
||||
}, floorId)
|
||||
|
||||
expect(target).not.toBeNull()
|
||||
const label = page.locator(
|
||||
`[data-poi-label-kind="ambient"][data-poi-id="${target?.poiId}"]:visible`
|
||||
)
|
||||
await expect(label).toHaveCount(1, { timeout: 30_000 })
|
||||
|
||||
const bounds = await label.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.click(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
|
||||
await expect.poll(() => page.evaluate((poiId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
return poiId ? api?.getPoiFocusState(poiId).selectedPoiId || '' : ''
|
||||
}, target!.poiId), { timeout: 30_000 }).toBe(target!.poiId)
|
||||
|
||||
await expect.poll(() => page.evaluate((poiId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
return poiId ? api?.getPoiFocusState(poiId).modelHighlightRootNames || [] : []
|
||||
}, target!.poiId), { timeout: 30_000 }).toEqual(['L1_文创_01'])
|
||||
})
|
||||
|
||||
test('secondary services use one text label and do not expose old visual markers', async ({ page }) => {
|
||||
const floorId = await openFirstFloor(page)
|
||||
const targets = await page.evaluate(async (requestedFloorId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
const pois = await api.getFloorPois(requestedFloorId)
|
||||
return ['存包处', '轮椅及儿童车租赁'].map((name) => (
|
||||
pois.find((poi) => poi.name === name) || null
|
||||
))
|
||||
}, floorId)
|
||||
|
||||
for (const target of targets) {
|
||||
expect(target, 'missing 1F secondary service POI').not.toBeNull()
|
||||
await page.evaluate(async (candidate) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api || !candidate) throw new Error('ThreeMap diagnostics or service unavailable')
|
||||
await api.focusTargetPoi({ ...candidate, requestId: `service-focus-${candidate.poiId}` })
|
||||
}, target)
|
||||
|
||||
const label = page.locator(
|
||||
`[data-poi-label-kind="ambient"][data-poi-id="${target!.poiId}"]:visible`
|
||||
)
|
||||
await expect(label).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(label.locator('.three-poi-dom-label__title')).toHaveText(target!.name)
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
return api?.getReport().visibleVisualMarkerWithoutTextCount ?? -1
|
||||
}), { timeout: 30_000 }).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('floor switcher exists only in an indoor floor view', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
||||
)), { timeout: 180_000 }).toBe(true)
|
||||
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
return api?.getReport().activeView || ''
|
||||
}), { timeout: 30_000 }).toBe('overview')
|
||||
await expect(page.locator('.floor-switcher')).toHaveCount(0)
|
||||
})
|
||||
@@ -66,7 +66,7 @@ test('hall goes directly to paged guide objects without outline requests', async
|
||||
expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
test('embedded hall list leaves title and return navigation to the host mini-program', async ({ page }) => {
|
||||
test('embedded hall list exposes an H5 return control that keeps host markers', async ({ page }) => {
|
||||
await page.route('**/app-api/gis/guide/catalog/**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (url.includes('/catalog/halls?')) {
|
||||
@@ -78,24 +78,29 @@ test('embedded hall list leaves title and return navigation to the host mini-pro
|
||||
|
||||
await page.goto('/#/pages/explain/list?embedded=wechat-mini-program&weapp=1')
|
||||
const back = page.locator('.header-back')
|
||||
await expect(back).toHaveCount(0)
|
||||
await expect(back).toHaveCount(1)
|
||||
await expect(back).toBeVisible()
|
||||
await expect(page.locator('.hall-overview-card')).toHaveCount(1)
|
||||
for (const width of [375, 390, 430]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
const layout = await page.locator('.explain-hall-select').evaluate((container) => {
|
||||
const header = container.querySelector<HTMLElement>('.explain-page-header')
|
||||
const card = container.querySelector<HTMLElement>('.hall-overview-card')
|
||||
if (!card) throw new Error('missing hall card')
|
||||
if (!header || !card) throw new Error('missing header or hall card')
|
||||
const headerRect = header.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
return {
|
||||
clientWidth: container.clientWidth,
|
||||
scrollWidth: container.scrollWidth,
|
||||
headerBottom: headerRect.bottom,
|
||||
cardTop: cardRect.top
|
||||
}
|
||||
})
|
||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
|
||||
expect(layout.cardTop).toBeGreaterThanOrEqual(0)
|
||||
expect(layout.cardTop).toBeGreaterThanOrEqual(layout.headerBottom)
|
||||
}
|
||||
await expect(page.locator('.explain-page-header')).toHaveCount(0)
|
||||
await back.click()
|
||||
await expect(page).toHaveURL(/#\/\?tab=guide&embedded=wechat-mini-program&weapp=1$/)
|
||||
})
|
||||
|
||||
test('ordinary H5 hall list return reaches the guide home', async ({ page }) => {
|
||||
|
||||
76
tests/e2e/guide-performance.spec.ts
Normal file
76
tests/e2e/guide-performance.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
interface GuidePerformanceSummary {
|
||||
operationCount: number
|
||||
operations: Array<{
|
||||
kind: string
|
||||
name: string
|
||||
p50Ms?: number
|
||||
p95Ms?: number
|
||||
}>
|
||||
}
|
||||
|
||||
interface GuidePerformanceApi {
|
||||
snapshot: () => Array<{ kind: string; name: string; outcome: string }>
|
||||
summary: () => GuidePerformanceSummary
|
||||
}
|
||||
|
||||
test('publishes model and POI label performance metrics in the H5 runtime', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
||||
|
||||
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)
|
||||
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const api = (window as Window & { __SGS_GUIDE_PERFORMANCE__?: GuidePerformanceApi })
|
||||
.__SGS_GUIDE_PERFORMANCE__
|
||||
return api?.snapshot().some((record) => (
|
||||
record.kind === 'interaction' && record.name === 'poi-label-first-render' && record.outcome === 'success'
|
||||
)) || false
|
||||
}), { timeout: 30_000 }).toBe(true)
|
||||
|
||||
const summary = await page.evaluate(() => (
|
||||
(window as Window & { __SGS_GUIDE_PERFORMANCE__?: GuidePerformanceApi })
|
||||
.__SGS_GUIDE_PERFORMANCE__?.summary()
|
||||
))
|
||||
|
||||
expect(summary).toBeTruthy()
|
||||
console.log(`[guide-performance] ${JSON.stringify(summary)}`)
|
||||
expect(summary!.operationCount).toBeGreaterThan(0)
|
||||
expect(summary!.operations).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: 'model'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'interaction',
|
||||
name: 'poi-label-first-render'
|
||||
})
|
||||
]))
|
||||
|
||||
await page.getByText('+', { exact: true }).click()
|
||||
await expect(page.locator('.floor-switcher')).toBeVisible({ timeout: 30_000 })
|
||||
const targetFloor = page.locator('.floor-item:not(.active)').first()
|
||||
await expect(targetFloor).toBeVisible({ timeout: 30_000 })
|
||||
const targetFloorLabel = await targetFloor.locator('.floor-label').innerText()
|
||||
await targetFloor.click()
|
||||
await expect(page.locator('.floor-item.active .floor-label')).toHaveText(targetFloorLabel, {
|
||||
timeout: 120_000
|
||||
})
|
||||
|
||||
await page.locator('.search-box').click()
|
||||
await expect(page.locator('[data-testid="poi-result-list"] .result-row').first())
|
||||
.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const interactionSummary = await page.evaluate(() => (
|
||||
(window as Window & { __SGS_GUIDE_PERFORMANCE__?: GuidePerformanceApi })
|
||||
.__SGS_GUIDE_PERFORMANCE__?.summary()
|
||||
))
|
||||
console.log(`[guide-performance-interactions] ${JSON.stringify(interactionSummary)}`)
|
||||
expect(interactionSummary?.operations).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'interaction', name: 'floor-switch', successCount: 1 }),
|
||||
expect.objectContaining({ kind: 'interaction', name: 'poi-search-data', successCount: 1 })
|
||||
]))
|
||||
})
|
||||
379
tests/e2e/infrastructure-distance-labels.spec.ts
Normal file
379
tests/e2e/infrastructure-distance-labels.spec.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { expect, test, type Page, type TestInfo } from '@playwright/test'
|
||||
|
||||
type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
|
||||
|
||||
interface PoiCandidate {
|
||||
poiId: string
|
||||
floorId: string
|
||||
name: string
|
||||
iconType: string
|
||||
positionGltf: [number, number, number]
|
||||
}
|
||||
|
||||
interface VisibleLabel {
|
||||
poiId: string
|
||||
icon: string
|
||||
landmark: boolean
|
||||
text: string
|
||||
}
|
||||
|
||||
interface AmbientLabelState {
|
||||
poiId: string
|
||||
floorId: string
|
||||
iconType: string
|
||||
text: string
|
||||
visible: boolean
|
||||
inViewport: boolean
|
||||
anchor: { x: number; y: number; z: number }
|
||||
}
|
||||
|
||||
interface GuideDiagnostics {
|
||||
isInitialModelReady: () => boolean
|
||||
getReport: () => {
|
||||
floorId: string
|
||||
activeView: 'overview' | 'floor' | 'multi'
|
||||
isCameraTweening: boolean
|
||||
}
|
||||
getFloors: () => Array<{ floorId: string; label: string }>
|
||||
getFloorPois: (floorId: string) => Promise<PoiCandidate[]>
|
||||
getAmbientPoiLabelStates: () => AmbientLabelState[]
|
||||
getPoiVisibilityTier: () => PoiVisibilityTier
|
||||
resetToViewBaseline: (options: { view: 'floor'; floorId: string; reason: 'floor-reset' }) => Promise<unknown>
|
||||
zoomCamera: (direction: 'in') => void
|
||||
}
|
||||
|
||||
interface FacilityFloorSelection {
|
||||
floorId: string
|
||||
label: string
|
||||
balancedPoiIds: string[]
|
||||
fullPoiIds: string[]
|
||||
}
|
||||
|
||||
const balancedIcons = new Set(['restroom', 'nursing-room', 'elevator'])
|
||||
const fullIcons = new Set(['escalator', 'stairs'])
|
||||
const tierRank: Record<PoiVisibilityTier, number> = {
|
||||
tight: 0,
|
||||
balanced: 1,
|
||||
full: 2
|
||||
}
|
||||
|
||||
const openGuide = async (page: Page) => {
|
||||
await page.goto('/')
|
||||
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
||||
)), { timeout: 180_000 }).toBe(true)
|
||||
}
|
||||
|
||||
const findFacilityFloors = (page: Page) => page.evaluate(async () => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
|
||||
const floors: FacilityFloorSelection[] = []
|
||||
for (const floor of diagnostics.getFloors()) {
|
||||
const pois = await diagnostics.getFloorPois(floor.floorId)
|
||||
const balancedPoiIds = pois
|
||||
.filter((poi) => ['restroom', 'toilet', 'accessible_toilet', 'restroom_accessible', 'mother_baby_room', 'nursing_room', 'elevator'].includes(poi.iconType))
|
||||
.map((poi) => poi.poiId)
|
||||
const fullPoiIds = pois
|
||||
.filter((poi) => ['escalator', 'stairs', 'stair'].includes(poi.iconType))
|
||||
.map((poi) => poi.poiId)
|
||||
if (balancedPoiIds.length || fullPoiIds.length) {
|
||||
floors.push({ ...floor, balancedPoiIds, fullPoiIds })
|
||||
}
|
||||
}
|
||||
return floors
|
||||
})
|
||||
|
||||
const getVisibleLabels = (page: Page) => page.evaluate(() => (
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[data-poi-label-kind="ambient"]'))
|
||||
.filter((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return style.visibility !== 'hidden'
|
||||
&& Number(style.opacity) > 0
|
||||
&& bounds.width > 0
|
||||
&& bounds.height > 0
|
||||
})
|
||||
.map((element) => ({
|
||||
poiId: element.dataset.poiId || '',
|
||||
icon: element.dataset.poiIcon || '',
|
||||
landmark: element.classList.contains('three-poi-dom-label--landmark'),
|
||||
text: element.querySelector('.three-poi-dom-label__title')?.textContent || ''
|
||||
}))
|
||||
))
|
||||
|
||||
const expectFacilityLabelsUseShortNames = (labels: VisibleLabel[]) => {
|
||||
const expectedByIcon: Record<string, string[]> = {
|
||||
elevator: ['电梯'],
|
||||
stairs: ['楼梯'],
|
||||
escalator: ['扶梯'],
|
||||
restroom: ['卫生间', '男卫生间', '女卫生间', '男女卫生间', '无障碍卫生间'],
|
||||
'nursing-room': ['母婴室']
|
||||
}
|
||||
|
||||
for (const label of labels.filter((candidate) => balancedIcons.has(candidate.icon) || fullIcons.has(candidate.icon))) {
|
||||
expect(expectedByIcon[label.icon], `unexpected infrastructure icon ${label.icon}`).toContain(label.text)
|
||||
expect(label.text).not.toMatch(/L\d|[A-Z][-_]?\d|\d$/i)
|
||||
}
|
||||
}
|
||||
|
||||
const expectVisibleAnchorsMatchFloorPois = async (page: Page, floor: FacilityFloorSelection) => {
|
||||
const result = await page.evaluate(async (floorId) => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return {
|
||||
pois: await diagnostics.getFloorPois(floorId),
|
||||
labels: diagnostics.getAmbientPoiLabelStates().filter((label) => label.visible)
|
||||
}
|
||||
}, floor.floorId)
|
||||
const poisById = new Map(result.pois.map((poi) => [poi.poiId, poi]))
|
||||
|
||||
for (const label of result.labels.filter((candidate) => (
|
||||
['elevator', 'stairs', 'stair', 'escalator'].includes(candidate.iconType)
|
||||
))) {
|
||||
const poi = poisById.get(label.poiId)
|
||||
expect(poi, `missing source POI for label ${label.poiId}`).toBeTruthy()
|
||||
expect(label.anchor.x).toBeCloseTo(poi!.positionGltf[0], 5)
|
||||
expect(label.anchor.y).toBeCloseTo(poi!.positionGltf[1], 5)
|
||||
expect(label.anchor.z).toBeCloseTo(poi!.positionGltf[2], 5)
|
||||
}
|
||||
}
|
||||
|
||||
const waitForCameraIdle = async (page: Page) => {
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
return diagnostics?.getReport().isCameraTweening || false
|
||||
}), { timeout: 10_000 }).toBe(false)
|
||||
await page.waitForTimeout(160)
|
||||
}
|
||||
|
||||
const waitForFloorLabelLayer = async (page: Page, floor: FacilityFloorSelection) => {
|
||||
await expect.poll(async () => page.evaluate(({ floorId, balancedPoiIds, fullPoiIds }) => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) return false
|
||||
const labelIds = new Set(
|
||||
diagnostics.getAmbientPoiLabelStates()
|
||||
.filter((label) => label.floorId === floorId)
|
||||
.map((label) => label.poiId)
|
||||
)
|
||||
const hasBalanced = !balancedPoiIds.length || balancedPoiIds.some((poiId) => labelIds.has(poiId))
|
||||
const hasFull = !fullPoiIds.length || fullPoiIds.some((poiId) => labelIds.has(poiId))
|
||||
return hasBalanced && hasFull
|
||||
}, floor), { timeout: 30_000 }).toBe(true)
|
||||
await page.waitForTimeout(160)
|
||||
}
|
||||
|
||||
const getVisibilityTier = (page: Page) => page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return diagnostics.getPoiVisibilityTier()
|
||||
})
|
||||
|
||||
const zoomToTier = async (page: Page, targetTier: PoiVisibilityTier) => {
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
const currentTier = await getVisibilityTier(page)
|
||||
if (currentTier === targetTier) return
|
||||
expect(tierRank[currentTier]).toBeLessThan(tierRank[targetTier])
|
||||
await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
diagnostics.zoomCamera('in')
|
||||
})
|
||||
await waitForCameraIdle(page)
|
||||
}
|
||||
|
||||
expect(await getVisibilityTier(page)).toBe(targetTier)
|
||||
}
|
||||
|
||||
const expectLandmarksToRemainVisible = async (page: Page, poiIds: string[]) => {
|
||||
const states = await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
return diagnostics?.getAmbientPoiLabelStates() || []
|
||||
})
|
||||
const statesById = new Map(states.map((state) => [state.poiId, state]))
|
||||
const eligibleIds = poiIds.filter((poiId) => statesById.get(poiId)?.inViewport)
|
||||
|
||||
for (const poiId of eligibleIds) {
|
||||
expect(statesById.get(poiId)?.visible, `${poiId} was displaced while remaining in viewport`).toBe(true)
|
||||
}
|
||||
|
||||
return {
|
||||
originalCount: poiIds.length,
|
||||
eligibleCount: eligibleIds.length,
|
||||
retainedCount: eligibleIds.filter((poiId) => statesById.get(poiId)?.visible).length
|
||||
}
|
||||
}
|
||||
|
||||
const expectFacilitiesNotToOverlapLandmarks = async (page: Page) => {
|
||||
const labels = page.locator('[data-poi-label-kind="ambient"]:visible')
|
||||
const labelCount = await labels.count()
|
||||
const landmarks: Array<{ poiId: string; bounds: { x: number; y: number; width: number; height: number } }> = []
|
||||
const facilities: Array<{ poiId: string; bounds: { x: number; y: number; width: number; height: number } }> = []
|
||||
|
||||
for (let index = 0; index < labelCount; index += 1) {
|
||||
const label = labels.nth(index)
|
||||
const icon = await label.getAttribute('data-poi-icon') || ''
|
||||
const poiId = await label.getAttribute('data-poi-id') || ''
|
||||
const bounds = await label.boundingBox()
|
||||
if (!bounds) continue
|
||||
if (await label.evaluate((element) => element.classList.contains('three-poi-dom-label--landmark'))) {
|
||||
landmarks.push({ poiId, bounds })
|
||||
} else if (balancedIcons.has(icon) || fullIcons.has(icon)) {
|
||||
facilities.push({ poiId, bounds })
|
||||
}
|
||||
}
|
||||
|
||||
for (const facility of facilities) {
|
||||
for (const landmark of landmarks) {
|
||||
const overlaps = facility.bounds.x < landmark.bounds.x + landmark.bounds.width
|
||||
&& facility.bounds.x + facility.bounds.width > landmark.bounds.x
|
||||
&& facility.bounds.y < landmark.bounds.y + landmark.bounds.height
|
||||
&& facility.bounds.y + facility.bounds.height > landmark.bounds.y
|
||||
expect(overlaps, `${facility.poiId} overlaps landmark ${landmark.poiId}`).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logPhase = (
|
||||
phase: string,
|
||||
floor: FacilityFloorSelection,
|
||||
labels: VisibleLabel[],
|
||||
landmarkRetention?: { originalCount: number; eligibleCount: number; retainedCount: number }
|
||||
) => {
|
||||
const facilityCount = labels.filter((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon)).length
|
||||
console.log('[infrastructure-distance-labels]', JSON.stringify({
|
||||
phase,
|
||||
floor: floor.label,
|
||||
floorId: floor.floorId,
|
||||
landmarkCount: labels.filter((label) => label.landmark).length,
|
||||
elevatorCount: labels.filter((label) => label.icon === 'elevator').length,
|
||||
restroomCount: labels.filter((label) => label.icon === 'restroom').length,
|
||||
stairsCount: labels.filter((label) => label.icon === 'stairs').length,
|
||||
escalatorCount: labels.filter((label) => label.icon === 'escalator').length,
|
||||
facilityTexts: labels
|
||||
.filter((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon))
|
||||
.map((label) => label.text),
|
||||
landmarkRetention: landmarkRetention || null,
|
||||
facilityCount,
|
||||
balancedFacilityCount: labels.filter((label) => balancedIcons.has(label.icon)).length,
|
||||
fullFacilityCount: labels.filter((label) => fullIcons.has(label.icon)).length
|
||||
}))
|
||||
}
|
||||
|
||||
const capturePhaseScreenshot = async (
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
fileName: string
|
||||
) => {
|
||||
const path = testInfo.outputPath(fileName)
|
||||
await page.screenshot({ path })
|
||||
console.log('[infrastructure-distance-labels-screenshot]', path)
|
||||
}
|
||||
|
||||
const runFloorScenario = async (
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
floor: FacilityFloorSelection,
|
||||
requiredTier: 'balanced' | 'full'
|
||||
) => {
|
||||
await page.evaluate(async (floorId) => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await diagnostics.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
|
||||
}, floor.floorId)
|
||||
await waitForCameraIdle(page)
|
||||
await waitForFloorLabelLayer(page, floor)
|
||||
expect(await getVisibilityTier(page)).toBe('tight')
|
||||
|
||||
const farLabels = await getVisibleLabels(page)
|
||||
const farLandmarkIds = farLabels.filter((label) => label.landmark).map((label) => label.poiId)
|
||||
expect(farLandmarkIds.length).toBeGreaterThan(0)
|
||||
expect(farLabels.some((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon))).toBe(false)
|
||||
await capturePhaseScreenshot(page, testInfo, `infrastructure-labels-far-${floor.label}.png`)
|
||||
logPhase('far', floor, farLabels)
|
||||
|
||||
await zoomToTier(page, 'balanced')
|
||||
const balancedLabels = await getVisibleLabels(page)
|
||||
const balancedLandmarkRetention = await expectLandmarksToRemainVisible(page, farLandmarkIds)
|
||||
if (floor.balancedPoiIds.length > 0) {
|
||||
expect(balancedLabels.filter((label) => balancedIcons.has(label.icon)).length).toBeGreaterThan(0)
|
||||
}
|
||||
if (floor.label === '1F') {
|
||||
expect(balancedLabels.some((label) => label.icon === 'elevator' && label.text === '电梯')).toBe(true)
|
||||
}
|
||||
expectFacilityLabelsUseShortNames(balancedLabels)
|
||||
await expectVisibleAnchorsMatchFloorPois(page, floor)
|
||||
expect(balancedLabels.filter((label) => fullIcons.has(label.icon))).toHaveLength(0)
|
||||
expect(balancedLabels.filter((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon)).length).toBeLessThanOrEqual(4)
|
||||
await expectFacilitiesNotToOverlapLandmarks(page)
|
||||
await capturePhaseScreenshot(page, testInfo, `infrastructure-labels-balanced-${floor.label}.png`)
|
||||
logPhase('balanced', floor, balancedLabels, balancedLandmarkRetention)
|
||||
|
||||
if (requiredTier === 'full') {
|
||||
await zoomToTier(page, 'full')
|
||||
const fullLabels = await getVisibleLabels(page)
|
||||
const fullFacilityStates = await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
return (diagnostics?.getAmbientPoiLabelStates() || []).filter((label) => (
|
||||
['stairs', 'stair', 'escalator'].includes(label.iconType)
|
||||
))
|
||||
})
|
||||
console.log('[infrastructure-distance-label-states]', JSON.stringify({
|
||||
floor: floor.label,
|
||||
states: fullFacilityStates
|
||||
}))
|
||||
const fullLandmarkRetention = await expectLandmarksToRemainVisible(page, farLandmarkIds)
|
||||
expect(fullLabels.filter((label) => fullIcons.has(label.icon)).length).toBeGreaterThan(0)
|
||||
if (floor.label === '1F') {
|
||||
expect(fullLabels.some((label) => (
|
||||
(label.icon === 'stairs' && label.text === '楼梯')
|
||||
|| (label.icon === 'escalator' && label.text === '扶梯')
|
||||
))).toBe(true)
|
||||
}
|
||||
expectFacilityLabelsUseShortNames(fullLabels)
|
||||
await expectVisibleAnchorsMatchFloorPois(page, floor)
|
||||
expect(fullLabels.filter((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon)).length).toBeLessThanOrEqual(8)
|
||||
await expectFacilitiesNotToOverlapLandmarks(page)
|
||||
await capturePhaseScreenshot(page, testInfo, `infrastructure-labels-full-${floor.label}.png`)
|
||||
logPhase('full', floor, fullLabels, fullLandmarkRetention)
|
||||
}
|
||||
}
|
||||
|
||||
test('1F reveals centered infrastructure labels with large-screen anchors', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
const floors = await findFacilityFloors(page)
|
||||
const firstFloor = floors.find((floor) => floor.label === '1F')
|
||||
expect(firstFloor?.balancedPoiIds.length, '1F has no balanced infrastructure POIs').toBeGreaterThan(0)
|
||||
expect(firstFloor?.fullPoiIds.length, '1F has no near-only infrastructure POIs').toBeGreaterThan(0)
|
||||
if (firstFloor) await runFloorScenario(page, testInfo, firstFloor, 'full')
|
||||
})
|
||||
|
||||
test('5F keeps infrastructure distance-label behavior', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
const floors = await findFacilityFloors(page)
|
||||
const fifthFloor = floors.find((floor) => floor.label === '5F')
|
||||
expect(fifthFloor, '5F has no infrastructure POIs').toBeTruthy()
|
||||
if (fifthFloor) {
|
||||
await runFloorScenario(page, testInfo, fifthFloor, fifthFloor.fullPoiIds.length ? 'full' : 'balanced')
|
||||
}
|
||||
})
|
||||
@@ -10,10 +10,33 @@ interface PoiCandidate {
|
||||
|
||||
interface GuideDiagnostics {
|
||||
isInitialModelReady: () => boolean
|
||||
getFloors: () => Array<{ floorId: string }>
|
||||
getReport: () => { floorId: string; activeFocusPoiId: string }
|
||||
getFloors: () => Array<{ floorId: string; label: string }>
|
||||
getReport: () => {
|
||||
floorId: string
|
||||
activeFocusPoiId: string
|
||||
activeView: 'overview' | 'floor' | 'multi'
|
||||
visibleVisualMarkerWithoutTextCount: number
|
||||
}
|
||||
resetToViewBaseline: (options: { view: 'floor'; floorId: string; reason: 'floor-reset' }) => Promise<unknown>
|
||||
switchFloor: (floorId: string) => Promise<unknown>
|
||||
showMultiFloor: () => Promise<unknown>
|
||||
getFloorPois: (floorId: string) => Promise<PoiCandidate[]>
|
||||
getVisiblePoiScreenPositions: () => Array<{
|
||||
poiId: string
|
||||
screen: { x: number; y: number }
|
||||
}>
|
||||
getPoiFocusState: (poiId?: string) => {
|
||||
poiId: string
|
||||
selectedPoiId: string
|
||||
dataTier: 'fast' | 'full' | null
|
||||
focusStartedDataTier: 'fast' | 'full' | null
|
||||
markerCount: number
|
||||
displayPositionCount: number
|
||||
baseAffordanceCount: number
|
||||
pulseAffordanceCount: number
|
||||
glowAffordanceCount: number
|
||||
modelHighlightRootCount: number
|
||||
}
|
||||
focusTargetPoi: (request: PoiCandidate & { requestId: string }) => Promise<unknown>
|
||||
clearTargetFocus: () => void
|
||||
}
|
||||
@@ -27,12 +50,57 @@ const openGuide = async (page: Page) => {
|
||||
)), { timeout: 180_000 }).toBe(true)
|
||||
}
|
||||
|
||||
const resetToActiveFloor = async (page: Page) => page.evaluate(async () => {
|
||||
const resetToFloorWithLandmarkLabels = async (page: Page) => page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await api.resetToViewBaseline({ view: 'floor', floorId: api.getReport().floorId, reason: 'floor-reset' })
|
||||
return api.getReport().floorId
|
||||
|
||||
for (const floor of api.getFloors()) {
|
||||
const pois = await api.getFloorPois(floor.floorId)
|
||||
const hasLandmark = pois.some((poi) => poi.primaryCategory.startsWith('exhibition_hall'))
|
||||
if (!hasLandmark) continue
|
||||
|
||||
await api.resetToViewBaseline({ view: 'floor', floorId: floor.floorId, reason: 'floor-reset' })
|
||||
return floor.floorId
|
||||
}
|
||||
|
||||
throw new Error('No floor contains landmark POIs')
|
||||
})
|
||||
|
||||
const resetToB2WithDuplicatedBusinessName = async (page: Page) => page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
|
||||
const floor = api.getFloors().find((candidate) => candidate.label === 'B2')
|
||||
if (floor) {
|
||||
await api.resetToViewBaseline({ view: 'floor', floorId: floor.floorId, reason: 'floor-reset' })
|
||||
return floor.floorId
|
||||
}
|
||||
|
||||
throw new Error('B2 landmark floor is unavailable')
|
||||
})
|
||||
|
||||
const resetToFloorByLabel = async (page: Page, label: string) => page.evaluate(async (floorLabel) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
const floor = api.getFloors().find((candidate) => candidate.label === floorLabel)
|
||||
if (!floor) throw new Error(`Floor ${floorLabel} is unavailable`)
|
||||
|
||||
await api.resetToViewBaseline({ view: 'floor', floorId: floor.floorId, reason: 'floor-reset' })
|
||||
return floor.floorId
|
||||
}, label)
|
||||
|
||||
test('uses the current backend name for the exterior Honghuatan Road label', async ({ page }) => {
|
||||
await openGuide(page)
|
||||
|
||||
const roadLabel = page.locator(
|
||||
'[data-poi-label-kind="ambient"][data-poi-id="overview-honghua-road"]:visible'
|
||||
)
|
||||
await expect(roadLabel).toBeVisible({ timeout: 30_000 })
|
||||
await expect(roadLabel.locator('.three-poi-dom-label__title')).toHaveText('红花潭路')
|
||||
await expect(roadLabel.locator('.three-poi-dom-label__icon')).toHaveAttribute('data-poi-icon', 'road')
|
||||
})
|
||||
|
||||
for (const deviceScaleFactor of [1, 3]) {
|
||||
@@ -41,18 +109,49 @@ for (const deviceScaleFactor of [1, 3]) {
|
||||
|
||||
test('renders measured native labels and clears focused labels without residue', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
const floorId = await resetToActiveFloor(page)
|
||||
const floorId = await resetToFloorWithLandmarkLabels(page)
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getPoiFocusState().dataTier || null
|
||||
)), { timeout: 30_000 }).toBe('full')
|
||||
// Exterior labels are scene-owned. Once an indoor floor commits, no
|
||||
// exterior DOM label may survive an earlier asynchronous load.
|
||||
await expect(page.locator('.three-poi-dom-label--overview')).toHaveCount(0)
|
||||
const labels = page.locator('[data-poi-label-kind="ambient"]:visible')
|
||||
await expect(labels.first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const hall = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--hall:visible').first()
|
||||
const facility = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--service').first()
|
||||
const markerPositions = new Map<string, { x: number; y: number }>()
|
||||
;(await page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getVisiblePoiScreenPositions() || []
|
||||
))).forEach((marker) => {
|
||||
// A merged logical POI can have several physical markers, while its
|
||||
// single DOM label is anchored to the first (canonical) marker.
|
||||
if (!markerPositions.has(marker.poiId)) markerPositions.set(marker.poiId, marker.screen)
|
||||
})
|
||||
const labelCount = await labels.count()
|
||||
await expect(labels.first().locator('.three-poi-dom-label__icon')).toBeVisible()
|
||||
for (let index = 0; index < labelCount; index += 1) {
|
||||
const label = labels.nth(index)
|
||||
const poiId = await label.getAttribute('data-poi-id')
|
||||
const bounds = await label.boundingBox()
|
||||
const marker = poiId ? markerPositions.get(poiId) : null
|
||||
if (!bounds || !marker) continue
|
||||
|
||||
// Only the one calibrated B2 pair may use a compact screen-only
|
||||
// offset; all other labels keep their management-calibrated anchor.
|
||||
expect(Math.abs(bounds.x + bounds.width / 2 - marker.x)).toBeLessThanOrEqual(44)
|
||||
expect(Math.abs(bounds.y + bounds.height - marker.y)).toBeLessThanOrEqual(25)
|
||||
await expect(page.locator(`[data-poi-leader-for="${poiId}"]`)).toHaveCount(0)
|
||||
}
|
||||
|
||||
const hall = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--landmark:visible').first()
|
||||
await expect(hall).toBeVisible()
|
||||
|
||||
for (const locator of [hall, facility]) {
|
||||
for (const locator of [hall]) {
|
||||
await expect(locator).toHaveCSS('pointer-events', 'none')
|
||||
const fontSize = await locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(fontSize).toBeGreaterThanOrEqual(locator === hall ? 14 : 13)
|
||||
expect(fontSize).toBeGreaterThanOrEqual(12)
|
||||
}
|
||||
|
||||
for (const locator of [hall]) {
|
||||
@@ -64,7 +163,7 @@ for (const deviceScaleFactor of [1, 3]) {
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(844)
|
||||
}
|
||||
|
||||
const poiId = await facility.getAttribute('data-poi-id')
|
||||
const poiId = await hall.getAttribute('data-poi-id')
|
||||
expect(poiId).toBeTruthy()
|
||||
await page.evaluate(async ({ floorId: targetFloorId, targetPoiId }) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
@@ -75,11 +174,12 @@ for (const deviceScaleFactor of [1, 3]) {
|
||||
await api.focusTargetPoi({ ...poi, requestId: `poi-dom-label-${targetPoiId}` })
|
||||
}, { floorId, targetPoiId: poiId! })
|
||||
|
||||
const focus = page.locator('[data-poi-label-kind="focus"][data-focus-label="active"]')
|
||||
const focus = page.locator(`[data-poi-label-kind="ambient"][data-poi-id="${poiId}"]`)
|
||||
await expect(focus).toBeVisible({ timeout: 20_000 })
|
||||
await expect(focus).toHaveCSS('pointer-events', 'none')
|
||||
await expect(focus.locator('.three-poi-dom-label__title')).toHaveCSS('font-size', '16px')
|
||||
await expect(focus.locator('.three-poi-dom-label__meta')).toHaveCSS('font-size', '13px')
|
||||
await expect(focus).toHaveClass(/three-poi-dom-label--selected/)
|
||||
await expect(focus.locator('.three-poi-dom-label__title')).toHaveCSS('font-size', '12px')
|
||||
await expect(focus.locator('.three-poi-dom-label__meta')).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath(`poi-dom-labels-dpr-${deviceScaleFactor}-focus.png`) })
|
||||
|
||||
await page.evaluate(() => {
|
||||
@@ -87,8 +187,311 @@ for (const deviceScaleFactor of [1, 3]) {
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
api?.clearTargetFocus()
|
||||
})
|
||||
await expect(focus).toHaveCount(0)
|
||||
await expect(focus).not.toHaveClass(/three-poi-dom-label--selected/)
|
||||
await page.screenshot({ path: testInfo.outputPath(`poi-dom-labels-dpr-${deviceScaleFactor}-ambient.png`) })
|
||||
})
|
||||
|
||||
test('keeps the B2 landmark pair separate and merges duplicate business labels', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
await resetToB2WithDuplicatedBusinessName(page)
|
||||
|
||||
const vr = page.locator('[data-poi-label-kind="ambient"]', { hasText: 'XR虚拟现实影院' })
|
||||
const youth = page.locator('[data-poi-label-kind="ambient"]', { hasText: '青少年探索中心' })
|
||||
await expect(vr).toBeVisible({ timeout: 30_000 })
|
||||
await expect(youth).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect.poll(async () => {
|
||||
const vrBox = await vr.boundingBox()
|
||||
const youthBox = await youth.boundingBox()
|
||||
if (!vrBox || !youthBox) return null
|
||||
return vrBox.x < youthBox.x + youthBox.width
|
||||
&& vrBox.x + vrBox.width > youthBox.x
|
||||
&& vrBox.y < youthBox.y + youthBox.height
|
||||
&& vrBox.y + vrBox.height > youthBox.y
|
||||
}, { timeout: 30_000 }).toBe(false)
|
||||
|
||||
await expect(page.locator('[data-poi-label-kind="ambient"]:visible', { hasText: '秦匠' })).toHaveCount(1)
|
||||
|
||||
const artisan = await page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
const floorId = api.getReport().floorId
|
||||
return (await api.getFloorPois(floorId)).find((poi) => poi.name === '秦匠') || null
|
||||
})
|
||||
expect(artisan).not.toBeNull()
|
||||
await page.evaluate(async (request) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api || !request) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await api.focusTargetPoi({ ...request, requestId: 'poi-dom-label-merged-business' })
|
||||
}, artisan!)
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport().activeFocusPoiId || ''
|
||||
))).toBe(artisan!.poiId)
|
||||
|
||||
await expect.poll(async () => page.evaluate((poiId) => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getPoiFocusState(poiId).dataTier || null
|
||||
), artisan!.poiId), { timeout: 30_000 }).toBe('full')
|
||||
|
||||
const focusState = await page.evaluate((poiId) => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getPoiFocusState(poiId) || null
|
||||
), artisan!.poiId)
|
||||
expect(focusState).toMatchObject({
|
||||
poiId: artisan!.poiId,
|
||||
selectedPoiId: artisan!.poiId,
|
||||
dataTier: 'full',
|
||||
markerCount: 1,
|
||||
displayPositionCount: 2,
|
||||
baseAffordanceCount: 0,
|
||||
pulseAffordanceCount: 0,
|
||||
glowAffordanceCount: 0,
|
||||
modelHighlightRootCount: 2
|
||||
})
|
||||
await page.screenshot({ path: testInfo.outputPath(`qinjiang-focus-dpr-${deviceScaleFactor}.png`) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('keeps floor ambient labels scene-owned across floor, multi, and floor transitions', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
await openGuide(page)
|
||||
const floorId = await resetToFloorWithLandmarkLabels(page)
|
||||
const ambientLabels = page.locator('[data-poi-label-kind="ambient"]:visible')
|
||||
await expect(ambientLabels.first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport().visibleVisualMarkerWithoutTextCount ?? -1
|
||||
))).toBe(0)
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await api.showMultiFloor()
|
||||
})
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport().activeView || ''
|
||||
)), { timeout: 120_000 }).toBe('multi')
|
||||
await expect(ambientLabels).toHaveCount(0)
|
||||
await page.waitForTimeout(1500)
|
||||
await expect(ambientLabels).toHaveCount(0)
|
||||
|
||||
await page.evaluate(async (targetFloorId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await api.resetToViewBaseline({ view: 'floor', floorId: targetFloorId, reason: 'floor-reset' })
|
||||
}, floorId)
|
||||
await expect(ambientLabels.first()).toBeVisible({ timeout: 30_000 })
|
||||
const visibleFloorIds = await ambientLabels.evaluateAll((elements) => (
|
||||
elements.map((element) => (element as HTMLElement).dataset.floorId || '')
|
||||
))
|
||||
expect(new Set(visibleFloorIds)).toEqual(new Set([floorId]))
|
||||
})
|
||||
|
||||
test('shows the eleven fresh B2 default destinations after the map floor changes', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
await openGuide(page)
|
||||
const b2FloorId = await page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
const floor = api.getFloors().find((candidate) => candidate.label === 'B2')
|
||||
if (!floor) throw new Error('B2 is unavailable')
|
||||
await api.switchFloor(floor.floorId)
|
||||
return floor.floorId
|
||||
})
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const report = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport()
|
||||
return report ? `${report.activeView}:${report.floorId}` : ''
|
||||
}), { timeout: 120_000 }).toBe(`floor:${b2FloorId}`)
|
||||
|
||||
await page.locator('.search-box').click()
|
||||
await expect(page.locator('[data-testid="poi-result-list"] .result-row')).toHaveCount(11, {
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect(page.locator('.result-count')).toContainText('11 处地点')
|
||||
})
|
||||
|
||||
test('merges the four numbered L1 ticket machines across labels, markers, and search', async ({ page }) => {
|
||||
await openGuide(page)
|
||||
const l1FloorId = await resetToFloorByLabel(page, '1F')
|
||||
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getPoiFocusState('5682').dataTier || null
|
||||
)), { timeout: 30_000 }).toBe('full')
|
||||
|
||||
await expect(page.locator('[data-poi-label-kind="ambient"][data-poi-id="5682"]:visible')).toHaveCount(1, {
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect(page.locator('[data-poi-label-kind="ambient"][data-poi-id="5591"]:visible')).toHaveCount(1)
|
||||
await expect(page.locator(
|
||||
'[data-poi-label-kind="ambient"][data-poi-id="352502409978861752"]:visible'
|
||||
)).toHaveCount(1)
|
||||
|
||||
const l1Pois = await page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
const floor = api.getFloors().find((candidate) => candidate.label === '1F')
|
||||
if (!floor) throw new Error('1F is unavailable')
|
||||
return api.getFloorPois(floor.floorId)
|
||||
})
|
||||
expect(l1Pois.find((poi) => poi.poiId === '5591')?.positionGltf).toEqual([
|
||||
3.587204,
|
||||
1.205524,
|
||||
13.269173
|
||||
])
|
||||
|
||||
const floorExits = [
|
||||
['floor-exit-2077983437229297849', '展览入口'],
|
||||
['floor-exit-2077983437229297850', '观众次入口'],
|
||||
['floor-exit-2077983437229297851', '影剧院入口']
|
||||
] as const
|
||||
for (const [poiId, name] of floorExits) {
|
||||
const exitPoi = l1Pois.find((poi) => poi.poiId === poiId)
|
||||
expect(exitPoi).toMatchObject({ name, primaryCategory: 'navigation_anchor' })
|
||||
}
|
||||
const floorExitStates = await page.evaluate((poiIds) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return poiIds.map((poiId) => api.getPoiFocusState(poiId))
|
||||
}, floorExits.map(([poiId]) => poiId))
|
||||
expect(floorExitStates).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ markerCount: 1, displayPositionCount: 1 }),
|
||||
expect.objectContaining({ markerCount: 1, displayPositionCount: 1 }),
|
||||
expect.objectContaining({ markerCount: 1, displayPositionCount: 1 })
|
||||
]))
|
||||
await expect(page.locator(
|
||||
'[data-poi-label-kind="ambient"][data-poi-id="floor-exit-2077983437229297849"]:visible'
|
||||
)).toHaveCount(1)
|
||||
await expect(page.locator(
|
||||
'[data-poi-label-kind="ambient"][data-poi-id="floor-exit-2077983437229297851"]:visible'
|
||||
)).toHaveCount(1)
|
||||
|
||||
const markerState = await page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getPoiFocusState('5682') || null
|
||||
))
|
||||
expect(markerState).toMatchObject({
|
||||
dataTier: 'full',
|
||||
markerCount: 4,
|
||||
displayPositionCount: 4
|
||||
})
|
||||
|
||||
await page.getByTestId('poi-category-ticket-office').first().click()
|
||||
await expect(page.getByTestId('poi-result-5682')).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(page.getByTestId('poi-result-5683')).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(page.getByTestId('poi-result-5684')).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(page.getByTestId('poi-result-5685')).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(page.getByTestId('poi-result-5591')).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(page.getByTestId('poi-result-352502409978861752')).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(page.getByTestId('poi-result-5682').locator('.result-name')).toHaveText('售票机')
|
||||
await expect(page.getByTestId('poi-result-5682').locator('.result-meta')).toHaveText('1F · 售票处')
|
||||
await expect(page.locator('.home-category-results-meta')).toHaveText('当前楼层 1F · 3 处地点')
|
||||
await expect(page.locator('.floor-switcher')).toBeVisible()
|
||||
await expect(page.locator('.clear-button')).toHaveCount(0)
|
||||
await expect(page.getByTestId('poi-category-cancel')).toHaveCount(1)
|
||||
|
||||
await page.locator('canvas').hover()
|
||||
await page.mouse.wheel(0, 3000)
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const report = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport()
|
||||
return report ? `${report.activeView}:${report.floorId}` : ''
|
||||
}), { timeout: 30_000 }).toBe(`floor:${l1FloorId}`)
|
||||
await expect(page.locator('.floor-switcher')).toBeVisible()
|
||||
await expect(page.locator('.home-category-results-meta')).toHaveText('当前楼层 1F · 3 处地点')
|
||||
})
|
||||
|
||||
test('keeps the floor mode toggle light while committing multi and single views', async ({ page }) => {
|
||||
await openGuide(page)
|
||||
const l1FloorId = await resetToFloorByLabel(page, '1F')
|
||||
const floorHeader = page.locator('.floor-header')
|
||||
|
||||
await page.getByTestId('poi-category-ticket-office').first().click()
|
||||
await expect(page.getByTestId('poi-result-5682')).toBeVisible({ timeout: 30_000 })
|
||||
await expect(floorHeader).toBeVisible({ timeout: 30_000 })
|
||||
await expect(floorHeader.locator('.floor-header-label')).toHaveText('多层')
|
||||
const singleLayout = await floorHeader.evaluate((element) => {
|
||||
const icon = element.querySelector('.floor-header-icon')?.getBoundingClientRect()
|
||||
const label = element.querySelector('.floor-header-label')?.getBoundingClientRect()
|
||||
if (!icon || !label) return null
|
||||
return {
|
||||
iconBottom: icon.bottom,
|
||||
labelTop: label.top,
|
||||
iconCenter: icon.left + icon.width / 2,
|
||||
labelCenter: label.left + label.width / 2
|
||||
}
|
||||
})
|
||||
expect(singleLayout).not.toBeNull()
|
||||
expect(singleLayout!.iconBottom).toBeLessThanOrEqual(singleLayout!.labelTop)
|
||||
expect(Math.abs(singleLayout!.iconCenter - singleLayout!.labelCenter)).toBeLessThan(1)
|
||||
await floorHeader.click()
|
||||
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport().activeView || ''
|
||||
)), { timeout: 120_000 }).toBe('multi')
|
||||
await expect(floorHeader).toHaveClass(/active/)
|
||||
await expect(floorHeader.locator('.floor-header-label')).toHaveText('单层')
|
||||
await expect(floorHeader).toHaveCSS('background-color', 'rgb(237, 241, 255)')
|
||||
|
||||
await page.getByTestId('poi-category-cancel').click()
|
||||
await expect(page.locator('.home-category-results')).toHaveCount(0)
|
||||
await expect(floorHeader).toBeVisible()
|
||||
await floorHeader.click()
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const report = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport()
|
||||
return report ? `${report.activeView}:${report.floorId}` : ''
|
||||
}), { timeout: 120_000 }).toBe(`floor:${l1FloorId}`)
|
||||
await expect(floorHeader).not.toHaveClass(/active/)
|
||||
await expect(floorHeader.locator('.floor-header-label')).toHaveText('多层')
|
||||
await expect(floorHeader).toHaveCSS('background-color', 'rgb(255, 255, 255)')
|
||||
})
|
||||
|
||||
test('uses one visual specification for landmark and service labels', async ({ page }) => {
|
||||
await openGuide(page)
|
||||
await resetToFloorByLabel(page, '1F')
|
||||
|
||||
await expect.poll(async () => page.locator(
|
||||
'[data-poi-label-kind="ambient"]:visible'
|
||||
).count(), { timeout: 30_000 }).toBeGreaterThan(3)
|
||||
|
||||
const metrics = await page.locator('[data-poi-label-kind="ambient"]:visible').evaluateAll((elements) => (
|
||||
elements.map((element) => {
|
||||
const icon = element.querySelector('.three-poi-dom-label__icon')
|
||||
const style = window.getComputedStyle(element)
|
||||
const iconStyle = icon ? window.getComputedStyle(icon) : null
|
||||
return {
|
||||
height: element.getBoundingClientRect().height,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
iconWidth: iconStyle?.width || '',
|
||||
iconHeight: iconStyle?.height || ''
|
||||
}
|
||||
})
|
||||
))
|
||||
|
||||
expect(metrics).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ height: 22, fontSize: '12px', lineHeight: '15px', iconWidth: '16px', iconHeight: '16px' })
|
||||
]))
|
||||
for (const metric of metrics) {
|
||||
expect(metric).toEqual({
|
||||
height: 22,
|
||||
fontSize: '12px',
|
||||
lineHeight: '15px',
|
||||
iconWidth: '16px',
|
||||
iconHeight: '16px'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
54
tests/e2e/route-detail-cross-floor.spec.ts
Normal file
54
tests/e2e/route-detail-cross-floor.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
interface GuidePerformanceApi {
|
||||
summary: () => {
|
||||
operations: Array<{
|
||||
kind: string
|
||||
name: string
|
||||
count: number
|
||||
successCount: number
|
||||
failureCount: number
|
||||
p50Ms?: number
|
||||
p95Ms?: number
|
||||
maxMs?: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
test('plans a cross-floor route from the route detail start-floor picker', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
await page.goto('/#/pages/route/detail?facilityId=5515&target=%E6%AF%8D%E5%A9%B4%E5%AE%A4')
|
||||
|
||||
await expect(page.locator('.indoor-guide-card')).toBeVisible({ timeout: 180_000 })
|
||||
await expect(page.locator('.start-point-select')).toBeVisible({ timeout: 30_000 })
|
||||
await page.locator('.start-point-select').click()
|
||||
await expect(page.locator('.route-point-picker')).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const firstFloor = page.locator('.picker-floor-option').filter({ hasText: '1F' }).first()
|
||||
await expect(firstFloor).toBeVisible({ timeout: 30_000 })
|
||||
await firstFloor.click()
|
||||
const startOption = page.locator('.point-column .picker-option').first()
|
||||
await expect(startOption).toBeVisible({ timeout: 30_000 })
|
||||
const startText = await startOption.locator('.option-meta').innerText()
|
||||
expect(startText).toContain('1F')
|
||||
await startOption.click()
|
||||
|
||||
await expect(page.locator('.route-point-picker')).toBeHidden({ timeout: 30_000 })
|
||||
await expect(page.locator('.start-point-select-value')).toContainText('1F')
|
||||
await page.getByText('查看位置关系', { exact: true }).click()
|
||||
|
||||
await expect(page.locator('.indoor-guide-desc')).toContainText('已生成', { timeout: 120_000 })
|
||||
const performance = await page.evaluate(() => (
|
||||
(window as Window & { __SGS_GUIDE_PERFORMANCE__?: GuidePerformanceApi })
|
||||
.__SGS_GUIDE_PERFORMANCE__?.summary()
|
||||
))
|
||||
const routePlan = performance?.operations.find((operation) => (
|
||||
operation.kind === 'interaction' && operation.name === 'route-plan'
|
||||
))
|
||||
expect(routePlan, 'cross-floor route-plan performance record is required').toMatchObject({
|
||||
count: 1,
|
||||
successCount: 1,
|
||||
failureCount: 0
|
||||
})
|
||||
console.log(`[cross-floor-route-performance] ${JSON.stringify(routePlan)}`)
|
||||
})
|
||||
209
tests/e2e/route-navigation.spec.ts
Normal file
209
tests/e2e/route-navigation.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
interface VisiblePoi {
|
||||
poiId: string
|
||||
floorId: string
|
||||
name: string
|
||||
screen: { x: number; y: number }
|
||||
}
|
||||
|
||||
interface GuideDiagnostics {
|
||||
isInitialModelReady: () => boolean
|
||||
getReport: () => {
|
||||
activeView: 'overview' | 'floor' | 'multi'
|
||||
floorId: string
|
||||
isCameraTweening: boolean
|
||||
}
|
||||
getFloors: () => Array<{ floorId: string; label: string }>
|
||||
switchFloor: (floorId: string) => Promise<void>
|
||||
getVisiblePoiScreenPositions: () => VisiblePoi[]
|
||||
}
|
||||
|
||||
interface GuidePerformanceApi {
|
||||
summary: () => {
|
||||
operations: Array<{
|
||||
kind: string
|
||||
name: string
|
||||
count: number
|
||||
successCount: number
|
||||
failureCount: number
|
||||
p50Ms?: number
|
||||
p95Ms?: number
|
||||
maxMs?: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const openGuideAtFirstFloor = async (page: Page) => {
|
||||
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__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
||||
)), { timeout: 180_000 }).toBe(true)
|
||||
|
||||
const floorId = await page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
const floor = api.getFloors().find((candidate) => candidate.label === '1F')
|
||||
if (!floor) throw new Error('1F is unavailable')
|
||||
await api.switchFloor(floor.floorId)
|
||||
return floor.floorId
|
||||
})
|
||||
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const report = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport()
|
||||
return report ? `${report.activeView}:${report.floorId}:${report.isCameraTweening}` : ''
|
||||
}), { timeout: 120_000 }).toBe(`floor:${floorId}:false`)
|
||||
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getVisiblePoiScreenPositions().length || 0
|
||||
)), { timeout: 30_000 }).toBeGreaterThan(1)
|
||||
|
||||
return floorId
|
||||
}
|
||||
|
||||
const findVisiblePoi = async (page: Page, name: string) => {
|
||||
const deadline = Date.now() + 30_000
|
||||
let poi: VisiblePoi | null = null
|
||||
while (!poi && Date.now() < deadline) {
|
||||
poi = await page.evaluate((targetName) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
return api?.getVisiblePoiScreenPositions().find((candidate) => candidate.name === targetName) || null
|
||||
}, name)
|
||||
if (!poi) await page.waitForTimeout(100)
|
||||
}
|
||||
expect(poi, `visible POI ${name} is required for route flow`).not.toBeNull()
|
||||
return poi!
|
||||
}
|
||||
|
||||
const clickPoiLabel = async (page: Page, poi: VisiblePoi) => {
|
||||
const selector = `[data-poi-label-kind="ambient"][data-poi-id="${poi.poiId}"]`
|
||||
const canvas = page.locator('.three-canvas-wrapper canvas').first()
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 })
|
||||
const deadline = Date.now() + 30_000
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const labelCenter = await page.locator(selector).first().evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
if (
|
||||
style.visibility !== 'visible'
|
||||
|| style.opacity === '0'
|
||||
|| bounds.width <= 0
|
||||
|| bounds.height <= 0
|
||||
) return null
|
||||
return {
|
||||
x: bounds.left + bounds.width / 2,
|
||||
y: bounds.top + bounds.height / 2
|
||||
}
|
||||
}).catch(() => null)
|
||||
const canvasBounds = labelCenter ? await canvas.boundingBox() : null
|
||||
if (labelCenter && canvasBounds) {
|
||||
const position = {
|
||||
x: labelCenter.x - canvasBounds.x,
|
||||
y: labelCenter.y - canvasBounds.y
|
||||
}
|
||||
if (
|
||||
position.x >= 0
|
||||
&& position.x <= canvasBounds.width
|
||||
&& position.y >= 0
|
||||
&& position.y <= canvasBounds.height
|
||||
) {
|
||||
await canvas.click({ position, timeout: 5_000 })
|
||||
return
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
|
||||
throw new Error(`label for ${poi.name} did not provide a stable map click point`)
|
||||
}
|
||||
|
||||
test('plans a same-floor route from a real map start and restores the browse floor on close', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
const floorId = await openGuideAtFirstFloor(page)
|
||||
const destination = await findVisiblePoi(page, '售票机')
|
||||
const start = await findVisiblePoi(page, '影院服务台')
|
||||
expect(destination.poiId).not.toBe(start.poiId)
|
||||
expect(destination.floorId).toBe(floorId)
|
||||
expect(start.floorId).toBe(floorId)
|
||||
|
||||
await clickPoiLabel(page, destination)
|
||||
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-planner-panel')).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.locator('.route-start-card')).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await clickPoiLabel(page, start)
|
||||
await expect(page.locator('.route-confirm-mask')).toBeVisible({ timeout: 30_000 })
|
||||
await page.locator('.route-confirm-action.primary').click()
|
||||
|
||||
const routeReadyOrError = page.locator('.route-ready-card, .route-state-card.error')
|
||||
await expect(routeReadyOrError).toBeVisible({ timeout: 120_000 })
|
||||
await expect(page.locator('.route-ready-card'), 'same-floor route should be generated').toBeVisible()
|
||||
await expect(page.locator('.route-ready-summary')).toContainText('米')
|
||||
|
||||
const performance = await page.evaluate(() => (
|
||||
(window as Window & { __SGS_GUIDE_PERFORMANCE__?: GuidePerformanceApi })
|
||||
.__SGS_GUIDE_PERFORMANCE__?.summary()
|
||||
))
|
||||
const routePlan = performance?.operations.find((operation) => (
|
||||
operation.kind === 'interaction' && operation.name === 'route-plan'
|
||||
))
|
||||
expect(routePlan, 'route-plan performance record is required').toMatchObject({
|
||||
count: 1,
|
||||
successCount: 1,
|
||||
failureCount: 0
|
||||
})
|
||||
console.log(`[route-performance] ${JSON.stringify(routePlan)}`)
|
||||
|
||||
await page.locator('.route-panel-close').click()
|
||||
await expect(page.locator('.route-planner-panel')).toBeHidden({ timeout: 30_000 })
|
||||
await expect(page.locator('.guide-poi-card')).toBeVisible({ timeout: 30_000 })
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const report = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport()
|
||||
return report ? `${report.activeView}:${report.floorId}` : ''
|
||||
}), { timeout: 30_000 }).toBe(`floor:${floorId}`)
|
||||
})
|
||||
|
||||
test('shows a recoverable route error when the route endpoint fails', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
await page.route('**/app-api/gis/sdk/routes/plan', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 500, msg: 'simulated route failure' })
|
||||
})
|
||||
})
|
||||
|
||||
const floorId = await openGuideAtFirstFloor(page)
|
||||
const destination = await findVisiblePoi(page, '售票机')
|
||||
const start = await findVisiblePoi(page, '影院服务台')
|
||||
await clickPoiLabel(page, destination)
|
||||
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-planner-panel')).toBeVisible({ timeout: 30_000 })
|
||||
await clickPoiLabel(page, start)
|
||||
await expect(page.locator('.route-confirm-mask')).toBeVisible({ timeout: 30_000 })
|
||||
await page.locator('.route-confirm-action.primary').click()
|
||||
|
||||
await expect(page.locator('.route-state-card.error')).toBeVisible({ timeout: 60_000 })
|
||||
await expect(page.locator('.route-state-card.error')).toContainText('路线服务暂时不可用,请稍后重试')
|
||||
await expect(page.locator('.route-state-card.error')).not.toContainText('/app-api')
|
||||
await expect(page.locator('.route-state-card.error')).not.toContainText('500')
|
||||
await expect(page.locator('.route-state-card.error')).not.toContainText('body=')
|
||||
await expect(page.locator('.route-ready-card')).toHaveCount(0)
|
||||
await page.locator('.route-panel-close').click()
|
||||
await expect(page.locator('.route-planner-panel')).toBeHidden({ timeout: 30_000 })
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const report = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.getReport()
|
||||
return report ? `${report.activeView}:${report.floorId}` : ''
|
||||
}), { timeout: 30_000 }).toBe(`floor:${floorId}`)
|
||||
})
|
||||
@@ -116,20 +116,6 @@ const getReport = async (page: Page) => {
|
||||
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 })
|
||||
@@ -169,23 +155,20 @@ const getMaximumCameraDelta = (before: CameraReport, after: CameraReport) => Mat
|
||||
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) => {
|
||||
const assertStableTransition = (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 getFloorBaseline = async (page: Page, floorId: string) => page.evaluate((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')
|
||||
return api.getFloorBaseline(targetFloorId)
|
||||
}, floorId)
|
||||
|
||||
const screenshot = async (page: Page, testInfo: TestInfo, name: string) => {
|
||||
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) })
|
||||
}
|
||||
@@ -213,7 +196,6 @@ const waitForTransition = async (
|
||||
}
|
||||
|
||||
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 })
|
||||
@@ -224,11 +206,15 @@ const switchFloor = async (page: Page, testInfo: TestInfo, floorId: string, name
|
||||
await waitForTransition(page, transition, testInfo, name)
|
||||
const after = await getReport(page)
|
||||
await screenshot(page, testInfo, `${name}-after`)
|
||||
assertStableTransition(before, after)
|
||||
assertStableTransition(after)
|
||||
expect(after.activeView).toBe('floor')
|
||||
expect(after.floorId).toBe(floorId)
|
||||
const baseline = await getFloorBaseline(page, floorId)
|
||||
expect(baseline, `missing ${floorId} floor baseline`).not.toBeNull()
|
||||
expect(getMaximumCameraDelta(baseline!, after.camera)).toBeLessThan(1e-6)
|
||||
}
|
||||
|
||||
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 })
|
||||
@@ -239,7 +225,8 @@ const showOverview = async (page: Page, testInfo: TestInfo, name: string) => {
|
||||
await waitForTransition(page, transition, testInfo, name)
|
||||
const after = await getReport(page)
|
||||
await screenshot(page, testInfo, `${name}-after`)
|
||||
assertStableTransition(before, after)
|
||||
assertStableTransition(after)
|
||||
expect(after.activeView).toBe('overview')
|
||||
}
|
||||
|
||||
const exitOverviewWithZoomInput = async (
|
||||
@@ -272,6 +259,7 @@ const exitOverviewWithZoomInput = async (
|
||||
}
|
||||
|
||||
await expect.poll(async () => (await getReport(page)).activeView, { timeout: 10_000 }).toBe('overview')
|
||||
await expect.poll(async () => (await getReport(page)).isCameraTweening, { timeout: 10_000 }).toBe(false)
|
||||
|
||||
const after = await getReport(page)
|
||||
await screenshot(page, testInfo, `${name}-after`)
|
||||
@@ -316,20 +304,18 @@ const getVectorDelta = (left: { x: number; y: number; z: number }, right: [numbe
|
||||
)
|
||||
|
||||
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
|
||||
if (report.focus && stableFrames >= 3) return report
|
||||
|
||||
await page.waitForTimeout(40)
|
||||
}
|
||||
@@ -379,6 +365,40 @@ test('ordinary exterior and floor switches preserve the GLB_METER camera project
|
||||
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-touch-exit', 'touch')
|
||||
})
|
||||
|
||||
test('delayed exterior zoom button presses still enter the indoor view at the fixed threshold', async ({ page }) => {
|
||||
await openGuide(page)
|
||||
expect((await getReport(page)).activeView).toBe('overview')
|
||||
|
||||
await page.getByText('+', { exact: true }).click()
|
||||
await page.waitForTimeout(2_200)
|
||||
await page.getByText('+', { exact: true }).click()
|
||||
|
||||
await expect.poll(async () => (await getReport(page)).activeView, { timeout: 20_000 }).toBe('floor')
|
||||
})
|
||||
|
||||
test('3F exterior round trip commits the floor at its visual-center baseline', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
const state = await getApiState(page)
|
||||
expect(state).not.toBeNull()
|
||||
const l3 = resolveRequiredFloorId(state!.floors, 'L3')
|
||||
|
||||
await switchFloor(page, testInfo, l3, 'l3-round-trip-enter')
|
||||
const baseline = await getFloorBaseline(page, l3)
|
||||
expect(baseline).not.toBeNull()
|
||||
await showOverview(page, testInfo, 'l3-round-trip-overview')
|
||||
|
||||
await page.getByText('+', { exact: true }).click()
|
||||
await expect.poll(async () => (await getReport(page)).activeView, { timeout: 20_000 }).toBe('floor')
|
||||
await expect.poll(async () => (await getReport(page)).isCameraTweening, { timeout: 10_000 }).toBe(false)
|
||||
|
||||
const reentered = await getReport(page)
|
||||
await screenshot(page, testInfo, 'l3-round-trip-reentered')
|
||||
expect(reentered.floorId).toBe(l3)
|
||||
expect(reentered.isCameraTweening).toBe(false)
|
||||
expect(reentered.modelRoot).not.toBeNull()
|
||||
expect(getMaximumCameraDelta(baseline!, reentered.camera)).toBeLessThan(1e-6)
|
||||
})
|
||||
|
||||
test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and does not rebound', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
|
||||
@@ -486,15 +506,8 @@ test('floor view baseline is deterministic across repeated resets', async ({ pag
|
||||
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)
|
||||
const baseline = await getFloorBaseline(page, 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) => {
|
||||
@@ -506,7 +519,6 @@ test('floor view baseline is deterministic across repeated resets', async ({ pag
|
||||
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)
|
||||
}
|
||||
@@ -541,7 +553,12 @@ test.describe('mobile POI card close restores the floor baseline', () => {
|
||||
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) {
|
||||
// Floor-change completion triggers background neighbor preloading. Let it
|
||||
// finish before checking that selecting an already-rendered marker does
|
||||
// not itself start another model request.
|
||||
await page.waitForTimeout(1_500)
|
||||
|
||||
for (const marker of [facility!, hall!]) {
|
||||
const baseline = await page.evaluate((floorId) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
@@ -549,16 +566,14 @@ test.describe('mobile POI card close restores the floor baseline', () => {
|
||||
}, marker.floorId)
|
||||
expect(baseline).not.toBeNull()
|
||||
let modelRequestsAfterBaseline = 0
|
||||
page.on('request', (request) => {
|
||||
const onRequest = (request: { url: () => string }) => {
|
||||
if (/\.(glb|gltf)(?:$|\?)/i.test(request.url())) modelRequestsAfterBaseline += 1
|
||||
})
|
||||
}
|
||||
page.on('request', onRequest)
|
||||
|
||||
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('')
|
||||
@@ -571,6 +586,7 @@ test.describe('mobile POI card close restores the floor baseline', () => {
|
||||
expect(afterClose.floorId).toBe(marker.floorId)
|
||||
expect(getMaximumCameraDelta(baseline!, afterClose.camera)).toBeLessThan(1e-6)
|
||||
expect(modelRequestsAfterBaseline).toBe(0)
|
||||
page.off('request', onRequest)
|
||||
await page.waitForTimeout(300)
|
||||
const afterIdle = await getReport(page)
|
||||
expect(getMaximumCameraDelta(afterClose.camera, afterIdle.camera)).toBeLessThan(1e-6)
|
||||
|
||||
1255
tests/e2e/weak-network-fallback.spec.ts
Normal file
1255
tests/e2e/weak-network-fallback.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user