test(subscribe): cover season, files and history dialogs (#544)

This commit is contained in:
InfinityPacer
2026-07-17 18:25:28 +08:00
committed by GitHub
parent 7d2c98c5ae
commit 2b310c9a97
10 changed files with 1532 additions and 10 deletions

View File

@@ -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>

View File

@@ -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 }">

View File

@@ -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(() => {

View 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('Hashhash-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()
})
})

View 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()
})
})

View 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 })
})
})

View File

@@ -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,
}
}

View File

@@ -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 })
})
}

View File

@@ -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 = [],

View File

@@ -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,