From b13674d0e76b9107d92d82cb87b404dc20c95da1 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:40:48 +0800 Subject: [PATCH] test(plugin): cover market list state workflows (#631) --- eslint-suppressions.json | 14 - src/components/cards/PluginFolderCard.vue | 122 +- .../cards/__tests__/PluginFolderCard.spec.ts | 207 +++ src/views/plugin/PluginCardListView.vue | 429 ++++-- .../__tests__/PluginCardListView.spec.ts | 1355 +++++++++++++++++ vite.config.ts | 14 + 6 files changed, 1937 insertions(+), 204 deletions(-) create mode 100644 src/components/cards/__tests__/PluginFolderCard.spec.ts create mode 100644 src/views/plugin/__tests__/PluginCardListView.spec.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 4a305f51..774f4a7d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -897,20 +897,6 @@ "count": 1 } }, - "src/views/plugin/PluginCardListView.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 15 - }, - "@typescript-eslint/no-unused-vars": { - "count": 9 - }, - "no-useless-catch": { - "count": 1 - }, - "sonarjs/no-ignored-exceptions": { - "count": 4 - } - }, "src/views/reorganize/TransferHistoryView.vue": { "@typescript-eslint/no-explicit-any": { "count": 11 diff --git a/src/components/cards/PluginFolderCard.vue b/src/components/cards/PluginFolderCard.vue index e9490f77..77566679 100644 --- a/src/components/cards/PluginFolderCard.vue +++ b/src/components/cards/PluginFolderCard.vue @@ -6,7 +6,9 @@ import { useDisplay } from 'vuetify' import { openSharedDialog } from '@/composables/useSharedDialog' const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue')) -const PluginFolderSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderSettingsDialog.vue')) +const PluginFolderSettingsDialog = defineAsyncComponent( + () => import('@/components/dialog/PluginFolderSettingsDialog.vue'), +) // 文件夹配置接口 interface FolderConfig { @@ -60,7 +62,7 @@ const defaultColor = '#2196F3' const defaultIcon = 'mdi-folder' // 默认渐变 const defaultGradient = - 'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)' + 'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)' // 计算背景图片 const backgroundImage = computed(() => { @@ -166,7 +168,6 @@ function showSettingDialog() { // 保存设置 function saveSettings(config: FolderConfig) { emit('update-config', props.folderName, config) - $toast.success(t('folder.folderSettingsSaved')) } onUnmounted(() => { @@ -226,69 +227,72 @@ const dropdownItems = ref([ 'plugin-folder-card--sortable': props.sortable, }" > - + - -
+ +
- -
+ +
- -
- -
- -
- + +
+ +
+ +
+ +
+ + +
+ +

+ {{ props.folderName }} +

+ +

{{ t('folder.pluginCount', { count: props.pluginCount }) }}

+
- -
- -

- {{ props.folderName }} -

- -

{{ t('folder.pluginCount', { count: props.pluginCount }) }}

+ +
+ + + + + + {{ item.title }} + + +
- - -
- - - - - - {{ item.title }} - - - -
-
diff --git a/src/components/cards/__tests__/PluginFolderCard.spec.ts b/src/components/cards/__tests__/PluginFolderCard.spec.ts new file mode 100644 index 00000000..b7788da9 --- /dev/null +++ b/src/components/cards/__tests__/PluginFolderCard.spec.ts @@ -0,0 +1,207 @@ +import PluginFolderCard from '@/components/cards/PluginFolderCard.vue' +import { fireEvent, screen } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { defineComponent, h } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + isHovering: false, + openSharedDialog: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), +})) + +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.confirm, +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +const HoverStub = defineComponent({ + name: 'VHover', + setup(_props, { slots }) { + return () => h('div', slots.default?.({ isHovering: mocks.isHovering, props: {} })) + }, +}) + +const MenuStub = defineComponent({ + name: 'VMenu', + props: { modelValue: Boolean }, + emits: ['update:modelValue'], + setup(props, { emit, slots }) { + return () => + h('div', [ + slots.activator?.({ props: { onClick: () => emit('update:modelValue', !props.modelValue) } }), + slots.default?.(), + ]) + }, +}) + +const ButtonStub = defineComponent({ + name: 'IconBtn', + inheritAttrs: false, + setup(_props, { attrs, slots }) { + return () => h('button', { ...attrs, type: 'button' }, slots.default?.()) + }, +}) + +const ListItemStub = defineComponent({ + name: 'VListItem', + inheritAttrs: false, + setup(_props, { attrs, slots }) { + return () => h('button', { ...attrs, type: 'button' }, [slots.prepend?.(), slots.default?.()]) + }, +}) + +const passthroughStubs = { + IconBtn: ButtonStub, + VCard: defineComponent({ + name: 'VCard', + inheritAttrs: false, + setup(_props, { attrs, slots }) { + return () => h('article', attrs, [slots.image?.(), slots.default?.()]) + }, + }), + VHover: HoverStub, + VIcon: true, + VImg: true, + VList: defineComponent({ + name: 'VList', + setup(_props, { slots }) { + return () => h('div', slots.default?.()) + }, + }), + VListItem: ListItemStub, + VListItemTitle: defineComponent({ + name: 'VListItemTitle', + setup(_props, { slots }) { + return () => h('span', slots.default?.()) + }, + }), + VMenu: MenuStub, +} + +const defaultFolderConfig = { + color: '#2196F3', + gradient: 'linear-gradient(#111, #222)', + icon: 'mdi-folder-star', + plugins: ['PluginA', 'PluginB'], + showIcon: true, +} + +async function renderFolder(sortable = false, folderConfig: Record = defaultFolderConfig) { + return renderWithProviders(PluginFolderCard, { + global: { stubs: passthroughStubs }, + props: { + folderConfig, + folderName: '媒体工具', + pluginCount: 2, + sortable, + }, + }) +} + +function getDialogEvents(index = -1) { + const call = mocks.openSharedDialog.mock.calls.at(index) + if (!call) throw new Error('未打开共享弹窗') + return call[2] as Record unknown> +} + +describe('PluginFolderCard', () => { + beforeEach(() => { + mocks.isHovering = false + mocks.confirm.mockResolvedValue(true) + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) + }) + + it('renders folder identity and opens it only outside sort mode', async () => { + const normal = await renderFolder() + + expect(screen.getByText('媒体工具')).toBeInTheDocument() + expect(screen.getByText('2 个插件')).toBeInTheDocument() + await fireEvent.click(normal.container.querySelector('article') as Element) + expect(normal.emitted('open')).toEqual([['媒体工具']]) + + normal.unmount() + const sortable = await renderFolder(true) + await fireEvent.click(sortable.container.querySelector('article') as Element) + expect(sortable.emitted('open') ?? []).toHaveLength(0) + expect(screen.queryByText('设置外观')).not.toBeInTheDocument() + }) + + it('uses default appearance and supports a background without an icon', async () => { + const defaults = await renderFolder(false, {}) + expect(defaults.container.querySelector('v-icon-stub[icon="mdi-folder"]')).toBeInTheDocument() + expect(defaults.container.querySelector('.plugin-folder-card__bg')).toBeInTheDocument() + + defaults.unmount() + const image = await renderFolder(false, { background: 'https://example.com/folder.jpg', showIcon: false }) + expect(image.container.querySelector('v-img-stub[src="https://example.com/folder.jpg"]')).toBeInTheDocument() + expect(image.container.querySelector('.plugin-folder-card__icon-container')).not.toBeInTheDocument() + }) + + it('applies hover state and accepts the menu model update', async () => { + mocks.isHovering = true + const { container } = await renderFolder() + + expect(container.querySelector('.plugin-folder-card--hover')).toBeInTheDocument() + await fireEvent.click(container.querySelector('button') as Element) + expect(screen.getByText('设置外观')).toBeInTheDocument() + }) + + it('delegates appearance persistence without announcing success before the owner responds', async () => { + const { emitted } = await renderFolder() + + await fireEvent.click(screen.getByText('设置外观')) + const config = { color: '#ff0000', icon: 'mdi-folder-heart', showIcon: false } + getDialogEvents().save(config) + + expect(emitted('update-config')).toEqual([['媒体工具', config]]) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + }) + + it('validates rename input and emits a valid rename through the shared dialog', async () => { + const { emitted } = await renderFolder() + await fireEvent.click(screen.getByText('重命名')) + await fireEvent.click(screen.getByText('重命名')) + const events = getDialogEvents() + + await events.rename(' ') + expect(mocks.toastError).toHaveBeenCalledWith('文件夹名称不能为空') + + await events.rename('影音工具') + expect(emitted('rename')).toEqual([['媒体工具', '影音工具']]) + }) + + it('closes rename without emitting when the name is unchanged', async () => { + const close = vi.fn() + mocks.openSharedDialog.mockReturnValueOnce({ close, id: 1, updateProps: vi.fn() }) + const { emitted } = await renderFolder() + + await fireEvent.click(screen.getByText('重命名')) + await getDialogEvents().rename('媒体工具') + + expect(close).toHaveBeenCalledOnce() + expect(emitted('rename') ?? []).toHaveLength(0) + }) + + it('emits deletion only after confirmation', async () => { + const cancelled = await renderFolder() + mocks.confirm.mockResolvedValueOnce(false) + await fireEvent.click(screen.getByText('删除文件夹')) + expect(cancelled.emitted('delete') ?? []).toHaveLength(0) + + cancelled.unmount() + const confirmed = await renderFolder() + mocks.confirm.mockResolvedValueOnce(true) + await fireEvent.click(screen.getByText('删除文件夹')) + expect(confirmed.emitted('delete')).toEqual([['媒体工具']]) + }) +}) diff --git a/src/views/plugin/PluginCardListView.vue b/src/views/plugin/PluginCardListView.vue index e4189ec4..55e45b81 100644 --- a/src/views/plugin/PluginCardListView.vue +++ b/src/views/plugin/PluginCardListView.vue @@ -1,9 +1,8 @@ @@ -1756,7 +1897,19 @@ function onDragStartPlugin(evt: any) {
- + + + +
{{ t('common.sortModeHint') }} @@ -1901,9 +2054,23 @@ function onDragStartPlugin(evt: any) {
+ + + new URL(`plugin/install/${pluginId}`, API_BASE_URL).href, + list: new URL('plugin/', API_BASE_URL).href, + order: new URL('user/config/PluginOrder', API_BASE_URL).href, + rating: new URL('plugin/rating', API_BASE_URL).href, + sidebar: new URL('plugin/sidebar_nav', API_BASE_URL).href, + statistic: new URL('plugin/statistic', API_BASE_URL).href, +} + +const mocks = vi.hoisted(() => ({ + appMode: false, + keepAliveHandler: undefined as undefined | ((context?: Record) => Promise), + infiniteDone: vi.fn(), + openSharedDialog: vi.fn(), + registerHeaderTab: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + toastWarning: vi.fn(), + useDynamicButton: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess, warning: mocks.toastWarning }), +})) + +vi.mock('@/composables/useDynamicButton', () => ({ + useDynamicButton: (options: unknown) => mocks.useDynamicButton(options), +})) + +vi.mock('@/composables/useDynamicHeaderTab', () => ({ + useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }), +})) + +vi.mock('@/composables/useKeepAliveRefresh', () => ({ + useKeepAliveRefresh: (handler: (context?: Record) => Promise) => { + mocks.keepAliveHandler = handler + return { refresh: handler } + }, +})) + +vi.mock('@/composables/useCardAccentColor', () => ({ + getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'), +})) + +vi.mock('@/composables/usePWA', () => ({ + usePWA: () => ({ appMode: computed(() => mocks.appMode) }), +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +type MaybeRef = T | Ref | ComputedRef + +interface HeaderButton { + action?: () => void | Promise + dataAttr?: string + icon: string +} + +interface HeaderConfig { + appendButtons: HeaderButton[] + modelValue: Ref +} + +interface DynamicButtonConfig { + menuItems?: MaybeRef + onClick: () => void +} + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise(nextResolve => { + resolve = nextResolve + }) + return { promise, resolve } +} + +function createPlugin(overrides: Partial = {}): Plugin { + return { + id: 'DemoPlugin', + plugin_name: '演示插件', + plugin_author: 'MoviePilot', + plugin_desc: '插件市场测试数据', + plugin_label: '工具,整理', + plugin_version: '1.0.0', + repo_url: 'https://github.com/example/plugins', + ...overrides, + } +} + +const PassthroughStub = defineComponent({ + name: 'PassthroughStub', + setup(_props, { slots }) { + return () => h('div', slots.default?.()) + }, +}) + +const ProgressiveCardGridStub = defineComponent({ + name: 'ProgressiveCardGrid', + props: { + getItemKey: { type: Function as PropType<(item: unknown) => string>, default: undefined }, + items: { type: Array as PropType, required: true }, + }, + setup(props, { slots }) { + return () => + h( + 'section', + props.items.flatMap(item => { + props.getItemKey?.(item) + return slots.default?.({ item }) ?? [] + }), + ) + }, +}) + +const PluginMixedSortCardStub = defineComponent({ + name: 'PluginMixedSortCard', + props: { + item: { type: Object as PropType>, required: true }, + pluginStatistics: { type: Object as PropType>, default: () => ({}) }, + sortable: Boolean, + }, + emits: [ + 'delete-folder', + 'drop-to-folder', + 'open-folder', + 'refresh-data', + 'rename-folder', + 'remove-from-folder', + 'update-folder-config', + ], + setup(props, { emit }) { + return () => { + const item = props.item + const type = String(item.type) + const id = String(item.id) + const data = item.data as + | { + average_rating?: number + config?: { color?: string } + has_update?: boolean + plugin_name?: string + repo_url?: string + } + | undefined + const name = type === 'folder' ? id : data?.plugin_name || id + const dropTarget = document.createElement('div') + dropTarget.classList.add('drag-over') + + return h('article', { 'data-testid': `${type}-${id}` }, [ + h('span', `${type}:${name}`), + h('output', { 'aria-label': `sortable-${id}` }, String(props.sortable)), + type === 'plugin' + ? h('output', { 'aria-label': `update-${id}` }, String(data?.has_update ?? false)) + : h('output', { 'aria-label': `folder-color-${id}` }, data?.config?.color || ''), + type === 'plugin' ? h('output', { 'aria-label': `repo-${id}` }, data?.repo_url || '') : null, + type === 'plugin' + ? h('output', { 'aria-label': `statistic-${id}` }, String(props.pluginStatistics[id] ?? '')) + : null, + type === 'plugin' + ? h('output', { 'aria-label': `installed-rating-${id}` }, String(data?.average_rating ?? '')) + : null, + type === 'folder' + ? h('button', { onClick: () => emit('open-folder', id), type: 'button' }, `open-folder-${id}`) + : h('button', { onClick: () => emit('refresh-data'), type: 'button' }, `refresh-plugin-${id}`), + type === 'folder' + ? h( + 'button', + { onClick: () => emit('update-folder-config', id, { color: '#ff0000' }), type: 'button' }, + `configure-folder-${id}`, + ) + : null, + type === 'folder' + ? h( + 'button', + { onClick: () => emit('rename-folder', id, `${id}-renamed`), type: 'button' }, + `rename-folder-${id}`, + ) + : null, + type === 'folder' + ? h('button', { onClick: () => emit('delete-folder', id), type: 'button' }, `delete-folder-${id}`) + : null, + type === 'folder' + ? h( + 'button', + { + onClick: () => + emit( + 'drop-to-folder', + { currentTarget: dropTarget, preventDefault: vi.fn(), stopPropagation: vi.fn() }, + id, + ), + type: 'button', + }, + `drop-to-folder-${id}`, + ) + : null, + type === 'plugin' + ? h('button', { onClick: () => emit('remove-from-folder', id), type: 'button' }, `remove-plugin-${id}`) + : null, + ]) + } + }, +}) + +const PluginAppCardStub = defineComponent({ + name: 'PluginAppCard', + props: { plugin: { type: Object as PropType, required: true } }, + emits: ['install'], + setup(props, { emit }) { + return () => + h('article', { 'data-testid': `market-${props.plugin.id}` }, [ + h('span', `market:${props.plugin.plugin_name}`), + h('output', { 'aria-label': `rating-${props.plugin.id}` }, String(props.plugin.average_rating ?? '')), + h('button', { onClick: () => emit('install'), type: 'button' }, `installed-${props.plugin.id}`), + ]) + }, +}) + +const DraggableStub = defineComponent({ + name: 'Draggable', + props: { modelValue: { type: Array as PropType, required: true } }, + emits: ['end', 'start', 'update:modelValue'], + setup(props, { emit, slots }) { + async function reverse() { + emit('update:modelValue', [...props.modelValue].reverse()) + await nextTick() + emit('end') + } + + return () => + h('section', [ + ...props.modelValue.flatMap(element => slots.item?.({ element }) ?? []), + h('button', { onClick: reverse, type: 'button' }, 'reverse-plugin-order'), + ...props.modelValue.map((_item, index) => + h( + 'button', + { onClick: () => emit('start', { oldIndex: index }), type: 'button' }, + `start-plugin-drag-${index}`, + ), + ), + h('button', { onClick: () => emit('end'), type: 'button' }, 'finish-plugin-drag'), + ]) + }, +}) + +const LoadingBannerStub = defineComponent({ + name: 'LoadingBanner', + template: '
正在加载插件
', +}) + +const NoDataFoundStub = defineComponent({ + name: 'NoDataFound', + props: { errorDescription: String, errorTitle: String }, + template: '
{{ errorTitle }} {{ errorDescription }}
', +}) + +const InfiniteScrollStub = defineComponent({ + name: 'VInfiniteScroll', + emits: ['load'], + setup(_props, { emit, slots }) { + return () => + h('section', [ + slots.default?.(), + h('button', { onClick: () => emit('load', { done: mocks.infiniteDone }), type: 'button' }, 'load-more-market'), + ]) + }, +}) + +const FieldStub = defineComponent({ + name: 'FieldStub', + inheritAttrs: false, + props: { label: String, modelValue: [String, Number], placeholder: String }, + emits: ['keyup', 'update:modelValue'], + setup(props, { emit }) { + return () => + h('input', { + 'aria-label': props.label || props.placeholder, + value: props.modelValue, + onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), + onKeyup: (event: KeyboardEvent) => emit('keyup', event), + }) + }, +}) + +const SelectStub = defineComponent({ + name: 'VSelect', + props: { + items: { type: Array as PropType, default: () => [] }, + label: String, + modelValue: { type: Array as PropType, default: () => [] }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h('fieldset', { 'aria-label': props.label }, [ + ...props.items.map(item => + h( + 'button', + { + onClick: () => + emit( + 'update:modelValue', + props.modelValue.includes(item) + ? props.modelValue.filter(value => value !== item) + : [...props.modelValue, item], + ), + type: 'button', + }, + item, + ), + ), + ]) + }, +}) + +interface ListResponses { + folders?: () => JsonBodyType | Promise + installed?: () => Plugin[] | Promise + installedStatus?: number + market?: () => Plugin[] | Promise + marketStatus?: number + order?: unknown[] + rating?: (ids: string[]) => Record | Promise> + statistic?: () => Record | Promise> +} + +function registerListHandlers(responses: ListResponses = {}) { + server.use( + http.get(apiUrls.order, () => + HttpResponse.json({ data: { value: responses.order ?? [] }, success: true } as JsonBodyType), + ), + http.get(apiUrls.folders, async () => HttpResponse.json((await responses.folders?.()) ?? {})), + http.get(apiUrls.list, async ({ request }) => { + const state = new URL(request.url).searchParams.get('state') + const plugins = state === 'installed' ? await responses.installed?.() : await responses.market?.() + return HttpResponse.json((plugins ?? []) as unknown as JsonBodyType, { + status: state === 'installed' ? (responses.installedStatus ?? 200) : (responses.marketStatus ?? 200), + }) + }), + http.get(apiUrls.statistic, async () => HttpResponse.json((await responses.statistic?.()) ?? {})), + http.get(apiUrls.sidebar, () => HttpResponse.json([])), + http.get(apiUrls.rating, async ({ request }) => { + const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? [] + return HttpResponse.json(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType) + }), + ) +} + +async function renderList(responses: ListResponses = {}) { + registerListHandlers(responses) + return renderWithProviders(PluginCardListView, { + initialRoute: '/plugins', + initialState: { + user: { + permissions: DEFAULT_PERMISSIONS, + superUser: true, + }, + }, + global: { + stubs: { + LoadingBanner: LoadingBannerStub, + NoDataFound: NoDataFoundStub, + PluginAppCard: PluginAppCardStub, + PluginMixedSortCard: PluginMixedSortCardStub, + ProgressiveCardGrid: ProgressiveCardGridStub, + VCombobox: FieldStub, + VInfiniteScroll: InfiniteScrollStub, + VMenu: PassthroughStub, + VSelect: SelectStub, + VTextField: FieldStub, + VPageContentTitle: true, + VWindow: PassthroughStub, + VWindowItem: PassthroughStub, + draggable: DraggableStub, + }, + }, + }) +} + +function getHeaderConfig() { + return mocks.registerHeaderTab.mock.calls.at(-1)?.[0] as HeaderConfig +} + +function getDynamicButtonConfig() { + return mocks.useDynamicButton.mock.calls.at(-1)?.[0] as DynamicButtonConfig +} + +function getDynamicMenuItem(titleKey: string) { + const item = unref(getDynamicButtonConfig().menuItems)?.find(candidate => candidate.titleKey === titleKey) + if (!item) throw new Error(`未注册动态操作 ${titleKey}`) + return item +} + +function getHeaderButton(icon: string) { + const button = getHeaderConfig().appendButtons.find(candidate => candidate.icon === icon) + if (!button?.action) throw new Error(`未注册 header 操作 ${icon}`) + return button +} + +function getInstalledLabels() { + return [...document.querySelectorAll('[data-testid^="folder-"], [data-testid^="plugin-"]')].map( + node => node.querySelector('span')?.textContent, + ) +} + +function getDialogEvents(index = -1) { + const call = mocks.openSharedDialog.mock.calls.at(index) + if (!call) throw new Error('未打开共享弹窗') + return call[2] as Record unknown> +} + +async function waitForRequestsToFinish() { + await new Promise(resolve => setTimeout(resolve, 0)) + await waitFor(() => expect(getActiveRequestsCount()).toBe(0)) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(getActiveRequestsCount()).toBe(0) +} + +describe('PluginCardListView loading and request ownership', () => { + beforeEach(() => { + mocks.appMode = false + mocks.keepAliveHandler = undefined + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('keeps the newest forced market response when the older ordinary request finishes last', async () => { + const older = createDeferred() + const newer = createDeferred() + let marketRequest = 0 + await renderList({ + installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], + market: () => (marketRequest++ === 0 ? older.promise : newer.promise), + }) + + await waitFor(() => expect(marketRequest).toBe(1)) + await getHeaderConfig() + .appendButtons.find(button => button.icon === 'mdi-refresh') + ?.action?.() + await waitFor(() => expect(marketRequest).toBe(2)) + + newer.resolve([createPlugin({ id: 'Newest', plugin_name: '最新市场插件' })]) + expect(await screen.findByText('market:最新市场插件')).toBeInTheDocument() + + older.resolve([createPlugin({ id: 'Stale', plugin_name: '过期市场插件' })]) + await waitForRequestsToFinish() + expect(screen.queryByText('market:过期市场插件')).not.toBeInTheDocument() + expect(screen.getByText('market:最新市场插件')).toBeInTheDocument() + }) + + it('keeps the newest installed response when an older refresh finishes last', async () => { + const older = createDeferred() + const newer = createDeferred() + let installedRequest = 0 + await renderList({ + installed: () => (installedRequest++ === 0 ? older.promise : newer.promise), + }) + + await waitFor(() => expect(installedRequest).toBe(1)) + if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调') + const newestRefresh = mocks.keepAliveHandler({ source: 'resume' }) + await waitFor(() => expect(installedRequest).toBe(2)) + + newer.resolve([createPlugin({ id: 'NewestInstalled', installed: true, plugin_name: '最新已安装快照' })]) + expect(await screen.findByText('plugin:最新已安装快照')).toBeInTheDocument() + await newestRefresh + + older.resolve([createPlugin({ id: 'StaleInstalled', installed: true, plugin_name: '过期已安装快照' })]) + await waitForRequestsToFinish() + expect(screen.queryByText('plugin:过期已安装快照')).not.toBeInTheDocument() + expect(screen.getByText('plugin:最新已安装快照')).toBeInTheDocument() + }) + + it('keeps rating and statistic results from the newest refresh generation', async () => { + const olderRatings = createDeferred>() + const newerRatings = createDeferred>() + const olderStatistics = createDeferred>() + const newerStatistics = createDeferred>() + let ratingRequest = 0 + let statisticRequest = 0 + await renderList({ + installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], + rating: () => { + ratingRequest += 1 + if (ratingRequest === 1) { + return { Installed: { average_rating: 3, plugin_id: 'Installed', rating_count: 1 } } + } + return ratingRequest === 2 ? olderRatings.promise : newerRatings.promise + }, + statistic: () => { + statisticRequest += 1 + if (statisticRequest === 1) return { Installed: 1 } + return statisticRequest === 2 ? olderStatistics.promise : newerStatistics.promise + }, + }) + await waitForRequestsToFinish() + + if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调') + const olderRefresh = mocks.keepAliveHandler({ source: 'resume' }) + await waitFor(() => { + expect(ratingRequest).toBe(2) + expect(statisticRequest).toBe(2) + }) + const newerRefresh = mocks.keepAliveHandler({ source: 'resume' }) + await waitFor(() => { + expect(ratingRequest).toBe(3) + expect(statisticRequest).toBe(3) + }) + + newerRatings.resolve({ Installed: { average_rating: 4.9, plugin_id: 'Installed', rating_count: 9 } }) + newerStatistics.resolve({ Installed: 99 }) + await newerRefresh + expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.9') + expect(screen.getByLabelText('statistic-Installed')).toHaveTextContent('99') + + olderRatings.resolve({ Installed: { average_rating: 1, plugin_id: 'Installed', rating_count: 1 } }) + olderStatistics.resolve({ Installed: 2 }) + await olderRefresh + await waitForRequestsToFinish() + expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.9') + expect(screen.getByLabelText('statistic-Installed')).toHaveTextContent('99') + }) + + it('rejects a rating snapshot when the current plugin ID set changes before the next rating request', async () => { + const staleRatings = createDeferred>() + const currentMarket = createDeferred() + let installedRequest = 0 + let marketRequest = 0 + let ratingRequest = 0 + await renderList({ + installed: () => { + installedRequest += 1 + if (installedRequest === 1) { + return [createPlugin({ average_rating: 4, id: 'Shared', installed: true, plugin_name: '共享插件' })] + } + if (installedRequest === 2) { + return [ + createPlugin({ average_rating: 4.2, id: 'Shared', installed: true, plugin_name: '共享插件' }), + createPlugin({ id: 'Removed', installed: true, plugin_name: '即将移除' }), + ] + } + return [ + createPlugin({ average_rating: 4.6, id: 'Shared', installed: true, plugin_name: '共享插件' }), + createPlugin({ id: 'Added', installed: true, plugin_name: '当前新增' }), + ] + }, + market: () => { + marketRequest += 1 + return marketRequest === 3 ? currentMarket.promise : [] + }, + rating: () => { + ratingRequest += 1 + if (ratingRequest === 1) { + return { Shared: { average_rating: 4, plugin_id: 'Shared', rating_count: 1 } } + } + if (ratingRequest === 2) return staleRatings.promise + return { Shared: { average_rating: 4.9, plugin_id: 'Shared', rating_count: 9 } } + }, + }) + await waitForRequestsToFinish() + + if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调') + const staleRefresh = mocks.keepAliveHandler({ source: 'resume' }) + await waitFor(() => expect(ratingRequest).toBe(2)) + + const currentRefresh = mocks.keepAliveHandler({ source: 'resume' }) + expect(await screen.findByText('plugin:当前新增')).toBeInTheDocument() + await waitFor(() => expect(marketRequest).toBe(3)) + + staleRatings.resolve({ + Removed: { average_rating: 1, plugin_id: 'Removed', rating_count: 1 }, + Shared: { average_rating: 1, plugin_id: 'Shared', rating_count: 1 }, + }) + await staleRefresh + expect(screen.getByLabelText('installed-rating-Shared')).toHaveTextContent('4.6') + + currentMarket.resolve([]) + await currentRefresh + expect(screen.getByLabelText('installed-rating-Shared')).toHaveTextContent('4.9') + await waitForRequestsToFinish() + }) + + it('merges update metadata into installed cards and excludes update entries from the market list', async () => { + await renderList({ + installed: () => [ + createPlugin({ id: 'Shared', installed: true, plugin_name: '已安装共享插件', repo_url: 'old/repo' }), + ], + market: () => [ + createPlugin({ + has_update: true, + id: 'Shared', + installed: true, + plugin_name: '市场更新项', + repo_url: 'new/repo', + }), + createPlugin({ id: 'Available', plugin_name: '可安装插件' }), + ], + }) + + await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('true')) + expect(screen.getByLabelText('repo-Shared')).toHaveTextContent('new/repo') + expect(screen.queryByText('market:市场更新项')).not.toBeInTheDocument() + expect(screen.getByText('market:可安装插件')).toBeInTheDocument() + await waitForRequestsToFinish() + }) + + it('keeps an initial installed failure retryable instead of presenting a successful empty list', async () => { + await renderList({ installedStatus: 500 }) + + expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText('正在加载插件')).not.toBeInTheDocument()) + + server.use( + http.get(apiUrls.list, ({ request }) => { + const state = new URL(request.url).searchParams.get('state') + const plugins = + state === 'installed' ? [createPlugin({ id: 'Recovered', installed: true, plugin_name: '重试恢复插件' })] : [] + return HttpResponse.json(plugins as unknown as JsonBodyType) + }), + ) + await fireEvent.click(screen.getByRole('button', { name: '重试' })) + expect(await screen.findByText('plugin:重试恢复插件')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText('正在加载插件')).not.toBeInTheDocument()) + await waitForRequestsToFinish() + }) + + it('leaves an initial market failure in a retryable error state instead of permanent loading', async () => { + await renderList({ + installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], + marketStatus: 500, + }) + + expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument() + await waitForRequestsToFinish() + expect(screen.queryByText('正在加载插件')).not.toBeInTheDocument() + + server.use( + http.get(apiUrls.list, ({ request }) => { + const state = new URL(request.url).searchParams.get('state') + const plugins = + state === 'market' ? [createPlugin({ id: 'MarketRecovered', plugin_name: '市场重试恢复插件' })] : [] + return HttpResponse.json(plugins as unknown as JsonBodyType) + }), + ) + await fireEvent.click(screen.getByRole('button', { name: '重试' })) + expect(await screen.findByText('market:市场重试恢复插件')).toBeInTheDocument() + await waitForRequestsToFinish() + }) + + it('batches ratings by 100 plugin IDs and merges the returned values into current cards', async () => { + const requestedChunks: string[][] = [] + const installed = Array.from({ length: 101 }, (_, index) => + createPlugin({ id: `Installed-${index}`, installed: true, plugin_name: `已安装-${index}` }), + ) + const market = Array.from({ length: 101 }, (_, index) => + createPlugin({ id: `Market-${index}`, plugin_name: `市场-${index}` }), + ) + await renderList({ + installed: () => installed, + market: () => market, + rating: ids => { + requestedChunks.push(ids) + return Object.fromEntries( + ids.map(id => [id, { average_rating: id === 'Market-0' ? 4.8 : 4, plugin_id: id, rating_count: 2 }]), + ) + }, + }) + + await waitFor(() => expect(requestedChunks).toHaveLength(3)) + expect(requestedChunks.map(chunk => chunk.length)).toEqual([100, 100, 2]) + expect(await screen.findByLabelText('rating-Market-0')).toHaveTextContent('4.8') + await waitForRequestsToFinish() + }) +}) + +describe('PluginCardListView market filtering and pagination', () => { + beforeEach(() => { + mocks.appMode = false + mocks.keepAliveHandler = undefined + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('filters and sorts market entries, labels local repos, and appends pages of 20', async () => { + const market = Array.from({ length: 25 }, (_, index) => + createPlugin({ + add_time: index, + id: `Market-${index}`, + plugin_author: index % 2 === 0 ? 'Alice' : 'Bob', + plugin_desc: index === 5 ? 'Contains Needle' : `描述 ${index}`, + plugin_label: index % 3 === 0 ? '工具,整理' : '通知', + plugin_name: index === 0 ? 'Zulu' : index === 24 ? 'Alpha' : `插件 ${String(index).padStart(2, '0')}`, + repo_url: + index === 0 + ? 'local://repo?path=%2Ftmp%2Fplugins' + : index === 1 + ? 'local://repo' + : 'https://github.com/example/repo', + }), + ) + await renderList({ + market: () => market, + statistic: () => Object.fromEntries(market.map((plugin, index) => [plugin.id, 100 - index])), + }) + + await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(20)) + expect(document.querySelector('[data-testid^="market-"]')).toHaveTextContent('market:Zulu') + + await fireEvent.click(screen.getByRole('button', { name: 'load-more-market' })) + await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(25)) + expect(mocks.infiniteDone).toHaveBeenLastCalledWith('ok') + await fireEvent.click(screen.getByRole('button', { name: 'load-more-market' })) + expect(mocks.infiniteDone).toHaveBeenLastCalledWith('empty') + + getHeaderConfig().modelValue.value = 'market' + await nextTick() + await waitForRequestsToFinish() + const marketFilterButton = getHeaderConfig().appendButtons.find(button => button.dataAttr === 'market-filter-btn') + if (!marketFilterButton?.action) throw new Error('未注册市场过滤操作') + marketFilterButton.action() + await nextTick() + + const initialRepository = screen.getByRole('group', { name: '仓库' }) + expect(within(initialRepository).getByRole('button', { name: '/tmp/plugins' })).toBeInTheDocument() + expect(within(initialRepository).getByRole('button', { name: '本地' })).toBeInTheDocument() + + await fireEvent.click(screen.getByText('插件名称')) + await waitFor(() => expect(document.querySelector('[data-testid^="market-"]')).toHaveTextContent('market:Alpha')) + + marketFilterButton.action() + await nextTick() + const nameInput = screen.getByRole('textbox', { name: '名称' }) + await fireEvent.update(nameInput, 'needle') + await fireEvent.keyUp(nameInput, { key: 'Enter' }) + await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(1)) + expect(screen.getByText('market:插件 05')).toBeInTheDocument() + + marketFilterButton.action() + await nextTick() + const reopenedNameInput = screen.getByRole('textbox', { name: '名称' }) + const repository = screen.getByRole('group', { name: '仓库' }) + await fireEvent.update(reopenedNameInput, '') + const author = screen.getByRole('group', { name: '作者' }) + await fireEvent.click(within(author).getByRole('button', { name: 'Alice' })) + await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(13)) + expect(screen.queryByText('market:插件 05')).not.toBeInTheDocument() + + await fireEvent.click(within(author).getByRole('button', { name: 'Alice' })) + const label = screen.getByRole('group', { name: '标签' }) + await fireEvent.click(within(label).getByRole('button', { name: '工具' })) + await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(9)) + + await fireEvent.click(within(label).getByRole('button', { name: '工具' })) + await fireEvent.click(within(repository).getByRole('button', { name: '/tmp/plugins' })) + await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(1)) + expect(screen.getByText('market:Zulu')).toBeInTheDocument() + await waitForRequestsToFinish() + }) +}) + +describe('PluginCardListView installed filtering and host callbacks', () => { + beforeEach(() => { + mocks.appMode = true + mocks.keepAliveHandler = undefined + mocks.openSharedDialog.mockImplementation(() => ({ close: vi.fn(), id: 1, updateProps: vi.fn() })) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('applies running, update, and name filters through the installed menu', async () => { + await renderList({ + installed: () => [ + createPlugin({ has_update: true, id: 'Alpha', installed: true, plugin_name: 'Alpha', state: true }), + createPlugin({ has_update: false, id: 'Beta', installed: true, plugin_name: 'Beta', state: true }), + createPlugin({ has_update: true, id: 'Gamma', installed: true, plugin_name: 'Gamma', state: false }), + ], + }) + await screen.findByText('plugin:Alpha') + await waitForRequestsToFinish() + const installedFilterButton = getHeaderConfig().appendButtons.find( + button => button.dataAttr === 'installed-filter-btn', + ) + if (!installedFilterButton?.action) throw new Error('未注册已安装过滤操作') + + installedFilterButton.action() + await nextTick() + await fireEvent.click(screen.getByText('运行中')) + await waitFor(() => expect(screen.queryByText('plugin:Gamma')).not.toBeInTheDocument()) + expect(screen.getByText('plugin:Alpha')).toBeInTheDocument() + expect(screen.getByText('plugin:Beta')).toBeInTheDocument() + + installedFilterButton.action() + await nextTick() + await fireEvent.click(screen.getByText('有新版本')) + await waitFor(() => expect(screen.queryByText('plugin:Beta')).not.toBeInTheDocument()) + expect(screen.getByText('plugin:Alpha')).toBeInTheDocument() + + installedFilterButton.action() + await nextTick() + await fireEvent.click(screen.getByText('运行中')) + installedFilterButton.action() + await nextTick() + await fireEvent.click(screen.getByText('有新版本')) + installedFilterButton.action() + await nextTick() + const nameInput = screen.getByRole('textbox', { name: '名称' }) + await fireEvent.update(nameInput, 'bet') + await fireEvent.keyUp(nameInput, { key: 'Enter' }) + expect(screen.getByText('plugin:Beta')).toBeInTheDocument() + expect(screen.queryByText('plugin:Alpha')).not.toBeInTheDocument() + expect(screen.queryByText('plugin:Gamma')).not.toBeInTheDocument() + }) + + it('updates search results and refreshes market and sidebar through public callbacks', async () => { + let marketRequests = 0 + const controller = { close: vi.fn(), id: 1, updateProps: vi.fn() } + mocks.openSharedDialog.mockReturnValue(controller) + const { pinia } = await renderList({ + installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], + market: () => { + marketRequests += 1 + return [createPlugin({ id: 'Available', plugin_desc: 'Needle', plugin_name: '可安装插件' })] + }, + }) + await waitForRequestsToFinish() + const sidebarStore = usePluginSidebarNavStore(pinia) + + getDynamicButtonConfig().onClick() + const searchEvents = getDialogEvents() + searchEvents['update:keyword']('needle') + expect(controller.updateProps).toHaveBeenCalledWith( + expect.objectContaining({ keyword: 'needle', plugins: [expect.objectContaining({ id: 'Available' })] }), + ) + + getHeaderConfig().modelValue.value = 'market' + await nextTick() + await waitForRequestsToFinish() + getDynamicMenuItem('dialog.pluginMarketSetting.title').action() + getDialogEvents().save() + await waitFor(() => expect(marketRequests).toBeGreaterThanOrEqual(3)) + + await fireEvent.click(screen.getByRole('button', { name: 'installed-Available' })) + await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)) + await waitForRequestsToFinish() + }) +}) + +describe('PluginCardListView search installation', () => { + beforeEach(() => { + mocks.appMode = false + mocks.keepAliveHandler = undefined + mocks.openSharedDialog.mockImplementation(() => ({ close: vi.fn(), id: 1, updateProps: vi.fn() })) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('blocks incompatible plugins and reports business and HTTP failures without success', async () => { + let installRequests = 0 + let mode: 'business' | 'http' = 'business' + await renderList() + await waitForRequestsToFinish() + server.use( + http.get(apiUrls.install('SearchPlugin'), () => { + installRequests += 1 + return mode === 'business' + ? HttpResponse.json({ message: 'Rejected', success: false }) + : HttpResponse.json({ message: 'HTTP failure' }, { status: 500 }) + }), + ) + + getDynamicButtonConfig().onClick() + await getDialogEvents()['open-plugin']( + createPlugin({ + id: 'SearchPlugin', + plugin_name: '不兼容插件', + system_version_compatible: false, + system_version_message: '版本不兼容', + }), + ) + expect(mocks.toastError).toHaveBeenLastCalledWith('版本不兼容') + expect(installRequests).toBe(0) + + mocks.toastError.mockClear() + getDynamicButtonConfig().onClick() + await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: '业务失败插件' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(installRequests).toBe(1) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + + mocks.toastError.mockClear() + mode = 'http' + getDynamicButtonConfig().onClick() + await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: 'HTTP 失败插件' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(installRequests).toBe(2) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + await waitForRequestsToFinish() + }) + + it('preserves install query parameters and refreshes the list and sidebar after success', async () => { + let installed = false + let installUrl: URL | undefined + const target = createPlugin({ + has_update: true, + id: 'SearchPlugin', + plugin_name: '搜索安装插件', + repo_url: 'https://github.com/example/search-plugin', + }) + const { pinia } = await renderList({ + installed: () => + installed ? [createPlugin({ id: 'SearchPlugin', installed: true, plugin_name: '搜索安装插件' })] : [], + market: () => [target], + }) + await waitForRequestsToFinish() + server.use( + http.get(apiUrls.install('SearchPlugin'), ({ request }) => { + installUrl = new URL(request.url) + installed = true + return HttpResponse.json({ data: null, success: true }) + }), + ) + const sidebarStore = usePluginSidebarNavStore(pinia) + + getDynamicButtonConfig().onClick() + await getDialogEvents()['open-plugin'](target) + + expect(await screen.findByText('plugin:搜索安装插件')).toBeInTheDocument() + await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)) + expect(installUrl?.searchParams.get('repo_url')).toBe('https://github.com/example/search-plugin') + expect(installUrl?.searchParams.get('force')).toBe('true') + expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 搜索安装插件 安装成功!') + await waitForRequestsToFinish() + }) +}) + +describe('PluginCardListView folders and persistence', () => { + beforeEach(() => { + mocks.appMode = false + mocks.keepAliveHandler = undefined + mocks.openSharedDialog.mockImplementation(() => ({ close: vi.fn(), id: 1, updateProps: vi.fn() })) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('normalizes legacy folders and mixed PluginOrder while preserving the configured order', async () => { + await renderList({ + folders: () => ({ + Legacy: ['Installed-B'], + Modern: { color: '#00ff00', order: 0, plugins: ['Installed-A'], showIcon: false }, + }), + installed: () => [ + createPlugin({ id: 'Installed-A', installed: true, plugin_name: '插件 A' }), + createPlugin({ id: 'Installed-B', installed: true, plugin_name: '插件 B' }), + createPlugin({ id: 'Installed-C', installed: true, plugin_name: '插件 C' }), + ], + order: [ + { id: 'Modern', order: 0, type: 'folder' }, + { id: 'Installed-C', order: 1, type: 'plugin' }, + { id: 'Legacy', order: 2, type: 'folder' }, + ], + }) + + await waitFor(() => expect(screen.getByText('folder:Modern')).toBeInTheDocument()) + const labels = [...document.querySelectorAll('[data-testid^="folder-"], [data-testid^="plugin-"]')].map( + node => node.querySelector('span')?.textContent, + ) + expect(labels.slice(0, 3)).toEqual(['folder:Modern', 'plugin:插件 C', 'folder:Legacy']) + expect(screen.queryByText('plugin:插件 A')).not.toBeInTheDocument() + expect(screen.queryByText('plugin:插件 B')).not.toBeInTheDocument() + await waitForRequestsToFinish() + }) + + it('normalizes the legacy PluginOrder string array', async () => { + await renderList({ + installed: () => [ + createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), + createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), + ], + order: ['Plugin-B', 'Plugin-A'], + }) + + await screen.findByText('plugin:插件 B') + expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 B', 'plugin:插件 A']) + await waitForRequestsToFinish() + }) + + it('rolls back a new folder and reports failure when persistence returns success false', async () => { + mocks.appMode = true + await renderList({ + installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], + }) + await screen.findByText('plugin:已安装插件') + await waitForRequestsToFinish() + server.use(http.post(apiUrls.folders, () => HttpResponse.json({ message: '保存被拒绝', success: false }))) + + getDynamicMenuItem('plugin.newFolder').action() + const events = getDialogEvents() + events['update:name']('失败文件夹') + await events.create() + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(screen.queryByText('folder:失败文件夹')).not.toBeInTheDocument() + await waitForRequestsToFinish() + }) + + it('validates a new folder and persists a unique trimmed name', async () => { + mocks.appMode = true + await renderList({ folders: () => ({ Existing: [] }) }) + await screen.findByText('folder:Existing') + await waitForRequestsToFinish() + server.use(http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true }))) + + getDynamicMenuItem('plugin.newFolder').action() + const events = getDialogEvents() + events['update:name'](' ') + await events.create() + expect(mocks.toastError).toHaveBeenLastCalledWith('文件夹名称不能为空') + + events['update:name']('Existing') + await events.create() + expect(mocks.toastError).toHaveBeenLastCalledWith('文件夹已存在') + + events['update:name'](' New Folder ') + await events.create() + expect(await screen.findByText('folder:New Folder')).toBeInTheDocument() + expect(mocks.toastSuccess).toHaveBeenLastCalledWith('文件夹创建成功') + }) + + it('persists folder appearance, rename, and deletion on successful responses', async () => { + await renderList({ + folders: () => ({ Tools: { color: '#00ff00', order: 0, plugins: ['Installed'] } }), + installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '文件夹插件' })], + }) + await screen.findByText('folder:Tools') + await waitForRequestsToFinish() + server.use(http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true }))) + + await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' })) + await waitFor(() => expect(screen.getByLabelText('folder-color-Tools')).toHaveTextContent('#ff0000')) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('文件夹设置已保存')) + + await fireEvent.click(screen.getByRole('button', { name: 'rename-folder-Tools' })) + expect(await screen.findByText('folder:Tools-renamed')).toBeInTheDocument() + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('文件夹重命名成功')) + + await fireEvent.click(screen.getByRole('button', { name: 'delete-folder-Tools-renamed' })) + await waitFor(() => expect(screen.queryByText('folder:Tools-renamed')).not.toBeInTheDocument()) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('文件夹删除成功')) + expect(screen.getByText('plugin:文件夹插件')).toBeInTheDocument() + }) + + it('rolls back appearance, rename, and deletion on business or HTTP failures', async () => { + await renderList({ + folders: () => ({ Tools: { color: '#00ff00', order: 0, plugins: ['Installed'] } }), + installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '文件夹插件' })], + }) + await screen.findByText('folder:Tools') + await waitForRequestsToFinish() + let saveAttempt = 0 + server.use( + http.post(apiUrls.folders, () => { + saveAttempt += 1 + return saveAttempt === 2 + ? HttpResponse.json({ message: 'HTTP failure' }, { status: 500 }) + : HttpResponse.json({ message: 'Rejected', success: false }) + }), + ) + + await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) + expect(screen.getByLabelText('folder-color-Tools')).toHaveTextContent('#00ff00') + + await fireEvent.click(screen.getByRole('button', { name: 'rename-folder-Tools' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(2)) + expect(screen.getByText('folder:Tools')).toBeInTheDocument() + expect(screen.queryByText('folder:Tools-renamed')).not.toBeInTheDocument() + + await fireEvent.click(screen.getByRole('button', { name: 'delete-folder-Tools' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(3)) + expect(screen.getByText('folder:Tools')).toBeInTheDocument() + expect(mocks.toastSuccess).not.toHaveBeenCalled() + }) + + it('rolls back a failed removal and persists a later removal from a folder', async () => { + let saveSucceeds = false + await renderList({ + folders: () => ({ Tools: ['Installed'] }), + installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '文件夹插件' })], + }) + await screen.findByText('folder:Tools') + await waitForRequestsToFinish() + server.use( + http.post(apiUrls.folders, () => + saveSucceeds + ? HttpResponse.json({ data: null, success: true }) + : HttpResponse.json({ message: 'Rejected' }, { status: 500 }), + ), + ) + + await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) + expect(await screen.findByText('plugin:文件夹插件')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: 'remove-plugin-Installed' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(screen.getByText('plugin:文件夹插件')).toBeInTheDocument() + + saveSucceeds = true + await fireEvent.click(screen.getByRole('button', { name: 'remove-plugin-Installed' })) + await waitFor(() => expect(screen.queryByText('plugin:文件夹插件')).not.toBeInTheDocument()) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件已移出文件夹')) + }) + + it('rolls back mixed order failures and persists a later successful order', async () => { + let orderSucceeds = false + let savedOrder: unknown + await renderList({ + folders: () => ({ Tools: [] }), + installed: () => [ + createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), + createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), + ], + order: [ + { id: 'Plugin-A', order: 0, type: 'plugin' }, + { id: 'Tools', order: 1, type: 'folder' }, + { id: 'Plugin-B', order: 2, type: 'plugin' }, + ], + }) + await screen.findByText('folder:Tools') + await waitForRequestsToFinish() + server.use( + http.post(apiUrls.order, async ({ request }) => { + savedOrder = await request.json() + return orderSucceeds + ? HttpResponse.json({ data: null, success: true }) + : HttpResponse.json({ message: 'Rejected', success: false }) + }), + http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true })), + ) + + getHeaderButton('mdi-sort-variant').action?.() + await nextTick() + expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 A', 'folder:Tools', 'plugin:插件 B']) + await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 A', 'folder:Tools', 'plugin:插件 B']) + + orderSucceeds = true + await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) + await waitFor(() => + expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 B', 'folder:Tools', 'plugin:插件 A']), + ) + expect(savedOrder).toEqual([ + { id: 'Plugin-B', order: 0, type: 'plugin' }, + { id: 'Tools', order: 1, type: 'folder' }, + { id: 'Plugin-A', order: 2, type: 'plugin' }, + ]) + await waitForRequestsToFinish() + }) + + it('reloads server ordering when folders fail after PluginOrder is persisted', async () => { + let persistedOrder: unknown[] = [ + { id: 'Plugin-A', order: 0, type: 'plugin' }, + { id: 'Tools', order: 1, type: 'folder' }, + { id: 'Plugin-B', order: 2, type: 'plugin' }, + ] + let folderReads = 0 + let orderReads = 0 + await renderList({ + folders: () => ({ Tools: [] }), + installed: () => [ + createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), + createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), + ], + order: persistedOrder, + }) + await screen.findByText('folder:Tools') + await waitForRequestsToFinish() + server.use( + http.get(apiUrls.order, () => { + orderReads += 1 + return HttpResponse.json({ data: { value: persistedOrder }, success: true }) + }), + http.get(apiUrls.folders, () => { + folderReads += 1 + return HttpResponse.json({ Tools: [] }) + }), + http.post(apiUrls.order, async ({ request }) => { + persistedOrder = (await request.json()) as unknown[] + return HttpResponse.json({ data: null, success: true }) + }), + http.post(apiUrls.folders, () => HttpResponse.json({ message: 'Rejected', success: false })), + ) + + getHeaderButton('mdi-sort-variant').action?.() + await nextTick() + await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(orderReads).toBe(1) + expect(folderReads).toBe(1) + expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 B', 'folder:Tools', 'plugin:插件 A']) + expect(persistedOrder).toEqual([ + { id: 'Plugin-B', order: 0, type: 'plugin' }, + { id: 'Tools', order: 1, type: 'folder' }, + { id: 'Plugin-A', order: 2, type: 'plugin' }, + ]) + await waitForRequestsToFinish() + }) + + it('reloads both ordering sources when an internal folder sort is only partially persisted', async () => { + let persistedOrder: unknown[] = [ + { id: 'Tools', order: 0, type: 'folder' }, + { id: 'Plugin-A', order: 0.1, type: 'plugin' }, + { id: 'Plugin-B', order: 0.2, type: 'plugin' }, + ] + let folderReads = 0 + let orderReads = 0 + await renderList({ + folders: () => ({ Tools: ['Plugin-A', 'Plugin-B'] }), + installed: () => [ + createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), + createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), + ], + order: persistedOrder, + }) + await screen.findByText('folder:Tools') + await waitForRequestsToFinish() + server.use( + http.get(apiUrls.order, () => { + orderReads += 1 + return HttpResponse.json({ data: { value: persistedOrder }, success: true }) + }), + http.get(apiUrls.folders, () => { + folderReads += 1 + return HttpResponse.json({ Tools: ['Plugin-A', 'Plugin-B'] }) + }), + http.post(apiUrls.order, async ({ request }) => { + persistedOrder = (await request.json()) as unknown[] + return HttpResponse.json({ data: null, success: true }) + }), + http.post(apiUrls.folders, () => HttpResponse.json({ message: 'Rejected' }, { status: 500 })), + ) + + await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) + getHeaderButton('mdi-sort-variant').action?.() + await nextTick() + await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(orderReads).toBe(1) + expect(folderReads).toBe(1) + expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 A', 'plugin:插件 B']) + await waitForRequestsToFinish() + }) + + it('rolls back a failed folder drop and persists a later successful drop', async () => { + let folderSaveSucceeds = false + await renderList({ + folders: () => ({ Tools: [] }), + installed: () => [createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' })], + order: [ + { id: 'Plugin-A', order: 0, type: 'plugin' }, + { id: 'Tools', order: 1, type: 'folder' }, + ], + }) + await screen.findByText('folder:Tools') + await waitForRequestsToFinish() + server.use( + http.post(apiUrls.order, () => HttpResponse.json({ data: null, success: true })), + http.post(apiUrls.folders, () => + folderSaveSucceeds + ? HttpResponse.json({ data: null, success: true }) + : HttpResponse.json({ message: 'Rejected', success: false }), + ), + ) + + getHeaderButton('mdi-sort-variant').action?.() + await nextTick() + await fireEvent.click(screen.getByRole('button', { name: 'start-plugin-drag-0' })) + await fireEvent.click(screen.getByRole('button', { name: 'drop-to-folder-Tools' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + + folderSaveSucceeds = true + await fireEvent.click(screen.getByRole('button', { name: 'finish-plugin-drag' })) + await waitFor(() => expect(screen.getByText('plugin:插件 A')).toBeInTheDocument()) + + await fireEvent.click(screen.getByRole('button', { name: 'start-plugin-drag-0' })) + await fireEvent.click(screen.getByRole('button', { name: 'drop-to-folder-Tools' })) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件已移动到文件夹 "Tools"')) + await fireEvent.click(screen.getByRole('button', { name: 'finish-plugin-drag' })) + await waitForRequestsToFinish() + + await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) + expect(await screen.findByText('plugin:插件 A')).toBeInTheDocument() + }) + + it('rolls back folder-internal order and persists a later successful order', async () => { + let orderSucceeds = false + await renderList({ + folders: () => ({ Tools: ['Plugin-A', 'Plugin-B'] }), + installed: () => [ + createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), + createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), + ], + order: [ + { id: 'Tools', order: 0, type: 'folder' }, + { id: 'Plugin-A', order: 0.1, type: 'plugin' }, + { id: 'Plugin-B', order: 0.2, type: 'plugin' }, + ], + }) + await screen.findByText('folder:Tools') + await waitForRequestsToFinish() + server.use( + http.post(apiUrls.order, () => + orderSucceeds + ? HttpResponse.json({ data: null, success: true }) + : HttpResponse.json({ message: 'Rejected', success: false }), + ), + http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true })), + ) + + await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) + getHeaderButton('mdi-sort-variant').action?.() + await nextTick() + expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 A', 'plugin:插件 B']) + await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 A', 'plugin:插件 B']) + + orderSucceeds = true + await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) + await waitFor(() => expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 B', 'plugin:插件 A'])) + await waitForRequestsToFinish() + + getHeaderButton('mdi-arrow-left').action?.() + expect(await screen.findByText('folder:Tools')).toBeInTheDocument() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 8dc89550..72208ce0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -369,6 +369,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/components/cards/MediaCard.vue', 'src/components/cards/SiteCard.vue', 'src/components/cards/DownloadingCard.vue', + 'src/components/cards/PluginFolderCard.vue', 'src/components/slide/VirtualSlideView.vue', 'src/views/discover/PersonCardSlideView.vue', 'src/views/reorganize/TransferHistoryView.vue', @@ -376,6 +377,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/utils/searchStream.ts', 'src/views/site/SiteCardListView.vue', 'src/views/reorganize/DownloadingListView.vue', + 'src/views/plugin/PluginCardListView.vue', 'src/utils/siteIconCache.ts', ], provider: 'v8', @@ -404,6 +406,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 80, statements: 80, }, + 'src/components/cards/PluginFolderCard.vue': { + branches: 80, + functions: 85, + lines: 85, + statements: 85, + }, + 'src/views/plugin/PluginCardListView.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/views/reorganize/FileBrowserView.vue': { branches: 80, functions: 85,