Files
frontend-miniapp/tests/unit/IndexPoiSearchFlow.spec.ts

463 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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> | void)
}))
const hostEnvironmentMocks = vi.hoisted(() => ({
embeddedInWechatMiniProgram: false
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: vi.fn(),
onShow: (handler: () => Promise<void> | 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: '<main><slot /></main>'
})
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: '<section data-testid="guide-map"><slot name="overlay" /><slot /></section>'
})
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: '<div data-testid="poi-search"></div>'
})
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<string, unknown> = {}
vi.spyOn(window.history, 'state', 'get').mockImplementation(() => currentHistoryState)
const pushState = vi.spyOn(window.history, 'pushState').mockImplementation((state) => {
currentHistoryState = state as Record<string, unknown>
})
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.searchResetCount).toBeGreaterThan(0)
expect(testState.searchRestoreCount).toBe(0)
expect(map.props('activeFloor')).toBe('L1')
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
})
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.searchResetCount).toBeGreaterThan(0)
expect(testState.searchRestoreCount).toBe(0)
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
})
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()
})
})