mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 18:45:36 +08:00
test(download): cover downloading task workflows (#612)
This commit is contained in:
@@ -115,11 +115,6 @@
|
||||
"count": 27
|
||||
}
|
||||
},
|
||||
"src/components/cards/DownloadingCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/FilterRuleCard.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 1
|
||||
@@ -995,11 +990,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/views/reorganize/DownloadingListView.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/views/reorganize/FileBrowserView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
@@ -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>
|
||||
|
||||
139
src/components/cards/__tests__/DownloadingCard.spec.ts
Normal file
139
src/components/cards/__tests__/DownloadingCard.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -7,11 +7,11 @@ import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useDataRefresh } = useBackground()
|
||||
const { useConditionalDataRefresh } = useBackground()
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps<{
|
||||
@@ -28,8 +28,8 @@ const dataList = ref<DownloadingInfo[]>([])
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData(_context: KeepAliveRefreshContext = {}) {
|
||||
// 获取当前下载器的任务快照
|
||||
async function fetchData() {
|
||||
try {
|
||||
dataList.value = await api.get('download/', { params: { name: props.name } })
|
||||
isRefreshed.value = true
|
||||
@@ -38,7 +38,7 @@ async function fetchData(_context: KeepAliveRefreshContext = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
|
||||
// 管理员显示全部下载任务,普通用户仅显示本人任务
|
||||
const filteredDataList = computed(() => {
|
||||
// 从 Store 中获取用户信息
|
||||
const superUser = userStore.superUser
|
||||
@@ -47,10 +47,11 @@ const filteredDataList = computed(() => {
|
||||
else return dataList.value.filter(data => data.userid === userName || data.username === userName)
|
||||
})
|
||||
|
||||
// 使用数据刷新定时器
|
||||
const { loading: dataLoading } = useDataRefresh(
|
||||
'downloading-list',
|
||||
// 每个下载器独立持有刷新身份,非活动标签不占用轮询资源。
|
||||
useConditionalDataRefresh(
|
||||
`downloading-list-${props.name}`,
|
||||
fetchData,
|
||||
computed(() => props.active !== false),
|
||||
3000, // 3秒间隔
|
||||
false, // 初始加载交给 keep-alive 页面自身,避免同时发起两次请求
|
||||
)
|
||||
|
||||
323
src/views/reorganize/__tests__/DownloadingListView.spec.ts
Normal file
323
src/views/reorganize/__tests__/DownloadingListView.spec.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { downloadingListHandler } from '@tests/support/msw/handlers/download'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { defineComponent, ref, type PropType, type Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const refreshMocks = vi.hoisted(() => ({
|
||||
registrations: [] as Array<{
|
||||
callback: () => Promise<void> | void
|
||||
condition: Ref<boolean>
|
||||
id: string
|
||||
immediate: boolean
|
||||
interval: number
|
||||
kind: 'conditional' | 'unconditional'
|
||||
}>,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useBackground', async () => {
|
||||
const { computed, onMounted, ref, watch } = await import('vue')
|
||||
|
||||
function register(
|
||||
kind: 'conditional' | 'unconditional',
|
||||
id: string,
|
||||
callback: () => Promise<void> | void,
|
||||
condition: Ref<boolean>,
|
||||
interval: number,
|
||||
immediate: boolean,
|
||||
) {
|
||||
refreshMocks.registrations.push({ callback, condition, id, immediate, interval, kind })
|
||||
if (kind === 'conditional') {
|
||||
onMounted(() => {
|
||||
if (condition.value && immediate) void callback()
|
||||
})
|
||||
watch(condition, active => {
|
||||
if (active && immediate) void callback()
|
||||
})
|
||||
}
|
||||
return {
|
||||
isActive: ref(condition.value),
|
||||
loading: ref(false),
|
||||
refresh: callback,
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
useBackground: () => ({
|
||||
useConditionalDataRefresh: (
|
||||
id: string,
|
||||
callback: () => Promise<void> | void,
|
||||
condition: Ref<boolean>,
|
||||
interval: number,
|
||||
immediate = true,
|
||||
) => register('conditional', id, callback, condition, interval, immediate),
|
||||
useDataRefresh: (id: string, callback: () => Promise<void> | void, interval: number) =>
|
||||
register(
|
||||
'unconditional',
|
||||
id,
|
||||
callback,
|
||||
computed(() => true),
|
||||
interval,
|
||||
true,
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const LoadingBannerStub = defineComponent({
|
||||
template: '<div data-testid="loading-banner">loading</div>',
|
||||
})
|
||||
|
||||
const NoDataFoundStub = defineComponent({
|
||||
template: '<div data-testid="no-data">empty</div>',
|
||||
})
|
||||
|
||||
const DownloadingCardStub = defineComponent({
|
||||
props: {
|
||||
downloaderName: String,
|
||||
info: Object,
|
||||
},
|
||||
template: '<article :data-testid="`download-${info.hash}`">{{ info.title }}|{{ downloaderName }}</article>',
|
||||
})
|
||||
|
||||
const ProgressiveCardGridStub = defineComponent({
|
||||
props: {
|
||||
getItemKey: {
|
||||
type: Function as PropType<(item: DownloadingInfo) => string | undefined>,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<DownloadingInfo[]>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div data-testid="grid">
|
||||
<div v-for="item in items" :key="getItemKey(item)" :data-item-key="getItemKey(item)">
|
||||
<slot :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
function downloading(hash: string, title: string, overrides: Partial<DownloadingInfo> = {}): DownloadingInfo {
|
||||
return {
|
||||
dlspeed: '2 MiB',
|
||||
hash,
|
||||
left_time: '1 小时',
|
||||
media: { title },
|
||||
name: title,
|
||||
progress: 20,
|
||||
size: 1024,
|
||||
state: 'downloading',
|
||||
title,
|
||||
upspeed: '1 MiB',
|
||||
userid: 'tester',
|
||||
username: 'tester',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function renderList(
|
||||
props: { active?: boolean; name?: string } = {},
|
||||
options: {
|
||||
onRequest?: (url: URL) => void
|
||||
response?: DownloadingInfo[] | ((url: URL) => DownloadingInfo[] | Promise<DownloadingInfo[]>)
|
||||
status?: number
|
||||
superUser?: boolean
|
||||
userName?: string
|
||||
} = {},
|
||||
) {
|
||||
server.use(downloadingListHandler(options.response ?? [], options.status ?? 200, options.onRequest))
|
||||
return renderWithProviders(DownloadingListView, {
|
||||
props: {
|
||||
active: props.active ?? true,
|
||||
name: props.name ?? 'primary',
|
||||
},
|
||||
initialState: {
|
||||
user: {
|
||||
superUser: options.superUser ?? false,
|
||||
userName: options.userName ?? 'tester',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
DownloadingCard: DownloadingCardStub,
|
||||
LoadingBanner: LoadingBannerStub,
|
||||
NoDataFound: NoDataFoundStub,
|
||||
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function runRegisteredRefreshes() {
|
||||
const jobsById = new Map(refreshMocks.registrations.map(registration => [registration.id, registration]))
|
||||
await Promise.all(
|
||||
[...jobsById.values()]
|
||||
.filter(registration => registration.condition.value)
|
||||
.map(registration => registration.callback()),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
refreshMocks.registrations.length = 0
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('DownloadingListView loading and ownership', () => {
|
||||
it('queries the selected downloader and filters a normal user by either owner field', async () => {
|
||||
const requested = vi.fn()
|
||||
await renderList(
|
||||
{ name: 'qb-main' },
|
||||
{
|
||||
onRequest: requested,
|
||||
response: [
|
||||
downloading('own-id', 'Own by id', { userid: 'tester', username: 'other' }),
|
||||
downloading('', 'Own by name', { userid: 'other', username: 'tester' }),
|
||||
downloading('other', 'Other task', { userid: 'other', username: 'other' }),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Own by id|qb-main')).toBeInTheDocument()
|
||||
expect(screen.getByText('Own by name|qb-main')).toBeInTheDocument()
|
||||
expect(screen.getByText('Own by id|qb-main').parentElement).toHaveAttribute('data-item-key', 'own-id')
|
||||
expect(screen.getByText('Own by name|qb-main').parentElement).toHaveAttribute('data-item-key', 'Own by name')
|
||||
expect(screen.queryByText('Other task|qb-main')).not.toBeInTheDocument()
|
||||
expect(requested).toHaveBeenCalledOnce()
|
||||
expect(requested.mock.calls[0][0].searchParams.get('name')).toBe('qb-main')
|
||||
})
|
||||
|
||||
it('lets a superuser see every task', async () => {
|
||||
await renderList(
|
||||
{ name: 'transmission' },
|
||||
{
|
||||
response: [
|
||||
downloading('own', 'Own task'),
|
||||
downloading('other', 'Other task', { userid: 'other', username: 'other' }),
|
||||
],
|
||||
superUser: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Own task|transmission')).toBeInTheDocument()
|
||||
expect(screen.getByText('Other task|transmission')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replaces the loading state with the successful empty state', async () => {
|
||||
let resolveResponse: ((value: DownloadingInfo[]) => void) | undefined
|
||||
await renderList(
|
||||
{},
|
||||
{
|
||||
response: () =>
|
||||
new Promise<DownloadingInfo[]>(resolve => {
|
||||
resolveResponse = resolve
|
||||
}),
|
||||
},
|
||||
)
|
||||
expect(screen.getByTestId('loading-banner')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => expect(resolveResponse).toBeTypeOf('function'))
|
||||
resolveResponse?.([])
|
||||
expect(await screen.findByTestId('no-data')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not misrepresent an HTTP failure as a successful empty snapshot', async () => {
|
||||
await renderList({}, { status: 503 })
|
||||
|
||||
await waitFor(() => expect(console.error).toHaveBeenCalled())
|
||||
expect(screen.queryByTestId('no-data')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DownloadingListView refresh ownership', () => {
|
||||
it('uses downloader-scoped identities and refreshes only the active downloader snapshot', async () => {
|
||||
const requested = vi.fn()
|
||||
const snapshots: Record<string, DownloadingInfo[]> = {
|
||||
alpha: [downloading('alpha-old', 'Alpha old')],
|
||||
beta: [downloading('beta-old', 'Beta old')],
|
||||
}
|
||||
server.use(downloadingListHandler(url => snapshots[url.searchParams.get('name') || ''] ?? [], 200, requested))
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { DownloadingListView },
|
||||
setup() {
|
||||
const activeName = ref('alpha')
|
||||
return { activeName }
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="activeName = 'beta'">activate beta</button>
|
||||
<DownloadingListView name="alpha" :active="activeName === 'alpha'" />
|
||||
<DownloadingListView name="beta" :active="activeName === 'beta'" />
|
||||
`,
|
||||
})
|
||||
await renderWithProviders(Host, {
|
||||
initialState: { user: { superUser: true, userName: 'admin' } },
|
||||
global: {
|
||||
stubs: {
|
||||
DownloadingCard: DownloadingCardStub,
|
||||
LoadingBanner: LoadingBannerStub,
|
||||
NoDataFound: NoDataFoundStub,
|
||||
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Alpha old|alpha')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Beta old|beta')).toBeInTheDocument()
|
||||
expect(requested).toHaveBeenCalledTimes(2)
|
||||
expect(requested.mock.calls.map(call => call[0].searchParams.get('name')).sort()).toEqual(['alpha', 'beta'])
|
||||
snapshots.alpha = [downloading('alpha-new', 'Alpha new')]
|
||||
snapshots.beta = [downloading('beta-new', 'Beta new')]
|
||||
|
||||
await runRegisteredRefreshes()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Alpha new|alpha')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Alpha old|alpha')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Beta old|beta')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beta new|beta')).not.toBeInTheDocument()
|
||||
expect(requested).toHaveBeenCalledTimes(3)
|
||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(1)
|
||||
|
||||
snapshots.alpha = [downloading('alpha-later', 'Alpha later')]
|
||||
snapshots.beta = [downloading('beta-activated', 'Beta activated')]
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'activate beta' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Beta activated|beta')).toBeInTheDocument())
|
||||
expect(requested).toHaveBeenCalledTimes(4)
|
||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(2)
|
||||
|
||||
snapshots.beta = [downloading('beta-new', 'Beta new')]
|
||||
await runRegisteredRefreshes()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Beta new|beta')).toBeInTheDocument())
|
||||
expect(screen.getByText('Alpha new|alpha')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha later|alpha')).not.toBeInTheDocument()
|
||||
expect(requested).toHaveBeenCalledTimes(5)
|
||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(3)
|
||||
expect(refreshMocks.registrations).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'downloading-list-alpha',
|
||||
immediate: false,
|
||||
interval: 3000,
|
||||
kind: 'conditional',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'downloading-list-beta',
|
||||
immediate: false,
|
||||
interval: 3000,
|
||||
kind: 'conditional',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
62
tests/support/msw/handlers/download.ts
Normal file
62
tests/support/msw/handlers/download.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||
|
||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||
|
||||
export interface DownloadMutationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const downloadApiUrls = {
|
||||
action: (operation: 'start' | 'stop', hash: string) => new URL(`download/${operation}/${hash}`, API_BASE_URL).href,
|
||||
delete: (hash: string) => new URL(`download/${hash}`, API_BASE_URL).href,
|
||||
list: new URL('download/', API_BASE_URL).href,
|
||||
}
|
||||
|
||||
function jsonResponse(body: JsonBodyType, status: number) {
|
||||
return HttpResponse.json(body, { status })
|
||||
}
|
||||
|
||||
/** 拦截下载任务快照查询,并保留下载器查询参数供断言。 */
|
||||
export function downloadingListHandler(
|
||||
response: DownloadingInfo[] | ((url: URL) => DownloadingInfo[] | Promise<DownloadingInfo[]>) = [],
|
||||
status = 200,
|
||||
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(downloadApiUrls.list, async ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
await onRequest(url)
|
||||
const body = typeof response === 'function' ? await response(url) : response
|
||||
return jsonResponse(body as unknown as JsonBodyType, status)
|
||||
})
|
||||
}
|
||||
|
||||
/** 拦截暂停或继续下载请求,并保留下载器查询参数供断言。 */
|
||||
export function downloadActionHandler(
|
||||
operation: 'start' | 'stop',
|
||||
hash: string,
|
||||
response: DownloadMutationResponse = { success: true },
|
||||
status = 200,
|
||||
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(downloadApiUrls.action(operation, hash), async ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
await onRequest(url)
|
||||
return jsonResponse(response, status)
|
||||
})
|
||||
}
|
||||
|
||||
/** 拦截删除下载任务请求,并保留下载器查询参数供断言。 */
|
||||
export function deleteDownloadHandler(
|
||||
hash: string,
|
||||
response: DownloadMutationResponse = { success: true },
|
||||
status = 200,
|
||||
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.delete(downloadApiUrls.delete(hash), async ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
await onRequest(url)
|
||||
return jsonResponse(response, status)
|
||||
})
|
||||
}
|
||||
@@ -331,11 +331,13 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/views/discover/MediaDetailView.vue',
|
||||
'src/components/cards/MediaCard.vue',
|
||||
'src/components/cards/SiteCard.vue',
|
||||
'src/components/cards/DownloadingCard.vue',
|
||||
'src/components/slide/VirtualSlideView.vue',
|
||||
'src/views/discover/PersonCardSlideView.vue',
|
||||
'src/utils/mediaStatusCache.ts',
|
||||
'src/utils/searchStream.ts',
|
||||
'src/views/site/SiteCardListView.vue',
|
||||
'src/views/reorganize/DownloadingListView.vue',
|
||||
'src/utils/siteIconCache.ts',
|
||||
],
|
||||
provider: 'v8',
|
||||
@@ -622,6 +624,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/components/cards/DownloadingCard.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/utils/siteIconCache.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
@@ -652,6 +660,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/views/reorganize/DownloadingListView.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/views/subscribe/FullCalendarView.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
|
||||
Reference in New Issue
Block a user