test(download): cover download dialog workflows (#610)

This commit is contained in:
InfinityPacer
2026-07-31 12:49:59 +08:00
committed by GitHub
parent 7f2bd5b3ae
commit 3f414afff4
6 changed files with 833 additions and 27 deletions

View File

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

View File

@@ -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) {
// 添加下载成功

View File

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

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

View File

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

View File

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