From 3b758ea54325551c1122d53105b9d4f8404f42de Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:51:04 +0800 Subject: [PATCH] fix(system): harden scheduler and cache operations (#685) --- src/views/system/CacheView.vue | 26 +- src/views/system/ServiceView.vue | 8 +- src/views/system/__tests__/CacheView.spec.ts | 911 +++++++++++++++++- .../system/__tests__/LoggingView.spec.ts | 301 ++++++ .../system/__tests__/ServiceView.spec.ts | 311 ++++++ vite.config.ts | 21 + 6 files changed, 1518 insertions(+), 60 deletions(-) create mode 100644 src/views/system/__tests__/LoggingView.spec.ts create mode 100644 src/views/system/__tests__/ServiceView.spec.ts diff --git a/src/views/system/CacheView.vue b/src/views/system/CacheView.vue index 22d8fd85..89112935 100644 --- a/src/views/system/CacheView.vue +++ b/src/views/system/CacheView.vue @@ -73,7 +73,7 @@ const filteredData = computed(() => { }) // 选中的缓存项 -const selectedItems = ref([]) +const selectedItems = ref([]) // 加载状态 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(`torrent/cache/${item.domain}/${hash}`, { feedback: 'silent' }) - } - return Promise.resolve() - }) + const deletePromises = selectedItems.value.map(item => + api.delete(`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(`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 diff --git a/src/views/system/ServiceView.vue b/src/views/system/ServiceView.vue index 25c260bb..cac8cfa5 100644 --- a/src/views/system/ServiceView.vue +++ b/src/views/system/ServiceView.vue @@ -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) diff --git a/src/views/system/__tests__/CacheView.spec.ts b/src/views/system/__tests__/CacheView.spec.ts index fc8a05fd..f2d69381 100644 --- a/src/views/system/__tests__/CacheView.spec.ts +++ b/src/views/system/__tests__/CacheView.spec.ts @@ -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() + 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, + 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: '', +}) + +const TooltipStub = defineComponent({ + name: 'VTooltip', + template: '', +}) + +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, + 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 unknown)>, + default: 'id', + }, + items: { + type: Array as PropType, + default: () => [], + }, + loading: Boolean, + modelValue: { + type: Array as PropType, + default: () => [], + }, + returnObject: Boolean, + }, + emits: ['update:modelValue'], + setup(props, { emit, slots }) { + function selectCurrentItems() { + const selectedByValue = new Map() + 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, + 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, + 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: '
识别缓存面板
', +}) + +const stubs = { + ProgressiveCardGrid: ProgressiveGridStub, + RecognitionCachePanel: RecognitionCachePanelStub, + VAutocomplete: AutocompleteStub, + VBtn: ButtonStub, + VBtnToggle: ButtonToggleStub, + VCol: defineComponent({ template: '
' }), + VDataTable: DataTableStub, + VIcon: IconStub, + VImg: ImageStub, + VInfiniteScroll: InfiniteScrollStub, + VList: PassthroughStub, + VListItem: ListItemStub, + VListItemTitle: PassthroughStub, + VMenu: MenuStub, + VRow: defineComponent({ template: '
' }), + VChip: PassthroughStub, + VTextField: TextFieldStub, + VTooltip: TooltipStub, +} + +function createCacheItem(overrides: Partial = {}): 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(data: T) { + return { data, message: '', success: true } +} + +function businessFailure(message = '业务失败') { + return { data: null, message, success: false } +} + +function deferred() { + let reject!: (reason?: unknown) => void + let resolve!: (value: T) => void + const promise = new Promise((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: '
{{ item.title }}
', - }, - }, - }, - 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>>() + 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>>() + 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, + Record Promise | 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, + Record Promise | 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 + > + + 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') }) }) diff --git a/src/views/system/__tests__/LoggingView.spec.ts b/src/views/system/__tests__/LoggingView.spec.ts new file mode 100644 index 00000000..66df52ac --- /dev/null +++ b/src/views/system/__tests__/LoggingView.spec.ts @@ -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 + 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: '
{{ text }}
', + }, + }, + }, + }) + } + + 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) + }) +}) diff --git a/src/views/system/__tests__/ServiceView.spec.ts b/src/views/system/__tests__/ServiceView.spec.ts new file mode 100644 index 00000000..8ad6416e --- /dev/null +++ b/src/views/system/__tests__/ServiceView.spec.ts @@ -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 + id: string + interval: number + options?: { + runInBackground?: boolean + skipInitialRun?: boolean + } +} + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiRequests: [] as Promise[], + 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 + + mocks.apiRequests.push(request) + // 生产客户端返回拒绝 Promise;预先登记观察者,避免被测组件未消费时污染 Vitest 进程。 + void request.catch(() => {}) + return request + }, + }, + } +}) + +vi.mock('@/utils/backgroundManager', () => ({ + addBackgroundTimer: ( + id: string, + callback: () => Promise | 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() { + let reject!: (reason?: unknown) => void + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + + return { promise, reject, resolve } +} + +function schedule(overrides: Partial = {}): 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() + 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() + const failedRefresh = Promise.reject(new Error('列表暂时不可用')) + void failedRefresh.catch(() => {}) + const responses: Array | 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) + }, + ) +}) diff --git a/vite.config.ts b/vite.config.ts index 76083391..627fef13 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,