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

547 lines
17 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
requestId?: number
}
)
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('固定展示 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' } })
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('快捷分类调用 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(initialState, 'restroom', 'floor-1')
expect(renderedResultIds(wrapper)).toEqual(['restroom-1'])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
floorId: 'floor-1',
categoryId: 'restroom',
visiblePoiIds: ['restroom-1']
})
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('首页分类结果在组件重挂载后仍发送递增的跨组件版本号', async () => {
const firstWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await firstWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const firstRequestId = lastResultState(firstWrapper).requestId
firstWrapper.unmount()
const secondWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await secondWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(firstRequestId).toEqual(expect.any(Number))
expect(lastResultState(secondWrapper).requestId).toBeGreaterThan(firstRequestId as number)
secondWrapper.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(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',
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(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()
})
})