test(plugin): cover market list state workflows (#631)

This commit is contained in:
InfinityPacer
2026-08-03 16:40:48 +08:00
committed by GitHub
parent 9a533cd103
commit b13674d0e7
6 changed files with 1937 additions and 204 deletions
+63 -59
View File
@@ -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,
}"
>
<template v-if="backgroundImage" #image>
<VImg :src="backgroundImage" cover position="top"> </VImg>
</template>
<template v-if="backgroundImage" #image>
<VImg :src="backgroundImage" cover position="top"> </VImg>
</template>
<!-- 背景遮罩当有背景图片时 -->
<div v-if="backgroundImage" class="plugin-folder-card__overlay" />
<!-- 背景遮罩当有背景图片时 -->
<div v-if="backgroundImage" class="plugin-folder-card__overlay" />
<!-- 背景渐变层 -->
<div v-else class="plugin-folder-card__bg" :style="{ background: backgroundGradient }" />
<!-- 背景渐变层 -->
<div v-else class="plugin-folder-card__bg" :style="{ background: backgroundGradient }" />
<!-- 卡片内容 -->
<div class="plugin-folder-card__content">
<!-- 主体内容 -->
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
<!-- 文件夹图标 -->
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
<VIcon
:icon="folderIcon"
:size="display.mobile ? 56 : 72"
:color="iconColor"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
/>
<!-- 卡片内容 -->
<div class="plugin-folder-card__content">
<!-- 主体内容 -->
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
<!-- 文件夹图标 -->
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
<VIcon
:icon="folderIcon"
:size="display.mobile ? 56 : 72"
:color="iconColor"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
/>
</div>
<!-- 文件夹信息 -->
<div
class="plugin-folder-card__info"
:class="{
'cursor-move': props.sortable && display.mdAndUp.value,
'plugin-folder-card__info--no-icon': !shouldShowIcon,
}"
>
<!-- 文件夹名称 -->
<h3 class="plugin-folder-card__name">
{{ props.folderName }}
</h3>
<!-- 插件数量 -->
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
</div>
</div>
<!-- 文件夹信息 -->
<div
class="plugin-folder-card__info"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
>
<!-- 文件夹名称 -->
<h3 class="plugin-folder-card__name">
{{ props.folderName }}
</h3>
<!-- 插件数量 -->
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
<!-- 更多菜单按钮 - 右下角 -->
<div v-if="!props.sortable" class="absolute top-0 right-0">
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<IconBtn v-bind="menuProps" @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
</IconBtn>
</template>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" size="16" />
</template>
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</div>
<!-- 更多菜单按钮 - 右下角 -->
<div v-if="!props.sortable" class="absolute top-0 right-0">
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<IconBtn v-bind="menuProps" @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
</IconBtn>
</template>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" size="16" />
</template>
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</div>
</VCard>
</div>
</template>
@@ -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<string, unknown> = 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<string, (...args: unknown[]) => 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([['媒体工具']])
})
})