mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-13 17:44:52 +08:00
test(setting): cover directory and rule settings (#668)
This commit is contained in:
@@ -841,10 +841,7 @@
|
||||
},
|
||||
"src/views/setting/AccountSettingDirectory.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 9
|
||||
},
|
||||
"no-prototype-builtins": {
|
||||
"count": 1
|
||||
"count": 8
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
@@ -861,9 +858,6 @@
|
||||
"src/views/setting/AccountSettingRule.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 14
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSearch.vue": {
|
||||
|
||||
@@ -126,7 +126,9 @@ async function loadSystemSettings() {
|
||||
// 将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 (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
|
||||
if (Object.prototype.hasOwnProperty.call(result.data, key)) {
|
||||
Reflect.set(SystemSettings.value[sectionKey], key, result.data[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -174,6 +176,7 @@ async function saveStorages() {
|
||||
else $toast.error(t('setting.directory.storageSaveFailed'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('setting.directory.storageSaveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +205,7 @@ async function saveDirectories() {
|
||||
} else $toast.error(t('setting.directory.directorySaveFailed'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('setting.directory.directorySaveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +300,7 @@ async function saveSystemSettings(value: any) {
|
||||
} else $toast.error(t('setting.directory.organizeSaveFailed'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('setting.directory.organizeSaveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ async function saveCustomRules() {
|
||||
else $toast.error(t('setting.rule.customRuleSaveFailed'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('setting.rule.customRuleSaveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +142,7 @@ async function saveFilterRuleGroups() {
|
||||
else $toast.error(t('setting.rule.ruleGroupSaveFailed'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('setting.rule.ruleGroupSaveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +168,7 @@ async function shareRules(rules: CustomRule[] | FilterRuleGroup[], type: string)
|
||||
|
||||
// 复制到剪贴板
|
||||
try {
|
||||
let success
|
||||
success = copyToClipboard(value)
|
||||
const success = copyToClipboard(value)
|
||||
if (await success)
|
||||
$toast.success(
|
||||
type === 'custom' ? t('setting.rule.customRuleCopySuccess') : t('setting.rule.ruleGroupCopySuccess'),
|
||||
@@ -374,6 +375,7 @@ async function saveTorrentPriority() {
|
||||
else $toast.error('优先规则保存失败!')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error('优先规则保存失败!')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,52 +2,93 @@ import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent } from 'vue'
|
||||
import { computed, defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
useSilentSettingRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
default: { get: mocks.apiGet, post: mocks.apiPost },
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
}),
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSilentSettingRefresh', () => ({
|
||||
useSilentSettingRefresh: vi.fn(),
|
||||
useSilentSettingRefresh: mocks.useSilentSettingRefresh,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: mocks.openSharedDialog,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/cards/DirectoryCard.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return { default: defineComponent({ name: 'DirectoryCardStub', template: '<div />' }) }
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'DirectoryCardStub',
|
||||
props: { directory: { type: Object, required: true } },
|
||||
emits: ['close', 'update:modelValue'],
|
||||
template: `
|
||||
<section :aria-label="'directory-' + directory.name">
|
||||
<span>{{ directory.name }}</span>
|
||||
<button :aria-label="'rename-' + directory.name" @click="directory.name = '目录1'">rename</button>
|
||||
<button :aria-label="'remove-' + directory.name" @click="$emit('close')">remove</button>
|
||||
<button
|
||||
:aria-label="'paths-' + directory.name"
|
||||
@click="directory.download_path = '/new-download'; directory.library_path = '/new-library'"
|
||||
>paths</button>
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/cards/StorageCard.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return { default: defineComponent({ name: 'StorageCardStub', template: '<div />' }) }
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'StorageCardStub',
|
||||
props: { storage: { type: Object, required: true } },
|
||||
emits: ['close', 'done'],
|
||||
template: `
|
||||
<section :aria-label="'storage-' + storage.name">
|
||||
<span>{{ storage.name }}</span>
|
||||
<button :aria-label="'remove-' + storage.name" @click="$emit('close')">remove</button>
|
||||
<button :aria-label="'reload-' + storage.name" @click="$emit('done')">done</button>
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vuedraggable', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'DraggableStub',
|
||||
setup(_props, { slots }) {
|
||||
return () => h('div', slots.default?.())
|
||||
props: { modelValue: { type: Array, default: () => [] } },
|
||||
emits: ['update:modelValue', 'end'],
|
||||
setup(props, { emit, slots }) {
|
||||
const reverse = () => {
|
||||
emit('update:modelValue', [...props.modelValue].reverse())
|
||||
emit('end')
|
||||
}
|
||||
return () => {
|
||||
const items = props.modelValue as Array<{ name?: string }>
|
||||
return h('div', [
|
||||
h('button', { 'aria-label': `reverse-${items[0]?.name ?? 'empty'}`, onClick: reverse }, 'reverse'),
|
||||
...items.map(element => slots.item?.({ element })),
|
||||
])
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -55,20 +96,60 @@ vi.mock('vuedraggable', async () => {
|
||||
|
||||
const AceEditorStub = defineComponent({
|
||||
name: 'VAceEditor',
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
},
|
||||
template: '<div />',
|
||||
props: { value: { type: String, default: '' } },
|
||||
emits: ['update:value'],
|
||||
template: '<textarea :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
})
|
||||
|
||||
function mockSettings(settingValue: boolean | null) {
|
||||
const storagesFixture = [
|
||||
{ name: '本地存储', type: 'local', config: {} },
|
||||
{ name: '自定义存储 1', type: 'custom1', config: {} },
|
||||
]
|
||||
|
||||
const directoriesFixture = [
|
||||
{
|
||||
name: '目录1',
|
||||
storage: 'local',
|
||||
download_path: '/downloads/a',
|
||||
library_path: '/media/a',
|
||||
priority: 8,
|
||||
monitor_type: '',
|
||||
media_type: '',
|
||||
media_category: '',
|
||||
transfer_type: '',
|
||||
},
|
||||
{
|
||||
name: '目录3',
|
||||
storage: 'local',
|
||||
download_path: '/downloads/b',
|
||||
library_path: '/media/b',
|
||||
priority: 2,
|
||||
monitor_type: '',
|
||||
media_type: '',
|
||||
media_category: '',
|
||||
transfer_type: '',
|
||||
},
|
||||
]
|
||||
|
||||
function mockLoadedSettings(options: { mountedDisk?: boolean | null } = {}) {
|
||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||
if (endpoint === 'system/setting/public/Directories') return { data: { value: [] } }
|
||||
if (endpoint === 'system/setting/public/Storages') return { data: { value: [] } }
|
||||
if (endpoint === 'media/category') return {}
|
||||
if (endpoint === 'system/env') return { data: {}, success: true }
|
||||
if (endpoint === 'system/setting/public/Directories')
|
||||
return { data: { value: structuredClone(directoriesFixture) } }
|
||||
if (endpoint === 'system/setting/public/Storages') return { data: { value: structuredClone(storagesFixture) } }
|
||||
if (endpoint === 'media/category') return { 电影: ['华语'] }
|
||||
if (endpoint === 'system/env') {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
MOVIE_RENAME_FORMAT: '{{ title }}',
|
||||
TV_RENAME_FORMAT: '{{ name }}',
|
||||
MUSIC_RENAME_FORMAT: '{{ artist }}',
|
||||
UNRELATED: 'ignored',
|
||||
},
|
||||
}
|
||||
}
|
||||
if (endpoint === 'system/setting/MountedLocalDiskDeleteEmptyDirs') {
|
||||
return { data: { value: settingValue }, success: true }
|
||||
return { data: { value: options.mountedDisk ?? null }, success: true }
|
||||
}
|
||||
throw new Error(`Unexpected GET ${endpoint}`)
|
||||
})
|
||||
@@ -77,57 +158,188 @@ function mockSettings(settingValue: boolean | null) {
|
||||
|
||||
async function renderDirectorySettings() {
|
||||
return renderWithProviders(AccountSettingDirectory, {
|
||||
global: {
|
||||
stubs: {
|
||||
VAceEditor: AceEditorStub,
|
||||
},
|
||||
},
|
||||
global: { stubs: { VAceEditor: AceEditorStub } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('mounted local disk empty directory cleanup setting', () => {
|
||||
function getCard(title: string) {
|
||||
const card = screen.getByText(title).closest('.v-card')
|
||||
expect(card).not.toBeNull()
|
||||
return within(card as HTMLElement)
|
||||
}
|
||||
|
||||
function getRenameEditors() {
|
||||
return screen.getAllByRole('textbox').filter(element => element.tagName === 'TEXTAREA')
|
||||
}
|
||||
|
||||
describe('AccountSettingDirectory', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.openSharedDialog.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.useSilentSettingRefresh.mockReset()
|
||||
mockLoadedSettings()
|
||||
})
|
||||
|
||||
it('defaults to enabled when no saved value exists', async () => {
|
||||
mockSettings(null)
|
||||
it('loads owned values, keeps the mounted-disk default, and follows active refresh state', async () => {
|
||||
const { rerender } = await renderDirectorySettings()
|
||||
|
||||
await renderDirectorySettings()
|
||||
expect(await screen.findByText('目录1')).toBeInTheDocument()
|
||||
expect(screen.getByText('目录3')).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: '挂载盘删除空目录' })).toBeChecked()
|
||||
expect(getRenameEditors().map(input => (input as HTMLTextAreaElement).value)).toEqual([
|
||||
'{{ title }}',
|
||||
'{{ artist }}',
|
||||
'{{ name }}',
|
||||
])
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: '挂载盘删除空目录' })).toBeChecked()
|
||||
const refreshOptions = mocks.useSilentSettingRefresh.mock.calls[0]?.[1]
|
||||
expect(computed(() => refreshOptions.active.value).value).toBe(true)
|
||||
await rerender({ active: false })
|
||||
expect(refreshOptions.active.value).toBe(false)
|
||||
})
|
||||
|
||||
it('lists every supported music metadata source for scraping', async () => {
|
||||
mockSettings(null)
|
||||
await renderDirectorySettings()
|
||||
|
||||
const organizeCard = (await screen.findByText('整理 & 刮削')).closest('.v-card')
|
||||
expect(organizeCard).not.toBeNull()
|
||||
it('adds a non-conflicting directory name, updates paths, and saves current priority order', async () => {
|
||||
const user = userEvent.setup()
|
||||
await user.click(within(organizeCard as HTMLElement).getByLabelText('刮削数据源'))
|
||||
|
||||
expect(await screen.findByRole('option', { name: 'MusicBrainz' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'TheAudioDB' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: '豆瓣音乐' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves the disabled value with the organization settings', async () => {
|
||||
mockSettings(true)
|
||||
await renderDirectorySettings()
|
||||
const cleanupSwitch = await screen.findByRole('checkbox', { name: '挂载盘删除空目录' })
|
||||
await fireEvent.click(cleanupSwitch)
|
||||
await screen.findByText('目录1')
|
||||
const directoryCard = getCard('目录')
|
||||
const actionButtons = directoryCard.getAllByRole('button')
|
||||
await user.click(actionButtons.at(-2)!)
|
||||
|
||||
const organizeCard = screen.getByText('整理 & 刮削').closest('.v-card')
|
||||
expect(organizeCard).not.toBeNull()
|
||||
await fireEvent.click(within(organizeCard as HTMLElement).getByRole('button', { name: '保存' }))
|
||||
expect(screen.getByText('目录4')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'paths-目录1' }))
|
||||
await user.click(screen.getByRole('button', { name: 'reverse-目录1' }))
|
||||
await user.click(directoryCard.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/Directories', [
|
||||
expect.objectContaining({ name: '目录4', priority: 0 }),
|
||||
expect.objectContaining({ name: '目录3', priority: 1 }),
|
||||
expect.objectContaining({
|
||||
name: '目录1',
|
||||
priority: 2,
|
||||
download_path: '/new-download',
|
||||
library_path: '/new-library',
|
||||
}),
|
||||
])
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('目录设置保存成功')
|
||||
})
|
||||
|
||||
it('blocks duplicate directory names before the API call', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('目录3')
|
||||
await user.click(screen.getByRole('button', { name: 'rename-目录3' }))
|
||||
await user.click(getCard('目录').getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(mocks.apiPost).not.toHaveBeenCalledWith('system/setting/Directories', expect.anything())
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('存在重复目录名称!无法保存,请修改!')
|
||||
})
|
||||
|
||||
it('removes directories and storages and persists the remaining collections', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('目录3')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'remove-目录1' }))
|
||||
await user.click(getCard('目录').getByRole('button', { name: '保存' }))
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/Directories', [
|
||||
expect.objectContaining({ name: '目录3', priority: 0 }),
|
||||
])
|
||||
|
||||
mocks.apiPost.mockClear()
|
||||
await user.click(screen.getByRole('button', { name: 'remove-自定义存储 1' }))
|
||||
await user.click(getCard('存储').getByRole('button', { name: '保存' }))
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/Storages', [
|
||||
expect.objectContaining({ name: '本地存储', type: 'local' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('filters existing storage types and creates unique custom storage names', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('自定义存储 1')
|
||||
const storageCard = getCard('存储')
|
||||
const actionButtons = storageCard.getAllByRole('button')
|
||||
await user.click(actionButtons.at(-1)!)
|
||||
|
||||
expect(screen.queryByText('本地', { selector: '.v-list-item-title' })).not.toBeInTheDocument()
|
||||
await user.click(await screen.findByText('自定义', { selector: '.v-list-item-title' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||
'system/setting/Storages',
|
||||
expect.arrayContaining([expect.objectContaining({ name: '自定义 3', type: 'custom3' })]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('converts cleared rename formats to null and requires both organization saves to succeed', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('整理 & 刮削')
|
||||
for (const editor of getRenameEditors()) await fireEvent.update(editor, '')
|
||||
await user.click(screen.getByRole('checkbox', { name: '挂载盘删除空目录' }))
|
||||
await user.click(getCard('整理 & 刮削').getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/env', {
|
||||
SCRAP_SOURCE: 'themoviedb',
|
||||
MOVIE_RENAME_FORMAT: null,
|
||||
TV_RENAME_FORMAT: null,
|
||||
MUSIC_RENAME_FORMAT: null,
|
||||
})
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/MountedLocalDiskDeleteEmptyDirs', false)
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('整理选项设置保存成功')
|
||||
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.apiPost.mockResolvedValueOnce({ success: true }).mockResolvedValueOnce({ success: false })
|
||||
await user.click(getCard('整理 & 刮削').getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('整理选项设置保存失败!'))
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports HTTP failures for storage, directory, and organization saves', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('目录1')
|
||||
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
|
||||
await user.click(getCard('存储').getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('存储设置保存失败!'))
|
||||
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
|
||||
await user.click(getCard('目录').getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('目录设置保存失败!'))
|
||||
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
|
||||
await user.click(getCard('整理 & 刮削').getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('整理选项设置保存失败!'))
|
||||
})
|
||||
|
||||
it('opens the shared category editor and reloads storage data after a card completes', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDirectorySettings()
|
||||
await screen.findByText('本地存储')
|
||||
const directoryCard = getCard('目录')
|
||||
await user.click(directoryCard.getByRole('button', { name: '分类策略' }))
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
|
||||
const initialStorageLoads = mocks.apiGet.mock.calls.filter(
|
||||
([url]) => url === 'system/setting/public/Storages',
|
||||
).length
|
||||
await user.click(screen.getByRole('button', { name: 'reload-本地存储' }))
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiGet.mock.calls.filter(([url]) => url === 'system/setting/public/Storages')).toHaveLength(
|
||||
initialStorageLoads + 1,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
373
src/views/setting/__tests__/AccountSettingRule.spec.ts
Normal file
373
src/views/setting/__tests__/AccountSettingRule.spec.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastWarning: vi.fn(),
|
||||
useSilentSettingRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet, post: mocks.apiPost },
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess, warning: mocks.toastWarning }),
|
||||
}))
|
||||
|
||||
vi.mock('@/@core/utils/navigator', () => ({
|
||||
copyToClipboard: mocks.copyToClipboard,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: mocks.openSharedDialog,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSilentSettingRefresh', () => ({
|
||||
useSilentSettingRefresh: mocks.useSilentSettingRefresh,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/cards/CustomRuleCard.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'CustomRuleCardStub',
|
||||
props: { rule: { type: Object, required: true } },
|
||||
emits: ['close', 'change'],
|
||||
template: `
|
||||
<section :aria-label="'custom-' + rule.id">
|
||||
<span>{{ rule.id }} / {{ rule.name }}</span>
|
||||
<input
|
||||
:aria-label="'custom-id-' + rule.id"
|
||||
:value="rule.id"
|
||||
@input="$emit('change', { ...rule, id: $event.target.value }, rule.id)"
|
||||
/>
|
||||
<input
|
||||
:aria-label="'custom-name-' + rule.id"
|
||||
:value="rule.name"
|
||||
@input="$emit('change', { ...rule, name: $event.target.value }, rule.id)"
|
||||
/>
|
||||
<button :aria-label="'remove-custom-' + rule.id" @click="$emit('close')">remove</button>
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/cards/FilterRuleGroupCard.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'FilterRuleGroupCardStub',
|
||||
props: { group: { type: Object, required: true } },
|
||||
emits: ['close', 'change'],
|
||||
template: `
|
||||
<section :aria-label="'group-' + group.name">
|
||||
<span>{{ group.name }}</span>
|
||||
<input
|
||||
:aria-label="'group-name-' + group.name"
|
||||
:value="group.name"
|
||||
@input="$emit('change', { ...group, name: $event.target.value }, group.name)"
|
||||
/>
|
||||
<button :aria-label="'remove-group-' + group.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 }) {
|
||||
const reverse = () => emit('update:modelValue', [...props.modelValue].reverse())
|
||||
return () => {
|
||||
const items = props.modelValue as Array<{ name?: string }>
|
||||
return h('div', [
|
||||
h('button', { 'aria-label': `reverse-${items[0]?.name ?? 'empty'}`, onClick: reverse }, 'reverse'),
|
||||
...items.map(element => slots.item?.({ element })),
|
||||
])
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const customRulesFixture = [
|
||||
{ id: 'RULE1', name: '规则1', include: 'WEB-DL' },
|
||||
{ id: 'RULE3', name: '规则3', exclude: 'CAM' },
|
||||
]
|
||||
|
||||
const groupsFixture = [
|
||||
{ name: '规则组1', rule_string: 'RULE1', media_type: '电影', category: '' },
|
||||
{ name: '规则组3', rule_string: 'RULE3', media_type: '', category: '' },
|
||||
]
|
||||
|
||||
function mockLoadedRules() {
|
||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||
if (endpoint === 'media/category') return { 电影: ['华语'] }
|
||||
if (endpoint === 'system/setting/CustomFilterRules') {
|
||||
return { success: true, data: { value: structuredClone(customRulesFixture) } }
|
||||
}
|
||||
if (endpoint === 'system/setting/UserFilterRuleGroups') {
|
||||
return { success: true, data: { value: structuredClone(groupsFixture) } }
|
||||
}
|
||||
if (endpoint === 'system/setting/TorrentsPriority') {
|
||||
return { success: true, data: { value: ['site', 'seeder'] } }
|
||||
}
|
||||
throw new Error(`Unexpected GET ${endpoint}`)
|
||||
})
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
}
|
||||
|
||||
async function renderRuleSettings() {
|
||||
return renderWithProviders(AccountSettingRule)
|
||||
}
|
||||
|
||||
function getCard(title: string) {
|
||||
const card = screen.getByText(title).closest('.v-card')
|
||||
expect(card).not.toBeNull()
|
||||
return within(card as HTMLElement)
|
||||
}
|
||||
|
||||
function getCommandButtons(title: string) {
|
||||
const card = screen.getByText(title).closest('.v-card')
|
||||
expect(card).not.toBeNull()
|
||||
return Array.from((card as HTMLElement).querySelectorAll<HTMLButtonElement>('button.v-btn'))
|
||||
}
|
||||
|
||||
function getImportSave(callIndex: number) {
|
||||
return mocks.openSharedDialog.mock.calls[callIndex]?.[2]?.save as (type: string, value: { value: string }) => void
|
||||
}
|
||||
|
||||
describe('AccountSettingRule', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.copyToClipboard.mockReset()
|
||||
mocks.openSharedDialog.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.toastWarning.mockReset()
|
||||
mocks.useSilentSettingRefresh.mockReset()
|
||||
mocks.copyToClipboard.mockResolvedValue(true)
|
||||
mockLoadedRules()
|
||||
})
|
||||
|
||||
it('loads rules, groups, priority, and follows active refresh state', async () => {
|
||||
const { rerender } = await renderRuleSettings()
|
||||
|
||||
expect(await screen.findByText('RULE1 / 规则1')).toBeInTheDocument()
|
||||
expect(screen.getByText('规则组1')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('当前使用下载优先规则')).toHaveValue('site, seeder')
|
||||
|
||||
const refreshOptions = mocks.useSilentSettingRefresh.mock.calls[0]?.[1]
|
||||
expect(refreshOptions.active.value).toBe(true)
|
||||
await rerender({ active: false })
|
||||
expect(refreshOptions.active.value).toBe(false)
|
||||
})
|
||||
|
||||
it('saves custom rules, groups, and torrent priority in current order', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderRuleSettings()
|
||||
await screen.findByText('RULE1 / 规则1')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'reverse-规则1' }))
|
||||
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/CustomFilterRules', [
|
||||
expect.objectContaining({ id: 'RULE3' }),
|
||||
expect.objectContaining({ id: 'RULE1' }),
|
||||
])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'reverse-规则组1' }))
|
||||
await user.click(getCard('优先级规则组').getByRole('button', { name: '保存' }))
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/UserFilterRuleGroups', [
|
||||
expect.objectContaining({ name: '规则组3' }),
|
||||
expect.objectContaining({ name: '规则组1' }),
|
||||
])
|
||||
|
||||
await user.click(getCard('下载规则').getByRole('button', { name: '保存' }))
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/TorrentsPriority', ['site', 'seeder'])
|
||||
})
|
||||
|
||||
it('adds unique automatic names and removes the selected items', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderRuleSettings()
|
||||
await screen.findByText('RULE1 / 规则1')
|
||||
|
||||
await user.click(getCommandButtons('自定义规则')[1])
|
||||
expect(screen.getByText('RULE4 / 规则4')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'remove-custom-RULE1' }))
|
||||
expect(screen.queryByText('RULE1 / 规则1')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(getCommandButtons('优先级规则组')[1])
|
||||
expect(screen.getByText('规则组4')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'remove-group-规则组1' }))
|
||||
expect(screen.queryByText('规则组1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks empty and duplicate custom rule identifiers or names', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderRuleSettings()
|
||||
await screen.findByText('RULE1 / 规则1')
|
||||
const save = getCard('自定义规则').getByRole('button', { name: '保存' })
|
||||
const idInput = screen.getByLabelText('custom-id-RULE1')
|
||||
const nameInput = screen.getByLabelText('custom-name-RULE1')
|
||||
|
||||
await fireEvent.update(idInput, '')
|
||||
await user.click(save)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('存在空ID的规则,无法保存,请修改!')
|
||||
|
||||
await fireEvent.update(idInput, 'RULE3')
|
||||
await user.click(save)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('存在重复规则ID!无法保存,请修改!')
|
||||
|
||||
await fireEvent.update(idInput, 'RULE1')
|
||||
await fireEvent.update(nameInput, '')
|
||||
await user.click(save)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('存在空名字的规则,无法保存,请修改!')
|
||||
|
||||
await fireEvent.update(nameInput, '规则3')
|
||||
await user.click(save)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('存在重复规则名称!无法保存,请修改!')
|
||||
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks empty and duplicate rule group names', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderRuleSettings()
|
||||
await screen.findByText('规则组1')
|
||||
const save = getCard('优先级规则组').getByRole('button', { name: '保存' })
|
||||
const nameInput = screen.getByLabelText('group-name-规则组1')
|
||||
|
||||
await fireEvent.update(nameInput, '')
|
||||
await user.click(save)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('存在空名字的规则组!无法保存,请修改!')
|
||||
|
||||
await fireEvent.update(nameInput, '规则组3')
|
||||
await user.click(save)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('存在重复规则组名称!无法保存,请修改!')
|
||||
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('imports only contract fields for custom rules and rule groups', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderRuleSettings()
|
||||
await screen.findByText('规则组1')
|
||||
|
||||
await user.click(getCommandButtons('自定义规则')[2])
|
||||
getImportSave(0)('custom', {
|
||||
value: JSON.stringify([{ id: 'RULE9', name: '规则9', include: 'REMUX', unexpected: 'ignored' }]),
|
||||
})
|
||||
expect(await screen.findByText('RULE9 / 规则9')).toBeInTheDocument()
|
||||
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
||||
expect(mocks.apiPost).toHaveBeenLastCalledWith(
|
||||
'system/setting/CustomFilterRules',
|
||||
expect.arrayContaining([
|
||||
{
|
||||
id: 'RULE9',
|
||||
name: '规则9',
|
||||
include: 'REMUX',
|
||||
exclude: undefined,
|
||||
size_range: undefined,
|
||||
seeders: undefined,
|
||||
publish_time: undefined,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
await user.click(getCommandButtons('优先级规则组')[2])
|
||||
getImportSave(1)('group', {
|
||||
value: JSON.stringify([{ name: '规则组9', rule_string: 'RULE9', media_type: '电影', extra: true }]),
|
||||
})
|
||||
expect(await screen.findByText('规则组9')).toBeInTheDocument()
|
||||
await user.click(getCard('优先级规则组').getByRole('button', { name: '保存' }))
|
||||
expect(mocks.apiPost).toHaveBeenLastCalledWith(
|
||||
'system/setting/UserFilterRuleGroups',
|
||||
expect.arrayContaining([{ name: '规则组9', rule_string: 'RULE9', media_type: '电影', category: undefined }]),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed or structurally invalid imports without mutating rules', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderRuleSettings()
|
||||
await screen.findByText('RULE1 / 规则1')
|
||||
|
||||
await user.click(getCommandButtons('自定义规则')[2])
|
||||
getImportSave(0)('custom', { value: '{bad json' })
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('导入规则失败!无法解析输入的数据!')
|
||||
expect(screen.queryByText('RULE9 / 规则9')).not.toBeInTheDocument()
|
||||
|
||||
getImportSave(0)('unknown', { value: JSON.stringify([{ id: 'RULE9', name: '规则9' }]) })
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('导入规则失败!未知的数据类型!')
|
||||
expect(screen.queryByText('RULE9 / 规则9')).not.toBeInTheDocument()
|
||||
|
||||
getImportSave(0)('custom', { value: JSON.stringify([{ name: '缺少ID' }]) })
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('导入失败!发现有规则不存在ID,可能属于优先级规则组!')
|
||||
|
||||
await user.click(getCommandButtons('优先级规则组')[2])
|
||||
getImportSave(1)('group', { value: JSON.stringify([{ id: 'RULE1', name: '不合法规则组' }]) })
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('导入失败!发现有规则存在相同ID,可能属于自定义规则!')
|
||||
expect(screen.queryByText('不合法规则组')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shares and clears each collection through its command group', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderRuleSettings()
|
||||
await screen.findByText('RULE1 / 规则1')
|
||||
|
||||
await user.click(getCommandButtons('自定义规则')[3])
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith(JSON.stringify(customRulesFixture))
|
||||
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('自定义规则已复制到剪贴板!'))
|
||||
await user.click(getCommandButtons('自定义规则')[4])
|
||||
expect(screen.queryByText('RULE1 / 规则1')).not.toBeInTheDocument()
|
||||
|
||||
mocks.copyToClipboard.mockResolvedValueOnce(false)
|
||||
await user.click(getCommandButtons('优先级规则组')[3])
|
||||
await waitFor(() =>
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('优先级规则组复制失败:可能是浏览器不支持或被用户阻止!'),
|
||||
)
|
||||
await user.click(getCommandButtons('优先级规则组')[4])
|
||||
expect(screen.queryByText('规则组1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports business and HTTP failures for each save responsibility', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderRuleSettings()
|
||||
await screen.findByText('RULE1 / 规则1')
|
||||
|
||||
const responsibilities = [
|
||||
{ card: '自定义规则', failure: '自定义规则保存失败!' },
|
||||
{ card: '优先级规则组', failure: '优先级规则组保存失败!' },
|
||||
{ card: '下载规则', failure: '优先规则保存失败!' },
|
||||
]
|
||||
|
||||
for (const responsibility of responsibilities) {
|
||||
mocks.apiPost.mockResolvedValueOnce({ success: false })
|
||||
await user.click(getCard(responsibility.card).getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(responsibility.failure))
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
|
||||
mocks.toastError.mockClear()
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
|
||||
await user.click(getCard(responsibility.card).getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(responsibility.failure))
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
mocks.toastError.mockClear()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -388,6 +388,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/utils/mediaStatusCache.ts',
|
||||
'src/utils/searchStream.ts',
|
||||
'src/views/site/SiteCardListView.vue',
|
||||
'src/views/setting/AccountSettingDirectory.vue',
|
||||
'src/views/setting/AccountSettingRule.vue',
|
||||
'src/views/setting/AccountSettingSearch.vue',
|
||||
'src/views/setting/AccountSettingSite.vue',
|
||||
'src/views/setting/AccountSettingSubscribe.vue',
|
||||
@@ -447,6 +449,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/views/setting/AccountSettingDirectory.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/views/setting/AccountSettingRule.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/views/setting/AccountSettingSearch.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
|
||||
Reference in New Issue
Block a user