mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user