import { expect, test, type Page } from '@playwright/test' interface PoiCandidate { poiId: string floorId: string name: string primaryCategory: string positionGltf: [number, number, number] } interface GuideDiagnostics { isInitialModelReady: () => boolean 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 switchFloor: (floorId: string) => Promise showMultiFloor: () => Promise getFloorPois: (floorId: string) => Promise 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 clearTargetFocus: () => void } 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 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') 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]) { test.describe(`POI DOM labels at DPR ${deviceScaleFactor}`, () => { test.use({ viewport: { width: 390, height: 844 }, deviceScaleFactor }) test('renders measured native labels and clears focused labels without residue', async ({ page }, testInfo) => { await openGuide(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 markerPositions = new Map() ;(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]) { await expect(locator).toHaveCSS('pointer-events', 'none') const fontSize = await locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)) expect(fontSize).toBeGreaterThanOrEqual(12) } for (const locator of [hall]) { const box = await locator.boundingBox() expect(box).not.toBeNull() expect(box!.x).toBeGreaterThanOrEqual(0) expect(box!.y).toBeGreaterThanOrEqual(0) expect(box!.x + box!.width).toBeLessThanOrEqual(390) expect(box!.y + box!.height).toBeLessThanOrEqual(844) } 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 }) .__GUIDE_3D_VISUAL_STABILITY__ if (!api) throw new Error('ThreeMap diagnostics unavailable') const poi = (await api.getFloorPois(targetFloorId)).find((candidate) => candidate.poiId === targetPoiId) if (!poi) throw new Error(`missing POI ${targetPoiId}`) await api.focusTargetPoi({ ...poi, requestId: `poi-dom-label-${targetPoiId}` }) }, { floorId, targetPoiId: poiId! }) 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).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(() => { const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics }) .__GUIDE_3D_VISUAL_STABILITY__ api?.clearTargetFocus() }) 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' }) } })