test(setting): cover directory and rule settings (#668)

This commit is contained in:
InfinityPacer
2026-08-12 07:17:46 +08:00
committed by GitHub
parent 155ecffd89
commit 5e58d904fe
6 changed files with 665 additions and 65 deletions

View File

@@ -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": {

View File

@@ -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'))
}
}

View File

@@ -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('优先规则保存失败!')
}
}

View File

@@ -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,
)
})
})
})

View 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()
}
})
})

View File

@@ -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,