This commit is contained in:
938
tests/unit/WeakNetworkGuideFallback.spec.ts
Normal file
938
tests/unit/WeakNetworkGuideFallback.spec.ts
Normal file
@@ -0,0 +1,938 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WeakNetworkGuideFallback from '@/components/map/WeakNetworkGuideFallback.vue'
|
||||
import {
|
||||
GUIDE_FLOOR_BASEMAP_PROJECTION,
|
||||
GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION,
|
||||
GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
||||
resetGuideFloorBasemapManifestCacheForTests,
|
||||
type GuideFloorBasemapManifest
|
||||
} from '@/services/model/GuideFloorBasemap'
|
||||
|
||||
const createOrthographicRenderIdentity = () => ({
|
||||
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
|
||||
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
|
||||
})
|
||||
|
||||
const yaw = 54 * Math.PI / 180
|
||||
const screenRight = { x: Math.cos(yaw), z: -Math.sin(yaw) }
|
||||
const screenDown = { x: Math.sin(yaw), z: Math.cos(yaw) }
|
||||
type ProjectedPoint = { x: number; y: number }
|
||||
|
||||
const createProjection = (width: number, height: number) => {
|
||||
const halfX = Math.abs(width / 2 * screenRight.x) + Math.abs(height / 2 * screenDown.x)
|
||||
const halfZ = Math.abs(width / 2 * screenRight.z) + Math.abs(height / 2 * screenDown.z)
|
||||
return {
|
||||
type: 'orthographic-xz' as const,
|
||||
minX: -halfX,
|
||||
maxX: halfX,
|
||||
minZ: -halfZ,
|
||||
maxZ: halfZ,
|
||||
planeY: 0,
|
||||
center: { x: 0, z: 0 },
|
||||
width,
|
||||
height,
|
||||
screenRight,
|
||||
screenDown
|
||||
}
|
||||
}
|
||||
|
||||
const l1BasemapManifest: GuideFloorBasemapManifest = {
|
||||
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
||||
render: createOrthographicRenderIdentity(),
|
||||
floors: [{
|
||||
floorId: 'L1',
|
||||
floorCode: 'L1',
|
||||
modelVersion: 'model-v1',
|
||||
projection: createProjection(80, 40),
|
||||
initialViewport: {
|
||||
centerX: 0,
|
||||
centerZ: 0,
|
||||
visibleWorldSpan: 40,
|
||||
maxVisibleWorldSpan: 64
|
||||
},
|
||||
image: {
|
||||
path: '/static/guide-floor-basemaps/L1.model-v1.ortho-yaw54-v2.compact-floor-center-v1.webp',
|
||||
width: 1600,
|
||||
height: 800,
|
||||
format: 'webp'
|
||||
}
|
||||
}],
|
||||
routeAssets: []
|
||||
}
|
||||
|
||||
const exteriorBasemapManifest: GuideFloorBasemapManifest = {
|
||||
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
||||
render: createOrthographicRenderIdentity(),
|
||||
floors: [{
|
||||
floorId: '2065808919904776194',
|
||||
floorCode: 'EXTERIOR',
|
||||
modelVersion: 'model-v1',
|
||||
projection: createProjection(200, 100),
|
||||
initialViewport: {
|
||||
centerX: 0,
|
||||
centerZ: 0,
|
||||
visibleWorldSpan: 100,
|
||||
maxVisibleWorldSpan: 135
|
||||
},
|
||||
image: {
|
||||
path: '/static/guide-floor-basemaps/EXTERIOR.model-v1.ortho-yaw54-v2.compact-floor-center-v1.webp',
|
||||
width: 1600,
|
||||
height: 800,
|
||||
format: 'webp'
|
||||
}
|
||||
}],
|
||||
routeAssets: []
|
||||
}
|
||||
|
||||
const L1_FLOOR_ID = '2065808921272119298'
|
||||
const EXTERIOR_FLOOR_ID = '2065808919904776194'
|
||||
const compositeBasemapManifest: GuideFloorBasemapManifest = {
|
||||
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
||||
render: createOrthographicRenderIdentity(),
|
||||
floors: [],
|
||||
routeAssets: [{
|
||||
floorId: 'EXTERIOR_L1',
|
||||
floorCode: 'EXTERIOR_L1',
|
||||
assetId: '2079549316670373890',
|
||||
role: 'SAME_GROUND_COMPOSITE',
|
||||
modelVersion: 'V20260721201311_179f0_SAME_GROUND_NO_BACKGROUND',
|
||||
modelUrl: '/app-api/composite.glb',
|
||||
coverageFloorCodes: ['EXTERIOR', 'L1'],
|
||||
coverageFloorIds: [EXTERIOR_FLOOR_ID, L1_FLOOR_ID],
|
||||
projection: createProjection(240, 480),
|
||||
initialViewport: {
|
||||
centerX: 0,
|
||||
centerZ: 20,
|
||||
visibleWorldSpan: 200,
|
||||
maxVisibleWorldSpan: 270
|
||||
},
|
||||
image: {
|
||||
path: '/static/guide-floor-basemaps/EXTERIOR_L1.composite-v1.ortho-yaw54-v2.compact-floor-center-v1.webp',
|
||||
width: 750,
|
||||
height: 1500,
|
||||
format: 'webp'
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
const pois = [
|
||||
{
|
||||
id: 'ticket',
|
||||
name: '售票处',
|
||||
floorId: 'L1',
|
||||
primaryCategory: 'ticket_office',
|
||||
primaryCategoryZh: '售票',
|
||||
iconType: 'ticket_office',
|
||||
positionGltf: [0, 0, 0] as [number, number, number]
|
||||
},
|
||||
{
|
||||
id: 'lift',
|
||||
name: '电梯',
|
||||
floorId: 'L1',
|
||||
primaryCategory: 'elevator',
|
||||
primaryCategoryZh: '电梯',
|
||||
iconType: 'elevator',
|
||||
positionGltf: [20, 0, 10] as [number, number, number]
|
||||
}
|
||||
]
|
||||
|
||||
describe('WeakNetworkGuideFallback', () => {
|
||||
afterEach(() => {
|
||||
resetGuideFloorBasemapManifestCacheForTests()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps floor POIs and route lines available without a 3D model', async () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
floorLabel: '1F',
|
||||
floors: [{ floorId: 'L1', label: '1F' }, { floorId: 'L2', label: '2F' }],
|
||||
pois,
|
||||
routePreview: {
|
||||
id: 'route-1',
|
||||
start: { poiId: 'ticket', name: '售票处', floorId: 'L1', floorLabel: '1F', position: [0, 0, 0] },
|
||||
end: { poiId: 'lift', name: '电梯', floorId: 'L1', floorLabel: '1F', position: [20, 0, 10] },
|
||||
distanceMeters: 25,
|
||||
nodeIds: [],
|
||||
points: [],
|
||||
floorSegments: [{
|
||||
floorId: 'L1',
|
||||
floorLabel: '1F',
|
||||
points: [
|
||||
{ nodeId: 'a', floorId: 'L1', position: [0, 0, 0] },
|
||||
{ nodeId: 'b', floorId: 'L1', position: [20, 0, 10] }
|
||||
]
|
||||
}],
|
||||
connectorPoints: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-testid="weak-network-map"]').text()).toContain('网络较弱')
|
||||
expect(wrapper.findAll('.weak-network-marker')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.weak-network-route-line')).toHaveLength(1)
|
||||
|
||||
await wrapper.get('[data-poi-id="ticket"]').trigger('tap')
|
||||
const secondFloor = wrapper.findAll('[data-testid="weak-network-floor-option"]')
|
||||
.find((item) => item.text() === '2F')
|
||||
expect(secondFloor).toBeDefined()
|
||||
await secondFloor!.trigger('tap')
|
||||
await wrapper.get('[data-testid="weak-network-retry"]').trigger('tap')
|
||||
|
||||
expect(wrapper.emitted('poiClick')?.[0][0]).toMatchObject({ id: 'ticket' })
|
||||
expect(wrapper.emitted('floorChange')?.[0][0]).toBe('L2')
|
||||
expect(wrapper.emitted('retry')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not reuse indoor POIs or the floor rail over an exterior basemap', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'EXTERIOR',
|
||||
floorLabel: '室外导览',
|
||||
sceneView: 'overview',
|
||||
floors: [{ floorId: 'L1', label: '1F' }, { floorId: 'L2', label: '2F' }],
|
||||
pois
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="weak-network-floor"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('[data-testid="weak-network-floor-option"]')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.weak-network-marker')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-poi-id="overview-parking-entry"]').attributes('data-label'))
|
||||
.toBe('停车场入口')
|
||||
expect(wrapper.get('[data-poi-id="overview-parking-exit"]').attributes('data-label'))
|
||||
.toBe('停车场出口')
|
||||
})
|
||||
|
||||
it('keeps exterior POIs when the fallback uses the real overview floor id', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: '2065808919904776194',
|
||||
sceneView: 'overview',
|
||||
presentation: 'two-dimensional',
|
||||
basemapManifest: exteriorBasemapManifest,
|
||||
pois: [
|
||||
{
|
||||
...pois[0],
|
||||
id: 'parking',
|
||||
name: '停车场入口',
|
||||
floorId: '2065808919904776194',
|
||||
primaryCategory: 'entrance',
|
||||
primaryCategoryZh: '出入口',
|
||||
iconType: 'entrance'
|
||||
},
|
||||
{
|
||||
...pois[1],
|
||||
id: 'outdoor-stairs',
|
||||
name: '楼梯',
|
||||
floorId: '2065808919904776194',
|
||||
primaryCategory: 'stairs',
|
||||
primaryCategoryZh: '楼梯',
|
||||
iconType: 'stairs'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-testid="weak-network-basemap"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.weak-network-marker')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-poi-id="parking"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-poi-id="parking"]').attributes('data-label'))
|
||||
.toBe('停车场入口')
|
||||
expect(wrapper.get('[data-poi-id="overview-parking-exit"]').attributes('data-label'))
|
||||
.toBe('停车场出口')
|
||||
expect(wrapper.find('[data-poi-id="outdoor-stairs"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-testid="weak-network-marker-label-parking"]').text())
|
||||
.toContain('停车场入口')
|
||||
})
|
||||
|
||||
it('keeps selected labels while culling overlapping secondary labels', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
floorModelVersion: 'model-v1',
|
||||
basemapManifest: l1BasemapManifest,
|
||||
sceneViewport: {
|
||||
scene: 'floor',
|
||||
floorId: 'L1',
|
||||
centerX: 0,
|
||||
centerZ: 0,
|
||||
visibleWorldSpan: 16,
|
||||
revision: 1
|
||||
},
|
||||
selectedPoiId: 'selected',
|
||||
pois: [
|
||||
{
|
||||
id: 'landmark',
|
||||
name: '服务中心',
|
||||
floorId: 'L1',
|
||||
primaryCategory: 'business_poi',
|
||||
primaryCategoryZh: '服务',
|
||||
iconType: 'service_desk',
|
||||
positionGltf: [20, 0, 10]
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
name: '卫生间',
|
||||
floorId: 'L1',
|
||||
primaryCategory: 'basic_service_facility',
|
||||
primaryCategoryZh: '服务',
|
||||
iconType: 'restroom',
|
||||
positionGltf: [0.1, 0, 0.1]
|
||||
},
|
||||
{
|
||||
id: 'selected',
|
||||
name: '目标地点',
|
||||
floorId: 'L1',
|
||||
primaryCategory: 'basic_service_facility',
|
||||
primaryCategoryZh: '服务',
|
||||
iconType: 'restroom',
|
||||
positionGltf: [0.2, 0, 0.2]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.weak-network-marker')).toHaveLength(3)
|
||||
expect(wrapper.get('[data-testid="weak-network-marker-label-landmark"]').text()).toBe('服务中心')
|
||||
expect(wrapper.find('[data-testid="weak-network-marker-label-secondary"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-testid="weak-network-marker-label-selected"]').text()).toBe('卫生间')
|
||||
})
|
||||
|
||||
it('increases floor markers and labels across tight, balanced, and full tiers', async () => {
|
||||
const tierPois = [
|
||||
{
|
||||
id: 'landmark', name: '展厅地标', floorId: 'L1', primaryCategory: 'space_public_area',
|
||||
primaryCategoryZh: '空间', iconType: 'space', kind: 'space' as const,
|
||||
positionGltf: [-7, 0, -12] as [number, number, number]
|
||||
},
|
||||
{
|
||||
id: 'device', name: 'L1_A_导览屏_01', floorId: 'L1', primaryCategory: 'poi',
|
||||
primaryCategoryZh: '设备', iconType: 'device_terminal', kind: 'facility' as const,
|
||||
positionGltf: [7, 0, -12] as [number, number, number]
|
||||
},
|
||||
{
|
||||
id: 'elevator', name: 'L1_A电梯', floorId: 'L1', primaryCategory: 'transport_circulation',
|
||||
primaryCategoryZh: '交通', iconType: 'elevator', kind: 'facility' as const,
|
||||
positionGltf: [0, 0, 0] as [number, number, number]
|
||||
},
|
||||
{
|
||||
id: 'restroom', name: 'L1_卫生间', floorId: 'L1', primaryCategory: 'basic_service_facility',
|
||||
primaryCategoryZh: '服务', iconType: 'restroom', kind: 'facility' as const,
|
||||
positionGltf: [-7, 0, 12] as [number, number, number]
|
||||
},
|
||||
{
|
||||
id: 'stairs', name: 'L1_A楼梯', floorId: 'L1', primaryCategory: 'transport_circulation',
|
||||
primaryCategoryZh: '交通', iconType: 'stairs', kind: 'facility' as const,
|
||||
positionGltf: [0, 0, 12] as [number, number, number]
|
||||
},
|
||||
{
|
||||
id: 'locker', name: '寄存柜', floorId: 'L1', primaryCategory: 'basic_service_facility',
|
||||
primaryCategoryZh: '服务', iconType: 'locker', kind: 'facility' as const,
|
||||
positionGltf: [7, 0, 12] as [number, number, number]
|
||||
}
|
||||
]
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
sceneView: 'floor',
|
||||
floorModelVersion: 'model-v1',
|
||||
basemapManifest: l1BasemapManifest,
|
||||
presentation: 'two-dimensional',
|
||||
pois: tierPois
|
||||
}
|
||||
})
|
||||
const renderer = wrapper.vm as unknown as {
|
||||
zoomCamera: (direction: 'in' | 'out') => void
|
||||
}
|
||||
const counts = () => ({
|
||||
tier: wrapper.get('[data-testid="weak-network-map"]').attributes('data-visibility-tier'),
|
||||
markers: wrapper.findAll('.weak-network-marker').length,
|
||||
labels: wrapper.findAll('.weak-network-marker-label').length
|
||||
})
|
||||
|
||||
const tight = counts()
|
||||
renderer.zoomCamera('in')
|
||||
renderer.zoomCamera('in')
|
||||
await wrapper.vm.$nextTick()
|
||||
const balanced = counts()
|
||||
renderer.zoomCamera('in')
|
||||
renderer.zoomCamera('in')
|
||||
await wrapper.vm.$nextTick()
|
||||
const full = counts()
|
||||
|
||||
expect([tight.tier, balanced.tier, full.tier]).toEqual(['tight', 'balanced', 'full'])
|
||||
expect(balanced.markers).toBeGreaterThan(tight.markers)
|
||||
expect(full.markers).toBeGreaterThan(balanced.markers)
|
||||
expect(balanced.labels).toBeGreaterThan(tight.labels)
|
||||
expect(full.labels).toBeGreaterThan(balanced.labels)
|
||||
expect(wrapper.get('[data-poi-id="stairs"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps search, selected, and route endpoint labels visible through collisions', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
floorModelVersion: 'model-v1',
|
||||
basemapManifest: l1BasemapManifest,
|
||||
visiblePoiIds: ['search'],
|
||||
selectedPoiId: 'selected',
|
||||
pois: [
|
||||
{
|
||||
id: 'search', name: '查询地点', floorId: 'L1', primaryCategory: 'poi',
|
||||
primaryCategoryZh: '地点', iconType: 'default', positionGltf: [0, 0, 0]
|
||||
},
|
||||
{
|
||||
id: 'selected', name: '选中地点', floorId: 'L1', primaryCategory: 'poi',
|
||||
primaryCategoryZh: '地点', iconType: 'default', positionGltf: [0, 0, 0]
|
||||
}
|
||||
],
|
||||
routePreview: {
|
||||
id: 'forced-route',
|
||||
start: { poiId: 'route', name: '路线端点', floorId: 'L1', floorLabel: '1F', position: [0, 0, 0] },
|
||||
end: { poiId: 'other-floor', name: '二层端点', floorId: 'L2', floorLabel: '2F', position: [0, 0, 0] },
|
||||
distanceMeters: 20,
|
||||
nodeIds: [],
|
||||
points: [],
|
||||
floorSegments: [],
|
||||
connectorPoints: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(new Set(wrapper.findAll('.weak-network-marker').map((marker) => marker.attributes('data-poi-id'))))
|
||||
.toEqual(new Set(['search', 'selected', 'route']))
|
||||
expect(wrapper.findAll('.weak-network-marker-label')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('uses the shared circular POI icon and label treatment in two-dimensional mode', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
presentation: 'two-dimensional',
|
||||
visiblePoiIds: ['ticket'],
|
||||
pois
|
||||
}
|
||||
})
|
||||
|
||||
const marker = wrapper.get('[data-poi-id="ticket"]')
|
||||
expect(marker.attributes('data-poi-icon')).toBe('ticket-office')
|
||||
expect(marker.get('.weak-network-marker-label-icon use').attributes('href'))
|
||||
.toBe('/static/icons/poi/shortcut-icons.svg#poi-ticket-office')
|
||||
expect(marker.get('.weak-network-marker-label').classes()).toContain('weak-network-marker-label')
|
||||
})
|
||||
|
||||
it('treats an explicit empty POI filter as an empty search result', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
visiblePoiIds: [],
|
||||
pois
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.weak-network-marker')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps a route endpoint visible when it is absent from floor POI data', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
pois: [],
|
||||
routePreview: {
|
||||
id: 'route-endpoint-only',
|
||||
start: { poiId: 'origin', name: '主入口', floorId: 'L1', floorLabel: '1F', position: [0, 0, 0] },
|
||||
end: { poiId: 'destination', name: '二层展厅', floorId: 'L2', floorLabel: '2F', position: [10, 0, 10] },
|
||||
distanceMeters: 20,
|
||||
nodeIds: [],
|
||||
points: [],
|
||||
floorSegments: [{
|
||||
floorId: 'L1',
|
||||
floorLabel: '1F',
|
||||
points: [{ nodeId: 'origin', floorId: 'L1', position: [0, 0, 0] }]
|
||||
}],
|
||||
connectorPoints: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.weak-network-marker')).toHaveLength(1)
|
||||
expect(wrapper.get('[data-poi-id="origin"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-testid="weak-network-marker-label-origin"]').text()).toBe('主入口')
|
||||
})
|
||||
|
||||
it('uses a matching WebP basemap without the grid and keeps route endpoints projected with the displayed image frame', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
floorModelVersion: 'model-v1',
|
||||
basemapManifest: l1BasemapManifest,
|
||||
pois: [],
|
||||
routePreview: {
|
||||
id: 'route-on-basemap',
|
||||
start: { poiId: 'origin', name: '主入口', floorId: 'L1', floorLabel: '1F', position: [-6, 0, -12] },
|
||||
end: { poiId: 'destination', name: '目的地', floorId: 'L1', floorLabel: '1F', position: [26, 0, 12] },
|
||||
distanceMeters: 80,
|
||||
nodeIds: [],
|
||||
points: [],
|
||||
floorSegments: [{
|
||||
floorId: 'L1',
|
||||
floorLabel: '1F',
|
||||
points: [
|
||||
{ nodeId: 'origin', floorId: 'L1', position: [-6, 0, -12] },
|
||||
{ nodeId: 'destination', floorId: 'L1', position: [26, 0, 12] }
|
||||
]
|
||||
}],
|
||||
connectorPoints: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-testid="weak-network-basemap"]').attributes('src'))
|
||||
.toBe('/static/guide-floor-basemaps/L1.model-v1.ortho-yaw54-v2.compact-floor-center-v1.webp')
|
||||
expect(wrapper.find('[data-testid="weak-network-neutral-grid"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-poi-id="origin"]').attributes('style')).toContain('left:')
|
||||
expect(wrapper.get('[data-poi-id="origin"]').attributes('style')).toContain('top:')
|
||||
expect(wrapper.get('[data-poi-id="destination"]').attributes('style')).toContain('left:')
|
||||
expect(wrapper.get('[data-poi-id="destination"]').attributes('style')).toContain('top:')
|
||||
expect(wrapper.get('[data-poi-id="destination"]').exists()).toBe(true)
|
||||
expect(wrapper.get('.weak-network-route-line').attributes('style')).toContain('width:')
|
||||
})
|
||||
|
||||
it('uses multiple 2D zoom levels before reporting a scene-boundary attempt', async () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
sceneView: 'floor',
|
||||
floorModelVersion: 'model-v1',
|
||||
basemapManifest: l1BasemapManifest,
|
||||
presentation: 'two-dimensional',
|
||||
pois
|
||||
}
|
||||
})
|
||||
const renderer = wrapper.vm as unknown as {
|
||||
zoomCamera: (direction: 'in' | 'out') => void
|
||||
getGuideViewportState: () => { visibleWorldSpan: number; scene: string }
|
||||
}
|
||||
const initialSpan = renderer.getGuideViewportState().visibleWorldSpan
|
||||
|
||||
renderer.zoomCamera('in')
|
||||
renderer.zoomCamera('in')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const intents = wrapper.emitted('zoomIntent') || []
|
||||
expect(intents).toHaveLength(2)
|
||||
expect(intents.every(([event]) => !(event as { boundaryAttempt: boolean }).boundaryAttempt)).toBe(true)
|
||||
expect((intents[0][0] as { referenceVisibleWorldSpan: number }).referenceVisibleWorldSpan).toBe(40)
|
||||
expect(renderer.getGuideViewportState()).toMatchObject({ scene: 'floor' })
|
||||
expect(renderer.getGuideViewportState().visibleWorldSpan).toBeLessThan(initialSpan)
|
||||
})
|
||||
|
||||
it('reports the overview threshold on the first 2D zoom step', async () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: '2065808919904776194',
|
||||
sceneView: 'overview',
|
||||
floorModelVersion: 'model-v1',
|
||||
basemapManifest: exteriorBasemapManifest,
|
||||
presentation: 'two-dimensional',
|
||||
pois: []
|
||||
}
|
||||
})
|
||||
const renderer = wrapper.vm as unknown as {
|
||||
zoomCamera: (direction: 'in' | 'out') => void
|
||||
}
|
||||
|
||||
renderer.zoomCamera('in')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('zoomIntent')?.[0]?.[0]).toMatchObject({
|
||||
direction: 'in',
|
||||
referenceVisibleWorldSpan: 100,
|
||||
thresholdReached: true,
|
||||
viewport: { visibleWorldSpan: 80 }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes native H5 wheel input through the semantic viewport zoom', async () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
sceneView: 'floor',
|
||||
floorModelVersion: 'model-v1',
|
||||
basemapManifest: l1BasemapManifest,
|
||||
presentation: 'two-dimensional',
|
||||
pois
|
||||
}
|
||||
})
|
||||
const renderer = wrapper.vm as unknown as {
|
||||
getGuideViewportState: () => { visibleWorldSpan: number }
|
||||
}
|
||||
const initialSpan = renderer.getGuideViewportState().visibleWorldSpan
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: 195,
|
||||
clientY: 422,
|
||||
deltaY: -120
|
||||
})
|
||||
|
||||
wrapper.get('[data-testid="weak-network-surface"]').element.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
const wheelIntent = wrapper.emitted('zoomIntent')?.[0]?.[0] as {
|
||||
direction: string
|
||||
boundaryAttempt: boolean
|
||||
viewport: { visibleWorldSpan: number }
|
||||
}
|
||||
expect(wheelIntent).toMatchObject({ direction: 'in', boundaryAttempt: false })
|
||||
expect(wheelIntent.viewport.visibleWorldSpan).toBeLessThan(initialSpan)
|
||||
})
|
||||
|
||||
it('uses the manifest initial viewport instead of fitting the full projection coverage', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => l1BasemapManifest
|
||||
})))
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
sceneView: 'floor',
|
||||
presentation: 'two-dimensional',
|
||||
pois
|
||||
}
|
||||
})
|
||||
const renderer = wrapper.vm as unknown as {
|
||||
getGuideViewportState: () => { visibleWorldSpan: number }
|
||||
}
|
||||
|
||||
expect(wrapper.emitted('viewportChange')).toBeUndefined()
|
||||
await flushPromises()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(renderer.getGuideViewportState().visibleWorldSpan).toBeCloseTo(40, 8)
|
||||
expect(wrapper.emitted('viewportChange')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps selected POI and route state when renderer presentation changes', async () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
selectedPoiId: 'ticket',
|
||||
presentation: 'two-dimensional',
|
||||
pois,
|
||||
routePreview: {
|
||||
id: 'preserved-route',
|
||||
start: { poiId: 'ticket', name: '售票处', floorId: 'L1', floorLabel: '1F', position: [0, 0, 0] },
|
||||
end: { poiId: 'lift', name: '电梯', floorId: 'L1', floorLabel: '1F', position: [20, 0, 10] },
|
||||
distanceMeters: 25,
|
||||
nodeIds: [],
|
||||
points: [],
|
||||
floorSegments: [{
|
||||
floorId: 'L1',
|
||||
floorLabel: '1F',
|
||||
points: [
|
||||
{ nodeId: 'a', floorId: 'L1', position: [0, 0, 0] },
|
||||
{ nodeId: 'b', floorId: 'L1', position: [20, 0, 10] }
|
||||
]
|
||||
}],
|
||||
connectorPoints: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.setProps({ presentation: 'weak-network' })
|
||||
|
||||
expect(wrapper.get('[data-poi-id="ticket"]').classes()).toContain('weak-network-marker--selected')
|
||||
expect(wrapper.findAll('.weak-network-route-line')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to the neutral grid when the floor model version has no basemap', () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
floorModelVersion: 'model-v2',
|
||||
basemapManifest: l1BasemapManifest,
|
||||
pois
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="weak-network-basemap"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-testid="weak-network-neutral-grid"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps map-based route-start selection available in the two-dimensional guide', async () => {
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
basemapManifest: {
|
||||
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
||||
render: createOrthographicRenderIdentity(),
|
||||
floors: [],
|
||||
routeAssets: []
|
||||
},
|
||||
routeStartSelectionActive: true,
|
||||
routeSelectablePoints: [{
|
||||
routeTargetId: 'ticket:entrance',
|
||||
poiId: 'ticket',
|
||||
sourceId: 'ticket',
|
||||
name: '售票处',
|
||||
floorId: 'L1',
|
||||
routeNodeId: 'node-ticket',
|
||||
positionGltf: [0, 0, 0]
|
||||
}],
|
||||
pois
|
||||
}
|
||||
})
|
||||
|
||||
const ticketStyle = wrapper.get('[data-poi-id="ticket"]').attributes('style')
|
||||
const ticketX = Number(/left:\s*([\d.-]+)/.exec(ticketStyle)?.[1])
|
||||
const ticketY = Number(/top:\s*([\d.-]+)/.exec(ticketStyle)?.[1])
|
||||
await wrapper.get('[data-testid="weak-network-surface"]').trigger('tap', {
|
||||
detail: {
|
||||
x: ticketX,
|
||||
y: ticketY
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.emitted('routeStartCandidate')?.[0][0]).toMatchObject({
|
||||
routeTargetId: 'ticket:entrance',
|
||||
poiId: 'ticket',
|
||||
routeNodeId: 'node-ticket'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders and roams the real L1 exterior L1 route as one composite projection', async () => {
|
||||
let animationCallback: FrameRequestCallback | null = null
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
animationCallback = callback
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => undefined)
|
||||
vi.stubGlobal('performance', { now: () => 0 })
|
||||
|
||||
const toPoints = (floorId: string, coordinates: Array<[number, number]>) => coordinates.map(([x, z], index) => ({
|
||||
nodeId: `${floorId}-${index}`,
|
||||
floorId,
|
||||
position: [x, 0, z] as [number, number, number]
|
||||
}))
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: L1_FLOOR_ID,
|
||||
floorLabel: '1F',
|
||||
presentation: 'two-dimensional',
|
||||
basemapManifest: compositeBasemapManifest,
|
||||
selectedPoiId: 'selected-exterior',
|
||||
selectedPoi: {
|
||||
id: 'selected-exterior',
|
||||
name: '室外节点',
|
||||
floorId: EXTERIOR_FLOOR_ID,
|
||||
primaryCategory: 'target_preview',
|
||||
primaryCategoryZh: '路线地点',
|
||||
iconType: 'target_preview',
|
||||
positionGltf: [-20.16, 0, 8.99]
|
||||
},
|
||||
routeNavigationActive: false,
|
||||
pois: [],
|
||||
routePreview: {
|
||||
id: 'real-l1-exterior-l1-route',
|
||||
start: {
|
||||
poiId: 'ticket-office',
|
||||
name: '展厅售票点',
|
||||
floorId: L1_FLOOR_ID,
|
||||
floorLabel: '1F',
|
||||
position: [-59.617386, 0, 16.816814]
|
||||
},
|
||||
end: {
|
||||
poiId: 'stamp-point',
|
||||
name: '文创盖章打卡',
|
||||
floorId: L1_FLOOR_ID,
|
||||
floorLabel: '1F',
|
||||
position: [61.8, 0, 45.3]
|
||||
},
|
||||
distanceMeters: 177.9,
|
||||
nodeIds: [],
|
||||
points: [],
|
||||
floorSegments: [
|
||||
{
|
||||
floorId: L1_FLOOR_ID,
|
||||
floorLabel: '1F',
|
||||
points: toPoints(L1_FLOOR_ID, [
|
||||
[-59.617386, 16.816814], [-58.8212, 14.344444], [-58.940376, 6.615569],
|
||||
[-41.808034, 1.618447], [-33.403716, -6.75775], [-28.536788, -4.662431]
|
||||
])
|
||||
},
|
||||
{
|
||||
floorId: EXTERIOR_FLOOR_ID,
|
||||
floorLabel: '室外',
|
||||
points: toPoints(EXTERIOR_FLOOR_ID, [
|
||||
[-27.324438, -5.125352], [-20.553785, -3.017094],
|
||||
[-20.16194, 8.999322], [-10.293926, 11.905159]
|
||||
])
|
||||
},
|
||||
{
|
||||
floorId: L1_FLOOR_ID,
|
||||
floorLabel: '1F',
|
||||
points: toPoints(L1_FLOOR_ID, [
|
||||
[-8.49571, 11.77092], [-6.183785, 12.700883], [8.789075, 6.158879],
|
||||
[19.639385, 17.51139], [33.859702, 18.582956], [41.518894, 21.521948],
|
||||
[49.182552, 26.843338], [65.167565, 33.958185], [67.426422, 41.425653],
|
||||
[63.238589, 41.268468], [61.8, 45.3]
|
||||
])
|
||||
}
|
||||
],
|
||||
connectorPoints: [],
|
||||
transitions: [
|
||||
{
|
||||
id: 'exhibition-entrance',
|
||||
fromFloorId: L1_FLOOR_ID,
|
||||
toFloorId: EXTERIOR_FLOOR_ID,
|
||||
fromPosition: [-28.536788, 0, -4.662431],
|
||||
toPosition: [-27.324438, 0, -5.125352],
|
||||
transferType: 'ENTRANCE_EXIT',
|
||||
connectorName: '展览入口'
|
||||
},
|
||||
{
|
||||
id: 'theater-entrance',
|
||||
fromFloorId: EXTERIOR_FLOOR_ID,
|
||||
toFloorId: L1_FLOOR_ID,
|
||||
fromPosition: [-10.293926, 0, 11.905159],
|
||||
toPosition: [-8.49571, 0, 11.77092],
|
||||
transferType: 'ENTRANCE_EXIT',
|
||||
connectorName: '影剧院入口'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const map = wrapper.get('[data-testid="weak-network-map"]')
|
||||
expect(map.attributes('data-active-basemap-floor-code')).toBe('EXTERIOR_L1')
|
||||
expect(map.attributes('data-active-basemap-role')).toBe('SAME_GROUND_COMPOSITE')
|
||||
expect(map.attributes('data-viewport-scene')).toBe('floor')
|
||||
expect(map.attributes('data-viewport-floor-id')).toBe('EXTERIOR_L1')
|
||||
expect(wrapper.get('[data-testid="weak-network-basemap"]').attributes('src')).toContain('EXTERIOR_L1')
|
||||
expect(wrapper.find('[data-testid="weak-network-floor"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.weak-network-route-line')).toHaveLength(20)
|
||||
expect(new Set(wrapper.findAll('.weak-network-route-line').map((line) => line.attributes('data-route-floor-ids'))))
|
||||
.toEqual(new Set([`${L1_FLOOR_ID},${EXTERIOR_FLOOR_ID}`]))
|
||||
|
||||
const diagnostics = (wrapper.vm as unknown as {
|
||||
getCompositeNavigationDiagnostics: () => {
|
||||
routeSegmentFloorIds: string[]
|
||||
routeLines: Array<{ start: ProjectedPoint; end: ProjectedPoint }>
|
||||
markers: Array<{ id: string; point: ProjectedPoint }>
|
||||
routeProgressPoint: ProjectedPoint | null
|
||||
}
|
||||
}).getCompositeNavigationDiagnostics()
|
||||
expect(diagnostics.routeSegmentFloorIds).toEqual([L1_FLOOR_ID, EXTERIOR_FLOOR_ID, L1_FLOOR_ID])
|
||||
expect(new Set(diagnostics.markers.map((marker) => marker.id)))
|
||||
.toEqual(new Set(['ticket-office', 'stamp-point', 'selected-exterior']))
|
||||
for (const point of [
|
||||
...diagnostics.routeLines.flatMap((line) => [line.start, line.end]),
|
||||
...diagnostics.markers.map((marker) => marker.point)
|
||||
]) {
|
||||
expect(point.x).toBeGreaterThanOrEqual(0)
|
||||
expect(point.x).toBeLessThanOrEqual(390)
|
||||
expect(point.y).toBeGreaterThanOrEqual(0)
|
||||
expect(point.y).toBeLessThanOrEqual(844)
|
||||
}
|
||||
|
||||
await wrapper.setProps({ routeNavigationActive: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(map.attributes('data-viewport-scene')).toBe('floor')
|
||||
expect(map.attributes('data-viewport-floor-id')).toBe('EXTERIOR_L1')
|
||||
expect(animationCallback).not.toBeNull()
|
||||
animationCallback?.(10_000)
|
||||
await wrapper.vm.$nextTick()
|
||||
const progress = (wrapper.vm as unknown as {
|
||||
getCompositeNavigationDiagnostics: () => { routeProgressPoint: ProjectedPoint | null }
|
||||
}).getCompositeNavigationDiagnostics().routeProgressPoint
|
||||
expect(progress).not.toBeNull()
|
||||
expect(progress!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(progress!.x).toBeLessThanOrEqual(390)
|
||||
expect(progress!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(progress!.y).toBeLessThanOrEqual(844)
|
||||
|
||||
animationCallback?.(100_000)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('routeRoamingTransfer')).toBeUndefined()
|
||||
expect(wrapper.emitted('floorChange')).toBeUndefined()
|
||||
expect(wrapper.emitted('routeRoamingProgress')?.at(-1)?.[0]).toMatchObject({ remainingMeters: 0, progress: 1 })
|
||||
})
|
||||
|
||||
it('keeps route roaming and cross-floor transfer active in two-dimensional mode', async () => {
|
||||
let animationCallback: FrameRequestCallback | null = null
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
animationCallback = callback
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => undefined)
|
||||
vi.stubGlobal('performance', { now: () => 0 })
|
||||
|
||||
const wrapper = mount(WeakNetworkGuideFallback, {
|
||||
props: {
|
||||
floorId: 'L1',
|
||||
presentation: 'two-dimensional',
|
||||
routeNavigationActive: false,
|
||||
pois,
|
||||
routePreview: {
|
||||
id: 'route-roaming',
|
||||
start: { poiId: 'ticket', name: '售票处', floorId: 'L1', floorLabel: '1F', position: [0, 0, 0] },
|
||||
end: { poiId: 'lift', name: '电梯', floorId: 'L2', floorLabel: '2F', position: [20, 0, 10] },
|
||||
distanceMeters: 30,
|
||||
nodeIds: [],
|
||||
points: [],
|
||||
floorSegments: [
|
||||
{
|
||||
floorId: 'L1',
|
||||
floorLabel: '1F',
|
||||
points: [
|
||||
{ nodeId: 'a', floorId: 'L1', position: [0, 0, 0] },
|
||||
{ nodeId: 'b', floorId: 'L1', position: [10, 0, 0] }
|
||||
]
|
||||
},
|
||||
{
|
||||
floorId: 'L2',
|
||||
floorLabel: '2F',
|
||||
points: [
|
||||
{ nodeId: 'c', floorId: 'L2', position: [10, 0, 0] },
|
||||
{ nodeId: 'd', floorId: 'L2', position: [20, 0, 10] }
|
||||
]
|
||||
}
|
||||
],
|
||||
connectorPoints: [],
|
||||
transitions: [{
|
||||
id: 'l1-l2',
|
||||
fromFloorId: 'L1',
|
||||
toFloorId: 'L2',
|
||||
fromPosition: [10, 0, 0],
|
||||
toPosition: [10, 0, 0],
|
||||
transferType: 'ELEVATOR',
|
||||
connectorName: '电梯'
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.setProps({ routeNavigationActive: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(animationCallback).not.toBeNull()
|
||||
expect(wrapper.get('[data-testid="weak-network-route-progress"]').classes()).not.toContain('is-hidden')
|
||||
animationCallback?.(10_000)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
animationCallback?.(100_000)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('routeRoamingTransfer')?.[0][0]).toMatchObject({
|
||||
fromFloorId: 'L1',
|
||||
toFloorId: 'L2',
|
||||
transferType: 'ELEVATOR'
|
||||
})
|
||||
expect(wrapper.emitted('floorChange')?.[0][0]).toBe('L2')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user