mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 08:46:40 +08:00
test(plugin): cover market list state workflows (#631)
This commit is contained in:
@@ -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([['媒体工具']])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user