939 lines
34 KiB
TypeScript
939 lines
34 KiB
TypeScript
// @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')
|
|
})
|
|
})
|