test(download): cover downloading task workflows (#612)

This commit is contained in:
InfinityPacer
2026-07-31 15:25:31 +08:00
committed by GitHub
parent 38fe17ab99
commit 39395ca6b6
7 changed files with 589 additions and 73 deletions
+42 -55
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import type { ApiResponse, DownloadingInfo } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
@@ -35,19 +35,11 @@ watch(
},
)
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 下载状态控制
async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start'
try {
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
const result: ApiResponse<unknown> = await api.get(`download/${operation}/${props.info?.hash}`, {
params: {
name: props.downloaderName,
},
@@ -59,11 +51,13 @@ async function toggleDownload() {
}
}
// 删除下
// 删除下载任务
async function deleteDownload() {
try {
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
cardState.value = false
const result: ApiResponse<unknown> = await api.delete(`download/${props.info?.hash}`, {
params: { name: props.downloaderName },
})
if (result.success) cardState.value = false
} catch (error) {
console.error(error)
}
@@ -83,53 +77,46 @@ async function deleteDownload() {
}"
min-height="150"
>
<template #image>
<VImg
:src="props.info?.media.image"
class="downloading-card-image"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
position="top"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none downloading-card-background"></div>
</template>
</VImg>
</template>
<template #image>
<VImg :src="props.info?.media.image" class="downloading-card-image" aspect-ratio="2/3" cover position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none downloading-card-background"></div>
</template>
</VImg>
</template>
<div>
<VCardTitle class="break-words whitespace-normal text-white">
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<div>
<VCardTitle class="break-words whitespace-normal text-white">
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<VCardSubtitle class="break-words whitespace-normal text-white">
{{ props.info?.title }}
</VCardSubtitle>
<VCardSubtitle class="break-words whitespace-normal text-white">
{{ props.info?.title }}
</VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
{{ getSpeedText() }}
</VCardText>
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
{{ getSpeedText() }}
</VCardText>
<VCardText v-if="getPercentage() > 0" class="text-white">
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
</VCardText>
<VCardText v-if="getPercentage() > 0" class="text-white">
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
</VCardText>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</div>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</div>
</VCard>
</div>
</template>
@@ -0,0 +1,139 @@
import type { DownloadingInfo } from '@/api/types'
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { deleteDownloadHandler, downloadActionHandler } from '@tests/support/msw/handlers/download'
import { server } from '@tests/support/msw/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo {
return {
dlspeed: '2 MiB',
hash: 'hash-1',
left_time: '1 小时',
media: {
episode: 'E02',
image: 'https://images.example.com/poster.jpg',
season: 'S01',
title: '测试媒体',
},
name: 'fallback-name',
progress: 40,
season_episode: 'S01E02',
size: 1024,
state: 'downloading',
title: '下载任务标题',
upspeed: '1 MiB',
...overrides,
}
}
async function renderCard(info = downloading(), downloaderName = 'qb-main') {
return renderWithProviders(DownloadingCard, {
props: { downloaderName, info },
})
}
function actionButtons(container: Element) {
const buttons = [...container.querySelectorAll<HTMLButtonElement>('.v-card-actions button')]
expect(buttons).toHaveLength(2)
return { deleteButton: buttons[1]!, toggleButton: buttons[0]! }
}
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
describe('DownloadingCard display and pause state', () => {
it('renders task metadata, progress, speed and the current download state', async () => {
const { container } = await renderCard()
expect(screen.getByText(/测试媒体/)).toBeInTheDocument()
expect(screen.getByText(/S01 E02/)).toBeInTheDocument()
expect(screen.getByText('下载任务标题')).toBeInTheDocument()
expect(screen.getByText(/1 小时/)).toBeInTheDocument()
expect(container.querySelector('.v-card-text .v-progress-linear')).toBeInTheDocument()
})
it('falls back to the task name and season string when media recognition is incomplete', async () => {
const { container } = await renderCard(
downloading({
media: {},
name: '未识别任务',
progress: 0,
season_episode: 'S03E04',
state: 'stopped',
}),
)
expect(screen.getByText(/未识别任务/)).toBeInTheDocument()
expect(screen.getByText(/S03E04/)).toBeInTheDocument()
expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument()
})
it('uses the current operation and downloader name, changing state only on business success', async () => {
const stopRequested = vi.fn()
const startRequested = vi.fn()
server.use(
downloadActionHandler('stop', 'hash-1', { success: false }, 200, stopRequested),
downloadActionHandler('start', 'hash-1', { success: true }, 200, startRequested),
)
const { container, rerender } = await renderCard()
const { toggleButton } = actionButtons(container)
await fireEvent.click(toggleButton)
await waitFor(() => expect(stopRequested).toHaveBeenCalledOnce())
expect(stopRequested.mock.calls[0][0].searchParams.get('name')).toBe('qb-main')
await fireEvent.click(toggleButton)
await waitFor(() => expect(stopRequested).toHaveBeenCalledTimes(2))
await rerender({ downloaderName: 'transmission', info: downloading({ state: 'stopped' }) })
await fireEvent.click(toggleButton)
await waitFor(() => expect(startRequested).toHaveBeenCalledOnce())
expect(startRequested.mock.calls[0][0].searchParams.get('name')).toBe('transmission')
await fireEvent.click(toggleButton)
await waitFor(() => expect(stopRequested).toHaveBeenCalledTimes(3))
})
it('keeps the current state when the pause request fails at the HTTP boundary', async () => {
server.use(downloadActionHandler('stop', 'hash-1', { success: false }, 503))
const { container } = await renderCard()
await fireEvent.click(actionButtons(container).toggleButton)
await waitFor(() => expect(console.error).toHaveBeenCalled())
})
})
describe('DownloadingCard deletion', () => {
it('keeps the card visible when HTTP 200 reports business failure', async () => {
const requested = vi.fn()
server.use(deleteDownloadHandler('hash-1', { success: false, message: '任务仍在运行' }, 200, requested))
const { container } = await renderCard()
await fireEvent.click(actionButtons(container).deleteButton)
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(requested.mock.calls[0][0].searchParams.get('name')).toBe('qb-main')
expect(container.querySelector('.downloading-card')).toBeInTheDocument()
})
it('hides the card only after business success', async () => {
server.use(deleteDownloadHandler('hash-1', { success: true }))
const { container } = await renderCard()
await fireEvent.click(actionButtons(container).deleteButton)
await waitFor(() => expect(container.querySelector('.downloading-card')).not.toBeInTheDocument())
})
it('keeps the card visible when deletion fails at the HTTP boundary', async () => {
server.use(deleteDownloadHandler('hash-1', { success: false }, 503))
const { container } = await renderCard()
await fireEvent.click(actionButtons(container).deleteButton)
await waitFor(() => expect(console.error).toHaveBeenCalled())
expect(container.querySelector('.downloading-card')).toBeInTheDocument()
})
})