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 getAmbientPoiLabelStates: () => AmbientLabelState[] getPoiVisibilityTier: () => PoiVisibilityTier resetToViewBaseline: (options: { view: 'floor'; floorId: string; reason: 'floor-reset' }) => Promise 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 = { 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('[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 = { 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') } })