From 4559524190ee8d00e5b6fb338f5534ba76812366 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:32:05 +0800 Subject: [PATCH] test(settings): cover advanced system configuration (#676) --- eslint-suppressions.json | 9 - src/views/setting/AccountSettingSystem.vue | 47 +- .../__tests__/AccountSettingSystem.spec.ts | 647 +++++++++++++++++- vite.config.ts | 7 + 4 files changed, 673 insertions(+), 37 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e103b7b3..d95c5a0a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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": { diff --git a/src/views/setting/AccountSettingSystem.vue b/src/views/setting/AccountSettingSystem.vue index f42d26b4..984fd22c 100644 --- a/src/views/setting/AccountSettingSystem.vue +++ b/src/views/setting/AccountSettingSystem.vue @@ -479,7 +479,7 @@ function normalizeThinkingLevelValue(value?: unknown) { return aliasMap[normalized] || normalized } -function resolveThinkingLevelValue(data?: Record) { +function resolveThinkingLevelValue(data?: Record) { 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>('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) { 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)[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) { 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, 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) => { - + {{ item.title }} @@ -1867,7 +1872,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => { - + {{ item.title }} diff --git a/src/views/setting/__tests__/AccountSettingSystem.spec.ts b/src/views/setting/__tests__/AccountSettingSystem.spec.ts index fe3ea43f..db90866e 100644 --- a/src/views/setting/__tests__/AccountSettingSystem.spec.ts +++ b/src/views/setting/__tests__/AccountSettingSystem.spec.ts @@ -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: ` +
+ {{ downloader.name }} / {{ downloader.type }} / {{ allowRefresh }} + + +
+ `, + }), + } +}) + +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: ` +
+ {{ mediaserver.name }} / {{ mediaserver.type }} / {{ defaultSyncInterval ?? 'none' }} + + +
+ `, + }), + } +}) + +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 = {}) { return { @@ -103,6 +181,20 @@ function createDialogController() { let systemEnv: Record +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> +let mediaServersSetting: Array> +let scrapingSetting: Record + 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('添加 CIDR,如:198.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 + 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('高级设置保存成功') + }) }) diff --git a/vite.config.ts b/vite.config.ts index dd22df7c..0468dec4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,