463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
// @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()
|
||
})
|
||
})
|