// @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') }) })