修复搜索与点位定位闭环
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-12 03:07:49 +08:00
parent e006333c0a
commit d8420fc7c2
18 changed files with 2961 additions and 621 deletions

View File

@@ -0,0 +1,319 @@
// @vitest-environment happy-dom
import { defineComponent } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import type { MuseumPoi } from '@/domain/museum'
import {
parseFacilityDetailSearchContext
} from '@/view-models/guideViewModels'
const mocks = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record<string, unknown>) => Promise<void>),
getFloors: vi.fn(),
getPoiById: vi.fn(),
searchPois: vi.fn(),
getHallById: vi.fn(),
loadPackage: vi.fn(),
loadFloorPois: vi.fn()
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: (handler: (options?: Record<string, unknown>) => Promise<void>) => {
mocks.onLoadHandler = handler
}
}))
vi.mock('@/usecases/guideUseCase', () => ({
guideUseCase: {
getModelSource: () => ({
loadPackage: mocks.loadPackage,
loadFloorPois: mocks.loadFloorPois
}),
getFloors: mocks.getFloors,
getPoiById: mocks.getPoiById,
searchPois: mocks.searchPois,
normalizeFloorId: (value: string) => value
}
}))
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
getHallById: mocks.getHallById
}
}))
vi.mock('@/utils/guideTopTabs', () => ({
navigateToGuideTopTab: vi.fn()
}))
import FacilityDetail from '@/pages/facility/detail.vue'
const facilityPoi: MuseumPoi = {
id: 'poi-restroom-l2',
name: '二层卫生间',
floorId: 'floor-l2',
floorLabel: '2F',
primaryCategory: {
id: 'restroom',
label: '卫生间'
},
categories: [{ id: 'restroom', label: '卫生间' }],
positionGltf: [12, 3, -8],
accessible: true
}
const cinemaPoi: MuseumPoi = {
...facilityPoi,
id: 'space-cinema-l1',
name: '球幕影院',
floorId: 'floor-l1',
floorLabel: '1F',
primaryCategory: {
id: 'space_theater',
label: '剧场空间',
iconType: 'theater'
},
categories: [{ id: 'space_theater', label: '剧场空间', iconType: 'theater' }],
accessible: false
}
const GuidePageFrameStub = defineComponent({
name: 'GuidePageFrameStub',
emits: ['back', 'tab-change'],
template: '<div data-testid="guide-page-frame"><slot /></div>'
})
const GuideMapShellStub = defineComponent({
name: 'GuideMapShellStub',
props: {
targetFocusRequest: {
type: Object,
default: null
},
activeFloor: {
type: String,
default: ''
}
},
emits: ['floor-change', 'target-focus'],
template: '<div data-testid="guide-map-shell"><slot /></div>'
})
const mountDetail = () => mount(FacilityDetail, {
global: {
stubs: {
GuidePageFrame: GuidePageFrameStub,
GuideMapShell: GuideMapShellStub
}
}
})
const loadDetail = async (options: Record<string, unknown>) => {
expect(mocks.onLoadHandler).toBeTypeOf('function')
await mocks.onLoadHandler?.(options)
await flushPromises()
}
beforeEach(() => {
mocks.onLoadHandler = null
mocks.getFloors.mockResolvedValue([
{ id: 'floor-l1', label: '1F', order: 1 },
{ id: 'floor-l2', label: '2F', order: 2 }
])
mocks.getPoiById.mockResolvedValue(facilityPoi)
mocks.searchPois.mockResolvedValue([])
mocks.getHallById.mockResolvedValue(null)
mocks.loadPackage.mockResolvedValue({
overviewModelUrl: '/overview.glb',
floors: [
{ floorId: 'floor-l1', label: '1F', order: 1, modelUrl: '/l1.glb' },
{ floorId: 'floor-l2', label: '2F', order: 2, modelUrl: '/l2.glb' }
]
})
mocks.loadFloorPois.mockResolvedValue([])
vi.stubGlobal('uni', {
navigateBack: vi.fn(),
reLaunch: vi.fn()
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('点位详情实际渲染与返回闭环', () => {
it('只展示真实详情字段,并向地图发送正确楼层与点位定位请求', async () => {
const wrapper = mountDetail()
await loadDetail({
id: encodeURIComponent(facilityPoi.id),
target: encodeURIComponent(facilityPoi.name),
floorId: facilityPoi.floorId,
floorLabel: facilityPoi.floorLabel
})
const sheet = wrapper.get('[data-testid="facility-detail-sheet"]')
const mapShell = wrapper.getComponent(GuideMapShellStub)
expect(sheet.text()).toContain('二层卫生间')
expect(sheet.text()).toContain('类别卫生间')
expect(sheet.text()).toContain('楼层2F')
expect(sheet.text()).not.toContain('可无障碍到达')
expect(sheet.text()).not.toContain('展示点位')
expect(sheet.text()).not.toContain('已在对应楼层模型中标出点位')
expect(wrapper.find('.detail-restore').exists()).toBe(false)
expect(mapShell.props('activeFloor')).toBe('floor-l2')
expect(mapShell.props('targetFocusRequest')).toMatchObject({
poiId: 'poi-restroom-l2',
floorId: 'floor-l2',
positionGltf: [12, 3, -8]
})
})
it('详情复用统一分类名称,不回退到 SDK 原始空间类别', async () => {
mocks.getPoiById.mockResolvedValue(cinemaPoi)
const wrapper = mountDetail()
await loadDetail({
source: 'search',
resultCount: '2',
id: cinemaPoi.id,
target: cinemaPoi.name,
floorId: cinemaPoi.floorId,
floorLabel: cinemaPoi.floorLabel
})
const sheetText = wrapper.get('[data-testid="facility-detail-sheet"]').text()
expect(sheetText).toContain('类别影院')
expect(sheetText).not.toContain('剧场空间')
})
it('搜索多结果关闭详情后返回原结果列表', async () => {
const wrapper = mountDetail()
await loadDetail({
source: 'search',
resultCount: '4',
id: facilityPoi.id,
target: facilityPoi.name
})
await wrapper.get('[data-testid="facility-detail-close"]').trigger('tap')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.reLaunch).not.toHaveBeenCalled()
})
it('搜索单结果关闭详情后回到首页', async () => {
const wrapper = mountDetail()
await loadDetail({
source: 'search',
resultCount: '1',
id: facilityPoi.id,
target: facilityPoi.name
})
await wrapper.get('[data-testid="facility-detail-close"]').trigger('tap')
expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/index/index' })
expect(uni.navigateBack).not.toHaveBeenCalled()
})
it('直接进入详情时使用常规返回', async () => {
const wrapper = mountDetail()
await loadDetail({ id: facilityPoi.id, target: facilityPoi.name })
await wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.reLaunch).not.toHaveBeenCalled()
})
it('地图报告点位缺失或定位失败时显示面向用户的错误反馈', async () => {
const wrapper = mountDetail()
await loadDetail({ id: facilityPoi.id, target: facilityPoi.name })
const mapShell = wrapper.getComponent(GuideMapShellStub)
mapShell.vm.$emit('target-focus', {
requestId: 1,
poiId: facilityPoi.id,
floorId: facilityPoi.floorId,
status: 'missing'
})
await flushPromises()
expect(wrapper.get('[data-testid="facility-location-error"]').text())
.toContain('缺少可用的位置数据')
mapShell.vm.$emit('target-focus', {
requestId: 1,
poiId: facilityPoi.id,
floorId: facilityPoi.floorId,
status: 'error'
})
await flushPromises()
expect(wrapper.get('[data-testid="facility-location-error"]').text())
.toContain('地图定位暂不可用')
})
it('位置数据源加载失败时提供明确重试反馈', async () => {
mocks.getPoiById.mockRejectedValueOnce(new Error('network unavailable'))
mocks.loadPackage.mockRejectedValueOnce(new Error('model package unavailable'))
const wrapper = mountDetail()
await loadDetail({
source: 'search',
resultCount: '2',
id: 'poi-network-error',
target: '网络异常点位',
floorId: 'floor-l2',
floorLabel: '2F'
})
expect(wrapper.get('[data-testid="facility-location-error"]').text())
.toContain('位置数据加载失败,请稍后重试')
expect(wrapper.get('[data-testid="facility-detail-meta"]').text()).toContain('楼层2F')
})
it('点位缺少楼层时显示可见兜底且不发送无效定位请求', async () => {
mocks.getPoiById.mockResolvedValueOnce({
...facilityPoi,
id: 'poi-without-floor',
floorId: '',
floorLabel: ''
})
const wrapper = mountDetail()
await loadDetail({ id: 'poi-without-floor', target: '楼层缺失点位' })
expect(wrapper.get('[data-testid="facility-detail-meta"]').text())
.toContain('楼层楼层信息缺失')
expect(wrapper.get('[data-testid="facility-location-error"]').text())
.toContain('缺少楼层信息')
expect(wrapper.getComponent(GuideMapShellStub).props('targetFocusRequest')).toBeNull()
})
it('完整解析并保留搜索关键词、分类、楼层和列表位置上下文', () => {
expect(parseFacilityDetailSearchContext({
source: 'search',
searchOrigin: 'home',
searchKeyword: encodeURIComponent('卫生间'),
searchCategoryId: 'restroom',
searchFloorId: 'floor-l2',
searchFloorLabel: '2F',
resultCount: '4',
floorResultCount: '2',
visiblePoiIds: 'poi-a,poi-b,poi-a',
listScrollTop: '168'
})).toEqual({
source: 'search',
searchOrigin: 'home',
searchKeyword: '卫生间',
searchCategoryId: 'restroom',
searchFloorId: 'floor-l2',
searchFloorLabel: '2F',
resultCount: 4,
floorResultCount: 2,
visiblePoiIds: ['poi-a', 'poi-b'],
listScrollTop: 168
})
})
})

View File

@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
const ThreeMapStub = defineComponent({
name: 'ThreeMap',
props: {
visiblePoiIds: {
type: Array,
default: null
},
targetFocus: {
type: Object,
default: null
}
},
emits: ['floor-change', 'target-focus'],
template: '<div data-testid="three-map"></div>'
})
const modelSource = {
loadPackage: async () => ({ overviewModelUrl: '', floors: [] }),
loadFloorPois: async () => []
}
const mountShell = (visiblePoiIds: string[] | null) => mount(GuideMapShell, {
props: {
mapType: 'indoor',
indoorModelSource: modelSource,
floors: [
{ id: 'L1', label: '1F' },
{ id: 'L2', label: '2F' }
],
activeFloor: 'L1',
visiblePoiIds
},
global: {
stubs: {
ThreeMap: ThreeMapStub
}
}
})
describe('GuideMapShell 点位过滤契约', () => {
it.each([
['不过滤时透传 null', null],
['无结果时透传空列表', []],
['有结果时透传精确 ID', ['poi-a', 'poi-b']]
])('%s', (_label, visiblePoiIds) => {
const wrapper = mountShell(visiblePoiIds as string[] | null)
expect(wrapper.getComponent(ThreeMapStub).props('visiblePoiIds')).toEqual(visiblePoiIds)
})
it('原样转发跨楼层与目标聚焦结果', async () => {
const wrapper = mountShell(['poi-l2'])
const renderer = wrapper.getComponent(ThreeMapStub)
const focusResult = {
requestId: 3,
poiId: 'poi-l2',
floorId: 'L2',
status: 'focused' as const
}
renderer.vm.$emit('floor-change', 'L2')
renderer.vm.$emit('target-focus', focusResult)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('floorChange')).toEqual([['L2']])
expect(wrapper.emitted('targetFocus')).toEqual([[focusResult]])
})
})

View File

@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
SgsSdkApiProvider,
SgsSdkManifestPayload
} from '@/data/providers/sgsSdkApiProvider'
import { SgsSdkGuideRepository } from '@/repositories/GuideRepository'
import { toMuseumPoiFromSgs } from '@/data/adapters/sgsSdkGuideAdapter'
const manifest: SgsSdkManifestPayload = {
mapId: 'museum',
mapName: '深圳自然博物馆',
floors: [
{
floorId: '101',
floorCode: 'L1',
floorName: '1F',
sortOrder: 1
}
]
}
const floorPoi = {
id: 'poi-toilet',
name: '一层卫生间',
type: 'toilet',
typeName: '卫生间',
floorId: '101',
position: {
x: 4,
y: 12,
z: 8
}
}
const theaterSpace = {
id: 'space-theater',
name: '球幕影院',
type: 'theater',
floorId: '101',
boundaryWkt: 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))'
}
const createProvider = (overrides: Partial<SgsSdkApiProvider> = {}) => ({
getManifest: vi.fn().mockResolvedValue(manifest),
getFloorPois: vi.fn().mockResolvedValue([floorPoi]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn().mockResolvedValue([theaterSpace]),
getNavigablePlaces: vi.fn().mockResolvedValue([]),
...overrides
}) as unknown as SgsSdkApiProvider
afterEach(() => {
vi.restoreAllMocks()
})
describe('SgsSdkGuideRepository 点位搜索', () => {
it('源数据仅有 floorCode 时回填 manifest 的稳定 floorId', () => {
const poi = toMuseumPoiFromSgs({
...floorPoi,
id: 'poi-floor-code-only',
floorId: undefined,
floorCode: 'L1'
}, manifest.floors)
expect(poi.floorId).toBe('101')
expect(poi.floorLabel).toBe('1F')
})
it('SDK null 坐标不会被误转换为模型原点', () => {
const poi = toMuseumPoiFromSgs({
...floorPoi,
id: 'poi-with-null-position',
position: {
x: null,
y: null,
z: null
}
}, manifest.floors)
expect(poi.positionGltf).toBeUndefined()
})
it('合并普通点位与空间点位,并保留影院真实分类和楼层高度', async () => {
const repository = new SgsSdkGuideRepository(createProvider())
const results = await repository.searchPois()
const theaterResults = results.filter((poi) => poi.name === '球幕影院')
expect(theaterResults).toHaveLength(1)
expect(theaterResults[0]).toMatchObject({
id: 'space-space-theater',
floorId: '101',
floorLabel: '1F',
primaryCategory: {
id: 'space_theater'
},
positionGltf: [5, 12, 5]
})
expect(results.some((poi) => poi.id === 'poi-toilet')).toBe(true)
})
it('部分来源失败时继续返回可用点位,并在后续搜索重试失败来源', async () => {
const getFloorSpaces = vi.fn()
.mockRejectedValueOnce(new Error('spaces offline'))
.mockRejectedValueOnce(new Error('spaces offline'))
.mockResolvedValue([theaterSpace])
const provider = createProvider({ getFloorSpaces })
const repository = new SgsSdkGuideRepository(provider)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const firstResults = await repository.searchPois()
expect(firstResults.map((poi) => poi.id)).toEqual(['poi-toilet'])
const secondResults = await repository.searchPois()
expect(secondResults.some((poi) => poi.id === 'space-space-theater')).toBe(true)
expect(getFloorSpaces).toHaveBeenCalledTimes(4)
})
it('补充来源失败不会阻止核心点位结果缓存', async () => {
const getFloorPois = vi.fn().mockResolvedValue([floorPoi])
const getFloorSpaces = vi.fn().mockResolvedValue([theaterSpace])
const getFloorBusinessPois = vi.fn().mockRejectedValue(new Error('business offline'))
const provider = createProvider({
getFloorPois,
getFloorSpaces,
getFloorBusinessPois
})
const repository = new SgsSdkGuideRepository(provider)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await repository.listPois()
await repository.listPois()
expect(getFloorPois).toHaveBeenCalledTimes(1)
expect(getFloorSpaces).toHaveBeenCalledTimes(1)
expect(getFloorBusinessPois).toHaveBeenCalledTimes(1)
})
it('楼层高度来源短暂失败时不缓存缺失的空间点位', async () => {
const getFloorPois = vi.fn()
.mockRejectedValueOnce(new Error('pois offline'))
.mockRejectedValueOnce(new Error('pois offline'))
.mockResolvedValue([floorPoi])
const provider = createProvider({ getFloorPois })
const repository = new SgsSdkGuideRepository(provider)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(repository.searchPois()).rejects.toThrow('点位数据加载失败,请检查网络后重试')
const recoveredResults = await repository.searchPois()
expect(recoveredResults.find((poi) => poi.id === 'space-space-theater')?.positionGltf)
.toEqual([5, 12, 5])
expect(getFloorPois).toHaveBeenCalledTimes(4)
})
it('所有核心楼层与空间请求失败时返回明确的可重试错误', async () => {
const provider = createProvider({
getFloorPois: vi.fn().mockRejectedValue(new Error('pois offline')),
getFloorSpaces: vi.fn().mockRejectedValue(new Error('spaces offline'))
})
const repository = new SgsSdkGuideRepository(provider)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(repository.searchPois()).rejects.toThrow('点位数据加载失败,请检查网络后重试')
})
})

View File

@@ -0,0 +1,283 @@
// @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,
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() {},
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.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('向搜索面板传入当前楼层,并将分类结果 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('跨楼层结果使用既有 target-focus并携带返回上下文打开详情', 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)
})
it('页面重新显示时要求搜索面板恢复原列表位置', async () => {
await mountIndex()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.searchRestoreCount).toBe(1)
})
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()
})
})

View File

@@ -0,0 +1,357 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
import type { MuseumFloor, MuseumPoi } from '@/domain/museum'
const guideMocks = vi.hoisted(() => ({
getFloors: vi.fn(),
getInitialSearchSpacePoints: vi.fn(),
searchPois: vi.fn()
}))
vi.mock('@/usecases/guideUseCase', () => ({
guideUseCase: guideMocks
}))
const floors: MuseumFloor[] = [
{ id: 'floor-1', label: '1F', order: 1 },
{ id: 'floor-2', label: '2F', order: 2 }
]
const createPoi = (
id: string,
name: string,
floorId: string,
floorLabel: string,
iconType: string,
positionGltf?: [number, number, number]
): MuseumPoi => ({
id,
name,
floorId,
floorLabel,
primaryCategory: {
id: iconType,
label: name.includes('卫生间') ? '卫生间' : name.includes('母婴') ? '母婴室' : '展厅',
iconType
},
categories: [],
positionGltf,
accessible: true
})
const poiPool: MuseumPoi[] = [
createPoi('hall-1', '地球厅', 'floor-1', '1F', 'exhibition_hall', [1, 0, 1]),
createPoi('restroom-1', '卫生间 A', 'floor-1', '1F', 'toilet', [2, 0, 2]),
createPoi('restroom-missing-position', '卫生间 B', 'floor-1', '1F', 'toilet'),
createPoi('restroom-2', '卫生间 C', 'floor-2', '2F', 'toilet', [3, 0, 3]),
createPoi('nursing-2', '母婴室', 'floor-2', '2F', 'mother_baby_room', [4, 0, 4])
]
const flushMountedSearch = async () => {
await flushPromises()
await flushPromises()
}
const createDeferred = <T>() => {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
beforeEach(() => {
guideMocks.getFloors.mockResolvedValue(floors)
guideMocks.getInitialSearchSpacePoints.mockResolvedValue(poiPool)
guideMocks.searchPois.mockImplementation(async (keyword = '') => (
keyword
? poiPool.filter((poi) => poi.name.includes(keyword))
: poiPool
))
vi.stubGlobal('uni', {
hideKeyboard: vi.fn(),
navigateTo: vi.fn(),
showToast: vi.fn()
})
})
afterEach(() => {
document.documentElement.className = ''
document.body.className = ''
vi.unstubAllGlobals()
})
describe('POI 搜索面板实际渲染与状态闭环', () => {
it('首页显示六类统一入口,搜索页显示完整十一类入口', async () => {
const homeWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
const homeCategories = homeWrapper.findAll('.home-category-chip')
expect(homeCategories).toHaveLength(6)
expect(homeCategories.map((item) => item.text())).toEqual([
'展厅', '卫生间', '母婴室', '电梯', '楼梯', '扶梯'
])
homeCategories.forEach((item) => {
expect(item.find('.line-icon').exists()).toBe(true)
expect(item.find('.home-category-label').exists()).toBe(true)
})
homeWrapper.unmount()
const pageWrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(pageWrapper.findAll('.category-item')).toHaveLength(11)
expect(pageWrapper.text()).toContain('影院')
expect(pageWrapper.text()).toContain('服务中心')
expect(pageWrapper.text()).toContain('文创')
expect(pageWrapper.text()).not.toContain('服务设施')
pageWrapper.unmount()
})
it('首页分类只展示当前楼层结果,并上报可定位点位的精确 marker ID', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-home-category-results"]').text()).toContain('当前楼层 1F · 2 个点位')
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
const missingPositionRow = wrapper.get('[data-testid="poi-result-restroom-missing-position"]')
expect(missingPositionRow.classes()).toContain('disabled')
expect(missingPositionRow.text()).toContain('暂无地图坐标')
const resultStates = wrapper.emitted('results-change') || []
expect(resultStates.at(-1)?.[0]).toMatchObject({
active: true,
categoryId: 'restroom',
floorId: 'floor-1',
floorLabel: '1F',
resultCount: 2,
floorResultCount: 2,
visiblePoiIds: ['restroom-1']
})
await missingPositionRow.trigger('tap')
expect(wrapper.emitted('resultTap')).toBeUndefined()
expect(uni.showToast).toHaveBeenCalledWith(expect.objectContaining({
title: '该点位缺少地图坐标,暂无法定位'
}))
await wrapper.get('[data-testid="poi-result-restroom-1"]').trigger('tap')
expect(wrapper.emitted('resultTap')?.at(-1)?.[1]).toMatchObject({
categoryId: 'restroom',
resultCount: 2,
visiblePoiIds: ['restroom-1']
})
wrapper.unmount()
})
it('首页分类跟随当前地图楼层更新结果与 marker ID并对无结果给出反馈', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
await wrapper.setProps({ currentFloorId: 'floor-2', currentFloorLabel: '2F' })
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-home-category-results"]').text()).toContain('当前楼层 2F · 1 个点位')
expect(wrapper.find('[data-testid="poi-result-restroom-2"]').exists()).toBe(true)
expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({
active: true,
floorId: 'floor-2',
visiblePoiIds: ['restroom-2']
})
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-nursing-room"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-nursing-2"]').exists()).toBe(true)
await wrapper.setProps({ currentFloorId: 'floor-1', currentFloorLabel: '1F' })
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-empty"]').text()).toContain('当前楼层暂无匹配点位')
expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({
active: true,
visiblePoiIds: []
})
wrapper.unmount()
})
it('独立搜索页进入详情时传递关键词、分类、楼层、结果数和列表位置', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const floor2Tab = wrapper.findAll('.floor-tab').find((item) => item.text() === '2F')
expect(floor2Tab).toBeDefined()
await floor2Tab!.trigger('tap')
await flushMountedSearch()
wrapper.get('[data-testid="poi-result-list"]').element.scrollTop = 128
await wrapper.get('[data-testid="poi-result-restroom-2"]').trigger('tap')
expect(uni.navigateTo).toHaveBeenCalledTimes(1)
const url = vi.mocked(uni.navigateTo).mock.calls[0][0].url
const query = new URLSearchParams(url.split('?')[1])
expect(query.get('source')).toBe('search')
expect(query.get('searchOrigin')).toBe('page')
expect(query.get('searchKeyword')).toBe('卫生间')
expect(query.get('searchCategoryId')).toBe('restroom')
expect(query.get('searchFloorId')).toBe('floor-2')
expect(query.get('resultCount')).toBe('3')
expect(query.get('floorResultCount')).toBe('1')
expect(query.get('visiblePoiIds')).toBe('restroom-2')
expect(query.get('listScrollTop')).toBe('128')
wrapper.unmount()
})
it('页面重新显示时恢复原结果列表位置', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const resultList = wrapper.get('[data-testid="poi-result-list"]')
resultList.element.scrollTop = 128
await wrapper.get('[data-testid="poi-result-restroom-1"]').trigger('tap')
resultList.element.scrollTop = 0
await (wrapper.vm as unknown as {
restoreResultListScroll: () => Promise<void>
}).restoreResultListScroll()
expect(resultList.element.scrollTop).toBe(128)
wrapper.unmount()
})
it('部分数据不会固化,分类点击会重新读取并恢复结果', async () => {
guideMocks.getInitialSearchSpacePoints
.mockResolvedValueOnce([poiPool[0]])
.mockResolvedValueOnce(poiPool)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(2)
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
wrapper.unmount()
})
it('旧的部分结果晚到时不会覆盖最新完整缓存', async () => {
const oldRequest = createDeferred<MuseumPoi[]>()
const latestRequest = createDeferred<MuseumPoi[]>()
guideMocks.getInitialSearchSpacePoints
.mockImplementationOnce(() => oldRequest.promise)
.mockImplementationOnce(() => latestRequest.promise)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(1)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushPromises()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(2)
latestRequest.resolve(poiPool)
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
oldRequest.resolve([poiPool[0]])
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
wrapper.unmount()
})
it('服务中心只展示真实服务台,不把普通服务空间误归类', async () => {
const serviceSpace = createPoi(
'space-service-lounge',
'贵宾休息室',
'floor-1',
'1F',
'service_space',
[5, 0, 5]
)
const serviceDesk = createPoi(
'poi-service-desk',
'咨询服务台',
'floor-1',
'1F',
'service_desk',
[6, 0, 6]
)
guideMocks.getInitialSearchSpacePoints.mockResolvedValue([serviceSpace, serviceDesk])
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-service-center"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-poi-service-desk"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="poi-result-space-service-lounge"]').exists()).toBe(false)
wrapper.unmount()
})
it('弱网失败显示可重试状态,恢复后重新渲染结果', async () => {
guideMocks.getInitialSearchSpacePoints
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce(poiPool)
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-empty"]').text()).toContain('点位加载失败')
expect(wrapper.find('[data-testid="poi-retry"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-retry"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-retry"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="poi-result-hall-1"]').exists()).toBe(true)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
const testState = vi.hoisted(() => ({
restoreCount: 0,
onShowHandler: null as null | (() => Promise<void> | void)
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: vi.fn(),
onShow: (handler: () => Promise<void> | void) => {
testState.onShowHandler = handler
}
}))
vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => false
}))
import SearchPage from '@/pages/search/index.vue'
const PoiSearchPanelStub = defineComponent({
name: 'PoiSearchPanel',
methods: {
restoreResultListScroll() {
testState.restoreCount += 1
}
},
template: '<div data-testid="poi-search-panel"></div>'
})
beforeEach(() => {
testState.restoreCount = 0
testState.onShowHandler = null
vi.stubGlobal('uni', {
reLaunch: vi.fn()
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('独立搜索页取消闭环', () => {
it('主动返回地图时直接回到馆内首页', async () => {
const wrapper = mount(SearchPage, {
global: {
stubs: {
PoiSearchPanel: PoiSearchPanelStub
}
}
})
await testState.onShowHandler?.()
await flushPromises()
expect(testState.restoreCount).toBe(1)
await wrapper.get('.map-link').trigger('tap')
expect(uni.reLaunch).toHaveBeenCalledWith({
url: '/pages/index/index?tab=guide'
})
})
})