mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 08:03:45 +08:00
test(download): cover download dialog workflows (#610)
This commit is contained in:
@@ -208,19 +208,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AddDownloadDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AddSubtitleDownloadDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AgentMcpSettingsDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { DownloaderConf, MediaDataSource, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import type {
|
||||
ApiResponse,
|
||||
DownloaderConf,
|
||||
MediaDataSource,
|
||||
MediaInfo,
|
||||
TorrentInfo,
|
||||
TransferDirectoryConf,
|
||||
} from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -44,7 +51,7 @@ const selectedDownloader = ref<string | null>(null)
|
||||
const selectedDirectory = ref<string | null>(null)
|
||||
|
||||
// 下载器
|
||||
const downloaders = ref<DownloaderConf[]>([])
|
||||
const downloaders = ref<Array<Pick<DownloaderConf, 'name' | 'type'>>>([])
|
||||
|
||||
// 所有目录设置
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
@@ -91,7 +98,10 @@ const dialogSubtitle = computed(() => {
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
const result = await api.get<
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>,
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>
|
||||
>('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -103,7 +113,8 @@ function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
// storage 缺省是受支持的本地目录配置,不能生成 undefined/null 前缀。
|
||||
if (item.storage === undefined || item.storage === null || item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
@@ -120,7 +131,10 @@ const targetDirectories = computed(() => {
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
downloaders.value = await api.get('download/clients')
|
||||
downloaders.value = await api.get<
|
||||
Array<Pick<DownloaderConf, 'name' | 'type'>>,
|
||||
Array<Pick<DownloaderConf, 'name' | 'type'>>
|
||||
>('download/clients')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -139,9 +153,14 @@ async function addDownload() {
|
||||
startNProgress()
|
||||
loading.value = true
|
||||
try {
|
||||
let result: { [key: string]: any }
|
||||
|
||||
const payload: any = {
|
||||
const payload: {
|
||||
downloader: string | null
|
||||
media_id?: string
|
||||
media_in?: MediaInfo
|
||||
media_source?: MediaDataSource
|
||||
save_path: string | null
|
||||
torrent_in: TorrentInfo | undefined
|
||||
} = {
|
||||
torrent_in: props.torrent,
|
||||
downloader: selectedDownloader.value,
|
||||
save_path: selectedDirectory.value,
|
||||
@@ -159,7 +178,7 @@ async function addDownload() {
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
result = await api.post(endpoint, payload)
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>(endpoint, payload)
|
||||
|
||||
if (result && result.success) {
|
||||
// 添加下载成功
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaDataSource, SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import type { ApiResponse, MediaDataSource, SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
@@ -75,7 +75,10 @@ const buttonText = computed(() =>
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
const result = await api.get<
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>,
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>
|
||||
>('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -86,7 +89,8 @@ function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
// storage 缺省是受支持的本地目录配置,不能生成 undefined/null 前缀。
|
||||
if (item.storage === undefined || item.storage === null || item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
@@ -105,7 +109,12 @@ async function addSubtitleDownload() {
|
||||
startNProgress()
|
||||
loading.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
const payload: {
|
||||
media_id?: string
|
||||
media_source?: MediaDataSource
|
||||
save_path: string | null
|
||||
subtitle_in: SubtitleInfo | undefined
|
||||
} = {
|
||||
subtitle_in: props.subtitle,
|
||||
save_path: selectedDirectory.value,
|
||||
}
|
||||
@@ -115,7 +124,7 @@ async function addSubtitleDownload() {
|
||||
payload.media_id = mediaId.value
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.post('download/subtitle', payload)
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>('download/subtitle', payload)
|
||||
|
||||
if (result && result.success) {
|
||||
$toast.success(
|
||||
|
||||
422
src/components/dialog/__tests__/AddDownloadDialog.spec.ts
Normal file
422
src/components/dialog/__tests__/AddDownloadDialog.spec.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import type { MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import AddDownloadDialog from '@/components/dialog/AddDownloadDialog.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||
import { defineComponent, h, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
doneNProgress: vi.fn(),
|
||||
startNProgress: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/nprogress', () => ({
|
||||
configureNProgress: vi.fn(),
|
||||
doneNProgress: mocks.doneNProgress,
|
||||
startNProgress: mocks.startNProgress,
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
type SelectItem = string | { title: string; value: string }
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'NativeSelectStub',
|
||||
props: {
|
||||
items: { type: Array as PropType<SelectItem[]>, default: () => [] },
|
||||
label: String,
|
||||
modelValue: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const itemLabel = (item: SelectItem) => (typeof item === 'string' ? item : item.title)
|
||||
const itemValue = (item: SelectItem) => (typeof item === 'string' ? item : item.value)
|
||||
|
||||
return () =>
|
||||
h('label', [
|
||||
props.label,
|
||||
h(
|
||||
'select',
|
||||
{
|
||||
'aria-label': props.label,
|
||||
'value': props.modelValue ?? '',
|
||||
'onChange': (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
|
||||
},
|
||||
[
|
||||
h('option', { value: '' }, '默认'),
|
||||
...props.items.map(item => h('option', { key: itemValue(item), value: itemValue(item) }, itemLabel(item))),
|
||||
],
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const TextFieldStub = defineComponent({
|
||||
name: 'NativeTextFieldStub',
|
||||
props: {
|
||||
label: String,
|
||||
modelValue: { type: String, default: '' },
|
||||
},
|
||||
emits: ['click:append-inner', 'update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('label', [
|
||||
props.label,
|
||||
h('input', {
|
||||
'aria-label': props.label,
|
||||
'value': props.modelValue ?? '',
|
||||
'onInput': (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||
}),
|
||||
h('button', { onClick: () => emit('click:append-inner'), type: 'button' }, '查询媒体编号'),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const MediaIdSelectorStub = defineComponent({
|
||||
name: 'MediaIdSelector',
|
||||
emits: ['close', 'update:modelValue'],
|
||||
setup(_props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
emit('update:modelValue', '1295644')
|
||||
emit('close')
|
||||
},
|
||||
type: 'button',
|
||||
},
|
||||
'选择媒体编号',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const DialogStub = defineComponent({
|
||||
name: 'VDialog',
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: true },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => (props.modelValue ? h('div', { role: 'dialog' }, slots.default?.()) : null)
|
||||
},
|
||||
})
|
||||
|
||||
const DialogCloseButtonStub = defineComponent({
|
||||
name: 'VDialogCloseBtn',
|
||||
emits: ['click'],
|
||||
setup(_props, { emit }) {
|
||||
return () => h('button', { onClick: () => emit('click'), type: 'button' }, '关闭')
|
||||
},
|
||||
})
|
||||
|
||||
function createDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
|
||||
return {
|
||||
download_path: '/downloads/default',
|
||||
name: '下载目录',
|
||||
priority: 0,
|
||||
storage: 'local',
|
||||
transfer_type: 'link',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createTorrent(overrides: Partial<TorrentInfo> = {}): TorrentInfo {
|
||||
return {
|
||||
category: 'movie',
|
||||
downloadvolumefactor: 1,
|
||||
enclosure: 'https://tracker.example/download/goal-6d.torrent',
|
||||
freedate: '',
|
||||
freedate_diff: '',
|
||||
grabs: 3,
|
||||
hit_and_run: false,
|
||||
imdbid: 'tt0060001',
|
||||
labels: [],
|
||||
peers: 2,
|
||||
pri_order: 0,
|
||||
seeders: 10,
|
||||
site_name: '测试站',
|
||||
site_order: 0,
|
||||
site_proxy: false,
|
||||
size: 1024,
|
||||
title: '测试种子',
|
||||
uploadvolumefactor: 1,
|
||||
volume_factor: '1x',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMedia(overrides: Partial<MediaInfo> = {}): MediaInfo {
|
||||
return {
|
||||
episode_run_time: [],
|
||||
origin_country: [],
|
||||
source: 'themoviedb',
|
||||
title: '测试电影',
|
||||
tmdb_id: 6001,
|
||||
type: '电影',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function directoriesHandler(directories: TransferDirectoryConf[]) {
|
||||
return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: directories }, success: true }),
|
||||
)
|
||||
}
|
||||
|
||||
function downloadersHandler(downloaders: Array<{ name: string; type: string }> = []) {
|
||||
return http.get(new URL('download/clients', API_BASE_URL).href, () => HttpResponse.json(downloaders))
|
||||
}
|
||||
|
||||
function downloadHandler(
|
||||
endpoint: 'download/' | 'download/add',
|
||||
response: JsonBodyType | Promise<JsonBodyType>,
|
||||
status = 200,
|
||||
onRequest: (body: unknown) => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.post(new URL(endpoint, API_BASE_URL).href, async ({ request }) => {
|
||||
await onRequest(await request.json())
|
||||
return HttpResponse.json(await response, { status })
|
||||
})
|
||||
}
|
||||
|
||||
async function renderDialog({
|
||||
directories = [],
|
||||
downloaders = [],
|
||||
media,
|
||||
recognizeSource = 'themoviedb',
|
||||
torrent = createTorrent(),
|
||||
}: {
|
||||
directories?: TransferDirectoryConf[]
|
||||
downloaders?: Array<{ name: string; type: string }>
|
||||
media?: MediaInfo
|
||||
recognizeSource?: string
|
||||
torrent?: TorrentInfo
|
||||
} = {}) {
|
||||
const events = {
|
||||
close: vi.fn(),
|
||||
done: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}
|
||||
server.use(directoriesHandler(directories), downloadersHandler(downloaders))
|
||||
const result = await renderWithProviders(AddDownloadDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppCombobox: SelectStub,
|
||||
AppSelect: SelectStub,
|
||||
AppTextField: TextFieldStub,
|
||||
MediaIdSelector: MediaIdSelectorStub,
|
||||
VCombobox: SelectStub,
|
||||
VDialog: DialogStub,
|
||||
VDialogCloseBtn: DialogCloseButtonStub,
|
||||
VSelect: SelectStub,
|
||||
VTextField: TextFieldStub,
|
||||
},
|
||||
},
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
RECOGNIZE_SOURCE: recognizeSource,
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
media,
|
||||
modelValue: true,
|
||||
onClose: events.close,
|
||||
onDone: events.done,
|
||||
onError: events.error,
|
||||
title: media?.title,
|
||||
torrent,
|
||||
},
|
||||
})
|
||||
|
||||
return { ...result, events, torrent }
|
||||
}
|
||||
|
||||
describe('AddDownloadDialog directories', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('normalizes local, remote, missing-storage, and duplicate directories while loading downloaders', async () => {
|
||||
const missingStorage = createDirectory({
|
||||
download_path: '/downloads/legacy',
|
||||
name: '兼容目录',
|
||||
storage: undefined as unknown as string,
|
||||
})
|
||||
const nullStorage = createDirectory({ download_path: '/downloads/null-storage', name: '空存储目录' })
|
||||
nullStorage.storage = null as unknown as string
|
||||
|
||||
await renderDialog({
|
||||
directories: [
|
||||
createDirectory({ download_path: '/downloads/local' }),
|
||||
createDirectory({ download_path: '/downloads/remote', name: '远程目录', storage: 'rclone' }),
|
||||
missingStorage,
|
||||
nullStorage,
|
||||
createDirectory({ download_path: '/downloads/empty-storage', name: '空字符串存储', storage: '' }),
|
||||
createDirectory({ download_path: '/downloads/remote', name: '重复目录', storage: 'rclone' }),
|
||||
createDirectory({ download_path: undefined, name: '无下载路径' }),
|
||||
],
|
||||
downloaders: [{ name: '下载器 A', type: 'qbittorrent' }],
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('option', { name: '/downloads/local' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: '/downloads/legacy' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: '/downloads/null-storage' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: ':/downloads/empty-storage' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('option', { name: 'rclone:/downloads/remote' })).toHaveLength(1)
|
||||
expect(screen.queryByText('undefined:/downloads/legacy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('null:/downloads/null-storage')).not.toBeInTheDocument()
|
||||
expect(await screen.findByRole('option', { name: '下载器 A' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('保存目录(自动)')).toHaveValue('')
|
||||
expect(screen.getByLabelText('下载器(默认)')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddDownloadDialog submissions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('emits close from the dialog close button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }))
|
||||
|
||||
expect(events.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('submits a directly entered media ID through v-model', async () => {
|
||||
const submitted = vi.fn()
|
||||
server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted))
|
||||
const user = userEvent.setup()
|
||||
|
||||
await renderDialog({ recognizeSource: 'bangumi' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
|
||||
await user.type(screen.getByLabelText('Bangumi编号'), '24680')
|
||||
await user.click(screen.getByRole('button', { name: '开始下载' }))
|
||||
|
||||
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
|
||||
expect(submitted.mock.calls[0][0]).toMatchObject({
|
||||
media_id: '24680',
|
||||
media_source: 'bangumi',
|
||||
})
|
||||
})
|
||||
|
||||
it('submits download/add with advanced media ID once and clears pending state on success', async () => {
|
||||
const deferred = createDeferred<JsonBodyType>()
|
||||
const submitted = vi.fn()
|
||||
server.use(downloadHandler('download/add', deferred.promise, 200, submitted))
|
||||
const torrent = createTorrent()
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog({
|
||||
directories: [createDirectory({ download_path: '/downloads/remote', storage: 'rclone' })],
|
||||
downloaders: [{ name: '下载器 A', type: 'qbittorrent' }],
|
||||
recognizeSource: 'douban',
|
||||
torrent,
|
||||
})
|
||||
|
||||
await screen.findByRole('option', { name: '下载器 A' })
|
||||
await screen.findByRole('option', { name: 'rclone:/downloads/remote' })
|
||||
await user.selectOptions(screen.getByLabelText('下载器(默认)'), '下载器 A')
|
||||
await user.selectOptions(screen.getByLabelText('保存目录(自动)'), 'rclone:/downloads/remote')
|
||||
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
|
||||
await user.click(screen.getByRole('button', { name: '查询媒体编号' }))
|
||||
await user.click(screen.getByRole('button', { name: '选择媒体编号' }))
|
||||
const submitButton = screen.getByRole('button', { name: '开始下载' })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
|
||||
expect(submitted.mock.calls[0][0]).toEqual({
|
||||
downloader: '下载器 A',
|
||||
media_id: '1295644',
|
||||
media_source: 'douban',
|
||||
save_path: 'rclone:/downloads/remote',
|
||||
torrent_in: torrent,
|
||||
})
|
||||
expect(submitButton).toBeDisabled()
|
||||
expect(submitButton).toHaveTextContent('下载中...')
|
||||
await user.click(submitButton)
|
||||
expect(submitted).toHaveBeenCalledOnce()
|
||||
expect(mocks.startNProgress).toHaveBeenCalledOnce()
|
||||
|
||||
deferred.resolve({ data: null, success: true })
|
||||
await waitFor(() => expect(events.done).toHaveBeenCalledWith(torrent.enclosure))
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('测试站 测试种子 下载成功!')
|
||||
expect(mocks.doneNProgress).toHaveBeenCalledOnce()
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('uses download/ for an existing media without locking unrelated optional fields', async () => {
|
||||
const submitted = vi.fn()
|
||||
server.use(downloadHandler('download/', { data: null, success: true }, 200, submitted))
|
||||
const media = createMedia()
|
||||
const torrent = createTorrent()
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog({ media, torrent })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始下载' }))
|
||||
|
||||
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
|
||||
expect(submitted.mock.calls[0][0]).toMatchObject({
|
||||
downloader: null,
|
||||
media_in: media,
|
||||
save_path: null,
|
||||
torrent_in: torrent,
|
||||
})
|
||||
expect(events.done).toHaveBeenCalledWith(torrent.enclosure)
|
||||
expect(mocks.doneNProgress).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('treats success:false at HTTP 200 as a business failure', async () => {
|
||||
server.use(downloadHandler('download/add', { data: null, message: '下载器拒绝任务', success: false }))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始下载' }))
|
||||
|
||||
await waitFor(() => expect(events.error).toHaveBeenCalledWith('下载器拒绝任务'))
|
||||
expect(events.done).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('测试站 测试种子 下载失败:下载器拒绝任务!')
|
||||
expect(mocks.startNProgress).toHaveBeenCalledOnce()
|
||||
expect(mocks.doneNProgress).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: '开始下载' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('clears loading and progress after an HTTP failure without emitting done', async () => {
|
||||
server.use(downloadHandler('download/add', { message: '服务异常', success: false }, 500))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始下载' }))
|
||||
|
||||
await waitFor(() => expect(mocks.doneNProgress).toHaveBeenCalledOnce())
|
||||
expect(mocks.startNProgress).toHaveBeenCalledOnce()
|
||||
expect(events.done).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: '开始下载' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import AddSubtitleDownloadDialog from '@/components/dialog/AddSubtitleDownloadDialog.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||
import { defineComponent, h, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
doneNProgress: vi.fn(),
|
||||
startNProgress: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/nprogress', () => ({
|
||||
configureNProgress: vi.fn(),
|
||||
doneNProgress: mocks.doneNProgress,
|
||||
startNProgress: mocks.startNProgress,
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'NativeSelectStub',
|
||||
props: {
|
||||
items: { type: Array as PropType<string[]>, default: () => [] },
|
||||
label: String,
|
||||
modelValue: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('label', [
|
||||
props.label,
|
||||
h(
|
||||
'select',
|
||||
{
|
||||
'aria-label': props.label,
|
||||
'value': props.modelValue ?? '',
|
||||
'onChange': (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
|
||||
},
|
||||
[
|
||||
h('option', { value: '' }, '默认'),
|
||||
...props.items.map(item => h('option', { key: item, value: item }, item)),
|
||||
],
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const TextFieldStub = defineComponent({
|
||||
name: 'NativeTextFieldStub',
|
||||
props: {
|
||||
label: String,
|
||||
modelValue: { type: String, default: '' },
|
||||
},
|
||||
emits: ['click:append-inner', 'update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('label', [
|
||||
props.label,
|
||||
h('input', {
|
||||
'aria-label': props.label,
|
||||
'value': props.modelValue ?? '',
|
||||
'onInput': (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||
}),
|
||||
h('button', { onClick: () => emit('click:append-inner'), type: 'button' }, '查询媒体编号'),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const MediaIdSelectorStub = defineComponent({
|
||||
name: 'MediaIdSelector',
|
||||
emits: ['close', 'update:modelValue'],
|
||||
setup(_props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
emit('update:modelValue', '98765')
|
||||
emit('close')
|
||||
},
|
||||
type: 'button',
|
||||
},
|
||||
'选择媒体编号',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const DialogStub = defineComponent({
|
||||
name: 'VDialog',
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: true },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => (props.modelValue ? h('div', { role: 'dialog' }, slots.default?.()) : null)
|
||||
},
|
||||
})
|
||||
|
||||
const DialogCloseButtonStub = defineComponent({
|
||||
name: 'VDialogCloseBtn',
|
||||
emits: ['click'],
|
||||
setup(_props, { emit }) {
|
||||
return () => h('button', { onClick: () => emit('click'), type: 'button' }, '关闭')
|
||||
},
|
||||
})
|
||||
|
||||
function createDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
|
||||
return {
|
||||
download_path: '/subtitles/default',
|
||||
name: '字幕目录',
|
||||
priority: 0,
|
||||
storage: 'local',
|
||||
transfer_type: 'link',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createSubtitle(overrides: Partial<SubtitleInfo> = {}): SubtitleInfo {
|
||||
return {
|
||||
enclosure: 'https://subtitle.example/download/goal-6d#mp_sig=signed%2Fvalue%3D&mp_purpose=subtitle-download%3A42',
|
||||
language: '简体中文',
|
||||
site: 42,
|
||||
site_name: '字幕站',
|
||||
size: 2048,
|
||||
title: '测试字幕',
|
||||
uploader: '字幕组',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function directoriesHandler(directories: TransferDirectoryConf[]) {
|
||||
return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: directories }, success: true }),
|
||||
)
|
||||
}
|
||||
|
||||
function subtitleDownloadHandler(
|
||||
response: JsonBodyType | Promise<JsonBodyType>,
|
||||
status = 200,
|
||||
onRequest: (body: unknown) => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.post(new URL('download/subtitle', API_BASE_URL).href, async ({ request }) => {
|
||||
await onRequest(await request.json())
|
||||
return HttpResponse.json(await response, { status })
|
||||
})
|
||||
}
|
||||
|
||||
async function renderDialog({
|
||||
directories = [],
|
||||
recognizeSource = 'themoviedb',
|
||||
subtitle = createSubtitle(),
|
||||
}: {
|
||||
directories?: TransferDirectoryConf[]
|
||||
recognizeSource?: string
|
||||
subtitle?: SubtitleInfo
|
||||
} = {}) {
|
||||
const events = {
|
||||
close: vi.fn(),
|
||||
done: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}
|
||||
server.use(directoriesHandler(directories))
|
||||
const result = await renderWithProviders(AddSubtitleDownloadDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppCombobox: SelectStub,
|
||||
AppTextField: TextFieldStub,
|
||||
MediaIdSelector: MediaIdSelectorStub,
|
||||
VCombobox: SelectStub,
|
||||
VDialog: DialogStub,
|
||||
VDialogCloseBtn: DialogCloseButtonStub,
|
||||
VTextField: TextFieldStub,
|
||||
},
|
||||
},
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
RECOGNIZE_SOURCE: recognizeSource,
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
modelValue: true,
|
||||
onClose: events.close,
|
||||
onDone: events.done,
|
||||
onError: events.error,
|
||||
subtitle,
|
||||
title: '测试电影',
|
||||
},
|
||||
})
|
||||
|
||||
return { ...result, events, subtitle }
|
||||
}
|
||||
|
||||
describe('AddSubtitleDownloadDialog directories', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('normalizes local, remote, missing-storage, and duplicate directories while keeping the default empty option', async () => {
|
||||
const missingStorage = createDirectory({
|
||||
download_path: '/subtitles/legacy',
|
||||
name: '兼容目录',
|
||||
storage: undefined as unknown as string,
|
||||
})
|
||||
const nullStorage = createDirectory({ download_path: '/subtitles/null-storage', name: '空存储目录' })
|
||||
nullStorage.storage = null as unknown as string
|
||||
|
||||
await renderDialog({
|
||||
directories: [
|
||||
createDirectory({ download_path: '/subtitles/local' }),
|
||||
createDirectory({ download_path: '/subtitles/remote', name: '远程目录', storage: 's3' }),
|
||||
missingStorage,
|
||||
nullStorage,
|
||||
createDirectory({ download_path: '/subtitles/empty-storage', name: '空字符串存储', storage: '' }),
|
||||
createDirectory({ download_path: '/subtitles/remote', name: '重复目录', storage: 's3' }),
|
||||
createDirectory({ download_path: undefined, name: '无下载路径' }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('option', { name: '/subtitles/local' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: '/subtitles/legacy' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: '/subtitles/null-storage' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: ':/subtitles/empty-storage' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('option', { name: 's3:/subtitles/remote' })).toHaveLength(1)
|
||||
expect(screen.queryByText('undefined:/subtitles/legacy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('null:/subtitles/null-storage')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('保存目录(自动)')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddSubtitleDownloadDialog submissions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('emits close from the dialog close button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }))
|
||||
|
||||
expect(events.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('submits a directly entered media ID through v-model', async () => {
|
||||
const submitted = vi.fn()
|
||||
server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted))
|
||||
const user = userEvent.setup()
|
||||
|
||||
await renderDialog({ recognizeSource: 'douban' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
|
||||
await user.type(screen.getByLabelText('豆瓣编号'), '13579')
|
||||
await user.click(screen.getByRole('button', { name: '下载字幕' }))
|
||||
|
||||
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
|
||||
expect(submitted.mock.calls[0][0]).toMatchObject({
|
||||
media_id: '13579',
|
||||
media_source: 'douban',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the signed enclosure in download/subtitle and prevents duplicate submission', async () => {
|
||||
const deferred = createDeferred<JsonBodyType>()
|
||||
const submitted = vi.fn()
|
||||
server.use(subtitleDownloadHandler(deferred.promise, 200, submitted))
|
||||
const subtitle = createSubtitle()
|
||||
const expectedEnclosure = subtitle.enclosure
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog({
|
||||
directories: [createDirectory({ download_path: '/subtitles/remote', storage: 's3' })],
|
||||
recognizeSource: 'anilist',
|
||||
subtitle,
|
||||
})
|
||||
|
||||
await screen.findByRole('option', { name: 's3:/subtitles/remote' })
|
||||
await user.selectOptions(screen.getByLabelText('保存目录(自动)'), 's3:/subtitles/remote')
|
||||
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
|
||||
await user.click(screen.getByRole('button', { name: '查询媒体编号' }))
|
||||
await user.click(screen.getByRole('button', { name: '选择媒体编号' }))
|
||||
const submitButton = screen.getByRole('button', { name: '下载字幕' })
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
|
||||
expect(submitted.mock.calls[0][0]).toEqual({
|
||||
media_id: '98765',
|
||||
media_source: 'anilist',
|
||||
save_path: 's3:/subtitles/remote',
|
||||
subtitle_in: subtitle,
|
||||
})
|
||||
expect((submitted.mock.calls[0][0] as { subtitle_in: SubtitleInfo }).subtitle_in.enclosure).toBe(expectedEnclosure)
|
||||
expect(submitButton).toBeDisabled()
|
||||
expect(submitButton).toHaveTextContent('下载中...')
|
||||
await user.click(submitButton)
|
||||
expect(submitted).toHaveBeenCalledOnce()
|
||||
expect(mocks.startNProgress).toHaveBeenCalledOnce()
|
||||
|
||||
deferred.resolve({ data: null, success: true })
|
||||
await waitFor(() => expect(events.done).toHaveBeenCalledWith(expectedEnclosure))
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('字幕站 测试字幕 字幕下载成功!')
|
||||
expect(mocks.doneNProgress).toHaveBeenCalledOnce()
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('treats success:false at HTTP 200 as a business failure', async () => {
|
||||
server.use(subtitleDownloadHandler({ data: null, message: '签名已过期', success: false }))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '下载字幕' }))
|
||||
|
||||
await waitFor(() => expect(events.error).toHaveBeenCalledWith('签名已过期'))
|
||||
expect(events.done).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('字幕站 测试字幕 字幕下载失败:签名已过期!')
|
||||
expect(mocks.startNProgress).toHaveBeenCalledOnce()
|
||||
expect(mocks.doneNProgress).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: '下载字幕' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('clears loading and progress after an HTTP failure without emitting done', async () => {
|
||||
server.use(subtitleDownloadHandler({ message: '服务异常', success: false }, 500))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '下载字幕' }))
|
||||
|
||||
await waitFor(() => expect(mocks.doneNProgress).toHaveBeenCalledOnce())
|
||||
expect(mocks.startNProgress).toHaveBeenCalledOnce()
|
||||
expect(events.done).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: '下载字幕' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -321,6 +321,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/components/dialog/DiscoverTabOrderDialog.vue',
|
||||
'src/components/dialog/SiteResourceDialog.vue',
|
||||
'src/components/dialog/SiteUserDataDialog.vue',
|
||||
'src/components/dialog/AddDownloadDialog.vue',
|
||||
'src/components/dialog/AddSubtitleDownloadDialog.vue',
|
||||
'src/views/discover/TheMovieDbView.vue',
|
||||
'src/views/discover/DoubanView.vue',
|
||||
'src/views/discover/BangumiView.vue',
|
||||
@@ -572,6 +574,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/components/dialog/AddDownloadDialog.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/dialog/AddSubtitleDownloadDialog.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/views/discover/TheMovieDbView.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
|
||||
Reference in New Issue
Block a user