Files
frontend-miniapp/tests/unit/PoiSearchPanel.spec.ts
lyf 9ea1bfab71
Some checks failed
CI / verify (push) Has been cancelled
同步 sgs-frontend-mobile 源码
2026-07-27 11:26:01 +08:00

676 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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'
import type { GuidePoiSearchViewState } from '@/domain/poiSearch'
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
type PoiCategoryId
} from '@/domain/poiCategories'
const guideMocks = vi.hoisted(() => ({
getFloors: vi.fn(),
createInitialPoiSearchState: vi.fn(),
selectPoiSearchCategory: vi.fn(),
searchPoiKeyword: vi.fn(),
clearPoiSearch: vi.fn(),
changePoiSearchFloor: vi.fn()
}))
const hostEnvironmentMocks = vi.hoisted(() => ({
embeddedInWechatMiniProgram: false
}))
vi.mock('@/usecases/guideUseCase', () => ({
guideUseCase: guideMocks
}))
vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram
}))
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,
categoryId: string,
positionGltf: [number, number, number]
): MuseumPoi => ({
id,
name,
floorId,
floorLabel,
primaryCategory: {
id: categoryId,
label: categoryId,
iconType: categoryId
},
categories: [],
positionGltf,
accessible: true
})
const destinationFloor1 = [
createPoi('hall-1', '地球厅', 'floor-1', '1F', 'exhibition_hall', [1, 0, 1]),
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 renderedResultIds = (wrapper: ReturnType<typeof mount>) => (
wrapper.findAll('.result-row').map((row) => (
row.attributes('data-testid').replace('poi-result-', '')
))
)
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.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(),
showToast: vi.fn()
})
})
afterEach(() => {
document.documentElement.className = ''
document.body.className = ''
vi.unstubAllGlobals()
})
describe('PoiSearchPanel 搜索状态契约', () => {
it('首页折叠挂载只加载楼层,不提前读取全馆搜索数据', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
expect(guideMocks.getFloors).toHaveBeenCalledTimes(1)
expect(guideMocks.createInitialPoiSearchState).not.toHaveBeenCalled()
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(guideMocks.searchPoiKeyword).not.toHaveBeenCalled()
expect(wrapper.findAll('.home-category-chip')).toHaveLength(10)
wrapper.unmount()
})
it('首页折叠态同步当前楼层时不创建默认搜索结果', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: '',
currentFloorLabel: ''
}
})
await flushMountedSearch()
await wrapper.setProps({
currentFloorId: 'floor-2',
currentFloorLabel: '2F'
})
await flushMountedSearch()
expect(guideMocks.createInitialPoiSearchState).not.toHaveBeenCalled()
expect(guideMocks.changePoiSearchFloor).not.toHaveBeenCalled()
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(guideMocks.searchPoiKeyword).not.toHaveBeenCalled()
wrapper.unmount()
})
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('首页未初始化搜索状态时分类入口保持可用,并触发首次分类查询', 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()).not.toContain('disabled')
expect(cinema.attributes('aria-disabled')).toBe('false')
await cinema.trigger('tap')
await flushMountedSearch()
expect(guideMocks.selectPoiSearchCategory).toHaveBeenCalledWith(null, 'cinema', 'floor-1')
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true)
wrapper.unmount()
})
it('默认列表只消费 createInitialPoiSearchState 返回的当前楼层空间目的地', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
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('楼层展示文案不一致时仍按稳定 floorId 显示空间结果', async () => {
const storageSpace = createPoi(
'space-storage',
'藏品技术保护区',
'floor-1',
'1.0层',
'space_storage',
[2, 0, 2]
)
guideMocks.createInitialPoiSearchState.mockResolvedValue(createSearchState({
results: [storageSpace]
}))
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(renderedResultIds(wrapper)).toEqual(['space-storage'])
expect(wrapper.get('.result-count').text()).toContain('1 处地点')
wrapper.unmount()
})
it('直接渲染当前楼层状态结果,不用瞬时楼层选项二次过滤后端别名', async () => {
const b2Floor = { id: 'floor-b2', label: 'B2', order: -2 }
const aliasedSpace = createPoi(
'space-b2-storage',
'B2 藏品库',
'legacy-b2-alias',
'B2',
'space_storage',
[2, -8, 2]
)
const b2State: GuidePoiSearchViewState = {
...createSearchState(),
floorId: b2Floor.id,
floorLabel: b2Floor.label,
results: [aliasedSpace],
visiblePoiIds: [aliasedSpace.id]
}
guideMocks.getFloors.mockResolvedValue([b2Floor])
guideMocks.createInitialPoiSearchState.mockResolvedValue(b2State)
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(guideMocks.createInitialPoiSearchState).toHaveBeenCalledWith('floor-b2')
expect(renderedResultIds(wrapper)).toEqual(['space-b2-storage'])
expect(wrapper.get('.result-count').text()).toContain('1 处地点')
wrapper.unmount()
})
it('父级楼层快速往返时使旧楼层请求失效且不覆盖已提交状态', async () => {
const deferredFloor2 = createDeferred<GuidePoiSearchViewState>()
guideMocks.changePoiSearchFloor.mockReturnValueOnce(deferredFloor2.promise)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1'])
await wrapper.setProps({ currentFloorId: 'floor-2', currentFloorLabel: '2F' })
await flushMountedSearch()
expect(renderedResultIds(wrapper)).toEqual([])
await wrapper.setProps({ currentFloorId: 'floor-1', currentFloorLabel: '1F' })
await flushMountedSearch()
expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1'])
deferredFloor2.resolve(createSearchState({ floorId: 'floor-2' }))
await flushMountedSearch()
expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1'])
expect(wrapper.find('[data-testid="poi-result-hall-2"]').exists()).toBe(false)
wrapper.unmount()
})
it('首次默认查询在途时跟随父级新楼层重发并忽略旧响应', async () => {
const deferredFloor1 = createDeferred<GuidePoiSearchViewState>()
guideMocks.createInitialPoiSearchState
.mockReturnValueOnce(deferredFloor1.promise)
.mockResolvedValueOnce(createSearchState({ floorId: 'floor-2' }))
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
await wrapper.setProps({ currentFloorId: 'floor-2', currentFloorLabel: '2F' })
await flushMountedSearch()
expect(guideMocks.createInitialPoiSearchState).toHaveBeenLastCalledWith('floor-2')
expect(renderedResultIds(wrapper)).toEqual(['hall-2'])
deferredFloor1.resolve(createSearchState({ floorId: 'floor-1' }))
await flushMountedSearch()
expect(renderedResultIds(wrapper)).toEqual(['hall-2'])
wrapper.unmount()
})
it('快捷分类调用 selectPoiSearchCategory列表 ID 与 results-change visiblePoiIds 完全一致', 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('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(guideMocks.selectPoiSearchCategory).toHaveBeenCalledWith(null, 'restroom', 'floor-1')
expect(renderedResultIds(wrapper)).toEqual(['restroom-1'])
expect(wrapper.find('.clear-button').exists()).toBe(false)
expect(wrapper.get('[data-testid="poi-category-cancel"]').exists()).toBe(true)
expect(wrapper.get('.result-name').text()).toBe('洗手间 A')
expect(wrapper.get('.result-meta').text()).toBe('1F · 洗手间')
expect(lastResultState(wrapper)).toMatchObject({
active: true,
floorId: 'floor-1',
categoryId: 'restroom',
visiblePoiIds: ['restroom-1']
})
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
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(
expect.objectContaining({ mode: 'default', floorId: 'floor-1' }),
'恐龙',
'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',
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(
expect.objectContaining({ mode: 'default', floorId: 'floor-1' }),
'洗手间',
'floor-1'
)
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(lastResultState(wrapper)).toMatchObject({
active: true,
keyword: '洗手间',
categoryId: ''
})
wrapper.unmount()
})
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',
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(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()
})
})