Files
frontend-miniapp/tests/unit/IndexPoiSearchFlow.spec.ts
cxk b90c58451c
Some checks failed
CI / verify (push) Has been cancelled
修复首页快捷入口与三维点位同步
2026-07-21 00:40:06 +08:00

991 lines
34 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(() => ({
mapMountCount: 0,
mapUnmountCount: 0,
resetToViewBaselineCalls: [] as Array<{
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}>,
searchMountCount: 0,
searchUnmountCount: 0,
searchRestoreCount: 0,
searchCollapseAfterDetailCount: 0,
searchResetCount: 0,
floorSwitchRequests: [] as string[],
onShowHandler: null as null | (() => Promise<void> | void),
halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>,
hallResolutionPromises: [] as Array<Promise<Array<{
id: string
poiId?: string
location?: { poiId?: string }
name: string
}>>>,
explainResults: [] as Array<{
type: 'hall' | 'exhibit'
hallId?: string
exhibitId?: string
}>
}))
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 () => (
testState.hallResolutionPromises.shift() || Promise.resolve(testState.halls)
),
searchExplain: async () => testState.explainResults
}
}))
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', 'floor-request', 'floor-switch-failed', 'initial-model-ready', 'poi-click', 'selection-clear', 'indoor-view-change'],
setup() {
onMounted(() => {
testState.mapMountCount += 1
})
onUnmounted(() => {
testState.mapUnmountCount += 1
})
return {}
},
methods: {
switchFloor(floorId: string) {
testState.floorSwitchRequests.push(floorId)
return Promise.resolve()
},
resetToViewBaseline(options: {
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}) {
testState.resetToViewBaselineCalls.push(options)
return true
}
},
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
}
const confirmLatestNavigation = () => {
const navigation = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]
navigation?.success?.({ errMsg: 'navigateTo:ok' } as never)
}
beforeEach(() => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
testState.mapMountCount = 0
testState.mapUnmountCount = 0
testState.resetToViewBaselineCalls = []
testState.searchMountCount = 0
testState.searchUnmountCount = 0
testState.searchRestoreCount = 0
testState.searchCollapseAfterDetailCount = 0
testState.searchResetCount = 0
testState.floorSwitchRequests = []
testState.onShowHandler = null
testState.halls = []
testState.hallResolutionPromises = []
testState.explainResults = []
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('overview')
map.vm.$emit('floor-change', 'L1')
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(testState.floorSwitchRequests).toEqual(['L2'])
expect(map.props('activeFloor')).toBe('L1')
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('indoorView')).toBe('floor')
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
search.vm.$emit('results-change', {
...categoryContext,
floorId: 'L1',
floorLabel: '1F',
visiblePoiIds: [],
active: true,
pending: true
})
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('visiblePoiIds')).toEqual([])
})
it('三维模型重新以总览就绪后,不将旧单层状态误作楼层提交', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('floor-change', 'L1')
await wrapper.vm.$nextTick()
expect(map.props('indoorView')).toBe('floor')
map.vm.$emit('initial-model-ready', { view: 'overview', floorId: 'L1' })
await wrapper.vm.$nextTick()
expect(map.props('indoorView')).toBe('overview')
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', {
...categoryContext,
floorId: 'L1',
floorLabel: '1F',
active: true,
requestId: 20
})
await wrapper.vm.$nextTick()
expect(testState.floorSwitchRequests).toEqual(['L1'])
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L1')
await wrapper.vm.$nextTick()
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, requestId: 8 })
search.vm.$emit('results-change', {
...categoryContext,
floorId: 'L1',
floorLabel: '1F',
visiblePoiIds: ['stale-l1'],
active: true,
requestId: 7
})
await wrapper.vm.$nextTick()
expect(testState.floorSwitchRequests).toEqual(['L2'])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('楼层切换失败时清空快捷分类标记,重试提交后恢复与列表一致的 ID', 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, requestId: 12 })
await wrapper.vm.$nextTick()
map.vm.$emit('floor-switch-failed', { floorId: 'L2', floorLabel: '2F' })
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L1')
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => {
const originalFloors = [...floors]
floors.splice(0, floors.length,
{ id: 'F5', label: 'F5', order: 5 },
{ id: 'F2', label: 'F2', order: 2 },
{ id: 'F1', label: 'F1', order: 1 }
)
try {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
expect(map.props('activeFloor')).toBe('F1')
expect(wrapper.getComponent(PoiSearchPanelStub).props('currentFloorId')).toBe('F1')
} finally {
floors.splice(0, floors.length, ...originalFloors)
}
})
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
})
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
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('guide-poi')
expect(query.get('id')).toBe(poi.id)
expect(query.get('floorId')).toBe('L2')
expect(query.has('searchKeyword')).toBe(false)
expect(testState.searchMountCount).toBe(1)
expect(testState.searchUnmountCount).toBe(0)
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.searchResetCount).toBe(0)
expect(testState.searchRestoreCount).toBeGreaterThan(0)
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
expect(map.props('targetFocusRequest')).toBeNull()
const resetCount = testState.searchResetCount
await testState.onShowHandler?.()
await flushPromises()
expect(testState.searchResetCount).toBe(resetCount)
})
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()
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.searchResetCount).toBe(0)
expect(testState.searchRestoreCount).toBeGreaterThan(0)
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
expect(map.props('activeFloor')).toBe('L2')
})
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 })
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
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('模型点选展厅后查看详情,返回时恢复该楼层视觉基线且不重挂载地图', async () => {
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
const viewHallAction = wrapper.find('.poi-action.primary')
expect(viewHallAction.exists()).toBe(true)
await viewHallAction.trigger('tap')
await flushPromises()
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2')
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toHaveLength(0)
expect(wrapper.find('.guide-poi-card').exists()).toBe(true)
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toEqual([{
view: 'floor',
floorId: 'L2',
reason: 'poi-detail-close'
}])
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
expect(wrapper.find('.guide-poi-card').exists()).toBe(false)
expect(testState.mapMountCount).toBe(1)
expect(testState.mapUnmountCount).toBe(0)
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toHaveLength(1)
})
it('模型点选展厅只显示一个讲解入口,并在返回时复位原楼层', async () => {
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
const actions = wrapper.findAll('.poi-action')
expect(actions).toHaveLength(1)
expect(wrapper.text()).not.toContain('相关讲解')
await wrapper.find('.poi-action.primary').trigger('tap')
await flushPromises()
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2')
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toEqual([{
view: 'floor',
floorId: 'L2',
reason: 'poi-detail-close'
}])
expect(wrapper.find('.guide-poi-card').exists()).toBe(false)
})
it('同一个展厅从地图和搜索进入时使用相同详情路由和 hallId', async () => {
const hallPoi = {
...poi,
kind: 'hall' as const,
hallId: 'hall-l2',
primaryCategory: { id: 'exhibition_hall', label: '展厅' }
}
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
const search = wrapper.getComponent(PoiSearchPanelStub)
map.vm.$emit('poi-click', {
...hallPoi,
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
await wrapper.find('.poi-action.primary').trigger('tap')
await flushPromises()
const mapUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string
vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.fail?.({ errMsg: 'navigateTo:fail test cleanup' })
search.vm.$emit('result-tap', hallPoi, categoryContext)
await flushPromises()
const searchUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string
const mapRoute = mapUrl.split('?')[0]
const searchRoute = searchUrl.split('?')[0]
const mapQuery = new URLSearchParams(mapUrl.split('?')[1])
const searchQuery = new URLSearchParams(searchUrl.split('?')[1])
expect(mapRoute).toBe('/pages/explain/guide-stop-list')
expect(searchRoute).toBe(mapRoute)
expect(mapQuery.get('hallId')).toBe('hall-l2')
expect(searchQuery.get('hallId')).toBe(mapQuery.get('hallId'))
expect(searchUrl).toBe(mapUrl)
})
it('同一个普通设施从地图和搜索进入时都解析为设施详情', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
const search = wrapper.getComponent(PoiSearchPanelStub)
map.vm.$emit('poi-click', {
...poi,
primaryCategory: 'restroom',
primaryCategoryZh: '卫生间'
})
await flushPromises()
await wrapper.find('.poi-card-collapsed').trigger('tap')
await wrapper.vm.$nextTick()
await wrapper.find('.poi-action.primary').trigger('tap')
await flushPromises()
const mapUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string
vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.fail?.({ errMsg: 'navigateTo:fail test cleanup' })
search.vm.$emit('result-tap', poi, categoryContext)
await flushPromises()
const searchUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string
expect(mapUrl.split('?')[0]).toBe('/pages/facility/detail')
expect(searchUrl.split('?')[0]).toBe('/pages/facility/detail')
expect(new URLSearchParams(mapUrl.split('?')[1]).get('id')).toBe(poi.id)
expect(new URLSearchParams(searchUrl.split('?')[1]).get('id')).toBe(poi.id)
expect(searchUrl).toBe(mapUrl)
})
it('展厅类型空间没有讲解展厅关联时只进入普通点位详情,不显示虚假讲解入口', async () => {
testState.halls = [{ id: 'hall-biology', poiId: 'another-poi', name: '生物厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
id: 'space-l2-temporary',
name: '生物厅临展空间',
kind: 'space',
hallId: 'space-l2-temporary',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
expect(wrapper.find('.guide-poi-card').exists()).toBe(true)
expect(wrapper.find('.poi-card-actions').exists()).toBe(true)
expect(wrapper.text()).not.toContain('查看展厅')
expect(wrapper.text()).not.toContain('相关讲解')
expect(wrapper.text()).toContain('查看详情')
await wrapper.find('.poi-action.primary').trigger('tap')
await flushPromises()
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/facility/detail?')
})
it('快速切换点位时忽略上一个点位较晚返回的展厅关联结果', async () => {
let resolveFirst!: (halls: typeof testState.halls) => void
let resolveSecond!: (halls: typeof testState.halls) => void
testState.hallResolutionPromises = [
new Promise((resolve) => { resolveFirst = resolve }),
new Promise((resolve) => { resolveSecond = resolve })
]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
const firstPoi = {
...poi,
id: 'poi-hall-first',
kind: 'hall' as const,
hallId: 'hall-first',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
}
const secondPoi = {
...poi,
id: 'poi-hall-second',
kind: 'hall' as const,
hallId: 'hall-second',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
}
map.vm.$emit('poi-click', firstPoi)
map.vm.$emit('poi-click', secondPoi)
resolveSecond([{ id: 'hall-second', poiId: secondPoi.id, name: '第二展厅' }])
await flushPromises()
resolveFirst([{ id: 'hall-first', poiId: firstPoi.id, name: '第一展厅' }])
await flushPromises()
expect(wrapper.text()).toContain(secondPoi.name)
await wrapper.find('.poi-action.primary').trigger('tap')
await flushPromises()
const url = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url as string
expect(new URLSearchParams(url.split('?')[1]).get('hallId')).toBe('hall-second')
})
it('展厅编号名称规范化后仍能关联真实讲解展厅', async () => {
testState.halls = [{ id: 'hall-biology', name: '生物厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
id: 'hall-space-biology',
name: '展厅_6生物厅',
kind: 'space',
hallId: 'space-biology',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
const actions = wrapper.findAll('.poi-action')
expect(actions).toHaveLength(1)
await actions[0]!.trigger('tap')
await flushPromises()
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain(
'/pages/explain/guide-stop-list?hallId=hall-biology'
)
})
it('模型点选展厅的唯一讲解入口进入讲解对象列表并保留一次性返回上下文', async () => {
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
await wrapper.find('.poi-action.primary').trigger('tap')
await flushPromises()
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2')
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls[0]).toMatchObject({ view: 'floor', floorId: 'L2' })
})
it('模型点选详情打开失败时取消返回上下文,不触发楼层复位', async () => {
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
await wrapper.find('.poi-action.primary').trigger('tap')
await flushPromises()
const navigation = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]
navigation?.fail?.({ errMsg: 'navigateTo:fail simulated' })
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toHaveLength(0)
expect(wrapper.find('.guide-poi-card').exists()).toBe(true)
expect(map.props('activeFloor')).toBe('L2')
})
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)
expect(testState.resetToViewBaselineCalls).toHaveLength(0)
})
it('直接点选普通设施后展开并关闭卡片,恢复该楼层视觉基线且不重挂载地图', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
primaryCategory: 'restroom',
primaryCategoryZh: '卫生间'
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.poi-card-collapsed').exists()).toBe(true)
await wrapper.find('.poi-card-collapsed').trigger('tap')
await wrapper.vm.$nextTick()
await wrapper.find('.poi-card-close').trigger('tap')
await flushPromises()
expect(testState.resetToViewBaselineCalls).toEqual([{
view: 'floor',
floorId: poi.floorId,
reason: 'floor-reset'
}])
expect(wrapper.find('.guide-poi-card').exists()).toBe(false)
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
expect(testState.mapMountCount).toBe(1)
expect(testState.mapUnmountCount).toBe(0)
})
it('直接点选展厅后关闭卡片,复用楼层基线复位而非详情返回语义', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.poi-card-close').exists()).toBe(true)
await wrapper.find('.poi-card-close').trigger('tap')
await flushPromises()
expect(testState.resetToViewBaselineCalls).toEqual([{
view: 'floor',
floorId: poi.floorId,
reason: 'floor-reset'
}])
expect(wrapper.find('.guide-poi-card').exists()).toBe(false)
expect(testState.mapMountCount).toBe(1)
expect(testState.mapUnmountCount).toBe(0)
})
it('renderer 清选和返回建筑外观不会触发楼层复位', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', { ...poi, primaryCategory: 'restroom' })
map.vm.$emit('selection-clear')
map.vm.$emit('indoor-view-change', 'overview')
await wrapper.vm.$nextTick()
expect(testState.resetToViewBaselineCalls).toHaveLength(0)
})
it('单结果分类使用统一详情目标,主动取消恢复默认标记并清除目标', 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('source')).toBe('guide-poi')
expect(map.props('targetFocusRequest')).toBeNull()
search.vm.$emit('cancel')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
})
})