mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 01:06:41 +08:00
test(plugin): cover plugin host surfaces (#630)
This commit is contained in:
@@ -270,25 +270,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialog/PluginConfigDialog.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 4
|
|
||||||
},
|
|
||||||
"vue/valid-v-else": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialog/PluginDataDialog.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 3
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"prefer-const": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialog/PluginMarketDetailDialog.vue": {
|
"src/components/dialog/PluginMarketDetailDialog.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin, RenderProps } from '@/api/types'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
@@ -9,6 +9,9 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||||
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
import RemoteComponentError from '@/components/misc/RemoteComponentError.vue'
|
||||||
|
import RemoteComponentLoading from '@/components/misc/RemoteComponentLoading.vue'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -27,10 +30,10 @@ const emit = defineEmits(['close', 'save', 'switch'])
|
|||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
|
||||||
// 插件配置表单数据
|
// 插件配置表单数据
|
||||||
const pluginConfigForm = ref({})
|
const pluginConfigForm = ref<Record<string, unknown>>({})
|
||||||
|
|
||||||
// 插件表单配置项
|
// 插件表单配置项
|
||||||
let pluginFormItems = reactive([])
|
const pluginFormItems = ref<RenderProps[]>([])
|
||||||
|
|
||||||
// 进度框
|
// 进度框
|
||||||
const progressDialog = ref(false)
|
const progressDialog = ref(false)
|
||||||
@@ -48,11 +51,17 @@ provide('moviepilot:toast', $toast)
|
|||||||
const nativeSubscribe = usePluginNativeSubscribe()
|
const nativeSubscribe = usePluginNativeSubscribe()
|
||||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||||
|
|
||||||
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
|
||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 只有成功取得合法表单响应时才允许展示空配置状态。
|
||||||
|
const loadError = ref(false)
|
||||||
|
|
||||||
// 渲染模式: 'vuetify' 或 'vue'
|
// 渲染模式: 'vuetify' 或 'vue'
|
||||||
const renderMode = ref('vuetify')
|
type PluginRenderMode = 'vue' | 'vuetify'
|
||||||
|
const renderMode = ref<PluginRenderMode>('vuetify')
|
||||||
|
|
||||||
// 插件未声明布局偏好时沿用标准配置弹窗宽度。
|
// 插件未声明布局偏好时沿用标准配置弹窗宽度。
|
||||||
const dialogMaxWidth = ref('60rem')
|
const dialogMaxWidth = ref('60rem')
|
||||||
@@ -62,6 +71,10 @@ interface PluginConfigLayout {
|
|||||||
maxWidth?: string
|
maxWidth?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPluginRenderMode(value: unknown): value is PluginRenderMode {
|
||||||
|
return value === 'vue' || value === 'vuetify'
|
||||||
|
}
|
||||||
|
|
||||||
// Vue 模式:动态加载的组件
|
// Vue 模式:动态加载的组件
|
||||||
const dynamicComponent = defineAsyncComponent({
|
const dynamicComponent = defineAsyncComponent({
|
||||||
// 工厂函数
|
// 工厂函数
|
||||||
@@ -78,22 +91,13 @@ const dynamicComponent = defineAsyncComponent({
|
|||||||
return module
|
return module
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载远程组件失败:', error)
|
console.error('加载远程组件失败:', error)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 加载中显示的组件
|
// 加载中显示的组件
|
||||||
loadingComponent: {
|
loadingComponent: RemoteComponentLoading,
|
||||||
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
|
|
||||||
},
|
|
||||||
// 添加错误处理
|
// 添加错误处理
|
||||||
errorComponent: {
|
errorComponent: RemoteComponentError,
|
||||||
template: `
|
|
||||||
<div class="pa-4">
|
|
||||||
<VAlert type="error" title="组件加载错误">
|
|
||||||
无法加载组件,请稍后再试
|
|
||||||
</VAlert>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
// 添加超时设置
|
// 添加超时设置
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
})
|
})
|
||||||
@@ -102,40 +106,47 @@ const dynamicComponent = defineAsyncComponent({
|
|||||||
async function loadPluginUIData() {
|
async function loadPluginUIData() {
|
||||||
// 重置
|
// 重置
|
||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
pluginFormItems = []
|
loadError.value = false
|
||||||
|
pluginFormItems.value = []
|
||||||
pluginConfigForm.value = {}
|
pluginConfigForm.value = {}
|
||||||
renderMode.value = 'vuetify'
|
renderMode.value = 'vuetify'
|
||||||
dialogMaxWidth.value = '60rem'
|
dialogMaxWidth.value = '60rem'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取UI定义
|
// 获取UI定义
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
const result = (await api.get(`plugin/form/${props.plugin?.id}`)) as {
|
||||||
if (!result) {
|
conf?: RenderProps[]
|
||||||
|
model?: Record<string, unknown>
|
||||||
|
render_mode?: string
|
||||||
|
}
|
||||||
|
if (!result || !isPluginRenderMode(result.render_mode)) {
|
||||||
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败:无效的响应`)
|
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败:无效的响应`)
|
||||||
|
loadError.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
renderMode.value = result.render_mode
|
renderMode.value = result.render_mode
|
||||||
if (renderMode.value === 'vue') {
|
if (renderMode.value === 'vue') {
|
||||||
// Vue模式下,初始配置在同一个API返回
|
// Vue模式下,初始配置在同一个API返回
|
||||||
if (!isNullOrEmptyObject(result.model)) {
|
if (result.model && !isNullOrEmptyObject(result.model)) {
|
||||||
pluginConfigForm.value = result.model
|
pluginConfigForm.value = result.model
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Vuetify模式
|
// Vuetify模式
|
||||||
pluginFormItems = result.conf || []
|
pluginFormItems.value = result.conf || []
|
||||||
if (result.model) {
|
if (result.model) {
|
||||||
pluginConfigForm.value = result.model
|
pluginConfigForm.value = result.model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
loadError.value = true
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 Vue 组件触发的保存事件
|
// 处理 Vue 组件触发的保存事件
|
||||||
function handleVueComponentSave(newConfig: Record<string, any>) {
|
function handleVueComponentSave(newConfig: Record<string, unknown>) {
|
||||||
pluginConfigForm.value = newConfig
|
pluginConfigForm.value = newConfig
|
||||||
savePluginConf()
|
savePluginConf()
|
||||||
}
|
}
|
||||||
@@ -152,18 +163,26 @@ async function savePluginConf() {
|
|||||||
progressDialog.value = true
|
progressDialog.value = true
|
||||||
progressText.value = t('dialog.pluginConfig.saving', { name: props.plugin?.plugin_name })
|
progressText.value = t('dialog.pluginConfig.saving', { name: props.plugin?.plugin_name })
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
const result = (await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)) as {
|
||||||
|
message?: string
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
|
||||||
// 通知父组件刷新
|
// 通知父组件刷新
|
||||||
emit('save')
|
emit('save')
|
||||||
|
// 导航声明可能由插件配置控制;刷新失败不改变已经成功的配置保存结果。
|
||||||
|
void pluginSidebarNavStore.ensureSidebarNav(true).catch(error => console.error(error))
|
||||||
} else {
|
} else {
|
||||||
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message }))
|
||||||
|
} finally {
|
||||||
progressDialog.value = false
|
progressDialog.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
@@ -177,7 +196,13 @@ onBeforeMount(async () => {
|
|||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||||
<VCardText v-else="isRefreshed">
|
<VCardText v-else-if="loadError">
|
||||||
|
<VAlert type="error" title="配置加载失败">
|
||||||
|
<div>插件配置加载失败,请稍后重试</div>
|
||||||
|
<VBtn class="mt-3" color="error" variant="tonal" @click="loadPluginUIData">重试</VBtn>
|
||||||
|
</VAlert>
|
||||||
|
</VCardText>
|
||||||
|
<VCardText v-else>
|
||||||
<div>
|
<div>
|
||||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
|
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
|
||||||
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
|
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
|
||||||
@@ -196,7 +221,7 @@ onBeforeMount(async () => {
|
|||||||
<VSpacer />
|
<VSpacer />
|
||||||
<!-- 只有Vuetify模式显示默认保存按钮,Vue模式由组件内部控制 -->
|
<!-- 只有Vuetify模式显示默认保存按钮,Vue模式由组件内部控制 -->
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="renderMode === 'vuetify'"
|
v-if="isRefreshed && !loadError && renderMode === 'vuetify'"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@click="savePluginConf"
|
@click="savePluginConf"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin, RenderProps } from '@/api/types'
|
||||||
import PageRender from '@/components/render/PageRender.vue'
|
import PageRender from '@/components/render/PageRender.vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||||
|
import RemoteComponentError from '@/components/misc/RemoteComponentError.vue'
|
||||||
|
import RemoteComponentLoading from '@/components/misc/RemoteComponentLoading.vue'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -38,16 +40,23 @@ provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
|||||||
|
|
||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
// 只有成功取得合法页面响应时才允许展示空页面状态。
|
||||||
|
const loadError = ref(false)
|
||||||
// 组件是否已加载成功
|
// 组件是否已加载成功
|
||||||
const componentLoaded = ref(false)
|
const componentLoaded = ref(false)
|
||||||
// 是否正在加载数据
|
// 是否正在加载数据
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
// 渲染模式: 'vuetify' 或 'vue'
|
// 渲染模式: 'vuetify' 或 'vue'
|
||||||
const renderMode = ref('vuetify')
|
type PluginRenderMode = 'vue' | 'vuetify'
|
||||||
|
const renderMode = ref<PluginRenderMode>('vuetify')
|
||||||
|
|
||||||
// 插件数据页面配置项
|
// 插件数据页面配置项
|
||||||
let pluginPageItems = ref([])
|
const pluginPageItems = ref<RenderProps[]>([])
|
||||||
|
|
||||||
|
function isPluginRenderMode(value: unknown): value is PluginRenderMode {
|
||||||
|
return value === 'vue' || value === 'vuetify'
|
||||||
|
}
|
||||||
|
|
||||||
// Vue 模式:动态加载的组件
|
// Vue 模式:动态加载的组件
|
||||||
const dynamicComponent = defineAsyncComponent({
|
const dynamicComponent = defineAsyncComponent({
|
||||||
@@ -65,22 +74,13 @@ const dynamicComponent = defineAsyncComponent({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载远程组件失败:', error)
|
console.error('加载远程组件失败:', error)
|
||||||
componentLoaded.value = false
|
componentLoaded.value = false
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 加载中显示的组件
|
// 加载中显示的组件
|
||||||
loadingComponent: {
|
loadingComponent: RemoteComponentLoading,
|
||||||
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
|
|
||||||
},
|
|
||||||
// 添加错误处理
|
// 添加错误处理
|
||||||
errorComponent: {
|
errorComponent: RemoteComponentError,
|
||||||
template: `
|
|
||||||
<div class="pa-4">
|
|
||||||
<VAlert type="error" title="组件加载错误">
|
|
||||||
无法加载组件,请稍后再试
|
|
||||||
</VAlert>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
// 添加超时设置
|
// 添加超时设置
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
})
|
})
|
||||||
@@ -92,6 +92,7 @@ async function loadPluginUIData() {
|
|||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
|
loadError.value = false
|
||||||
pluginPageItems.value = []
|
pluginPageItems.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -102,9 +103,13 @@ async function loadPluginUIData() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)
|
const result = (await api.get(`plugin/page/${props.plugin?.id}`)) as {
|
||||||
if (!result || !result.render_mode) {
|
page?: RenderProps[]
|
||||||
|
render_mode?: string
|
||||||
|
}
|
||||||
|
if (!result || !isPluginRenderMode(result.render_mode)) {
|
||||||
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败:无效的响应`)
|
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败:无效的响应`)
|
||||||
|
loadError.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
renderMode.value = result.render_mode
|
renderMode.value = result.render_mode
|
||||||
@@ -112,8 +117,9 @@ async function loadPluginUIData() {
|
|||||||
// Vuetify模式
|
// Vuetify模式
|
||||||
pluginPageItems.value = result.page || []
|
pluginPageItems.value = result.page || []
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
loadError.value = true
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -121,16 +127,16 @@ async function loadPluginUIData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 重新加载数据(可由 PageRender 或 Vue component 触发)
|
// 重新加载数据(可由 PageRender 或 Vue component 触发)
|
||||||
function handleAction(event: any) {
|
function handleAction() {
|
||||||
// 避免在组件已加载的情况下重复调用loadPluginUIData
|
// 避免在组件已加载的情况下重复调用loadPluginUIData
|
||||||
if (renderMode.value === 'vue' && componentLoaded.value) {
|
if (renderMode.value === 'vue' && componentLoaded.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadPluginUIData()
|
void loadPluginUIData()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPluginUIData()
|
void loadPluginUIData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -139,6 +145,12 @@ onMounted(() => {
|
|||||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
|
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||||
|
<VCardText v-else-if="loadError" class="min-h-40">
|
||||||
|
<VAlert type="error" title="数据加载失败">
|
||||||
|
<div>插件数据加载失败,请稍后重试</div>
|
||||||
|
<VBtn class="mt-3" color="error" variant="tonal" @click="loadPluginUIData">重试</VBtn>
|
||||||
|
</VAlert>
|
||||||
|
</VCardText>
|
||||||
<VCardText v-else class="min-h-40">
|
<VCardText v-else class="min-h-40">
|
||||||
<div>
|
<div>
|
||||||
<PageRender @action="handleAction" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
<PageRender @action="handleAction" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import type { Plugin } from '@/api/types'
|
||||||
|
import PluginConfigDialog from '@/components/dialog/PluginConfigDialog.vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { defineComponent, h, inject, type Component, type PropType } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPut: vi.fn(),
|
||||||
|
ensureSidebarNav: vi.fn(),
|
||||||
|
loadRemoteComponent: vi.fn(),
|
||||||
|
nativeSubscribe: vi.fn(),
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: mocks.apiGet,
|
||||||
|
put: mocks.apiPut,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/federationLoader', () => ({
|
||||||
|
loadRemoteComponent: mocks.loadRemoteComponent,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePluginNativeSubscribe', () => ({
|
||||||
|
usePluginNativeSubscribe: () => mocks.nativeSubscribe,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/pluginSidebarNav', () => ({
|
||||||
|
usePluginSidebarNavStore: () => ({ ensureSidebarNav: mocks.ensureSidebarNav }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => mocks.toast,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const plugin: Plugin = {
|
||||||
|
has_page: true,
|
||||||
|
id: 'DemoPlugin',
|
||||||
|
plugin_name: '演示插件',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogStub = defineComponent({
|
||||||
|
name: 'VDialog',
|
||||||
|
props: { maxWidth: String },
|
||||||
|
setup:
|
||||||
|
(props, { slots }) =>
|
||||||
|
() =>
|
||||||
|
h('section', { 'data-max-width': props.maxWidth, role: 'dialog' }, slots.default?.()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const LoadingBannerStub = defineComponent({
|
||||||
|
name: 'LoadingBanner',
|
||||||
|
setup: () => () => h('div', '正在加载'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProgressDialogStub = defineComponent({
|
||||||
|
name: 'ProgressDialog',
|
||||||
|
props: { text: String },
|
||||||
|
setup: props => () => h('div', { 'data-testid': 'save-progress' }, props.text),
|
||||||
|
})
|
||||||
|
|
||||||
|
const CloseButtonStub = defineComponent({
|
||||||
|
name: 'VDialogCloseBtn',
|
||||||
|
setup:
|
||||||
|
(_, { attrs }) =>
|
||||||
|
() =>
|
||||||
|
h('button', { ...attrs, 'aria-label': '关闭', type: 'button' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const FormRenderStub = defineComponent({
|
||||||
|
name: 'FormRender',
|
||||||
|
props: {
|
||||||
|
config: { type: Object as PropType<Record<string, unknown>>, required: true },
|
||||||
|
model: { type: Object as PropType<Record<string, unknown>>, required: true },
|
||||||
|
},
|
||||||
|
setup: props => () => h('div', { 'data-testid': 'form-render' }, `${props.config.component}:${props.model.enabled}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
type RemoteCapture = {
|
||||||
|
api: unknown
|
||||||
|
initialConfig: Record<string, unknown>
|
||||||
|
injectedNativeSubscribe: unknown
|
||||||
|
injectedToast: unknown
|
||||||
|
nativeSubscribe: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve
|
||||||
|
reject = promiseReject
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, reject, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemoteConfig(captures: RemoteCapture[]): Component {
|
||||||
|
return defineComponent({
|
||||||
|
name: 'RemoteConfigFixture',
|
||||||
|
props: {
|
||||||
|
api: Object,
|
||||||
|
initialConfig: { type: Object as PropType<Record<string, unknown>>, required: true },
|
||||||
|
nativeSubscribe: Function,
|
||||||
|
},
|
||||||
|
emits: ['close', 'layout', 'save', 'switch'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
captures.push({
|
||||||
|
api: props.api,
|
||||||
|
initialConfig: props.initialConfig,
|
||||||
|
injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
|
||||||
|
injectedToast: inject('moviepilot:toast'),
|
||||||
|
nativeSubscribe: props.nativeSubscribe,
|
||||||
|
})
|
||||||
|
return () =>
|
||||||
|
h('section', { 'data-testid': 'remote-config' }, [
|
||||||
|
h('button', { onClick: () => emit('layout', { maxWidth: '72rem' }), type: 'button' }, '调整布局'),
|
||||||
|
h('button', { onClick: () => emit('save', { enabled: false }), type: 'button' }, '远程保存'),
|
||||||
|
h('button', { onClick: () => emit('switch'), type: 'button' }, '切换数据'),
|
||||||
|
h('button', { onClick: () => emit('close'), type: 'button' }, '关闭远程配置'),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog() {
|
||||||
|
return renderWithProviders(PluginConfigDialog, {
|
||||||
|
props: { modelValue: true, plugin },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
FormRender: FormRenderStub,
|
||||||
|
LoadingBanner: LoadingBannerStub,
|
||||||
|
ProgressDialog: ProgressDialogStub,
|
||||||
|
VDialog: DialogStub,
|
||||||
|
VDialogCloseBtn: CloseButtonStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginConfigDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
mocks.apiPut.mockReset()
|
||||||
|
mocks.ensureSidebarNav.mockReset().mockResolvedValue(undefined)
|
||||||
|
mocks.loadRemoteComponent.mockReset()
|
||||||
|
mocks.nativeSubscribe.mockReset()
|
||||||
|
mocks.toast.error.mockReset()
|
||||||
|
mocks.toast.success.mockReset()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes an empty Vuetify form from a failed load and retries in place', async () => {
|
||||||
|
mocks.apiGet.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValueOnce({
|
||||||
|
conf: [],
|
||||||
|
model: {},
|
||||||
|
render_mode: 'vuetify',
|
||||||
|
})
|
||||||
|
mocks.apiPut.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('插件配置加载失败,请稍后重试')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('此插件没有可配置项')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('此插件没有可配置项')).toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', {}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose configuration saving while the form is loading or failed', async () => {
|
||||||
|
const load = createDeferred<never>()
|
||||||
|
mocks.apiGet.mockReturnValue(load.promise)
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(screen.getByText('正在加载')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
load.reject(new Error('request failed'))
|
||||||
|
|
||||||
|
expect(await screen.findByText('插件配置加载失败,请稍后重试')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||||
|
expect(mocks.apiPut).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the Vuetify form with the merged model returned by the backend', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({
|
||||||
|
conf: [{ component: 'VSwitch', props: { label: '启用' } }],
|
||||||
|
model: { enabled: true },
|
||||||
|
render_mode: 'vuetify',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('form-render')).toHaveTextContent('VSwitch:true')
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '查看数据' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
|
||||||
|
expect(result.emitted().switch).toHaveLength(1)
|
||||||
|
expect(result.emitted().close).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes the same host capabilities through props and provide and forwards remote events', async () => {
|
||||||
|
const captures: RemoteCapture[] = []
|
||||||
|
mocks.apiGet.mockResolvedValue({ model: { enabled: true }, render_mode: 'vue' })
|
||||||
|
mocks.apiPut.mockResolvedValue({ success: true })
|
||||||
|
mocks.loadRemoteComponent.mockResolvedValue(createRemoteConfig(captures))
|
||||||
|
|
||||||
|
const result = await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('remote-config')).toBeInTheDocument()
|
||||||
|
expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Config')
|
||||||
|
expect(captures).toHaveLength(1)
|
||||||
|
expect(captures[0]).toMatchObject({ initialConfig: { enabled: true } })
|
||||||
|
expect(captures[0].api).toMatchObject({ get: mocks.apiGet, put: mocks.apiPut })
|
||||||
|
expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
|
||||||
|
expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
|
||||||
|
expect(captures[0].injectedToast).toBe(mocks.toast)
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '调整布局' }))
|
||||||
|
expect(screen.getByRole('dialog')).toHaveAttribute('data-max-width', '72rem')
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '远程保存' }))
|
||||||
|
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: false }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '切换数据' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '关闭远程配置' }))
|
||||||
|
|
||||||
|
expect(result.emitted().switch).toHaveLength(1)
|
||||||
|
expect(result.emitted().close).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the async error component when the remote Config rejects', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ model: {}, render_mode: 'vue' })
|
||||||
|
mocks.loadRemoteComponent.mockRejectedValue(new Error('remote unavailable'))
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the precompiled loading state while the remote Config is pending', async () => {
|
||||||
|
const remote = createDeferred<Component>()
|
||||||
|
mocks.apiGet.mockResolvedValue({ model: {}, render_mode: 'vue' })
|
||||||
|
mocks.loadRemoteComponent.mockReturnValue(remote.promise)
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('remote-component-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps configuration save successful when the sidebar refresh fails', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ conf: [], model: { enabled: true }, render_mode: 'vuetify' })
|
||||||
|
mocks.apiPut.mockResolvedValue({ success: true })
|
||||||
|
mocks.ensureSidebarNav.mockRejectedValue(new Error('sidebar unavailable'))
|
||||||
|
|
||||||
|
const result = await renderDialog()
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: true })
|
||||||
|
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
expect(result.emitted().save).toHaveLength(1)
|
||||||
|
})
|
||||||
|
expect(mocks.toast.success).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.toast.error).not.toHaveBeenCalled()
|
||||||
|
expect(screen.queryByTestId('save-progress')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['business failure', () => Promise.resolve({ message: '配置被拒绝', success: false })],
|
||||||
|
['HTTP failure', () => Promise.reject(new Error('request failed'))],
|
||||||
|
])('keeps the dialog open and reports a %s', async (_case, saveResult) => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ conf: [], model: {}, render_mode: 'vuetify' })
|
||||||
|
mocks.apiPut.mockImplementation(saveResult)
|
||||||
|
|
||||||
|
const result = await renderDialog()
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toast.error).toHaveBeenCalledOnce())
|
||||||
|
expect(result.emitted().save).toBeUndefined()
|
||||||
|
expect(mocks.ensureSidebarNav).not.toHaveBeenCalled()
|
||||||
|
expect(screen.queryByTestId('save-progress')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import type { Plugin } from '@/api/types'
|
||||||
|
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { defineComponent, h, inject, type Component, type PropType } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
loadRemoteComponent: vi.fn(),
|
||||||
|
nativeSubscribe: vi.fn(),
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: { get: mocks.apiGet },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/federationLoader', () => ({
|
||||||
|
loadRemoteComponent: mocks.loadRemoteComponent,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePluginNativeSubscribe', () => ({
|
||||||
|
usePluginNativeSubscribe: () => mocks.nativeSubscribe,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePWA', () => ({
|
||||||
|
usePWA: () => ({ appMode: false }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => mocks.toast,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const plugin: Plugin = {
|
||||||
|
id: 'DemoPlugin',
|
||||||
|
plugin_name: '演示插件',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogStub = defineComponent({
|
||||||
|
name: 'VDialog',
|
||||||
|
setup:
|
||||||
|
(_, { slots }) =>
|
||||||
|
() =>
|
||||||
|
h('section', { role: 'dialog' }, slots.default?.()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const LoadingBannerStub = defineComponent({
|
||||||
|
name: 'LoadingBanner',
|
||||||
|
setup: () => () => h('div', '正在加载'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const CloseButtonStub = defineComponent({
|
||||||
|
name: 'VDialogCloseBtn',
|
||||||
|
setup:
|
||||||
|
(_, { attrs }) =>
|
||||||
|
() =>
|
||||||
|
h('button', { ...attrs, 'aria-label': '关闭', type: 'button' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const FabStub = defineComponent({
|
||||||
|
name: 'VFab',
|
||||||
|
setup:
|
||||||
|
(_, { attrs }) =>
|
||||||
|
() =>
|
||||||
|
h('button', { ...attrs, 'aria-label': '切换配置', type: 'button' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PageRenderStub = defineComponent({
|
||||||
|
name: 'PageRender',
|
||||||
|
props: {
|
||||||
|
config: { type: Object as PropType<Record<string, unknown>>, required: true },
|
||||||
|
},
|
||||||
|
emits: ['action'],
|
||||||
|
setup:
|
||||||
|
(props, { emit }) =>
|
||||||
|
() =>
|
||||||
|
h('button', { onClick: () => emit('action'), type: 'button' }, String(props.config.component)),
|
||||||
|
})
|
||||||
|
|
||||||
|
type RemoteCapture = {
|
||||||
|
api: unknown
|
||||||
|
injectedNativeSubscribe: unknown
|
||||||
|
injectedToast: unknown
|
||||||
|
nativeSubscribe: unknown
|
||||||
|
showSwitch: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
const promise = new Promise<T>(promiseResolve => {
|
||||||
|
resolve = promiseResolve
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemotePage(captures: RemoteCapture[]): Component {
|
||||||
|
return defineComponent({
|
||||||
|
name: 'RemotePageFixture',
|
||||||
|
props: {
|
||||||
|
api: Object,
|
||||||
|
nativeSubscribe: Function,
|
||||||
|
show_switch: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['action', 'close', 'switch'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
captures.push({
|
||||||
|
api: props.api,
|
||||||
|
injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
|
||||||
|
injectedToast: inject('moviepilot:toast'),
|
||||||
|
nativeSubscribe: props.nativeSubscribe,
|
||||||
|
showSwitch: props.show_switch,
|
||||||
|
})
|
||||||
|
return () =>
|
||||||
|
h('section', { 'data-testid': 'remote-page' }, [
|
||||||
|
h('button', { onClick: () => emit('action'), type: 'button' }, '刷新远程页面'),
|
||||||
|
h('button', { onClick: () => emit('switch'), type: 'button' }, '切换配置'),
|
||||||
|
h('button', { onClick: () => emit('close'), type: 'button' }, '关闭远程页面'),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog(showSwitch = true) {
|
||||||
|
return renderWithProviders(PluginDataDialog, {
|
||||||
|
props: { modelValue: true, plugin, show_switch: showSwitch },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
LoadingBanner: LoadingBannerStub,
|
||||||
|
PageRender: PageRenderStub,
|
||||||
|
VDialog: DialogStub,
|
||||||
|
VDialogCloseBtn: CloseButtonStub,
|
||||||
|
VFab: FabStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginDataDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
mocks.loadRemoteComponent.mockReset()
|
||||||
|
mocks.nativeSubscribe.mockReset()
|
||||||
|
mocks.toast.error.mockReset()
|
||||||
|
mocks.toast.success.mockReset()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes an empty Vuetify page from a failed load and retries in place', async () => {
|
||||||
|
mocks.apiGet.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValueOnce({
|
||||||
|
page: [],
|
||||||
|
render_mode: 'vuetify',
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('插件数据加载失败,请稍后重试')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('此插件没有详情页面')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('此插件没有详情页面')).toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Vuetify page definitions and reloads them after an action', async () => {
|
||||||
|
mocks.apiGet
|
||||||
|
.mockResolvedValueOnce({ page: [{ component: 'VBtn' }], render_mode: 'vuetify' })
|
||||||
|
.mockResolvedValueOnce({ page: [{ component: 'VChip' }], render_mode: 'vuetify' })
|
||||||
|
|
||||||
|
const result = await renderDialog()
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: 'VBtn' }))
|
||||||
|
expect(await screen.findByRole('button', { name: 'VChip' })).toBeInTheDocument()
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '切换配置' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||||
|
expect(result.emitted().switch).toHaveLength(1)
|
||||||
|
expect(result.emitted().close).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes the same host capabilities through props and provide and forwards remote events', async () => {
|
||||||
|
const captures: RemoteCapture[] = []
|
||||||
|
mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
|
||||||
|
mocks.loadRemoteComponent.mockResolvedValue(createRemotePage(captures))
|
||||||
|
|
||||||
|
const result = await renderDialog(false)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('remote-page')).toBeInTheDocument()
|
||||||
|
expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Page')
|
||||||
|
expect(captures).toHaveLength(1)
|
||||||
|
expect(captures[0].api).toMatchObject({ get: mocks.apiGet })
|
||||||
|
expect(captures[0].showSwitch).toBe(false)
|
||||||
|
expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
|
||||||
|
expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
|
||||||
|
expect(captures[0].injectedToast).toBe(mocks.toast)
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '刷新远程页面' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '切换配置' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '关闭远程页面' }))
|
||||||
|
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||||
|
expect(result.emitted().switch).toHaveLength(1)
|
||||||
|
expect(result.emitted().close).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the async error component when the remote Page rejects', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
|
||||||
|
mocks.loadRemoteComponent.mockRejectedValue(new Error('remote unavailable'))
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the precompiled loading state while the remote Page is pending', async () => {
|
||||||
|
const remote = createDeferred<Component>()
|
||||||
|
mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
|
||||||
|
mocks.loadRemoteComponent.mockReturnValue(remote.promise)
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('remote-component-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an unsupported render mode as a load error instead of a blank dialog', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ render_mode: 'legacy' })
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('插件数据加载失败,请稍后重试')).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(screen.queryByText('此插件没有详情页面')).not.toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import { isNullOrEmptyObject } from '@/@core/utils'
|
|||||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||||
|
import RemoteComponentError from './RemoteComponentError.vue'
|
||||||
|
|
||||||
type DashboardComponentLoader = () => Promise<any>
|
type DashboardComponentLoader = () => Promise<any>
|
||||||
|
|
||||||
@@ -114,50 +115,50 @@ const isDashboardElementLoaded = ref(false)
|
|||||||
|
|
||||||
let isDashboardElementUnmounted = false
|
let isDashboardElementUnmounted = false
|
||||||
let pluginDashboardComponentLoadPromise: Promise<any> | null = null
|
let pluginDashboardComponentLoadPromise: Promise<any> | null = null
|
||||||
|
let dashboardLoadGeneration = 0
|
||||||
|
|
||||||
// 插件UI渲染模式 ('vuetify' 或 'vue')
|
// 插件UI渲染模式 ('vuetify' 或 'vue')
|
||||||
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
||||||
|
|
||||||
// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise。
|
// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载。
|
||||||
function loadPluginDashboardComponent() {
|
const pluginDashboardIdentity = computed(
|
||||||
if (!props.config?.id) return Promise.reject(new Error('插件ID不存在'))
|
() => `${props.config?.id ?? ''}:${props.config?.key ?? ''}:${pluginRenderMode.value}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise。
|
||||||
|
function loadPluginDashboardComponent(pluginId: string) {
|
||||||
if (!pluginDashboardComponentLoadPromise) {
|
if (!pluginDashboardComponentLoadPromise) {
|
||||||
pluginDashboardComponentLoadPromise = loadRemoteComponent(props.config.id, 'Dashboard').catch(error => {
|
const loadPromise = loadRemoteComponent(pluginId, 'Dashboard')
|
||||||
|
const guardedPromise = loadPromise.catch(error => {
|
||||||
|
if (pluginDashboardComponentLoadPromise === guardedPromise) {
|
||||||
pluginDashboardComponentLoadPromise = null
|
pluginDashboardComponentLoadPromise = null
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
|
pluginDashboardComponentLoadPromise = guardedPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
return pluginDashboardComponentLoadPromise
|
return pluginDashboardComponentLoadPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vue 模式:动态加载的组件
|
// 每个插件节点身份使用独立异步组件,避免 Vue 复用前一个 remote 的成功解析结果。
|
||||||
const dynamicPluginComponent = defineAsyncComponent({
|
const dynamicPluginComponent = computed(() => {
|
||||||
// 工厂函数
|
const pluginId = props.config?.id
|
||||||
|
const identity = pluginDashboardIdentity.value
|
||||||
|
|
||||||
|
return defineAsyncComponent({
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
try {
|
try {
|
||||||
const module = await loadPluginDashboardComponent()
|
if (!pluginId) throw new Error(`插件ID不存在: ${identity}`)
|
||||||
|
return await loadPluginDashboardComponent(pluginId)
|
||||||
// 直接返回加载的组件,无需再获取default
|
|
||||||
return module
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载远程组件失败:', error)
|
console.error('加载远程组件失败:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 加载中显示的组件
|
|
||||||
loadingComponent: DashboardSkeleton,
|
loadingComponent: DashboardSkeleton,
|
||||||
// 添加错误处理
|
errorComponent: RemoteComponentError,
|
||||||
errorComponent: {
|
})
|
||||||
template: `
|
|
||||||
<div class="pa-4">
|
|
||||||
<VAlert type="error" title="组件加载错误">
|
|
||||||
无法加载组件,请稍后再试
|
|
||||||
</VAlert>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断当前配置是否对应内置异步仪表盘组件。
|
// 判断当前配置是否对应内置异步仪表盘组件。
|
||||||
@@ -179,28 +180,38 @@ function emitDashboardElementLoaded() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 等待当前仪表盘节点的异步组件加载完成,静态渲染模式则等待一次 DOM 更新。
|
// 等待当前仪表盘节点的异步组件加载完成,静态渲染模式则等待一次 DOM 更新。
|
||||||
async function waitForDashboardElementLoaded() {
|
async function waitForDashboardElementLoaded(generation: number) {
|
||||||
if (isDashboardElementLoaded.value) return
|
if (isDashboardElementLoaded.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isBuiltInDashboardElement() && props.config?.id) {
|
if (isBuiltInDashboardElement() && props.config?.id) {
|
||||||
await loadBuiltInDashboardComponent(props.config.id)
|
await loadBuiltInDashboardComponent(props.config.id)
|
||||||
} else if (isVuePluginDashboardElement()) {
|
} else if (isVuePluginDashboardElement() && props.config?.id) {
|
||||||
await loadPluginDashboardComponent()
|
await loadPluginDashboardComponent(props.config.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
emitDashboardElementLoaded()
|
if (generation === dashboardLoadGeneration) emitDashboardElementLoaded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.config?.id, props.config?.key, pluginRenderMode.value],
|
() => [props.config?.id, props.config?.key, pluginRenderMode.value],
|
||||||
() => {
|
(_identity, previousIdentity) => {
|
||||||
void waitForDashboardElementLoaded()
|
const pluginIdentityChanged =
|
||||||
|
previousIdentity &&
|
||||||
|
(pluginRenderMode.value === 'vue' || previousIdentity[2] === 'vue') &&
|
||||||
|
previousIdentity.some((value, index) => value !== _identity[index])
|
||||||
|
if (pluginIdentityChanged) {
|
||||||
|
isDashboardElementLoaded.value = false
|
||||||
|
pluginDashboardComponentLoadPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = ++dashboardLoadGeneration
|
||||||
|
void waitForDashboardElementLoaded(generation)
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -233,6 +244,7 @@ onUnmounted(() => {
|
|||||||
<!-- Vue 渲染模式 -->
|
<!-- Vue 渲染模式 -->
|
||||||
<div v-if="pluginRenderMode === 'vue'" class="dashboard-plugin-vue-renderer">
|
<div v-if="pluginRenderMode === 'vue'" class="dashboard-plugin-vue-renderer">
|
||||||
<component
|
<component
|
||||||
|
:key="pluginDashboardIdentity"
|
||||||
:is="dynamicPluginComponent"
|
:is="dynamicPluginComponent"
|
||||||
:config="props.config"
|
:config="props.config"
|
||||||
:allow-refresh="props.allowRefresh"
|
:allow-refresh="props.allowRefresh"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- 联邦远程组件加载失败时使用预编译错误态,兼容生产环境的 Vue runtime-only 构建。 -->
|
||||||
|
<template>
|
||||||
|
<div class="pa-4">
|
||||||
|
<VAlert type="error" title="组件加载错误"> 无法加载组件,请稍后再试 </VAlert>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- 联邦远程组件加载期间使用预编译骨架,兼容生产环境的 Vue runtime-only 构建。 -->
|
||||||
|
<template>
|
||||||
|
<div class="pa-4" data-testid="remote-component-loading">
|
||||||
|
<VSkeletonLoader type="card" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import type { DashboardItem } from '@/api/types'
|
||||||
|
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, inject, type Component, type PropType } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
loadRemoteComponent: vi.fn(),
|
||||||
|
nativeSubscribe: vi.fn(),
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: { get: mocks.apiGet },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/federationLoader', () => ({
|
||||||
|
loadRemoteComponent: mocks.loadRemoteComponent,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePluginNativeSubscribe', () => ({
|
||||||
|
usePluginNativeSubscribe: () => mocks.nativeSubscribe,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => mocks.toast,
|
||||||
|
}))
|
||||||
|
|
||||||
|
type RemoteCapture = {
|
||||||
|
allowRefresh: boolean
|
||||||
|
api: unknown
|
||||||
|
config: DashboardItem
|
||||||
|
injectedNativeSubscribe: unknown
|
||||||
|
injectedToast: unknown
|
||||||
|
nativeSubscribe: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve
|
||||||
|
reject = promiseReject
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, reject, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemoteDashboard(captures: RemoteCapture[], label = 'remote'): Component {
|
||||||
|
return defineComponent({
|
||||||
|
name: 'RemoteDashboardFixture',
|
||||||
|
props: {
|
||||||
|
allowRefresh: Boolean,
|
||||||
|
api: Object,
|
||||||
|
config: { type: Object as PropType<DashboardItem>, required: true },
|
||||||
|
nativeSubscribe: Function,
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
captures.push({
|
||||||
|
allowRefresh: props.allowRefresh,
|
||||||
|
api: props.api,
|
||||||
|
config: props.config,
|
||||||
|
injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
|
||||||
|
injectedToast: inject('moviepilot:toast'),
|
||||||
|
nativeSubscribe: props.nativeSubscribe,
|
||||||
|
})
|
||||||
|
return () => h('div', { 'data-testid': 'remote-dashboard' }, `${label}:${props.config.name}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPluginDashboard(overrides: Partial<DashboardItem> = {}): DashboardItem {
|
||||||
|
return {
|
||||||
|
attrs: { border: true, title: '插件仪表盘' },
|
||||||
|
cols: { lg: 6 },
|
||||||
|
elements: [],
|
||||||
|
id: 'DemoPlugin',
|
||||||
|
key: 'main',
|
||||||
|
name: '演示仪表盘',
|
||||||
|
render_mode: 'vue',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DashboardElement plugin host', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.loadRemoteComponent.mockReset()
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
mocks.nativeSubscribe.mockReset()
|
||||||
|
mocks.toast.error.mockReset()
|
||||||
|
mocks.toast.success.mockReset()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses one remote promise and passes identical prop/provide capabilities', async () => {
|
||||||
|
const captures: RemoteCapture[] = []
|
||||||
|
const config = createPluginDashboard()
|
||||||
|
mocks.loadRemoteComponent.mockResolvedValue(createRemoteDashboard(captures))
|
||||||
|
|
||||||
|
const result = await renderWithProviders(DashboardElement, {
|
||||||
|
props: { allowRefresh: false, config, refreshStatus: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote:演示仪表盘')
|
||||||
|
expect(mocks.loadRemoteComponent).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard')
|
||||||
|
expect(captures).toHaveLength(1)
|
||||||
|
expect(captures[0].config).toStrictEqual(config)
|
||||||
|
expect(captures[0].allowRefresh).toBe(false)
|
||||||
|
expect(captures[0].api).toMatchObject({ get: mocks.apiGet })
|
||||||
|
expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
|
||||||
|
expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
|
||||||
|
expect(captures[0].injectedToast).toBe(mocks.toast)
|
||||||
|
await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
|
||||||
|
|
||||||
|
result.unmount()
|
||||||
|
expect(result.emitted()['update:refreshStatus']).toEqual([[false]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the remote error and retries after the plugin dashboard identity changes', async () => {
|
||||||
|
const captures: RemoteCapture[] = []
|
||||||
|
mocks.loadRemoteComponent
|
||||||
|
.mockRejectedValueOnce(new Error('remote unavailable'))
|
||||||
|
.mockResolvedValueOnce(createRemoteDashboard(captures))
|
||||||
|
|
||||||
|
const result = await renderWithProviders(DashboardElement, {
|
||||||
|
props: { config: createPluginDashboard(), refreshStatus: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
|
||||||
|
expect(mocks.loadRemoteComponent).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
await result.rerender({
|
||||||
|
config: createPluginDashboard({ key: 'secondary', name: '恢复后的仪表盘' }),
|
||||||
|
refreshStatus: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote:恢复后的仪表盘')
|
||||||
|
expect(mocks.loadRemoteComponent).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces a successfully resolved remote component when the plugin identity changes', async () => {
|
||||||
|
const captures: RemoteCapture[] = []
|
||||||
|
mocks.loadRemoteComponent
|
||||||
|
.mockResolvedValueOnce(createRemoteDashboard(captures, 'remote-a'))
|
||||||
|
.mockResolvedValueOnce(createRemoteDashboard(captures, 'remote-b'))
|
||||||
|
|
||||||
|
const result = await renderWithProviders(DashboardElement, {
|
||||||
|
props: { config: createPluginDashboard(), refreshStatus: false },
|
||||||
|
})
|
||||||
|
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-a:演示仪表盘')
|
||||||
|
|
||||||
|
await result.rerender({
|
||||||
|
config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '另一个仪表盘' }),
|
||||||
|
refreshStatus: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:另一个仪表盘')
|
||||||
|
expect(mocks.loadRemoteComponent).toHaveBeenNthCalledWith(2, 'OtherPlugin', 'Dashboard')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores an older remote that resolves after the replacement has loaded', async () => {
|
||||||
|
const captures: RemoteCapture[] = []
|
||||||
|
const remoteA = createDeferred<Component>()
|
||||||
|
const remoteB = createDeferred<Component>()
|
||||||
|
mocks.loadRemoteComponent.mockReturnValueOnce(remoteA.promise).mockReturnValueOnce(remoteB.promise)
|
||||||
|
|
||||||
|
const result = await renderWithProviders(DashboardElement, {
|
||||||
|
props: { config: createPluginDashboard(), refreshStatus: false },
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard'))
|
||||||
|
|
||||||
|
await result.rerender({
|
||||||
|
config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '替换后的仪表盘' }),
|
||||||
|
refreshStatus: false,
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('OtherPlugin', 'Dashboard'))
|
||||||
|
|
||||||
|
remoteB.resolve(createRemoteDashboard(captures, 'remote-b'))
|
||||||
|
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:替换后的仪表盘')
|
||||||
|
await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
|
||||||
|
|
||||||
|
remoteA.resolve(createRemoteDashboard(captures, 'remote-a'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('remote-dashboard')).toHaveTextContent('remote-b:替换后的仪表盘')
|
||||||
|
expect(captures).toHaveLength(1)
|
||||||
|
expect(captures[0].config.id).toBe('OtherPlugin')
|
||||||
|
expect(result.emitted().loaded).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports loaded only after the current remote resolves', async () => {
|
||||||
|
const captures: RemoteCapture[] = []
|
||||||
|
const remoteA = createDeferred<Component>()
|
||||||
|
const remoteB = createDeferred<Component>()
|
||||||
|
mocks.loadRemoteComponent.mockReturnValueOnce(remoteA.promise).mockReturnValueOnce(remoteB.promise)
|
||||||
|
|
||||||
|
const result = await renderWithProviders(DashboardElement, {
|
||||||
|
props: { config: createPluginDashboard(), refreshStatus: false },
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard'))
|
||||||
|
|
||||||
|
await result.rerender({
|
||||||
|
config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '当前仪表盘' }),
|
||||||
|
refreshStatus: false,
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('OtherPlugin', 'Dashboard'))
|
||||||
|
|
||||||
|
remoteA.resolve(createRemoteDashboard(captures, 'remote-a'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(result.emitted().loaded).toBeUndefined()
|
||||||
|
expect(captures).toHaveLength(0)
|
||||||
|
|
||||||
|
remoteB.resolve(createRemoteDashboard(captures, 'remote-b'))
|
||||||
|
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:当前仪表盘')
|
||||||
|
await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the Vuetify plugin branch without loading a remote component', async () => {
|
||||||
|
const DashboardRenderStub = defineComponent({
|
||||||
|
name: 'DashboardRender',
|
||||||
|
props: { config: { type: Object as PropType<Record<string, unknown>>, required: true } },
|
||||||
|
setup: props => () => h('div', { 'data-testid': 'dashboard-render' }, String(props.config.component)),
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderWithProviders(DashboardElement, {
|
||||||
|
props: {
|
||||||
|
config: createPluginDashboard({
|
||||||
|
elements: [{ component: 'VChip' }],
|
||||||
|
render_mode: 'vuetify',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
global: { stubs: { DashboardRender: DashboardRenderStub } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('dashboard-render')).toHaveTextContent('VChip')
|
||||||
|
expect(screen.getByText('插件仪表盘')).toBeInTheDocument()
|
||||||
|
expect(mocks.loadRemoteComponent).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an explicit error for an unsupported plugin render mode', async () => {
|
||||||
|
await renderWithProviders(DashboardElement, {
|
||||||
|
props: { config: createPluginDashboard({ render_mode: 'legacy' }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('无法渲染插件仪表盘部件: 未知渲染模式或配置错误')).toBeInTheDocument()
|
||||||
|
expect(mocks.loadRemoteComponent).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -354,6 +354,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/components/dialog/AddSubtitleDownloadDialog.vue',
|
'src/components/dialog/AddSubtitleDownloadDialog.vue',
|
||||||
'src/components/dialog/ReorganizeDialog.vue',
|
'src/components/dialog/ReorganizeDialog.vue',
|
||||||
'src/components/dialog/TransferQueueDialog.vue',
|
'src/components/dialog/TransferQueueDialog.vue',
|
||||||
|
'src/components/dialog/PluginConfigDialog.vue',
|
||||||
|
'src/components/dialog/PluginDataDialog.vue',
|
||||||
'src/views/reorganize/FileBrowserView.vue',
|
'src/views/reorganize/FileBrowserView.vue',
|
||||||
'src/components/filebrowser/FileBrowser.vue',
|
'src/components/filebrowser/FileBrowser.vue',
|
||||||
'src/components/filebrowser/FileToolbar.vue',
|
'src/components/filebrowser/FileToolbar.vue',
|
||||||
@@ -516,6 +518,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 85,
|
lines: 85,
|
||||||
statements: 85,
|
statements: 85,
|
||||||
},
|
},
|
||||||
|
'src/components/dialog/PluginConfigDialog.vue': {
|
||||||
|
branches: 80,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
|
'src/components/dialog/PluginDataDialog.vue': {
|
||||||
|
branches: 80,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
'src/composables/useMediaSubscribe.ts': {
|
'src/composables/useMediaSubscribe.ts': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user