This commit is contained in:
379
tests/e2e/infrastructure-distance-labels.spec.ts
Normal file
379
tests/e2e/infrastructure-distance-labels.spec.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { expect, test, type Page, type TestInfo } from '@playwright/test'
|
||||
|
||||
type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
|
||||
|
||||
interface PoiCandidate {
|
||||
poiId: string
|
||||
floorId: string
|
||||
name: string
|
||||
iconType: string
|
||||
positionGltf: [number, number, number]
|
||||
}
|
||||
|
||||
interface VisibleLabel {
|
||||
poiId: string
|
||||
icon: string
|
||||
landmark: boolean
|
||||
text: string
|
||||
}
|
||||
|
||||
interface AmbientLabelState {
|
||||
poiId: string
|
||||
floorId: string
|
||||
iconType: string
|
||||
text: string
|
||||
visible: boolean
|
||||
inViewport: boolean
|
||||
anchor: { x: number; y: number; z: number }
|
||||
}
|
||||
|
||||
interface GuideDiagnostics {
|
||||
isInitialModelReady: () => boolean
|
||||
getReport: () => {
|
||||
floorId: string
|
||||
activeView: 'overview' | 'floor' | 'multi'
|
||||
isCameraTweening: boolean
|
||||
}
|
||||
getFloors: () => Array<{ floorId: string; label: string }>
|
||||
getFloorPois: (floorId: string) => Promise<PoiCandidate[]>
|
||||
getAmbientPoiLabelStates: () => AmbientLabelState[]
|
||||
getPoiVisibilityTier: () => PoiVisibilityTier
|
||||
resetToViewBaseline: (options: { view: 'floor'; floorId: string; reason: 'floor-reset' }) => Promise<unknown>
|
||||
zoomCamera: (direction: 'in') => void
|
||||
}
|
||||
|
||||
interface FacilityFloorSelection {
|
||||
floorId: string
|
||||
label: string
|
||||
balancedPoiIds: string[]
|
||||
fullPoiIds: string[]
|
||||
}
|
||||
|
||||
const balancedIcons = new Set(['restroom', 'nursing-room', 'elevator'])
|
||||
const fullIcons = new Set(['escalator', 'stairs'])
|
||||
const tierRank: Record<PoiVisibilityTier, number> = {
|
||||
tight: 0,
|
||||
balanced: 1,
|
||||
full: 2
|
||||
}
|
||||
|
||||
const openGuide = async (page: Page) => {
|
||||
await page.goto('/')
|
||||
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
||||
)), { timeout: 180_000 }).toBe(true)
|
||||
}
|
||||
|
||||
const findFacilityFloors = (page: Page) => page.evaluate(async () => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
|
||||
const floors: FacilityFloorSelection[] = []
|
||||
for (const floor of diagnostics.getFloors()) {
|
||||
const pois = await diagnostics.getFloorPois(floor.floorId)
|
||||
const balancedPoiIds = pois
|
||||
.filter((poi) => ['restroom', 'toilet', 'accessible_toilet', 'restroom_accessible', 'mother_baby_room', 'nursing_room', 'elevator'].includes(poi.iconType))
|
||||
.map((poi) => poi.poiId)
|
||||
const fullPoiIds = pois
|
||||
.filter((poi) => ['escalator', 'stairs', 'stair'].includes(poi.iconType))
|
||||
.map((poi) => poi.poiId)
|
||||
if (balancedPoiIds.length || fullPoiIds.length) {
|
||||
floors.push({ ...floor, balancedPoiIds, fullPoiIds })
|
||||
}
|
||||
}
|
||||
return floors
|
||||
})
|
||||
|
||||
const getVisibleLabels = (page: Page) => page.evaluate(() => (
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[data-poi-label-kind="ambient"]'))
|
||||
.filter((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return style.visibility !== 'hidden'
|
||||
&& Number(style.opacity) > 0
|
||||
&& bounds.width > 0
|
||||
&& bounds.height > 0
|
||||
})
|
||||
.map((element) => ({
|
||||
poiId: element.dataset.poiId || '',
|
||||
icon: element.dataset.poiIcon || '',
|
||||
landmark: element.classList.contains('three-poi-dom-label--landmark'),
|
||||
text: element.querySelector('.three-poi-dom-label__title')?.textContent || ''
|
||||
}))
|
||||
))
|
||||
|
||||
const expectFacilityLabelsUseShortNames = (labels: VisibleLabel[]) => {
|
||||
const expectedByIcon: Record<string, string[]> = {
|
||||
elevator: ['电梯'],
|
||||
stairs: ['楼梯'],
|
||||
escalator: ['扶梯'],
|
||||
restroom: ['卫生间', '男卫生间', '女卫生间', '男女卫生间', '无障碍卫生间'],
|
||||
'nursing-room': ['母婴室']
|
||||
}
|
||||
|
||||
for (const label of labels.filter((candidate) => balancedIcons.has(candidate.icon) || fullIcons.has(candidate.icon))) {
|
||||
expect(expectedByIcon[label.icon], `unexpected infrastructure icon ${label.icon}`).toContain(label.text)
|
||||
expect(label.text).not.toMatch(/L\d|[A-Z][-_]?\d|\d$/i)
|
||||
}
|
||||
}
|
||||
|
||||
const expectVisibleAnchorsMatchFloorPois = async (page: Page, floor: FacilityFloorSelection) => {
|
||||
const result = await page.evaluate(async (floorId) => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return {
|
||||
pois: await diagnostics.getFloorPois(floorId),
|
||||
labels: diagnostics.getAmbientPoiLabelStates().filter((label) => label.visible)
|
||||
}
|
||||
}, floor.floorId)
|
||||
const poisById = new Map(result.pois.map((poi) => [poi.poiId, poi]))
|
||||
|
||||
for (const label of result.labels.filter((candidate) => (
|
||||
['elevator', 'stairs', 'stair', 'escalator'].includes(candidate.iconType)
|
||||
))) {
|
||||
const poi = poisById.get(label.poiId)
|
||||
expect(poi, `missing source POI for label ${label.poiId}`).toBeTruthy()
|
||||
expect(label.anchor.x).toBeCloseTo(poi!.positionGltf[0], 5)
|
||||
expect(label.anchor.y).toBeCloseTo(poi!.positionGltf[1], 5)
|
||||
expect(label.anchor.z).toBeCloseTo(poi!.positionGltf[2], 5)
|
||||
}
|
||||
}
|
||||
|
||||
const waitForCameraIdle = async (page: Page) => {
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
return diagnostics?.getReport().isCameraTweening || false
|
||||
}), { timeout: 10_000 }).toBe(false)
|
||||
await page.waitForTimeout(160)
|
||||
}
|
||||
|
||||
const waitForFloorLabelLayer = async (page: Page, floor: FacilityFloorSelection) => {
|
||||
await expect.poll(async () => page.evaluate(({ floorId, balancedPoiIds, fullPoiIds }) => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) return false
|
||||
const labelIds = new Set(
|
||||
diagnostics.getAmbientPoiLabelStates()
|
||||
.filter((label) => label.floorId === floorId)
|
||||
.map((label) => label.poiId)
|
||||
)
|
||||
const hasBalanced = !balancedPoiIds.length || balancedPoiIds.some((poiId) => labelIds.has(poiId))
|
||||
const hasFull = !fullPoiIds.length || fullPoiIds.some((poiId) => labelIds.has(poiId))
|
||||
return hasBalanced && hasFull
|
||||
}, floor), { timeout: 30_000 }).toBe(true)
|
||||
await page.waitForTimeout(160)
|
||||
}
|
||||
|
||||
const getVisibilityTier = (page: Page) => page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
return diagnostics.getPoiVisibilityTier()
|
||||
})
|
||||
|
||||
const zoomToTier = async (page: Page, targetTier: PoiVisibilityTier) => {
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
const currentTier = await getVisibilityTier(page)
|
||||
if (currentTier === targetTier) return
|
||||
expect(tierRank[currentTier]).toBeLessThan(tierRank[targetTier])
|
||||
await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
diagnostics.zoomCamera('in')
|
||||
})
|
||||
await waitForCameraIdle(page)
|
||||
}
|
||||
|
||||
expect(await getVisibilityTier(page)).toBe(targetTier)
|
||||
}
|
||||
|
||||
const expectLandmarksToRemainVisible = async (page: Page, poiIds: string[]) => {
|
||||
const states = await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
return diagnostics?.getAmbientPoiLabelStates() || []
|
||||
})
|
||||
const statesById = new Map(states.map((state) => [state.poiId, state]))
|
||||
const eligibleIds = poiIds.filter((poiId) => statesById.get(poiId)?.inViewport)
|
||||
|
||||
for (const poiId of eligibleIds) {
|
||||
expect(statesById.get(poiId)?.visible, `${poiId} was displaced while remaining in viewport`).toBe(true)
|
||||
}
|
||||
|
||||
return {
|
||||
originalCount: poiIds.length,
|
||||
eligibleCount: eligibleIds.length,
|
||||
retainedCount: eligibleIds.filter((poiId) => statesById.get(poiId)?.visible).length
|
||||
}
|
||||
}
|
||||
|
||||
const expectFacilitiesNotToOverlapLandmarks = async (page: Page) => {
|
||||
const labels = page.locator('[data-poi-label-kind="ambient"]:visible')
|
||||
const labelCount = await labels.count()
|
||||
const landmarks: Array<{ poiId: string; bounds: { x: number; y: number; width: number; height: number } }> = []
|
||||
const facilities: Array<{ poiId: string; bounds: { x: number; y: number; width: number; height: number } }> = []
|
||||
|
||||
for (let index = 0; index < labelCount; index += 1) {
|
||||
const label = labels.nth(index)
|
||||
const icon = await label.getAttribute('data-poi-icon') || ''
|
||||
const poiId = await label.getAttribute('data-poi-id') || ''
|
||||
const bounds = await label.boundingBox()
|
||||
if (!bounds) continue
|
||||
if (await label.evaluate((element) => element.classList.contains('three-poi-dom-label--landmark'))) {
|
||||
landmarks.push({ poiId, bounds })
|
||||
} else if (balancedIcons.has(icon) || fullIcons.has(icon)) {
|
||||
facilities.push({ poiId, bounds })
|
||||
}
|
||||
}
|
||||
|
||||
for (const facility of facilities) {
|
||||
for (const landmark of landmarks) {
|
||||
const overlaps = facility.bounds.x < landmark.bounds.x + landmark.bounds.width
|
||||
&& facility.bounds.x + facility.bounds.width > landmark.bounds.x
|
||||
&& facility.bounds.y < landmark.bounds.y + landmark.bounds.height
|
||||
&& facility.bounds.y + facility.bounds.height > landmark.bounds.y
|
||||
expect(overlaps, `${facility.poiId} overlaps landmark ${landmark.poiId}`).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logPhase = (
|
||||
phase: string,
|
||||
floor: FacilityFloorSelection,
|
||||
labels: VisibleLabel[],
|
||||
landmarkRetention?: { originalCount: number; eligibleCount: number; retainedCount: number }
|
||||
) => {
|
||||
const facilityCount = labels.filter((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon)).length
|
||||
console.log('[infrastructure-distance-labels]', JSON.stringify({
|
||||
phase,
|
||||
floor: floor.label,
|
||||
floorId: floor.floorId,
|
||||
landmarkCount: labels.filter((label) => label.landmark).length,
|
||||
elevatorCount: labels.filter((label) => label.icon === 'elevator').length,
|
||||
restroomCount: labels.filter((label) => label.icon === 'restroom').length,
|
||||
stairsCount: labels.filter((label) => label.icon === 'stairs').length,
|
||||
escalatorCount: labels.filter((label) => label.icon === 'escalator').length,
|
||||
facilityTexts: labels
|
||||
.filter((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon))
|
||||
.map((label) => label.text),
|
||||
landmarkRetention: landmarkRetention || null,
|
||||
facilityCount,
|
||||
balancedFacilityCount: labels.filter((label) => balancedIcons.has(label.icon)).length,
|
||||
fullFacilityCount: labels.filter((label) => fullIcons.has(label.icon)).length
|
||||
}))
|
||||
}
|
||||
|
||||
const capturePhaseScreenshot = async (
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
fileName: string
|
||||
) => {
|
||||
const path = testInfo.outputPath(fileName)
|
||||
await page.screenshot({ path })
|
||||
console.log('[infrastructure-distance-labels-screenshot]', path)
|
||||
}
|
||||
|
||||
const runFloorScenario = async (
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
floor: FacilityFloorSelection,
|
||||
requiredTier: 'balanced' | 'full'
|
||||
) => {
|
||||
await page.evaluate(async (floorId) => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!diagnostics) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await diagnostics.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
|
||||
}, floor.floorId)
|
||||
await waitForCameraIdle(page)
|
||||
await waitForFloorLabelLayer(page, floor)
|
||||
expect(await getVisibilityTier(page)).toBe('tight')
|
||||
|
||||
const farLabels = await getVisibleLabels(page)
|
||||
const farLandmarkIds = farLabels.filter((label) => label.landmark).map((label) => label.poiId)
|
||||
expect(farLandmarkIds.length).toBeGreaterThan(0)
|
||||
expect(farLabels.some((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon))).toBe(false)
|
||||
await capturePhaseScreenshot(page, testInfo, `infrastructure-labels-far-${floor.label}.png`)
|
||||
logPhase('far', floor, farLabels)
|
||||
|
||||
await zoomToTier(page, 'balanced')
|
||||
const balancedLabels = await getVisibleLabels(page)
|
||||
const balancedLandmarkRetention = await expectLandmarksToRemainVisible(page, farLandmarkIds)
|
||||
if (floor.balancedPoiIds.length > 0) {
|
||||
expect(balancedLabels.filter((label) => balancedIcons.has(label.icon)).length).toBeGreaterThan(0)
|
||||
}
|
||||
if (floor.label === '1F') {
|
||||
expect(balancedLabels.some((label) => label.icon === 'elevator' && label.text === '电梯')).toBe(true)
|
||||
}
|
||||
expectFacilityLabelsUseShortNames(balancedLabels)
|
||||
await expectVisibleAnchorsMatchFloorPois(page, floor)
|
||||
expect(balancedLabels.filter((label) => fullIcons.has(label.icon))).toHaveLength(0)
|
||||
expect(balancedLabels.filter((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon)).length).toBeLessThanOrEqual(4)
|
||||
await expectFacilitiesNotToOverlapLandmarks(page)
|
||||
await capturePhaseScreenshot(page, testInfo, `infrastructure-labels-balanced-${floor.label}.png`)
|
||||
logPhase('balanced', floor, balancedLabels, balancedLandmarkRetention)
|
||||
|
||||
if (requiredTier === 'full') {
|
||||
await zoomToTier(page, 'full')
|
||||
const fullLabels = await getVisibleLabels(page)
|
||||
const fullFacilityStates = await page.evaluate(() => {
|
||||
const diagnostics = (window as Window & {
|
||||
__GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics
|
||||
}).__GUIDE_3D_VISUAL_STABILITY__
|
||||
return (diagnostics?.getAmbientPoiLabelStates() || []).filter((label) => (
|
||||
['stairs', 'stair', 'escalator'].includes(label.iconType)
|
||||
))
|
||||
})
|
||||
console.log('[infrastructure-distance-label-states]', JSON.stringify({
|
||||
floor: floor.label,
|
||||
states: fullFacilityStates
|
||||
}))
|
||||
const fullLandmarkRetention = await expectLandmarksToRemainVisible(page, farLandmarkIds)
|
||||
expect(fullLabels.filter((label) => fullIcons.has(label.icon)).length).toBeGreaterThan(0)
|
||||
if (floor.label === '1F') {
|
||||
expect(fullLabels.some((label) => (
|
||||
(label.icon === 'stairs' && label.text === '楼梯')
|
||||
|| (label.icon === 'escalator' && label.text === '扶梯')
|
||||
))).toBe(true)
|
||||
}
|
||||
expectFacilityLabelsUseShortNames(fullLabels)
|
||||
await expectVisibleAnchorsMatchFloorPois(page, floor)
|
||||
expect(fullLabels.filter((label) => balancedIcons.has(label.icon) || fullIcons.has(label.icon)).length).toBeLessThanOrEqual(8)
|
||||
await expectFacilitiesNotToOverlapLandmarks(page)
|
||||
await capturePhaseScreenshot(page, testInfo, `infrastructure-labels-full-${floor.label}.png`)
|
||||
logPhase('full', floor, fullLabels, fullLandmarkRetention)
|
||||
}
|
||||
}
|
||||
|
||||
test('1F reveals centered infrastructure labels with large-screen anchors', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
const floors = await findFacilityFloors(page)
|
||||
const firstFloor = floors.find((floor) => floor.label === '1F')
|
||||
expect(firstFloor?.balancedPoiIds.length, '1F has no balanced infrastructure POIs').toBeGreaterThan(0)
|
||||
expect(firstFloor?.fullPoiIds.length, '1F has no near-only infrastructure POIs').toBeGreaterThan(0)
|
||||
if (firstFloor) await runFloorScenario(page, testInfo, firstFloor, 'full')
|
||||
})
|
||||
|
||||
test('5F keeps infrastructure distance-label behavior', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
const floors = await findFacilityFloors(page)
|
||||
const fifthFloor = floors.find((floor) => floor.label === '5F')
|
||||
expect(fifthFloor, '5F has no infrastructure POIs').toBeTruthy()
|
||||
if (fifthFloor) {
|
||||
await runFloorScenario(page, testInfo, fifthFloor, fifthFloor.fullPoiIds.length ? 'full' : 'balanced')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user