// @vitest-environment happy-dom import { defineComponent, onMounted, onUnmounted } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import IndexPage from '@/pages/index/index.vue' import type { GuideRouteResult } from '@/domain/museum' import type { GuideRoutePlanRequest, GuideRoutePlanResult } from '@/usecases/guideRouteUseCase' const testState = vi.hoisted(() => ({ mapMountCount: 0, mapUnmountCount: 0, resetToViewBaselineCalls: [] as Array<{ view: 'overview' | 'floor' floorId?: string reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' }>, searchMountCount: 0, searchUnmountCount: 0, searchRestoreCount: 0, searchCollapseAfterDetailCount: 0, searchResetCount: 0, onShowHandler: null as null | (() => Promise | void), halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>, hallResolutionPromises: [] as Array>>, explainResults: [] as Array<{ type: 'hall' | 'exhibit' hallId?: string exhibitId?: string }>, switchFloorCalls: [] as string[], routeTargets: [] as Array<{ poiId: string name: string floorId: string floorLabel: string categoryLabel?: string routeNodeId: string }>, routePlanResult: { route: null, error: '' } as GuideRoutePlanResult, routePlanRequests: [] as GuideRoutePlanRequest[] })) const hostEnvironmentMocks = vi.hoisted(() => ({ embeddedInWechatMiniProgram: false })) vi.mock('@dcloudio/uni-app', () => ({ onLoad: vi.fn(), onShow: (handler: () => Promise | void) => { testState.onShowHandler = handler } })) vi.mock('@/utils/hostEnvironment', () => ({ isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram })) const floors = [ { id: 'L1', label: '1F', order: 1 }, { id: 'L2', label: '2F', order: 2 } ] const modelSource = { loadPackage: async () => ({ overviewModelUrl: '', floors: [] }), loadFloorPois: async () => [] } vi.mock('@/usecases/guideUseCase', () => ({ guideUseCase: { getAssetBaseUrl: () => '/static/nav-assets', getModelSource: () => modelSource, normalizeFloorId: (value: string) => ( value === '1F' ? 'L1' : value === '2F' ? 'L2' : value ), getFloors: async () => floors } })) vi.mock('@/usecases/guideRouteUseCase', () => ({ guideRouteUseCase: { listTargets: async () => testState.routeTargets, searchTargets: async () => testState.routeTargets, getRouteReadiness: async () => ({ ready: true, message: '' }), planRoute: async (request: GuideRoutePlanRequest) => { testState.routePlanRequests.push(request) return testState.routePlanResult } } })) vi.mock('@/usecases/explainUseCase', () => ({ explainUseCase: { loadExplainHalls: async () => [], loadExplainHallSummaries: async () => ({}), listHalls: async () => ( testState.hallResolutionPromises.shift() || Promise.resolve(testState.halls) ), searchExplain: async () => testState.explainResults } })) const GuidePageFrameStub = defineComponent({ name: 'GuidePageFrame', template: '
' }) const GuideMapShellStub = defineComponent({ name: 'GuideMapShell', props: { activeFloor: { type: String, default: '' }, indoorView: { type: String, default: '' }, visiblePoiIds: { type: Array, default: null }, targetFocusRequest: { type: Object, default: null }, showFloor: { type: Boolean, default: false }, floorBottom: { type: String, default: '' }, disableAutoExit: { type: Boolean, default: false }, routeStartSelectionActive: { type: Boolean, default: false }, routeSelectablePoiIds: { type: Array, default: () => [] } }, emits: ['floor-change', 'poi-click', 'selection-clear', 'indoor-view-change', 'auto-switch', 'route-start-candidate', 'route-start-candidate-rejected', 'route-roaming-progress'], setup() { onMounted(() => { testState.mapMountCount += 1 }) onUnmounted(() => { testState.mapUnmountCount += 1 }) return {} }, methods: { resetToViewBaseline(options: { view: 'overview' | 'floor' floorId?: string reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset' }) { testState.resetToViewBaselineCalls.push(options) return true }, switchFloor(floorId: string) { testState.switchFloorCalls.push(floorId) } }, template: '
' }) const PoiSearchPanelStub = defineComponent({ name: 'PoiSearchPanel', props: { currentFloorId: { type: String, default: '' }, currentFloorLabel: { type: String, default: '' } }, emits: [ 'expanded-change', 'category-mode-change', 'results-change', 'result-tap', 'cancel' ], setup() { onMounted(() => { testState.searchMountCount += 1 }) onUnmounted(() => { testState.searchUnmountCount += 1 }) return {} }, methods: { collapseHomePanel() {}, resetSearchState() { testState.searchResetCount += 1 }, returnToCollapsedAfterDetail() { testState.searchCollapseAfterDetailCount += 1 }, restoreResultListScroll() { testState.searchRestoreCount += 1 } }, template: '
' }) const RoutePlannerPanelStub = defineComponent({ name: 'RoutePlannerPanel', props: { endPoint: { type: Object, default: null }, startCandidate: { type: Object, default: null } }, emits: ['confirm-start', 'cancel-start', 'simulate-guide', 'back'], template: '
' }) const stubs = { GuidePageFrame: GuidePageFrameStub, GuideMapShell: GuideMapShellStub, PoiSearchPanel: PoiSearchPanelStub, RoutePlannerPanel: RoutePlannerPanelStub, ArrivalPanel: true, ExplainHallSelect: true } const poi = { id: 'poi-l2-restroom', name: '二层卫生间', floorId: 'L2', floorLabel: '2F', primaryCategory: { id: 'restroom', label: '卫生间' }, categories: [{ id: 'restroom', label: '卫生间' }], positionGltf: [12, 3, 24] as [number, number, number], accessible: true } const categoryContext = { origin: 'home' as const, keyword: '卫生间', categoryId: 'restroom' as const, floorId: 'L2', floorLabel: '2F', resultCount: 5, floorResultCount: 2, visiblePoiIds: ['poi-l2-restroom', 'poi-l2-restroom-2'], listScrollTop: 126 } const mountIndex = async () => { const wrapper = mount(IndexPage, { global: { stubs } }) await flushPromises() return wrapper } const confirmLatestNavigation = () => { const navigation = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0] navigation?.success?.({ errMsg: 'navigateTo:ok' } as never) } beforeEach(() => { hostEnvironmentMocks.embeddedInWechatMiniProgram = false testState.mapMountCount = 0 testState.mapUnmountCount = 0 testState.resetToViewBaselineCalls = [] testState.switchFloorCalls = [] testState.routePlanResult = { route: null, error: '' } testState.routePlanRequests = [] testState.searchMountCount = 0 testState.searchUnmountCount = 0 testState.searchRestoreCount = 0 testState.searchCollapseAfterDetailCount = 0 testState.searchResetCount = 0 testState.onShowHandler = null testState.halls = [] testState.hallResolutionPromises = [] testState.explainResults = [] testState.routeTargets = [] window.history.replaceState({}, '', '/#/pages/index/index?tab=guide') vi.stubGlobal('uni', { navigateTo: vi.fn(), navigateBack: vi.fn(), reLaunch: vi.fn(), showToast: vi.fn(), hideKeyboard: vi.fn() }) }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) describe('首页搜索与地图闭环', () => { it('微信内嵌展开搜索只写入一条覆盖层历史记录', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true window.location.hash = '#/pages/index/index?tab=guide&embedded=wechat-mini-program' window.history.replaceState({}, '', window.location.href) const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const pushState = vi.spyOn(window.history, 'pushState') search.vm.$emit('expanded-change', true) await flushPromises() expect(pushState).toHaveBeenCalledTimes(1) expect(pushState).toHaveBeenLastCalledWith( expect.objectContaining({ museumGuideOverlay: 'poi-search' }), '', window.location.href ) search.vm.$emit('expanded-change', true) await flushPromises() expect(pushState).toHaveBeenCalledTimes(1) wrapper.unmount() }) it('微信内嵌搜索返回消费 popstate 并回到首页收缩态', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true window.location.hash = '#/pages/index/index?tab=guide&embedded=wechat-mini-program' window.history.replaceState({}, '', window.location.href) const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const currentUrl = window.location.href search.vm.$emit('expanded-change', true) await flushPromises() window.dispatchEvent(new PopStateEvent('popstate', { state: {} })) await flushPromises() expect(testState.searchResetCount).toBeGreaterThan(0) expect(window.location.href).toBe(currentUrl) expect(wrapper.getComponent(GuideMapShellStub).props('visiblePoiIds')).toBeNull() wrapper.unmount() }) it('微信内嵌搜索多次展开和收缩不会累积覆盖层历史', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true window.location.hash = '#/pages/index/index?tab=guide&embedded=wechat-mini-program' window.history.replaceState({}, '', window.location.href) const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) let currentHistoryState: Record = {} vi.spyOn(window.history, 'state', 'get').mockImplementation(() => currentHistoryState) const pushState = vi.spyOn(window.history, 'pushState').mockImplementation((state) => { currentHistoryState = state as Record }) const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => { currentHistoryState = {} window.dispatchEvent(new PopStateEvent('popstate', { state: {} })) }) search.vm.$emit('expanded-change', true) search.vm.$emit('expanded-change', false) await flushPromises() search.vm.$emit('expanded-change', true) search.vm.$emit('expanded-change', false) await flushPromises() expect(pushState).toHaveBeenCalledTimes(2) expect(historyBack).toHaveBeenCalledTimes(2) expect(currentHistoryState).not.toMatchObject({ museumGuideOverlay: 'poi-search' }) wrapper.unmount() }) it('普通 H5 展开搜索不写入覆盖层历史,也不消费浏览器返回', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const pushState = vi.spyOn(window.history, 'pushState') search.vm.$emit('expanded-change', true) window.dispatchEvent(new PopStateEvent('popstate', { state: {} })) await flushPromises() expect(pushState).not.toHaveBeenCalled() expect(testState.searchResetCount).toBe(0) wrapper.unmount() }) it('同步首页标签时保留内嵌宿主参数', async () => { window.location.hash = '#/pages/index/index?tab=guide&embedded=wechat-mini-program' await mountIndex() expect(window.location.hash).toBe('#/pages/index/index?tab=guide&embedded=wechat-mini-program') }) it('首页暂不展示来馆入口,导览快捷按钮文案固定且保留原切换行为', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) const guideAction = wrapper.findAll('.guide-quick-action')[0] expect(wrapper.text()).not.toContain('来馆') expect(wrapper.findAll('.guide-quick-action').map((item) => item.text())).toEqual([ '导览', '讲解' ]) await guideAction.trigger('tap') await flushPromises() expect(map.props('indoorView')).toBe('overview') map.vm.$emit('floor-change', 'L1') await flushPromises() expect(map.props('indoorView')).toBe('floor') expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览') await guideAction.trigger('tap') await flushPromises() expect(map.props('indoorView')).toBe('overview') expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览') }) it('向搜索面板传入当前楼层,并将分类结果 ID 精确传给地图', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) expect(search.props('currentFloorId')).toBe('L1') expect(search.props('currentFloorLabel')).toBe('1F') expect(map.props('disableAutoExit')).toBe(false) search.vm.$emit('category-mode-change', true) search.vm.$emit('results-change', { ...categoryContext, active: true }) await wrapper.vm.$nextTick() expect(map.props('activeFloor')).toBe('L1') expect(map.props('indoorView')).toBe('overview') expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) expect(map.props('disableAutoExit')).toBe(true) expect(testState.switchFloorCalls).toEqual(['L1', 'L2']) map.vm.$emit('floor-change', 'L2') await wrapper.vm.$nextTick() expect(map.props('activeFloor')).toBe('L2') expect(map.props('indoorView')).toBe('floor') search.vm.$emit('results-change', { ...categoryContext, floorId: 'L1', floorLabel: '1F', visiblePoiIds: [], active: true, pending: true }) await wrapper.vm.$nextTick() expect(map.props('activeFloor')).toBe('L2') map.vm.$emit('floor-change', 'L1') await wrapper.vm.$nextTick() expect(map.props('activeFloor')).toBe('L1') expect(map.props('visiblePoiIds')).toEqual([]) expect(map.props('disableAutoExit')).toBe(true) search.vm.$emit('results-change', { ...categoryContext, active: false, visiblePoiIds: [] }) search.vm.$emit('category-mode-change', false) await wrapper.vm.$nextTick() expect(map.props('disableAutoExit')).toBe(false) }) it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => { const originalFloors = [...floors] floors.splice(0, floors.length, { id: 'F5', label: 'F5', order: 5 }, { id: 'F2', label: 'F2', order: 2 }, { id: 'F1', label: 'F1', order: 1 } ) try { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) expect(map.props('activeFloor')).toBe('F1') expect(wrapper.getComponent(PoiSearchPanelStub).props('currentFloorId')).toBe('F1') } finally { floors.splice(0, floors.length, ...originalFloors) } }) it('分类结果在首页显示统一地点操作卡并定位目标', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) search.vm.$emit('category-mode-change', true) search.vm.$emit('results-change', { ...categoryContext, active: true }) search.vm.$emit('result-tap', poi, categoryContext) await flushPromises() expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id, floorId: 'L2' }) expect(wrapper.find('.poi-card-title').text()).toBe(poi.name) expect(wrapper.findAll('.poi-action')).toHaveLength(1) expect(wrapper.find('[data-action-id="navigate"]').text()).toContain('去这里') expect(vi.mocked(uni.navigateTo)).not.toHaveBeenCalled() expect(map.props('activeFloor')).toBe('L1') map.vm.$emit('floor-change', 'L2') await wrapper.vm.$nextTick() expect(map.props('activeFloor')).toBe('L2') expect(testState.searchMountCount).toBe(1) expect(testState.searchUnmountCount).toBe(0) }) it('全屏搜索结果同样在首页显示地点操作卡', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) const fullSearchContext = { ...categoryContext, categoryId: '' as const, keyword: '二层卫生间' } search.vm.$emit('result-tap', poi, fullSearchContext) await flushPromises() expect(wrapper.find('.poi-card-title').text()).toBe(poi.name) expect(wrapper.find('[data-action-id="navigate"]').exists()).toBe(true) expect(vi.mocked(uni.navigateTo)).not.toHaveBeenCalled() expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id }) expect(map.props('activeFloor')).toBe('L1') map.vm.$emit('floor-change', 'L2') await wrapper.vm.$nextTick() expect(map.props('activeFloor')).toBe('L2') }) it('搜索展开期间若渲染器提交外观,立即重新请求当前楼层并等待提交', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) const search = wrapper.getComponent(PoiSearchPanelStub) map.vm.$emit('floor-change', 'L1') search.vm.$emit('expanded-change', true) await flushPromises() testState.switchFloorCalls = [] map.vm.$emit('auto-switch', { from: 'floor', to: 'overview', trigger: 'zoom-out', distance: 320 }) await flushPromises() expect(map.props('indoorView')).toBe('overview') expect(map.props('disableAutoExit')).toBe(true) expect(testState.switchFloorCalls).toEqual(['L1']) map.vm.$emit('floor-change', 'L1') await wrapper.vm.$nextTick() expect(map.props('indoorView')).toBe('floor') }) it('页面重新显示时要求搜索面板恢复原列表位置', async () => { await mountIndex() await testState.onShowHandler?.() await flushPromises() expect(testState.searchRestoreCount).toBe(1) }) it('搜索结果不直接跳转详情页,并保持当前搜索状态', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) search.vm.$emit('category-mode-change', true) search.vm.$emit('results-change', { ...categoryContext, active: true }) search.vm.$emit('result-tap', poi, categoryContext) await flushPromises() expect(vi.mocked(uni.navigateTo)).not.toHaveBeenCalled() expect(wrapper.find('.guide-poi-card').exists()).toBe(true) expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id }) }) it('模型点选展厅后查看详情,返回时恢复该楼层视觉基线且不重挂载地图', async () => { testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, kind: 'hall', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() const viewHallAction = wrapper.find('[data-action-id="detail"]') expect(viewHallAction.exists()).toBe(true) expect(viewHallAction.text()).toContain('讲解') await viewHallAction.trigger('tap') await flushPromises() expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2') await testState.onShowHandler?.() await flushPromises() expect(testState.resetToViewBaselineCalls).toHaveLength(0) expect(wrapper.find('.guide-poi-card').exists()).toBe(true) confirmLatestNavigation() await testState.onShowHandler?.() await flushPromises() expect(testState.resetToViewBaselineCalls).toEqual([{ view: 'floor', floorId: 'L2', reason: 'poi-detail-close' }]) expect(map.props('activeFloor')).toBe('L2') expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull() expect(wrapper.find('.guide-poi-card').exists()).toBe(false) expect(testState.mapMountCount).toBe(1) expect(testState.mapUnmountCount).toBe(0) await testState.onShowHandler?.() await flushPromises() expect(testState.resetToViewBaselineCalls).toHaveLength(1) }) it('模型点选展厅显示去这里和详情,并在详情返回时复位原楼层', async () => { testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, kind: 'hall', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() const actions = wrapper.findAll('.poi-action') expect(actions).toHaveLength(2) expect(wrapper.text()).not.toContain('相关讲解') await wrapper.find('[data-action-id="detail"]').trigger('tap') await flushPromises() expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2') confirmLatestNavigation() await testState.onShowHandler?.() await flushPromises() expect(testState.resetToViewBaselineCalls).toEqual([{ view: 'floor', floorId: 'L2', reason: 'poi-detail-close' }]) expect(wrapper.find('.guide-poi-card').exists()).toBe(false) }) it('同一个展厅从地图和搜索进入时使用相同详情路由和 hallId', async () => { const hallPoi = { ...poi, kind: 'hall' as const, hallId: 'hall-l2', primaryCategory: { id: 'exhibition_hall', label: '展厅' } } testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) const search = wrapper.getComponent(PoiSearchPanelStub) map.vm.$emit('poi-click', { ...hallPoi, primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() await wrapper.find('[data-action-id="detail"]').trigger('tap') await flushPromises() const mapUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.fail?.({ errMsg: 'navigateTo:fail test cleanup' }) search.vm.$emit('result-tap', hallPoi, categoryContext) await flushPromises() await wrapper.find('[data-action-id="detail"]').trigger('tap') await flushPromises() const searchUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string const mapRoute = mapUrl.split('?')[0] const searchRoute = searchUrl.split('?')[0] const mapQuery = new URLSearchParams(mapUrl.split('?')[1]) const searchQuery = new URLSearchParams(searchUrl.split('?')[1]) expect(mapRoute).toBe('/pages/explain/guide-stop-list') expect(searchRoute).toBe(mapRoute) expect(mapQuery.get('hallId')).toBe('hall-l2') expect(searchQuery.get('hallId')).toBe(mapQuery.get('hallId')) expect(searchUrl).toBe(mapUrl) }) it('普通设施从地图和搜索进入时只提供去这里', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) const search = wrapper.getComponent(PoiSearchPanelStub) map.vm.$emit('poi-click', { ...poi, primaryCategory: 'restroom', primaryCategoryZh: '卫生间' }) await flushPromises() expect(wrapper.findAll('.poi-action')).toHaveLength(1) expect(wrapper.find('[data-action-id="detail"]').exists()).toBe(false) await wrapper.find('[data-action-id="navigate"]').trigger('tap') await flushPromises() expect(vi.mocked(uni.navigateTo)).not.toHaveBeenCalled() search.vm.$emit('result-tap', poi, categoryContext) await flushPromises() expect(wrapper.findAll('.poi-action')).toHaveLength(1) expect(wrapper.find('[data-action-id="detail"]').exists()).toBe(false) expect(wrapper.find('[data-action-id="navigate"]').exists()).toBe(true) expect(vi.mocked(uni.navigateTo)).not.toHaveBeenCalled() }) it('去这里后仅接受已接入路网的地图点位作为起点,并要求确认', async () => { const crossFloorStart = { ...poi, id: 'poi-l1-start', name: '一层服务台', floorId: 'L1', floorLabel: '1F' } testState.routeTargets = [{ poiId: poi.id, name: poi.name, floorId: poi.floorId, floorLabel: poi.floorLabel, categoryLabel: poi.primaryCategory.label, routeNodeId: 'route-node-restroom-l2' }, { routeTargetId: 'anchor-service-l1', poiId: 'route-node-service-l1', sourceId: crossFloorStart.id, name: crossFloorStart.name, floorId: crossFloorStart.floorId, floorLabel: crossFloorStart.floorLabel, categoryLabel: crossFloorStart.primaryCategory.label, routeNodeId: 'route-node-service-l1' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, primaryCategory: 'restroom', primaryCategoryZh: '卫生间' }) await flushPromises() await wrapper.find('[data-action-id="navigate"]').trigger('tap') await flushPromises() const planner = wrapper.getComponent(RoutePlannerPanelStub) expect(planner.props('endPoint')).toMatchObject({ poiId: poi.id }) expect(map.props('routeStartSelectionActive')).toBe(true) expect(map.props('routeSelectablePoiIds')).toEqual([poi.id, 'route-node-service-l1']) expect(map.props('showFloor')).toBe(true) expect(map.props('floorBottom')).toBe('193px') map.vm.$emit('floor-change', crossFloorStart.floorId) map.vm.$emit('route-start-candidate', { ...crossFloorStart, primaryCategory: 'restroom', primaryCategoryZh: '卫生间' }) await flushPromises() expect(planner.props('startCandidate')).toMatchObject({ routeTargetId: 'anchor-service-l1', poiId: 'route-node-service-l1', floorId: 'L1' }) expect(map.props('routeStartSelectionActive')).toBe(false) planner.vm.$emit('cancel-start') await flushPromises() expect(planner.props('startCandidate')).toBeNull() expect(map.props('routeStartSelectionActive')).toBe(true) // A POI click delivered after a floor switch still belongs to the active // route-start session and must not reopen its normal "go here" card. map.vm.$emit('poi-click', crossFloorStart) await flushPromises() expect(planner.props('startCandidate')).toMatchObject({ routeTargetId: 'anchor-service-l1', poiId: 'route-node-service-l1' }) expect(wrapper.find('.guide-poi-card').exists()).toBe(false) planner.vm.$emit('cancel-start') await flushPromises() // A space can be visible and selectable without being listed as a formal // navigable place. Its coordinate must survive the delayed poi-click path // so the backend can snap it to the nearest graph node. const office = { ...crossFloorStart, id: 'space-office-5', name: '办公室', floorId: 'L5', floorLabel: '5F', spaceId: '354513947405226300', positionGltf: [-119.394396, 32.4643, 39.492556] as [number, number, number] } map.vm.$emit('floor-change', office.floorId) map.vm.$emit('poi-click', office) await flushPromises() expect(planner.props('startCandidate')).toMatchObject({ poiId: office.id, sourceId: office.spaceId, floorId: office.floorId, positionGltf: office.positionGltf, coordinateFallback: true }) }) it('取消馆内导览后回到发起导览时的当前楼层和地点卡片', async () => { testState.routeTargets = [{ poiId: poi.id, name: poi.name, floorId: poi.floorId, floorLabel: poi.floorLabel, categoryLabel: poi.primaryCategory.label, routeNodeId: 'route-node-restroom-l2' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, primaryCategory: 'restroom', primaryCategoryZh: '卫生间' }) await flushPromises() await wrapper.find('[data-action-id="navigate"]').trigger('tap') await flushPromises() wrapper.getComponent(RoutePlannerPanelStub).vm.$emit('back') await flushPromises() expect(wrapper.find('.guide-poi-card').exists()).toBe(true) expect(wrapper.text()).toContain(poi.name) expect(map.props('activeFloor')).toBe('L2') expect(testState.resetToViewBaselineCalls.at(-1)).toMatchObject({ floorId: 'L2', reason: 'floor-reset' }) }) it('模拟导览完成后回到发起导览时的当前楼层和地点卡片', async () => { const startPoint = { poiId: 'poi-l1-service', name: '一层服务台', floorId: 'L1', floorLabel: '1F', categoryLabel: '服务设施', routeNodeId: 'route-node-service-l1', positionGltf: [1, 0, 1] as [number, number, number] } testState.routeTargets = [{ poiId: poi.id, name: poi.name, floorId: poi.floorId, floorLabel: poi.floorLabel, categoryLabel: poi.primaryCategory.label, routeNodeId: 'route-node-restroom-l2' }, startPoint] testState.routePlanResult = { route: { id: 'route-l1-l2', start: { poiId: startPoint.poiId, name: startPoint.name, floorId: startPoint.floorId, floorLabel: startPoint.floorLabel, routeNodeId: startPoint.routeNodeId, position: [1, 0, 1] }, end: { poiId: poi.id, name: poi.name, floorId: poi.floorId, floorLabel: poi.floorLabel, routeNodeId: 'route-node-restroom-l2', position: [12, 3, 24] }, distanceMeters: 38, nodeIds: ['route-node-service-l1', 'route-node-restroom-l2'], points: [], floorSegments: [{ floorId: 'L1', floorLabel: '1F', points: [{ nodeId: 'route-node-service-l1', floorId: 'L1', position: [1, 0, 1] }] }], connectorPoints: [] } satisfies GuideRouteResult, error: '' } const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, primaryCategory: 'restroom', primaryCategoryZh: '卫生间' }) await flushPromises() await wrapper.find('[data-action-id="navigate"]').trigger('tap') await flushPromises() const planner = wrapper.getComponent(RoutePlannerPanelStub) map.vm.$emit('route-start-candidate', startPoint) await flushPromises() planner.vm.$emit('confirm-start', startPoint) await flushPromises() expect(testState.routePlanRequests.at(-1)).toMatchObject({ startPoiId: startPoint.poiId, startTarget: { poiId: startPoint.poiId, floorId: startPoint.floorId, positionGltf: startPoint.positionGltf, routeNodeId: startPoint.routeNodeId } }) planner.vm.$emit('simulate-guide') await flushPromises() map.vm.$emit('route-roaming-progress', { remainingMeters: 0, progress: 1 }) await flushPromises() expect(wrapper.find('.guide-poi-card').exists()).toBe(true) expect(wrapper.text()).toContain(poi.name) expect(map.props('activeFloor')).toBe('L2') }) it('非展厅空间没有关联讲解时只保留去这里', async () => { testState.halls = [{ id: 'hall-biology', poiId: 'another-poi', name: '生物厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, id: 'space-l2-temporary', name: '生物厅临展空间', kind: 'space', hallId: 'space-l2-temporary', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() expect(wrapper.find('.guide-poi-card').exists()).toBe(true) expect(wrapper.find('.poi-card-actions').exists()).toBe(true) expect(wrapper.findAll('.poi-action')).toHaveLength(1) expect(wrapper.find('[data-action-id="detail"]').exists()).toBe(false) await wrapper.find('[data-action-id="navigate"]').trigger('tap') expect(vi.mocked(uni.navigateTo)).not.toHaveBeenCalled() }) it('没有进入讲解内容库的展厅语义点位也只保留去这里', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, id: 'hall-l1-dynamic-cinema', name: '动感多维影院', kind: 'hall', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() expect(wrapper.findAll('.poi-action')).toHaveLength(1) expect(wrapper.find('[data-action-id="navigate"]').exists()).toBe(true) expect(wrapper.find('[data-action-id="detail"]').exists()).toBe(false) }) it('快速切换点位时忽略上一个点位较晚返回的展厅关联结果', async () => { let resolveFirst!: (halls: typeof testState.halls) => void let resolveSecond!: (halls: typeof testState.halls) => void testState.hallResolutionPromises = [ new Promise((resolve) => { resolveFirst = resolve }), new Promise((resolve) => { resolveSecond = resolve }) ] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) const firstPoi = { ...poi, id: 'poi-hall-first', kind: 'hall' as const, hallId: 'hall-first', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' } const secondPoi = { ...poi, id: 'poi-hall-second', kind: 'hall' as const, hallId: 'hall-second', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' } map.vm.$emit('poi-click', firstPoi) map.vm.$emit('poi-click', secondPoi) resolveSecond([{ id: 'hall-second', poiId: secondPoi.id, name: '第二展厅' }]) await flushPromises() resolveFirst([{ id: 'hall-first', poiId: firstPoi.id, name: '第一展厅' }]) await flushPromises() expect(wrapper.text()).toContain(secondPoi.name) await wrapper.find('[data-action-id="detail"]').trigger('tap') await flushPromises() const url = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url as string expect(new URLSearchParams(url.split('?')[1]).get('hallId')).toBe('hall-second') }) it('展厅编号名称规范化后仍能关联真实讲解展厅', async () => { testState.halls = [{ id: 'hall-biology', name: '生物厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, id: 'hall-space-biology', name: '展厅_6生物厅', kind: 'space', hallId: 'space-biology', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() const actions = wrapper.findAll('.poi-action') expect(actions).toHaveLength(2) await wrapper.find('[data-action-id="detail"]').trigger('tap') await flushPromises() expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain( '/pages/explain/guide-stop-list?hallId=hall-biology' ) }) it('模型点选展厅的详情入口进入讲解对象列表并保留一次性返回上下文', async () => { testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, kind: 'hall', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() await wrapper.find('[data-action-id="detail"]').trigger('tap') await flushPromises() expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2') confirmLatestNavigation() await testState.onShowHandler?.() await flushPromises() expect(testState.resetToViewBaselineCalls[0]).toMatchObject({ view: 'floor', floorId: 'L2' }) }) it('模型点选详情打开失败时取消返回上下文,不触发楼层复位', async () => { testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, kind: 'hall', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() await wrapper.find('[data-action-id="detail"]').trigger('tap') await flushPromises() const navigation = vi.mocked(uni.navigateTo).mock.calls[0]?.[0] navigation?.fail?.({ errMsg: 'navigateTo:fail simulated' }) await testState.onShowHandler?.() await flushPromises() expect(testState.resetToViewBaselineCalls).toHaveLength(0) expect(wrapper.find('.guide-poi-card').exists()).toBe(true) expect(map.props('activeFloor')).toBe('L2') }) it('地图点位卡隐藏首页 dock 时仍保留搜索组件,关闭后可继续使用原列表', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { id: poi.id, name: poi.name, floorId: poi.floorId, primaryCategory: 'restroom', primaryCategoryZh: '卫生间', iconType: 'restroom', positionGltf: poi.positionGltf }) await wrapper.vm.$nextTick() expect(wrapper.get('[data-testid="poi-search"]').exists()).toBe(true) expect(testState.searchUnmountCount).toBe(0) map.vm.$emit('selection-clear') await wrapper.vm.$nextTick() expect(wrapper.getComponent(PoiSearchPanelStub).exists()).toBe(true) expect(testState.searchMountCount).toBe(1) expect(testState.resetToViewBaselineCalls).toHaveLength(0) }) it('直接点选普通设施后显示统一名称和楼层卡片,关闭后恢复该楼层视觉基线且不重挂载地图', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, primaryCategory: 'restroom', primaryCategoryZh: '卫生间' }) await wrapper.vm.$nextTick() expect(wrapper.find('.poi-card-collapsed').exists()).toBe(false) expect(wrapper.find('.poi-card-title').text()).toBe(poi.name) expect(wrapper.find('.poi-card-subtitle').text()).toBe('2F') await wrapper.find('.poi-card-close').trigger('tap') await flushPromises() expect(testState.resetToViewBaselineCalls).toEqual([{ view: 'floor', floorId: poi.floorId, reason: 'floor-reset' }]) expect(wrapper.find('.guide-poi-card').exists()).toBe(false) expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull() expect(testState.mapMountCount).toBe(1) expect(testState.mapUnmountCount).toBe(0) }) it('直接点选展厅后关闭卡片,复用楼层基线复位而非详情返回语义', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, kind: 'hall', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await wrapper.vm.$nextTick() expect(wrapper.find('.poi-card-close').exists()).toBe(true) await wrapper.find('.poi-card-close').trigger('tap') await flushPromises() expect(testState.resetToViewBaselineCalls).toEqual([{ view: 'floor', floorId: poi.floorId, reason: 'floor-reset' }]) expect(wrapper.find('.guide-poi-card').exists()).toBe(false) expect(testState.mapMountCount).toBe(1) expect(testState.mapUnmountCount).toBe(0) }) it('renderer 清选和返回建筑外观不会触发楼层复位', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, primaryCategory: 'restroom' }) map.vm.$emit('selection-clear') map.vm.$emit('indoor-view-change', 'overview') await wrapper.vm.$nextTick() expect(testState.resetToViewBaselineCalls).toHaveLength(0) }) it('导览地图不渲染任何遮挡模型的临时提示条', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) expect(wrapper.find('.indoor-gesture-hint').exists()).toBe(false) map.vm.$emit('auto-switch', { from: 'floor', to: 'overview', trigger: 'zoom-out', distance: 120 }) await wrapper.vm.$nextTick() expect(wrapper.find('.indoor-gesture-hint').exists()).toBe(false) }) it('单结果分类显示地点操作卡,取消后清除定位目标', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) const singleContext = { ...categoryContext, floorResultCount: 1, visiblePoiIds: [poi.id] } search.vm.$emit('category-mode-change', true) search.vm.$emit('results-change', { ...singleContext, active: true }) search.vm.$emit('result-tap', poi, singleContext) await flushPromises() expect(wrapper.find('.guide-poi-card').exists()).toBe(true) expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id }) expect(vi.mocked(uni.navigateTo)).not.toHaveBeenCalled() search.vm.$emit('cancel') await wrapper.vm.$nextTick() expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull() }) })