516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
// @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('固定展示 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('首页快捷分类请求期间清空列表并下发 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()
|
||
})
|
||
})
|