// @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(() => ({ searchMountCount: 0, searchUnmountCount: 0, searchRestoreCount: 0, searchCollapseAfterDetailCount: 0, searchResetCount: 0, onShowHandler: null as null | (() => Promise | void) })) 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 () => [], searchExplain: async () => [] } })) 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'], 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 } beforeEach(() => { hostEnvironmentMocks.embeddedInWechatMiniProgram = false testState.searchMountCount = 0 testState.searchUnmountCount = 0 testState.searchRestoreCount = 0 testState.searchCollapseAfterDetailCount = 0 testState.searchResetCount = 0 testState.onShowHandler = null 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('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('分类结果进入详情时不触发首页地图,返回后才恢复目标焦点与楼层', 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) await testState.onShowHandler?.() await flushPromises() expect(testState.searchCollapseAfterDetailCount).toBe(1) expect(testState.searchRestoreCount).toBe(0) expect(map.props('activeFloor')).toBe('L2') expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id, floorId: 'L2' }) }) 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() await testState.onShowHandler?.() await flushPromises() expect(testState.searchCollapseAfterDetailCount).toBe(1) expect(testState.searchRestoreCount).toBe(0) expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id }) }) 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('地图点位卡隐藏首页 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) }) 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() }) })