fix(system): harden scheduler and cache operations (#685)

This commit is contained in:
InfinityPacer
2026-08-19 12:51:04 +08:00
committed by GitHub
parent 0355ce5fdb
commit 3b758ea543
6 changed files with 1518 additions and 60 deletions
+14 -12
View File
@@ -73,7 +73,7 @@ const filteredData = computed(() => {
})
// 选中的缓存项
const selectedItems = ref<string[]>([])
const selectedItems = ref<TorrentCacheItem[]>([])
// 加载状态
const loading = ref(false)
@@ -182,13 +182,9 @@ async function deleteSelectedItems() {
try {
loading.value = true
const deletePromises = selectedItems.value.map(hash => {
const item = cacheData.value.data.find(d => d.hash === hash)
if (item) {
return api.delete<null>(`torrent/cache/${item.domain}/${hash}`, { feedback: 'silent' })
}
return Promise.resolve()
})
const deletePromises = selectedItems.value.map(item =>
api.delete<null>(`torrent/cache/${item.domain}/${item.hash}`, { feedback: 'silent' }),
)
await Promise.all(deletePromises)
$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' })
$toast.success(t('setting.cache.deleteSuccess'))
await loadCacheData()
// 从选中列表中移除
const index = selectedItems.value.indexOf(item.hash)
const itemIdentity = getCacheItemIdentity(item)
const index = selectedItems.value.findIndex(selectedItem => getCacheItemIdentity(selectedItem) === itemIdentity)
if (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) {
currentReidentifyItem.value = item
@@ -319,7 +320,7 @@ function getMobileMediaTypeChipClass(type: string): 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"
:loading="loading"
item-value="hash"
:item-value="getCacheItemIdentity"
return-object
show-select
hover
fixed-header
+3 -5
View File
@@ -79,17 +79,15 @@ function getMobileSchedulerStatusText(scheduler: ScheduleInfo) {
return getDisplayedSchedulerStatusText(scheduler)
}
/** 执行指定定时服务,并在短延迟刷新列表。 */
function runCommand(id: string) {
/** 执行指定定时服务,并在请求成功后的短延迟刷新列表。 */
async function runCommand(id: string) {
try {
// 异步提交
api.get('system/runscheduler', {
await api.get('system/runscheduler', {
params: {
jobid: id,
},
})
$toast.success(t('setting.scheduler.executeSuccess'))
// 1秒后刷新数据
setTimeout(() => {
loadSchedulerList()
}, 1000)
+868 -43
View File
@@ -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 { screen, waitFor } from '@testing-library/vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
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'
const mocks = vi.hoisted(() => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPost: vi.fn(),
confirm: vi.fn(),
mobile: false,
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
delete: vi.fn(),
get: mocks.apiGet,
post: vi.fn(),
},
default: createDataApiMock({
delete: (...args: unknown[]) => mocks.apiDelete(...args),
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
}),
}))
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', () => ({
@@ -25,49 +43,856 @@ vi.mock('@/composables/usePWA', () => ({
}))
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(() => {
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.toastSuccess.mockReset()
mocks.toastWarning.mockReset()
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('renders the unwrapped torrent cache data returned by the default API client', async () => {
mocks.apiGet.mockResolvedValue({
count: 1,
sites: 1,
data: [
{
domain: 'example.com',
hash: 'cache-entry-1',
media_name: '已识别媒体',
site_name: '示例站点',
size: 1024,
title: '缓存直返条目',
},
],
})
it('loads unwrapped cache data and filters titles case-insensitively and sites by exact source', async () => {
const items = [
createCacheItem({ site_name: 'Zulu Tracker' }),
createCacheItem({
domain: 'beta.example',
hash: 'beta-hash',
site_name: 'Alpha Tracker',
title: 'Beta.Show.S01',
}),
createCacheItem({ domain: 'gamma.example', hash: 'gamma-hash', site_name: 'Zulu Tracker', title: 'Gamma.Album' }),
]
mocks.apiGet.mockResolvedValue(success(createCacheData(items)))
await renderWithProviders(CacheView, {
global: {
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 renderCache()
await waitForInitialLoad()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('torrent/cache', { feedback: 'silent' }))
expect(await screen.findByText('缓存直返条目')).toBeInTheDocument()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(screen.getByText('Alpha.Movie.2026')).toBeInTheDocument()
expect(screen.getByText('Beta.Show.S01')).toBeInTheDocument()
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)
},
)
})
+21
View File
@@ -333,6 +333,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/views/dashboard/AnalyticsScheduler.vue',
'src/views/dashboard/AnalyticsStorage.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/subscribe/FullCalendarView.vue',
'src/views/subscribe/SubscribeListView.vue',
@@ -481,6 +484,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 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': {
branches: 75,
functions: 80,