mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
fix(plugin): align lifecycle action state handling (#637)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { ApiResponse, Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
@@ -39,7 +39,7 @@ const createConfirm = useConfirm()
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
// 获取当前插件的标签
|
||||
const pluginLabels = computed(() => {
|
||||
@@ -51,9 +51,6 @@ const pluginLabels = computed(() => {
|
||||
.filter(tag => tag.length > 0)
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
@@ -74,7 +71,6 @@ function closeInstallProgress() {
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
@@ -99,20 +95,16 @@ function visitPluginPage() {
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||
|
||||
try {
|
||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||
repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
if (repoUrl?.includes('raw.githubusercontent.com')) {
|
||||
try {
|
||||
const rawUrl = new URL(repoUrl)
|
||||
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
|
||||
if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!repoUrl) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
window.open(repoUrl, '_blank')
|
||||
@@ -159,7 +151,7 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
@@ -167,8 +159,6 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
@@ -178,8 +168,15 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
$toast.error(
|
||||
t('plugin.installFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closeInstallProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +320,7 @@ onUnmounted(() => {
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { ApiResponse, Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
|
||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||
@@ -45,11 +46,13 @@ const display = useDisplay()
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
@@ -62,9 +65,6 @@ const menuVisible = ref(false)
|
||||
// 用户头像是否加载完成
|
||||
const isAvatarLoaded = ref(false)
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
@@ -98,7 +98,6 @@ watch(
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
@@ -124,17 +123,15 @@ async function uninstallPlugin() {
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
// 隐藏等待提示框
|
||||
closePluginProgress()
|
||||
const result: ApiResponse<unknown> = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
@@ -144,8 +141,15 @@ async function uninstallPlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +208,12 @@ async function resetPlugin() {
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
@@ -218,6 +223,12 @@ async function resetPlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -243,14 +254,13 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(
|
||||
releaseVersion
|
||||
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
||||
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
@@ -258,16 +268,14 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
},
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
@@ -277,8 +285,15 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +377,11 @@ async function showPluginAbout() {
|
||||
count: props.count,
|
||||
},
|
||||
{
|
||||
install: () => emit('save'),
|
||||
install: () => {
|
||||
emit('save')
|
||||
// 详情弹窗的安装事件只刷新父列表,动态导航由卡片补充同步。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
},
|
||||
},
|
||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||
)
|
||||
@@ -445,7 +464,7 @@ async function executePluginClone(cloneForm: {
|
||||
try {
|
||||
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
suffix: cloneForm.suffix.trim(),
|
||||
name: cloneForm.name.trim(),
|
||||
description: cloneForm.description.trim(),
|
||||
@@ -453,21 +472,21 @@ async function executePluginClone(cloneForm: {
|
||||
icon: cloneForm.icon.trim(),
|
||||
})
|
||||
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
|
||||
cloneDialogController?.close()
|
||||
cloneDialogController = null
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(t('plugin.cloneFailedGeneral'))
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +600,7 @@ const dropdownItems = ref([
|
||||
// 监听插件状态变化
|
||||
watch(
|
||||
() => props.plugin?.has_update,
|
||||
(newHasUpdate, _) => {
|
||||
newHasUpdate => {
|
||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||
|
||||
@@ -593,7 +612,7 @@ watch(
|
||||
// 监听插件窗口状态变化
|
||||
watch(
|
||||
() => props.plugin?.page_open,
|
||||
(newOpenState, _) => {
|
||||
newOpenState => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -695,7 +714,7 @@ watch(
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accentFromImage: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
@@ -16,7 +44,32 @@ const plugin: Plugin = {
|
||||
installed: false,
|
||||
}
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
})
|
||||
|
||||
describe('PluginAppCard rating badge', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.openSharedDialog.mockReset().mockImplementation(() => {
|
||||
const close = vi.fn()
|
||||
mocks.dialogCloses.push(close)
|
||||
return {
|
||||
close,
|
||||
id: mocks.dialogCloses.length,
|
||||
updateProps: vi.fn(),
|
||||
}
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('shows the top-right score only after the plugin has ratings', async () => {
|
||||
const unrated = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } },
|
||||
@@ -32,4 +85,175 @@ describe('PluginAppCard rating badge', () => {
|
||||
expect(badge).toHaveTextContent('4.3')
|
||||
expect(rated.container.querySelector('.plugin-app-card__title--with-rating')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reports an HTTP failure without emitting install or hiding the card', async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error('network unavailable'))
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('安装失败'))
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(container.querySelector('.plugin-app-card-hover-area')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens market details and forwards only the detail completion event', async () => {
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin, count: 12 },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({ plugin, count: 12 })
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||
closeOn: ['close', 'install', 'update:modelValue'],
|
||||
})
|
||||
const detailEvents = mocks.openSharedDialog.mock.calls[0][2] as { install: () => void }
|
||||
detailEvents.install()
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installs a selected release with exact parameters and emits completion', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: '0.9.0',
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the version dialog open and emits nothing on a business failure', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: false, message: '下载失败' })
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await dialogEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 安装失败:下载失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
})
|
||||
|
||||
it('blocks incompatible latest installs and honors a cancelled release confirmation', async () => {
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
system_version_compatible: false,
|
||||
system_version_message: '需要更高版本',
|
||||
}
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('需要更高版本')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
|
||||
mocks.confirm.mockResolvedValueOnce(false)
|
||||
await dialogEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders normalized labels, handles image events, and opens a raw GitHub repository', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: {
|
||||
plugin: {
|
||||
...plugin,
|
||||
plugin_icon: 'https://example.com/plugin.png',
|
||||
plugin_label: ' 工具, 自动化, ,工具 ',
|
||||
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||
},
|
||||
},
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('工具')).toHaveLength(2)
|
||||
expect(screen.getByText('自动化')).toBeInTheDocument()
|
||||
const image = screen.getByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
await fireEvent.contextMenu(image)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/example/plugins', '_blank')
|
||||
})
|
||||
|
||||
it('uses the author page for a local plugin project link', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: {
|
||||
plugin: {
|
||||
...plugin,
|
||||
is_local: true,
|
||||
repo_url: 'local://DemoPlugin',
|
||||
author_url: 'https://github.com/example-author',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/example-author', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accentFromImage: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
delete: mocks.apiDelete,
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_desc: '用于测试插件生命周期',
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
installed: true,
|
||||
state: true,
|
||||
}
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
})
|
||||
|
||||
describe('PluginCard lifecycle actions', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.apiDelete.mockReset()
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.openSharedDialog.mockReset().mockImplementation(() => {
|
||||
const close = vi.fn()
|
||||
mocks.dialogCloses.push(close)
|
||||
return {
|
||||
close,
|
||||
id: mocks.dialogCloses.length,
|
||||
updateProps: vi.fn(),
|
||||
}
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('refreshes plugin sidebar navigation after uninstall succeeds', async () => {
|
||||
mocks.apiDelete.mockResolvedValue({ success: true })
|
||||
const { container, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith('plugin/DemoPlugin'))
|
||||
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 卸载成功!')
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors uninstall cancellation and preserves the card on business failure', async () => {
|
||||
mocks.confirm.mockResolvedValueOnce(false)
|
||||
const cancelled = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(cancelled.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
expect(mocks.apiDelete).not.toHaveBeenCalled()
|
||||
cancelled.unmount()
|
||||
|
||||
mocks.confirm.mockResolvedValueOnce(true)
|
||||
mocks.apiDelete.mockResolvedValueOnce({ success: false, message: '仍有任务运行' })
|
||||
const failed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(failed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 卸载失败:仍有任务运行'))
|
||||
expect(failed.emitted()).not.toHaveProperty('remove')
|
||||
expect(failed.container.querySelector('.plugin-card-hover-area')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reports uninstall HTTP failures and always closes progress', async () => {
|
||||
mocks.apiDelete.mockRejectedValue(new Error('network unavailable'))
|
||||
const { container, emitted } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('卸载失败')))
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(emitted()).not.toHaveProperty('remove')
|
||||
})
|
||||
|
||||
it('resets plugin data only after confirmation and refreshes navigation on success', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('plugin/reset/DemoPlugin'))
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: expect.stringContaining('演示插件') }),
|
||||
)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 数据已重置')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports reset business and HTTP failures without emitting save', async () => {
|
||||
mocks.apiGet.mockResolvedValueOnce({ success: false, message: '无法清理数据' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重置失败:无法清理数据'))
|
||||
expect(businessFailed.emitted()).not.toHaveProperty('save')
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重置失败:服务器连接失败'))
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('save')
|
||||
})
|
||||
|
||||
it('updates from a confirmed Release with exact query parameters', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin: updatablePlugin },
|
||||
})
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: '0.9.0',
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks an incompatible latest update without sending a request', async () => {
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
system_version_compatible: false,
|
||||
system_version_message: '需要更高版本',
|
||||
}
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('需要更高版本')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||
})
|
||||
|
||||
it('keeps version history open after update business and HTTP failures', async () => {
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
mocks.apiGet.mockResolvedValueOnce({ success: false, message: '校验失败' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
let versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:校验失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(businessFailed.emitted()).not.toHaveProperty('save')
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.openSharedDialog.mockClear()
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:服务器连接失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('save')
|
||||
})
|
||||
|
||||
it('creates a clone with trimmed form values and refreshes navigation', async () => {
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone({
|
||||
suffix: ' Test ',
|
||||
name: '演示分身',
|
||||
description: ' 独立配置 ',
|
||||
version: ' 1.0.1 ',
|
||||
icon: ' https://example.com/icon.png ',
|
||||
})
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/clone/DemoPlugin', {
|
||||
suffix: 'Test',
|
||||
name: '演示分身',
|
||||
description: '独立配置',
|
||||
version: '1.0.1',
|
||||
icon: 'https://example.com/icon.png',
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件分身 演示分身 创建成功!')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().remove).toHaveLength(1)
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty clone suffix before calling the API', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone({ suffix: ' ', name: '', description: '', version: '', icon: '' })
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('分身后缀不能为空')
|
||||
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps clone dialog open after business and HTTP failures', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce({ success: false, message: '后缀已存在' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
let cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
const form = { suffix: 'Test', name: '测试', description: '', version: '', icon: '' }
|
||||
await cloneEvents.clone(form)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败:后缀已存在')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.openSharedDialog.mockClear()
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (value: typeof form) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone(form)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('remove')
|
||||
})
|
||||
|
||||
it('opens data and config surfaces with reciprocal switch contracts', async () => {
|
||||
const { container, emitted } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin: { ...plugin, has_page: true } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
|
||||
plugin: expect.objectContaining({ id: 'DemoPlugin' }),
|
||||
})
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({ closeOn: ['close', 'switch'] })
|
||||
|
||||
const dataEvents = mocks.openSharedDialog.mock.calls[0][2] as { switch: () => void }
|
||||
dataEvents.switch()
|
||||
expect(mocks.openSharedDialog.mock.calls[1][3]).toEqual({ closeOn: ['close', 'save', 'switch'] })
|
||||
|
||||
const configEvents = mocks.openSharedDialog.mock.calls[1][2] as { save: () => void; switch: () => void }
|
||||
configEvents.save()
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
configEvents.switch()
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('handles image lifecycle and ignores card clicks while sorting', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
props: {
|
||||
plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' },
|
||||
sortable: true,
|
||||
},
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
const [image, authorImage] = screen.getAllByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
await fireEvent.contextMenu(image)
|
||||
await fireEvent.click(authorImage)
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the shared log dialog from the menu', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('查看日志'))
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ plugin },
|
||||
{},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
})
|
||||
|
||||
it('opens plugin detail from an external action exactly once', async () => {
|
||||
const { emitted, rerender } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin, action: false },
|
||||
})
|
||||
|
||||
await rerender({ plugin, action: true })
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
expect(emitted().actionDone).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -54,9 +55,11 @@ describe('PluginCard about menu', () => {
|
||||
})
|
||||
|
||||
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin, count: 24 },
|
||||
})
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
@@ -76,5 +79,47 @@ describe('PluginCard about menu', () => {
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
})
|
||||
expect(dialogProps.count).toBe(24)
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||
closeOn: ['close', 'install', 'update:modelValue'],
|
||||
})
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { install: () => void }
|
||||
dialogEvents.install()
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('resolves a missing installed repo from market metadata before opening the project page', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/history/DemoPlugin') return Promise.resolve({ ...plugin, repo_url: 'local://DemoPlugin' })
|
||||
if (url === 'plugin/') {
|
||||
return Promise.resolve([
|
||||
{
|
||||
...plugin,
|
||||
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||
},
|
||||
])
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
const replace = vi.fn()
|
||||
const popup = {
|
||||
close: vi.fn(),
|
||||
location: { replace },
|
||||
opener: window,
|
||||
} as unknown as Window
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(popup)
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/', {
|
||||
params: { force: false, state: 'market' },
|
||||
})
|
||||
})
|
||||
expect(open).toHaveBeenCalledWith('about:blank', '_blank')
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith('https://github.com/example/plugins'))
|
||||
expect(popup.opener).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user