mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 08:46:40 +08:00
test(resource): cover torrent card interactions (#608)
This commit is contained in:
@@ -187,16 +187,6 @@
|
|||||||
"count": 2
|
"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": {
|
"src/components/cards/UserCard.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
@@ -76,9 +76,12 @@ async function handleAddDownload(item: Context | null = null) {
|
|||||||
openSharedDialog(
|
openSharedDialog(
|
||||||
AddDownloadDialog,
|
AddDownloadDialog,
|
||||||
{
|
{
|
||||||
title: `${downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name} ${
|
title: [
|
||||||
downloadItem.value?.meta_info?.season_episode
|
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,
|
media: downloadItem.value?.media_info,
|
||||||
torrent: downloadItem.value?.torrent_info,
|
torrent: downloadItem.value?.torrent_info,
|
||||||
},
|
},
|
||||||
@@ -92,16 +95,10 @@ async function handleAddDownload(item: Context | null = null) {
|
|||||||
|
|
||||||
// 打开种子详情页面
|
// 打开种子详情页面
|
||||||
function openTorrentDetail(item: Context | null = null) {
|
function openTorrentDetail(item: Context | null = null) {
|
||||||
if (item && !isNullOrEmptyObject(item) && !isNullOrEmptyObject(item.torrent_info)) {
|
const pageUrl = item && !isNullOrEmptyObject(item) ? item.torrent_info?.page_url : torrent.value?.page_url
|
||||||
window.open(item.torrent_info.page_url, '_blank')
|
if (pageUrl) {
|
||||||
return
|
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]))
|
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||||
|
|
||||||
// 查询站点图标
|
// 查询站点图标
|
||||||
async function getSiteIcon() {
|
async function getSiteIcon(site: number | undefined) {
|
||||||
if (!torrent?.value?.site) {
|
if (!site) {
|
||||||
|
siteIcon.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
const icon = await getCachedSiteIcon(site, async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
const response = await api.get(`site/icon/${site}`)
|
||||||
|
|
||||||
return response?.data?.icon || ''
|
return response?.data?.icon || ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -45,9 +46,15 @@ async function getSiteIcon() {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// 只提交当前站点的响应,避免 Context 快速切换时旧请求覆盖新图标。
|
||||||
|
if (torrent.value?.site === site) {
|
||||||
|
siteIcon.value = icon
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load site icon:', 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 ''
|
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() {
|
async function handleAddDownload() {
|
||||||
// 打开下载对话框
|
// 打开下载对话框
|
||||||
@@ -99,7 +97,9 @@ function addDownloadError(error: string) {
|
|||||||
|
|
||||||
// 打开种子详情页面
|
// 打开种子详情页面
|
||||||
function openTorrentDetail() {
|
function openTorrentDetail() {
|
||||||
window.open(torrent.value?.page_url, '_blank')
|
if (torrent.value?.page_url) {
|
||||||
|
window.open(torrent.value.page_url, '_blank')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -108,7 +108,7 @@ watch(
|
|||||||
torrent.value = value?.torrent_info
|
torrent.value = value?.torrent_info
|
||||||
media.value = value?.media_info
|
media.value = value?.media_info
|
||||||
meta.value = value?.meta_info
|
meta.value = value?.meta_info
|
||||||
getSiteIcon()
|
getSiteIcon(value?.torrent_info?.site)
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/composables/useTorrentFilter.ts',
|
||||||
'src/components/cards/SubscribeCard.vue',
|
'src/components/cards/SubscribeCard.vue',
|
||||||
'src/components/filter/TorrentFilterBar.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/SiteAddEditDialog.vue',
|
||||||
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
||||||
'src/components/dialog/SiteImportDialog.vue',
|
'src/components/dialog/SiteImportDialog.vue',
|
||||||
@@ -345,6 +348,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 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': {
|
'src/components/cards/SubscribeShareCard.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user