mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 09:05:28 +08:00
test(subscribe): cover season, files and history dialogs (#544)
This commit is contained in:
@@ -64,15 +64,19 @@ const subScribeInfo = ref<SubscrbieInfo>()
|
|||||||
// 是否加载中
|
// 是否加载中
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const loadError = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用 API 查询订阅文件信息。
|
* 调用 API 查询订阅文件信息。
|
||||||
*/
|
*/
|
||||||
async function loadSubscribeFilesInfo() {
|
async function loadSubscribeFilesInfo() {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadError.value = false
|
||||||
subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)
|
subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
|
loadError.value = true
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -294,7 +298,11 @@ const episodeGroups = computed<SubscribeEpisodeGroup[]>(() => {
|
|||||||
const totalCount = computed(() => {
|
const totalCount = computed(() => {
|
||||||
const subscribeTotal = subscribe.value?.total_episode ?? 0
|
const subscribeTotal = subscribe.value?.total_episode ?? 0
|
||||||
if (subscribe.value?.type === '电影') return Math.max(episodeGroups.value.length, 1)
|
if (subscribe.value?.type === '电影') return Math.max(episodeGroups.value.length, 1)
|
||||||
return Math.max(subscribeTotal, episodeGroups.value.length)
|
|
||||||
|
// 电视剧以起始集到总集数作为目标范围,自定义起始集需要换算为实际集数。
|
||||||
|
const startEpisode = subscribe.value?.start_episode || 1
|
||||||
|
const targetCount = subscribeTotal >= startEpisode ? subscribeTotal - startEpisode + 1 : 0
|
||||||
|
return Math.max(targetCount, episodeGroups.value.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 已下载集数
|
// 已下载集数
|
||||||
@@ -402,7 +410,15 @@ onBeforeMount(() => {
|
|||||||
<LoadingBanner v-if="loading" />
|
<LoadingBanner v-if="loading" />
|
||||||
|
|
||||||
<VCardText v-else class="subscribe-files-dialog__body">
|
<VCardText v-else class="subscribe-files-dialog__body">
|
||||||
<div v-if="subScribeInfo?.subscribe" class="subscribe-files-shell">
|
<div v-if="loadError" class="subscribe-files-empty subscribe-files-empty--standalone">
|
||||||
|
<VIcon icon="mdi-folder-alert-outline" size="40" />
|
||||||
|
<div>{{ t('error.serverError') }}</div>
|
||||||
|
<VBtn color="primary" prepend-icon="mdi-refresh" @click="loadSubscribeFilesInfo">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="subScribeInfo?.subscribe" class="subscribe-files-shell">
|
||||||
<section class="subscribe-files-hero" :style="heroStyle">
|
<section class="subscribe-files-hero" :style="heroStyle">
|
||||||
<div class="subscribe-files-hero__shade" />
|
<div class="subscribe-files-hero__shade" />
|
||||||
<div class="subscribe-files-hero__content">
|
<div class="subscribe-files-hero__content">
|
||||||
@@ -424,7 +440,7 @@ onBeforeMount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<h2 class="subscribe-files-hero__title">{{ subscribe?.name }}</h2>
|
<h2 class="subscribe-files-hero__title">{{ subscribe?.name }}</h2>
|
||||||
<div class="subscribe-files-hero__chips">
|
<div class="subscribe-files-hero__chips">
|
||||||
<VChip v-if="subscribe?.season" color="primary" variant="flat" size="small">
|
<VChip v-if="subscribe?.season != null" color="primary" variant="flat" size="small">
|
||||||
{{ t('dialog.subscribeFiles.season', { number: subscribe.season }) }}
|
{{ t('dialog.subscribeFiles.season', { number: subscribe.season }) }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip v-if="subscribe?.year" variant="tonal" size="small">{{ subscribe.year }}</VChip>
|
<VChip v-if="subscribe?.year" variant="tonal" size="small">{{ subscribe.year }}</VChip>
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { useDisplay } from 'vuetify'
|
|||||||
import ProgressDialog from './ProgressDialog.vue'
|
import ProgressDialog from './ProgressDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mediaTypeDict } from '@/api/constants'
|
import { mediaTypeDict } from '@/api/constants'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const $toast = useToast()
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
|
||||||
@@ -97,9 +100,12 @@ async function reSubscribe(item: Subscribe) {
|
|||||||
const result: { [key: string]: any } = await api.post('subscribe/', item)
|
const result: { [key: string]: any } = await api.post('subscribe/', item)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
emit('save')
|
emit('save')
|
||||||
|
} else {
|
||||||
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
}
|
}
|
||||||
progressDialog.value = false
|
progressDialog.value = false
|
||||||
}
|
}
|
||||||
@@ -113,6 +119,7 @@ async function deleteHistory(item: Subscribe) {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +165,14 @@ function getMediaTypeText(type: string | undefined) {
|
|||||||
<template #loading>
|
<template #loading>
|
||||||
<LoadingBanner />
|
<LoadingBanner />
|
||||||
</template>
|
</template>
|
||||||
|
<template #error="{ props: retryProps }">
|
||||||
|
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
|
||||||
|
<span class="text-medium-emphasis">{{ t('subscribe.requestFailed') }}</span>
|
||||||
|
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #empty />
|
<template #empty />
|
||||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
||||||
<template #default="{ item, itemRef }">
|
<template #default="{ item, itemRef }">
|
||||||
|
|||||||
@@ -185,8 +185,9 @@ async function getEpisodeGroups() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询TMDB的所有季信息
|
// 查询媒体的季信息
|
||||||
async function getMediaSeasons() {
|
async function getMediaSeasons() {
|
||||||
|
isRefreshed.value = false
|
||||||
try {
|
try {
|
||||||
seasonInfos.value = await api.get('media/seasons', {
|
seasonInfos.value = await api.get('media/seasons', {
|
||||||
params: {
|
params: {
|
||||||
@@ -196,9 +197,10 @@ async function getMediaSeasons() {
|
|||||||
season: props.media?.season,
|
season: props.media?.season,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
isRefreshed.value = true
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshed.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +416,7 @@ function syncSelectedSeason() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 季信息与缺失状态共享剧集组上下文,剧集组变化时统一刷新两者。
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (episodeGroup.value) getGroupSeasons()
|
if (episodeGroup.value) getGroupSeasons()
|
||||||
else getMediaSeasons()
|
else getMediaSeasons()
|
||||||
@@ -434,10 +437,7 @@ watch(episodeGroupOptions, () => nextTick(updateEpisodeGroupScrollState), { flus
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('resize', updateEpisodeGroupScrollState)
|
window.addEventListener('resize', updateEpisodeGroupScrollState)
|
||||||
// 自定义剧集组由 watchEffect 首次加载,避免默认季数据异步覆盖它。
|
|
||||||
if (!episodeGroup.value) getMediaSeasons()
|
|
||||||
getEpisodeGroups()
|
getEpisodeGroups()
|
||||||
checkSeasonsNotExists()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
512
src/components/dialog/__tests__/SubscribeFilesDialog.spec.ts
Normal file
512
src/components/dialog/__tests__/SubscribeFilesDialog.spec.ts
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import type { Subscribe, SubscrbieInfo } from '@/api/types'
|
||||||
|
import SubscribeFilesDialog from '@/components/dialog/SubscribeFilesDialog.vue'
|
||||||
|
import { screen, waitFor, within } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { subscribeApiUrls, subscribeFilesHandler } from '@tests/support/msw/handlers/subscribe'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/@core/utils/navigator', () => ({
|
||||||
|
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
|
||||||
|
return {
|
||||||
|
best_version: 0,
|
||||||
|
current_priority: 0,
|
||||||
|
date: '2026-07-17 10:00:00',
|
||||||
|
id: 3101,
|
||||||
|
last_update: '2026-07-17 10:00:00',
|
||||||
|
name: '文件测试剧',
|
||||||
|
page_open: false,
|
||||||
|
show_edit_dialog: false,
|
||||||
|
sites: [],
|
||||||
|
state: 'R',
|
||||||
|
tmdbid: 31010,
|
||||||
|
type: '电视剧',
|
||||||
|
username: 'tester',
|
||||||
|
year: '2026',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFilesInfo(overrides: Partial<SubscrbieInfo> = {}): SubscrbieInfo {
|
||||||
|
return {
|
||||||
|
episodes: {
|
||||||
|
1: {
|
||||||
|
download: [],
|
||||||
|
library: [],
|
||||||
|
title: '第一集',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscribe: createSubscribe({ season: 1, total_episode: 1 }),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTvFilesInfo(): SubscrbieInfo {
|
||||||
|
return createFilesInfo({
|
||||||
|
episodes: {
|
||||||
|
10: {
|
||||||
|
description: '第十集简介',
|
||||||
|
download: [],
|
||||||
|
library: [],
|
||||||
|
title: '第十集',
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
description: '第二集简介',
|
||||||
|
download: [
|
||||||
|
{
|
||||||
|
downloader: 'Transmission',
|
||||||
|
file_path: '/downloads/show.S01E02.1080p.mkv',
|
||||||
|
hash: 'hash-episode-2',
|
||||||
|
site_name: '站点二',
|
||||||
|
torrent_title: 'Show.S01E02.1080p.WEB-DL',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
library: [
|
||||||
|
{
|
||||||
|
file_path: '/media/show.S01E02.1080p.mkv',
|
||||||
|
storage: 'local-disk',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: '第二集',
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
backdrop: 'https://image.example.com/t/p/w500/episode-1.jpg',
|
||||||
|
description: '第一集简介',
|
||||||
|
download: [
|
||||||
|
{
|
||||||
|
downloader: 'qBittorrent',
|
||||||
|
file_path: '/downloads/show.S01E01.2160p.mkv',
|
||||||
|
hash: 'hash-episode-1',
|
||||||
|
site_name: '站点一',
|
||||||
|
torrent_title: 'Show.S01E01.2160p.WEB-DL',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
library: [],
|
||||||
|
title: '第一集',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscribe: createSubscribe({
|
||||||
|
backdrop: 'https://image.example.com/t/p/w780/show.jpg',
|
||||||
|
description: '整剧简介',
|
||||||
|
poster: 'https://image.example.com/poster.jpg',
|
||||||
|
season: 1,
|
||||||
|
total_episode: 4,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>(done => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewport(width: number) {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFilesResponse(
|
||||||
|
id: number,
|
||||||
|
response: JsonBodyType,
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
server.use(subscribeFilesHandler(id, response, status, onRequest))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog(subid: number) {
|
||||||
|
const close = vi.fn()
|
||||||
|
const result = await renderWithProviders(SubscribeFilesDialog, {
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
modelValue: true,
|
||||||
|
subid,
|
||||||
|
onClose: close,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...result, close }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SubscribeFilesDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
setViewport(1280)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts TV episodes numerically, selects the first one, and applies status priority and statistics', async () => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
useFilesResponse(3110, createTvFilesInfo() as unknown as JsonBodyType, 200, requested)
|
||||||
|
|
||||||
|
await renderDialog(3110)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
expect(requested.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/files/3110')
|
||||||
|
|
||||||
|
const rail = document.querySelector('.subscribe-files-episode-rail')
|
||||||
|
expect(rail).not.toBeNull()
|
||||||
|
const episodeButtons = within(rail as HTMLElement).getAllByRole('button')
|
||||||
|
expect(episodeButtons.map(button => button.textContent)).toEqual([
|
||||||
|
expect.stringMatching(/E01.*第一集.*已下载/s),
|
||||||
|
expect.stringMatching(/E02.*第二集.*已入库/s),
|
||||||
|
expect.stringMatching(/E10.*第十集.*待入库/s),
|
||||||
|
])
|
||||||
|
expect(episodeButtons[0]).toHaveClass('subscribe-files-episode-item--active')
|
||||||
|
|
||||||
|
const detailTitle = document.querySelector('.subscribe-files-detail__title')
|
||||||
|
expect(detailTitle).not.toBeNull()
|
||||||
|
expect(detailTitle).toHaveTextContent('E01')
|
||||||
|
expect(detailTitle).toHaveTextContent('第一集')
|
||||||
|
|
||||||
|
const statCards = document.querySelectorAll('.subscribe-files-stat-card')
|
||||||
|
expect(statCards).toHaveLength(2)
|
||||||
|
expect(statCards[0]).toHaveTextContent('下载')
|
||||||
|
expect(statCards[0]).toHaveTextContent('2/4')
|
||||||
|
expect(statCards[1]).toHaveTextContent('入库')
|
||||||
|
expect(statCards[1]).toHaveTextContent('1/4')
|
||||||
|
expect(screen.getByText('缺失 3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts a non-TMDB custom episode range instead of treating its ending episode as the total', async () => {
|
||||||
|
const episodes = Object.fromEntries(
|
||||||
|
[44, 45, 46, 47, 48].map(episode => [
|
||||||
|
episode,
|
||||||
|
{
|
||||||
|
download: [],
|
||||||
|
library: [],
|
||||||
|
title: `第 ${episode} 集`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const info = createFilesInfo({
|
||||||
|
episodes,
|
||||||
|
subscribe: createSubscribe({
|
||||||
|
doubanid: 'douban-range-44-48',
|
||||||
|
start_episode: 44,
|
||||||
|
tmdbid: undefined,
|
||||||
|
total_episode: 48,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
useFilesResponse(3120, info as unknown as JsonBodyType)
|
||||||
|
|
||||||
|
await renderDialog(3120)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('0/5')).toHaveLength(2)
|
||||||
|
expect(screen.getByText('缺失 5')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the returned TMDB episode keys when they exceed the configured target range', async () => {
|
||||||
|
const episodes = Object.fromEntries(
|
||||||
|
[1, 2, 3, 4].map(episode => [
|
||||||
|
episode,
|
||||||
|
{
|
||||||
|
download: [],
|
||||||
|
library: [],
|
||||||
|
title: `第 ${episode} 集`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const info = createFilesInfo({
|
||||||
|
episodes,
|
||||||
|
subscribe: createSubscribe({ start_episode: 3, tmdbid: 31210, total_episode: 4 }),
|
||||||
|
})
|
||||||
|
useFilesResponse(3121, info as unknown as JsonBodyType)
|
||||||
|
|
||||||
|
await renderDialog(3121)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('0/4')).toHaveLength(2)
|
||||||
|
expect(screen.getByText('缺失 4')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders download details, switches desktop episodes, and shows both tab empty states', async () => {
|
||||||
|
useFilesResponse(3111, createTvFilesInfo() as unknown as JsonBodyType)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog(3111)
|
||||||
|
|
||||||
|
const firstTorrent = await screen.findByRole('heading', { name: 'Show.S01E01.2160p.WEB-DL' })
|
||||||
|
const firstFileCard = firstTorrent.closest('.subscribe-files-file-card')
|
||||||
|
expect(firstFileCard).not.toBeNull()
|
||||||
|
expect(within(firstFileCard as HTMLElement).getByText('2160P')).toBeInTheDocument()
|
||||||
|
expect(within(firstFileCard as HTMLElement).getByText('站点一')).toBeInTheDocument()
|
||||||
|
expect(within(firstFileCard as HTMLElement).getByText('下载器:qBittorrent')).toBeInTheDocument()
|
||||||
|
expect(within(firstFileCard as HTMLElement).getByText('Hash:hash-episode-1')).toBeInTheDocument()
|
||||||
|
expect(within(firstFileCard as HTMLElement).getByText('/downloads/show.S01E01.2160p.mkv')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const rail = document.querySelector('.subscribe-files-episode-rail') as HTMLElement
|
||||||
|
const episodeButtons = within(rail).getAllByRole('button')
|
||||||
|
await user.click(episodeButtons[1])
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Show.S01E02.1080p.WEB-DL' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Show.S01E01.2160p.WEB-DL' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(episodeButtons[2])
|
||||||
|
expect(await screen.findByText('暂无下载文件')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: '媒体库文件' }))
|
||||||
|
expect(await screen.findByText('暂无媒体库文件')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders local library paths and safe HTTP(S) media-server links', async () => {
|
||||||
|
const httpUrl = 'http://media.example.com/items/emby-1'
|
||||||
|
const httpsUrl = 'https://media.example.com/items/jellyfin-2'
|
||||||
|
const info = createFilesInfo({
|
||||||
|
episodes: {
|
||||||
|
1: {
|
||||||
|
download: [],
|
||||||
|
library: [
|
||||||
|
{ file_path: '/media/show.S01E01.1080p.mkv', storage: '本地存储' },
|
||||||
|
{ file_path: httpUrl, itemid: 'emby-1', server: '家庭 Emby', server_type: 'emby' },
|
||||||
|
{ file_path: httpsUrl, itemid: 'jellyfin-2', server: '家庭 Jellyfin', server_type: 'jellyfin' },
|
||||||
|
{},
|
||||||
|
],
|
||||||
|
title: '媒体库详情集',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscribe: createSubscribe({ season: 1, total_episode: 1 }),
|
||||||
|
})
|
||||||
|
useFilesResponse(3112, info as unknown as JsonBodyType)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog(3112)
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: '媒体库文件' }))
|
||||||
|
|
||||||
|
const localPath = await screen.findByText('/media/show.S01E01.1080p.mkv')
|
||||||
|
expect(localPath.closest('a')).toBeNull()
|
||||||
|
expect(screen.getByText('1080P')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('本地存储')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('local')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('家庭 Emby')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('家庭 Jellyfin')).toBeInTheDocument()
|
||||||
|
|
||||||
|
for (const url of [httpUrl, httpsUrl]) {
|
||||||
|
const link = screen.getByRole('link', { name: url })
|
||||||
|
expect(link).toHaveAttribute('href', url)
|
||||||
|
expect(link).toHaveAttribute('target', '_blank')
|
||||||
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.getByText('暂无路径')).toBeInTheDocument()
|
||||||
|
const copyButtons = screen.getAllByRole('button', { name: '复制路径' })
|
||||||
|
expect(copyButtons.at(-1)).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports clipboard success, false results, and exceptions while disabling empty paths', async () => {
|
||||||
|
const info = createFilesInfo({
|
||||||
|
episodes: {
|
||||||
|
1: {
|
||||||
|
download: [
|
||||||
|
{ file_path: '/downloads/one.mkv', torrent_title: '文件一' },
|
||||||
|
{ file_path: '/downloads/two.mkv', torrent_title: '文件二' },
|
||||||
|
{ file_path: '/downloads/three.mkv', torrent_title: '文件三' },
|
||||||
|
{ torrent_title: '无路径文件' },
|
||||||
|
],
|
||||||
|
library: [],
|
||||||
|
title: '复制测试集',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscribe: createSubscribe({ total_episode: 1 }),
|
||||||
|
})
|
||||||
|
useFilesResponse(3113, info as unknown as JsonBodyType)
|
||||||
|
mocks.copyToClipboard
|
||||||
|
.mockResolvedValueOnce(true)
|
||||||
|
.mockResolvedValueOnce(false)
|
||||||
|
.mockRejectedValueOnce(new Error('clipboard denied'))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog(3113)
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件一' })).toBeInTheDocument()
|
||||||
|
const copyButtons = screen.getAllByRole('button', { name: '复制路径' })
|
||||||
|
expect(copyButtons).toHaveLength(4)
|
||||||
|
expect(copyButtons[3]).toBeDisabled()
|
||||||
|
|
||||||
|
await user.click(copyButtons[0])
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('路径已复制'))
|
||||||
|
await user.click(copyButtons[1])
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
|
||||||
|
await user.click(copyButtons[2])
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(2))
|
||||||
|
await user.click(copyButtons[3])
|
||||||
|
|
||||||
|
expect(mocks.copyToClipboard.mock.calls).toEqual([
|
||||||
|
['/downloads/one.mkv'],
|
||||||
|
['/downloads/two.mkv'],
|
||||||
|
['/downloads/three.mkv'],
|
||||||
|
])
|
||||||
|
expect(mocks.toastError).toHaveBeenNthCalledWith(1, '复制失败')
|
||||||
|
expect(mocks.toastError).toHaveBeenNthCalledWith(2, '复制失败')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders every episode and its active-tab files on mobile', async () => {
|
||||||
|
setViewport(480)
|
||||||
|
const info = createFilesInfo({
|
||||||
|
episodes: {
|
||||||
|
2: {
|
||||||
|
download: [{ file_path: '/downloads/mobile-2.mkv', torrent_title: '移动第二集' }],
|
||||||
|
library: [],
|
||||||
|
title: '移动第二集',
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
download: [{ file_path: '/downloads/mobile-1.mkv', torrent_title: '移动第一集' }],
|
||||||
|
library: [{ file_path: '/media/mobile-1.mkv', storage: 'mobile-storage' }],
|
||||||
|
title: '移动第一集',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscribe: createSubscribe({ total_episode: 2 }),
|
||||||
|
})
|
||||||
|
useFilesResponse(3114, info as unknown as JsonBodyType)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog(3114)
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(document.querySelector('.subscribe-files-mobile-list')).not.toBeNull())
|
||||||
|
expect(document.querySelector('.subscribe-files-episode-rail')).toBeNull()
|
||||||
|
const mobileCards = document.querySelectorAll('.subscribe-files-mobile-card')
|
||||||
|
expect(mobileCards).toHaveLength(2)
|
||||||
|
expect(mobileCards[0]).toHaveTextContent('E01')
|
||||||
|
expect(mobileCards[0]).toHaveTextContent('/downloads/mobile-1.mkv')
|
||||||
|
expect(mobileCards[1]).toHaveTextContent('E02')
|
||||||
|
expect(mobileCards[1]).toHaveTextContent('/downloads/mobile-2.mkv')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '媒体库文件' }))
|
||||||
|
expect(await screen.findByText('/media/mobile-1.mkv')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('暂无媒体库文件')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the movie model for episode zero and keeps its statistics meaningful', async () => {
|
||||||
|
const info = createFilesInfo({
|
||||||
|
episodes: {
|
||||||
|
0: {
|
||||||
|
download: [{ file_path: '/downloads/movie.4k.mkv', torrent_title: 'Movie.4K' }],
|
||||||
|
library: [],
|
||||||
|
title: '电影正片',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscribe: createSubscribe({ season: undefined, total_episode: 0, type: '电影' }),
|
||||||
|
})
|
||||||
|
useFilesResponse(3115, info as unknown as JsonBodyType)
|
||||||
|
|
||||||
|
await renderDialog(3115)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
const rail = document.querySelector('.subscribe-files-episode-rail') as HTMLElement
|
||||||
|
expect(within(rail).getByRole('button')).toHaveTextContent('电影')
|
||||||
|
expect(within(rail).getByRole('button')).toHaveTextContent('电影正片')
|
||||||
|
expect(document.querySelector('.subscribe-files-detail__title')).toHaveTextContent('电影')
|
||||||
|
expect(screen.getAllByText('1/1')).toHaveLength(1)
|
||||||
|
expect(screen.getByText('4K')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows season zero as a valid season label', async () => {
|
||||||
|
const info = createFilesInfo({
|
||||||
|
subscribe: createSubscribe({ season: 0, total_episode: 1 }),
|
||||||
|
})
|
||||||
|
useFilesResponse(3101, info as unknown as JsonBodyType)
|
||||||
|
|
||||||
|
await renderDialog(3101)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('第 0 季')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes an HTTP failure from an empty response and offers retry', async () => {
|
||||||
|
const info = createFilesInfo({ subscribe: createSubscribe({ name: '重试恢复剧', total_episode: 1 }) })
|
||||||
|
let requestCount = 0
|
||||||
|
server.use(http.get(subscribeApiUrls.filesById(3102), () => {
|
||||||
|
requestCount += 1
|
||||||
|
if (requestCount === 1) return HttpResponse.json({}, { status: 500 })
|
||||||
|
return HttpResponse.json(info as unknown as JsonBodyType)
|
||||||
|
}))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog(3102)
|
||||||
|
|
||||||
|
expect(await screen.findByText('服务器错误,请稍后重试。')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('没有数据')).not.toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: '重试' }))
|
||||||
|
expect(await screen.findByRole('heading', { name: '重试恢复剧' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('服务器错误,请稍后重试。')).not.toBeInTheDocument()
|
||||||
|
expect(requestCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading until the request resolves', async () => {
|
||||||
|
const deferred = createDeferred<JsonBodyType>()
|
||||||
|
server.use(http.get(subscribeApiUrls.filesById(3116), async () => {
|
||||||
|
return HttpResponse.json(await deferred.promise)
|
||||||
|
}))
|
||||||
|
|
||||||
|
await renderDialog(3116)
|
||||||
|
|
||||||
|
expect(document.querySelector('.initial-loading-container')).not.toBeNull()
|
||||||
|
deferred.resolve(createFilesInfo() as unknown as JsonBodyType)
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
expect(document.querySelector('.initial-loading-container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a successful empty response as the no-data state without retry', async () => {
|
||||||
|
useFilesResponse(3117, { episodes: {}, subscribe: null })
|
||||||
|
|
||||||
|
await renderDialog(3117)
|
||||||
|
|
||||||
|
expect(await screen.findByText('没有数据')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['R', '订阅中'],
|
||||||
|
['P', '待处理'],
|
||||||
|
['S', '已暂停'],
|
||||||
|
['N', '新订阅'],
|
||||||
|
['unexpected', '未知'],
|
||||||
|
])('renders subscription state %s as %s', async (state, label) => {
|
||||||
|
const info = createFilesInfo({
|
||||||
|
episodes: {},
|
||||||
|
subscribe: createSubscribe({ state, total_episode: 0 }),
|
||||||
|
})
|
||||||
|
useFilesResponse(3118, info as unknown as JsonBodyType)
|
||||||
|
|
||||||
|
await renderDialog(3118)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
const chips = document.querySelector('.subscribe-files-hero__chips') as HTMLElement
|
||||||
|
expect(within(chips).getByText(label)).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('0/0')).toHaveLength(2)
|
||||||
|
expect(screen.getByText('缺失 0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close from the dialog close button', async () => {
|
||||||
|
useFilesResponse(3119, createFilesInfo() as unknown as JsonBodyType)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { close } = await renderDialog(3119)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: '关闭' }))
|
||||||
|
|
||||||
|
expect(close).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
429
src/components/dialog/__tests__/SubscribeHistoryDialog.spec.ts
Normal file
429
src/components/dialog/__tests__/SubscribeHistoryDialog.spec.ts
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||||
|
import { mediaTypeDict } from '@/api/constants'
|
||||||
|
import type { Subscribe } from '@/api/types'
|
||||||
|
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
||||||
|
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {
|
||||||
|
createSubscribeHandler,
|
||||||
|
deleteSubscribeHistoryHandler,
|
||||||
|
subscribeApiUrls,
|
||||||
|
subscribeHistoryHandler,
|
||||||
|
} from '@tests/support/msw/handlers/subscribe'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||||
|
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
toastError: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||||
|
|
||||||
|
const InfiniteScrollStub = defineComponent({
|
||||||
|
name: 'VInfiniteScroll',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<unknown[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['load'],
|
||||||
|
setup(_props, { emit, slots }) {
|
||||||
|
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
status.value = 'loading'
|
||||||
|
emit('load', {
|
||||||
|
done(nextStatus: InfiniteScrollStatus) {
|
||||||
|
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('div', { 'data-testid': 'history-infinite-scroll' }, [
|
||||||
|
status.value === 'loading' ? slots.loading?.({}) : null,
|
||||||
|
status.value === 'error'
|
||||||
|
? slots.error?.({
|
||||||
|
side: 'end',
|
||||||
|
props: { color: undefined, onClick: load },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
status.value === 'empty' ? slots.empty?.({}) : null,
|
||||||
|
slots.default?.(),
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
'aria-label': '加载更多历史',
|
||||||
|
type: 'button',
|
||||||
|
onClick: load,
|
||||||
|
},
|
||||||
|
'加载更多历史',
|
||||||
|
),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const VirtualScrollStub = defineComponent({
|
||||||
|
name: 'VVirtualScroll',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<Subscribe[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
const itemRef = () => {}
|
||||||
|
return () => h('div', props.items.map(item => slots.default?.({ item, itemRef })))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const MenuStub = defineComponent({
|
||||||
|
name: 'VMenu',
|
||||||
|
setup(_props, { slots }) {
|
||||||
|
return () => h('div', slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProgressDialogStub = defineComponent({
|
||||||
|
name: 'ProgressDialog',
|
||||||
|
props: {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => h('div', { role: 'status' }, props.text)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve: (value: T) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>(done => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
let historySeed = 4000
|
||||||
|
|
||||||
|
function createHistory(overrides: Partial<Subscribe> = {}): Subscribe {
|
||||||
|
historySeed += 1
|
||||||
|
return {
|
||||||
|
best_version: 0,
|
||||||
|
current_priority: 0,
|
||||||
|
date: '2026-07-17 12:00:00',
|
||||||
|
description: `历史说明 ${historySeed}`,
|
||||||
|
id: historySeed,
|
||||||
|
last_update: '2026-07-17 12:00:00',
|
||||||
|
name: `历史媒体 ${historySeed}`,
|
||||||
|
poster: `/images/history-${historySeed}.jpg`,
|
||||||
|
show_edit_dialog: false,
|
||||||
|
sites: [],
|
||||||
|
state: 'R',
|
||||||
|
tmdbid: historySeed,
|
||||||
|
type: '电影',
|
||||||
|
username: 'tester',
|
||||||
|
year: '2026',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog(type: '电影' | '电视剧' = '电影') {
|
||||||
|
const events = {
|
||||||
|
close: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
}
|
||||||
|
const result = await renderWithProviders(SubscribeHistoryDialog, {
|
||||||
|
props: {
|
||||||
|
modelValue: true,
|
||||||
|
type,
|
||||||
|
onClose: events.close,
|
||||||
|
onSave: events.save,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
components: {
|
||||||
|
VDialogCloseBtn: DialogCloseBtn,
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
ProgressDialog: ProgressDialogStub,
|
||||||
|
VInfiniteScroll: InfiniteScrollStub,
|
||||||
|
VMenu: MenuStub,
|
||||||
|
VVirtualScroll: VirtualScrollStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ...result, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyRow(item: Subscribe) {
|
||||||
|
const description = screen.getByText(item.description!)
|
||||||
|
const row = description.closest('.v-list-item')
|
||||||
|
if (!row) throw new Error(`History row ${item.id} was not rendered`)
|
||||||
|
return row as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SubscribeHistoryDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads movie history with the exact type, page and count request', async () => {
|
||||||
|
const movie = createHistory({ name: '首载电影' })
|
||||||
|
const requests: URL[] = []
|
||||||
|
server.use(
|
||||||
|
subscribeHistoryHandler('电影', [movie], 200, url => {
|
||||||
|
requests.push(url)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDialog('电影')
|
||||||
|
|
||||||
|
expect(await screen.findByText('首载电影')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(`${mediaTypeDict['电影']}订阅历史`)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/第 \d+ 季/)).not.toBeInTheDocument()
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(decodeURIComponent(requests[0].pathname).endsWith('/subscribe/history/电影')).toBe(true)
|
||||||
|
expect(requests[0].searchParams.get('page')).toBe('1')
|
||||||
|
expect(requests[0].searchParams.get('count')).toBe('30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads TV history and renders the season copy', async () => {
|
||||||
|
const show = createHistory({ name: '首载剧集', season: 3, type: '电视剧' })
|
||||||
|
const requests: URL[] = []
|
||||||
|
server.use(
|
||||||
|
subscribeHistoryHandler('电视剧', [show], 200, url => {
|
||||||
|
requests.push(url)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDialog('电视剧')
|
||||||
|
|
||||||
|
expect(await screen.findByText('首载剧集')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('第 3 季')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(`${mediaTypeDict['电视剧']}订阅历史`)).toBeInTheDocument()
|
||||||
|
expect(decodeURIComponent(requests[0].pathname).endsWith('/subscribe/history/电视剧')).toBe(true)
|
||||||
|
expect(requests[0].searchParams.get('page')).toBe('1')
|
||||||
|
expect(requests[0].searchParams.get('count')).toBe('30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends later pages and keeps existing rows when the next page is empty', async () => {
|
||||||
|
const first = createHistory({ name: '第一页电影' })
|
||||||
|
const second = createHistory({ name: '第二页电影' })
|
||||||
|
const requestedPages: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(subscribeApiUrls.historyByType('电影'), ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const page = url.searchParams.get('page') ?? ''
|
||||||
|
requestedPages.push(page)
|
||||||
|
if (page === '1') return HttpResponse.json([first])
|
||||||
|
if (page === '2') return HttpResponse.json([second])
|
||||||
|
return HttpResponse.json([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('第一页电影')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: '加载更多历史' }))
|
||||||
|
expect(await screen.findByText('第二页电影')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: '加载更多历史' }))
|
||||||
|
await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3']))
|
||||||
|
|
||||||
|
expect(screen.getByText('第一页电影')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('第二页电影')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('没有已完成的订阅')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the empty state after an empty first page', async () => {
|
||||||
|
server.use(subscribeHistoryHandler('电影'))
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('没有已完成的订阅')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('完成的订阅会显示在这里')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a visible list error and retries the same page', async () => {
|
||||||
|
const recovered = createHistory({ name: '重试恢复电影' })
|
||||||
|
const requestedPages: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(subscribeApiUrls.historyByType('电影'), ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
requestedPages.push(url.searchParams.get('page') ?? '')
|
||||||
|
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
|
||||||
|
return HttpResponse.json([recovered])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('请求失败,请稍后重试')
|
||||||
|
await user.click(screen.getByRole('button', { name: '重试' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('重试恢复电影')).toBeInTheDocument()
|
||||||
|
expect(requestedPages).toEqual(['1', '1'])
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not issue another request when the load event reenters while pending', async () => {
|
||||||
|
const pending = createDeferred<Subscribe[]>()
|
||||||
|
const requested = vi.fn()
|
||||||
|
const movie = createHistory({ name: '重入保护电影' })
|
||||||
|
server.use(
|
||||||
|
http.get(subscribeApiUrls.historyByType('电影'), async () => {
|
||||||
|
requested()
|
||||||
|
return HttpResponse.json(await pending.promise)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '加载更多历史' }))
|
||||||
|
expect(requested).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
pending.resolve([movie])
|
||||||
|
expect(await screen.findByText('重入保护电影')).toBeInTheDocument()
|
||||||
|
expect(requested).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['电影', createHistory({ name: '重新订阅电影' }), '正在重新订阅 重新订阅电影...'],
|
||||||
|
[
|
||||||
|
'电视剧',
|
||||||
|
createHistory({ name: '重新订阅剧集', season: 2, type: '电视剧' }),
|
||||||
|
'正在重新订阅 重新订阅剧集 第 2 季...',
|
||||||
|
],
|
||||||
|
] as const)('shows the %s pending copy and emits save only after success', async (type, item, progressText) => {
|
||||||
|
const pending = createDeferred<{ success: boolean }>()
|
||||||
|
let payload: JsonBodyType | undefined
|
||||||
|
server.use(
|
||||||
|
subscribeHistoryHandler(type, [item]),
|
||||||
|
http.post(subscribeApiUrls.create, async ({ request }) => {
|
||||||
|
payload = (await request.json()) as JsonBodyType
|
||||||
|
return HttpResponse.json(await pending.promise)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog(type)
|
||||||
|
expect(await screen.findByText(item.name)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(within(historyRow(item)).getByText('重新订阅'))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(progressText)
|
||||||
|
expect(events.save).not.toHaveBeenCalled()
|
||||||
|
pending.resolve({ success: true })
|
||||||
|
await waitFor(() => expect(events.save).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
expect(payload).toEqual(item)
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
expect(events.close).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts a business failure when resubscribing and does not emit save', async () => {
|
||||||
|
const movie = createHistory({ name: '业务失败电影' })
|
||||||
|
server.use(
|
||||||
|
subscribeHistoryHandler('电影', [movie]),
|
||||||
|
createSubscribeHandler({ success: false }),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog()
|
||||||
|
expect(await screen.findByText(movie.name)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(within(historyRow(movie)).getByText('重新订阅'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
|
||||||
|
expect(events.save).not.toHaveBeenCalled()
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(movie.name)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts an HTTP failure when resubscribing and does not emit save', async () => {
|
||||||
|
const movie = createHistory({ name: '网络失败电影' })
|
||||||
|
server.use(
|
||||||
|
subscribeHistoryHandler('电影', [movie]),
|
||||||
|
http.post(subscribeApiUrls.create, () => HttpResponse.json({ detail: 'failed' }, { status: 500 })),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog()
|
||||||
|
expect(await screen.findByText(movie.name)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(within(historyRow(movie)).getByText('重新订阅'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
|
||||||
|
expect(events.save).not.toHaveBeenCalled()
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(movie.name)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes only the target row when the history endpoint returns success true', async () => {
|
||||||
|
const first = createHistory({ name: '待删除电影' })
|
||||||
|
const second = createHistory({ name: '保留电影' })
|
||||||
|
const deleteRequested = vi.fn()
|
||||||
|
server.use(
|
||||||
|
subscribeHistoryHandler('电影', [first, second]),
|
||||||
|
deleteSubscribeHistoryHandler(first.id, { success: true }, 200, deleteRequested),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog()
|
||||||
|
expect(await screen.findByText(first.name)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(within(historyRow(first)).getByText('删除'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText(first.name)).not.toBeInTheDocument())
|
||||||
|
expect(screen.getByText(second.name)).toBeInTheDocument()
|
||||||
|
expect(deleteRequested).toHaveBeenCalledOnce()
|
||||||
|
expect(events.save).not.toHaveBeenCalled()
|
||||||
|
expect(events.close).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the row and toasts when history deletion fails over HTTP', async () => {
|
||||||
|
const movie = createHistory({ name: '删除失败电影' })
|
||||||
|
server.use(
|
||||||
|
subscribeHistoryHandler('电影', [movie]),
|
||||||
|
http.delete(subscribeApiUrls.historyById(movie.id), () =>
|
||||||
|
HttpResponse.json({ detail: 'failed' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderDialog()
|
||||||
|
expect(await screen.findByText(movie.name)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(within(historyRow(movie)).getByText('删除'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
|
||||||
|
expect(screen.getByText(movie.name)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close from the dialog close button', async () => {
|
||||||
|
server.use(subscribeHistoryHandler('电影'))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog()
|
||||||
|
expect(await screen.findByText('没有已完成的订阅')).toBeInTheDocument()
|
||||||
|
const closeButton = document.querySelector('.absolute.right-3.top-3')
|
||||||
|
if (!(closeButton instanceof HTMLButtonElement)) throw new Error('Dialog close button was not rendered')
|
||||||
|
|
||||||
|
await user.click(closeButton)
|
||||||
|
|
||||||
|
expect(events.close).toHaveBeenCalledOnce()
|
||||||
|
expect(events.save).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
410
src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts
Normal file
410
src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import type { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||||
|
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||||
|
import SubscribeSeasonDialog from '@/components/dialog/SubscribeSeasonDialog.vue'
|
||||||
|
import type { SubscribeMode } from '@/composables/useMediaSubscribe'
|
||||||
|
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {
|
||||||
|
createMediaInfo,
|
||||||
|
createMediaSeason,
|
||||||
|
createNotExistMediaInfo,
|
||||||
|
} from '@tests/support/factories/media'
|
||||||
|
import {
|
||||||
|
mediaEpisodeGroupsHandler,
|
||||||
|
mediaGroupSeasonsHandler,
|
||||||
|
mediaNotExistsHandler,
|
||||||
|
mediaSeasonsHandler,
|
||||||
|
} from '@tests/support/msw/handlers/media'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
interface SeasonDialogProps {
|
||||||
|
defaultSubscribeMode?: SubscribeMode
|
||||||
|
initialEpisodeGroup?: string
|
||||||
|
media?: MediaInfo
|
||||||
|
modelValue?: boolean
|
||||||
|
selectedSeason?: number
|
||||||
|
subscribedSeasonModes?: Record<number, SubscribeMode>
|
||||||
|
subscribedSeasons?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred() {
|
||||||
|
let resolve!: () => void
|
||||||
|
const promise = new Promise<void>(done => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTvMedia(overrides: Partial<MediaInfo> = {}) {
|
||||||
|
return createMediaInfo({
|
||||||
|
poster_path: '/images/fallback-poster.jpg',
|
||||||
|
season: 1,
|
||||||
|
title: '季订阅测试剧',
|
||||||
|
tmdb_id: 7301,
|
||||||
|
type: '电视剧',
|
||||||
|
year: '2026',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog(props: SeasonDialogProps = {}) {
|
||||||
|
const events = {
|
||||||
|
close: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
}
|
||||||
|
const result = await renderWithProviders(SubscribeSeasonDialog, {
|
||||||
|
global: {
|
||||||
|
components: {
|
||||||
|
VDialogCloseBtn: DialogCloseBtn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: {
|
||||||
|
GLOBAL_IMAGE_CACHE: false,
|
||||||
|
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
media: createTvMedia(),
|
||||||
|
modelValue: true,
|
||||||
|
...props,
|
||||||
|
onClose: events.close,
|
||||||
|
onSubscribe: events.subscribe,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...result, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
function seasonRow(number: number) {
|
||||||
|
const title = screen.getByText(`第 ${number} 季`)
|
||||||
|
const row = title.closest('.v-list-item')
|
||||||
|
if (!row) throw new Error(`Season ${number} row was not rendered`)
|
||||||
|
return row as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleRequests() {
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SubscribeSeasonDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the default TMDB seasons once with exact requests and renders season states', async () => {
|
||||||
|
const media = createTvMedia({ season: 0, tmdb_id: 7302 })
|
||||||
|
const seasons = [
|
||||||
|
createMediaSeason({ air_date: '2020-01-02', episode_count: 3, poster_path: '', season_number: 0 }),
|
||||||
|
createMediaSeason({ air_date: '2021-02-03', episode_count: 4, season_number: 1 }),
|
||||||
|
createMediaSeason({ air_date: '2022-03-04', episode_count: 5, season_number: 2 }),
|
||||||
|
]
|
||||||
|
const states = [
|
||||||
|
createNotExistMediaInfo({ episodes: [1, 2, 3], season: 0, total_episode: 3 }),
|
||||||
|
createNotExistMediaInfo({ episodes: [1, 2], season: 1, total_episode: 4 }),
|
||||||
|
createNotExistMediaInfo({ episodes: [], season: 2, total_episode: 5 }),
|
||||||
|
]
|
||||||
|
const seasonRequests: URL[] = []
|
||||||
|
const missingPayloads: Record<string, unknown>[] = []
|
||||||
|
const groupRequests = vi.fn()
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler(seasons, 200, url => {
|
||||||
|
seasonRequests.push(url)
|
||||||
|
}),
|
||||||
|
mediaNotExistsHandler(states, 200, payload => {
|
||||||
|
missingPayloads.push(payload)
|
||||||
|
}),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, [], 200, groupRequests),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDialog({ media })
|
||||||
|
|
||||||
|
expect(await screen.findByText('第 0 季')).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(screen.getByText('部分缺失')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('缺失')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('首播于 2020年1月2日')).toBeInTheDocument()
|
||||||
|
expect(seasonRow(0)).toHaveTextContent('3 集')
|
||||||
|
await settleRequests()
|
||||||
|
|
||||||
|
expect(seasonRequests).toHaveLength(1)
|
||||||
|
expect(missingPayloads).toHaveLength(1)
|
||||||
|
expect(groupRequests).toHaveBeenCalledOnce()
|
||||||
|
expect(seasonRequests[0].searchParams.get('mediaid')).toBe(`tmdb:${media.tmdb_id}`)
|
||||||
|
expect(seasonRequests[0].searchParams.get('title')).toBe(media.title)
|
||||||
|
expect(seasonRequests[0].searchParams.get('year')).toBe(media.year)
|
||||||
|
expect(seasonRequests[0].searchParams.get('season')).toBe('0')
|
||||||
|
expect(missingPayloads[0]).toMatchObject({ episode_group: '', season: 0, tmdb_id: media.tmdb_id })
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Douban', { douban_id: 'db-7303', tmdb_id: undefined }, 'douban:db-7303'],
|
||||||
|
['Bangumi', { bangumi_id: 'bgm-7304', douban_id: undefined, tmdb_id: undefined }, 'bangumi:bgm-7304'],
|
||||||
|
[
|
||||||
|
'custom source',
|
||||||
|
{ bangumi_id: undefined, douban_id: undefined, media_id: 'custom-7305', mediaid_prefix: 'custom', tmdb_id: undefined },
|
||||||
|
'custom:custom-7305',
|
||||||
|
],
|
||||||
|
] as const)('uses the %s media identifier without requesting TMDB groups', async (_label, overrides, mediaId) => {
|
||||||
|
const media = createTvMedia(overrides)
|
||||||
|
const requested = vi.fn()
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })], 200, requested),
|
||||||
|
mediaNotExistsHandler([]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDialog({ media })
|
||||||
|
|
||||||
|
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
|
||||||
|
await settleRequests()
|
||||||
|
expect(requested).toHaveBeenCalledOnce()
|
||||||
|
expect(requested.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('synchronizes visible selections and modes, then emits the five-argument subscription payload', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7306 })
|
||||||
|
const seasons = [
|
||||||
|
createMediaSeason({ season_number: 0 }),
|
||||||
|
createMediaSeason({ season_number: 1 }),
|
||||||
|
createMediaSeason({ season_number: 2 }),
|
||||||
|
]
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler(seasons),
|
||||||
|
mediaNotExistsHandler([
|
||||||
|
createNotExistMediaInfo({ episodes: [1], season: 0, total_episode: 1 }),
|
||||||
|
createNotExistMediaInfo({ episodes: [], season: 1, total_episode: 12 }),
|
||||||
|
createNotExistMediaInfo({ episodes: [1], season: 2, total_episode: 12 }),
|
||||||
|
]),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog({
|
||||||
|
defaultSubscribeMode: 'normal',
|
||||||
|
media,
|
||||||
|
selectedSeason: 0,
|
||||||
|
subscribedSeasonModes: { 1: 'best_version' },
|
||||||
|
subscribedSeasons: [1, 99],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('第 2 季')).toBeInTheDocument()
|
||||||
|
await settleRequests()
|
||||||
|
expect(within(seasonRow(0)).getByRole('button', { name: '全集洗版' })).toHaveClass('v-btn--active')
|
||||||
|
expect(within(seasonRow(1)).getByRole('button', { name: '分集洗版' })).toHaveClass('v-btn--active')
|
||||||
|
|
||||||
|
await user.click(within(seasonRow(0)).getByRole('button', { name: '普通订阅' }))
|
||||||
|
await user.click(seasonRow(2))
|
||||||
|
await user.click(within(seasonRow(2)).getByRole('button', { name: '分集洗版' }))
|
||||||
|
await user.click(screen.getByRole('button', { name: '提交订阅' }))
|
||||||
|
|
||||||
|
expect(events.subscribe).toHaveBeenCalledOnce()
|
||||||
|
const [selected, states, episodeGroup, modes, visible] = events.subscribe.mock.calls[0]
|
||||||
|
expect(selected).toEqual(seasons)
|
||||||
|
expect(states).toEqual({ 0: 0, 1: 2, 2: 1 })
|
||||||
|
expect(episodeGroup).toBe('')
|
||||||
|
expect(modes).toMatchObject({ 0: 'normal', 1: 'best_version', 2: 'best_version' })
|
||||||
|
expect(visible).toEqual([0, 1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the submit action disabled when subscribed selections and modes are unchanged', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7307 })
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
|
||||||
|
mediaNotExistsHandler([createNotExistMediaInfo({ episodes: [], season: 1 })]),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDialog({
|
||||||
|
media,
|
||||||
|
subscribedSeasonModes: { 1: 'best_version' },
|
||||||
|
subscribedSeasons: [1],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: '提交订阅' })).toBeDisabled()
|
||||||
|
expect(await screen.findByText('缺失')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('已订阅')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a manually chosen mode when delayed missing-state data arrives', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7308 })
|
||||||
|
const missingGate = createDeferred()
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
|
||||||
|
mediaNotExistsHandler(
|
||||||
|
[createNotExistMediaInfo({ episodes: [], season: 1 })],
|
||||||
|
200,
|
||||||
|
async () => missingGate.promise,
|
||||||
|
),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog({
|
||||||
|
defaultSubscribeMode: 'best_version',
|
||||||
|
media,
|
||||||
|
selectedSeason: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
|
||||||
|
await user.click(within(seasonRow(1)).getByRole('button', { name: '普通订阅' }))
|
||||||
|
missingGate.resolve()
|
||||||
|
await waitFor(() => expect(screen.getByText('缺失')).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByRole('button', { name: '提交订阅' }))
|
||||||
|
|
||||||
|
const modes = events.subscribe.mock.calls[0][3]
|
||||||
|
expect(modes[1]).toBe('normal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Loading instead of an error empty state while switching from a custom group to default seasons', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7309 })
|
||||||
|
const defaultRequestStarted = createDeferred()
|
||||||
|
const defaultResponseGate = createDeferred()
|
||||||
|
server.use(
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, [
|
||||||
|
{ episode_count: 8, group_count: 1, id: 'group-a', name: '自定义排序 A' },
|
||||||
|
]),
|
||||||
|
mediaGroupSeasonsHandler('group-a', [createMediaSeason({ season_number: 5 })]),
|
||||||
|
mediaSeasonsHandler(
|
||||||
|
[createMediaSeason({ season_number: 1 })],
|
||||||
|
200,
|
||||||
|
async () => {
|
||||||
|
defaultRequestStarted.resolve()
|
||||||
|
await defaultResponseGate.promise
|
||||||
|
},
|
||||||
|
),
|
||||||
|
mediaNotExistsHandler([]),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderDialog({ initialEpisodeGroup: 'group-a', media })
|
||||||
|
|
||||||
|
expect(await screen.findByText('第 5 季')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: /默认/ }))
|
||||||
|
await defaultRequestStarted.promise
|
||||||
|
const showedLoading = document.querySelector('.initial-loading-container') !== null
|
||||||
|
const showedErrorEmptyState = screen.queryByText(`${media.title} 未查询到季集信息`) !== null
|
||||||
|
defaultResponseGate.resolve()
|
||||||
|
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(showedLoading).toBe(true)
|
||||||
|
expect(showedErrorEmptyState).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exits Loading after the default season request fails', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7310 })
|
||||||
|
const requested = vi.fn()
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler([], 500, requested),
|
||||||
|
mediaNotExistsHandler([]),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
|
||||||
|
)
|
||||||
|
await renderDialog({ media })
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalled())
|
||||||
|
await settleRequests()
|
||||||
|
|
||||||
|
expect(document.querySelector('.initial-loading-container')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(`${media.title} 未查询到季集信息`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps season selection usable when the missing-state request fails', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7313 })
|
||||||
|
const missingRequested = vi.fn()
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
|
||||||
|
mediaNotExistsHandler([], 500, missingRequested),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog({ media })
|
||||||
|
|
||||||
|
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(missingRequested).toHaveBeenCalledOnce())
|
||||||
|
await settleRequests()
|
||||||
|
await user.click(seasonRow(1))
|
||||||
|
await user.click(screen.getByRole('button', { name: '提交订阅' }))
|
||||||
|
|
||||||
|
expect(events.subscribe).toHaveBeenCalledOnce()
|
||||||
|
expect(events.subscribe.mock.calls[0][1]).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps season selection usable when optional episode groups fail to load', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7314 })
|
||||||
|
const groupsRequested = vi.fn()
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
|
||||||
|
mediaNotExistsHandler([createNotExistMediaInfo({ episodes: [], season: 1 })]),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, [], 500, groupsRequested),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog({ media })
|
||||||
|
|
||||||
|
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(groupsRequested).toHaveBeenCalledOnce())
|
||||||
|
await settleRequests()
|
||||||
|
await user.click(seasonRow(1))
|
||||||
|
await user.click(screen.getByRole('button', { name: '提交订阅' }))
|
||||||
|
|
||||||
|
expect(events.subscribe).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByRole('button', { name: /^默认/ })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the successful empty state and emits close without submitting', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7311 })
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler([]),
|
||||||
|
mediaNotExistsHandler([]),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
|
||||||
|
)
|
||||||
|
const { events } = await renderDialog({ media })
|
||||||
|
|
||||||
|
expect(await screen.findByText(`${media.title} 未查询到季集信息`)).toBeInTheDocument()
|
||||||
|
await settleRequests()
|
||||||
|
const closeButton = document.querySelector('.absolute.right-3')
|
||||||
|
expect(closeButton).not.toBeNull()
|
||||||
|
await fireEvent.click(closeButton!)
|
||||||
|
|
||||||
|
expect(events.close).toHaveBeenCalledOnce()
|
||||||
|
expect(events.subscribe).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates episode-group rail navigation from its real scroll position', async () => {
|
||||||
|
const media = createTvMedia({ tmdb_id: 7312 })
|
||||||
|
server.use(
|
||||||
|
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
|
||||||
|
mediaNotExistsHandler([]),
|
||||||
|
mediaEpisodeGroupsHandler(media.tmdb_id!, [
|
||||||
|
{ episode_count: 8, group_count: 1, id: 'group-a', name: '排序 A' },
|
||||||
|
{ episode_count: 8, group_count: 1, id: 'group-b', name: '排序 B' },
|
||||||
|
]),
|
||||||
|
mediaGroupSeasonsHandler('group-a', [createMediaSeason({ season_number: 1 })]),
|
||||||
|
mediaGroupSeasonsHandler('group-b', [createMediaSeason({ season_number: 2 })]),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderDialog({ media })
|
||||||
|
await screen.findByText('排序 B')
|
||||||
|
|
||||||
|
const rail = document.querySelector('.subscribe-season-group-options') as HTMLElement & {
|
||||||
|
scrollBy: (options: ScrollToOptions) => void
|
||||||
|
}
|
||||||
|
Object.defineProperties(rail, {
|
||||||
|
clientWidth: { configurable: true, value: 400 },
|
||||||
|
scrollLeft: { configurable: true, value: 0, writable: true },
|
||||||
|
scrollWidth: { configurable: true, value: 1000 },
|
||||||
|
})
|
||||||
|
rail.scrollBy = vi.fn()
|
||||||
|
await fireEvent.scroll(rail)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看更多剧集组' }))
|
||||||
|
expect(rail.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', left: 288 })
|
||||||
|
|
||||||
|
rail.scrollLeft = 300
|
||||||
|
await fireEvent.scroll(rail)
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看上一组剧集组' }))
|
||||||
|
expect(rail.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', left: -288 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { MediaInfo, TmdbEpisode } from '@/api/types'
|
import type { MediaInfo, MediaSeason, NotExistMediaInfo, TmdbEpisode } from '@/api/types'
|
||||||
|
|
||||||
let episodeSeed = 0
|
let episodeSeed = 0
|
||||||
let mediaSeed = 0
|
let mediaSeed = 0
|
||||||
|
let seasonSeed = 0
|
||||||
|
|
||||||
export function createTmdbEpisode(overrides: Partial<TmdbEpisode> = {}): TmdbEpisode {
|
export function createTmdbEpisode(overrides: Partial<TmdbEpisode> = {}): TmdbEpisode {
|
||||||
episodeSeed += 1
|
episodeSeed += 1
|
||||||
@@ -32,3 +33,28 @@ export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
|
|||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构造季选择弹窗使用的最小季信息。 */
|
||||||
|
export function createMediaSeason(overrides: Partial<MediaSeason> = {}): MediaSeason {
|
||||||
|
seasonSeed += 1
|
||||||
|
return {
|
||||||
|
air_date: `202${seasonSeed % 10}-01-01`,
|
||||||
|
episode_count: 12,
|
||||||
|
name: `第 ${seasonSeed} 季`,
|
||||||
|
poster_path: `/images/season-${seasonSeed}.jpg`,
|
||||||
|
season_number: seasonSeed,
|
||||||
|
vote_average: 8,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造媒体服务器返回的单季缺失状态。 */
|
||||||
|
export function createNotExistMediaInfo(overrides: Partial<NotExistMediaInfo> = {}): NotExistMediaInfo {
|
||||||
|
return {
|
||||||
|
episodes: [],
|
||||||
|
season: 1,
|
||||||
|
start_episode: 1,
|
||||||
|
total_episode: 12,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { MediaInfo, TmdbEpisode } from '@/api/types'
|
import type { MediaInfo, MediaSeason, NotExistMediaInfo, TmdbEpisode } from '@/api/types'
|
||||||
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||||
|
|
||||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||||
|
|
||||||
|
export const mediaApiUrls = {
|
||||||
|
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
||||||
|
groupSeasons: (episodeGroup: string) => new URL(`media/group/seasons/${episodeGroup}`, API_BASE_URL).href,
|
||||||
|
notExists: new URL('mediaserver/notexists', API_BASE_URL).href,
|
||||||
|
seasons: new URL('media/seasons', API_BASE_URL).href,
|
||||||
|
}
|
||||||
|
|
||||||
export function mediaDetailsHandler(
|
export function mediaDetailsHandler(
|
||||||
tmdbId: number,
|
tmdbId: number,
|
||||||
response: MediaInfo,
|
response: MediaInfo,
|
||||||
@@ -27,3 +34,50 @@ export function tmdbSeasonEpisodesHandler(
|
|||||||
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mediaSeasonsHandler(
|
||||||
|
response: MediaSeason[],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(mediaApiUrls.seasons, async ({ request }) => {
|
||||||
|
await onRequest(new URL(request.url))
|
||||||
|
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaEpisodeGroupsHandler(
|
||||||
|
tmdbId: number,
|
||||||
|
response: Record<string, unknown>[],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(mediaApiUrls.episodeGroups(tmdbId), async ({ request }) => {
|
||||||
|
await onRequest(new URL(request.url))
|
||||||
|
return HttpResponse.json(response as JsonBodyType, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaGroupSeasonsHandler(
|
||||||
|
episodeGroup: string,
|
||||||
|
response: MediaSeason[],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(mediaApiUrls.groupSeasons(episodeGroup), async ({ request }) => {
|
||||||
|
await onRequest(new URL(request.url))
|
||||||
|
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaNotExistsHandler(
|
||||||
|
response: NotExistMediaInfo[],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (payload: Record<string, unknown>, url: URL) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.post(mediaApiUrls.notExists, async ({ request }) => {
|
||||||
|
const payload = (await request.json()) as Record<string, unknown>
|
||||||
|
await onRequest(payload, new URL(request.url))
|
||||||
|
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export const subscribeApiUrls = {
|
|||||||
downloaders: new URL('download/clients', API_BASE_URL).href,
|
downloaders: new URL('download/clients', API_BASE_URL).href,
|
||||||
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
||||||
filterRuleGroups: new URL('system/setting/UserFilterRuleGroups', API_BASE_URL).href,
|
filterRuleGroups: new URL('system/setting/UserFilterRuleGroups', API_BASE_URL).href,
|
||||||
|
filesById: (id: number) => new URL(`subscribe/files/${id}`, API_BASE_URL).href,
|
||||||
|
historyById: (id: number) => new URL(`subscribe/history/${id}`, API_BASE_URL).href,
|
||||||
|
historyByType: (type: SubscribeMediaType) => new URL(`subscribe/history/${type}`, API_BASE_URL).href,
|
||||||
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
|
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
|
||||||
list: new URL('subscribe/', API_BASE_URL).href,
|
list: new URL('subscribe/', API_BASE_URL).href,
|
||||||
orderConfig: (type: SubscribeMediaType) =>
|
orderConfig: (type: SubscribeMediaType) =>
|
||||||
@@ -51,6 +54,42 @@ export function subscribeListHandler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscribeFilesHandler(
|
||||||
|
id: number,
|
||||||
|
response: JsonBodyType = { episodes: {}, subscribe: null },
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(subscribeApiUrls.filesById(id), async ({ request }) => {
|
||||||
|
await onRequest(new URL(request.url))
|
||||||
|
return jsonResponse(response, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeHistoryHandler(
|
||||||
|
type: SubscribeMediaType,
|
||||||
|
response: Subscribe[] = [],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(subscribeApiUrls.historyByType(type), async ({ request }) => {
|
||||||
|
await onRequest(new URL(request.url))
|
||||||
|
return jsonResponse(response as unknown as JsonBodyType, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSubscribeHistoryHandler(
|
||||||
|
id: number,
|
||||||
|
response: SubscribeMutationResponse = { success: true },
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.delete(subscribeApiUrls.historyById(id), async ({ request }) => {
|
||||||
|
await onRequest(new URL(request.url))
|
||||||
|
return jsonResponse(response, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function subscribeOrderConfigHandler(
|
export function subscribeOrderConfigHandler(
|
||||||
type: SubscribeMediaType,
|
type: SubscribeMediaType,
|
||||||
value: JsonBodyType = [],
|
value: JsonBodyType = [],
|
||||||
|
|||||||
@@ -283,6 +283,9 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'src/composables/useMediaSubscribe.ts',
|
'src/composables/useMediaSubscribe.ts',
|
||||||
'src/components/cards/SubscribeCard.vue',
|
'src/components/cards/SubscribeCard.vue',
|
||||||
'src/components/dialog/SubscribeEditDialog.vue',
|
'src/components/dialog/SubscribeEditDialog.vue',
|
||||||
|
'src/components/dialog/SubscribeFilesDialog.vue',
|
||||||
|
'src/components/dialog/SubscribeHistoryDialog.vue',
|
||||||
|
'src/components/dialog/SubscribeSeasonDialog.vue',
|
||||||
],
|
],
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json-summary', 'html'],
|
reporter: ['text', 'json-summary', 'html'],
|
||||||
@@ -304,6 +307,24 @@ export default defineConfig(({ mode }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/components/dialog/SubscribeFilesDialog.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
|
'src/components/dialog/SubscribeHistoryDialog.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
'src/components/dialog/SubscribeSeasonDialog.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/composables/useMediaSubscribe.ts': {
|
'src/composables/useMediaSubscribe.ts': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user