diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 9396c50a..6f05c721 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/src/components/dialog/AddDownloadDialog.vue b/src/components/dialog/AddDownloadDialog.vue index 36d69c99..f9453c72 100644 --- a/src/components/dialog/AddDownloadDialog.vue +++ b/src/components/dialog/AddDownloadDialog.vue @@ -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(null) const selectedDirectory = ref(null) // 下载器 -const downloaders = ref([]) +const downloaders = ref>>([]) // 所有目录设置 const directories = ref([]) @@ -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>, + Array> + >('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>(endpoint, payload) if (result && result.success) { // 添加下载成功 diff --git a/src/components/dialog/AddSubtitleDownloadDialog.vue b/src/components/dialog/AddSubtitleDownloadDialog.vue index ff799176..b2524f2c 100644 --- a/src/components/dialog/AddSubtitleDownloadDialog.vue +++ b/src/components/dialog/AddSubtitleDownloadDialog.vue @@ -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>('download/subtitle', payload) if (result && result.success) { $toast.success( diff --git a/src/components/dialog/__tests__/AddDownloadDialog.spec.ts b/src/components/dialog/__tests__/AddDownloadDialog.spec.ts new file mode 100644 index 00000000..03dea18c --- /dev/null +++ b/src/components/dialog/__tests__/AddDownloadDialog.spec.ts @@ -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, 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 { + return { + download_path: '/downloads/default', + name: '下载目录', + priority: 0, + storage: 'local', + transfer_type: 'link', + ...overrides, + } +} + +function createTorrent(overrides: Partial = {}): 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 { + return { + episode_run_time: [], + origin_country: [], + source: 'themoviedb', + title: '测试电影', + tmdb_id: 6001, + type: '电影', + ...overrides, + } +} + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise(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, + status = 200, + onRequest: (body: unknown) => void | Promise = () => {}, +) { + 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() + 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() + }) +}) diff --git a/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts b/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts new file mode 100644 index 00000000..f7761eb9 --- /dev/null +++ b/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts @@ -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, 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 { + return { + download_path: '/subtitles/default', + name: '字幕目录', + priority: 0, + storage: 'local', + transfer_type: 'link', + ...overrides, + } +} + +function createSubtitle(overrides: Partial = {}): 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() { + let resolve!: (value: T) => void + const promise = new Promise(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, + status = 200, + onRequest: (body: unknown) => void | Promise = () => {}, +) { + 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() + 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() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 1dcc095a..d58865c6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,