This commit is contained in:
94
tests/e2e/poi-dom-labels.spec.ts
Normal file
94
tests/e2e/poi-dom-labels.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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 }>
|
||||
getReport: () => { floorId: string; activeFocusPoiId: string }
|
||||
resetToViewBaseline: (options: { view: 'floor'; floorId: string; reason: 'floor-reset' }) => Promise<unknown>
|
||||
getFloorPois: (floorId: string) => Promise<PoiCandidate[]>
|
||||
focusTargetPoi: (request: PoiCandidate & { requestId: string }) => Promise<unknown>
|
||||
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 resetToActiveFloor = 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')
|
||||
await api.resetToViewBaseline({ view: 'floor', floorId: api.getReport().floorId, reason: 'floor-reset' })
|
||||
return api.getReport().floorId
|
||||
})
|
||||
|
||||
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 resetToActiveFloor(page)
|
||||
const labels = page.locator('[data-poi-label-kind="ambient"]:visible')
|
||||
await expect(labels.first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const hall = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--hall:visible').first()
|
||||
const facility = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--service').first()
|
||||
await expect(hall).toBeVisible()
|
||||
|
||||
for (const locator of [hall, facility]) {
|
||||
await expect(locator).toHaveCSS('pointer-events', 'none')
|
||||
const fontSize = await locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(fontSize).toBeGreaterThanOrEqual(locator === hall ? 14 : 13)
|
||||
}
|
||||
|
||||
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 facility.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="focus"][data-focus-label="active"]')
|
||||
await expect(focus).toBeVisible({ timeout: 20_000 })
|
||||
await expect(focus).toHaveCSS('pointer-events', 'none')
|
||||
await expect(focus.locator('.three-poi-dom-label__title')).toHaveCSS('font-size', '16px')
|
||||
await expect(focus.locator('.three-poi-dom-label__meta')).toHaveCSS('font-size', '13px')
|
||||
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).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath(`poi-dom-labels-dpr-${deviceScaleFactor}-ambient.png`) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,14 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ArrivalPanel from '@/components/navigation/ArrivalPanel.vue'
|
||||
import { ARRIVAL_TARGETS } from '@/data/arrivalSearchTargets'
|
||||
import { openExternalNavigation } from '@/services/MapNavigationService'
|
||||
import { openWechatMiniProgramLocation } from '@/services/WechatMiniProgramBridgeService'
|
||||
|
||||
const selectedTarget = ARRIVAL_TARGETS[0]
|
||||
vi.mock('@/services/MapNavigationService', () => ({ openExternalNavigation: vi.fn() }))
|
||||
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({ openWechatMiniProgramLocation: vi.fn() }))
|
||||
|
||||
const selectedTarget = ARRIVAL_TARGETS[1]
|
||||
const defaultUserAgent = window.navigator.userAgent
|
||||
|
||||
const mountArrivalPanel = () => mount(ArrivalPanel, {
|
||||
props: {
|
||||
visible: true,
|
||||
activeType: 'bus',
|
||||
activeType: selectedTarget.type,
|
||||
targets: [selectedTarget],
|
||||
selectedTargetId: selectedTarget.id,
|
||||
selectedTarget
|
||||
@@ -19,72 +23,62 @@ const mountArrivalPanel = () => mount(ArrivalPanel, {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(openExternalNavigation).mockResolvedValue(true)
|
||||
vi.mocked(openWechatMiniProgramLocation).mockResolvedValue({ status: 'not-embedded' })
|
||||
vi.stubGlobal('uni', { showToast: vi.fn(), showActionSheet: vi.fn() })
|
||||
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 MicroMessenger/8.0.50 miniProgram'
|
||||
})
|
||||
Object.assign(window, {
|
||||
__wxjs_environment: 'miniprogram',
|
||||
wx: {
|
||||
miniProgram: {
|
||||
navigateTo: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('uni', {
|
||||
showToast: vi.fn(),
|
||||
showActionSheet: vi.fn()
|
||||
})
|
||||
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Linux; Android 14)' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: defaultUserAgent
|
||||
})
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: defaultUserAgent })
|
||||
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
|
||||
delete (window as Window & { wx?: unknown }).wx
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('来馆面板 H5 实际渲染', () => {
|
||||
it('主按钮显示第三方导航,点击后弹出百度和高德选择框', async () => {
|
||||
describe('来馆面板 H5 导航分流', () => {
|
||||
it('嵌入小程序 web-view 时只向宿主页面导航,且完整传递当前选中目标', async () => {
|
||||
Object.assign(window, { __wxjs_environment: 'miniprogram' })
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 MicroMessenger/8.0.50' })
|
||||
vi.mocked(openWechatMiniProgramLocation).mockResolvedValue({ status: 'opened' })
|
||||
const wrapper = mountArrivalPanel()
|
||||
const showActionSheet = (uni as unknown as {
|
||||
showActionSheet: ReturnType<typeof vi.fn>
|
||||
}).showActionSheet
|
||||
const navigateTo = (
|
||||
window as Window & {
|
||||
wx?: { miniProgram?: { navigateTo?: ReturnType<typeof vi.fn> } }
|
||||
}
|
||||
).wx?.miniProgram?.navigateTo
|
||||
|
||||
expect(wrapper.get('.arrival-primary-text').text()).toBe('第三方导航')
|
||||
expect(wrapper.text()).not.toContain('打开地图导航')
|
||||
expect(wrapper.get('.arrival-primary-text').text()).toBe('打开地图导航')
|
||||
await wrapper.get('.arrival-primary').trigger('tap')
|
||||
|
||||
expect(showActionSheet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemList: ['百度地图', '高德地图']
|
||||
}))
|
||||
expect(wrapper.find('.provider-sheet').exists()).toBe(false)
|
||||
expect(navigateTo).not.toHaveBeenCalled()
|
||||
expect(openWechatMiniProgramLocation).toHaveBeenCalledTimes(1)
|
||||
expect(openWechatMiniProgramLocation).toHaveBeenCalledWith({
|
||||
latitude: selectedTarget.latitude,
|
||||
longitude: selectedTarget.longitude,
|
||||
name: selectedTarget.title,
|
||||
address: selectedTarget.subtitle
|
||||
})
|
||||
expect(openExternalNavigation).not.toHaveBeenCalled()
|
||||
expect((uni as unknown as { showActionSheet: ReturnType<typeof vi.fn> }).showActionSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('诊断信息默认隐藏,仅在 debug-build=1 时显示 H5 构建边界', () => {
|
||||
it('普通 H5 仅回退至第三方地图,并使用当前选中目标', async () => {
|
||||
const wrapper = mountArrivalPanel()
|
||||
expect(wrapper.get('.arrival-primary-text').text()).toBe('第三方导航')
|
||||
await wrapper.get('.arrival-primary').trigger('tap')
|
||||
|
||||
expect(openExternalNavigation).toHaveBeenCalledWith({
|
||||
latitude: selectedTarget.latitude,
|
||||
longitude: selectedTarget.longitude,
|
||||
name: selectedTarget.title,
|
||||
address: selectedTarget.subtitle
|
||||
})
|
||||
})
|
||||
|
||||
it('诊断信息默认隐藏,仅在 debug-build=1 时显示构建边界', () => {
|
||||
const normalWrapper = mountArrivalPanel()
|
||||
expect(normalWrapper.find('[data-testid="arrival-build-diagnostics"]').exists()).toBe(false)
|
||||
normalWrapper.unmount()
|
||||
|
||||
window.location.hash = '#/pages/index/index?tab=guide&debug-build=1'
|
||||
const debugWrapper = mountArrivalPanel()
|
||||
const diagnostics = debugWrapper.get('[data-testid="arrival-build-diagnostics"]')
|
||||
|
||||
expect(diagnostics.text()).toContain('构建 ID:test-build')
|
||||
expect(diagnostics.text()).toContain('Git commit:test-commit')
|
||||
expect(diagnostics.text()).toContain('构建平台:h5')
|
||||
expect(diagnostics.text()).toContain('navigationMode:third-party-providers')
|
||||
expect(diagnostics.text()).toContain('location.href:')
|
||||
expect(diagnostics.text()).toContain('UA:')
|
||||
expect(debugWrapper.get('[data-testid="arrival-build-diagnostics"]').text()).toContain('构建平台:h5')
|
||||
})
|
||||
})
|
||||
|
||||
87
tests/unit/WechatMiniProgramBridgeService.spec.ts
Normal file
87
tests/unit/WechatMiniProgramBridgeService.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const target = { latitude: 22.604321, longitude: 114.058765, name: '停车场 A&B', address: '龙华区 #1' }
|
||||
const setEmbeddedRuntime = () => Object.assign(window, { __wxjs_environment: 'miniprogram' })
|
||||
|
||||
const loadService = async () => {
|
||||
vi.resetModules()
|
||||
return import('@/services/WechatMiniProgramBridgeService')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('uni', { showToast: vi.fn() })
|
||||
delete (window as Window & { wx?: unknown }).wx
|
||||
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
document.querySelectorAll('script[data-wechat-js-sdk="true"]').forEach((node) => node.remove())
|
||||
})
|
||||
|
||||
describe('微信小程序 web-view 桥接', () => {
|
||||
it('普通浏览器立即返回 not-embedded,不加载 SDK', async () => {
|
||||
const { openWechatMiniProgramLocation } = await loadService()
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'not-embedded' })
|
||||
expect(document.querySelector('[data-wechat-js-sdk="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('等待延迟注入的 bridge 后导航到编码后的宿主页', async () => {
|
||||
setEmbeddedRuntime()
|
||||
const { openWechatMiniProgramLocation } = await loadService()
|
||||
const result = openWechatMiniProgramLocation(target)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
const navigateTo = vi.fn(({ success }) => success())
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
await expect(result).resolves.toEqual({ status: 'opened' })
|
||||
expect(navigateTo).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: expect.stringContaining('/pages/open-location/index?latitude=22.604321&longitude=114.058765&name=%E5%81%9C%E8%BD%A6%E5%9C%BA%20A%26B')
|
||||
}))
|
||||
})
|
||||
|
||||
it('handles getEnv false, navigate fail, page not found, and callback timeout', async () => {
|
||||
setEmbeddedRuntime()
|
||||
const { openWechatMiniProgramLocation } = await loadService()
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: false }), navigateTo: vi.fn() } } })
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'not-in-mini-program' })
|
||||
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }) } } })
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'bridge-unavailable' })
|
||||
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo: vi.fn(({ fail }) => fail({ errMsg: 'navigateTo:fail permission denied' })) } } })
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'navigation-failed' })
|
||||
|
||||
const navigateTo = vi.fn(({ fail }) => fail({ errMsg: 'navigateTo:fail page not found' }))
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'page-not-found' })
|
||||
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo: vi.fn() } } })
|
||||
const timeoutResult = openWechatMiniProgramLocation(target)
|
||||
await vi.advanceTimersByTimeAsync(3001)
|
||||
await expect(timeoutResult).resolves.toEqual({ status: 'failed', reason: 'timeout' })
|
||||
})
|
||||
|
||||
it('deduplicates rapid calls while navigation is in flight', async () => {
|
||||
setEmbeddedRuntime()
|
||||
const { openWechatMiniProgramLocation } = await loadService()
|
||||
let succeed: (() => void) | undefined
|
||||
const navigateTo = vi.fn(({ success }) => { succeed = success })
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
|
||||
const first = openWechatMiniProgramLocation(target)
|
||||
expect(openWechatMiniProgramLocation(target)).toBe(first)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(navigateTo).toHaveBeenCalledTimes(1)
|
||||
succeed?.()
|
||||
await expect(first).resolves.toEqual({ status: 'opened' })
|
||||
expect(navigateTo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
31
tests/unit/poiDomLabels.spec.ts
Normal file
31
tests/unit/poiDomLabels.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
domLabelBoundsOverlap,
|
||||
getDomLabelBounds,
|
||||
isDomLabelWithinViewport,
|
||||
projectObjectToDom
|
||||
} from '@/components/map/poiDomLabels'
|
||||
|
||||
describe('POI DOM label projection and collision', () => {
|
||||
it('projects a world-space anchor to CSS pixels', () => {
|
||||
const camera = new THREE.PerspectiveCamera(60, 2, 0.1, 100)
|
||||
camera.position.set(0, 0, 10)
|
||||
camera.lookAt(0, 0, 0)
|
||||
camera.updateProjectionMatrix()
|
||||
camera.updateMatrixWorld()
|
||||
|
||||
const anchor = new THREE.Object3D()
|
||||
anchor.position.set(0, 0, 0)
|
||||
expect(projectObjectToDom(anchor, camera, 390, 195)).toEqual({ x: 195, y: 97.5 })
|
||||
})
|
||||
|
||||
it('uses measured DOM dimensions for bottom-anchored overlap checks', () => {
|
||||
const hall = getDomLabelBounds({ x: 120, y: 100 }, { width: 146, height: 38 }, 'bottom')
|
||||
const facility = getDomLabelBounds({ x: 240, y: 100 }, { width: 88, height: 32 }, 'bottom')
|
||||
|
||||
expect(domLabelBoundsOverlap(hall, facility, 6)).toBe(true)
|
||||
expect(isDomLabelWithinViewport(hall, 390, 844)).toBe(true)
|
||||
expect(isDomLabelWithinViewport(getDomLabelBounds({ x: 5, y: 10 }, { width: 88, height: 32 }, 'bottom'), 390, 844)).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user