mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
test(transfer): cover queue dialog workflows (#616)
This commit is contained in:
@@ -385,17 +385,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/TransferQueueDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-dead-store": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/U115AuthDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
@@ -12,10 +13,12 @@ import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
type TransferTask = TransferQueue['tasks'][number]
|
||||
type FileProgressSSE = ReturnType<ReturnType<typeof useBackground>['useProgressSSE']>
|
||||
|
||||
interface MediaTaskGroup {
|
||||
key: string
|
||||
media: TransferQueue['media']
|
||||
titleYear: string
|
||||
season?: number
|
||||
tasks: TransferTask[]
|
||||
total: number
|
||||
completed: number
|
||||
@@ -24,6 +27,7 @@ interface MediaTaskGroup {
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
const { useProgressSSE } = useBackground()
|
||||
const toast = useToast()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -38,6 +42,8 @@ const emit = defineEmits(['close'])
|
||||
// 数据列表
|
||||
const dataList = ref<TransferQueue[]>([])
|
||||
|
||||
const queueLoadFailed = ref(false)
|
||||
|
||||
// 文件进度映射
|
||||
const fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())
|
||||
|
||||
@@ -51,7 +57,14 @@ const activeTab = ref('')
|
||||
const queueTimer = ref<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 文件进度SSE连接映射
|
||||
const fileProgressSSEMap = ref<Map<string, any>>(new Map())
|
||||
const fileProgressSSEMap = ref<Map<string, FileProgressSSE>>(new Map())
|
||||
|
||||
// 请求只能覆盖更早提交的快照,较新的未完成请求不阻塞当前可用结果。
|
||||
let nextQueueRequestId = 0
|
||||
let latestCommittedQueueRequestId = 0
|
||||
|
||||
// 异步操作完成后只有仍挂载的弹窗可以继续刷新队列或提示错误。
|
||||
let isMounted = false
|
||||
|
||||
// 状态标签
|
||||
const stateDict = computed<Record<string, string>>(() => ({
|
||||
@@ -62,23 +75,35 @@ const stateDict = computed<Record<string, string>>(() => ({
|
||||
cancelled: t('dialog.transferQueue.cancelledState'),
|
||||
}))
|
||||
|
||||
// 按媒体聚合队列,避免模板中重复扫描 dataList
|
||||
// 队列作业由来源原生媒体 ID 与季号共同标识;缺少媒体 ID 时才退回展示标题。
|
||||
function getMediaIdentity(item: TransferQueue) {
|
||||
const source = item.media.mediaid_prefix || item.media.source
|
||||
const mediaIdentity =
|
||||
source && item.media.media_id
|
||||
? `${source}:${item.media.media_id}`
|
||||
: `title:${item.media.title_year || item.media.title || ''}`
|
||||
|
||||
return `${mediaIdentity}:season:${item.season ?? ''}`
|
||||
}
|
||||
|
||||
// 按真实媒体标识聚合队列,避免同名同年但来源不同的媒体互相合并。
|
||||
const mediaTaskGroups = computed<MediaTaskGroup[]>(() => {
|
||||
const groupMap = new Map<string, MediaTaskGroup>()
|
||||
|
||||
dataList.value.forEach(item => {
|
||||
const titleYear = item.media.title_year || ''
|
||||
let group = groupMap.get(titleYear)
|
||||
const key = getMediaIdentity(item)
|
||||
let group = groupMap.get(key)
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
key,
|
||||
media: item.media,
|
||||
titleYear,
|
||||
season: item.season,
|
||||
tasks: [],
|
||||
total: 0,
|
||||
completed: 0,
|
||||
}
|
||||
groupMap.set(titleYear, group)
|
||||
groupMap.set(key, group)
|
||||
}
|
||||
|
||||
group.tasks.push(...item.tasks)
|
||||
@@ -91,7 +116,7 @@ const mediaTaskGroups = computed<MediaTaskGroup[]>(() => {
|
||||
|
||||
// 当前选中的媒体分组
|
||||
const activeMediaGroup = computed(() => {
|
||||
return mediaTaskGroups.value.find(item => item.titleYear === activeTab.value)
|
||||
return mediaTaskGroups.value.find(item => item.key === activeTab.value)
|
||||
})
|
||||
|
||||
// 当前媒体的文件任务
|
||||
@@ -139,10 +164,12 @@ function getStateIcon(state: string) {
|
||||
}
|
||||
|
||||
// 获取媒体显示标题。
|
||||
function getMediaTitle(media?: MediaInfo) {
|
||||
function getMediaTitle(media?: MediaInfo, season?: number) {
|
||||
if (!media) return '-'
|
||||
if (media.title_year) return media.title_year
|
||||
return [media.title, media.year ? `(${media.year})` : ''].filter(Boolean).join('') || '-'
|
||||
const title = media.title_year || [media.title, media.year ? `(${media.year})` : ''].filter(Boolean).join('') || '-'
|
||||
if (season === undefined || season === null) return title
|
||||
|
||||
return `${title} S${String(season).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 获取适配全局图片设置的媒体海报地址。
|
||||
@@ -150,12 +177,9 @@ function getPosterUrl(media?: MediaInfo) {
|
||||
const posterPath = media?.poster_path
|
||||
if (!posterPath) return noImage
|
||||
|
||||
let posterUrl = posterPath
|
||||
if (posterPath.startsWith('/')) {
|
||||
posterUrl = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
} else {
|
||||
posterUrl = posterPath.replace('original', 'w500')
|
||||
}
|
||||
const posterUrl = posterPath.startsWith('/')
|
||||
? `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
: posterPath.replace('original', 'w500')
|
||||
|
||||
return getDisplayImageUrl(posterUrl, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
@@ -187,16 +211,27 @@ function getTaskProgress(task: TransferTask) {
|
||||
|
||||
// 调用API获取队列信息。
|
||||
async function get_transfer_queue() {
|
||||
const requestId = ++nextQueueRequestId
|
||||
|
||||
try {
|
||||
dataList.value = await api.get('transfer/queue')
|
||||
const queue = (await api.get('transfer/queue')) as TransferQueue[]
|
||||
if (!isMounted || requestId < latestCommittedQueueRequestId) return
|
||||
|
||||
latestCommittedQueueRequestId = requestId
|
||||
queueLoadFailed.value = false
|
||||
dataList.value = queue
|
||||
if (dataList.value.length > 0) {
|
||||
if (!activeTab.value || activeTasks.value.length === 0) activeTab.value = dataList.value[0].media.title_year || ''
|
||||
if (!activeTab.value || activeTasks.value.length === 0) activeTab.value = mediaTaskGroups.value[0].key
|
||||
|
||||
if (!progressActive.value) startLoadingProgress()
|
||||
} else if (progressActive.value) {
|
||||
stopLoadingProgress()
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted || requestId < latestCommittedQueueRequestId) return
|
||||
|
||||
latestCommittedQueueRequestId = requestId
|
||||
queueLoadFailed.value = true
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -205,9 +240,14 @@ async function get_transfer_queue() {
|
||||
async function remove_queue_task(fileitem: FileItem) {
|
||||
try {
|
||||
await api.delete('transfer/queue', { data: fileitem })
|
||||
get_transfer_queue()
|
||||
if (!isMounted) return
|
||||
|
||||
void get_transfer_queue()
|
||||
} catch (error) {
|
||||
if (!isMounted) return
|
||||
|
||||
console.error(error)
|
||||
toast.error(t('common.serverConnectionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,18 +350,23 @@ function stopQueueTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => startQueueTimer())
|
||||
onMounted(() => {
|
||||
isMounted = true
|
||||
startQueueTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isMounted = false
|
||||
stopQueueTimer()
|
||||
stopLoadingProgress()
|
||||
progressActive.value = false
|
||||
stopAllFileProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="mx-auto" width="100%">
|
||||
<VCardItem :class="{'py-2': dataList.length > 0}">
|
||||
<VCardItem :class="{ 'py-2': dataList.length > 0 }">
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-menu" color="primary" size="28" class="me-2" />
|
||||
@@ -339,7 +384,17 @@ onUnmounted(() => {
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText v-if="dataList.length === 0" class="transfer-queue-empty">
|
||||
<VCardText v-if="queueLoadFailed" class="transfer-queue-empty">
|
||||
<VIcon class="transfer-queue-empty__icon" icon="mdi-alert-outline" size="30" />
|
||||
<div class="transfer-queue-empty__headline">
|
||||
{{ t('common.serverConnectionFailed') }}
|
||||
</div>
|
||||
<VBtn color="primary" variant="tonal" @click="get_transfer_queue">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-else-if="dataList.length === 0" class="transfer-queue-empty">
|
||||
<VIcon class="transfer-queue-empty__icon" icon="mdi-sync" size="30" />
|
||||
<div class="transfer-queue-empty__headline">
|
||||
{{ t('dialog.transferQueue.noTasks') }}
|
||||
@@ -375,21 +430,21 @@ onUnmounted(() => {
|
||||
<nav class="media-selector" :aria-label="t('dialog.transferQueue.mediaList')">
|
||||
<button
|
||||
v-for="group in mediaTaskGroups"
|
||||
:key="group.titleYear"
|
||||
:key="group.key"
|
||||
type="button"
|
||||
class="media-selector__item app-surface-shape"
|
||||
:class="{ 'media-selector__item--active': activeTab === group.titleYear }"
|
||||
:aria-current="activeTab === group.titleYear ? 'true' : undefined"
|
||||
@click="activeTab = group.titleYear"
|
||||
:class="{ 'media-selector__item--active': activeTab === group.key }"
|
||||
:aria-current="activeTab === group.key ? 'true' : undefined"
|
||||
@click="activeTab = group.key"
|
||||
>
|
||||
<VImg
|
||||
class="media-selector__poster"
|
||||
:src="getPosterUrl(group.media)"
|
||||
:alt="getMediaTitle(group.media)"
|
||||
:alt="getMediaTitle(group.media, group.season)"
|
||||
cover
|
||||
/>
|
||||
<div class="media-selector__info">
|
||||
<div class="media-selector__title">{{ getMediaTitle(group.media) }}</div>
|
||||
<div class="media-selector__title">{{ getMediaTitle(group.media, group.season) }}</div>
|
||||
<div class="media-selector__meta">
|
||||
<span>{{ getMediaCount(group) }}</span>
|
||||
<span class="media-selector__percent">{{ Math.round(getMediaProgress(group)) }}%</span>
|
||||
@@ -410,11 +465,13 @@ onUnmounted(() => {
|
||||
<VImg
|
||||
class="active-media__poster"
|
||||
:src="getPosterUrl(activeMediaGroup?.media)"
|
||||
:alt="getMediaTitle(activeMediaGroup?.media)"
|
||||
:alt="getMediaTitle(activeMediaGroup?.media, activeMediaGroup?.season)"
|
||||
cover
|
||||
/>
|
||||
<div class="active-media__info">
|
||||
<h3 class="active-media__title">{{ getMediaTitle(activeMediaGroup?.media) }}</h3>
|
||||
<h3 class="active-media__title">
|
||||
{{ getMediaTitle(activeMediaGroup?.media, activeMediaGroup?.season) }}
|
||||
</h3>
|
||||
<p class="active-media__meta">
|
||||
{{
|
||||
t('dialog.transferQueue.mediaFileSummary', {
|
||||
|
||||
522
src/components/dialog/__tests__/TransferQueueDialog.spec.ts
Normal file
522
src/components/dialog/__tests__/TransferQueueDialog.spec.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import type { MetaInfo, TransferQueue } from '@/api/types'
|
||||
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
|
||||
import { screen, waitFor, within } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiDelete: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
progressControllers: [] as Array<{
|
||||
handler: (event: MessageEvent) => void
|
||||
start: ReturnType<typeof vi.fn>
|
||||
stop: ReturnType<typeof vi.fn>
|
||||
}>,
|
||||
toastError: vi.fn(),
|
||||
useProgressSSE: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
delete: (...args: unknown[]) => mocks.apiDelete(...args),
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useBackground', () => ({
|
||||
useBackground: () => ({
|
||||
useProgressSSE: mocks.useProgressSSE,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError }),
|
||||
}))
|
||||
|
||||
function createMetaInfo(): MetaInfo {
|
||||
return {
|
||||
apply_words: [],
|
||||
audio_term: '',
|
||||
edition: '',
|
||||
episode: '',
|
||||
episode_list: [],
|
||||
episode_seq: '',
|
||||
episode_seqs: '',
|
||||
episodes: '',
|
||||
isfile: true,
|
||||
name: '',
|
||||
release_group: '',
|
||||
resource_term: '',
|
||||
sea: '',
|
||||
season: '',
|
||||
season_episode: '',
|
||||
season_list: [],
|
||||
season_seq: '',
|
||||
total_episode: 0,
|
||||
total_season: 0,
|
||||
type: '电影',
|
||||
video_term: '',
|
||||
web_source: '',
|
||||
}
|
||||
}
|
||||
|
||||
function createQueueItem({
|
||||
id,
|
||||
path,
|
||||
season,
|
||||
state = 'running',
|
||||
title,
|
||||
titleYear = `${title} (2026)`,
|
||||
}: {
|
||||
id: number
|
||||
path: string
|
||||
season?: number
|
||||
state?: string
|
||||
title: string
|
||||
titleYear?: string
|
||||
}): TransferQueue {
|
||||
return {
|
||||
media: {
|
||||
episode_run_time: [],
|
||||
origin_country: [],
|
||||
media_id: String(id),
|
||||
mediaid_prefix: 'themoviedb',
|
||||
source: 'themoviedb',
|
||||
title,
|
||||
title_year: titleYear,
|
||||
year: '2026',
|
||||
},
|
||||
season,
|
||||
tasks: [
|
||||
{
|
||||
fileitem: {
|
||||
name: `${title}.mkv`,
|
||||
path,
|
||||
size: 1024,
|
||||
storage: 'local',
|
||||
type: 'file',
|
||||
},
|
||||
meta: createMetaInfo(),
|
||||
state,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createQueue(title: string, path: string, state = 'running'): TransferQueue[] {
|
||||
return [createQueueItem({ id: path.length, path, state, title })]
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function renderDialog() {
|
||||
return renderWithProviders(TransferQueueDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VDialog: { template: '<div><slot /></div>' },
|
||||
VDialogCloseBtn: { template: '<button type="button">关闭</button>' },
|
||||
},
|
||||
},
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
GLOBAL_IMAGE_CACHE: false,
|
||||
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('TransferQueueDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.progressControllers.length = 0
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mocks.useProgressSSE.mockImplementation((_url: string, handler: (event: MessageEvent) => void) => {
|
||||
const controller = {
|
||||
handler,
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
mocks.progressControllers.push(controller)
|
||||
return controller
|
||||
})
|
||||
})
|
||||
|
||||
it('loads immediately, renders a real empty state, and polls again after three seconds', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.apiGet.mockResolvedValue([])
|
||||
|
||||
await renderDialog()
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByText('没有正在整理的任务')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps media with different real identities in separate tabs even when title_year matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.apiGet.mockResolvedValue([
|
||||
createQueueItem({
|
||||
id: 7101,
|
||||
path: '/downloads/source-a.mkv',
|
||||
title: '来源 A',
|
||||
titleYear: '同名作品 (2026)',
|
||||
}),
|
||||
createQueueItem({
|
||||
id: 7102,
|
||||
path: '/downloads/source-b.mkv',
|
||||
title: '来源 B',
|
||||
titleYear: '同名作品 (2026)',
|
||||
}),
|
||||
])
|
||||
|
||||
await renderDialog()
|
||||
|
||||
const mediaList = await screen.findByRole('navigation', { name: '媒体队列' })
|
||||
const mediaButtons = within(mediaList).getAllByRole('button')
|
||||
expect(mediaButtons).toHaveLength(2)
|
||||
expect(screen.getByText('来源 A.mkv')).toBeInTheDocument()
|
||||
expect(screen.queryByText('来源 B.mkv')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(mediaButtons[1])
|
||||
|
||||
expect(screen.getByText('来源 B.mkv')).toBeInTheDocument()
|
||||
expect(screen.queryByText('来源 A.mkv')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses canonical built-in and custom identities before falling back to the title', async () => {
|
||||
const builtIn = createQueueItem({
|
||||
id: 7301,
|
||||
path: '/downloads/built-in.mkv',
|
||||
title: '内置来源',
|
||||
titleYear: '重复标题 (2026)',
|
||||
})
|
||||
const custom = createQueueItem({
|
||||
id: 7302,
|
||||
path: '/downloads/custom.mkv',
|
||||
title: '自定义来源',
|
||||
titleYear: '重复标题 (2026)',
|
||||
})
|
||||
custom.media.source = 'custom-source'
|
||||
custom.media.mediaid_prefix = 'custom-source'
|
||||
custom.media.media_id = 'custom-7302'
|
||||
const fallback = createQueueItem({
|
||||
id: 7399,
|
||||
path: '/downloads/title-fallback.mkv',
|
||||
title: '标题回退',
|
||||
titleYear: '唯一回退标题 (2026)',
|
||||
})
|
||||
fallback.media.source = undefined
|
||||
fallback.media.mediaid_prefix = undefined
|
||||
fallback.media.media_id = undefined
|
||||
mocks.apiGet.mockResolvedValue([builtIn, custom, fallback])
|
||||
|
||||
await renderDialog()
|
||||
|
||||
const mediaList = await screen.findByRole('navigation', { name: '媒体队列' })
|
||||
expect(within(mediaList).getAllByRole('button')).toHaveLength(3)
|
||||
expect(screen.getByText('3 部媒体 · 3 个文件')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps different seasons of the same media in independent tabs', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.apiGet.mockResolvedValue([
|
||||
createQueueItem({
|
||||
id: 7350,
|
||||
path: '/downloads/season-1.mkv',
|
||||
season: 1,
|
||||
title: '季度作品 S01',
|
||||
titleYear: '季度作品 (2026)',
|
||||
}),
|
||||
createQueueItem({
|
||||
id: 7350,
|
||||
path: '/downloads/season-2.mkv',
|
||||
season: 2,
|
||||
title: '季度作品 S02',
|
||||
titleYear: '季度作品 (2026)',
|
||||
}),
|
||||
])
|
||||
|
||||
await renderDialog()
|
||||
|
||||
const mediaList = await screen.findByRole('navigation', { name: '媒体队列' })
|
||||
expect(within(mediaList).getByRole('button', { name: /季度作品 \(2026\) S01/ })).toBeInTheDocument()
|
||||
const seasonTwoButton = within(mediaList).getByRole('button', { name: /季度作品 \(2026\) S02/ })
|
||||
expect(screen.getByText('季度作品 S01.mkv')).toBeInTheDocument()
|
||||
|
||||
await user.click(seasonTwoButton)
|
||||
|
||||
expect(screen.getByText('季度作品 S02.mkv')).toBeInTheDocument()
|
||||
expect(screen.queryByText('季度作品 S01.mkv')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the active media tab when polling returns the same jobs in a different order', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = createQueueItem({
|
||||
id: 7361,
|
||||
path: '/downloads/order-a.mkv',
|
||||
title: '顺序 A',
|
||||
})
|
||||
const second = createQueueItem({
|
||||
id: 7362,
|
||||
path: '/downloads/order-b.mkv',
|
||||
title: '顺序 B',
|
||||
})
|
||||
mocks.apiGet.mockResolvedValueOnce([first, second]).mockResolvedValueOnce([second, first])
|
||||
|
||||
await renderDialog()
|
||||
await flushPromises()
|
||||
const mediaList = screen.getByRole('navigation', { name: '媒体队列' })
|
||||
within(mediaList).getAllByRole('button')[1].click()
|
||||
await flushPromises()
|
||||
expect(screen.getByText('顺序 B.mkv')).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByText('顺序 B.mkv')).toBeInTheDocument()
|
||||
expect(screen.queryByText('顺序 A.mkv')).not.toBeInTheDocument()
|
||||
expect(within(mediaList).getAllByRole('button')[0]).toHaveAttribute('aria-current', 'true')
|
||||
})
|
||||
|
||||
it('renders task states and applies dynamic SSE progress to the matching running file', async () => {
|
||||
const running = createQueueItem({
|
||||
id: 7201,
|
||||
path: '/downloads/running-progress.mkv',
|
||||
title: '进度文件',
|
||||
})
|
||||
running.tasks.push({
|
||||
fileitem: {
|
||||
name: '已经完成.mkv',
|
||||
path: '/downloads/completed.mkv',
|
||||
size: 2048,
|
||||
storage: 'local',
|
||||
type: 'file',
|
||||
},
|
||||
meta: createMetaInfo(),
|
||||
state: 'completed',
|
||||
})
|
||||
mocks.apiGet.mockResolvedValue([running])
|
||||
|
||||
await renderDialog()
|
||||
|
||||
await waitFor(() => expect(mocks.progressControllers).toHaveLength(1))
|
||||
mocks.progressControllers[0].handler(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({ enable: true, value: 42 }),
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByText('正在整理')).toBeInTheDocument()
|
||||
expect(screen.getByText('完成')).toBeInTheDocument()
|
||||
expect(screen.getByText('42%')).toBeInTheDocument()
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('1 / 2 个文件')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders waiting, failed, and cancelled task states without starting progress streams', async () => {
|
||||
const queue = createQueueItem({
|
||||
id: 7401,
|
||||
path: '/downloads/waiting.mkv',
|
||||
state: 'waiting',
|
||||
title: '终态展示',
|
||||
})
|
||||
queue.tasks.push(
|
||||
{
|
||||
fileitem: {
|
||||
name: '失败.mkv',
|
||||
path: '/downloads/failed.mkv',
|
||||
size: 2048,
|
||||
storage: 'local',
|
||||
type: 'file',
|
||||
},
|
||||
meta: createMetaInfo(),
|
||||
state: 'failed',
|
||||
},
|
||||
{
|
||||
fileitem: {
|
||||
name: '已取消.mkv',
|
||||
path: '/downloads/cancelled.mkv',
|
||||
size: 4096,
|
||||
storage: 'local',
|
||||
type: 'file',
|
||||
},
|
||||
meta: createMetaInfo(),
|
||||
state: 'cancelled',
|
||||
},
|
||||
)
|
||||
mocks.apiGet.mockResolvedValue([queue])
|
||||
|
||||
await renderDialog()
|
||||
|
||||
expect(await screen.findByText('等待中')).toBeInTheDocument()
|
||||
expect(screen.getByText('失败')).toBeInTheDocument()
|
||||
expect(screen.getByText('已取消')).toBeInTheDocument()
|
||||
expect(mocks.progressControllers).toHaveLength(0)
|
||||
expect(screen.getAllByText('0%')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('stops a file stream when the task reaches a terminal state on the next poll', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.apiGet
|
||||
.mockResolvedValueOnce(createQueue('状态迁移', '/downloads/terminal.mkv'))
|
||||
.mockResolvedValueOnce(createQueue('状态迁移', '/downloads/terminal.mkv', 'completed'))
|
||||
|
||||
await renderDialog()
|
||||
await flushPromises()
|
||||
expect(mocks.progressControllers).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.progressControllers[0].stop).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText('完成')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stops every running file progress stream when the dialog unmounts with a non-empty queue', async () => {
|
||||
mocks.apiGet.mockResolvedValue(createQueue('仍在整理', '/downloads/running.mkv'))
|
||||
|
||||
const { unmount } = await renderDialog()
|
||||
|
||||
await waitFor(() => expect(mocks.progressControllers).toHaveLength(1))
|
||||
expect(mocks.progressControllers[0].start).toHaveBeenCalledOnce()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mocks.progressControllers[0].stop).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the latest queue snapshot when an earlier polling response resolves later', async () => {
|
||||
vi.useFakeTimers()
|
||||
const older = createDeferred<TransferQueue[]>()
|
||||
const newer = createDeferred<TransferQueue[]>()
|
||||
mocks.apiGet.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
|
||||
|
||||
await renderDialog()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
|
||||
newer.resolve(createQueue('较新快照', '/downloads/newer.mkv'))
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(screen.getAllByText('较新快照 (2026)')).toHaveLength(2))
|
||||
|
||||
older.resolve(createQueue('较旧快照', '/downloads/older.mkv'))
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(screen.queryAllByText('较旧快照 (2026)')).toHaveLength(0))
|
||||
expect(screen.getAllByText('较新快照 (2026)')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('commits an older response while the next slow poll is still pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = createDeferred<TransferQueue[]>()
|
||||
const second = createDeferred<TransferQueue[]>()
|
||||
mocks.apiGet.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||
|
||||
await renderDialog()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
|
||||
first.resolve(createQueue('首个可用快照', '/downloads/first-available.mkv'))
|
||||
await flushPromises()
|
||||
expect(screen.getAllByText('首个可用快照 (2026)')).toHaveLength(2)
|
||||
|
||||
second.resolve(createQueue('后续快照', '/downloads/follow-up.mkv'))
|
||||
await flushPromises()
|
||||
expect(screen.queryAllByText('首个可用快照 (2026)')).toHaveLength(0)
|
||||
expect(screen.getAllByText('后续快照 (2026)')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores a pending queue response after the dialog unmounts', async () => {
|
||||
const pendingQueue = createDeferred<TransferQueue[]>()
|
||||
mocks.apiGet.mockReturnValue(pendingQueue.promise)
|
||||
|
||||
const { unmount } = await renderDialog()
|
||||
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||
unmount()
|
||||
|
||||
pendingQueue.resolve(createQueue('卸载后快照', '/downloads/after-unmount.mkv'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.useProgressSSE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes after DELETE regardless of a response body that resembles success false', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queue = createQueue('待取消', '/downloads/cancel.mkv')
|
||||
mocks.apiGet.mockResolvedValueOnce(queue).mockResolvedValueOnce([])
|
||||
mocks.apiDelete.mockResolvedValue({ success: false })
|
||||
|
||||
await renderDialog()
|
||||
await user.click(await screen.findByRole('button', { name: '取消任务' }))
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(mocks.apiDelete).toHaveBeenCalledWith('transfer/queue', {
|
||||
data: queue[0].tasks[0].fileitem,
|
||||
})
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText('没有正在整理的任务')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not refresh or recreate progress streams when DELETE resolves after unmount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const pendingDelete = createDeferred<unknown>()
|
||||
mocks.apiGet.mockResolvedValue(createQueue('卸载中取消', '/downloads/unmounted-delete.mkv'))
|
||||
mocks.apiDelete.mockReturnValue(pendingDelete.promise)
|
||||
|
||||
const { unmount } = await renderDialog()
|
||||
await user.click(await screen.findByRole('button', { name: '取消任务' }))
|
||||
expect(mocks.apiDelete).toHaveBeenCalledOnce()
|
||||
expect(mocks.useProgressSSE).toHaveBeenCalledOnce()
|
||||
|
||||
unmount()
|
||||
pendingDelete.resolve(undefined)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||
expect(mocks.useProgressSSE).toHaveBeenCalledOnce()
|
||||
expect(mocks.progressControllers[0].stop).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows the existing error toast when DELETE fails and keeps the current queue visible', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.apiGet.mockResolvedValue(createQueue('取消失败', '/downloads/cancel-failed.mkv'))
|
||||
mocks.apiDelete.mockRejectedValue(new Error('network failure'))
|
||||
|
||||
await renderDialog()
|
||||
await user.click(await screen.findByRole('button', { name: '取消任务' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('服务器连接失败'))
|
||||
expect(screen.getByText('取消失败.mkv')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('separates GET failure from an empty queue and retries in place', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce([])
|
||||
|
||||
await renderDialog()
|
||||
|
||||
expect(await screen.findByText('服务器连接失败')).toBeInTheDocument()
|
||||
expect(screen.queryByText('没有正在整理的任务')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重试' }))
|
||||
|
||||
expect(await screen.findByText('没有正在整理的任务')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -324,6 +324,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/components/dialog/AddDownloadDialog.vue',
|
||||
'src/components/dialog/AddSubtitleDownloadDialog.vue',
|
||||
'src/components/dialog/ReorganizeDialog.vue',
|
||||
'src/components/dialog/TransferQueueDialog.vue',
|
||||
'src/views/discover/TheMovieDbView.vue',
|
||||
'src/views/discover/DoubanView.vue',
|
||||
'src/views/discover/BangumiView.vue',
|
||||
@@ -446,6 +447,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/dialog/TransferQueueDialog.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/composables/useMediaSubscribe.ts': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
|
||||
Reference in New Issue
Block a user