// @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 = () => { let resolve!: (value: T) => void const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) return { promise, resolve } } const flushMountedSearch = async () => { await flushPromises() await flushPromises() } const renderedResultIds = (wrapper: ReturnType) => ( wrapper.findAll('.result-row').map((row) => ( row.attributes('data-testid').replace('poi-result-', '') )) ) const lastResultState = (wrapper: ReturnType) => ( (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() 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() 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() }) })