整改点位搜索数据加载与分类逻辑

This commit is contained in:
cxk
2026-07-20 21:30:55 +08:00
parent 65ba9720f8
commit 7ae539accc
14 changed files with 2352 additions and 812 deletions

View File

@@ -4,11 +4,20 @@ 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'
import type { GuidePoiSearchViewState } from '@/domain/poiSearch'
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
type PoiCategoryId
} from '@/domain/poiCategories'
const guideMocks = vi.hoisted(() => ({
getFloors: vi.fn(),
getInitialSearchSpacePoints: vi.fn(),
searchPois: vi.fn()
createInitialPoiSearchState: vi.fn(),
selectPoiSearchCategory: vi.fn(),
searchPoiKeyword: vi.fn(),
clearPoiSearch: vi.fn(),
changePoiSearchFloor: vi.fn()
}))
const hostEnvironmentMocks = vi.hoisted(() => ({
@@ -33,56 +42,184 @@ const createPoi = (
name: string,
floorId: string,
floorLabel: string,
iconType: string,
positionGltf?: [number, number, number]
categoryId: string,
positionGltf: [number, number, number]
): MuseumPoi => ({
id,
name,
floorId,
floorLabel,
primaryCategory: {
id: iconType,
label: name.includes('卫生间') ? '卫生间' : name.includes('母婴') ? '母婴室' : '展厅',
iconType
id: categoryId,
label: categoryId,
iconType: categoryId
},
categories: [],
positionGltf,
accessible: true
})
const poiPool: MuseumPoi[] = [
const destinationFloor1 = [
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])
createPoi('space-1', '生态展区', 'floor-1', '1F', 'space_destination', [2, 0, 2])
]
const destinationFloor2 = [
createPoi('hall-2', '恐龙厅', 'floor-2', '2F', 'exhibition_hall', [3, 0, 3])
]
const restroomFloor1 = [
createPoi('restroom-1', '洗手间 A', 'floor-1', '1F', 'toilet', [4, 0, 4])
]
const restroomFloor2 = [
createPoi('restroom-2', '洗手间 B', 'floor-2', '2F', 'toilet', [5, 0, 5])
]
const keywordFloor1 = [
createPoi('dinosaur-1', '恐龙化石', 'floor-1', '1F', 'exhibition_hall', [6, 0, 6]),
createPoi('dinosaur-shop-1', '恐龙文创店', 'floor-1', '1F', 'shop', [7, 0, 7])
]
type SearchStateOptions = {
mode?: GuidePoiSearchViewState['mode']
floorId?: string
keyword?: string
categoryId?: PoiCategoryId | ''
results?: MuseumPoi[]
disabledCategoryIds?: PoiCategoryId[]
}
const floorFor = (floorId = 'floor-1') => (
floors.find((floor) => floor.id === floorId) || floors[0]
)
const createSearchState = ({
mode = 'default',
floorId = 'floor-1',
keyword = '',
categoryId = '',
results,
disabledCategoryIds = []
}: SearchStateOptions = {}): GuidePoiSearchViewState => {
const floor = floorFor(floorId)
const resolvedResults = results || (floor.id === 'floor-2' ? destinationFloor2 : destinationFloor1)
const disabled = new Set(disabledCategoryIds)
return {
mode,
floorId: floor.id,
floorLabel: floor.label,
keyword,
categoryId,
results: resolvedResults,
visiblePoiIds: resolvedResults.map((poi) => poi.id),
categories: POI_CATEGORIES.map((definition) => ({
definition,
count: disabled.has(definition.id)
? 0
: definition.id === categoryId
? resolvedResults.length
: 1,
disabled: disabled.has(definition.id)
}))
}
}
const createDeferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
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
})
const renderedResultIds = (wrapper: ReturnType<typeof mount>) => (
wrapper.findAll('.result-row').map((row) => (
row.attributes('data-testid').replace('poi-result-', '')
))
)
return { promise, resolve, reject }
}
const lastResultState = (wrapper: ReturnType<typeof mount>) => (
(wrapper.emitted('results-change') || []).at(-1)?.[0] as {
visiblePoiIds: string[]
active: boolean
floorId: string
categoryId: string
keyword: string
pending?: boolean
}
)
beforeEach(() => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
guideMocks.getFloors.mockReset()
guideMocks.createInitialPoiSearchState.mockReset()
guideMocks.selectPoiSearchCategory.mockReset()
guideMocks.searchPoiKeyword.mockReset()
guideMocks.clearPoiSearch.mockReset()
guideMocks.changePoiSearchFloor.mockReset()
guideMocks.getFloors.mockResolvedValue(floors)
guideMocks.getInitialSearchSpacePoints.mockResolvedValue(poiPool)
guideMocks.searchPois.mockImplementation(async (keyword = '') => (
keyword
? poiPool.filter((poi) => poi.name.includes(keyword))
: poiPool
guideMocks.createInitialPoiSearchState.mockImplementation(async (floorId?: string) => (
createSearchState({ floorId })
))
guideMocks.selectPoiSearchCategory.mockImplementation(async (
_state: GuidePoiSearchViewState | null,
categoryId: PoiCategoryId,
floorId?: string
) => {
const isFloor2 = floorFor(floorId).id === 'floor-2'
const results = categoryId === 'restroom'
? isFloor2 ? restroomFloor2 : restroomFloor1
: []
return createSearchState({
mode: 'category',
floorId,
categoryId,
keyword: POI_CATEGORIES.find((category) => category.id === categoryId)?.label || '',
results
})
})
guideMocks.searchPoiKeyword.mockImplementation(async (
_state: GuidePoiSearchViewState | null,
keyword: string,
floorId?: string
) => createSearchState({
mode: 'keyword',
floorId,
keyword,
results: floorFor(floorId).id === 'floor-2' ? destinationFloor2 : keywordFloor1
}))
guideMocks.clearPoiSearch.mockImplementation(async (
_state: GuidePoiSearchViewState | null,
floorId?: string
) => createSearchState({ floorId }))
guideMocks.changePoiSearchFloor.mockImplementation(async (
state: GuidePoiSearchViewState | null,
floorId: string
) => {
if (state?.mode === 'category') {
return createSearchState({
mode: 'category',
floorId,
categoryId: state.categoryId,
keyword: state.keyword,
results: state.categoryId === 'restroom' ? restroomFloor2 : []
})
}
if (state?.mode === 'keyword') {
return createSearchState({
mode: 'keyword',
floorId,
keyword: state.keyword,
results: destinationFloor2
})
}
return createSearchState({ floorId })
})
vi.stubGlobal('uni', {
hideKeyboard: vi.fn(),
navigateTo: vi.fn(),
@@ -96,101 +233,72 @@ afterEach(() => {
vi.unstubAllGlobals()
})
describe('POI 搜索面板实际渲染与状态闭环', () => {
it('不渲染尚未支持的语音搜索入口', () => {
describe('PoiSearchPanel 搜索状态契约', () => {
it('固定展示 10 类快捷查找,并为收起和展开区域提供 aria-label', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
const expectedLabels = ['展厅', '影院', '售票处', '餐饮', '购物', '服务', '洗手间', '母婴', '电梯', '扶梯']
const collapsedCategories = wrapper.findAll('.home-category-chip')
expect(collapsedCategories).toHaveLength(10)
expect(collapsedCategories.map((item) => item.text()).sort()).toEqual([...expectedLabels].sort())
expect(wrapper.get('.home-category-strip').attributes('aria-label')).toBe('快捷查找')
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.findAll('.home-shortcut-grid .category-item')).toHaveLength(10)
expect(wrapper.get('.category-scroll').attributes('aria-label')).toBe('快捷查找')
expect(HOME_POI_CATEGORIES).toHaveLength(10)
wrapper.unmount()
})
it('无结果分类具有禁用态和 aria-disabled点击不会发起分类查询', async () => {
guideMocks.createInitialPoiSearchState.mockResolvedValue(createSearchState({
disabledCategoryIds: ['cinema']
}))
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
const cinema = wrapper.get('[data-testid="poi-category-cinema"]')
expect(cinema.classes()).toContain('disabled')
expect(cinema.attributes('aria-disabled')).toBe('true')
await cinema.trigger('tap')
await flushMountedSearch()
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false)
wrapper.unmount()
})
it('默认列表只消费 createInitialPoiSearchState 返回的当前楼层空间目的地', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
expect(wrapper.find('.voice-icon').exists()).toBe(false)
})
it('首页收缩和展开复用同一份九项快捷入口,搜索页保留完整分类', async () => {
const escalatorPoi = createPoi('escalator-1', '自动扶梯', 'floor-1', '1F', 'escalator', [5, 0, 5])
guideMocks.getInitialSearchSpacePoints.mockResolvedValue([...poiPool, escalatorPoi])
const homeWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
const homeCategories = homeWrapper.findAll('.home-category-chip')
expect(homeCategories).toHaveLength(9)
expect(homeCategories.map((item) => item.text())).toEqual([
'展览', '影院', '洗手间', '餐厅', '售票处',
'母婴室', '服务中心', '电梯', '扶梯'
])
expect(homeCategories.slice(0, 5).map((item) => item.text())).toEqual([
'展览', '影院', '洗手间', '餐厅', '售票处'
])
homeCategories.forEach((item) => {
expect(item.find('.poi-category-icon').exists()).toBe(true)
expect(item.find('.home-category-label').exists()).toBe(true)
})
await homeWrapper.get('[data-testid="poi-category-escalator"]').trigger('tap')
await flushMountedSearch()
expect(homeWrapper.find('[data-testid="poi-result-escalator-1"]').exists()).toBe(true)
await homeWrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
await homeWrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
const expandedCategories = homeWrapper.findAll('.home-shortcut-grid .category-item')
expect(expandedCategories).toHaveLength(9)
expect(expandedCategories.map((item) => item.text())).toEqual([
'展览', '影院', '洗手间', '餐厅', '售票处',
'母婴室', '服务中心', '电梯', '扶梯'
])
expect(expandedCategories.slice(0, 5).map((item) => item.text()))
.toEqual(homeCategories.slice(0, 5).map((item) => item.text()))
homeWrapper.unmount()
const pageWrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(pageWrapper.findAll('.category-item')).toHaveLength(12)
expect(pageWrapper.text()).toContain('影院')
expect(pageWrapper.text()).toContain('服务中心')
expect(pageWrapper.text()).toContain('文创')
expect(pageWrapper.text()).not.toContain('服务设施')
pageWrapper.unmount()
})
it('普通 H5 展开时保留内部标题与返回,并可分类和收起', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.get('.home-fullscreen-nav').text()).toContain('点位搜索')
expect(wrapper.find('[data-testid="poi-search-cancel"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-category-strip').exists()).toBe(true)
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
await wrapper.get('[data-testid="poi-search-cancel"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-category-strip').exists()).toBe(true)
expect(guideMocks.createInitialPoiSearchState).toHaveBeenCalledWith('floor-1')
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(guideMocks.searchPoiKeyword).not.toHaveBeenCalled()
expect(guideMocks.clearPoiSearch).not.toHaveBeenCalled()
expect(guideMocks.changePoiSearchFloor).not.toHaveBeenCalled()
expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1'])
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(false)
wrapper.unmount()
})
it('内嵌微信小程序展开时不渲染内部标题与返回,并可分类和收起', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
it('快捷分类调用 selectPoiSearchCategory列表 ID 与 results-change visiblePoiIds 完全一致', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
@@ -200,61 +308,90 @@ describe('POI 搜索面板实际渲染与状态闭环', () => {
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-fullscreen-nav').exists()).toBe(false)
expect(wrapper.find('[data-testid="poi-search-cancel"]').exists()).toBe(false)
expect(wrapper.get('.poi-search-panel').element.firstElementChild?.classList.contains('search-box'))
.toBe(true)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-category-strip').exists()).toBe(true)
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
await wrapper.get('.home-collapse-handle').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-category-strip').exists()).toBe(true)
wrapper.unmount()
})
it('展开态快捷入口退出全屏并进入当前楼层的分类结果', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.poi-search-content').exists()).toBe(true)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.poi-search-content').exists()).toBe(false)
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true)
expect(wrapper.get('[data-testid="poi-home-category-results"]').text())
.toContain('当前楼层 1F · 2 个点位')
expect(uni.hideKeyboard).toHaveBeenCalled()
expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({
expect(guideMocks.selectPoiSearchCategory).toHaveBeenCalledWith(initialState, 'restroom', 'floor-1')
expect(renderedResultIds(wrapper)).toEqual(['restroom-1'])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
categoryId: 'restroom',
floorId: 'floor-1',
categoryId: 'restroom',
visiblePoiIds: ['restroom-1']
})
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('关键词确认命中分类时仍保留全屏搜索结果', async () => {
it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => {
const initialState = createSearchState()
const deferredCategory = createDeferred<GuidePoiSearchViewState>()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
guideMocks.selectPoiSearchCategory.mockReturnValueOnce(deferredCategory.promise)
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(renderedResultIds(wrapper)).toEqual([])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
pending: true,
visiblePoiIds: []
})
deferredCategory.resolve(createSearchState({
mode: 'category',
categoryId: 'restroom',
keyword: '洗手间',
results: restroomFloor1
}))
await flushMountedSearch()
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('关键词确认调用 searchPoiKeyword并把关键词结果作为地图可见 ID', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await wrapper.get('.search-input').trigger('confirm', {
detail: { value: '恐龙' }
})
await flushMountedSearch()
expect(guideMocks.searchPoiKeyword).toHaveBeenCalledWith(initialState, '恐龙', 'floor-1')
expect(renderedResultIds(wrapper)).toEqual(['dinosaur-1', 'dinosaur-shop-1'])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
keyword: '恐龙',
categoryId: '',
visiblePoiIds: ['dinosaur-1', 'dinosaur-shop-1']
})
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('输入恰好为快捷分类名称时仍走统一关键词查询', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
@@ -270,90 +407,21 @@ describe('POI 搜索面板实际渲染与状态闭环', () => {
})
await flushMountedSearch()
expect(wrapper.find('.poi-search-content').exists()).toBe(true)
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false)
wrapper.unmount()
})
it('详情返回专用复位恢复默认快捷入口并清空搜索状态', 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()
wrapper.get('[data-testid="poi-result-list"]').element.scrollTop = 126
await (wrapper.vm as unknown as {
returnToCollapsedAfterDetail: () => Promise<void>
}).returnToCollapsedAfterDetail()
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false)
expect(wrapper.find('.poi-search-content').exists()).toBe(false)
expect(wrapper.findAll('.home-category-chip')).toHaveLength(9)
expect(wrapper.findAll('.home-category-chip.active')).toHaveLength(0)
expect((wrapper.get('.search-input').element as HTMLInputElement).value).toBe('')
expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({
active: false,
keyword: '',
categoryId: '',
visiblePoiIds: [],
listScrollTop: 0
})
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()
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({
expect(guideMocks.searchPoiKeyword).toHaveBeenCalledWith(initialState, '洗手间', 'floor-1')
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(lastResultState(wrapper)).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']
keyword: '洗手间',
categoryId: ''
})
wrapper.unmount()
})
it('首页分类跟随当前地图楼层更新结果与 marker ID并对无结果给出反馈', async () => {
it('首页关键词请求期间清空列表并下发 pending 空地图 ID', async () => {
const initialState = createSearchState()
const deferredKeyword = createDeferred<GuidePoiSearchViewState>()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
guideMocks.searchPoiKeyword.mockReturnValueOnce(deferredKeyword.promise)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
@@ -363,186 +431,85 @@ describe('POI 搜索面板实际渲染与状态闭环', () => {
})
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('.search-box').trigger('tap')
await wrapper.get('.search-input').trigger('confirm', {
detail: { value: '恐龙' }
})
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({
expect(renderedResultIds(wrapper)).toEqual([])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
pending: true,
visiblePoiIds: []
})
deferredKeyword.resolve(createSearchState({
mode: 'keyword',
keyword: '恐龙',
results: keywordFloor1
}))
await flushMountedSearch()
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('独立搜索页切换楼层调用 changePoiSearchFloor 并渲染新楼层当前模式结果', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const categoryState = guideMocks.selectPoiSearchCategory.mock.results[0]?.value
const floor2 = wrapper.findAll('.floor-tab').find((item) => item.text() === '2F')
expect(floor2).toBeDefined()
await floor2!.trigger('tap')
await flushMountedSearch()
expect(guideMocks.changePoiSearchFloor).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'category', categoryId: 'restroom', floorId: 'floor-1' }),
'floor-2'
)
expect(renderedResultIds(wrapper)).toEqual(['restroom-2'])
expect(categoryState).toBeDefined()
wrapper.unmount()
})
it('清除关键词调用 clearPoiSearch 并恢复默认空间目的地列表', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await wrapper.get('.search-input').trigger('confirm', {
detail: { value: '恐龙' }
})
await flushMountedSearch()
const clearButton = wrapper.get('.clear-button')
await clearButton.trigger('tap')
await flushMountedSearch()
expect(guideMocks.clearPoiSearch).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'keyword', keyword: '恐龙' }),
'floor-1'
)
expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1'])
expect(lastResultState(wrapper)).toMatchObject({
active: false,
categoryId: '',
keyword: '',
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()
})
})