Files
frontend-miniapp/tests/e2e/infrastructure-distance-labels.spec.ts
lyf 9ea1bfab71
Some checks failed
CI / verify (push) Has been cancelled
同步 sgs-frontend-mobile 源码
2026-07-27 11:26:01 +08:00

380 lines
15 KiB
TypeScript

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