From 2c2f55b6d7bcf6f703900e89e2c1dc17c12705c1 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:20:05 +0800 Subject: [PATCH] test(resource): cover torrent card interactions (#608) --- eslint-suppressions.json | 10 - src/components/cards/TorrentCard.vue | 21 +- src/components/cards/TorrentItem.vue | 32 +- .../cards/__tests__/TorrentCard.spec.ts | 346 ++++++++++++++++++ .../cards/__tests__/TorrentItem.spec.ts | 331 +++++++++++++++++ vite.config.ts | 21 ++ 6 files changed, 723 insertions(+), 38 deletions(-) create mode 100644 src/components/cards/__tests__/TorrentCard.spec.ts create mode 100644 src/components/cards/__tests__/TorrentItem.spec.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ce96e1fc..9396c50a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/src/components/cards/TorrentCard.vue b/src/components/cards/TorrentCard.vue index a1e6639e..f82d2f79 100644 --- a/src/components/cards/TorrentCard.vue +++ b/src/components/cards/TorrentCard.vue @@ -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') } // 获取优惠类型样式 diff --git a/src/components/cards/TorrentItem.vue b/src/components/cards/TorrentItem.vue index 82810ae1..5f728a54 100644 --- a/src/components/cards/TorrentItem.vue +++ b/src/components/cards/TorrentItem.vue @@ -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 }, ) diff --git a/src/components/cards/__tests__/TorrentCard.spec.ts b/src/components/cards/__tests__/TorrentCard.spec.ts new file mode 100644 index 00000000..d9e0a7db --- /dev/null +++ b/src/components/cards/__tests__/TorrentCard.spec.ts @@ -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 + meta?: Partial + torrent?: Partial +} + +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('.torrent-card') + expect(card).not.toBeNull() + return card as HTMLElement +} + +function getDetailButton(container: Element) { + const button = container.querySelector('.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, + Record void>, + Record, + ] + 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('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: '
', + } + 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() + }) +}) diff --git a/src/components/cards/__tests__/TorrentItem.spec.ts b/src/components/cards/__tests__/TorrentItem.spec.ts new file mode 100644 index 00000000..24e99bc8 --- /dev/null +++ b/src/components/cards/__tests__/TorrentItem.spec.ts @@ -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 + meta?: Partial + torrent?: Partial +} + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise(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('.torrent-item') + expect(item).not.toBeNull() + return item as HTMLElement +} + +function getDetailButton(container: Element) { + const button = container.querySelector('.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, + Record void>, + Record, + ] + 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() + const currentIcon = createDeferred() + 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('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('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('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('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() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 0472f07b..b88493a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,