test(settings): cover advanced system configuration (#676)

This commit is contained in:
InfinityPacer
2026-08-13 19:32:05 +08:00
committed by GitHub
parent 6a0217d18c
commit 4559524190
4 changed files with 673 additions and 37 deletions

View File

@@ -831,16 +831,7 @@
},
"src/views/setting/AccountSettingSystem.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 11
},
"no-unsafe-finally": {
"count": 1
},
"prefer-const": {
"count": 1
},
"vue/valid-v-for": {
"count": 2
}
},
"src/views/setup/MediaServerSettingsStep.vue": {

View File

@@ -479,7 +479,7 @@ function normalizeThinkingLevelValue(value?: unknown) {
return aliasMap[normalized] || normalized
}
function resolveThinkingLevelValue(data?: Record<string, any>) {
function resolveThinkingLevelValue(data?: Record<string, unknown>) {
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
if (explicit) return explicit
@@ -604,8 +604,8 @@ const logLevelItems = [
]
const dataCleanupFieldRules = [
(v: any) => v === 0 || !!v || t('setting.system.dataCleanupDaysRequired'),
(v: any) => v >= 0 || t('setting.system.dataCleanupDaysMin'),
(value: unknown) => value === 0 || !!value || t('setting.system.dataCleanupDaysRequired'),
(value: unknown) => Number(value) >= 0 || t('setting.system.dataCleanupDaysMin'),
]
// 安全域名添加变量
@@ -698,14 +698,15 @@ async function saveDownloaderSetting() {
await loadDownloaderSetting()
} catch (error) {
console.log(error)
$toast.error(t('setting.system.downloaderSaveFailed'))
}
}
// 处理默认下载器状态
function handleDefaultDownloaders(enabledDownloaders: any[], downloaders: any[]) {
function handleDefaultDownloaders(enabledDownloaders: DownloaderConf[], currentDownloaders: DownloaderConf[]) {
const enabledDefaultDownloader = enabledDownloaders.find(item => item.default)
if (enabledDownloaders.length > 0 && !enabledDefaultDownloader) {
downloaders = downloaders.map(item => {
return currentDownloaders.map(item => {
if (item === enabledDownloaders[0]) {
$toast.info(t('setting.system.defaultDownloaderNotice', { name: item.name }))
return { ...item, default: true }
@@ -714,7 +715,7 @@ function handleDefaultDownloaders(enabledDownloaders: any[], downloaders: any[])
return { ...item, default: false }
})
}
return downloaders
return currentDownloaders
}
// 调用API查询媒体服务器设置
@@ -736,6 +737,7 @@ async function saveMediaServerSetting() {
await loadMediaServerSetting()
} catch (error) {
console.log(error)
$toast.error(t('setting.system.mediaServerSaveFailed'))
}
}
@@ -743,14 +745,14 @@ async function saveMediaServerSetting() {
async function loadSystemSettings() {
invalidateLlmTestState()
try {
const result: { [key: string]: any } = await api.get('system/env')
const result = await api.get<Record<string, unknown>>('system/env')
const defaultSyncInterval = Number(result.MEDIASERVER_SYNC_INTERVAL ?? Number.NaN)
legacyMediaServerSyncInterval.value = Number.isFinite(defaultSyncInterval) ? defaultSyncInterval : null
// 将API返回的值赋值给SystemSettings
for (const sectionKey of Object.keys(SystemSettings.value) as Array<keyof typeof SystemSettings.value>) {
Object.keys(SystemSettings.value[sectionKey]).forEach((key: string) => {
if (Object.prototype.hasOwnProperty.call(result, key))
(SystemSettings.value[sectionKey] as any)[key] = result[key]
(SystemSettings.value[sectionKey] as Record<string, unknown>)[key] = result[key]
})
}
const accelAvailable = Boolean(result.RUST_ACCEL_AVAILABLE ?? result.RUST_ACCEL_ENABLED)
@@ -781,7 +783,7 @@ function handleAgentMcpSaved(servers: AgentMcpServer[]) {
}
// 调用API保存设置
async function saveSystemSetting(value: { [key: string]: any }) {
async function saveSystemSetting(value: Record<string, unknown>) {
try {
await api.post('system/env', value, { feedback: 'silent' })
return true
@@ -846,9 +848,10 @@ async function testLlmConnection() {
showLlmTestFailedToast(error instanceof Error ? error.message : String(error))
console.log(error)
} finally {
if (requestId !== llmTestRequestId) return
if (llmTestAbortController === abortController) llmTestAbortController = null
testingLlm.value = false
if (requestId === llmTestRequestId) {
if (llmTestAbortController === abortController) llmTestAbortController = null
testingLlm.value = false
}
}
}
@@ -861,6 +864,10 @@ async function saveAdvancedSettings() {
const advancedResult = await saveSystemSetting(SystemSettings.value.Advanced)
const scrapingResult = await saveScrapingSwitchs()
if (!advancedResult) {
$toast.error(t('setting.system.saveFailed', { message: t('common.apiRequestFailed') }))
}
if (advancedResult && scrapingResult) {
advancedDialog.value = false
$toast.success(t('setting.system.advancedSaveSuccess'))
@@ -868,9 +875,10 @@ async function saveAdvancedSettings() {
}
// 当字段为空时,将其设置为 null 提交,以便后端恢复为默认值
function cleanEmptyFields(settings: any, fields: string[]) {
function cleanEmptyFields(settings: Record<string, unknown>, fields: string[]) {
fields.forEach(field => {
if (settings[field]?.trim?.() === '') {
const value = settings[field]
if (typeof value === 'string' && value.trim() === '') {
settings[field] = null
}
})
@@ -879,8 +887,7 @@ function cleanEmptyFields(settings: any, fields: string[]) {
// 快捷复制到剪贴板
async function copyValue(value: string) {
try {
let success
success = copyToClipboard(value)
const success = copyToClipboard(value)
if (await success) $toast.success(t('setting.system.copySuccess'))
else $toast.error(t('setting.system.copyFailed'))
} catch (error) {
@@ -1012,9 +1019,7 @@ const moviePilotAutoUpdate = computed({
const fanartLanguageSelection = computed({
get: () => {
if (!SystemSettings.value.Advanced.FANART_LANG) return []
return SystemSettings.value.Advanced.FANART_LANG.split(',')
.filter(Boolean)
.map((lang: any) => lang.trim())
return (SystemSettings.value.Advanced.FANART_LANG.split(',') as string[]).filter(Boolean).map(lang => lang.trim())
},
set: (val: string[]) => {
SystemSettings.value.Advanced.FANART_LANG = val.join(',')
@@ -1816,7 +1821,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VIcon icon="mdi-plus" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem v-for="item in downloaderOptions" @click="addDownloader(item.value)">
<VListItem v-for="item in downloaderOptions" :key="item.value" @click="addDownloader(item.value)">
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
<VListItem @click="addDownloader('custom')">
@@ -1867,7 +1872,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VIcon icon="mdi-plus" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem v-for="item in mediaServerOptions" @click="addMediaServer(item.value)">
<VListItem v-for="item in mediaServerOptions" :key="item.value" @click="addMediaServer(item.value)">
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
<VListItem @click="addMediaServer('custom')">

View File

@@ -2,6 +2,7 @@ import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
import type { LlmModel, LlmProvider, LlmProviderAuthSession } from '@/composables/useLlmProviderDirectory'
import { useGlobalSettingsStore } from '@/stores'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -53,6 +54,83 @@ vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: mocks.openSharedDialog,
}))
vi.mock('@/components/cards/DownloaderCard.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'DownloaderCardStub',
props: {
downloader: { type: Object, required: true },
downloaders: { type: Array, required: true },
allowRefresh: { type: Boolean, default: true },
},
emits: ['change', 'close'],
template: `
<section :aria-label="'downloader-' + downloader.name">
<span>{{ downloader.name }} / {{ downloader.type }} / {{ allowRefresh }}</span>
<button
:aria-label="'change-' + downloader.name"
@click="$emit('change', { ...downloader, name: downloader.name + '-edited', enabled: true }, downloader.name)"
>change</button>
<button :aria-label="'remove-' + downloader.name" @click="$emit('close')">remove</button>
</section>
`,
}),
}
})
vi.mock('@/components/cards/MediaServerCard.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'MediaServerCardStub',
props: {
mediaserver: { type: Object, required: true },
mediaservers: { type: Array, required: true },
defaultSyncInterval: { type: Number, default: undefined },
},
emits: ['change', 'close'],
template: `
<section :aria-label="'mediaserver-' + mediaserver.name">
<span>{{ mediaserver.name }} / {{ mediaserver.type }} / {{ defaultSyncInterval ?? 'none' }}</span>
<button
:aria-label="'change-' + mediaserver.name"
@click="$emit('change', { ...mediaserver, name: mediaserver.name + '-edited', enabled: true }, mediaserver.name)"
>change</button>
<button :aria-label="'remove-' + mediaserver.name" @click="$emit('close')">remove</button>
</section>
`,
}),
}
})
vi.mock('vuedraggable', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'DraggableStub',
props: { modelValue: { type: Array, default: () => [] } },
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () => {
const items = props.modelValue as Array<{ name?: string }>
return h('div', [
h(
'button',
{
'aria-label': `reverse-${items[0]?.name ?? 'empty'}`,
onClick: () => emit('update:modelValue', [...items].reverse()),
},
'reverse',
),
...items.map(element => slots.item?.({ element })),
])
}
},
}),
}
})
/** 构造系统设置页所需的最小 LLM 目录状态。 */
function createLlmDirectoryState(overrides: Record<string, unknown> = {}) {
return {
@@ -103,6 +181,20 @@ function createDialogController() {
let systemEnv: Record<string, unknown>
const downloadersFixture = [
{ name: '下载器1', type: 'qbittorrent', default: false, enabled: true, config: { host: 'qb.example' } },
{ name: '下载器3', type: 'transmission', default: false, enabled: false, config: { host: 'tr.example' } },
]
const mediaServersFixture = [
{ name: '服务器1', type: 'emby', enabled: true, config: { host: 'emby.example' } },
{ name: '服务器3', type: 'plex', enabled: false, config: { host: 'plex.example' } },
]
let downloadersSetting: Array<Record<string, unknown>>
let mediaServersSetting: Array<Record<string, unknown>>
let scrapingSetting: Record<string, boolean | string>
const BASIC_SETTING_KEYS = [
'AI_AGENT_ENABLE',
'AI_AGENT_GLOBAL',
@@ -151,13 +243,12 @@ function mockLoadedSettings() {
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'system/env') return { success: true, data: systemEnv }
if (endpoint === 'message/agent/mcp/servers') return { success: true, data: { servers: [] } }
if (
endpoint === 'system/setting/Downloaders' ||
endpoint === 'system/setting/MediaServers' ||
endpoint === 'system/setting/ScrapingSwitchs'
) {
return { success: true, data: { value: [] } }
}
if (endpoint === 'system/setting/Downloaders')
return { success: true, data: { value: structuredClone(downloadersSetting) } }
if (endpoint === 'system/setting/MediaServers')
return { success: true, data: { value: structuredClone(mediaServersSetting) } }
if (endpoint === 'system/setting/ScrapingSwitchs')
return { success: true, data: { value: structuredClone(scrapingSetting) } }
throw new Error(`Unexpected GET ${endpoint}`)
})
}
@@ -201,6 +292,28 @@ function getBasicCard() {
return within(card as HTMLElement)
}
function getSettingsCard(title: string) {
const card = screen.getByText(title).closest('.v-card')
expect(card).not.toBeNull()
return within(card as HTMLElement)
}
async function openAdvancedTab(tab: string) {
await fireEvent.click(screen.getByRole('button', { name: /高级设置/ }))
await fireEvent.click(await screen.findByRole('tab', { name: tab }))
return within(screen.getByRole('dialog'))
}
async function selectOption(label: string, option: string) {
const user = userEvent.setup()
await user.click(screen.getByLabelText(label))
await user.click(await screen.findByRole('option', { name: option }))
}
function findPost(path: string) {
return mocks.apiPost.mock.calls.find(call => call[0] === path)
}
async function expandLlmSettings() {
await fireEvent.click(screen.getByRole('button', { name: '展开' }))
return screen.findByRole('button', { name: '测试调用' })
@@ -224,6 +337,9 @@ describe('AccountSettingSystem', () => {
DB_TYPE: 'sqlite',
RUST_ACCEL_AVAILABLE: false,
}
downloadersSetting = structuredClone(downloadersFixture)
mediaServersSetting = structuredClone(mediaServersFixture)
scrapingSetting = {}
mocks.useLlmProviderDirectory.mockReturnValue(createLlmDirectoryState())
mocks.openSharedDialog.mockImplementation(() => createDialogController())
mocks.apiPost.mockResolvedValue({ success: true })
@@ -505,4 +621,521 @@ describe('AccountSettingSystem', () => {
dialogEvents['update:modelValue'](false)
expect(closeAuthDialog).toHaveBeenCalledOnce()
})
it('round-trips the user-editable application and wallpaper settings', async () => {
await renderSettings()
await screen.findByDisplayValue('https://moviepilot.example')
await fireEvent.update(screen.getByLabelText('Github Token'), 'github-token')
await selectOption('背景壁纸', '自定义')
await fireEvent.update(screen.getByLabelText('自定义壁纸API地址'), 'https://wallpaper.example/api')
await fireEvent.click(getBasicCard().getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('基础设置保存成功'))
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
CUSTOMIZE_WALLPAPER_API_URL: 'https://wallpaper.example/api',
GITHUB_TOKEN: 'github-token',
WALLPAPER: 'customize',
}),
)
})
it('round-trips the user-editable LLM connection and inference settings', async () => {
const handleProviderSelection = vi.fn()
const applyModelMetadata = vi.fn()
const loadModels = vi.fn().mockResolvedValue(undefined)
const setBaseUrlPreset = vi.fn()
systemEnv = {
...systemEnv,
AI_AGENT_ENABLE: true,
LLM_API_KEY: 'old-key',
LLM_PROVIDER: 'deepseek',
LLM_MODEL: 'deepseek-chat',
LLM_USE_PROXY: true,
}
mocks.useLlmProviderDirectory.mockReturnValue(
createLlmDirectoryState({
applyModelMetadata,
canRefreshModels: ref(true),
handleProviderSelection,
loadModels,
models: ref([{ id: 'gpt-5', name: 'GPT-5' }]),
providerItems: ref([{ title: 'OpenAI', value: 'openai' }]),
setBaseUrlPreset,
showApiKeyField: ref(true),
showApiProtocolField: ref(true),
showBaseUrlField: ref(true),
}),
)
await renderSettings()
await expandLlmSettings()
await selectOption('LLM提供商', 'OpenAI')
await waitFor(() => expect(handleProviderSelection).toHaveBeenCalledWith(true))
await selectOption('API 协议', 'Responses')
await fireEvent.update(screen.getByLabelText('LLM基础URL'), 'https://llm.example/v1')
expect(setBaseUrlPreset).toHaveBeenLastCalledWith('', 'https://llm.example/v1')
await fireEvent.update(screen.getByLabelText('LLM API密钥'), 'new-key')
await selectOption('LLM模型名称', 'GPT-5')
expect(applyModelMetadata).toHaveBeenCalled()
await selectOption('联网搜索', '关闭联网搜索')
await fireEvent.update(screen.getByLabelText('LLM 最大上下文 Token 数量 (K)'), '256')
await fireEvent.update(screen.getByLabelText('温度参数'), '0.6')
await fireEvent.update(screen.getByLabelText('User-Agent'), 'MoviePilot-Test')
await selectOption('思考模式 / 深度', '高 (high)')
for (const label of ['使用系统代理', '模型支持图片输入']) {
await fireEvent.click(screen.getByLabelText(label))
}
await fireEvent.click(getBasicCard().getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('基础设置保存成功'))
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
LLM_API_KEY: 'new-key',
LLM_API_PROTOCOL: 'responses',
LLM_BASE_URL: 'https://api.deepseek.com',
LLM_MAX_CONTEXT_TOKENS: 256,
LLM_MODEL: 'gpt-5',
LLM_PROVIDER: 'openai',
LLM_SUPPORT_IMAGE_INPUT: true,
LLM_TEMPERATURE: 0.6,
LLM_THINKING_LEVEL: 'high',
LLM_USER_AGENT: 'MoviePilot-Test',
LLM_USE_PROXY: false,
LLM_WEB_SEARCH_MODE: 'disabled',
}),
)
})
it('round-trips assistant, audio, and recommendation settings', async () => {
systemEnv = { ...systemEnv, AI_AGENT_ENABLE: true }
await renderSettings()
await expandLlmSettings()
for (const label of ['全局智能助手', '啰嗦模式', '隐藏全局入口']) {
await fireEvent.click(screen.getByLabelText(label))
}
await selectOption('定时唤醒', '6小时')
for (const label of ['支持音频输入', '支持音频输出', '文件整理失败智能接管', '搜索结果智能推荐']) {
await fireEvent.click(screen.getByLabelText(label))
}
await selectOption('音频输入提供商', '小米 MiMo')
await fireEvent.update(screen.getByLabelText('音频输入模型'), 'mimo-stt')
await fireEvent.update(screen.getByLabelText('音频输入 API密钥'), 'audio-input-key')
await fireEvent.update(screen.getByLabelText('音频输入基础URL'), 'https://audio-input.example/v1')
await fireEvent.update(screen.getByLabelText('识别语言'), 'en')
await selectOption('音频输出提供商', 'MiniMax')
await fireEvent.update(screen.getByLabelText('音频输出模型'), 'speech-02-hd')
await fireEvent.update(screen.getByLabelText('音频输出 API密钥'), 'audio-output-key')
await fireEvent.update(screen.getByLabelText('音频输出基础URL'), 'https://audio-output.example/v1')
await fireEvent.update(screen.getByLabelText('语音音色'), 'female-shaonv')
await fireEvent.click(screen.getByLabelText('语音回复附带文字'))
await fireEvent.update(screen.getByLabelText('用户偏好'), '4K HDR')
await fireEvent.update(screen.getByLabelText('智能推荐分析条目上限'), '25')
await fireEvent.click(getBasicCard().getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('基础设置保存成功'))
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
AI_AGENT_GLOBAL: true,
AI_AGENT_HIDE_ENTRY: true,
AI_AGENT_JOB_INTERVAL: 6,
AI_AGENT_RETRY_TRANSFER: true,
AI_AGENT_VERBOSE: true,
AI_RECOMMEND_ENABLED: true,
AI_RECOMMEND_MAX_ITEMS: 25,
AI_RECOMMEND_USER_PREFERENCE: '4K HDR',
AUDIO_INPUT_API_KEY: 'audio-input-key',
AUDIO_INPUT_BASE_URL: 'https://audio-input.example/v1',
AUDIO_INPUT_LANGUAGE: 'en',
AUDIO_INPUT_MODEL: 'mimo-stt',
AUDIO_INPUT_PROVIDER: 'mimo',
AUDIO_OUTPUT_API_KEY: 'audio-output-key',
AUDIO_OUTPUT_BASE_URL: 'https://audio-output.example/v1',
AUDIO_OUTPUT_INCLUDE_TEXT: true,
AUDIO_OUTPUT_MODEL: 'speech-02-hd',
AUDIO_OUTPUT_PROVIDER: 'minimax',
AUDIO_OUTPUT_VOICE: 'female-shaonv',
LLM_SUPPORT_AUDIO_INPUT: true,
LLM_SUPPORT_AUDIO_OUTPUT: true,
}),
)
})
it('owns downloader creation, card changes, removal, ordering, default correction, and reload', async () => {
const user = userEvent.setup()
await renderSettings()
expect(await screen.findByText('下载器1 / qbittorrent / true')).toBeInTheDocument()
const card = getSettingsCard('下载器')
await user.click(card.getAllByRole('button').at(-1)!)
await user.click(await screen.findByText('Qbittorrent', { selector: '.v-list-item-title' }))
expect(screen.getByText('下载器4 / qbittorrent / true')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'reverse-下载器1' }))
await user.click(screen.getByRole('button', { name: 'change-下载器3' }))
await user.click(screen.getByRole('button', { name: 'remove-下载器1' }))
expect(screen.getByText('下载器3-edited / transmission / true')).toBeInTheDocument()
expect(screen.queryByLabelText('downloader-下载器1')).not.toBeInTheDocument()
await user.click(card.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('下载器设置保存成功'))
expect(findPost('system/setting/Downloaders')?.[1]).toEqual([
expect.objectContaining({ default: false, enabled: false, name: '下载器4', type: 'qbittorrent' }),
expect.objectContaining({ default: true, enabled: true, name: '下载器3-edited', type: 'transmission' }),
])
expect(mocks.toastInfo).toHaveBeenCalledWith('未设置默认下载器已将【下载器3-edited】作为默认下载器')
await waitFor(() => expect(screen.getByText('下载器1 / qbittorrent / true')).toBeInTheDocument())
expect(screen.queryByText('下载器3-edited / transmission / true')).not.toBeInTheDocument()
})
it('owns media server creation, card changes, removal, ordering, legacy interval, and reload', async () => {
const user = userEvent.setup()
systemEnv.MEDIASERVER_SYNC_INTERVAL = 12
await renderSettings()
expect(await screen.findByText('服务器1 / emby / 12')).toBeInTheDocument()
const card = getSettingsCard('媒体服务器')
await user.click(card.getAllByRole('button').at(-1)!)
await user.click(await screen.findByText('Emby', { selector: '.v-list-item-title' }))
expect(screen.getByText('服务器4 / emby / 12')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'reverse-服务器1' }))
await user.click(screen.getByRole('button', { name: 'change-服务器3' }))
await user.click(screen.getByRole('button', { name: 'remove-服务器1' }))
expect(screen.getByText('服务器3-edited / plex / 12')).toBeInTheDocument()
await user.click(card.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('媒体服务器设置保存成功'))
expect(findPost('system/setting/MediaServers')?.[1]).toEqual([
expect.objectContaining({ enabled: false, name: '服务器4', type: 'emby' }),
expect.objectContaining({ enabled: true, name: '服务器3-edited', type: 'plex' }),
])
await waitFor(() => expect(screen.getByText('服务器1 / emby / 12')).toBeInTheDocument())
expect(screen.queryByText('服务器3-edited / plex / 12')).not.toBeInTheDocument()
})
it('round-trips representative advanced tabs and normalizes scraping and empty log values', async () => {
systemEnv = {
...systemEnv,
AUXILIARY_AUTH_ENABLE: true,
TMDB_API_KEY: 'old-tmdb-key',
LOG_FILE_FORMAT: '',
PLUGIN_LOCAL_REPO_PATHS: '/plugins/old',
RUST_ACCEL_AVAILABLE: true,
RUST_ACCEL: true,
}
scrapingSetting = { movie_nfo: true, movie_poster: false, movie_backdrop: 'overwrite' }
await renderSettings()
const dialog = await openAdvancedTab('系统')
expect(dialog.getByLabelText('用户辅助认证')).toBeChecked()
await fireEvent.click(dialog.getByLabelText('用户辅助认证'))
await fireEvent.click(dialog.getByRole('tab', { name: '媒体' }))
expect(dialog.getByLabelText('TMDB API Key')).toHaveValue('old-tmdb-key')
await fireEvent.update(dialog.getByLabelText('TMDB API Key'), 'new-tmdb-key')
await fireEvent.click(dialog.getByRole('tab', { name: '日志' }))
expect(dialog.getByLabelText('日志文件格式')).toHaveValue('')
await fireEvent.click(dialog.getByRole('tab', { name: '实验室' }))
expect(dialog.getByLabelText('Rust 加速')).toBeChecked()
expect(dialog.getByLabelText('Rust 加速')).toHaveAttribute('aria-disabled', 'false')
await fireEvent.update(dialog.getByLabelText('本地插件仓库路径'), '/plugins/new')
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
AUXILIARY_AUTH_ENABLE: false,
LOG_FILE_FORMAT: null,
PLUGIN_LOCAL_REPO_PATHS: '/plugins/new',
RUST_ACCEL: true,
TMDB_API_KEY: 'new-tmdb-key',
}),
)
expect(findPost('system/setting/ScrapingSwitchs')?.[1]).toEqual(
expect.objectContaining({ movie_backdrop: 'overwrite', movie_nfo: 'missingOnly', movie_poster: 'skip' }),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('高级设置保存成功')
})
it('round-trips all advanced system switches and preserves the auto-update wire value', async () => {
await renderSettings()
const dialog = await openAdvancedTab('系统')
for (const label of [
'用户辅助认证',
'全局图片缓存',
'分享订阅数据',
'上报插件安装数据',
'上报安装版本统计',
'分享工作流数据',
'大内存模式',
'数据库WAL模式',
'自动更新MoviePilot',
'自动更新站点资源',
]) {
await fireEvent.click(dialog.getByLabelText(label))
}
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
AUXILIARY_AUTH_ENABLE: true,
AUTO_UPDATE_RESOURCE: false,
BIG_MEMORY_MODE: true,
DB_WAL_ENABLE: true,
GLOBAL_IMAGE_CACHE: true,
MOVIEPILOT_AUTO_UPDATE: 'release',
PLUGIN_STATISTIC_SHARE: false,
SUBSCRIBE_STATISTIC_SHARE: false,
USAGE_STATISTIC_SHARE: false,
WORKFLOW_STATISTIC_SHARE: false,
}),
)
})
it('round-trips advanced media metadata, recognition, and Fanart settings', async () => {
const user = userEvent.setup()
await renderSettings()
const dialog = await openAdvancedTab('媒体')
await fireEvent.update(dialog.getByLabelText('TMDB API服务地址'), 'api.tmdb.org')
await fireEvent.update(dialog.getByLabelText('TMDB API Key'), 'tmdb-key')
await fireEvent.update(dialog.getByLabelText('AcoustID API Key'), 'acoustid-key')
await fireEvent.update(dialog.getByLabelText('TMDB 图片服务地址'), 'image.tmdb.org')
await fireEvent.update(dialog.getByLabelText('音乐封面代理地址'), 'https://music.example')
await selectOption('TMDB 元数据语言', '繁体中文')
await fireEvent.update(dialog.getByLabelText('单条媒体元数据缓存有效期'), '48')
for (const label of [
'跟随TMDB识别整理',
'TMDB 刮削原语种图片',
'优先使用插件识别',
'共享使用媒体识别数据',
'Fanart图片数据源',
]) {
await fireEvent.click(dialog.getByLabelText(label))
}
await user.click(dialog.getByLabelText('Fanart语言'))
await user.click(await screen.findByRole('option', { name: '日文' }))
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
ACOUSTID_API_KEY: 'acoustid-key',
FANART_ENABLE: true,
FANART_LANG: 'zh,en,ja',
MEDIA_RECOGNIZE_SHARE: false,
META_CACHE_EXPIRE: '48',
MUSIC_COVER_PROXY: 'https://music.example',
RECOGNIZE_PLUGIN_FIRST: true,
SCRAP_FOLLOW_TMDB: false,
TMDB_API_DOMAIN: 'api.tmdb.org',
TMDB_API_KEY: 'tmdb-key',
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
TMDB_LOCALE: 'zh-TW',
TMDB_SCRAP_ORIGINAL_IMAGE: true,
}),
)
})
it('round-trips advanced network fields and extends both image access lists', async () => {
const user = userEvent.setup()
await renderSettings()
const dialog = await openAdvancedTab('网络')
await fireEvent.update(dialog.getByLabelText('代理服务器'), 'socks5://proxy.example:1080')
await fireEvent.update(dialog.getByLabelText('Github加速代理'), 'https://github-proxy.example')
await fireEvent.update(dialog.getByLabelText('PIP加速代理'), 'https://pypi.example/simple')
await fireEvent.click(dialog.getByLabelText('DNS Over HTTPS'))
await fireEvent.update(dialog.getByLabelText('DOH 服务器'), 'https://dns.example/dns-query')
await fireEvent.update(dialog.getByLabelText('DOH 域名'), 'example.com')
await user.click(dialog.getByText('安全图片域名'))
const domainInput = dialog.getByPlaceholderText('添加域名image.tmdb.org')
await fireEvent.update(domainInput, 'cdn.example.com')
await user.click(domainInput.closest('.v-input')!.querySelector('button')!)
const rangeInput = dialog.getByPlaceholderText('添加 CIDR198.18.0.0/15')
await fireEvent.update(rangeInput, '10.0.0.0/8')
await user.click(rangeInput.closest('.v-input')!.querySelector('button')!)
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
DOH_DOMAINS: 'example.com',
DOH_ENABLE: true,
DOH_RESOLVERS: 'https://dns.example/dns-query',
GITHUB_PROXY: 'https://github-proxy.example',
IMAGE_PROXY_ALLOWED_PRIVATE_RANGES: ['10.0.0.0/8'],
PIP_PROXY: 'https://pypi.example/simple',
PROXY_HOST: 'socks5://proxy.example:1080',
SECURITY_IMAGE_DOMAINS: ['cdn.example.com'],
}),
)
})
it('round-trips data cleanup boundaries and every advanced log field', async () => {
const user = userEvent.setup()
await renderSettings()
const dialog = await openAdvancedTab('数据')
await fireEvent.click(dialog.getByLabelText('启用数据清理'))
await fireEvent.update(dialog.getByLabelText('消息表保留天数'), '0')
await fireEvent.update(dialog.getByLabelText('下载历史表保留天数'), '30')
await fireEvent.update(dialog.getByLabelText('站点数据表保留天数'), '60')
await fireEvent.update(dialog.getByLabelText('整理历史表保留天数'), '90')
await user.click(dialog.getByRole('tab', { name: '日志' }))
await selectOption('日志等级', 'ERROR - 错误')
await fireEvent.update(dialog.getByLabelText('日志文件最大容量(MB)'), '20')
await fireEvent.update(dialog.getByLabelText('日志文件最大备份数量'), '7')
await fireEvent.update(dialog.getByLabelText('日志文件格式'), '')
await fireEvent.click(dialog.getByLabelText('调试模式'))
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS: 30,
DATA_CLEANUP_ENABLE: true,
DATA_CLEANUP_MESSAGE_DAYS: 0,
DATA_CLEANUP_SITE_USERDATA_DAYS: 60,
DATA_CLEANUP_TRANSFER_HISTORY_DAYS: 90,
DEBUG: true,
LOG_BACKUP_COUNT: '7',
LOG_FILE_FORMAT: null,
LOG_LEVEL: 'ERROR',
LOG_MAX_FILE_SIZE: '20',
}),
)
})
it('round-trips every advanced laboratory control when Rust is available', async () => {
systemEnv.RUST_ACCEL_AVAILABLE = true
await renderSettings()
const dialog = await openAdvancedTab('实验室')
await fireEvent.update(dialog.getByLabelText('本地插件仓库路径'), '/plugins/local')
await fireEvent.update(dialog.getByLabelText('文件整理线程数'), '4')
await fireEvent.update(dialog.getByLabelText('整理失败重试次数'), '6')
await fireEvent.update(dialog.getByLabelText('文件操作超时(秒)'), '45')
await fireEvent.update(dialog.getByLabelText('文件复制停滞超时(秒)'), '180')
for (const label of ['插件热加载', '本地文件操作隔离', '编码探测性能模式', 'Rust 加速', '网络存储快速监控']) {
await fireEvent.click(dialog.getByLabelText(label))
}
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
ENCODING_DETECTION_PERFORMANCE_MODE: false,
FS_PROXY_ENABLED: false,
FS_PROXY_STALL_TIMEOUT: 180,
FS_PROXY_TIMEOUT: 45,
MONITOR_NETWORK_FAST_MODE: true,
PLUGIN_AUTO_RELOAD: true,
PLUGIN_LOCAL_REPO_PATHS: '/plugins/local',
RUST_ACCEL: true,
TRANSFER_MAX_FAILED_RETRIES: 6,
TRANSFER_THREADS: 4,
}),
)
})
it.each([{ RUST_ACCEL_AVAILABLE: true, RUST_ACCEL_ENABLED: false }, { RUST_ACCEL_ENABLED: true }])(
'derives Rust availability from the supported capability fields %#',
async capability => {
delete systemEnv.RUST_ACCEL_AVAILABLE
Object.assign(systemEnv, capability, { RUST_ACCEL: true })
await renderSettings()
const dialog = await openAdvancedTab('实验室')
expect(dialog.getByLabelText('Rust 加速')).toHaveAttribute('aria-disabled', 'false')
expect(dialog.getByLabelText('Rust 加速')).toBeChecked()
},
)
it('disables unavailable Rust acceleration and forces its advanced payload off', async () => {
systemEnv.RUST_ACCEL_AVAILABLE = false
systemEnv.RUST_ACCEL_ENABLED = true
systemEnv.RUST_ACCEL = true
await renderSettings()
const dialog = await openAdvancedTab('实验室')
const rust = dialog.getByLabelText('Rust 加速')
expect(rust).toBeDisabled()
expect(rust).not.toBeChecked()
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(findPost('system/env')).toBeDefined())
expect(findPost('system/env')?.[1]).toEqual(expect.objectContaining({ RUST_ACCEL: false }))
})
it('suppresses silent refresh while the advanced dialog is open and resumes after save', async () => {
await renderSettings()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/env'))
const refresh = mocks.useSilentSettingRefresh.mock.calls[0]?.[0] as () => Promise<void>
const dialog = await openAdvancedTab('系统')
mocks.apiGet.mockClear()
await refresh()
expect(mocks.apiGet).not.toHaveBeenCalled()
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await refresh()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/env'))
})
it.each([
['下载器', 'system/setting/Downloaders', '下载器设置保存失败!'],
['媒体服务器', 'system/setting/MediaServers', '媒体服务器设置保存失败!'],
])('reports an HTTP failure while saving %s settings', async (cardTitle, endpoint, message) => {
let attempts = 0
mocks.apiPost.mockImplementation((path: string) => {
if (path === endpoint && attempts++ === 0) return Promise.reject(new Error('offline'))
return Promise.resolve({ success: true })
})
await renderSettings()
await fireEvent.click(getSettingsCard(cardTitle).getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
expect(mocks.toastSuccess).not.toHaveBeenCalled()
await fireEvent.click(getSettingsCard(cardTitle).getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledOnce())
expect(mocks.apiPost.mock.calls.filter(call => call[0] === endpoint)).toHaveLength(2)
})
it('reports an advanced environment save failure while keeping the dialog open', async () => {
mocks.apiPost.mockImplementation((path: string) => {
if (path === 'system/env') return Promise.reject(new Error('offline'))
return Promise.resolve({ success: true })
})
await renderSettings()
await fireEvent.click(screen.getByRole('button', { name: /高级设置/ }))
const dialog = within(screen.getByRole('dialog'))
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('设置保存失败:请求失败!'))
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/ScrapingSwitchs', expect.any(Object))
expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
it('keeps the advanced dialog open when scraping save fails after the environment succeeds', async () => {
mocks.apiPost.mockImplementation((path: string) => {
if (path === 'system/setting/ScrapingSwitchs') return Promise.reject(new Error('offline'))
return Promise.resolve({ success: true })
})
await renderSettings()
const dialog = await openAdvancedTab('系统')
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('刮削开关设置保存失败'))
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(findPost('system/env')).toBeDefined()
expect(mocks.toastSuccess).not.toHaveBeenCalledWith('高级设置保存成功')
})
})

View File

@@ -394,6 +394,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/views/setting/AccountSettingSearch.vue',
'src/views/setting/AccountSettingSite.vue',
'src/views/setting/AccountSettingSubscribe.vue',
'src/views/setting/AccountSettingSystem.vue',
'src/views/user/UserListView.vue',
'src/views/user/UserProfileView.vue',
'src/views/reorganize/DownloadingListView.vue',
@@ -486,6 +487,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 80,
statements: 80,
},
'src/views/setting/AccountSettingSystem.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/components/cards/TorrentCard.vue': {
branches: 75,
functions: 80,