// @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, onShowHandler: null as null | (() => Promise | void) })) vi.mock('@dcloudio/uni-app', () => ({ onLoad: vi.fn(), onShow: (handler: () => Promise | void) => { testState.onShowHandler = handler } })) 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() {}, 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(() => { testState.searchMountCount = 0 testState.searchUnmountCount = 0 testState.searchRestoreCount = 0 testState.searchCollapseAfterDetailCount = 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.unstubAllGlobals() }) describe('首页搜索与地图闭环', () => { it('首页暂不展示来馆入口及其到馆面板', async () => { const wrapper = await mountIndex() expect(wrapper.text()).not.toContain('来馆') expect(wrapper.findAll('.guide-quick-action').map((item) => item.text())).toEqual([ '馆内', '讲解' ]) }) 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('分类结果进入详情并返回后收起搜索、清空 marker,但保留目标焦点与楼层', 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('activeFloor')).toBe('L2') expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id, floorId: 'L2', floorLabel: '2F', positionGltf: poi.positionGltf }) 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')).toMatchObject({ poiId: poi.id }) }) 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')).not.toBeNull() search.vm.$emit('cancel') await wrapper.vm.$nextTick() expect(map.props('visiblePoiIds')).toBeNull() expect(map.props('targetFocusRequest')).toBeNull() }) })