mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
test(resource): cover search filtering and lifecycle (#607)
This commit is contained in:
@@ -4,7 +4,9 @@ import { useEventListener } from '@vueuse/core'
|
|||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
|
||||||
const TorrentAllFiltersDialog = defineAsyncComponent(() => import('@/components/dialog/TorrentAllFiltersDialog.vue'))
|
const TorrentAllFiltersDialog = defineAsyncComponent(() => import('@/components/dialog/TorrentAllFiltersDialog.vue'))
|
||||||
const TorrentSingleFilterDialog = defineAsyncComponent(() => import('@/components/dialog/TorrentSingleFilterDialog.vue'))
|
const TorrentSingleFilterDialog = defineAsyncComponent(
|
||||||
|
() => import('@/components/dialog/TorrentSingleFilterDialog.vue'),
|
||||||
|
)
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -288,6 +290,11 @@ onMounted(() => {
|
|||||||
}, 500)
|
}, 500)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
closeAllFilterDialog()
|
||||||
|
closeFilterDialog()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -593,7 +600,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||||
|
import { fireEvent, screen } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
closeAll: vi.fn(),
|
||||||
|
closeSingle: vi.fn(),
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
|
updateAll: vi.fn(),
|
||||||
|
updateSingle: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const PassThroughStub = defineComponent({
|
||||||
|
template: '<div><slot /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button type="button" @click="$emit(\'click\')"><slot name="prepend" /><slot /></button>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ListItemStub = defineComponent({
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button type="button" @click="$emit(\'click\')"><slot name="prepend" /><slot /></button>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ChipGroupStub = defineComponent({
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template:
|
||||||
|
'<button type="button" data-testid="filter-value-control" @click="$emit(\'update:modelValue\', [\'Site B\'])"><slot /></button>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ChipStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
closable: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
emits: ['click:close'],
|
||||||
|
template:
|
||||||
|
'<span><slot /><button v-if="closable" type="button" aria-label="移除筛选" @click="$emit(\'click:close\')" /></span>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const componentStubs = {
|
||||||
|
VBadge: PassThroughStub,
|
||||||
|
VBtn: ButtonStub,
|
||||||
|
VCard: PassThroughStub,
|
||||||
|
VCardText: PassThroughStub,
|
||||||
|
VChip: ChipStub,
|
||||||
|
VChipGroup: ChipGroupStub,
|
||||||
|
VDivider: PassThroughStub,
|
||||||
|
VIcon: PassThroughStub,
|
||||||
|
VList: PassThroughStub,
|
||||||
|
VListItem: ListItemStub,
|
||||||
|
VListItemTitle: PassThroughStub,
|
||||||
|
VMenu: PassThroughStub,
|
||||||
|
VSpacer: PassThroughStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
enableAnimation: false,
|
||||||
|
filterForm: {
|
||||||
|
edition: [],
|
||||||
|
freeState: [],
|
||||||
|
releaseGroup: [],
|
||||||
|
resolution: [],
|
||||||
|
season: [],
|
||||||
|
site: ['Site A'],
|
||||||
|
videoCode: [],
|
||||||
|
},
|
||||||
|
filterOptions: {
|
||||||
|
edition: [],
|
||||||
|
freeState: [],
|
||||||
|
releaseGroup: [],
|
||||||
|
resolution: [],
|
||||||
|
season: ['S01'],
|
||||||
|
site: ['Site A', 'Site B'],
|
||||||
|
videoCode: [],
|
||||||
|
},
|
||||||
|
filterTitles: {
|
||||||
|
edition: '版本',
|
||||||
|
freeState: '促销',
|
||||||
|
releaseGroup: '制作组',
|
||||||
|
resolution: '分辨率',
|
||||||
|
season: '季集',
|
||||||
|
site: '站点',
|
||||||
|
videoCode: '编码',
|
||||||
|
},
|
||||||
|
sortField: 'default',
|
||||||
|
sortTitles: {
|
||||||
|
default: '默认排序',
|
||||||
|
publishTime: '发布时间',
|
||||||
|
seeder: '做种数',
|
||||||
|
site: '站点排序',
|
||||||
|
size: '体积',
|
||||||
|
},
|
||||||
|
sortType: 'desc' as const,
|
||||||
|
totalFilteredCount: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFilterBar() {
|
||||||
|
return renderWithProviders(TorrentFilterBar, {
|
||||||
|
props: defaultProps,
|
||||||
|
global: { stubs: componentStubs },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TorrentFilterBar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
let openCount = 0
|
||||||
|
mocks.openSharedDialog.mockImplementation(() => {
|
||||||
|
openCount += 1
|
||||||
|
return openCount === 1
|
||||||
|
? { close: mocks.closeSingle, id: 1, updateProps: mocks.updateSingle }
|
||||||
|
: { close: mocks.closeAll, id: 2, updateProps: mocks.updateAll }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits desktop sorting, filtering, selection and clearing commands without mutating props', async () => {
|
||||||
|
const result = await renderFilterBar()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getAllByRole('button', { name: '升序' })[0])
|
||||||
|
await fireEvent.click(screen.getAllByRole('button', { name: '体积' })[0])
|
||||||
|
const siteValueControl = screen
|
||||||
|
.getAllByTestId('filter-value-control')
|
||||||
|
.find(control => control.textContent?.includes('Site A'))
|
||||||
|
expect(siteValueControl).toBeDefined()
|
||||||
|
await fireEvent.click(siteValueControl as HTMLElement)
|
||||||
|
await fireEvent.click(screen.getAllByRole('button', { name: '全选' }).at(-1) as HTMLElement)
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '清除' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '移除筛选' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
|
||||||
|
|
||||||
|
expect(result.emitted()['update:sortType']).toEqual([['asc']])
|
||||||
|
expect(result.emitted()['update:sortField']).toEqual([['size']])
|
||||||
|
expect(result.emitted()['update:filterForm']).toEqual([['site', ['Site B']]])
|
||||||
|
expect(result.emitted().selectAll).toEqual([['site']])
|
||||||
|
expect(result.emitted().clearFilter).toEqual([['site']])
|
||||||
|
expect(result.emitted().removeFilter).toEqual([['site', 'Site A']])
|
||||||
|
expect(result.emitted().clearAllFilters).toEqual([[]])
|
||||||
|
expect(defaultProps.filterForm.site).toEqual(['Site A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drives mobile shared dialogs with current controlled props and closes them on unmount', async () => {
|
||||||
|
const result = await renderFilterBar()
|
||||||
|
const mobileButtons = Array.from(result.container.querySelectorAll<HTMLButtonElement>('.filter-btn-mobile'))
|
||||||
|
const siteButton = mobileButtons.find(button => button.textContent?.includes('站点'))
|
||||||
|
const seasonButton = mobileButtons.find(button => button.textContent?.includes('季集'))
|
||||||
|
const allButton = mobileButtons.find(button => button.textContent?.includes('综合筛选'))
|
||||||
|
|
||||||
|
expect(siteButton).toBeDefined()
|
||||||
|
expect(seasonButton).toBeDefined()
|
||||||
|
expect(allButton).toBeDefined()
|
||||||
|
await fireEvent.click(siteButton as HTMLButtonElement)
|
||||||
|
|
||||||
|
const singleProps = mocks.openSharedDialog.mock.calls[0][1] as Record<string, unknown>
|
||||||
|
const singleEvents = mocks.openSharedDialog.mock.calls[0][2] as Record<string, (...args: unknown[]) => void>
|
||||||
|
expect(singleProps).toMatchObject({
|
||||||
|
filterForm: defaultProps.filterForm,
|
||||||
|
filterKey: 'site',
|
||||||
|
filterOptions: defaultProps.filterOptions,
|
||||||
|
filterTitle: '站点',
|
||||||
|
})
|
||||||
|
singleEvents['update:filterForm']('site', ['Site B'])
|
||||||
|
singleEvents.selectAll('site')
|
||||||
|
singleEvents.clearFilter('site')
|
||||||
|
expect(result.emitted()['update:filterForm']).toEqual([['site', ['Site B']]])
|
||||||
|
expect(result.emitted().selectAll).toEqual([['site']])
|
||||||
|
expect(result.emitted().clearFilter).toEqual([['site']])
|
||||||
|
|
||||||
|
await fireEvent.click(seasonButton as HTMLButtonElement)
|
||||||
|
expect(mocks.updateSingle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
filterKey: 'season',
|
||||||
|
filterTitle: '季集',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await fireEvent.click(allButton as HTMLButtonElement)
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
result.unmount()
|
||||||
|
|
||||||
|
expect(mocks.closeSingle).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.closeAll).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reopens shared dialogs after their close events release the active controller', async () => {
|
||||||
|
const result = await renderFilterBar()
|
||||||
|
const mobileButtons = Array.from(result.container.querySelectorAll<HTMLButtonElement>('.filter-btn-mobile'))
|
||||||
|
const siteButton = mobileButtons.find(button => button.textContent?.includes('站点'))
|
||||||
|
const allButton = mobileButtons.find(button => button.textContent?.includes('综合筛选'))
|
||||||
|
|
||||||
|
await fireEvent.click(siteButton as HTMLButtonElement)
|
||||||
|
const firstSingleEvents = mocks.openSharedDialog.mock.calls[0][2] as Record<string, (...args: unknown[]) => void>
|
||||||
|
firstSingleEvents.close()
|
||||||
|
|
||||||
|
await fireEvent.click(siteButton as HTMLButtonElement)
|
||||||
|
const secondSingleEvents = mocks.openSharedDialog.mock.calls[1][2] as Record<string, (...args: unknown[]) => void>
|
||||||
|
secondSingleEvents['update:modelValue'](false)
|
||||||
|
|
||||||
|
await fireEvent.click(allButton as HTMLButtonElement)
|
||||||
|
const firstAllEvents = mocks.openSharedDialog.mock.calls[2][2] as Record<string, (...args: unknown[]) => void>
|
||||||
|
firstAllEvents.close()
|
||||||
|
|
||||||
|
await fireEvent.click(allButton as HTMLButtonElement)
|
||||||
|
const secondAllEvents = mocks.openSharedDialog.mock.calls[3][2] as Record<string, (...args: unknown[]) => void>
|
||||||
|
secondAllEvents['update:modelValue'](false)
|
||||||
|
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(4)
|
||||||
|
result.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import type { Context } from '@/api/types'
|
||||||
|
import { useTorrentFilter } from '@/composables/useTorrentFilter'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
interface TorrentOverrides {
|
||||||
|
edition?: string
|
||||||
|
freeState?: string
|
||||||
|
name?: string
|
||||||
|
pageUrl?: string
|
||||||
|
priOrder?: number
|
||||||
|
publishTime?: string
|
||||||
|
releaseGroup?: string
|
||||||
|
resolution?: string
|
||||||
|
season?: string
|
||||||
|
seeders?: number
|
||||||
|
site?: string
|
||||||
|
size?: number
|
||||||
|
title?: string
|
||||||
|
videoCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTorrent(overrides: TorrentOverrides = {}): Context {
|
||||||
|
return {
|
||||||
|
media_info: {},
|
||||||
|
meta_info: {
|
||||||
|
edition: overrides.edition ?? 'WEB-DL',
|
||||||
|
name: overrides.name ?? '测试媒体',
|
||||||
|
resource_pix: overrides.resolution ?? '1080p',
|
||||||
|
resource_team: overrides.releaseGroup ?? 'Team A',
|
||||||
|
season_episode: overrides.season ?? 'S01',
|
||||||
|
video_encode: overrides.videoCode ?? 'H.264',
|
||||||
|
},
|
||||||
|
torrent_info: {
|
||||||
|
page_url: overrides.pageUrl ?? `https://example.test/${overrides.title ?? 'torrent'}`,
|
||||||
|
pri_order: overrides.priOrder ?? 0,
|
||||||
|
pubdate: overrides.publishTime ?? '2025-01-01T00:00:00Z',
|
||||||
|
seeders: overrides.seeders ?? 0,
|
||||||
|
site_name: overrides.site ?? 'Site A',
|
||||||
|
size: overrides.size ?? 1,
|
||||||
|
title: overrides.title ?? '测试资源',
|
||||||
|
volume_factor: overrides.freeState ?? 'FREE',
|
||||||
|
},
|
||||||
|
} as Context
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTorrentFilter', () => {
|
||||||
|
it('builds unique options and naturally orders whole seasons before episodes', () => {
|
||||||
|
const filter = useTorrentFilter()
|
||||||
|
|
||||||
|
filter.filterRowData([
|
||||||
|
createTorrent({ season: 'S02', site: 'Site B' }),
|
||||||
|
createTorrent({ season: 'S10E01-E03', site: 'Site A' }),
|
||||||
|
createTorrent({ season: 'S01E12', site: 'Site A' }),
|
||||||
|
createTorrent({ season: 'S10', site: 'Site B' }),
|
||||||
|
createTorrent({ season: 'Special' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(filter.filterOptions.site).toEqual(['Site B', 'Site A'])
|
||||||
|
expect(filter.filterOptions.season).toEqual(['S10', 'S02', 'S10E01-E03', 'S01E12', 'Special'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('combines filters and exposes every matching original row index', () => {
|
||||||
|
const filter = useTorrentFilter()
|
||||||
|
const torrents = [
|
||||||
|
createTorrent({ releaseGroup: 'Team A', resolution: '2160p', site: 'Site A', title: '匹配一' }),
|
||||||
|
createTorrent({ releaseGroup: 'Team B', resolution: '2160p', site: 'Site A', title: '发布组不匹配' }),
|
||||||
|
createTorrent({ releaseGroup: 'Team A', resolution: '1080p', site: 'Site A', title: '分辨率不匹配' }),
|
||||||
|
createTorrent({ releaseGroup: 'Team A', resolution: '2160p', site: 'Site B', title: '站点不匹配' }),
|
||||||
|
createTorrent({ releaseGroup: 'Team A', resolution: '2160p', site: 'Site A', title: '匹配二' }),
|
||||||
|
]
|
||||||
|
filter.filterForm.site = ['Site A']
|
||||||
|
filter.filterForm.releaseGroup = ['Team A']
|
||||||
|
filter.filterForm.resolution = ['2160p']
|
||||||
|
|
||||||
|
const result = filter.filterRowData(torrents)
|
||||||
|
|
||||||
|
expect(result.map(item => item.torrent_info.title)).toEqual(['匹配一', '匹配二'])
|
||||||
|
expect(filter.getFilteredIndices()).toEqual([0, 4])
|
||||||
|
expect(filter.totalFilteredCount.value).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['default', ['默认小', '默认大']],
|
||||||
|
['site', ['站点 A', '站点 B']],
|
||||||
|
['size', ['体积小', '体积大']],
|
||||||
|
['seeder', ['做种少', '做种多']],
|
||||||
|
['publishTime', ['发布时间早', '发布时间晚']],
|
||||||
|
])('sorts rows by %s in both directions', (sortField, ascendingTitles) => {
|
||||||
|
const filter = useTorrentFilter()
|
||||||
|
const titles = {
|
||||||
|
default: ['默认大', '默认小'],
|
||||||
|
publishTime: ['发布时间晚', '发布时间早'],
|
||||||
|
seeder: ['做种多', '做种少'],
|
||||||
|
site: ['站点 B', '站点 A'],
|
||||||
|
size: ['体积大', '体积小'],
|
||||||
|
}
|
||||||
|
const torrents = [
|
||||||
|
createTorrent({
|
||||||
|
priOrder: 20,
|
||||||
|
publishTime: '2025-02-01T00:00:00Z',
|
||||||
|
seeders: 20,
|
||||||
|
site: 'Site B',
|
||||||
|
size: 20,
|
||||||
|
title: titles[sortField as keyof typeof titles][0],
|
||||||
|
}),
|
||||||
|
createTorrent({
|
||||||
|
priOrder: 10,
|
||||||
|
publishTime: '2025-01-01T00:00:00Z',
|
||||||
|
seeders: 10,
|
||||||
|
site: 'Site A',
|
||||||
|
size: 10,
|
||||||
|
title: titles[sortField as keyof typeof titles][1],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
filter.sortField.value = sortField
|
||||||
|
filter.sortType.value = 'asc'
|
||||||
|
|
||||||
|
expect(filter.filterRowData(torrents).map(item => item.torrent_info.title)).toEqual(ascendingTitles)
|
||||||
|
|
||||||
|
filter.sortType.value = 'desc'
|
||||||
|
expect(filter.filterRowData(torrents).map(item => item.torrent_info.title)).toEqual([...ascendingTitles].reverse())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups matching cards while preserving every member original index for downstream consumers', () => {
|
||||||
|
const filter = useTorrentFilter()
|
||||||
|
const torrents = [
|
||||||
|
createTorrent({ pageUrl: 'https://example.test/a', site: 'Site A', title: '同组一' }),
|
||||||
|
createTorrent({ pageUrl: 'https://example.test/b', site: 'Site A', title: '同组二' }),
|
||||||
|
createTorrent({ name: '另一媒体', pageUrl: 'https://example.test/c', site: 'Site B', title: '另一组' }),
|
||||||
|
]
|
||||||
|
filter.filterForm.site = ['Site A']
|
||||||
|
|
||||||
|
const result = filter.filterCardData(torrents)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].torrent_info.title).toBe('同组一')
|
||||||
|
expect(result[0].more?.map(item => item.torrent_info.title)).toEqual(['同组二'])
|
||||||
|
expect(filter.totalFilteredCount.value).toBe(2)
|
||||||
|
expect(filter.getFilteredIndices()).toEqual([0, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['site', ['站点 A', '站点 B']],
|
||||||
|
['size', ['体积小', '体积大']],
|
||||||
|
['seeder', ['做种少', '做种多']],
|
||||||
|
['publishTime', ['发布时间早', '发布时间晚']],
|
||||||
|
])('sorts grouped cards by %s while keeping their original indices aligned', (sortField, ascendingTitles) => {
|
||||||
|
const filter = useTorrentFilter()
|
||||||
|
const titles = {
|
||||||
|
publishTime: ['发布时间晚', '发布时间早'],
|
||||||
|
seeder: ['做种多', '做种少'],
|
||||||
|
site: ['站点 B', '站点 A'],
|
||||||
|
size: ['体积大', '体积小'],
|
||||||
|
}
|
||||||
|
const torrents = [
|
||||||
|
createTorrent({
|
||||||
|
name: '媒体一',
|
||||||
|
priOrder: 20,
|
||||||
|
publishTime: '2025-02-01T00:00:00Z',
|
||||||
|
seeders: 20,
|
||||||
|
site: 'Site B',
|
||||||
|
size: 20,
|
||||||
|
title: titles[sortField as keyof typeof titles][0],
|
||||||
|
}),
|
||||||
|
createTorrent({
|
||||||
|
name: '媒体二',
|
||||||
|
priOrder: 10,
|
||||||
|
publishTime: '2025-01-01T00:00:00Z',
|
||||||
|
seeders: 10,
|
||||||
|
site: 'Site A',
|
||||||
|
size: 10,
|
||||||
|
title: titles[sortField as keyof typeof titles][1],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
filter.sortField.value = sortField
|
||||||
|
filter.sortType.value = 'asc'
|
||||||
|
|
||||||
|
expect(filter.filterCardData(torrents).map(item => item.torrent_info.title)).toEqual(ascendingTitles)
|
||||||
|
expect(filter.getFilteredIndices()).toEqual([1, 0])
|
||||||
|
|
||||||
|
filter.sortType.value = 'desc'
|
||||||
|
expect(filter.filterCardData(torrents).map(item => item.torrent_info.title)).toEqual([...ascendingTitles].reverse())
|
||||||
|
expect(filter.getFilteredIndices()).toEqual([0, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps grouped-card sorting stable when comparable torrent metadata is missing', () => {
|
||||||
|
const cases: Array<[string, TorrentOverrides]> = [
|
||||||
|
['site', { site: '' }],
|
||||||
|
['size', { size: 0 }],
|
||||||
|
['seeder', { seeders: 0 }],
|
||||||
|
['publishTime', { publishTime: '' }],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [sortField, overrides] of cases) {
|
||||||
|
const filter = useTorrentFilter()
|
||||||
|
filter.sortField.value = sortField
|
||||||
|
filter.sortType.value = 'asc'
|
||||||
|
const torrents = [
|
||||||
|
createTorrent({ ...overrides, name: `${sortField}-一`, title: `${sortField}-一` }),
|
||||||
|
createTorrent({ ...overrides, name: `${sortField}-二`, title: `${sortField}-二` }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(filter.filterCardData(torrents).map(item => item.torrent_info.title)).toEqual([
|
||||||
|
`${sortField}-一`,
|
||||||
|
`${sortField}-二`,
|
||||||
|
])
|
||||||
|
expect(filter.getFilteredIndices()).toEqual([0, 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses empty controlled defaults and toggles the sort direction in both directions', () => {
|
||||||
|
const filter = useTorrentFilter()
|
||||||
|
|
||||||
|
expect(filter.hasActiveFilters()).toBe(false)
|
||||||
|
filter.setFilterForm({ site: ['Site A'] })
|
||||||
|
expect(filter.filterForm.site).toEqual(['Site A'])
|
||||||
|
expect(filter.filterForm.resolution).toEqual([])
|
||||||
|
|
||||||
|
expect(filter.sortType.value).toBe('desc')
|
||||||
|
filter.handleSortIconClick()
|
||||||
|
expect(filter.sortType.value).toBe('asc')
|
||||||
|
filter.handleSortIconClick()
|
||||||
|
expect(filter.sortType.value).toBe('desc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports controlled filter actions and restores a detached state snapshot', () => {
|
||||||
|
const filter = useTorrentFilter()
|
||||||
|
filter.filterRowData([createTorrent({ site: 'Site A' }), createTorrent({ site: 'Site B' })])
|
||||||
|
|
||||||
|
filter.selectAll('site')
|
||||||
|
filter.filterForm.resolution = ['1080p']
|
||||||
|
filter.sortField.value = 'size'
|
||||||
|
filter.sortType.value = 'asc'
|
||||||
|
const snapshot = filter.getFilterState()
|
||||||
|
|
||||||
|
expect(filter.getFilterCount.value).toBe(3)
|
||||||
|
expect(filter.getSelectedFilters.value).toEqual({
|
||||||
|
resolution: ['1080p'],
|
||||||
|
site: ['Site A', 'Site B'],
|
||||||
|
})
|
||||||
|
expect(filter.hasActiveFilters()).toBe(true)
|
||||||
|
|
||||||
|
filter.removeFilter('site', 'Site A')
|
||||||
|
filter.clearFilter('resolution')
|
||||||
|
filter.clearAllFilters()
|
||||||
|
filter.sortField.value = 'default'
|
||||||
|
filter.sortType.value = 'desc'
|
||||||
|
filter.setFilterState(snapshot)
|
||||||
|
|
||||||
|
expect(filter.getFilterForm()).toEqual(snapshot.filterForm)
|
||||||
|
expect(filter.sortField.value).toBe('size')
|
||||||
|
expect(filter.sortType.value).toBe('asc')
|
||||||
|
snapshot.filterForm.site.push('mutated')
|
||||||
|
expect(filter.filterForm.site).toEqual(['Site A', 'Site B'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -171,7 +171,7 @@ export function useTorrentFilter() {
|
|||||||
function filterRowData(items: Context[] | undefined): Context[] {
|
function filterRowData(items: Context[] | undefined): Context[] {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
filteredIndices.value = []
|
filteredIndices.value = []
|
||||||
|
|
||||||
// 清空并重新初始化过滤选项
|
// 清空并重新初始化过滤选项
|
||||||
for (const key in filterOptions) {
|
for (const key in filterOptions) {
|
||||||
filterOptions[key] = []
|
filterOptions[key] = []
|
||||||
@@ -255,8 +255,8 @@ export function useTorrentFilter() {
|
|||||||
// 筛选数据
|
// 筛选数据
|
||||||
const filteredData: SearchTorrent[] = []
|
const filteredData: SearchTorrent[] = []
|
||||||
let matchCount = 0
|
let matchCount = 0
|
||||||
// 临时存储:每个分组的第一个原始索引
|
// 每张分组卡片保留全部匹配资源的原始索引,供后续批量处理复用。
|
||||||
const groupIndexMap = new Map<SearchTorrent, number>()
|
const groupIndicesMap = new Map<SearchTorrent, number[]>()
|
||||||
|
|
||||||
groupMap.forEach(value => {
|
groupMap.forEach(value => {
|
||||||
if (value.length > 0) {
|
if (value.length > 0) {
|
||||||
@@ -278,8 +278,10 @@ export function useTorrentFilter() {
|
|||||||
const firstData = cloneDeepWith(firstItem.data) as SearchTorrent
|
const firstData = cloneDeepWith(firstItem.data) as SearchTorrent
|
||||||
if (matchData.length > 1) firstData.more = matchData.slice(1).map(x => x.data)
|
if (matchData.length > 1) firstData.more = matchData.slice(1).map(x => x.data)
|
||||||
filteredData.push(firstData)
|
filteredData.push(firstData)
|
||||||
// 存储该分组的第一个原始索引
|
groupIndicesMap.set(
|
||||||
groupIndexMap.set(firstData, firstItem.originalIndex)
|
firstData,
|
||||||
|
matchData.map(item => item.originalIndex),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -289,8 +291,8 @@ export function useTorrentFilter() {
|
|||||||
// 排序数据
|
// 排序数据
|
||||||
const sortedData = sortCardData(filteredData)
|
const sortedData = sortCardData(filteredData)
|
||||||
|
|
||||||
// 在排序后重新构建 filteredIndices,保持与排序后顺序一致
|
// 索引顺序跟随排序后的卡片分组,同时保留组内的原始资源顺序。
|
||||||
filteredIndices.value = sortedData.map(item => groupIndexMap.get(item) || 0)
|
filteredIndices.value = sortedData.flatMap(item => groupIndicesMap.get(item) ?? [])
|
||||||
|
|
||||||
// 确保季集选项排序
|
// 确保季集选项排序
|
||||||
if (filterOptions.season.length > 0) {
|
if (filterOptions.season.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,897 @@
|
|||||||
|
import type { Context, SubtitleInfo } from '@/api/types'
|
||||||
|
import ResourcePage from '@/pages/resource.vue'
|
||||||
|
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { defineComponent, nextTick } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
dynamicButtonOptions: undefined as unknown as { onClick: () => void },
|
||||||
|
keepAliveRefresh: undefined as unknown as () => Promise<void>,
|
||||||
|
toastError: vi.fn(),
|
||||||
|
useDynamicButton: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||||
|
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useDynamicButton', () => ({
|
||||||
|
useDynamicButton: (options: { onClick: () => void }) => {
|
||||||
|
mocks.dynamicButtonOptions = options
|
||||||
|
mocks.useDynamicButton(options)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useKeepAliveRefresh', () => ({
|
||||||
|
useKeepAliveRefresh: (refresh: () => Promise<void>) => {
|
||||||
|
mocks.keepAliveRefresh = refresh
|
||||||
|
return { refresh }
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePWA', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
return {
|
||||||
|
usePWA: () => ({ appMode: ref(false) }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
error: mocks.toastError,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
class EventSourceFake {
|
||||||
|
static readonly CLOSED = 2
|
||||||
|
static readonly CONNECTING = 0
|
||||||
|
static readonly OPEN = 1
|
||||||
|
static instances: EventSourceFake[] = []
|
||||||
|
|
||||||
|
readonly CLOSED = EventSourceFake.CLOSED
|
||||||
|
readonly CONNECTING = EventSourceFake.CONNECTING
|
||||||
|
readonly OPEN = EventSourceFake.OPEN
|
||||||
|
readonly url: string
|
||||||
|
readonly withCredentials = false
|
||||||
|
onerror: ((event: Event) => void) | null = null
|
||||||
|
onmessage: ((event: MessageEvent<string>) => void) | null = null
|
||||||
|
onopen: ((event: Event) => void) | null = null
|
||||||
|
readyState = EventSourceFake.CONNECTING
|
||||||
|
closed = false
|
||||||
|
|
||||||
|
constructor(url: string | URL) {
|
||||||
|
this.url = String(url)
|
||||||
|
EventSourceFake.instances.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener() {}
|
||||||
|
dispatchEvent() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
removeEventListener() {}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true
|
||||||
|
this.readyState = EventSourceFake.CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
this.onerror?.(new Event('error'))
|
||||||
|
}
|
||||||
|
|
||||||
|
message(payload: Record<string, unknown>) {
|
||||||
|
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(payload) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TorrentOverrides {
|
||||||
|
name?: string
|
||||||
|
pageUrl?: string
|
||||||
|
site?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchRouteCase {
|
||||||
|
apiEndpoint: string
|
||||||
|
apiParams: Record<string, string>
|
||||||
|
displayTitle: string
|
||||||
|
expectedPath: string
|
||||||
|
query: Record<string, string>
|
||||||
|
result: Context | SubtitleInfo
|
||||||
|
streamParams: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTorrent(overrides: TorrentOverrides = {}): Context {
|
||||||
|
return {
|
||||||
|
media_info: {},
|
||||||
|
meta_info: {
|
||||||
|
edition: 'WEB-DL',
|
||||||
|
name: overrides.name ?? '测试媒体',
|
||||||
|
resource_pix: '1080p',
|
||||||
|
resource_team: 'Team A',
|
||||||
|
season_episode: 'S01',
|
||||||
|
video_encode: 'H.264',
|
||||||
|
},
|
||||||
|
torrent_info: {
|
||||||
|
page_url: overrides.pageUrl ?? `https://example.test/${overrides.title ?? 'torrent'}`,
|
||||||
|
pri_order: 0,
|
||||||
|
pubdate: '2025-01-01T00:00:00Z',
|
||||||
|
seeders: 1,
|
||||||
|
site_name: overrides.site ?? 'Site A',
|
||||||
|
size: 1,
|
||||||
|
title: overrides.title ?? '测试资源',
|
||||||
|
volume_factor: 'FREE',
|
||||||
|
},
|
||||||
|
} as Context
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSubtitle(title: string): SubtitleInfo {
|
||||||
|
return {
|
||||||
|
enclosure: `https://example.test/${title}.srt`,
|
||||||
|
site_name: 'Subtitle Site',
|
||||||
|
subtitle_id: title,
|
||||||
|
title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TorrentCardStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
torrent: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
template: '<article data-testid="torrent-card">{{ torrent.torrent_info.title }}</article>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TorrentItemStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
torrent: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
template: '<article data-testid="torrent-row">{{ torrent.torrent_info.title }}</article>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const SubtitleCardStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
subtitle: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
template: '<article data-testid="subtitle-card">{{ subtitle.title }}</article>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const SubtitleItemStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
subtitle: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
template: '<article data-testid="subtitle-row">{{ subtitle.title }}</article>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProgressiveCardGridStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
items: { type: Array, required: true },
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<template v-for="(item, index) in items" :key="index">
|
||||||
|
<slot :item="item" :index="index" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const TorrentFilterBarStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
filterForm: { type: Object, required: true },
|
||||||
|
sortField: { type: String, required: true },
|
||||||
|
},
|
||||||
|
emits: ['update:filterForm', 'update:sortField'],
|
||||||
|
template: `
|
||||||
|
<section
|
||||||
|
data-testid="torrent-filter-bar"
|
||||||
|
:data-sites="filterForm.site.join(',')"
|
||||||
|
:data-sort-field="sortField"
|
||||||
|
>
|
||||||
|
<button type="button" @click="$emit('update:filterForm', 'site', ['Site A'])">筛选 Site A</button>
|
||||||
|
<button type="button" @click="$emit('update:sortField', 'size')">按体积排序</button>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const NoDataFoundStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
errorDescription: { type: String, default: '' },
|
||||||
|
errorTitle: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
template: '<section data-testid="no-data">{{ errorTitle }} {{ errorDescription }}</section>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const PassThroughStub = defineComponent({
|
||||||
|
template: '<div><slot /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const RefreshButtonStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
disabled: Boolean,
|
||||||
|
loading: Boolean,
|
||||||
|
},
|
||||||
|
emits: ['click'],
|
||||||
|
template:
|
||||||
|
'<button type="button" aria-label="重新搜索" :disabled="disabled || loading" @click="$emit(\'click\')"><slot /></button>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageStubs = {
|
||||||
|
IconBtn: RefreshButtonStub,
|
||||||
|
LoadingBanner: PassThroughStub,
|
||||||
|
NoDataFound: NoDataFoundStub,
|
||||||
|
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||||
|
SubtitleCard: SubtitleCardStub,
|
||||||
|
SubtitleItem: SubtitleItemStub,
|
||||||
|
Teleport: true,
|
||||||
|
TorrentCard: TorrentCardStub,
|
||||||
|
TorrentFilterBar: TorrentFilterBarStub,
|
||||||
|
TorrentItem: TorrentItemStub,
|
||||||
|
VExpandXTransition: PassThroughStub,
|
||||||
|
VFadeTransition: PassThroughStub,
|
||||||
|
VScrollToTopBtn: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderResource(
|
||||||
|
initialRoute: { path: string; query?: Record<string, string> } = { path: '/resource' },
|
||||||
|
aiEnabled = false,
|
||||||
|
) {
|
||||||
|
return renderWithProviders(ResourcePage, {
|
||||||
|
initialRoute,
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { AI_RECOMMEND_ENABLED: aiEnabled },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
permissions: { ...DEFAULT_PERMISSIONS, search: true },
|
||||||
|
superUser: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stubActions: false,
|
||||||
|
global: { stubs: pageStubs },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestEventSource(expectedCount = 1) {
|
||||||
|
await waitFor(() => expect(EventSourceFake.instances).toHaveLength(expectedCount))
|
||||||
|
return EventSourceFake.instances[expectedCount - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishStream(source: EventSourceFake, items: Array<Context | SubtitleInfo>) {
|
||||||
|
source.message({
|
||||||
|
items,
|
||||||
|
total_items: items.length,
|
||||||
|
type: 'replace',
|
||||||
|
value: 100,
|
||||||
|
})
|
||||||
|
source.message({ type: 'done' })
|
||||||
|
source.fail()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsyncWork() {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await nextTick()
|
||||||
|
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchRouteCases: SearchRouteCase[] = [
|
||||||
|
{
|
||||||
|
apiEndpoint: 'search/title',
|
||||||
|
apiParams: { keyword: '普通标题', sites: '1,2' },
|
||||||
|
displayTitle: '普通标题结果',
|
||||||
|
expectedPath: '/api/v1/search/title/stream',
|
||||||
|
query: { keyword: '普通标题', result_type: 'invalid', sites: '1,2' },
|
||||||
|
result: createTorrent({ title: '普通标题结果' }),
|
||||||
|
streamParams: { keyword: '普通标题', sites: '1,2' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
apiEndpoint: 'search/media/tmdb:42',
|
||||||
|
apiParams: {
|
||||||
|
area: 'CN',
|
||||||
|
mtype: '电视剧',
|
||||||
|
season: '2',
|
||||||
|
sites: '1',
|
||||||
|
title: '媒体标题',
|
||||||
|
year: '2025',
|
||||||
|
},
|
||||||
|
displayTitle: '媒体资源结果',
|
||||||
|
expectedPath: '/api/v1/search/media/tmdb%3A42/stream',
|
||||||
|
query: {
|
||||||
|
area: 'CN',
|
||||||
|
keyword: 'tmdb:42',
|
||||||
|
result_type: 'torrent',
|
||||||
|
season: '2',
|
||||||
|
sites: '1',
|
||||||
|
title: '媒体标题',
|
||||||
|
type: '电视剧',
|
||||||
|
year: '2025',
|
||||||
|
},
|
||||||
|
result: createTorrent({ title: '媒体资源结果' }),
|
||||||
|
streamParams: {
|
||||||
|
area: 'CN',
|
||||||
|
mtype: '电视剧',
|
||||||
|
season: '2',
|
||||||
|
sites: '1',
|
||||||
|
title: '媒体标题',
|
||||||
|
year: '2025',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
apiEndpoint: 'search/subtitle/title',
|
||||||
|
apiParams: { keyword: '字幕标题', sites: '2' },
|
||||||
|
displayTitle: '字幕标题结果',
|
||||||
|
expectedPath: '/api/v1/search/subtitle/title/stream',
|
||||||
|
query: { keyword: '字幕标题', result_type: 'subtitle', sites: '2' },
|
||||||
|
result: createSubtitle('字幕标题结果'),
|
||||||
|
streamParams: { keyword: '字幕标题', sites: '2' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
apiEndpoint: 'search/subtitle/media/tmdb:84',
|
||||||
|
apiParams: {
|
||||||
|
episode: '3',
|
||||||
|
mtype: '电视剧',
|
||||||
|
season: '2',
|
||||||
|
sites: '2',
|
||||||
|
title: '字幕媒体',
|
||||||
|
year: '2025',
|
||||||
|
},
|
||||||
|
displayTitle: '字幕媒体结果',
|
||||||
|
expectedPath: '/api/v1/search/subtitle/media/tmdb%3A84/stream',
|
||||||
|
query: {
|
||||||
|
episode: '3',
|
||||||
|
keyword: 'tmdb:84',
|
||||||
|
result_type: 'subtitle',
|
||||||
|
season: '2',
|
||||||
|
sites: '2',
|
||||||
|
title: '字幕媒体',
|
||||||
|
type: '电视剧',
|
||||||
|
year: '2025',
|
||||||
|
},
|
||||||
|
result: createSubtitle('字幕媒体结果'),
|
||||||
|
streamParams: {
|
||||||
|
episode: '3',
|
||||||
|
mtype: '电视剧',
|
||||||
|
season: '2',
|
||||||
|
sites: '2',
|
||||||
|
title: '字幕媒体',
|
||||||
|
year: '2025',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('resource page search flow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
EventSourceFake.instances = []
|
||||||
|
vi.stubGlobal('EventSource', EventSourceFake)
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'search/last/context') {
|
||||||
|
return Promise.resolve({ success: true, data: { params: null, results: [] } })
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
mocks.apiPost.mockResolvedValue({ success: true, data: { status: 'disabled' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(searchRouteCases)(
|
||||||
|
'projects route input into the $apiEndpoint stream and exact fallback request',
|
||||||
|
async ({ apiEndpoint, apiParams, displayTitle, expectedPath, query, result, streamParams }) => {
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({ data: [result], success: true })
|
||||||
|
const rendered = await renderResource({ path: '/resource', query })
|
||||||
|
const source = await latestEventSource()
|
||||||
|
const streamUrl = new URL(source.url)
|
||||||
|
|
||||||
|
expect(streamUrl.pathname).toBe(expectedPath)
|
||||||
|
expect(Object.fromEntries(streamUrl.searchParams)).toEqual({
|
||||||
|
...streamParams,
|
||||||
|
_ts: expect.any(String),
|
||||||
|
locale: 'zh-CN',
|
||||||
|
})
|
||||||
|
|
||||||
|
source.fail()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith(apiEndpoint, {
|
||||||
|
params: {
|
||||||
|
...apiParams,
|
||||||
|
_ts: expect.any(String),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(await screen.findByText(displayTitle)).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
|
||||||
|
|
||||||
|
const storedParams = JSON.parse(localStorage.getItem('MP_ResourceSearchParams') || '{}')
|
||||||
|
expect(storedParams).toEqual({
|
||||||
|
area: query.area ?? '',
|
||||||
|
episode: query.episode ?? '',
|
||||||
|
keyword: query.keyword,
|
||||||
|
result_type: query.result_type === 'subtitle' ? 'subtitle' : 'torrent',
|
||||||
|
season: query.season ?? '',
|
||||||
|
sites: query.sites ?? '',
|
||||||
|
title: query.title ?? '',
|
||||||
|
type: query.type ?? '',
|
||||||
|
year: query.year ?? '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('clears damaged stored parameters and restores the last searchable context', async () => {
|
||||||
|
const restored = createTorrent({ title: '上次搜索结果' })
|
||||||
|
localStorage.setItem('MP_ResourceSearchParams', '{damaged')
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
params: { keyword: '上次关键词', result_type: 'invalid', sites: '3' },
|
||||||
|
results: [restored],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderResource()
|
||||||
|
|
||||||
|
expect(await screen.findByText('上次搜索结果')).toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('search/last/context')
|
||||||
|
expect(JSON.parse(localStorage.getItem('MP_ResourceSearchParams') || '{}')).toEqual({
|
||||||
|
area: '',
|
||||||
|
episode: '',
|
||||||
|
keyword: '上次关键词',
|
||||||
|
result_type: 'torrent',
|
||||||
|
season: '',
|
||||||
|
sites: '3',
|
||||||
|
title: '',
|
||||||
|
type: '',
|
||||||
|
year: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not automatically repeat a completed empty search when KeepAlive reactivates', async () => {
|
||||||
|
const rendered = await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '没有结果', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
finishStream(source, [])
|
||||||
|
await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
|
||||||
|
|
||||||
|
await mocks.keepAliveRefresh()
|
||||||
|
|
||||||
|
expect(EventSourceFake.instances).toHaveLength(1)
|
||||||
|
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
item: createTorrent({ title: '流式资源预览' }),
|
||||||
|
query: { keyword: '流式资源', result_type: 'torrent' },
|
||||||
|
testId: 'torrent-card',
|
||||||
|
title: '流式资源预览',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item: createSubtitle('流式字幕预览'),
|
||||||
|
query: { keyword: '流式字幕', result_type: 'subtitle' },
|
||||||
|
testId: 'subtitle-card',
|
||||||
|
title: '流式字幕预览',
|
||||||
|
},
|
||||||
|
])('shows $title from an append event before the final result arrives', async ({ item, query, testId, title }) => {
|
||||||
|
await renderResource({ path: '/resource', query })
|
||||||
|
const source = await latestEventSource()
|
||||||
|
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
source.message({
|
||||||
|
items: [item],
|
||||||
|
text: '正在聚合搜索结果',
|
||||||
|
total_items: 2,
|
||||||
|
type: 'append',
|
||||||
|
value: 25,
|
||||||
|
})
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(screen.getByTestId(testId)).toHaveTextContent(title)
|
||||||
|
source.message({ type: 'done' })
|
||||||
|
source.fail()
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the SSE business error instead of replacing it with the generic empty-state message', async () => {
|
||||||
|
const rendered = await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '业务失败', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
|
||||||
|
source.message({
|
||||||
|
message: '搜索服务暂不可用',
|
||||||
|
type: 'error',
|
||||||
|
value: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('no-data')).toHaveTextContent('搜索服务暂不可用')
|
||||||
|
await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the default empty-state message when a new search succeeds without results', async () => {
|
||||||
|
const rendered = await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '业务失败', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const failedSource = await latestEventSource()
|
||||||
|
failedSource.message({
|
||||||
|
message: '搜索服务暂不可用',
|
||||||
|
type: 'error',
|
||||||
|
value: 100,
|
||||||
|
})
|
||||||
|
expect(await screen.findByTestId('no-data')).toHaveTextContent('搜索服务暂不可用')
|
||||||
|
|
||||||
|
await rendered.router.push({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '正常空结果', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const emptySource = await latestEventSource(2)
|
||||||
|
finishStream(emptySource, [])
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源')
|
||||||
|
expect(screen.queryByText('搜索服务暂不可用')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the default empty-state message when the fallback request succeeds without results', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({ data: [], success: true })
|
||||||
|
const rendered = await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '降级空结果', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
|
||||||
|
source.fail()
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源')
|
||||||
|
expect(screen.queryByText('搜索连接已断开')).not.toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores heartbeat messages and accepts final results carried only by the done event', async () => {
|
||||||
|
await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '收尾结果', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
const result = createTorrent({ title: '仅在完成消息返回' })
|
||||||
|
|
||||||
|
source.message({ type: 'heartbeat' })
|
||||||
|
expect(screen.queryByText('仅在完成消息返回')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
source.message({
|
||||||
|
items: [result],
|
||||||
|
total_items: 1,
|
||||||
|
type: 'done',
|
||||||
|
value: 100,
|
||||||
|
})
|
||||||
|
source.fail()
|
||||||
|
|
||||||
|
expect(await screen.findByText('仅在完成消息返回')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silently refreshes existing results and keeps them visible until the replacement is complete', async () => {
|
||||||
|
const oldResult = createTorrent({ title: '现有结果' })
|
||||||
|
const newResult = createTorrent({ title: '静默刷新结果' })
|
||||||
|
await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '可刷新', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
finishStream(source, [oldResult])
|
||||||
|
expect(await screen.findByText('现有结果')).toBeInTheDocument()
|
||||||
|
|
||||||
|
let resolveRefresh!: (value: unknown) => void
|
||||||
|
mocks.apiGet.mockReturnValueOnce(
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolveRefresh = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const refreshPromise = mocks.keepAliveRefresh()
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
expect(screen.getByText('现有结果')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('静默刷新结果')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
resolveRefresh({ data: [newResult], success: true })
|
||||||
|
await refreshPromise
|
||||||
|
|
||||||
|
expect(await screen.findByText('静默刷新结果')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('现有结果')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the default empty-state message after a failed silent refresh is followed by an empty success', async () => {
|
||||||
|
await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '静默刷新空态', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
finishStream(source, [createTorrent({ title: '刷新前结果' })])
|
||||||
|
expect(await screen.findByText('刷新前结果')).toBeInTheDocument()
|
||||||
|
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({ message: '静默刷新失败', success: false })
|
||||||
|
await expect(mocks.keepAliveRefresh()).rejects.toThrow('静默刷新失败')
|
||||||
|
expect(screen.getByText('刷新前结果')).toBeInTheDocument()
|
||||||
|
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({ data: [], success: true })
|
||||||
|
await mocks.keepAliveRefresh()
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源')
|
||||||
|
expect(screen.queryByText('静默刷新失败')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to card view for an invalid persisted view type', async () => {
|
||||||
|
localStorage.setItem('MPTorrentsViewType', 'removed-view')
|
||||||
|
await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '视图回退', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
finishStream(source, [createTorrent({ title: '可见卡片结果' })])
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('torrent-card')).toHaveTextContent('可见卡片结果')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores row view and persists the dynamic card/row toggle', async () => {
|
||||||
|
localStorage.setItem('MPTorrentsViewType', 'row')
|
||||||
|
await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '视图切换', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
finishStream(source, [createTorrent({ title: '切换视图结果' })])
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('torrent-row')).toHaveTextContent('切换视图结果')
|
||||||
|
|
||||||
|
mocks.dynamicButtonOptions.onClick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('torrent-card')).toHaveTextContent('切换视图结果')
|
||||||
|
expect(localStorage.getItem('MPTorrentsViewType')).toBe('card')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears filters for a new route search', async () => {
|
||||||
|
const rendered = await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '首次搜索', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const firstSource = await latestEventSource()
|
||||||
|
finishStream(firstSource, [createTorrent({ title: '首次结果' })])
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '筛选 Site A' }))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('torrent-filter-bar')).toHaveAttribute('data-sites', 'Site A'))
|
||||||
|
|
||||||
|
await rendered.router.push({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '第二次搜索', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const secondSource = await latestEventSource(2)
|
||||||
|
finishStream(secondSource, [createTorrent({ title: '第二次结果' })])
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('torrent-filter-bar')).toHaveAttribute('data-sites', ''))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a late fallback response from an old search replace newer stream results', async () => {
|
||||||
|
let resolveOldRequest!: (value: unknown) => void
|
||||||
|
mocks.apiGet.mockReturnValueOnce(
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolveOldRequest = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const rendered = await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '旧搜索', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const firstSource = await latestEventSource()
|
||||||
|
firstSource.fail()
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
await rendered.router.push({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '新搜索', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const secondSource = await latestEventSource(2)
|
||||||
|
finishStream(secondSource, [createTorrent({ title: '新结果' })])
|
||||||
|
expect(await screen.findByText('新结果')).toBeInTheDocument()
|
||||||
|
|
||||||
|
resolveOldRequest({ data: [createTorrent({ title: '旧结果' })], success: true })
|
||||||
|
await flushAsyncWork()
|
||||||
|
|
||||||
|
expect(screen.getByText('新结果')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('旧结果')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates a pending fallback request when the page unmounts', async () => {
|
||||||
|
let resolveRequest!: (value: unknown) => void
|
||||||
|
mocks.apiGet.mockReturnValueOnce(
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolveRequest = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const initialQuery = { keyword: '卸载中的降级请求', result_type: 'torrent' }
|
||||||
|
const rendered = await renderResource({ path: '/resource', query: initialQuery })
|
||||||
|
const source = await latestEventSource()
|
||||||
|
source.fail()
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
|
||||||
|
expect(rendered.router.currentRoute.value.query).toEqual(initialQuery)
|
||||||
|
const replaceSpy = vi.spyOn(rendered.router, 'replace')
|
||||||
|
|
||||||
|
rendered.unmount()
|
||||||
|
resolveRequest({ data: [createTorrent({ title: '卸载后结果' })], success: true })
|
||||||
|
await flushAsyncWork()
|
||||||
|
|
||||||
|
expect(replaceSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes an active stream and ignores its completion when the page unmounts', async () => {
|
||||||
|
const initialQuery = { keyword: '卸载中的流', result_type: 'torrent' }
|
||||||
|
const rendered = await renderResource({ path: '/resource', query: initialQuery })
|
||||||
|
const source = await latestEventSource()
|
||||||
|
expect(rendered.router.currentRoute.value.query).toEqual(initialQuery)
|
||||||
|
const replaceSpy = vi.spyOn(rendered.router, 'replace')
|
||||||
|
|
||||||
|
rendered.unmount()
|
||||||
|
source.message({ items: [], total_items: 0, type: 'done', value: 100 })
|
||||||
|
source.fail()
|
||||||
|
await flushAsyncWork()
|
||||||
|
|
||||||
|
expect(source.closed).toBe(true)
|
||||||
|
expect(replaceSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settles an active refresh stream when a newer route search replaces it', async () => {
|
||||||
|
const rendered = await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '初始搜索', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const initialSource = await latestEventSource()
|
||||||
|
finishStream(initialSource, [createTorrent({ title: '初始结果' })])
|
||||||
|
await screen.findByText('初始结果')
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '重新搜索' }))
|
||||||
|
const refreshSource = await latestEventSource(2)
|
||||||
|
|
||||||
|
await rendered.router.push({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '替代搜索', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const replacementSource = await latestEventSource(3)
|
||||||
|
expect(refreshSource.closed).toBe(true)
|
||||||
|
finishStream(replacementSource, [createTorrent({ title: '替代结果' })])
|
||||||
|
|
||||||
|
expect(await screen.findByText('替代结果')).toBeInTheDocument()
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '重新搜索' }))
|
||||||
|
await latestEventSource(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a late business failure from an old fallback replace the current empty-state message', async () => {
|
||||||
|
let resolveOldRequest!: (value: unknown) => void
|
||||||
|
mocks.apiGet.mockReturnValueOnce(
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolveOldRequest = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const rendered = await renderResource({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '旧失败搜索', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const firstSource = await latestEventSource()
|
||||||
|
firstSource.fail()
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
await rendered.router.push({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '当前空搜索', result_type: 'torrent' },
|
||||||
|
})
|
||||||
|
const secondSource = await latestEventSource(2)
|
||||||
|
finishStream(secondSource, [])
|
||||||
|
await waitFor(() => expect(screen.getByTestId('no-data')).toBeInTheDocument())
|
||||||
|
|
||||||
|
resolveOldRequest({ message: '过期业务错误', success: false })
|
||||||
|
await flushAsyncWork()
|
||||||
|
|
||||||
|
expect(screen.queryByText(/过期业务错误/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a late last-search context replace newer replay parameters', async () => {
|
||||||
|
let resolveLastContext!: (value: unknown) => void
|
||||||
|
mocks.apiGet.mockReturnValueOnce(
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolveLastContext = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const rendered = await renderResource()
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('search/last/context'))
|
||||||
|
|
||||||
|
await rendered.router.push({
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: '当前搜索', result_type: 'torrent', sites: '2' },
|
||||||
|
})
|
||||||
|
const source = await latestEventSource()
|
||||||
|
finishStream(source, [createTorrent({ title: '当前结果' })])
|
||||||
|
expect(await screen.findByText('当前结果')).toBeInTheDocument()
|
||||||
|
|
||||||
|
resolveLastContext({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
params: { keyword: '过期搜索', result_type: 'torrent', sites: '1' },
|
||||||
|
results: [createTorrent({ title: '过期结果' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushAsyncWork()
|
||||||
|
|
||||||
|
expect(JSON.parse(localStorage.getItem('MP_ResourceSearchParams') || '{}')).toMatchObject({
|
||||||
|
keyword: '当前搜索',
|
||||||
|
sites: '2',
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('过期结果')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits every grouped match to AI and restores filters after returning to original results', async () => {
|
||||||
|
let statusChecks = 0
|
||||||
|
mocks.apiPost.mockImplementation((_endpoint: string, body: Record<string, unknown>) => {
|
||||||
|
if (body.check_only) {
|
||||||
|
statusChecks += 1
|
||||||
|
return Promise.resolve(
|
||||||
|
statusChecks === 1
|
||||||
|
? { data: { status: 'idle' }, success: true }
|
||||||
|
: { data: { results: [0], status: 'completed' }, success: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true })
|
||||||
|
})
|
||||||
|
await renderResource(
|
||||||
|
{
|
||||||
|
path: '/resource',
|
||||||
|
query: { keyword: 'AI 搜索', result_type: 'torrent' },
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
const source = await latestEventSource()
|
||||||
|
finishStream(source, [
|
||||||
|
createTorrent({ pageUrl: 'https://example.test/group-a', site: 'Site A', title: '同组一' }),
|
||||||
|
createTorrent({ pageUrl: 'https://example.test/group-b', site: 'Site A', title: '同组二' }),
|
||||||
|
createTorrent({ name: '其他媒体', pageUrl: 'https://example.test/other', site: 'Site B', title: '其他结果' }),
|
||||||
|
])
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '筛选 Site A' }))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('torrent-filter-bar')).toHaveAttribute('data-sites', 'Site A'))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
mocks.apiPost.mock.calls.some(
|
||||||
|
([endpoint, body]) =>
|
||||||
|
endpoint === 'search/recommend' && (body as Record<string, unknown>).check_only === true,
|
||||||
|
),
|
||||||
|
).toBe(true),
|
||||||
|
)
|
||||||
|
const aiButton = screen.getAllByRole('button', { name: /智能推荐/ })[0]
|
||||||
|
await waitFor(() => expect(aiButton).not.toBeDisabled())
|
||||||
|
|
||||||
|
await fireEvent.click(aiButton)
|
||||||
|
await waitFor(() => {
|
||||||
|
const initialRequest = mocks.apiPost.mock.calls.find(
|
||||||
|
([endpoint, body]) => endpoint === 'search/recommend' && !(body as Record<string, unknown>).check_only,
|
||||||
|
)
|
||||||
|
expect(initialRequest?.[1]).toEqual({ filtered_indices: [0, 1] })
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(screen.getByTestId('torrent-filter-bar')).toHaveAttribute('data-sites', ''))
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getAllByRole('button', { name: /智能推荐/ })[0])
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('torrent-filter-bar')).toHaveAttribute('data-sites', 'Site A'))
|
||||||
|
})
|
||||||
|
})
|
||||||
+51
-17
@@ -64,6 +64,9 @@ interface LastSearchContextResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resourceSearchParamsStorageKey = 'MP_ResourceSearchParams'
|
const resourceSearchParamsStorageKey = 'MP_ResourceSearchParams'
|
||||||
|
type TorrentViewType = 'card' | 'row'
|
||||||
|
// 只有最新搜索可以提交结果和可重放参数,避免旧请求覆盖新查询。
|
||||||
|
let activeSearchRequestId = 0
|
||||||
|
|
||||||
function createSearchParams(query: LocationQuery): SearchParams {
|
function createSearchParams(query: LocationQuery): SearchParams {
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +104,10 @@ function createSearchRequestToken(): string {
|
|||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTorrentViewType(value: string | null): TorrentViewType {
|
||||||
|
return value === 'row' ? 'row' : 'card'
|
||||||
|
}
|
||||||
|
|
||||||
function loadStoredSearchParams(): SearchParams | null {
|
function loadStoredSearchParams(): SearchParams | null {
|
||||||
try {
|
try {
|
||||||
const rawParams = localStorage.getItem(resourceSearchParamsStorageKey)
|
const rawParams = localStorage.getItem(resourceSearchParamsStorageKey)
|
||||||
@@ -150,9 +157,12 @@ if (hasSearchKeyword(initialSearchParams)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLastSearchContext() {
|
async function fetchLastSearchContext() {
|
||||||
|
const requestId = activeSearchRequestId
|
||||||
try {
|
try {
|
||||||
const result = (await api.get('search/last/context')) as LastSearchContextResponse
|
const result = (await api.get('search/last/context')) as LastSearchContextResponse
|
||||||
applyRememberedSearchParams(result?.data?.params, true)
|
if (requestId === activeSearchRequestId) {
|
||||||
|
applyRememberedSearchParams(result?.data?.params, true)
|
||||||
|
}
|
||||||
return Array.isArray(result?.data?.results) ? result.data.results : []
|
return Array.isArray(result?.data?.results) ? result.data.results : []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('读取上次搜索上下文失败,回退到仅加载结果:', error)
|
console.warn('读取上次搜索上下文失败,回退到仅加载结果:', error)
|
||||||
@@ -211,7 +221,7 @@ const resultType = computed(() => (activeSearchParams.value.result_type === 'sub
|
|||||||
const isSubtitleSearch = computed(() => resultType.value === 'subtitle')
|
const isSubtitleSearch = computed(() => resultType.value === 'subtitle')
|
||||||
|
|
||||||
// 视图类型,从localStorage中读取
|
// 视图类型,从localStorage中读取
|
||||||
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
|
const viewType = ref<TorrentViewType>(normalizeTorrentViewType(localStorage.getItem('MPTorrentsViewType')))
|
||||||
|
|
||||||
// 智能推荐相关
|
// 智能推荐相关
|
||||||
// 从全局设置中获取 AI_RECOMMEND_ENABLED 状态
|
// 从全局设置中获取 AI_RECOMMEND_ENABLED 状态
|
||||||
@@ -309,6 +319,7 @@ const errorDescription = ref(t('resource.noResourceFound'))
|
|||||||
|
|
||||||
let searchEventSource: EventSource | null = null
|
let searchEventSource: EventSource | null = null
|
||||||
let searchStreamIdleTimer: ReturnType<typeof setTimeout> | null = null
|
let searchStreamIdleTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let cancelActiveSearchStream: (() => void) | null = null
|
||||||
|
|
||||||
const streamPreviewLimit = 24
|
const streamPreviewLimit = 24
|
||||||
const streamUiFlushDelay = 1000
|
const streamUiFlushDelay = 1000
|
||||||
@@ -581,6 +592,7 @@ function resetSearchResults() {
|
|||||||
clearStreamPreviewState(true)
|
clearStreamPreviewState(true)
|
||||||
// 新搜索开始时先回到未完成态,避免上一轮空态在 SSE 返回前抢先显示。
|
// 新搜索开始时先回到未完成态,避免上一轮空态在 SSE 返回前抢先显示。
|
||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
|
errorDescription.value = t('resource.noResourceFound')
|
||||||
rawDataList.value = []
|
rawDataList.value = []
|
||||||
rawSubtitleDataList.value = []
|
rawSubtitleDataList.value = []
|
||||||
originalDataList.value = []
|
originalDataList.value = []
|
||||||
@@ -758,14 +770,18 @@ function handleSearchStreamMessage(eventData: { [key: string]: any }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 按请求搜索
|
// 按请求搜索
|
||||||
async function searchByRequest(params: SearchParams, requestToken?: string) {
|
async function searchByRequest(params: SearchParams, requestToken: string | undefined, requestId: number) {
|
||||||
const items = await requestSearchResults(params, requestToken)
|
const items = await requestSearchResults(params, requestToken)
|
||||||
|
if (requestId !== activeSearchRequestId) return false
|
||||||
|
|
||||||
|
errorDescription.value = t('resource.noResourceFound')
|
||||||
streamTotalCount.value = items.length
|
streamTotalCount.value = items.length
|
||||||
if (params.result_type === 'subtitle') {
|
if (params.result_type === 'subtitle') {
|
||||||
setSubtitleStreamResults(items as SubtitleInfo[])
|
setSubtitleStreamResults(items as SubtitleInfo[])
|
||||||
} else {
|
} else {
|
||||||
setStreamResults(items as Context[])
|
setStreamResults(items as Context[])
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 静默刷新使用普通请求,保留当前结果直到新数据完整返回,避免返回页面时露出搜索进度态。
|
// 静默刷新使用普通请求,保留当前结果直到新数据完整返回,避免返回页面时露出搜索进度态。
|
||||||
@@ -820,15 +836,15 @@ async function requestSearchResults(params: SearchParams, requestToken?: string)
|
|||||||
return (result.data || []) as Array<Context | SubtitleInfo>
|
return (result.data || []) as Array<Context | SubtitleInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
errorDescription.value = result?.message || t('resource.noResourceFound')
|
throw new Error(result?.message || t('resource.noResourceFound'))
|
||||||
throw new Error(errorDescription.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按流搜索
|
// 按流搜索
|
||||||
function searchByStream(params: SearchParams, requestToken?: string) {
|
function searchByStream(params: SearchParams, requestToken?: string) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
// 新搜索必须显式结束上一轮 Promise;EventSource.close() 本身不会触发 error。
|
||||||
closeSearchEventSource()
|
cancelActiveSearchStream?.()
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
let settled = false
|
let settled = false
|
||||||
let receivedDone = false
|
let receivedDone = false
|
||||||
const source = new EventSource(buildSearchStreamUrl(params, requestToken))
|
const source = new EventSource(buildSearchStreamUrl(params, requestToken))
|
||||||
@@ -838,9 +854,14 @@ function searchByStream(params: SearchParams, requestToken?: string) {
|
|||||||
if (settled) return
|
if (settled) return
|
||||||
|
|
||||||
settled = true
|
settled = true
|
||||||
|
if (cancelActiveSearchStream === cancelThisSearch) {
|
||||||
|
cancelActiveSearchStream = null
|
||||||
|
}
|
||||||
closeSearchEventSource(source)
|
closeSearchEventSource(source)
|
||||||
callback()
|
callback()
|
||||||
}
|
}
|
||||||
|
const cancelThisSearch = () => settleSearchStream(resolve)
|
||||||
|
cancelActiveSearchStream = cancelThisSearch
|
||||||
|
|
||||||
const resetIdleTimeout = () => {
|
const resetIdleTimeout = () => {
|
||||||
clearSearchStreamIdleTimer()
|
clearSearchStreamIdleTimer()
|
||||||
@@ -893,7 +914,7 @@ function searchByStream(params: SearchParams, requestToken?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设置视图类型
|
// 设置视图类型
|
||||||
function changeViewType(newType: string) {
|
function changeViewType(newType: TorrentViewType) {
|
||||||
if (viewType.value !== newType) {
|
if (viewType.value !== newType) {
|
||||||
// 立即更新视图类型
|
// 立即更新视图类型
|
||||||
viewType.value = newType
|
viewType.value = newType
|
||||||
@@ -906,6 +927,7 @@ function changeViewType(newType: string) {
|
|||||||
|
|
||||||
// 获取搜索列表数据
|
// 获取搜索列表数据
|
||||||
async function fetchData(options: { force?: boolean; params?: SearchParams; silent?: boolean } = {}) {
|
async function fetchData(options: { force?: boolean; params?: SearchParams; silent?: boolean } = {}) {
|
||||||
|
const requestId = ++activeSearchRequestId
|
||||||
const currentSearchParams = { ...(options.params ?? activeSearchParams.value) }
|
const currentSearchParams = { ...(options.params ?? activeSearchParams.value) }
|
||||||
if (hasSearchKeyword(currentSearchParams)) {
|
if (hasSearchKeyword(currentSearchParams)) {
|
||||||
activeSearchParams.value = { ...currentSearchParams }
|
activeSearchParams.value = { ...currentSearchParams }
|
||||||
@@ -920,6 +942,7 @@ async function fetchData(options: { force?: boolean; params?: SearchParams; sile
|
|||||||
if (!hasSearchKeyword(currentSearchParams)) {
|
if (!hasSearchKeyword(currentSearchParams)) {
|
||||||
// 查询上次搜索结果,并同步可重放的搜索参数
|
// 查询上次搜索结果,并同步可重放的搜索参数
|
||||||
const results = await fetchLastSearchContext()
|
const results = await fetchLastSearchContext()
|
||||||
|
if (requestId !== activeSearchRequestId) return
|
||||||
if (activeSearchParams.value.result_type === 'subtitle') {
|
if (activeSearchParams.value.result_type === 'subtitle') {
|
||||||
setSubtitleStreamResults((results || []) as SubtitleInfo[])
|
setSubtitleStreamResults((results || []) as SubtitleInfo[])
|
||||||
} else {
|
} else {
|
||||||
@@ -927,7 +950,16 @@ async function fetchData(options: { force?: boolean; params?: SearchParams; sile
|
|||||||
}
|
}
|
||||||
} else if (silentRefresh) {
|
} else if (silentRefresh) {
|
||||||
// keep-alive 重新进入时后台刷新,旧结果继续显示,等新结果完整返回后一次性替换。
|
// keep-alive 重新进入时后台刷新,旧结果继续显示,等新结果完整返回后一次性替换。
|
||||||
const results = await requestSearchResults(currentSearchParams, requestToken)
|
let results: Array<Context | SubtitleInfo>
|
||||||
|
try {
|
||||||
|
results = await requestSearchResults(currentSearchParams, requestToken)
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== activeSearchRequestId) return
|
||||||
|
errorDescription.value = error instanceof Error ? error.message : t('resource.noResourceFound')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (requestId !== activeSearchRequestId) return
|
||||||
|
errorDescription.value = t('resource.noResourceFound')
|
||||||
streamTotalCount.value = results.length
|
streamTotalCount.value = results.length
|
||||||
if (currentSearchParams.result_type === 'subtitle') {
|
if (currentSearchParams.result_type === 'subtitle') {
|
||||||
setSubtitleStreamResults(results as SubtitleInfo[])
|
setSubtitleStreamResults(results as SubtitleInfo[])
|
||||||
@@ -943,21 +975,26 @@ async function fetchData(options: { force?: boolean; params?: SearchParams; sile
|
|||||||
const streamErrorMessage = error instanceof Error ? error.message : t('resource.searchStreamDisconnected')
|
const streamErrorMessage = error instanceof Error ? error.message : t('resource.searchStreamDisconnected')
|
||||||
console.warn('渐进式搜索连接失败,回退到普通搜索:', error)
|
console.warn('渐进式搜索连接失败,回退到普通搜索:', error)
|
||||||
try {
|
try {
|
||||||
await searchByRequest(currentSearchParams, requestToken)
|
const applied = await searchByRequest(currentSearchParams, requestToken, requestId)
|
||||||
|
if (!applied) return
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
|
if (requestId !== activeSearchRequestId) return
|
||||||
errorDescription.value = streamErrorMessage
|
errorDescription.value = streamErrorMessage
|
||||||
throw fallbackError
|
throw fallbackError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (requestId !== activeSearchRequestId) return
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
// 搜索完成后移除地址栏参数,避免分享/刷新残留搜索条件
|
// 搜索完成后移除地址栏参数,避免分享/刷新残留搜索条件
|
||||||
if (Object.keys(route.query).length > 0) {
|
if (Object.keys(route.query).length > 0) {
|
||||||
await router.replace({ path: route.path, query: {} })
|
await router.replace({ path: route.path, query: {} })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (requestId !== activeSearchRequestId) return
|
||||||
// 标记已刷新
|
// 标记已刷新
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== activeSearchRequestId) return
|
||||||
console.error(error)
|
console.error(error)
|
||||||
closeSearchEventSource()
|
closeSearchEventSource()
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
@@ -1306,8 +1343,10 @@ useKeepAliveRefresh(async () => {
|
|||||||
await fetchData({ force: true, params: refreshParams, silent: true })
|
await fetchData({ force: true, params: refreshParams, silent: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
// 卸载时停止轮询
|
// 卸载时先让所有在途请求失效,并结束 EventSource.close() 不会主动完成的搜索 Promise。
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
activeSearchRequestId += 1
|
||||||
|
cancelActiveSearchStream?.()
|
||||||
closeSearchEventSource()
|
closeSearchEventSource()
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
clearProgressResetTimer()
|
clearProgressResetTimer()
|
||||||
@@ -1432,12 +1471,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<VExpandXTransition>
|
<VExpandXTransition>
|
||||||
<div v-if="aiRecommended || isRecommending" class="ai-action-group__more">
|
<div v-if="aiRecommended || isRecommending" class="ai-action-group__more">
|
||||||
<IconBtn
|
<IconBtn variant="text" color="gray" :disabled="isRecommending || !aiStatusChecked" @click="reRecommend">
|
||||||
variant="text"
|
|
||||||
color="gray"
|
|
||||||
:disabled="isRecommending || !aiStatusChecked"
|
|
||||||
@click="reRecommend"
|
|
||||||
>
|
|
||||||
<VIcon :icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-auto-fix'" />
|
<VIcon :icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-auto-fix'" />
|
||||||
<VTooltip activator="parent" location="top">
|
<VTooltip activator="parent" location="top">
|
||||||
{{ t('resource.reRecommend') }}
|
{{ t('resource.reRecommend') }}
|
||||||
|
|||||||
@@ -39,4 +39,41 @@ describe('SearchReplaceBatchCollector', () => {
|
|||||||
expect(isSearchReplaceBatchEvent(malformedEvent)).toBe(false)
|
expect(isSearchReplaceBatchEvent(malformedEvent)).toBe(false)
|
||||||
expect(() => new SearchReplaceBatchCollector<number>().append(malformedEvent)).toThrow('搜索结果批次格式无效')
|
expect(() => new SearchReplaceBatchCollector<number>().append(malformedEvent)).toThrow('搜索结果批次格式无效')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['negative batch index', { batch_index: -1 }],
|
||||||
|
['batch index outside the declared count', { batch_index: 3 }],
|
||||||
|
['non-integer total', { total_items: 1.5 }],
|
||||||
|
['missing items', { items: undefined }],
|
||||||
|
])('rejects %s metadata', (_case, overrides) => {
|
||||||
|
expect(isSearchReplaceBatchEvent({ ...createBatch(0, [1, 2]), ...overrides })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects metadata drift and resets before accepting another sequence', () => {
|
||||||
|
const collector = new SearchReplaceBatchCollector<number>()
|
||||||
|
|
||||||
|
expect(collector.append(createBatch(0, [1, 2]))).toBeNull()
|
||||||
|
expect(() => collector.append({ ...createBatch(1, [3, 4]), total_items: 6 })).toThrow('搜索结果批次顺序不连续')
|
||||||
|
|
||||||
|
expect(collector.append(createBatch(0, [1, 2]))).toBeNull()
|
||||||
|
expect(collector.append(createBatch(1, [3, 4]))).toBeNull()
|
||||||
|
expect(collector.append(createBatch(2, [5]))).toEqual([1, 2, 3, 4, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a completed sequence whose item count differs from the declared total', () => {
|
||||||
|
const collector = new SearchReplaceBatchCollector<number>()
|
||||||
|
|
||||||
|
expect(collector.append(createBatch(0, [1, 2]))).toBeNull()
|
||||||
|
expect(collector.append(createBatch(1, [3, 4]))).toBeNull()
|
||||||
|
expect(() => collector.append(createBatch(2, []))).toThrow('搜索结果批次数量不完整')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a fresh sequence when another first batch arrives', () => {
|
||||||
|
const collector = new SearchReplaceBatchCollector<number>()
|
||||||
|
|
||||||
|
expect(collector.append(createBatch(0, [99, 100]))).toBeNull()
|
||||||
|
expect(collector.append(createBatch(0, [1, 2]))).toBeNull()
|
||||||
|
expect(collector.append(createBatch(1, [3, 4]))).toBeNull()
|
||||||
|
expect(collector.append(createBatch(2, [5]))).toEqual([1, 2, 3, 4, 5])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/pages/discover.vue',
|
'src/pages/discover.vue',
|
||||||
'src/pages/browse.vue',
|
'src/pages/browse.vue',
|
||||||
'src/pages/media.vue',
|
'src/pages/media.vue',
|
||||||
|
'src/pages/resource.vue',
|
||||||
'src/pages/site.vue',
|
'src/pages/site.vue',
|
||||||
'src/pages/subscribe.vue',
|
'src/pages/subscribe.vue',
|
||||||
'src/views/dashboard/MediaRecommend.vue',
|
'src/views/dashboard/MediaRecommend.vue',
|
||||||
@@ -298,7 +299,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/views/subscribe/SubscribePopularView.vue',
|
'src/views/subscribe/SubscribePopularView.vue',
|
||||||
'src/views/subscribe/SubscribeShareView.vue',
|
'src/views/subscribe/SubscribeShareView.vue',
|
||||||
'src/composables/useMediaSubscribe.ts',
|
'src/composables/useMediaSubscribe.ts',
|
||||||
|
'src/composables/useTorrentFilter.ts',
|
||||||
'src/components/cards/SubscribeCard.vue',
|
'src/components/cards/SubscribeCard.vue',
|
||||||
|
'src/components/filter/TorrentFilterBar.vue',
|
||||||
'src/components/dialog/SiteAddEditDialog.vue',
|
'src/components/dialog/SiteAddEditDialog.vue',
|
||||||
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
||||||
'src/components/dialog/SiteImportDialog.vue',
|
'src/components/dialog/SiteImportDialog.vue',
|
||||||
@@ -324,6 +327,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/components/slide/VirtualSlideView.vue',
|
'src/components/slide/VirtualSlideView.vue',
|
||||||
'src/views/discover/PersonCardSlideView.vue',
|
'src/views/discover/PersonCardSlideView.vue',
|
||||||
'src/utils/mediaStatusCache.ts',
|
'src/utils/mediaStatusCache.ts',
|
||||||
|
'src/utils/searchStream.ts',
|
||||||
'src/views/site/SiteCardListView.vue',
|
'src/views/site/SiteCardListView.vue',
|
||||||
'src/utils/siteIconCache.ts',
|
'src/utils/siteIconCache.ts',
|
||||||
],
|
],
|
||||||
@@ -413,12 +417,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/composables/useTorrentFilter.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
|
'src/components/filter/TorrentFilterBar.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
'src/pages/recommend.vue': {
|
'src/pages/recommend.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/pages/resource.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
'src/pages/media.vue': {
|
'src/pages/media.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
@@ -455,6 +477,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/utils/searchStream.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/views/dashboard/MediaRecommend.vue': {
|
'src/views/dashboard/MediaRecommend.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user