From 79b8e42e7dafe5a990ca40cce40cadd6279e20f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9E=E5=8F=AF=20=C2=B7=20YU=20KE?= <1798456934@qq.com> Date: Fri, 28 Aug 2026 22:51:39 +0800 Subject: [PATCH] test(@vben/common-ui): add icon-picker keyword filter regression tests (#8320) Co-authored-by: EvoX --- .../icon-picker/__tests__/icon-picker.test.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 packages/effects/common-ui/src/components/icon-picker/__tests__/icon-picker.test.ts diff --git a/packages/effects/common-ui/src/components/icon-picker/__tests__/icon-picker.test.ts b/packages/effects/common-ui/src/components/icon-picker/__tests__/icon-picker.test.ts new file mode 100644 index 000000000..25b1e4e85 --- /dev/null +++ b/packages/effects/common-ui/src/components/icon-picker/__tests__/icon-picker.test.ts @@ -0,0 +1,176 @@ +import { DOMWrapper, mount } from '@vue/test-utils'; +import { defineComponent, h, markRaw, nextTick } from 'vue'; + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; + +import IconPicker from '../icon-picker.vue'; + +// VbenIcon renders through @iconify/vue's Icon, which lazily fetches icon data +// from api.iconify.design at render time. Isolate it in tests: we only care +// about which icon names are rendered into the grid, not the SVG bodies. +vi.mock('@iconify/vue', () => ({ + Icon: defineComponent({ + name: 'IconifyIconMock', + props: { icon: { type: String, default: '' } }, + setup() { + return () => h('span', { class: 'mock-icon' }); + }, + }), + addCollection: vi.fn(), + addIcon: vi.fn(), + listIcons: vi.fn(() => []), +})); + +vi.mock('../icons', () => ({ + ICONS_MAP: {}, + fetchIconsData: vi.fn(async (prefix: string) => + prefix === 'carbon' + ? [ + 'carbon:home', + 'carbon:search', + 'carbon:settings', + 'carbon:user-profile', + ] + : [], + ), +})); + +// Belt and braces: log + block any remaining network access. +beforeAll(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => { + console.log('[icon-picker test] blocked fetch:', String(url)); + return new Response('{}', { status: 200 }); + }), + ); +}); +afterEach(() => { + document.body.innerHTML = ''; +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const gridIconCount = () => + document.body.querySelector('.grid-cols-6')?.childElementCount ?? 0; + +async function openIconPicker(wrapper: ReturnType) { + await wrapper.find('input').trigger('click'); + await nextTick(); + await vi.waitFor(() => { + expect(document.body.querySelector('.grid-cols-6')).toBeTruthy(); + }); +} + +async function searchAndExpect(keyword: string, expectedCount: number) { + const inputs = [...document.body.querySelectorAll('input')]; + const el = inputs.at(-1); + if (!el) { + throw new Error('search input not found in popover'); + } + await new DOMWrapper(el).setValue(keyword); + // keyword debounce is 300ms; waitFor polls until the grid settles. + await vi.waitFor(() => { + expect(gridIconCount()).toBe(expectedCount); + }); +} + +// Mimics the antdv-next adapter registration: +// IconPicker: withDefaultPlaceholder(IconPicker, 'select', { +// iconSlot: 'addonAfter', +// inputComponent: Input, +// modelValueProp: 'value', +// }) +const AntdvNextLikeInput = defineComponent({ + name: 'AntdvNextLikeInput', + props: { + value: { type: String, default: '' }, + }, + emits: ['update:value'], + setup(props, { emit, attrs }) { + return () => + h('input', { + ...attrs, + class: ['antdv-next-like-input', attrs.class as string], + value: props.value, + onInput: (event: Event) => { + const target = event.target as HTMLInputElement; + emit('update:value', target.value); + }, + }); + }, +}); + +const STATIC_ICONS = ['home', 'search', 'settings', 'user-profile']; + +describe('icon-picker.vue', () => { + it('filters icons with the search input (static icons)', async () => { + const wrapper = mount(IconPicker, { + attachTo: document.body, + props: { + icons: STATIC_ICONS, + prefix: '', + }, + }); + await openIconPicker(wrapper); + expect(gridIconCount()).toBe(4); + + // 'ho' matches only 'home' + await searchAndExpect('ho', 1); + + await searchAndExpect('zzz', 0); // empty result + + wrapper.unmount(); + }); + + it('filters icons with the search input (prefix + fetched icons)', async () => { + const wrapper = mount(IconPicker, { + attachTo: document.body, + props: { + prefix: 'carbon', + }, + }); + // watchDebounced: immediate + 500ms debounce before fetchIconsData + await sleep(700); + await openIconPicker(wrapper); + expect(gridIconCount()).toBe(4); + + // 'user' matches only 'carbon:user-profile' + await searchAndExpect('user', 1); + + wrapper.unmount(); + }); + + it('filters icons through inputComponent using the value/onUpdate:value protocol', async () => { + const wrapper = mount(IconPicker, { + attachTo: document.body, + props: { + icons: STATIC_ICONS, + inputComponent: markRaw(AntdvNextLikeInput), + modelValueProp: 'value', + prefix: '', + }, + }); + await openIconPicker(wrapper); + expect(gridIconCount()).toBe(4); + + // 'se' matches 'search', 'settings' and 'user-profile' (u-s-e-r) + await searchAndExpect('se', 3); + + await searchAndExpect('zzz', 0); + + wrapper.unmount(); + }); +});