353 lines
11 KiB
TypeScript
353 lines
11 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,
|
||
onShowHandler: null as null | (() => Promise<void> | void)
|
||
}))
|
||
|
||
vi.mock('@dcloudio/uni-app', () => ({
|
||
onLoad: vi.fn(),
|
||
onShow: (handler: () => Promise<void> | 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: '<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() {},
|
||
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(() => {
|
||
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()
|
||
})
|
||
})
|