// @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' const guideMocks = vi.hoisted(() => ({ getFloors: vi.fn(), getInitialSearchSpacePoints: vi.fn(), searchPois: 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, iconType: string, positionGltf?: [number, number, number] ): MuseumPoi => ({ id, name, floorId, floorLabel, primaryCategory: { id: iconType, label: name.includes('卫生间') ? '卫生间' : name.includes('母婴') ? '母婴室' : '展厅', iconType }, categories: [], positionGltf, accessible: true }) const poiPool: MuseumPoi[] = [ 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]) ] const flushMountedSearch = async () => { await flushPromises() await flushPromises() } const createDeferred = () => { let resolve!: (value: T | PromiseLike) => void let reject!: (reason?: unknown) => void const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise reject = rejectPromise }) return { promise, resolve, reject } } beforeEach(() => { hostEnvironmentMocks.embeddedInWechatMiniProgram = false guideMocks.getFloors.mockResolvedValue(floors) guideMocks.getInitialSearchSpacePoints.mockResolvedValue(poiPool) guideMocks.searchPois.mockImplementation(async (keyword = '') => ( keyword ? poiPool.filter((poi) => poi.name.includes(keyword)) : poiPool )) vi.stubGlobal('uni', { hideKeyboard: vi.fn(), navigateTo: vi.fn(), showToast: vi.fn() }) }) afterEach(() => { document.documentElement.className = '' document.body.className = '' vi.unstubAllGlobals() }) describe('POI 搜索面板实际渲染与状态闭环', () => { it('首页收缩和展开复用同一份十项快捷入口,搜索页保留完整分类', async () => { const homeWrapper = mount(PoiSearchPanel, { props: { variant: 'home', currentFloorId: 'floor-1', currentFloorLabel: '1F' } }) await flushMountedSearch() const homeCategories = homeWrapper.findAll('.home-category-chip') expect(homeCategories).toHaveLength(5) expect(homeCategories.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('.search-box').trigger('tap') await flushMountedSearch() const expandedCategories = homeWrapper.findAll('.home-shortcut-grid .category-item') expect(expandedCategories).toHaveLength(10) expect(expandedCategories.map((item) => item.text())).toEqual([ '行程', '展览', '影院', '洗手间', '餐厅', '售票处', '母婴室', '服务中心', '电梯', '扶梯' ]) expect(expandedCategories.slice(0, 5).map((item) => item.text())) .toEqual(homeCategories.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) wrapper.unmount() }) it('内嵌微信小程序展开时不渲染内部标题与返回,并可分类和收起', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true 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('.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({ active: true, categoryId: 'restroom', floorId: 'floor-1', visiblePoiIds: ['restroom-1'] }) 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 wrapper.get('.search-input').trigger('confirm', { detail: { value: '洗手间' } }) 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 }).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(5) 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({ 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'] }) 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() 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 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({ active: true, 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 }).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() const latestRequest = createDeferred() 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() }) })