同步 sgs-frontend-mobile 源码
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-27 11:26:01 +08:00
parent 575dca430f
commit 9ea1bfab71
181 changed files with 24395 additions and 5212 deletions

View 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)
}
})

View 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)
})

View File

@@ -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 }) => {

View 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 })
]))
})

View 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')
}
})

View File

@@ -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'
})
}
})

View 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)}`)
})

View 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}`)
})

View File

@@ -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)

File diff suppressed because it is too large Load Diff