mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 08:03:45 +08:00
test(resource): cover torrent card interactions (#608)
This commit is contained in:
@@ -187,16 +187,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/cards/TorrentCard.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/TorrentItem.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/UserCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@@ -76,9 +76,12 @@ async function handleAddDownload(item: Context | null = null) {
|
||||
openSharedDialog(
|
||||
AddDownloadDialog,
|
||||
{
|
||||
title: `${downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name} ${
|
||||
downloadItem.value?.meta_info?.season_episode
|
||||
}`,
|
||||
title: [
|
||||
downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name,
|
||||
downloadItem.value?.meta_info?.season_episode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
media: downloadItem.value?.media_info,
|
||||
torrent: downloadItem.value?.torrent_info,
|
||||
},
|
||||
@@ -92,16 +95,10 @@ async function handleAddDownload(item: Context | null = null) {
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(item: Context | null = null) {
|
||||
if (item && !isNullOrEmptyObject(item) && !isNullOrEmptyObject(item.torrent_info)) {
|
||||
window.open(item.torrent_info.page_url, '_blank')
|
||||
return
|
||||
const pageUrl = item && !isNullOrEmptyObject(item) ? item.torrent_info?.page_url : torrent.value?.page_url
|
||||
if (pageUrl) {
|
||||
window.open(pageUrl, '_blank')
|
||||
}
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile() {
|
||||
window.open(torrent.value?.enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 获取优惠类型样式
|
||||
|
||||
@@ -29,15 +29,16 @@ const siteIcon = ref('')
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!torrent?.value?.site) {
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) {
|
||||
siteIcon.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
||||
const icon = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
@@ -45,9 +46,15 @@ async function getSiteIcon() {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
// 只提交当前站点的响应,避免 Context 快速切换时旧请求覆盖新图标。
|
||||
if (torrent.value?.site === site) {
|
||||
siteIcon.value = icon
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
siteIcon.value = ''
|
||||
if (torrent.value?.site === site) {
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +67,6 @@ function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolum
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 获取优惠标签类
|
||||
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'chip-free'
|
||||
if (downloadVolumeFactor === 0) return 'chip-free'
|
||||
else if (downloadVolumeFactor < 1) return 'chip-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 询问并添加下载
|
||||
async function handleAddDownload() {
|
||||
// 打开下载对话框
|
||||
@@ -99,7 +97,9 @@ function addDownloadError(error: string) {
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
if (torrent.value?.page_url) {
|
||||
window.open(torrent.value.page_url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -108,7 +108,7 @@ watch(
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
getSiteIcon()
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
346
src/components/cards/__tests__/TorrentCard.spec.ts
Normal file
346
src/components/cards/__tests__/TorrentCard.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import type { Context, MediaInfo, MetaInfo, TorrentInfo } from '@/api/types'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
import { downloadedTorrentMap } from '@/utils/torrentDownloadCache'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getCachedSiteIcon: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/siteIconCache', () => ({
|
||||
getCachedSiteIcon: (...args: unknown[]) => mocks.getCachedSiteIcon(...args),
|
||||
}))
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
alt: String,
|
||||
src: String,
|
||||
},
|
||||
setup: props => () => h('img', { alt: props.alt, src: props.src }),
|
||||
})
|
||||
|
||||
interface ContextOverrides {
|
||||
media?: Partial<MediaInfo>
|
||||
meta?: Partial<MetaInfo>
|
||||
torrent?: Partial<TorrentInfo>
|
||||
}
|
||||
|
||||
function createContext(overrides: ContextOverrides = {}): Context {
|
||||
return {
|
||||
media_info: {
|
||||
episode_run_time: [],
|
||||
origin_country: [],
|
||||
title: '测试电影',
|
||||
title_year: '测试电影 (2026)',
|
||||
...overrides.media,
|
||||
},
|
||||
meta_info: {
|
||||
apply_words: [],
|
||||
audio_term: '',
|
||||
edition: '',
|
||||
episode: '',
|
||||
episode_list: [],
|
||||
episode_seq: '',
|
||||
episode_seqs: '',
|
||||
episodes: '',
|
||||
isfile: false,
|
||||
name: '测试电影',
|
||||
release_group: '',
|
||||
resource_term: '',
|
||||
sea: '',
|
||||
season: '',
|
||||
season_episode: '',
|
||||
season_list: [],
|
||||
season_seq: '',
|
||||
total_episode: 0,
|
||||
total_season: 0,
|
||||
type: '电影',
|
||||
video_term: '',
|
||||
web_source: '',
|
||||
...overrides.meta,
|
||||
},
|
||||
torrent_info: {
|
||||
category: '电影',
|
||||
downloadvolumefactor: 1,
|
||||
enclosure: 'https://downloads.example.com/test.torrent',
|
||||
freedate: '',
|
||||
freedate_diff: '',
|
||||
grabs: 3,
|
||||
hit_and_run: false,
|
||||
imdbid: 'tt1000001',
|
||||
labels: [],
|
||||
page_url: 'https://tracker.example.com/details/1001',
|
||||
peers: 2,
|
||||
pri_order: 0,
|
||||
seeders: 10,
|
||||
site: 101,
|
||||
site_name: '测试站',
|
||||
site_order: 0,
|
||||
site_proxy: false,
|
||||
size: 1024,
|
||||
title: 'Test.Movie.2026.1080p',
|
||||
uploadvolumefactor: 1,
|
||||
volume_factor: 'FREE',
|
||||
...overrides.torrent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function renderCard(context: Context, more: Context[] = []) {
|
||||
return renderWithProviders(TorrentCard, {
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
props: { more, torrent: context },
|
||||
})
|
||||
}
|
||||
|
||||
function getCard(container: Element) {
|
||||
const card = container.querySelector<HTMLElement>('.torrent-card')
|
||||
expect(card).not.toBeNull()
|
||||
return card as HTMLElement
|
||||
}
|
||||
|
||||
function getDetailButton(container: Element) {
|
||||
const button = container.querySelector<HTMLButtonElement>('.v-card-actions .v-btn--icon')
|
||||
expect(button).not.toBeNull()
|
||||
return button as HTMLButtonElement
|
||||
}
|
||||
|
||||
function getDialogCall(index = 0) {
|
||||
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
Record<string, (...args: unknown[]) => void>,
|
||||
Record<string, unknown>,
|
||||
]
|
||||
return { events, options, props }
|
||||
}
|
||||
|
||||
describe('TorrentCard approved regressions', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(downloadedTorrentMap).forEach(url => delete downloadedTorrentMap[url])
|
||||
mocks.getCachedSiteIcon.mockImplementation((site: number) =>
|
||||
Promise.resolve(`https://images.example.com/site-${site}.png`),
|
||||
)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('omits a missing season and episode from the download title', async () => {
|
||||
const context = createContext({ meta: { season_episode: undefined } })
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
await fireEvent.click(getCard(container))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
|
||||
expect(mocks.openSharedDialog.mock.calls[0]?.[1]).toMatchObject({
|
||||
media: context.media_info,
|
||||
title: '测试电影 (2026)',
|
||||
torrent: context.torrent_info,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not open a blank detail page or trigger download when the URL is missing', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext({ torrent: { page_url: undefined } })
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
await fireEvent.click(getDetailButton(container))
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TorrentCard display and interactions', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(downloadedTorrentMap).forEach(url => delete downloadedTorrentMap[url])
|
||||
mocks.getCachedSiteIcon.mockImplementation((site: number) =>
|
||||
Promise.resolve(`https://images.example.com/site-${site}.png`),
|
||||
)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('renders the current title, release metadata, tracker state, labels, size, and time', async () => {
|
||||
const context = createContext({
|
||||
media: { title: '可见媒体' },
|
||||
meta: {
|
||||
edition: 'IMAX',
|
||||
resource_pix: '2160p',
|
||||
resource_team: '测试组',
|
||||
season_episode: 'S02E03',
|
||||
subtitle: '国粤双语',
|
||||
video_encode: 'HEVC',
|
||||
web_source: 'Netflix',
|
||||
},
|
||||
torrent: {
|
||||
description: '种子描述',
|
||||
downloadvolumefactor: 0,
|
||||
freedate_diff: '剩余 1 天',
|
||||
hit_and_run: true,
|
||||
labels: ['国语', '杜比视界'],
|
||||
peers: 4,
|
||||
pubdate: '2026-07-29 12:00:00',
|
||||
seeders: 12,
|
||||
size: 1536,
|
||||
site_name: '高清测试站',
|
||||
title: 'Visible.Media.S02E03.2160p',
|
||||
uploadvolumefactor: 2,
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
for (const text of [
|
||||
'可见媒体',
|
||||
'S02E03',
|
||||
'Visible.Media.S02E03.2160p',
|
||||
'国粤双语',
|
||||
'高清测试站',
|
||||
'Netflix',
|
||||
'IMAX',
|
||||
'2160p',
|
||||
'HEVC',
|
||||
'测试组',
|
||||
'国语',
|
||||
'杜比视界',
|
||||
'H&R',
|
||||
'剩余 1 天',
|
||||
formatFileSize(1536),
|
||||
formatDateDifference('2026-07-29 12:00:00'),
|
||||
]) {
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
}
|
||||
expect(container.querySelector('.discount-banner')).toHaveClass('bg-success')
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-101.png'))
|
||||
})
|
||||
|
||||
it('falls back to the recognized name and tracker initial when richer metadata and icon are unavailable', async () => {
|
||||
mocks.getCachedSiteIcon.mockRejectedValue(new Error('icon unavailable'))
|
||||
const context = createContext({
|
||||
media: { title: undefined },
|
||||
meta: { name: '识别名称', subtitle: undefined },
|
||||
torrent: { description: undefined, site_name: '备用站' },
|
||||
})
|
||||
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
expect(screen.getByText('识别名称')).toBeInTheDocument()
|
||||
expect(screen.getByText('备')).toBeInTheDocument()
|
||||
expect(container.querySelector('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['discount', 0.5, 1, 'bg-orange'],
|
||||
['upload bonus', 1, 2, 'bg-purple'],
|
||||
['unclassified promotion', 2, 1, null],
|
||||
] as const)('projects the %s promotion style', async (_case, downloadFactor, uploadFactor, expectedClass) => {
|
||||
const { container } = await renderCard(
|
||||
createContext({
|
||||
torrent: {
|
||||
downloadvolumefactor: downloadFactor,
|
||||
uploadvolumefactor: uploadFactor,
|
||||
volume_factor: _case,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const banner = container.querySelector('.discount-banner')
|
||||
|
||||
expect(banner).toBeInTheDocument()
|
||||
if (expectedClass) expect(banner).toHaveClass(expectedClass)
|
||||
else expect(banner).not.toHaveClass('bg-success', 'bg-orange', 'bg-purple')
|
||||
})
|
||||
|
||||
it('opens the download dialog with the current Context and shares successful download state across cards', async () => {
|
||||
const context = createContext({ meta: { season_episode: 'S01E02' } })
|
||||
const Harness = {
|
||||
components: { TorrentCard },
|
||||
data: () => ({ context }),
|
||||
template: '<div><TorrentCard :torrent="context" /><TorrentCard :torrent="context" /></div>',
|
||||
}
|
||||
const { container } = await renderWithProviders(Harness, {
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('.torrent-card') as Element)
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const { events, options, props } = getDialogCall()
|
||||
|
||||
expect(props).toEqual({
|
||||
media: context.media_info,
|
||||
title: '测试电影 (2026) S01E02',
|
||||
torrent: context.torrent_info,
|
||||
})
|
||||
expect(options).toEqual({ closeOn: ['close', 'done', 'error'] })
|
||||
|
||||
events.done('')
|
||||
expect(container.querySelectorAll('.torrent-card.border-success')).toHaveLength(0)
|
||||
|
||||
events.done(context.torrent_info.enclosure)
|
||||
await waitFor(() => expect(container.querySelectorAll('.torrent-card.border-success')).toHaveLength(2))
|
||||
|
||||
events.error('下载失败')
|
||||
expect(console.error).toHaveBeenCalledWith('下载失败')
|
||||
})
|
||||
|
||||
it('opens more sources with candidate icons and keeps alternative download and detail Context intact', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const primary = createContext()
|
||||
const alternative = createContext({
|
||||
media: { title: '备选媒体', title_year: '备选媒体 (2025)' },
|
||||
meta: { name: '备选名称', season_episode: 'S03E04' },
|
||||
torrent: {
|
||||
enclosure: 'https://downloads.example.com/alternative.torrent',
|
||||
page_url: 'https://tracker.example.com/details/202',
|
||||
site: 202,
|
||||
site_name: '备选站',
|
||||
title: 'Alternative.Media.S03E04',
|
||||
},
|
||||
})
|
||||
const { container } = await renderCard(primary, [alternative])
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /更多来源/ }))
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
const moreDialog = getDialogCall()
|
||||
expect(moreDialog.props.items).toEqual([alternative])
|
||||
expect(moreDialog.options).toEqual({ closeOn: ['close', 'update:modelValue'] })
|
||||
await waitFor(() =>
|
||||
expect(moreDialog.props.siteIcons).toEqual(
|
||||
expect.objectContaining({ 202: 'https://images.example.com/site-202.png' }),
|
||||
),
|
||||
)
|
||||
|
||||
moreDialog.events.download(alternative)
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2))
|
||||
expect(getDialogCall(1).props).toEqual({
|
||||
media: alternative.media_info,
|
||||
title: '备选媒体 (2025) S03E04',
|
||||
torrent: alternative.torrent_info,
|
||||
})
|
||||
|
||||
moreDialog.events.detail(alternative)
|
||||
expect(open).toHaveBeenCalledWith(alternative.torrent_info.page_url, '_blank')
|
||||
expect(container.querySelector('.torrent-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens an available primary detail URL without starting a download', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext()
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
await fireEvent.click(getDetailButton(container))
|
||||
|
||||
expect(open).toHaveBeenCalledWith(context.torrent_info.page_url, '_blank')
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
331
src/components/cards/__tests__/TorrentItem.spec.ts
Normal file
331
src/components/cards/__tests__/TorrentItem.spec.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import type { Context, MediaInfo, MetaInfo, TorrentInfo } from '@/api/types'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import { downloadedTorrentMap } from '@/utils/torrentDownloadCache'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getCachedSiteIcon: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/siteIconCache', () => ({
|
||||
getCachedSiteIcon: (...args: unknown[]) => mocks.getCachedSiteIcon(...args),
|
||||
}))
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
alt: String,
|
||||
src: String,
|
||||
},
|
||||
setup: props => () => h('img', { alt: props.alt, src: props.src }),
|
||||
})
|
||||
|
||||
interface ContextOverrides {
|
||||
media?: Partial<MediaInfo>
|
||||
meta?: Partial<MetaInfo>
|
||||
torrent?: Partial<TorrentInfo>
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createContext(overrides: ContextOverrides = {}): Context {
|
||||
return {
|
||||
media_info: {
|
||||
episode_run_time: [],
|
||||
origin_country: [],
|
||||
title: '测试电影',
|
||||
title_year: '测试电影 (2026)',
|
||||
...overrides.media,
|
||||
},
|
||||
meta_info: {
|
||||
apply_words: [],
|
||||
audio_term: '',
|
||||
edition: '',
|
||||
episode: '',
|
||||
episode_list: [],
|
||||
episode_seq: '',
|
||||
episode_seqs: '',
|
||||
episodes: '',
|
||||
isfile: false,
|
||||
name: '测试电影',
|
||||
release_group: '',
|
||||
resource_term: '',
|
||||
sea: '',
|
||||
season: '',
|
||||
season_episode: '',
|
||||
season_list: [],
|
||||
season_seq: '',
|
||||
total_episode: 0,
|
||||
total_season: 0,
|
||||
type: '电影',
|
||||
video_term: '',
|
||||
web_source: '',
|
||||
...overrides.meta,
|
||||
},
|
||||
torrent_info: {
|
||||
category: '电影',
|
||||
downloadvolumefactor: 1,
|
||||
enclosure: 'https://downloads.example.com/test.torrent',
|
||||
freedate: '',
|
||||
freedate_diff: '',
|
||||
grabs: 3,
|
||||
hit_and_run: false,
|
||||
imdbid: 'tt1000001',
|
||||
labels: [],
|
||||
page_url: 'https://tracker.example.com/details/1001',
|
||||
peers: 2,
|
||||
pri_order: 0,
|
||||
seeders: 10,
|
||||
site: 101,
|
||||
site_name: '测试站',
|
||||
site_order: 0,
|
||||
site_proxy: false,
|
||||
size: 1024,
|
||||
title: 'Test.Movie.2026.1080p',
|
||||
uploadvolumefactor: 1,
|
||||
volume_factor: 'FREE',
|
||||
...overrides.torrent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function renderItem(context: Context) {
|
||||
return renderWithProviders(TorrentItem, {
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
props: { torrent: context },
|
||||
})
|
||||
}
|
||||
|
||||
function getItem(container: Element) {
|
||||
const item = container.querySelector<HTMLElement>('.torrent-item')
|
||||
expect(item).not.toBeNull()
|
||||
return item as HTMLElement
|
||||
}
|
||||
|
||||
function getDetailButton(container: Element) {
|
||||
const button = container.querySelector<HTMLButtonElement>('.v-list-item__append .v-btn--icon')
|
||||
expect(button).not.toBeNull()
|
||||
return button as HTMLButtonElement
|
||||
}
|
||||
|
||||
function getDialogCall(index = 0) {
|
||||
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
Record<string, (...args: unknown[]) => void>,
|
||||
Record<string, unknown>,
|
||||
]
|
||||
return { events, options, props }
|
||||
}
|
||||
|
||||
describe('TorrentItem approved regressions', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(downloadedTorrentMap).forEach(url => delete downloadedTorrentMap[url])
|
||||
mocks.getCachedSiteIcon.mockResolvedValue('https://images.example.com/site-101.png')
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('does not open a blank detail page or trigger download when the URL is missing', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext({ torrent: { page_url: undefined } })
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
await fireEvent.click(getDetailButton(container))
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the current site icon when an older request resolves last', async () => {
|
||||
const oldIcon = createDeferred<string>()
|
||||
const currentIcon = createDeferred<string>()
|
||||
mocks.getCachedSiteIcon.mockImplementation((site: number) => (site === 101 ? oldIcon.promise : currentIcon.promise))
|
||||
const oldContext = createContext({ torrent: { site: 101, site_name: '旧站' } })
|
||||
const currentContext = createContext({ torrent: { site: 202, site_name: '当前站' } })
|
||||
|
||||
const { container, rerender } = await renderItem(oldContext)
|
||||
await waitFor(() => expect(mocks.getCachedSiteIcon).toHaveBeenCalledWith(101, expect.any(Function)))
|
||||
await rerender({ torrent: currentContext })
|
||||
await waitFor(() => expect(mocks.getCachedSiteIcon).toHaveBeenCalledWith(202, expect.any(Function)))
|
||||
|
||||
currentIcon.resolve('https://images.example.com/site-202.png')
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-202.png'))
|
||||
|
||||
oldIcon.resolve('https://images.example.com/site-101.png')
|
||||
await waitFor(() => expect(mocks.getCachedSiteIcon).toHaveBeenCalledTimes(2))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-202.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TorrentItem display and interactions', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(downloadedTorrentMap).forEach(url => delete downloadedTorrentMap[url])
|
||||
mocks.getCachedSiteIcon.mockImplementation((site: number) =>
|
||||
Promise.resolve(`https://images.example.com/site-${site}.png`),
|
||||
)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('renders the current title, release metadata, tracker state, labels, size, and time', async () => {
|
||||
const context = createContext({
|
||||
media: { title: '列表媒体' },
|
||||
meta: {
|
||||
edition: '导演剪辑版',
|
||||
resource_pix: '1080p',
|
||||
resource_team: '列表组',
|
||||
season_episode: 'S01E08',
|
||||
subtitle: '简繁字幕',
|
||||
video_encode: 'AV1',
|
||||
web_source: 'Apple TV+',
|
||||
},
|
||||
torrent: {
|
||||
downloadvolumefactor: 0.5,
|
||||
freedate_diff: '剩余 2 小时',
|
||||
hit_and_run: true,
|
||||
labels: ['国语', '高码率'],
|
||||
peers: 6,
|
||||
pubdate: '2026-07-29 18:00:00',
|
||||
seeders: 24,
|
||||
size: 2048,
|
||||
site_name: '列表测试站',
|
||||
title: 'List.Media.S01E08.1080p',
|
||||
uploadvolumefactor: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
for (const text of [
|
||||
'列表媒体',
|
||||
'S01E08',
|
||||
'List.Media.S01E08.1080p',
|
||||
'简繁字幕',
|
||||
'Apple TV+',
|
||||
'导演剪辑版',
|
||||
'1080p',
|
||||
'AV1',
|
||||
'列表组',
|
||||
'国语',
|
||||
'高码率',
|
||||
'H&R',
|
||||
'剩余 2 小时',
|
||||
formatFileSize(2048),
|
||||
formatDateDifference('2026-07-29 18:00:00'),
|
||||
]) {
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
}
|
||||
expect(container.querySelector('.discount-banner')).toHaveClass('bg-orange')
|
||||
expect(container.querySelector('.torrent-item [title="列表测试站"]')).toBeInTheDocument()
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-101.png'))
|
||||
})
|
||||
|
||||
it('falls back to the recognized name, placeholder description, and tracker initial', async () => {
|
||||
mocks.getCachedSiteIcon.mockRejectedValue(new Error('icon unavailable'))
|
||||
const context = createContext({
|
||||
media: { title: undefined },
|
||||
meta: { name: '列表识别名称', subtitle: undefined },
|
||||
torrent: { description: undefined, site_name: '备用列表站' },
|
||||
})
|
||||
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
expect(screen.getByText('列表识别名称')).toBeInTheDocument()
|
||||
expect(screen.getByText('暂无描述')).toBeInTheDocument()
|
||||
expect(screen.getByText('备')).toBeInTheDocument()
|
||||
expect(container.querySelector('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['free', 0, 1, 'bg-success'],
|
||||
['upload bonus', 1, 2, 'bg-purple'],
|
||||
['unclassified promotion', 2, 1, null],
|
||||
] as const)('projects the %s promotion style', async (_case, downloadFactor, uploadFactor, expectedClass) => {
|
||||
const { container } = await renderItem(
|
||||
createContext({
|
||||
torrent: {
|
||||
downloadvolumefactor: downloadFactor,
|
||||
uploadvolumefactor: uploadFactor,
|
||||
volume_factor: _case,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const banner = container.querySelector('.discount-banner')
|
||||
|
||||
expect(banner).toBeInTheDocument()
|
||||
if (expectedClass) expect(banner).toHaveClass(expectedClass)
|
||||
else expect(banner).not.toHaveClass('bg-success', 'bg-orange', 'bg-purple')
|
||||
})
|
||||
|
||||
it('opens the download dialog with the current Context and marks the item after success', async () => {
|
||||
const context = createContext({ meta: { season_episode: 'S04E05' } })
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
await fireEvent.click(getItem(container))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const { events, options, props } = getDialogCall()
|
||||
|
||||
expect(props).toEqual({
|
||||
media: context.media_info,
|
||||
title: '测试电影 (2026) S04E05',
|
||||
torrent: context.torrent_info,
|
||||
})
|
||||
expect(options).toEqual({ closeOn: ['close', 'done', 'error'] })
|
||||
|
||||
events.done('')
|
||||
expect(getItem(container)).not.toHaveClass('border-success')
|
||||
|
||||
events.done(context.torrent_info.enclosure)
|
||||
await waitFor(() => expect(getItem(container)).toHaveClass('border-success'))
|
||||
|
||||
events.error('下载失败')
|
||||
expect(console.error).toHaveBeenCalledWith('下载失败')
|
||||
})
|
||||
|
||||
it('opens an available detail URL without starting a download', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext()
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
await fireEvent.click(getDetailButton(container))
|
||||
|
||||
expect(open).toHaveBeenCalledWith(context.torrent_info.page_url, '_blank')
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a resolved icon when the current Context has no site identity', async () => {
|
||||
const current = createContext({ torrent: { site: 101, site_name: '当前站' } })
|
||||
const withoutSite = createContext({ torrent: { site: undefined, site_name: '无站点' } })
|
||||
const { container, rerender } = await renderItem(current)
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-101.png'))
|
||||
|
||||
await rerender({ torrent: withoutSite })
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('无')).toBeInTheDocument()
|
||||
expect(mocks.getCachedSiteIcon).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -302,6 +302,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/composables/useTorrentFilter.ts',
|
||||
'src/components/cards/SubscribeCard.vue',
|
||||
'src/components/filter/TorrentFilterBar.vue',
|
||||
'src/components/cards/TorrentCard.vue',
|
||||
'src/components/cards/TorrentItem.vue',
|
||||
'src/utils/torrentDownloadCache.ts',
|
||||
'src/components/dialog/SiteAddEditDialog.vue',
|
||||
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
||||
'src/components/dialog/SiteImportDialog.vue',
|
||||
@@ -345,6 +348,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/TorrentCard.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/TorrentItem.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/utils/torrentDownloadCache.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/components/cards/SubscribeShareCard.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
|
||||
Reference in New Issue
Block a user