mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-03 14:37:00 +08:00
899 lines
34 KiB
TypeScript
899 lines
34 KiB
TypeScript
import type { TorrentCacheData, TorrentCacheItem } from '@/api/types'
|
|
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
|
import CacheView from '@/views/system/CacheView.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: 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: mocks.toastSuccess,
|
|
warning: mocks.toastWarning,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/composables/useConfirm', () => ({
|
|
useConfirm: () => mocks.confirm,
|
|
}))
|
|
|
|
vi.mock('@/composables/usePWA', () => ({
|
|
usePWA: () => ({ appMode: true }),
|
|
}))
|
|
|
|
vi.mock('@/composables/useSharedDialog', () => ({
|
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
|
}))
|
|
|
|
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.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('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 renderCache()
|
|
await waitForInitialLoad()
|
|
|
|
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')
|
|
})
|
|
})
|