mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
test(download): cover downloading task workflows (#612)
This commit is contained in:
@@ -115,11 +115,6 @@
|
|||||||
"count": 27
|
"count": 27
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/cards/DownloadingCard.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/cards/FilterRuleCard.vue": {
|
"src/components/cards/FilterRuleCard.vue": {
|
||||||
"vue/no-mutating-props": {
|
"vue/no-mutating-props": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -995,11 +990,6 @@
|
|||||||
"count": 4
|
"count": 4
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/views/reorganize/DownloadingListView.vue": {
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/views/reorganize/FileBrowserView.vue": {
|
"src/views/reorganize/FileBrowserView.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { DownloadingInfo } from '@/api/types'
|
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
@@ -35,19 +35,11 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 图片是否加载完成
|
|
||||||
const imageLoaded = ref(false)
|
|
||||||
|
|
||||||
// 图片加载完成响应
|
|
||||||
function imageLoadHandler() {
|
|
||||||
imageLoaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下载状态控制
|
// 下载状态控制
|
||||||
async function toggleDownload() {
|
async function toggleDownload() {
|
||||||
const operation = isDownloading.value ? 'stop' : 'start'
|
const operation = isDownloading.value ? 'stop' : 'start'
|
||||||
try {
|
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: {
|
params: {
|
||||||
name: props.downloaderName,
|
name: props.downloaderName,
|
||||||
},
|
},
|
||||||
@@ -59,11 +51,13 @@ async function toggleDownload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除下截
|
// 删除下载任务
|
||||||
async function deleteDownload() {
|
async function deleteDownload() {
|
||||||
try {
|
try {
|
||||||
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
|
const result: ApiResponse<unknown> = await api.delete(`download/${props.info?.hash}`, {
|
||||||
cardState.value = false
|
params: { name: props.downloaderName },
|
||||||
|
})
|
||||||
|
if (result.success) cardState.value = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -83,53 +77,46 @@ async function deleteDownload() {
|
|||||||
}"
|
}"
|
||||||
min-height="150"
|
min-height="150"
|
||||||
>
|
>
|
||||||
<template #image>
|
<template #image>
|
||||||
<VImg
|
<VImg :src="props.info?.media.image" class="downloading-card-image" aspect-ratio="2/3" cover position="top">
|
||||||
:src="props.info?.media.image"
|
<template #placeholder>
|
||||||
class="downloading-card-image"
|
<div class="w-full h-full">
|
||||||
aspect-ratio="2/3"
|
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||||
cover
|
</div>
|
||||||
@load="imageLoadHandler"
|
</template>
|
||||||
position="top"
|
<template #default>
|
||||||
>
|
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||||
<template #placeholder>
|
</template>
|
||||||
<div class="w-full h-full">
|
</VImg>
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
</template>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #default>
|
|
||||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
|
||||||
</template>
|
|
||||||
</VImg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<VCardTitle class="break-words whitespace-normal text-white">
|
<VCardTitle class="break-words whitespace-normal text-white">
|
||||||
{{ props.info?.media.title || props.info?.name }}
|
{{ props.info?.media.title || props.info?.name }}
|
||||||
{{
|
{{
|
||||||
props.info?.media.episode
|
props.info?.media.episode
|
||||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||||
: props.info?.season_episode
|
: props.info?.season_episode
|
||||||
}}
|
}}
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
|
|
||||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||||
{{ props.info?.title }}
|
{{ props.info?.title }}
|
||||||
</VCardSubtitle>
|
</VCardSubtitle>
|
||||||
|
|
||||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||||
{{ getSpeedText() }}
|
{{ getSpeedText() }}
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardActions class="justify-space-between">
|
<VCardActions class="justify-space-between">
|
||||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,11 +7,11 @@ import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
|||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBackground } from '@/composables/useBackground'
|
import { useBackground } from '@/composables/useBackground'
|
||||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { useDataRefresh } = useBackground()
|
const { useConditionalDataRefresh } = useBackground()
|
||||||
|
|
||||||
// 定义输入参数
|
// 定义输入参数
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -28,8 +28,8 @@ const dataList = ref<DownloadingInfo[]>([])
|
|||||||
// 是否刷新过
|
// 是否刷新过
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
// 获取订阅列表数据
|
// 获取当前下载器的任务快照
|
||||||
async function fetchData(_context: KeepAliveRefreshContext = {}) {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
dataList.value = await api.get('download/', { params: { name: props.name } })
|
dataList.value = await api.get('download/', { params: { name: props.name } })
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
@@ -38,7 +38,7 @@ async function fetchData(_context: KeepAliveRefreshContext = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
|
// 管理员显示全部下载任务,普通用户仅显示本人任务
|
||||||
const filteredDataList = computed(() => {
|
const filteredDataList = computed(() => {
|
||||||
// 从 Store 中获取用户信息
|
// 从 Store 中获取用户信息
|
||||||
const superUser = userStore.superUser
|
const superUser = userStore.superUser
|
||||||
@@ -47,10 +47,11 @@ const filteredDataList = computed(() => {
|
|||||||
else return dataList.value.filter(data => data.userid === userName || data.username === userName)
|
else return dataList.value.filter(data => data.userid === userName || data.username === userName)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用数据刷新定时器
|
// 每个下载器独立持有刷新身份,非活动标签不占用轮询资源。
|
||||||
const { loading: dataLoading } = useDataRefresh(
|
useConditionalDataRefresh(
|
||||||
'downloading-list',
|
`downloading-list-${props.name}`,
|
||||||
fetchData,
|
fetchData,
|
||||||
|
computed(() => props.active !== false),
|
||||||
3000, // 3秒间隔
|
3000, // 3秒间隔
|
||||||
false, // 初始加载交给 keep-alive 页面自身,避免同时发起两次请求
|
false, // 初始加载交给 keep-alive 页面自身,避免同时发起两次请求
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/views/discover/MediaDetailView.vue',
|
||||||
'src/components/cards/MediaCard.vue',
|
'src/components/cards/MediaCard.vue',
|
||||||
'src/components/cards/SiteCard.vue',
|
'src/components/cards/SiteCard.vue',
|
||||||
|
'src/components/cards/DownloadingCard.vue',
|
||||||
'src/components/slide/VirtualSlideView.vue',
|
'src/components/slide/VirtualSlideView.vue',
|
||||||
'src/views/discover/PersonCardSlideView.vue',
|
'src/views/discover/PersonCardSlideView.vue',
|
||||||
'src/utils/mediaStatusCache.ts',
|
'src/utils/mediaStatusCache.ts',
|
||||||
'src/utils/searchStream.ts',
|
'src/utils/searchStream.ts',
|
||||||
'src/views/site/SiteCardListView.vue',
|
'src/views/site/SiteCardListView.vue',
|
||||||
|
'src/views/reorganize/DownloadingListView.vue',
|
||||||
'src/utils/siteIconCache.ts',
|
'src/utils/siteIconCache.ts',
|
||||||
],
|
],
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
@@ -622,6 +624,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/components/cards/DownloadingCard.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/utils/siteIconCache.ts': {
|
'src/utils/siteIconCache.ts': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
@@ -652,6 +660,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/views/reorganize/DownloadingListView.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/views/subscribe/FullCalendarView.vue': {
|
'src/views/subscribe/FullCalendarView.vue': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
|
|||||||
Reference in New Issue
Block a user