test(resource): cover search filtering and lifecycle (#607)

This commit is contained in:
InfinityPacer
2026-07-31 10:59:35 +08:00
committed by GitHub
parent b85d412308
commit a1802f7f93
8 changed files with 1507 additions and 26 deletions
+8 -2
View File
@@ -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'])
})
})
+9 -7
View File
@@ -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) {
+897
View File
@@ -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
View File
@@ -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) => { // 新搜索必须显式结束上一轮 PromiseEventSource.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') }}
+37
View File
@@ -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])
})
}) })
+28
View File
@@ -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,