380 lines
15 KiB
TypeScript
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')
|
|
}
|
|
})
|