// @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' 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 }>, explainResults: [] as Array<{ type: 'hall' | 'exhibit' hallId?: string exhibitId?: string }> })) 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 () => [], searchTargets: async () => [], getRouteReadiness: async () => ({ ready: false, message: '' }), planRoute: async () => null } })) vi.mock('@/usecases/explainUseCase', () => ({ explainUseCase: { loadExplainHalls: async () => [], loadExplainHallSummaries: async () => ({}), listHalls: async () => 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 } }, emits: ['floor-change', 'poi-click', 'selection-clear', 'indoor-view-change'], 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 } }, 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 stubs = { GuidePageFrame: GuidePageFrameStub, GuideMapShell: GuideMapShellStub, PoiSearchPanel: PoiSearchPanelStub, RoutePlannerPanel: true, 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.searchMountCount = 0 testState.searchUnmountCount = 0 testState.searchRestoreCount = 0 testState.searchCollapseAfterDetailCount = 0 testState.searchResetCount = 0 testState.onShowHandler = null testState.halls = [] testState.explainResults = [] 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') search.vm.$emit('category-mode-change', true) search.vm.$emit('results-change', { ...categoryContext, active: true }) await wrapper.vm.$nextTick() expect(map.props('activeFloor')).toBe('L2') expect(map.props('indoorView')).toBe('floor') expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) }) 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')).toBeNull() const navigateTo = vi.mocked(uni.navigateTo) const detailUrl = navigateTo.mock.calls[0]?.[0]?.url as string const query = new URLSearchParams(detailUrl.split('?')[1]) expect(query.get('source')).toBe('search') expect(query.get('searchOrigin')).toBe('home') expect(query.get('searchKeyword')).toBe('卫生间') expect(query.get('searchCategoryId')).toBe('restroom') expect(query.get('searchFloorId')).toBe('L2') expect(query.get('resultCount')).toBe('2') expect(query.get('floorResultCount')).toBe('2') expect(query.get('visiblePoiIds')).toBe(categoryContext.visiblePoiIds.join(',')) expect(query.get('listScrollTop')).toBe('126') expect(testState.searchMountCount).toBe(1) expect(testState.searchUnmountCount).toBe(0) confirmLatestNavigation() await testState.onShowHandler?.() await flushPromises() expect(testState.searchResetCount).toBeGreaterThan(0) expect(testState.searchRestoreCount).toBe(0) expect(map.props('activeFloor')).toBe('L2') expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull() const resetCount = testState.searchResetCount await testState.onShowHandler?.() await flushPromises() expect(testState.searchResetCount).toBe(resetCount) }) 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() confirmLatestNavigation() await testState.onShowHandler?.() await flushPromises() expect(testState.searchResetCount).toBeGreaterThan(0) expect(testState.searchRestoreCount).toBe(0) expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull() expect(map.props('activeFloor')).toBe('L2') }) 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() const navigateToCall = vi.mocked(uni.navigateTo).mock.calls[0]?.[0] navigateToCall?.fail?.({ errMsg: 'navigateTo:fail simulated' }) await testState.onShowHandler?.() await flushPromises() expect(testState.searchCollapseAfterDetailCount).toBe(0) expect(testState.searchRestoreCount).toBe(1) expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) expect(map.props('targetFocusRequest')).toBeNull() }) 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('.poi-action.primary') expect(viewHallAction.exists()).toBe(true) await viewHallAction.trigger('tap') await flushPromises() expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/hall/detail?id=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.explainResults = [{ type: 'exhibit', exhibitId: 'exhibit-l2' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) map.vm.$emit('poi-click', { ...poi, kind: 'hall', primaryCategory: 'exhibition_hall', primaryCategoryZh: '展厅' }) await flushPromises() const relatedActions = wrapper.findAll('.poi-action') expect(relatedActions).toHaveLength(2) await relatedActions[1]!.trigger('tap') await flushPromises() expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/exhibit/detail?id=exhibit-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('模型点选展厅的相关讲解优先进入展厅详情并保留一次性返回上下文', 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.findAll('.poi-action')[1]!.trigger('tap') await flushPromises() expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/hall/detail?id=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('.poi-action.primary').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(true) await wrapper.find('.poi-card-collapsed').trigger('tap') await wrapper.vm.$nextTick() 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('单结果分类写入 resultCount=1,主动取消恢复默认标记并清除目标', 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() const detailUrl = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url as string expect(new URLSearchParams(detailUrl.split('?')[1]).get('resultCount')).toBe('1') expect(map.props('targetFocusRequest')).toBeNull() search.vm.$emit('cancel') await wrapper.vm.$nextTick() expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull() }) })