mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
fix(system): harden scheduler and cache operations (#685)
This commit is contained in:
@@ -73,7 +73,7 @@ const filteredData = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 选中的缓存项
|
// 选中的缓存项
|
||||||
const selectedItems = ref<string[]>([])
|
const selectedItems = ref<TorrentCacheItem[]>([])
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -182,13 +182,9 @@ async function deleteSelectedItems() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const deletePromises = selectedItems.value.map(hash => {
|
const deletePromises = selectedItems.value.map(item =>
|
||||||
const item = cacheData.value.data.find(d => d.hash === hash)
|
api.delete<null>(`torrent/cache/${item.domain}/${item.hash}`, { feedback: 'silent' }),
|
||||||
if (item) {
|
)
|
||||||
return api.delete<null>(`torrent/cache/${item.domain}/${hash}`, { feedback: 'silent' })
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all(deletePromises)
|
await Promise.all(deletePromises)
|
||||||
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: selectedItems.value.length }))
|
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: selectedItems.value.length }))
|
||||||
@@ -209,8 +205,8 @@ async function deleteSingleItem(item: TorrentCacheItem) {
|
|||||||
await api.delete<null>(`torrent/cache/${item.domain}/${item.hash}`, { feedback: 'silent' })
|
await api.delete<null>(`torrent/cache/${item.domain}/${item.hash}`, { feedback: 'silent' })
|
||||||
$toast.success(t('setting.cache.deleteSuccess'))
|
$toast.success(t('setting.cache.deleteSuccess'))
|
||||||
await loadCacheData()
|
await loadCacheData()
|
||||||
// 从选中列表中移除
|
const itemIdentity = getCacheItemIdentity(item)
|
||||||
const index = selectedItems.value.indexOf(item.hash)
|
const index = selectedItems.value.findIndex(selectedItem => getCacheItemIdentity(selectedItem) === itemIdentity)
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
selectedItems.value.splice(index, 1)
|
selectedItems.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
@@ -222,6 +218,11 @@ async function deleteSingleItem(item: TorrentCacheItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 组合站点和内容哈希,确保跨站点相同资源仍可独立选择。 */
|
||||||
|
function getCacheItemIdentity(item: TorrentCacheItem): string {
|
||||||
|
return JSON.stringify([item.domain, item.hash])
|
||||||
|
}
|
||||||
|
|
||||||
/** 打开重新识别对话框。 */
|
/** 打开重新识别对话框。 */
|
||||||
function openReidentifyDialog(item: TorrentCacheItem) {
|
function openReidentifyDialog(item: TorrentCacheItem) {
|
||||||
currentReidentifyItem.value = item
|
currentReidentifyItem.value = item
|
||||||
@@ -319,7 +320,7 @@ function getMobileMediaTypeChipClass(type: string): string {
|
|||||||
|
|
||||||
/** 生成移动端缓存卡片的稳定渲染键。 */
|
/** 生成移动端缓存卡片的稳定渲染键。 */
|
||||||
function getMobileCacheItemKey(item: TorrentCacheItem, index: number): string {
|
function getMobileCacheItemKey(item: TorrentCacheItem, index: number): string {
|
||||||
return item.hash || [item.domain, item.title, index].join('-')
|
return item.hash ? getCacheItemIdentity(item) : [item.domain, item.title, index].join('-')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取移动端缓存卡片使用的媒体标题。 */
|
/** 获取移动端缓存卡片使用的媒体标题。 */
|
||||||
@@ -655,7 +656,8 @@ watch([titleFilter, siteFilter], () => {
|
|||||||
]"
|
]"
|
||||||
:items="filteredData"
|
:items="filteredData"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
item-value="hash"
|
:item-value="getCacheItemIdentity"
|
||||||
|
return-object
|
||||||
show-select
|
show-select
|
||||||
hover
|
hover
|
||||||
fixed-header
|
fixed-header
|
||||||
|
|||||||
@@ -79,17 +79,15 @@ function getMobileSchedulerStatusText(scheduler: ScheduleInfo) {
|
|||||||
return getDisplayedSchedulerStatusText(scheduler)
|
return getDisplayedSchedulerStatusText(scheduler)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行指定定时服务,并在短延迟后刷新列表。 */
|
/** 执行指定定时服务,并在请求成功后的短延迟刷新列表。 */
|
||||||
function runCommand(id: string) {
|
async function runCommand(id: string) {
|
||||||
try {
|
try {
|
||||||
// 异步提交
|
await api.get('system/runscheduler', {
|
||||||
api.get('system/runscheduler', {
|
|
||||||
params: {
|
params: {
|
||||||
jobid: id,
|
jobid: id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
$toast.success(t('setting.scheduler.executeSuccess'))
|
$toast.success(t('setting.scheduler.executeSuccess'))
|
||||||
// 1秒后刷新数据
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadSchedulerList()
|
loadSchedulerList()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
|
import type { TorrentCacheData, TorrentCacheItem } from '@/api/types'
|
||||||
|
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||||
import CacheView from '@/views/system/CacheView.vue'
|
import CacheView from '@/views/system/CacheView.vue'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { computed, defineComponent, h, inject, provide, ref, type InjectionKey, type PropType } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiDelete: vi.fn(),
|
||||||
apiGet: vi.fn(),
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
confirm: vi.fn(),
|
||||||
|
mobile: false,
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
toastError: vi.fn(),
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
toastWarning: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
default: {
|
default: createDataApiMock({
|
||||||
delete: vi.fn(),
|
delete: (...args: unknown[]) => mocks.apiDelete(...args),
|
||||||
get: mocks.apiGet,
|
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||||
post: vi.fn(),
|
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||||
},
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-toastification', () => ({
|
vi.mock('vue-toastification', () => ({
|
||||||
useToast: () => ({ error: mocks.toastError, success: vi.fn(), warning: vi.fn() }),
|
useToast: () => ({
|
||||||
|
error: mocks.toastError,
|
||||||
|
success: mocks.toastSuccess,
|
||||||
|
warning: mocks.toastWarning,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/usePWA', () => ({
|
vi.mock('@/composables/usePWA', () => ({
|
||||||
@@ -25,49 +43,856 @@ vi.mock('@/composables/usePWA', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useSharedDialog', () => ({
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
openSharedDialog: vi.fn(),
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('CacheView data client contract', () => {
|
vi.mock('vuetify', async importOriginal => {
|
||||||
|
const actual = await importOriginal<typeof import('vuetify')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useDisplay: () => ({ smAndDown: computed(() => mocks.mobile) }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSelectionKey: InjectionKey<(value: unknown) => void> = Symbol('cache-type-selection')
|
||||||
|
|
||||||
|
const ButtonToggleStub = defineComponent({
|
||||||
|
name: 'VBtnToggle',
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(_props, { emit, slots }) {
|
||||||
|
provide(toggleSelectionKey, value => emit('update:modelValue', value))
|
||||||
|
return () => h('div', { 'aria-label': '缓存类型切换' }, slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
name: 'VBtn',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
disabled: Boolean,
|
||||||
|
loading: Boolean,
|
||||||
|
value: {
|
||||||
|
type: [String, Number, Boolean] as PropType<string | number | boolean>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { attrs, slots }) {
|
||||||
|
const selectCacheType = inject(toggleSelectionKey, undefined)
|
||||||
|
return () => {
|
||||||
|
const { onClick, ...buttonAttrs } = attrs
|
||||||
|
return h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
...buttonAttrs,
|
||||||
|
'aria-busy': String(props.loading),
|
||||||
|
disabled: props.disabled,
|
||||||
|
onClick: [() => selectCacheType?.(props.value), onClick],
|
||||||
|
type: 'button',
|
||||||
|
},
|
||||||
|
slots.default?.(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const IconStub = defineComponent({
|
||||||
|
name: 'VIcon',
|
||||||
|
props: { icon: String },
|
||||||
|
template: '<span class="test-icon" :data-icon="icon"><slot /></span>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TooltipStub = defineComponent({
|
||||||
|
name: 'VTooltip',
|
||||||
|
template: '<span><slot /></span>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TextFieldStub = defineComponent({
|
||||||
|
name: 'VTextField',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
label: String,
|
||||||
|
modelValue: String,
|
||||||
|
placeholder: String,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () =>
|
||||||
|
h('input', {
|
||||||
|
'aria-label': attrs['aria-label'] ?? props.label ?? props.placeholder,
|
||||||
|
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value || null),
|
||||||
|
value: props.modelValue ?? '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const AutocompleteStub = defineComponent({
|
||||||
|
name: 'VAutocomplete',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<string[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
label: String,
|
||||||
|
modelValue: String,
|
||||||
|
placeholder: String,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'select',
|
||||||
|
{
|
||||||
|
'aria-label': attrs['aria-label'] ?? props.label ?? props.placeholder,
|
||||||
|
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value || null),
|
||||||
|
value: props.modelValue ?? '',
|
||||||
|
},
|
||||||
|
[h('option', { value: '' }, '全部站点'), ...props.items.map(site => h('option', { value: site }, site))],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
name: 'VDataTable',
|
||||||
|
props: {
|
||||||
|
itemValue: {
|
||||||
|
type: [String, Function] as PropType<string | ((item: TorrentCacheItem) => unknown)>,
|
||||||
|
default: 'id',
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<TorrentCacheItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
loading: Boolean,
|
||||||
|
modelValue: {
|
||||||
|
type: Array as PropType<TorrentCacheItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
returnObject: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit, slots }) {
|
||||||
|
function selectCurrentItems() {
|
||||||
|
const selectedByValue = new Map<unknown, TorrentCacheItem>()
|
||||||
|
props.items.forEach(item => {
|
||||||
|
const value =
|
||||||
|
typeof props.itemValue === 'function'
|
||||||
|
? props.itemValue(item)
|
||||||
|
: item[props.itemValue as keyof TorrentCacheItem]
|
||||||
|
if (!selectedByValue.has(value)) selectedByValue.set(value, item)
|
||||||
|
})
|
||||||
|
emit('update:modelValue', props.returnObject ? [...selectedByValue.values()] : [...selectedByValue.keys()])
|
||||||
|
}
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('section', { 'aria-label': '缓存列表' }, [
|
||||||
|
h('output', { 'aria-label': '缓存加载状态' }, String(props.loading)),
|
||||||
|
h(
|
||||||
|
'output',
|
||||||
|
{ 'aria-label': '缓存选择集合' },
|
||||||
|
JSON.stringify(props.modelValue.map(item => [item.domain, item.hash])),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
onClick: selectCurrentItems,
|
||||||
|
type: 'button',
|
||||||
|
},
|
||||||
|
'选择当前结果',
|
||||||
|
),
|
||||||
|
...props.items.map(item =>
|
||||||
|
h('article', { 'data-cache-hash': item.hash, 'data-cache-site': item.site_name }, [
|
||||||
|
h('span', item.title),
|
||||||
|
...(slots['item.actions']?.({ item }) ?? []),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
props.items.length === 0 ? slots['no-data']?.({}) : null,
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||||
|
|
||||||
|
const InfiniteScrollStub = defineComponent({
|
||||||
|
name: 'VInfiniteScroll',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<TorrentCacheItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['load'],
|
||||||
|
setup(props, { emit, slots }) {
|
||||||
|
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
status.value = 'loading'
|
||||||
|
emit('load', {
|
||||||
|
done(nextStatus: InfiniteScrollStatus) {
|
||||||
|
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('section', { 'aria-label': '移动缓存无限列表' }, [
|
||||||
|
h('output', { 'aria-label': '移动缓存无限列表状态' }, status.value),
|
||||||
|
h('output', { 'aria-label': '移动缓存数量' }, String(props.items.length)),
|
||||||
|
status.value === 'loading' ? slots.loading?.({}) : null,
|
||||||
|
slots.default?.(),
|
||||||
|
h('button', { onClick: load, type: 'button' }, '加载更多缓存'),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProgressiveGridStub = defineComponent({
|
||||||
|
name: 'ProgressiveCardGrid',
|
||||||
|
props: {
|
||||||
|
getItemKey: {
|
||||||
|
type: Function as PropType<(item: TorrentCacheItem, index: number) => string>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<TorrentCacheItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h('section', { 'aria-label': '移动缓存渐进网格' }, [
|
||||||
|
h(
|
||||||
|
'output',
|
||||||
|
{ 'aria-label': '移动缓存稳定键' },
|
||||||
|
JSON.stringify(props.items.map((item, index) => props.getItemKey(item, index))),
|
||||||
|
),
|
||||||
|
...props.items.flatMap(item => slots.default?.({ item }) ?? []),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const MenuStub = defineComponent({
|
||||||
|
name: 'VMenu',
|
||||||
|
setup(_props, { slots }) {
|
||||||
|
return () => h('div', [slots.activator?.({ props: {} }), slots.default?.()])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ListItemStub = defineComponent({
|
||||||
|
name: 'VListItem',
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup(_props, { attrs, slots }) {
|
||||||
|
return () => h('button', { ...attrs, type: 'button' }, [slots.prepend?.(), slots.default?.()])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ImageStub = defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
props: {
|
||||||
|
alt: String,
|
||||||
|
src: String,
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => h('img', { alt: props.alt, src: props.src })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PassthroughStub = defineComponent({
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup(_props, { attrs, slots }) {
|
||||||
|
return () => h('div', attrs, slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const RecognitionCachePanelStub = defineComponent({
|
||||||
|
name: 'RecognitionCachePanel',
|
||||||
|
template: '<section>识别缓存面板</section>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
ProgressiveCardGrid: ProgressiveGridStub,
|
||||||
|
RecognitionCachePanel: RecognitionCachePanelStub,
|
||||||
|
VAutocomplete: AutocompleteStub,
|
||||||
|
VBtn: ButtonStub,
|
||||||
|
VBtnToggle: ButtonToggleStub,
|
||||||
|
VCol: defineComponent({ template: '<div><slot /></div>' }),
|
||||||
|
VDataTable: DataTableStub,
|
||||||
|
VIcon: IconStub,
|
||||||
|
VImg: ImageStub,
|
||||||
|
VInfiniteScroll: InfiniteScrollStub,
|
||||||
|
VList: PassthroughStub,
|
||||||
|
VListItem: ListItemStub,
|
||||||
|
VListItemTitle: PassthroughStub,
|
||||||
|
VMenu: MenuStub,
|
||||||
|
VRow: defineComponent({ template: '<div><slot /></div>' }),
|
||||||
|
VChip: PassthroughStub,
|
||||||
|
VTextField: TextFieldStub,
|
||||||
|
VTooltip: TooltipStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCacheItem(overrides: Partial<TorrentCacheItem> = {}): TorrentCacheItem {
|
||||||
|
return {
|
||||||
|
domain: 'alpha.example',
|
||||||
|
hash: 'alpha-hash',
|
||||||
|
size: 1024,
|
||||||
|
title: 'Alpha.Movie.2026',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCacheData(items: TorrentCacheItem[]): TorrentCacheData {
|
||||||
|
return {
|
||||||
|
count: items.length,
|
||||||
|
sites: new Set(items.map(item => item.domain)).size,
|
||||||
|
data: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function success<T>(data: T) {
|
||||||
|
return { data, message: '', success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function businessFailure(message = '业务失败') {
|
||||||
|
return { data: null, message, success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
reject = promiseReject
|
||||||
|
resolve = promiseResolve
|
||||||
|
})
|
||||||
|
return { promise, reject, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconButton(icon: string, index = 0) {
|
||||||
|
const icons = [
|
||||||
|
...document.querySelectorAll(`[data-icon="${icon}"]`),
|
||||||
|
...Array.from(document.querySelectorAll('.test-icon')).filter(element => element.textContent?.trim() === icon),
|
||||||
|
]
|
||||||
|
const button = icons[index]?.closest('button')
|
||||||
|
expect(button).not.toBeNull()
|
||||||
|
return button as HTMLButtonElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDialogController() {
|
||||||
|
return {
|
||||||
|
close: vi.fn(),
|
||||||
|
id: 1,
|
||||||
|
updateProps: vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCache() {
|
||||||
|
return renderWithProviders(CacheView, {
|
||||||
|
global: { stubs },
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForInitialLoad() {
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('torrent/cache'))
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('false'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CacheView cache data and filtering', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.apiGet.mockReset()
|
mocks.apiDelete.mockReset().mockResolvedValue(success(null))
|
||||||
|
mocks.apiGet.mockReset().mockResolvedValue(success(createCacheData([])))
|
||||||
|
mocks.apiPost.mockReset().mockResolvedValue(success(null))
|
||||||
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
|
mocks.mobile = false
|
||||||
|
mocks.openSharedDialog.mockReset().mockReturnValue(createDialogController())
|
||||||
mocks.toastError.mockReset()
|
mocks.toastError.mockReset()
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
mocks.toastWarning.mockReset()
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the unwrapped torrent cache data returned by the default API client', async () => {
|
it('loads unwrapped cache data and filters titles case-insensitively and sites by exact source', async () => {
|
||||||
mocks.apiGet.mockResolvedValue({
|
const items = [
|
||||||
count: 1,
|
createCacheItem({ site_name: 'Zulu Tracker' }),
|
||||||
sites: 1,
|
createCacheItem({
|
||||||
data: [
|
domain: 'beta.example',
|
||||||
{
|
hash: 'beta-hash',
|
||||||
domain: 'example.com',
|
site_name: 'Alpha Tracker',
|
||||||
hash: 'cache-entry-1',
|
title: 'Beta.Show.S01',
|
||||||
media_name: '已识别媒体',
|
}),
|
||||||
site_name: '示例站点',
|
createCacheItem({ domain: 'gamma.example', hash: 'gamma-hash', site_name: 'Zulu Tracker', title: 'Gamma.Album' }),
|
||||||
size: 1024,
|
]
|
||||||
title: '缓存直返条目',
|
mocks.apiGet.mockResolvedValue(success(createCacheData(items)))
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await renderWithProviders(CacheView, {
|
await renderCache()
|
||||||
global: {
|
await waitForInitialLoad()
|
||||||
stubs: {
|
|
||||||
VDataTable: {
|
|
||||||
props: ['items'],
|
|
||||||
template: '<div><span v-for="item in items" :key="item.hash">{{ item.title }}</span></div>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
initialState: {
|
|
||||||
globalSettings: {
|
|
||||||
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('torrent/cache', { feedback: 'silent' }))
|
expect(screen.getByText('Alpha.Movie.2026')).toBeInTheDocument()
|
||||||
expect(await screen.findByText('缓存直返条目')).toBeInTheDocument()
|
expect(screen.getByText('Beta.Show.S01')).toBeInTheDocument()
|
||||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
expect(screen.getByText('Gamma.Album')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('按标题筛选'), 'bETA')
|
||||||
|
expect(screen.getByText('Beta.Show.S01')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Alpha.Movie.2026')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('按标题筛选'), '')
|
||||||
|
const siteFilter = screen.getByLabelText('按站点筛选') as HTMLSelectElement
|
||||||
|
expect(Array.from(siteFilter.options).map(option => option.value)).toEqual(['', 'Alpha Tracker', 'Zulu Tracker'])
|
||||||
|
await fireEvent.update(siteFilter, 'Zulu Tracker')
|
||||||
|
expect(screen.getByText('Alpha.Movie.2026')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Gamma.Album')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Beta.Show.S01')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches between torrent and recognition cache managers without issuing another torrent request', async () => {
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '识别缓存' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('识别缓存面板')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText('缓存列表')).not.toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['业务失败', () => mocks.apiGet.mockResolvedValueOnce(businessFailure('读取失败'))],
|
||||||
|
['HTTP 失败', () => mocks.apiGet.mockRejectedValueOnce(new Error('network down'))],
|
||||||
|
])('restores loading and reports an initial %s', async (_label, arrangeFailure) => {
|
||||||
|
arrangeFailure()
|
||||||
|
|
||||||
|
await renderCache()
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('加载缓存数据失败'))
|
||||||
|
expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('false')
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CacheView mobile cache list', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiDelete.mockReset().mockResolvedValue(success(null))
|
||||||
|
mocks.apiGet.mockReset().mockResolvedValue(success(createCacheData([])))
|
||||||
|
mocks.apiPost.mockReset().mockResolvedValue(success(null))
|
||||||
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
|
mocks.mobile = true
|
||||||
|
mocks.openSharedDialog.mockReset().mockReturnValue(createDialogController())
|
||||||
|
mocks.toastError.mockReset()
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
mocks.toastWarning.mockReset()
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 20 items initially, appends the next page, and resets pagination after filtering', async () => {
|
||||||
|
const items = Array.from({ length: 45 }, (_, index) => {
|
||||||
|
const itemNumber = index + 1
|
||||||
|
return createCacheItem({
|
||||||
|
domain: `site-${itemNumber}.example`,
|
||||||
|
hash: `cache-${itemNumber}`,
|
||||||
|
title: itemNumber <= 25 ? `Match.Item.${itemNumber}` : `Other.Item.${itemNumber}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mocks.apiGet.mockResolvedValue(success(createCacheData(items)))
|
||||||
|
await renderCache()
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText('移动缓存数量')).toHaveTextContent('20')
|
||||||
|
const initialStableKeys = JSON.parse(screen.getByLabelText('移动缓存稳定键').textContent ?? '[]')
|
||||||
|
expect(initialStableKeys[0]).toBe(JSON.stringify(['site-1.example', 'cache-1']))
|
||||||
|
expect(initialStableKeys[19]).toBe(JSON.stringify(['site-20.example', 'cache-20']))
|
||||||
|
expect(screen.queryByText('Match.Item.21')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '加载更多缓存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('移动缓存数量')).toHaveTextContent('40'))
|
||||||
|
expect(screen.getByText('Other.Item.40')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('移动缓存无限列表状态')).toHaveTextContent('idle')
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText('按标题筛选'), 'match')
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('移动缓存数量')).toHaveTextContent('20'))
|
||||||
|
expect(screen.getByText('Match.Item.20')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Match.Item.21')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '加载更多缓存' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('移动缓存数量')).toHaveTextContent('25'))
|
||||||
|
expect(screen.getByText('Match.Item.25')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('移动缓存无限列表状态')).toHaveTextContent('empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps rich cache metadata and a stable key into the mobile card', async () => {
|
||||||
|
const pubdate = '2026-08-18 12:00:00'
|
||||||
|
const item = createCacheItem({
|
||||||
|
hash: 'rich-cache-key',
|
||||||
|
media_name: 'Rich Media',
|
||||||
|
media_type: 'movie',
|
||||||
|
media_year: '2026',
|
||||||
|
page_url: 'https://tracker.example/details/42',
|
||||||
|
poster_path: 'https://images.example/poster.jpg',
|
||||||
|
pubdate,
|
||||||
|
resource_term: 'WEB-DL',
|
||||||
|
season_episode: 'S01E02',
|
||||||
|
site_name: 'Rich Tracker',
|
||||||
|
size: 1024 * 1024,
|
||||||
|
title: 'Rich.Torrent.Title',
|
||||||
|
})
|
||||||
|
mocks.apiGet.mockResolvedValue(success(createCacheData([item])))
|
||||||
|
const openPage = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
await renderCache()
|
||||||
|
|
||||||
|
expect(await screen.findByText('Rich.Torrent.Title')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('移动缓存稳定键')).toHaveTextContent(
|
||||||
|
JSON.stringify([JSON.stringify(['alpha.example', 'rich-cache-key'])]),
|
||||||
|
)
|
||||||
|
expect(screen.getByText('电影')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Rich Media')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2026 · S01E02')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(`${formatDateDifference(pubdate)} · WEB-DL · Rich Tracker`)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(formatFileSize(item.size))).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('img', { name: 'Rich Media' })).toHaveAttribute('src', 'https://images.example/poster.jpg')
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /在新窗口.*打开/ }))
|
||||||
|
|
||||||
|
expect(openPage).toHaveBeenCalledWith('https://tracker.example/details/42', '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps mobile cards distinct when multiple sites return the same content hash', async () => {
|
||||||
|
const items = [
|
||||||
|
createCacheItem({ domain: 'alpha.example', hash: 'shared-hash', title: 'Alpha.Shared.Release' }),
|
||||||
|
createCacheItem({ domain: 'beta.example', hash: 'shared-hash', title: 'Beta.Shared.Release' }),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockResolvedValue(success(createCacheData(items)))
|
||||||
|
await renderCache()
|
||||||
|
|
||||||
|
expect(await screen.findByText('Alpha.Shared.Release')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Beta.Shared.Release')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('移动缓存稳定键')).toHaveTextContent(
|
||||||
|
JSON.stringify(items.map(item => JSON.stringify([item.domain, item.hash]))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CacheView cache operations', () => {
|
||||||
|
const initialItems = [
|
||||||
|
createCacheItem({ site_name: 'Alpha Tracker' }),
|
||||||
|
createCacheItem({ domain: 'beta.example', hash: 'beta-hash', site_name: 'Beta Tracker', title: 'Beta.Show.S01' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiDelete.mockReset().mockResolvedValue(success(null))
|
||||||
|
mocks.apiGet.mockReset().mockResolvedValue(success(createCacheData(initialItems)))
|
||||||
|
mocks.apiPost.mockReset().mockResolvedValue(success(null))
|
||||||
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
|
mocks.mobile = false
|
||||||
|
mocks.openSharedDialog.mockReset().mockReturnValue(createDialogController())
|
||||||
|
mocks.toastError.mockReset()
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
mocks.toastWarning.mockReset()
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires confirmation before clearing and leaves the cache untouched when cancelled', async () => {
|
||||||
|
mocks.confirm.mockResolvedValue(false)
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-delete-variant'))
|
||||||
|
|
||||||
|
expect(mocks.confirm).toHaveBeenCalledWith({
|
||||||
|
content: '确认清空所有缓存吗?',
|
||||||
|
title: '确认',
|
||||||
|
type: 'warn',
|
||||||
|
})
|
||||||
|
expect(mocks.apiDelete).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByText('Alpha.Movie.2026')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps clear loading visible, reloads after success, and resets the selection collection', async () => {
|
||||||
|
const clearRequest = deferred<ReturnType<typeof success<null>>>()
|
||||||
|
mocks.apiDelete.mockReturnValueOnce(clearRequest.promise)
|
||||||
|
mocks.apiGet
|
||||||
|
.mockResolvedValueOnce(success(createCacheData(initialItems)))
|
||||||
|
.mockResolvedValueOnce(success(createCacheData([])))
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前结果' }))
|
||||||
|
|
||||||
|
const clearButton = getIconButton('mdi-delete-variant')
|
||||||
|
await fireEvent.click(clearButton)
|
||||||
|
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith('torrent/cache'))
|
||||||
|
expect(clearButton).toHaveAttribute('aria-busy', 'true')
|
||||||
|
|
||||||
|
clearRequest.resolve(success(null))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('缓存清理完成'))
|
||||||
|
expect(screen.getByLabelText('缓存选择集合')).toHaveTextContent('[]')
|
||||||
|
expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('false')
|
||||||
|
expect(screen.queryByText('Alpha.Movie.2026')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['业务失败', () => mocks.apiDelete.mockResolvedValueOnce(businessFailure('清理失败'))],
|
||||||
|
['HTTP 失败', () => mocks.apiDelete.mockRejectedValueOnce(new Error('network down'))],
|
||||||
|
])('preserves selections after a clear %s and allows a successful retry', async (_label, arrangeFailure) => {
|
||||||
|
mocks.apiGet
|
||||||
|
.mockResolvedValueOnce(success(createCacheData(initialItems)))
|
||||||
|
.mockResolvedValueOnce(success(createCacheData([])))
|
||||||
|
arrangeFailure()
|
||||||
|
mocks.apiDelete.mockResolvedValueOnce(success(null))
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前结果' }))
|
||||||
|
|
||||||
|
const clearButton = getIconButton('mdi-delete-variant')
|
||||||
|
await fireEvent.click(clearButton)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('清理缓存失败'))
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.getByLabelText('缓存选择集合')).toHaveTextContent(
|
||||||
|
'[["alpha.example","alpha-hash"],["beta.example","beta-hash"]]',
|
||||||
|
)
|
||||||
|
expect(clearButton).toHaveAttribute('aria-busy', 'false')
|
||||||
|
|
||||||
|
await fireEvent.click(clearButton)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledTimes(2))
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('缓存选择集合')).toHaveTextContent('[]'))
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('缓存清理完成')
|
||||||
|
expect(clearButton).toHaveAttribute('aria-busy', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['业务失败', () => mocks.apiPost.mockResolvedValueOnce(businessFailure('刷新失败'))],
|
||||||
|
['HTTP 失败', () => mocks.apiPost.mockRejectedValueOnce(new Error('network down'))],
|
||||||
|
])('recovers from a refresh %s and allows a successful retry', async (_label, arrangeFailure) => {
|
||||||
|
const refreshed = createCacheItem({ hash: 'refreshed-hash', title: 'Refreshed.Movie.2026' })
|
||||||
|
mocks.apiGet
|
||||||
|
.mockResolvedValueOnce(success(createCacheData(initialItems)))
|
||||||
|
.mockResolvedValueOnce(success(createCacheData([refreshed])))
|
||||||
|
arrangeFailure()
|
||||||
|
mocks.apiPost.mockResolvedValueOnce(success(null))
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
|
||||||
|
const refreshButton = getIconButton('mdi-refresh')
|
||||||
|
await fireEvent.click(refreshButton)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('刷新缓存失败'))
|
||||||
|
expect(refreshButton).toHaveAttribute('aria-busy', 'false')
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await fireEvent.click(refreshButton)
|
||||||
|
|
||||||
|
expect(await screen.findByText('Refreshed.Movie.2026')).toBeInTheDocument()
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('缓存刷新完成')
|
||||||
|
expect(refreshButton).toHaveAttribute('aria-busy', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps the selected cache items to their domain endpoints, reloads, and clears successful selections', async () => {
|
||||||
|
mocks.apiGet
|
||||||
|
.mockResolvedValueOnce(success(createCacheData(initialItems)))
|
||||||
|
.mockResolvedValueOnce(success(createCacheData([])))
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前结果' }))
|
||||||
|
expect(screen.getByLabelText('缓存选择集合')).toHaveTextContent(
|
||||||
|
'[["alpha.example","alpha-hash"],["beta.example","beta-hash"]]',
|
||||||
|
)
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-delete-sweep-outline'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledTimes(2))
|
||||||
|
expect(mocks.apiDelete).toHaveBeenNthCalledWith(1, 'torrent/cache/alpha.example/alpha-hash')
|
||||||
|
expect(mocks.apiDelete).toHaveBeenNthCalledWith(2, 'torrent/cache/beta.example/beta-hash')
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('成功删除 2 个缓存项'))
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('缓存选择集合')).toHaveTextContent('[]'))
|
||||||
|
expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes every selected site entry when the backend returns the same content hash for multiple domains', async () => {
|
||||||
|
const duplicatedAcrossSites = [
|
||||||
|
createCacheItem({ domain: 'alpha.example', hash: 'shared-hash', site_name: 'Alpha Tracker' }),
|
||||||
|
createCacheItem({
|
||||||
|
domain: 'beta.example',
|
||||||
|
hash: 'shared-hash',
|
||||||
|
site_name: 'Beta Tracker',
|
||||||
|
title: 'Alpha.Movie.2026',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockResolvedValueOnce(success(createCacheData(duplicatedAcrossSites)))
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前结果' }))
|
||||||
|
expect(screen.getByLabelText('缓存选择集合')).toHaveTextContent(
|
||||||
|
'[["alpha.example","shared-hash"],["beta.example","shared-hash"]]',
|
||||||
|
)
|
||||||
|
await fireEvent.click(getIconButton('mdi-delete-sweep-outline'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledTimes(2))
|
||||||
|
expect(mocks.apiDelete).toHaveBeenCalledWith('torrent/cache/alpha.example/shared-hash')
|
||||||
|
expect(mocks.apiDelete).toHaveBeenCalledWith('torrent/cache/beta.example/shared-hash')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps selections and restores loading when one selected deletion is a business failure', async () => {
|
||||||
|
mocks.apiDelete.mockImplementation(endpoint =>
|
||||||
|
Promise.resolve(endpoint === 'torrent/cache/beta.example/beta-hash' ? businessFailure('未找到') : success(null)),
|
||||||
|
)
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前结果' }))
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-delete-sweep-outline'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('删除缓存项失败'))
|
||||||
|
expect(mocks.apiDelete).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.getByLabelText('缓存选择集合')).toHaveTextContent(
|
||||||
|
'[["alpha.example","alpha-hash"],["beta.example","beta-hash"]]',
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('false')
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recovers from a single-delete HTTP failure and succeeds on retry', async () => {
|
||||||
|
mocks.apiDelete.mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce(success(null))
|
||||||
|
mocks.apiGet
|
||||||
|
.mockResolvedValueOnce(success(createCacheData(initialItems)))
|
||||||
|
.mockResolvedValueOnce(success(createCacheData([initialItems[1]])))
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前结果' }))
|
||||||
|
|
||||||
|
const firstItem = screen.getByText('Alpha.Movie.2026').closest('article')
|
||||||
|
expect(firstItem).not.toBeNull()
|
||||||
|
const deleteButton = within(firstItem as HTMLElement)
|
||||||
|
.getByText('mdi-delete')
|
||||||
|
.closest('button') as HTMLButtonElement
|
||||||
|
await fireEvent.click(deleteButton)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('删除缓存项失败'))
|
||||||
|
expect(screen.getByText('Alpha.Movie.2026')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('false')
|
||||||
|
|
||||||
|
await fireEvent.click(deleteButton)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiDelete).toHaveBeenNthCalledWith(2, 'torrent/cache/alpha.example/alpha-hash'))
|
||||||
|
expect(await screen.findByText('Beta.Show.S01')).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(screen.queryByText('Alpha.Movie.2026')).not.toBeInTheDocument())
|
||||||
|
expect(screen.getByLabelText('缓存选择集合')).toHaveTextContent('[["beta.example","beta-hash"]]')
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('缓存项删除成功')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CacheView reidentification', () => {
|
||||||
|
const item = createCacheItem({
|
||||||
|
media_source: 'doubanmusic',
|
||||||
|
music_type: 'album',
|
||||||
|
site_name: 'Alpha Tracker',
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiDelete.mockReset().mockResolvedValue(success(null))
|
||||||
|
mocks.apiGet.mockReset().mockResolvedValue(success(createCacheData([item])))
|
||||||
|
mocks.apiPost.mockReset().mockResolvedValue(success(null))
|
||||||
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
|
mocks.mobile = false
|
||||||
|
mocks.openSharedDialog.mockReset().mockReturnValue(createDialogController())
|
||||||
|
mocks.toastError.mockReset()
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
mocks.toastWarning.mockReset()
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens with the item source/type and forwards explicit identity while exposing dialog loading', async () => {
|
||||||
|
const controller = createDialogController()
|
||||||
|
const reidentifyRequest = deferred<ReturnType<typeof success<null>>>()
|
||||||
|
mocks.openSharedDialog.mockReturnValue(controller)
|
||||||
|
mocks.apiPost.mockReturnValueOnce(reidentifyRequest.promise)
|
||||||
|
mocks.apiGet
|
||||||
|
.mockResolvedValueOnce(success(createCacheData([item])))
|
||||||
|
.mockResolvedValueOnce(success(createCacheData([item])))
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-text-recognition'))
|
||||||
|
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
|
||||||
|
unknown,
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, (payload?: unknown) => Promise<void> | void>,
|
||||||
|
]
|
||||||
|
expect(dialogProps).toMatchObject({
|
||||||
|
itemTitle: 'Alpha.Movie.2026',
|
||||||
|
loading: false,
|
||||||
|
musicType: 'album',
|
||||||
|
recognizeSource: 'doubanmusic',
|
||||||
|
})
|
||||||
|
|
||||||
|
const action = dialogEvents.confirm({ mediaId: 'music-42', mediaSource: 'musicbrainz', musicType: 'album' })
|
||||||
|
await waitFor(() => expect(controller.updateProps).toHaveBeenCalledWith({ loading: true }))
|
||||||
|
expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('true')
|
||||||
|
|
||||||
|
reidentifyRequest.resolve(success(null))
|
||||||
|
await action
|
||||||
|
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('torrent/cache/reidentify/alpha.example/alpha-hash', null, {
|
||||||
|
params: { media_id: 'music-42', media_source: 'musicbrainz', music_type: 'album' },
|
||||||
|
})
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('重新识别完成')
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||||
|
expect(controller.close).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the global source and omits an unpaired source while preserving music type', async () => {
|
||||||
|
const itemWithoutIdentity = createCacheItem({ media_source: undefined, music_type: undefined })
|
||||||
|
mocks.apiGet.mockResolvedValue(success(createCacheData([itemWithoutIdentity])))
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-text-recognition'))
|
||||||
|
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
|
||||||
|
unknown,
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, (payload?: unknown) => Promise<void> | void>,
|
||||||
|
]
|
||||||
|
expect(dialogProps).toMatchObject({ musicType: 'recording', recognizeSource: 'themoviedb' })
|
||||||
|
|
||||||
|
await dialogEvents.confirm({ mediaSource: 'douban', musicType: 'recording' })
|
||||||
|
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('torrent/cache/reidentify/alpha.example/alpha-hash', null, {
|
||||||
|
params: { music_type: 'recording' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['业务失败', () => mocks.apiPost.mockResolvedValueOnce(businessFailure('重新识别失败'))],
|
||||||
|
['HTTP 失败', () => mocks.apiPost.mockRejectedValueOnce(new Error('network down'))],
|
||||||
|
])('keeps the dialog open and restores loading after a reidentification %s', async (_label, arrangeFailure) => {
|
||||||
|
const controller = createDialogController()
|
||||||
|
mocks.openSharedDialog.mockReturnValue(controller)
|
||||||
|
arrangeFailure()
|
||||||
|
await renderCache()
|
||||||
|
await waitForInitialLoad()
|
||||||
|
await fireEvent.click(getIconButton('mdi-text-recognition'))
|
||||||
|
const dialogEvents = mocks.openSharedDialog.mock.calls[0]?.[2] as Record<
|
||||||
|
string,
|
||||||
|
(payload?: unknown) => Promise<void> | void
|
||||||
|
>
|
||||||
|
|
||||||
|
await dialogEvents.confirm({})
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('重新识别失败')
|
||||||
|
expect(controller.close).not.toHaveBeenCalled()
|
||||||
|
expect(controller.updateProps).toHaveBeenNthCalledWith(1, { loading: true })
|
||||||
|
expect(controller.updateProps).toHaveBeenLastCalledWith({ loading: false })
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.getByLabelText('缓存加载状态')).toHaveTextContent('false')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import LoggingView from '@/views/system/LoggingView.vue'
|
||||||
|
import { fireEvent, screen } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
addMessageListener: vi.fn(),
|
||||||
|
removeMessageListener: vi.fn(),
|
||||||
|
useSSE: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useBackground', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
|
||||||
|
return {
|
||||||
|
useBackground: () => ({
|
||||||
|
useSSE: (...args: unknown[]) => {
|
||||||
|
mocks.useSSE(...args)
|
||||||
|
return {
|
||||||
|
isConnected: ref(false),
|
||||||
|
manager: {
|
||||||
|
addMessageListener: mocks.addMessageListener,
|
||||||
|
removeMessageListener: mocks.removeMessageListener,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LoggingView', () => {
|
||||||
|
let scrollTo: ReturnType<typeof vi.fn>
|
||||||
|
const originalScrollTo = HTMLElement.prototype.scrollTo
|
||||||
|
|
||||||
|
async function advanceTimers(ms: number) {
|
||||||
|
vi.advanceTimersByTime(ms)
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderLogging(logfile = 'moviepilot.log') {
|
||||||
|
return renderWithProviders(LoggingView, {
|
||||||
|
props: { logfile },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
LoadingBanner: {
|
||||||
|
props: ['text'],
|
||||||
|
template: '<div>{{ text }}</div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountReady(logfile = 'moviepilot.log') {
|
||||||
|
const result = await renderLogging(logfile)
|
||||||
|
await advanceTimers(200)
|
||||||
|
const handler = mocks.useSSE.mock.calls.at(-1)?.[1] as (event: MessageEvent) => void
|
||||||
|
expect(handler).toEqual(expect.any(Function))
|
||||||
|
return { ...result, handler }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitAndFlush(handler: (event: MessageEvent) => void, data: unknown) {
|
||||||
|
handler(new MessageEvent('message', { data }))
|
||||||
|
await advanceTimers(80)
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordBodies(container: Element) {
|
||||||
|
return [...container.querySelectorAll('.logging-record-body')].map(element => element.textContent?.trim() ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
mocks.addMessageListener.mockReset()
|
||||||
|
mocks.removeMessageListener.mockReset()
|
||||||
|
mocks.useSSE.mockReset()
|
||||||
|
scrollTo = vi.fn()
|
||||||
|
HTMLElement.prototype.scrollTo = scrollTo
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
HTMLElement.prototype.scrollTo = originalScrollTo
|
||||||
|
})
|
||||||
|
|
||||||
|
it('先展示初始化状态,延迟结束后进入等待日志状态并忽略空消息', async () => {
|
||||||
|
await renderLogging()
|
||||||
|
|
||||||
|
expect(screen.getByText('正在初始化 ...')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await advanceTimers(199)
|
||||||
|
expect(screen.getByText('正在初始化 ...')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await advanceTimers(1)
|
||||||
|
const handler = mocks.useSSE.mock.calls[0][1] as (event: MessageEvent) => void
|
||||||
|
handler(new MessageEvent('message', { data: '' }))
|
||||||
|
await advanceTimers(80)
|
||||||
|
|
||||||
|
expect(screen.getByText('等待日志输出...')).toBeInTheDocument()
|
||||||
|
expect(document.querySelectorAll('.logging-record-line')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('把编码后的日志文件名、listener identity 和后台恢复参数交给 SSE 边界', async () => {
|
||||||
|
await renderLogging('plugins/example app.log')
|
||||||
|
|
||||||
|
expect(mocks.useSSE).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/system/logging?logfile=plugins%2Fexample%20app.log',
|
||||||
|
expect.any(Function),
|
||||||
|
'logging-plugins/example app.log',
|
||||||
|
{
|
||||||
|
backgroundCloseDelay: 5_000,
|
||||||
|
connectDelay: 300,
|
||||||
|
maxReconnectAttempts: 3,
|
||||||
|
reconnectDelay: 3_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('解析常见日志格式、规范化级别并保留无法结构化的原始行', async () => {
|
||||||
|
const { container, handler } = await mountReady()
|
||||||
|
|
||||||
|
await emitAndFlush(
|
||||||
|
handler,
|
||||||
|
[
|
||||||
|
'\u001B[31mINFO: [Core] 2026-08-19 10:00:00,000 app.jobs - Python message\u001B[0m',
|
||||||
|
'【warn】 [Plugin] 2026-08-19 10:00:00,050 plugin.worker - Bracket message',
|
||||||
|
'2026-08-19 10:00:00,090 [ERROR] [Api] api.route - Timestamp message',
|
||||||
|
'[DEBUG]: 2026-08-19 10:00:00,095 scheduler.task - Inline message',
|
||||||
|
'plain fallback line',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(recordBodies(container)).toEqual([
|
||||||
|
'10:00:00,000 Python message',
|
||||||
|
'10:00:00,050 Bracket message',
|
||||||
|
'10:00:00,090 Timestamp message',
|
||||||
|
'10:00:00,095 Inline message',
|
||||||
|
'plain fallback line',
|
||||||
|
])
|
||||||
|
expect([...container.querySelectorAll('.logging-record-level')].map(node => node.textContent?.trim())).toEqual([
|
||||||
|
'INFO:',
|
||||||
|
'WARNING:',
|
||||||
|
'ERROR:',
|
||||||
|
'DEBUG:',
|
||||||
|
'LOG:',
|
||||||
|
])
|
||||||
|
expect([...container.querySelectorAll('.logging-record-app')].map(node => node.textContent?.trim())).toEqual([
|
||||||
|
'[Core]',
|
||||||
|
'[Plugin]',
|
||||||
|
'[Api]',
|
||||||
|
])
|
||||||
|
expect(container.textContent).not.toContain('\u001B[31m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按秒级时间、级别和相邻间隔分组,并在边界变化时拆分记录', async () => {
|
||||||
|
const { container, handler } = await mountReady()
|
||||||
|
|
||||||
|
await emitAndFlush(
|
||||||
|
handler,
|
||||||
|
[
|
||||||
|
'INFO: 2026-08-19 10:00:00,000 first.source - first',
|
||||||
|
'INFO: 2026-08-19 10:00:00,080 second.source - second',
|
||||||
|
'INFO: 2026-08-19 10:00:00,250 second.source - gap boundary',
|
||||||
|
'ERROR: 2026-08-19 10:00:00,260 second.source - level boundary',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const records = container.querySelectorAll('.logging-record')
|
||||||
|
expect(records).toHaveLength(3)
|
||||||
|
expect(records[0].querySelectorAll('.logging-record-line')).toHaveLength(2)
|
||||||
|
expect(records[0].querySelector('.logging-record-accent')).toHaveClass('is-burst')
|
||||||
|
expect(records[1].querySelectorAll('.logging-record-line')).toHaveLength(1)
|
||||||
|
expect(records[2].querySelector('.logging-record-level')).toHaveTextContent('ERROR:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按级别与大小写无关关键字过滤,并支持从来源字段命中', async () => {
|
||||||
|
const { container, handler } = await mountReady()
|
||||||
|
|
||||||
|
await emitAndFlush(
|
||||||
|
handler,
|
||||||
|
[
|
||||||
|
'INFO: [Core] 2026-08-19 10:00:00,000 worker.first - alpha result',
|
||||||
|
'ERROR: [Plugin] 2026-08-19 10:00:01,000 worker.second - beta result',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByPlaceholderText('搜索日志内容'), ' WORKER.SECOND ')
|
||||||
|
expect(recordBodies(container)).toEqual(['10:00:01,000 beta result'])
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByPlaceholderText('搜索日志内容'), '')
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'ERROR' }))
|
||||||
|
expect(recordBodies(container)).toEqual(['10:00:01,000 beta result'])
|
||||||
|
expect(screen.queryByText('alpha result', { exact: false })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByPlaceholderText('搜索日志内容'), 'missing')
|
||||||
|
expect(screen.getByText('没有符合条件的数据')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只为可选的自定义级别增加筛选入口,隐藏跟踪和致命级别入口', async () => {
|
||||||
|
const { handler } = await mountReady()
|
||||||
|
|
||||||
|
await emitAndFlush(
|
||||||
|
handler,
|
||||||
|
[
|
||||||
|
'【NOTICE】 2026-08-19 10:00:00,000 notice.source - notice line',
|
||||||
|
'【TRACE】 2026-08-19 10:00:01,000 trace.source - trace line',
|
||||||
|
'【fatal】 2026-08-19 10:00:02,000 fatal.source - fatal line',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'NOTICE' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'TRACE' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'CRITICAL' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('notice line', { exact: false })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('trace line', { exact: false })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('fatal line', { exact: false })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('合并短时间内到达的消息并把展示行数限制在最后 600 行', async () => {
|
||||||
|
const { container, handler } = await mountReady()
|
||||||
|
const lines = Array.from({ length: 605 }, (_, index) => `line-${String(index).padStart(3, '0')}`)
|
||||||
|
|
||||||
|
handler(new MessageEvent('message', { data: lines.slice(0, 300).join('\n') }))
|
||||||
|
handler(new MessageEvent('message', { data: lines.slice(300).join('\n') }))
|
||||||
|
|
||||||
|
await advanceTimers(79)
|
||||||
|
expect(container.querySelectorAll('.logging-record-line')).toHaveLength(0)
|
||||||
|
|
||||||
|
await advanceTimers(1)
|
||||||
|
const renderedLines = container.querySelectorAll('.logging-record-line')
|
||||||
|
expect(renderedLines).toHaveLength(600)
|
||||||
|
expect(container).not.toHaveTextContent('line-004')
|
||||||
|
expect(container).toHaveTextContent('line-005')
|
||||||
|
expect(container).toHaveTextContent('line-604')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('离开尾部时累计新日志,跳到最新后恢复跟随并平滑滚动', async () => {
|
||||||
|
const { container, handler } = await mountReady()
|
||||||
|
const viewport = container.querySelector('.logging-shell') as HTMLElement
|
||||||
|
Object.defineProperties(viewport, {
|
||||||
|
clientHeight: { configurable: true, value: 200 },
|
||||||
|
scrollHeight: { configurable: true, value: 1_000 },
|
||||||
|
scrollTop: { configurable: true, value: 100, writable: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.scroll(viewport)
|
||||||
|
await emitAndFlush(
|
||||||
|
handler,
|
||||||
|
'INFO: 2026-08-19 10:00:00,000 source - one\nINFO: 2026-08-19 10:00:01,000 source - two',
|
||||||
|
)
|
||||||
|
|
||||||
|
const jumpButton = screen.getByRole('button', { name: '查看最新 (2)' })
|
||||||
|
expect(jumpButton).toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(jumpButton)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: '查看最新 (2)' })).not.toBeInTheDocument()
|
||||||
|
expect(scrollTo).toHaveBeenLastCalledWith({ top: 1_000, behavior: 'smooth' })
|
||||||
|
|
||||||
|
viewport.scrollTop = 100
|
||||||
|
await fireEvent.scroll(viewport)
|
||||||
|
await emitAndFlush(handler, 'INFO: 2026-08-19 10:00:02,000 source - three')
|
||||||
|
expect(screen.getByRole('button', { name: '查看最新 (1)' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
viewport.scrollTop = 800
|
||||||
|
await fireEvent.scroll(viewport)
|
||||||
|
expect(screen.queryByRole('button', { name: '查看最新 (1)' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('暂停时立即刷新缓冲并断开连接,恢复后使用新连接继续接收日志', async () => {
|
||||||
|
const { container, handler } = await mountReady()
|
||||||
|
handler(new MessageEvent('message', { data: 'INFO: 2026-08-19 10:00:00,000 source - buffered before pause' }))
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByTitle('暂停日志流'))
|
||||||
|
|
||||||
|
expect(recordBodies(container)).toEqual(['10:00:00,000 buffered before pause'])
|
||||||
|
expect(mocks.removeMessageListener).toHaveBeenCalledWith('logging-moviepilot.log')
|
||||||
|
expect(screen.getByTitle('恢复日志流')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByTitle('恢复日志流'))
|
||||||
|
expect(mocks.addMessageListener).toHaveBeenCalledWith('logging-moviepilot.log', handler)
|
||||||
|
const resumedHandler = mocks.addMessageListener.mock.calls[0][1] as (event: MessageEvent) => void
|
||||||
|
await emitAndFlush(resumedHandler, 'ERROR: 2026-08-19 10:00:01,000 source - after resume')
|
||||||
|
|
||||||
|
expect(recordBodies(container)).toEqual(['10:00:00,000 buffered before pause', '10:00:01,000 after resume'])
|
||||||
|
expect(screen.getByTitle('暂停日志流')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('卸载时清空尚未刷新的组件缓冲定时器', async () => {
|
||||||
|
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||||
|
const { handler, unmount } = await mountReady()
|
||||||
|
handler(new MessageEvent('message', { data: 'INFO: 2026-08-19 10:00:00,000 source - pending' }))
|
||||||
|
const pendingTimers = vi.getTimerCount()
|
||||||
|
|
||||||
|
expect(pendingTimers).toBeGreaterThan(0)
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||||
|
expect(vi.getTimerCount()).toBeLessThan(pendingTimers)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import type { ScheduleInfo } from '@/api/types'
|
||||||
|
import ServiceView from '@/views/system/ServiceView.vue'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
interface TimerRegistration {
|
||||||
|
callback: () => Promise<void> | void
|
||||||
|
id: string
|
||||||
|
interval: number
|
||||||
|
options?: {
|
||||||
|
runInBackground?: boolean
|
||||||
|
skipInitialRun?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiRequests: [] as Promise<unknown>[],
|
||||||
|
removeBackgroundTimer: vi.fn(),
|
||||||
|
timerRegistrations: [] as TimerRegistration[],
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => {
|
||||||
|
const client = createDataApiMock({
|
||||||
|
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => {
|
||||||
|
const request = client.get(...args) as Promise<unknown>
|
||||||
|
|
||||||
|
mocks.apiRequests.push(request)
|
||||||
|
// 生产客户端返回拒绝 Promise;预先登记观察者,避免被测组件未消费时污染 Vitest 进程。
|
||||||
|
void request.catch(() => {})
|
||||||
|
return request
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/utils/backgroundManager', () => ({
|
||||||
|
addBackgroundTimer: (
|
||||||
|
id: string,
|
||||||
|
callback: () => Promise<void> | void,
|
||||||
|
interval: number,
|
||||||
|
options?: TimerRegistration['options'],
|
||||||
|
) => {
|
||||||
|
mocks.timerRegistrations.push({ callback, id, interval, options })
|
||||||
|
},
|
||||||
|
removeBackgroundTimer: (...args: unknown[]) => mocks.removeBackgroundTimer(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise
|
||||||
|
reject = rejectPromise
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, reject, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(overrides: Partial<ScheduleInfo> = {}): ScheduleInfo {
|
||||||
|
return {
|
||||||
|
id: 'cookiecloud',
|
||||||
|
name: 'CookieCloud',
|
||||||
|
provider: '内置服务',
|
||||||
|
status: '等待',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderServiceView() {
|
||||||
|
return renderWithProviders(ServiceView)
|
||||||
|
}
|
||||||
|
|
||||||
|
function timerRegistration(id: string) {
|
||||||
|
const registration = mocks.timerRegistrations.find(item => item.id === id)
|
||||||
|
if (!registration) throw new Error(`未注册后台刷新任务:${id}`)
|
||||||
|
|
||||||
|
return registration
|
||||||
|
}
|
||||||
|
|
||||||
|
function executionButtons() {
|
||||||
|
return screen.getAllByRole('button', { name: '执行' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ServiceView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
mocks.apiRequests.length = 0
|
||||||
|
mocks.removeBackgroundTimer.mockReset()
|
||||||
|
mocks.timerRegistrations.length = 0
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading until the unwrapped schedule list resolves and registers both refresh policies', async () => {
|
||||||
|
const pendingList = deferred<ScheduleInfo[]>()
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'dashboard/schedule') return pendingList.promise
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderServiceView()
|
||||||
|
|
||||||
|
expect(screen.getAllByText('加载中...')).toHaveLength(2)
|
||||||
|
expect(screen.queryByText('没有后台服务')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
pendingList.resolve([schedule({ name: '直返调度任务' })])
|
||||||
|
|
||||||
|
expect(await screen.findAllByText('直返调度任务')).toHaveLength(2)
|
||||||
|
expect(screen.queryByText('加载中...')).not.toBeInTheDocument()
|
||||||
|
expect(timerRegistration('scheduler-service-progress')).toMatchObject({
|
||||||
|
id: 'scheduler-service-progress',
|
||||||
|
interval: 1000,
|
||||||
|
options: { runInBackground: false, skipInitialRun: true },
|
||||||
|
})
|
||||||
|
expect(timerRegistration('scheduler-list')).toMatchObject({
|
||||||
|
id: 'scheduler-list',
|
||||||
|
interval: 3000,
|
||||||
|
options: { runInBackground: false, skipInitialRun: true },
|
||||||
|
})
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('dashboard/schedule')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers localized fields, preserves raw fallbacks, and derives running and waiting states consistently', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue([
|
||||||
|
schedule({
|
||||||
|
id: 'localized-waiting',
|
||||||
|
name: 'Raw waiting name',
|
||||||
|
name_i18n: '本地化等待任务',
|
||||||
|
next_run: 'raw next run',
|
||||||
|
next_run_i18n: '5 分钟',
|
||||||
|
provider: 'Raw provider',
|
||||||
|
provider_i18n: '本地化提供者',
|
||||||
|
status_i18n: 'Localized waiting status',
|
||||||
|
}),
|
||||||
|
schedule({
|
||||||
|
id: 'raw-fallback',
|
||||||
|
name: '原始回退任务',
|
||||||
|
next_run: '稍后',
|
||||||
|
provider: '原始提供者',
|
||||||
|
status: '自定义状态',
|
||||||
|
}),
|
||||||
|
schedule({
|
||||||
|
id: 'running',
|
||||||
|
name: '运行任务',
|
||||||
|
progress: 42,
|
||||||
|
progress_enable: true,
|
||||||
|
progress_text_i18n: '本地化进度',
|
||||||
|
status: '等待',
|
||||||
|
status_i18n: '错误的等待翻译',
|
||||||
|
}),
|
||||||
|
] satisfies ScheduleInfo[])
|
||||||
|
|
||||||
|
await renderServiceView()
|
||||||
|
|
||||||
|
expect(await screen.findAllByText('本地化等待任务')).toHaveLength(2)
|
||||||
|
expect(screen.getAllByText('本地化提供者')).toHaveLength(2)
|
||||||
|
expect(screen.getByText('5 分钟')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('5 分钟之后')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('原始回退任务')).toHaveLength(2)
|
||||||
|
expect(screen.getAllByText('原始提供者')).toHaveLength(2)
|
||||||
|
expect(screen.getAllByText('自定义状态')).toHaveLength(2)
|
||||||
|
expect(screen.queryByText('Localized waiting status')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('错误的等待翻译')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('正在运行')).toHaveLength(2)
|
||||||
|
expect(screen.getAllByText('本地化进度')).toHaveLength(2)
|
||||||
|
expect(screen.getAllByText('42%')).toHaveLength(2)
|
||||||
|
expect(document.querySelectorAll('.mobile-scheduler-status--waiting')).toHaveLength(1)
|
||||||
|
expect(document.querySelectorAll('.mobile-scheduler-status--default')).toHaveLength(1)
|
||||||
|
expect(document.querySelectorAll('.mobile-scheduler-status--running')).toHaveLength(1)
|
||||||
|
expect(executionButtons().filter(button => button.hasAttribute('disabled'))).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coalesces overlapping list refreshes and releases the gate after a failed refresh', async () => {
|
||||||
|
const firstRefresh = deferred<ScheduleInfo[]>()
|
||||||
|
const failedRefresh = Promise.reject<ScheduleInfo[]>(new Error('列表暂时不可用'))
|
||||||
|
void failedRefresh.catch(() => {})
|
||||||
|
const responses: Array<Promise<ScheduleInfo[]> | ScheduleInfo[]> = [
|
||||||
|
[schedule({ name: '初始任务' })],
|
||||||
|
firstRefresh.promise,
|
||||||
|
failedRefresh,
|
||||||
|
[schedule({ name: '恢复任务' })],
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint !== 'dashboard/schedule') throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
return responses.shift()
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderServiceView()
|
||||||
|
expect(await screen.findAllByText('初始任务')).toHaveLength(2)
|
||||||
|
const refresh = timerRegistration('scheduler-list').callback
|
||||||
|
|
||||||
|
const pendingRefresh = refresh()
|
||||||
|
const coalescedRefresh = refresh()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||||
|
expect(screen.getAllByText('初始任务')).toHaveLength(2)
|
||||||
|
|
||||||
|
firstRefresh.resolve([schedule({ name: '刷新任务' })])
|
||||||
|
await Promise.all([pendingRefresh, coalescedRefresh])
|
||||||
|
await waitFor(() => expect(screen.getAllByText('刷新任务')).toHaveLength(2))
|
||||||
|
|
||||||
|
await refresh()
|
||||||
|
expect(screen.getAllByText('刷新任务')).toHaveLength(2)
|
||||||
|
|
||||||
|
await refresh()
|
||||||
|
await waitFor(() => expect(screen.getAllByText('恢复任务')).toHaveLength(2))
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits the selected job and refreshes the list one second after a successful response', async () => {
|
||||||
|
let listReads = 0
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'dashboard/schedule') {
|
||||||
|
listReads += 1
|
||||||
|
return [schedule({ id: 'manual-job', name: '手动任务' })]
|
||||||
|
}
|
||||||
|
if (endpoint === 'system/runscheduler') return null
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
await renderServiceView()
|
||||||
|
expect(await screen.findAllByText('手动任务')).toHaveLength(2)
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
await fireEvent.click(executionButtons()[0])
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('system/runscheduler', {
|
||||||
|
params: { jobid: 'manual-job' },
|
||||||
|
})
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('定时作业执行请求提交成功!')
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(999)
|
||||||
|
expect(listReads).toBe(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
expect(listReads).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'业务失败',
|
||||||
|
() => ({ success: false, message: '任务拒绝执行' }),
|
||||||
|
{ businessFailure: true, message: '任务拒绝执行' },
|
||||||
|
],
|
||||||
|
['HTTP 失败', () => Promise.reject(new Error('服务不可用')), { businessFailure: false, message: '服务不可用' }],
|
||||||
|
])(
|
||||||
|
'does not report success or schedule a refresh after %s and remains retryable',
|
||||||
|
async (_label, createFailure, expectedFailure) => {
|
||||||
|
let executionAttempt = 0
|
||||||
|
let listReads = 0
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'dashboard/schedule') {
|
||||||
|
listReads += 1
|
||||||
|
return [schedule({ id: 'retryable-job', name: '可重试任务' })]
|
||||||
|
}
|
||||||
|
if (endpoint === 'system/runscheduler') {
|
||||||
|
executionAttempt += 1
|
||||||
|
return executionAttempt === 1 ? createFailure() : null
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
await renderServiceView()
|
||||||
|
expect(await screen.findAllByText('可重试任务')).toHaveLength(2)
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
await fireEvent.click(executionButtons()[0])
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
const executionRequest = mocks.apiRequests.at(-1)
|
||||||
|
expect(executionRequest).toBeDefined()
|
||||||
|
await expect(executionRequest).rejects.toMatchObject(expectedFailure)
|
||||||
|
expect.soft(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
expect.soft(listReads).toBe(1)
|
||||||
|
|
||||||
|
const readsBeforeRetry = listReads
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
await fireEvent.click(executionButtons()[0])
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledOnce()
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
expect(listReads).toBe(readsBeforeRetry + 1)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -333,6 +333,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/views/dashboard/AnalyticsScheduler.vue',
|
'src/views/dashboard/AnalyticsScheduler.vue',
|
||||||
'src/views/dashboard/AnalyticsStorage.vue',
|
'src/views/dashboard/AnalyticsStorage.vue',
|
||||||
'src/views/dashboard/DashboardRecentImports.vue',
|
'src/views/dashboard/DashboardRecentImports.vue',
|
||||||
|
'src/views/system/CacheView.vue',
|
||||||
|
'src/views/system/LoggingView.vue',
|
||||||
|
'src/views/system/ServiceView.vue',
|
||||||
'src/views/discover/MediaCardSlideView.vue',
|
'src/views/discover/MediaCardSlideView.vue',
|
||||||
'src/views/subscribe/FullCalendarView.vue',
|
'src/views/subscribe/FullCalendarView.vue',
|
||||||
'src/views/subscribe/SubscribeListView.vue',
|
'src/views/subscribe/SubscribeListView.vue',
|
||||||
@@ -481,6 +484,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/views/system/CacheView.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
'src/views/system/LoggingView.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
|
'src/views/system/ServiceView.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 85,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/components/cards/SubscribeCard.vue': {
|
'src/components/cards/SubscribeCard.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user