diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 13cec541..2dbfc554 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -315,7 +315,7 @@ }, "src/components/dialog/ReorganizeDialog.vue": { "@typescript-eslint/no-explicit-any": { - "count": 14 + "count": 10 }, "sonarjs/super-linear-regex": { "count": 1 diff --git a/src/components/dialog/ReorganizeDialog.vue b/src/components/dialog/ReorganizeDialog.vue index 7ba01fe1..11c810a9 100644 --- a/src/components/dialog/ReorganizeDialog.vue +++ b/src/components/dialog/ReorganizeDialog.vue @@ -85,6 +85,9 @@ const progressText = ref(t('dialog.reorganize.processing')) // 整理进度 const progressValue = ref(0) +// 整理请求执行期间保持单一提交,避免同一批源文件被重复处理。 +const transferSubmitting = ref(false) + // 进度SSE连接 const progressSSE = ref(null) @@ -299,7 +302,7 @@ const dialogSubtitle = computed(() => { } return t('dialog.reorganize.singleItemTitle', { path: normalizedItems.value[0].path }) - } else if (props.logids) { + } else if (props.logids?.length) { return t('dialog.reorganize.multipleItemsTitle', { count: props.logids.length }) } }) @@ -922,7 +925,7 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l } // 请求整理接口 -async function requestManualTransfer( +async function requestManualTransfer( payload: ManualTransferPayload, background: boolean = false, ): Promise> { @@ -1128,7 +1131,7 @@ function resolvePreviewResponseData(result: ApiResponse { try { @@ -1269,33 +1272,51 @@ async function togglePreview() { // 整理文件 async function handleTransfer(item: FileItem, background: boolean = false) { try { - const result: { [key: string]: any } = await requestManualTransfer(createTransferPayload({ item }), background) - if (!result.success) $toast.error(result.message) - else if (background) $toast.success(t('dialog.reorganize.successMessage', { name: item.name })) - } catch (e) { - console.log(e) + const result = await requestManualTransfer(createTransferPayload({ item }), background) + if (!result.success) { + $toast.error(result.message || t('dialog.reorganize.transferRequestFailed')) + return false + } + if (background) $toast.success(t('dialog.reorganize.successMessage', { name: item.name })) + return true + } catch (error: unknown) { + console.log(error) + if (error instanceof Error) $toast.error(error.message) + return false } } // 批量整理文件并按后台模式决定是否提示入队成功。 async function handleTransferBatch(items: FileItem[], background: boolean = false) { try { - const result: { [key: string]: any } = await requestManualTransfer(createTransferPayload({ items }), background) - if (!result.success) $toast.error(result.message) - else if (background) $toast.success(t('dialog.reorganize.successMessage', { name: getBatchItemsLabel(items) })) - } catch (e) { - console.log(e) + const result = await requestManualTransfer(createTransferPayload({ items }), background) + if (!result.success) { + $toast.error(result.message || t('dialog.reorganize.transferRequestFailed')) + return false + } + if (background) $toast.success(t('dialog.reorganize.successMessage', { name: getBatchItemsLabel(items) })) + return true + } catch (error: unknown) { + console.log(error) + if (error instanceof Error) $toast.error(error.message) + return false } } // 整理日志 async function handleTransferLog(logid: number, background: boolean = false) { try { - const result: { [key: string]: any } = await requestManualTransfer(createTransferPayload({ logid }), background) - if (!result.success) $toast.error(result.message) - else if (background) $toast.success(`历史记录 ${logid} 已加入整理队列!`) - } catch (e) { - console.log(e) + const result = await requestManualTransfer(createTransferPayload({ logid }), background) + if (!result.success) { + $toast.error(result.message || t('dialog.reorganize.transferRequestFailed')) + return false + } + if (background) $toast.success(`历史记录 ${logid} 已加入整理队列!`) + return true + } catch (error: unknown) { + console.log(error) + if (error instanceof Error) $toast.error(error.message) + return false } } @@ -1337,51 +1358,51 @@ function stopLoadingProgress() { // 整理文件 async function transfer(background: boolean = false) { - if (!props.logids && !normalizedItems.value.length) return + if ((!props.logids?.length && !normalizedItems.value.length) || transferSubmitting.value) return - // 显示进度条 + transferSubmitting.value = true progressDialog.value = true + let allSucceeded = true - // 文件整理 - if (normalizedItems.value.length) { - if (shouldUseBatchFileItems(normalizedItems.value)) { + try { + // 文件整理 + if (normalizedItems.value.length) { + if (shouldUseBatchFileItems(normalizedItems.value)) { + if (!background) { + startLoadingProgress('filetransfer') + } + allSucceeded = (await handleTransferBatch(normalizedItems.value, background)) && allSucceeded + } else { + for (const item of normalizedItems.value) { + if (!background) { + // 如果是文件,计算MD5 + const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString() + + // 开始监听进度 + startLoadingProgress(key) + } + allSucceeded = (await handleTransfer(item, background)) && allSucceeded + } + } + } + + // 日志整理 + if (props.logids?.length) { if (!background) { + // 为日志整理任务开启进度监听 startLoadingProgress('filetransfer') } - await handleTransferBatch(normalizedItems.value, background) - } else { - for (const item of normalizedItems.value) { - if (!background) { - // 如果是文件,计算MD5 - const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString() - - // 开始监听进度 - startLoadingProgress(key) - } - await handleTransfer(item, background) + for (const logid of props.logids) { + allSucceeded = (await handleTransferLog(logid, background)) && allSucceeded } } - } - // 日志整理 - if (props.logids) { - if (!background) { - // 为日志整理任务开启进度监听 - startLoadingProgress('filetransfer') - } - for (const logid of props.logids) { - await handleTransferLog(logid, background) - } + if (allSucceeded) emit('done') + } finally { + if (!background) stopLoadingProgress() + progressDialog.value = false + transferSubmitting.value = false } - if (!background) { - // 停止监听进度 - stopLoadingProgress() - } - - // 关闭进度条 - progressDialog.value = false - // 重新加载 - emit('done') } onMounted(async () => { @@ -1636,7 +1657,7 @@ onUnmounted(() => { persistent-hint /> - + { @click="transfer(true)" prepend-icon="mdi-plus" class="reorganize-action-btn reorganize-action-btn--queue" + :loading="transferSubmitting" + :disabled="transferSubmitting" > {{ t('dialog.reorganize.addToQueue') }} @@ -1675,7 +1698,8 @@ onUnmounted(() => { @click="transfer(false)" :prepend-icon="isReorganize ? 'mdi-refresh' : 'mdi-arrow-right-bold'" class="reorganize-action-btn reorganize-action-btn--primary" - :loading="manualHistoryLoading" + :loading="manualHistoryLoading || transferSubmitting" + :disabled="transferSubmitting" > {{ isReorganize ? t('dialog.reorganize.reorganizeAgain') : t('dialog.reorganize.reorganizeNow') }} diff --git a/src/components/dialog/__tests__/ReorganizeDialog.spec.ts b/src/components/dialog/__tests__/ReorganizeDialog.spec.ts new file mode 100644 index 00000000..87755d91 --- /dev/null +++ b/src/components/dialog/__tests__/ReorganizeDialog.spec.ts @@ -0,0 +1,972 @@ +import type { ApiResponse, FileItem, StorageConf, TransferDirectoryConf } from '@/api/types' +import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue' +import { fireEvent, 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 } 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/' +let initializationRequestCount = 0 + +const mocks = vi.hoisted(() => ({ + progressControllers: [] as Array<{ + active: unknown + handler: (event: MessageEvent) => void + key: string + start: ReturnType + stop: ReturnType + url: string + }>, + toastError: vi.fn(), + toastSuccess: vi.fn(), + toastWarning: vi.fn(), +})) + +vi.mock('@/composables/useBackground', () => ({ + useBackground: () => ({ + useProgressSSE: (url: string, handler: (event: MessageEvent) => void, key: string, active: unknown) => { + const controller = { + active, + handler, + key, + start: vi.fn(), + stop: vi.fn(), + url, + } + mocks.progressControllers.push(controller) + return controller + }, + }), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ + error: mocks.toastError, + success: mocks.toastSuccess, + warning: mocks.toastWarning, + }), +})) + +const DialogStub = defineComponent({ + name: 'VDialog', + props: { + modelValue: { type: Boolean, default: true }, + }, + setup(props, { slots }) { + return () => (props.modelValue ? h('div', { role: 'dialog' }, slots.default?.()) : null) + }, +}) + +type SelectItem = string | number | { title: string; value: unknown } + +const SelectStub = defineComponent({ + name: 'NativeSelectStub', + props: { + items: { type: Array as PropType, default: () => [] }, + label: String, + modelModifiers: { type: Object as PropType>, default: () => ({}) }, + modelValue: { default: null }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + const itemLabel = (item: SelectItem) => (typeof item === 'object' ? item.title : String(item)) + const itemValue = (item: SelectItem) => (typeof item === 'object' ? item.value : item) + + return () => { + const selectedIndex = props.items.findIndex(item => itemValue(item) === props.modelValue) + return h('label', [ + props.label, + h( + 'select', + { + 'aria-label': props.label, + 'onChange': (event: Event) => { + const index = Number((event.target as HTMLSelectElement).value) + const value = itemValue(props.items[index]) + emit('update:modelValue', props.modelModifiers.number ? Number(value) : value) + }, + 'value': selectedIndex >= 0 ? String(selectedIndex) : '', + }, + props.items.map((item, index) => + h('option', { key: `${itemLabel(item)}-${index}`, value: String(index) }, itemLabel(item)), + ), + ), + ]) + } + }, +}) + +const TextFieldStub = defineComponent({ + name: 'NativeTextFieldStub', + props: { + appendInnerIcon: String, + label: String, + modelModifiers: { type: Object as PropType>, default: () => ({}) }, + modelValue: { default: '' }, + }, + emits: ['click:append-inner', 'update:modelValue'], + setup(props, { emit, slots }) { + return () => + h('label', [ + props.label, + h('input', { + 'aria-label': props.label, + 'onInput': (event: Event) => { + const value = (event.target as HTMLInputElement).value + emit('update:modelValue', props.modelModifiers.number ? Number(value) : value) + }, + 'value': props.modelValue ?? '', + }), + props.appendInnerIcon + ? h( + 'button', + { + onClick: () => emit('click:append-inner'), + type: 'button', + }, + '查询媒体编号', + ) + : null, + slots['append-inner']?.(), + ]) + }, +}) + +const SwitchStub = defineComponent({ + name: 'NativeSwitchStub', + props: { + label: String, + modelValue: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h('label', [ + h('input', { + 'aria-label': props.label, + 'checked': props.modelValue, + 'onChange': (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked), + 'type': 'checkbox', + }), + props.label, + ]) + }, +}) + +const IconButtonStub = defineComponent({ + name: 'IconBtn', + props: { + disabled: Boolean, + icon: String, + }, + emits: ['click'], + setup(props, { emit }) { + return () => + h( + 'button', + { + 'aria-label': props.icon, + disabled: props.disabled, + onClick: (event: MouseEvent) => emit('click', event), + type: 'button', + }, + props.icon, + ) + }, +}) + +const DialogCloseButtonStub = defineComponent({ + name: 'VDialogCloseBtn', + emits: ['click'], + setup(_props, { emit }) { + return () => h('button', { onClick: () => emit('click'), type: 'button' }, '关闭') + }, +}) + +const TooltipStub = defineComponent({ + name: 'VTooltip', + setup(_props, { slots }) { + return () => h('span', [slots.activator?.({ props: {} }), slots.default?.()]) + }, +}) + +const ProgressDialogStub = defineComponent({ + name: 'ProgressDialog', + props: { + text: String, + value: Number, + }, + setup(props) { + return () => h('div', { 'data-testid': 'transfer-progress' }, `${props.text}:${props.value}`) + }, +}) + +const MediaIdSelectorStub = defineComponent({ + name: 'MediaIdSelector', + emits: ['close', 'select', 'update:modelValue'], + setup(_props, { emit }) { + return () => + h( + 'button', + { + onClick: () => { + emit('update:modelValue', '600') + emit('select', { type: 'tv' }) + emit('close') + }, + type: 'button', + }, + '选择电视剧', + ) + }, +}) + +function createFileItem(overrides: Partial = {}): FileItem { + return { + name: 'Movie.mkv', + path: '/downloads/Movie.mkv', + storage: 'local', + type: 'file', + ...overrides, + } +} + +function createDeferred() { + let reject!: (reason?: unknown) => void + let resolve!: (value: T) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, reject, resolve } +} + +function publicSettingHandlers({ + directories = [], + episodeRules = [], + storages = [], +}: { + directories?: TransferDirectoryConf[] + episodeRules?: unknown[] + storages?: StorageConf[] +} = {}) { + return [ + http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () => { + initializationRequestCount += 1 + return HttpResponse.json({ data: { value: directories }, success: true }) + }), + http.get(new URL('system/setting/public/Storages', API_BASE_URL).href, () => { + initializationRequestCount += 1 + return HttpResponse.json({ data: { value: storages }, success: true }) + }), + http.get(new URL('system/setting/public/EpisodeFormatRuleTable', API_BASE_URL).href, () => { + initializationRequestCount += 1 + return HttpResponse.json({ data: { value: episodeRules }, success: true }) + }), + http.post(new URL('transfer/manual/history', API_BASE_URL).href, () => { + initializationRequestCount += 1 + return HttpResponse.json({ data: { history_count: 0, reorganize: false }, success: true }) + }), + ] +} + +async function renderDialog({ + directories = [], + episodeRules = [], + items, + logids, + onClose = vi.fn(), + onDone = vi.fn(), + storages = [], +}: { + directories?: TransferDirectoryConf[] + episodeRules?: unknown[] + items?: FileItem[] + logids?: number[] + onClose?: ReturnType + onDone?: ReturnType + storages?: StorageConf[] +} = {}) { + const resolvedItems = items ?? (logids?.length ? [] : [createFileItem()]) + server.use(...publicSettingHandlers({ directories, episodeRules, storages })) + const result = await renderWithProviders(ReorganizeDialog, { + global: { + stubs: { + IconBtn: IconButtonStub, + MediaIdSelector: MediaIdSelectorStub, + ProgressDialog: ProgressDialogStub, + VCombobox: SelectStub, + VDialog: DialogStub, + VDialogCloseBtn: DialogCloseButtonStub, + VSelect: SelectStub, + VSwitch: SwitchStub, + VTextField: TextFieldStub, + VTooltip: TooltipStub, + }, + }, + initialState: { + globalSettings: { + data: { + RECOGNIZE_SOURCE: 'themoviedb', + }, + }, + }, + props: { + items: resolvedItems, + logids, + modelValue: true, + onClose, + onDone, + }, + }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /立即整理|重新整理/ })).toBeInTheDocument() + }) + await waitFor(() => { + expect(initializationRequestCount).toBe(logids?.length || !resolvedItems.length ? 3 : 4) + }) + + return { ...result, onClose, onDone } +} + +function previewResponse( + items: Array<{ + message?: string + source: string + success: boolean + target: string + title?: string + type?: string + }>, + message = '', +) { + return { + data: { + items, + message, + summary: { + failed: items.filter(item => !item.success).length, + success: items.filter(item => item.success).length, + total: items.length, + }, + }, + success: true, + } +} + +async function selectOption(label: string, index: number) { + await fireEvent.change(screen.getByLabelText(label), { target: { value: String(index) } }) +} + +describe('ReorganizeDialog submission safety', () => { + beforeEach(() => { + vi.clearAllMocks() + initializationRequestCount = 0 + mocks.progressControllers.length = 0 + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('keeps the dialog open when the backend reports a business failure', async () => { + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, () => + HttpResponse.json({ message: '整理失败', success: false }), + ), + ) + const user = userEvent.setup() + const { onDone } = await renderDialog() + + await user.click(screen.getByRole('button', { name: '立即整理' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('整理失败')) + expect(onDone).not.toHaveBeenCalled() + }) + + it('shows a fallback error when a business failure has no message', async () => { + server.use(http.post(new URL('transfer/manual', API_BASE_URL).href, () => HttpResponse.json({ success: false }))) + const user = userEvent.setup() + const { onDone } = await renderDialog() + + await user.click(screen.getByRole('button', { name: '立即整理' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('整理请求失败')) + expect(onDone).not.toHaveBeenCalled() + }) + + it('keeps the dialog open when the transfer request fails over HTTP', async () => { + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, () => + HttpResponse.json({ detail: 'server error' }, { status: 500 }), + ), + ) + const user = userEvent.setup() + const { onDone } = await renderDialog() + + await user.click(screen.getByRole('button', { name: '立即整理' })) + + await waitFor(() => expect(screen.queryByTestId('transfer-progress')).not.toBeInTheDocument()) + expect(onDone).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledTimes(1) + }) + + it('coalesces repeated submit clicks while a transfer request is pending', async () => { + const response = createDeferred() + let requestCount = 0 + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async () => { + requestCount += 1 + return HttpResponse.json(await response.promise) + }), + ) + const { onDone } = await renderDialog() + const submitButton = screen.getByRole('button', { name: '立即整理' }) + + await fireEvent.click(submitButton) + await fireEvent.click(submitButton) + + await waitFor(() => expect(requestCount).toBe(1)) + response.resolve({ data: undefined, success: true }) + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)) + }) + + it('does not submit or finish when log ids and file items are both empty', async () => { + let requestCount = 0 + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, () => { + requestCount += 1 + return HttpResponse.json({ success: true }) + }), + ) + const user = userEvent.setup() + const { onDone } = await renderDialog({ items: [], logids: [] }) + + await user.click(screen.getByRole('button', { name: '立即整理' })) + + expect(requestCount).toBe(0) + expect(onDone).not.toHaveBeenCalled() + }) +}) + +describe('ReorganizeDialog payloads and lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + initializationRequestCount = 0 + mocks.progressControllers.length = 0 + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('deduplicates selected files and submits nullable automatic target fields in one background request', async () => { + const bodies: unknown[] = [] + const backgrounds: string[] = [] + const first = createFileItem({ name: 'Episode 1.mkv', path: '/downloads/Episode 1.mkv' }) + const second = createFileItem({ name: 'Episode 2.mkv', path: '/downloads/Episode 2.mkv' }) + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + backgrounds.push(new URL(request.url).searchParams.get('background') ?? '') + return HttpResponse.json({ success: true }) + }), + ) + const user = userEvent.setup() + const { onDone } = await renderDialog({ items: [first, first, second] }) + + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)) + expect(backgrounds).toEqual(['true']) + expect(bodies).toEqual([ + expect.objectContaining({ + episode_group: null, + fileitems: [first, second], + media_id: null, + media_source: 'themoviedb', + target_path: null, + target_storage: null, + transfer_type: null, + }), + ]) + expect(bodies[0]).not.toHaveProperty('fileitem') + expect(mocks.progressControllers).toHaveLength(0) + expect(mocks.toastSuccess).toHaveBeenCalledWith('文件 共 2 项 已加入整理队列!') + }) + + it('submits mixed file and directory items separately and rotates synchronous progress streams', async () => { + const bodies: unknown[] = [] + const directory = createFileItem({ name: 'Series', path: '/downloads/Series', type: 'dir' }) + const file = createFileItem({ name: 'Movie.mkv', path: '/downloads/Movie.mkv' }) + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ success: true }) + }), + ) + const user = userEvent.setup() + const { onDone } = await renderDialog({ items: [directory, file] }) + + await user.click(screen.getByRole('button', { name: '立即整理' })) + + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)) + expect(bodies).toEqual([ + expect.objectContaining({ fileitem: directory, logid: 0 }), + expect.objectContaining({ fileitem: file, logid: 0 }), + ]) + expect(mocks.progressControllers).toHaveLength(2) + expect(mocks.progressControllers[0].key).toBe('reorganize-progress-filetransfer') + expect(mocks.progressControllers[0].start).toHaveBeenCalledTimes(1) + expect(mocks.progressControllers[0].stop).toHaveBeenCalledTimes(1) + expect(mocks.progressControllers[1].start).toHaveBeenCalledTimes(1) + expect(mocks.progressControllers[1].stop).toHaveBeenCalledTimes(1) + }) + + it('submits each historical record with reorganize semantics', async () => { + const bodies: unknown[] = [] + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ success: true }) + }), + ) + const user = userEvent.setup() + const { onDone } = await renderDialog({ logids: [41, 42] }) + + expect(screen.getByRole('button', { name: '重新整理' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)) + expect(bodies).toEqual([ + expect.objectContaining({ + fileitem: {}, + from_history: false, + logid: 41, + reorganize: true, + target_path: null, + target_storage: null, + }), + expect.objectContaining({ + fileitem: {}, + from_history: false, + logid: 42, + reorganize: true, + target_path: null, + target_storage: null, + }), + ]) + expect(mocks.toastSuccess).toHaveBeenNthCalledWith(1, '历史记录 41 已加入整理队列!') + expect(mocks.toastSuccess).toHaveBeenNthCalledWith(2, '历史记录 42 已加入整理队列!') + }) + + it('updates synchronous progress from SSE and always stops it after success', async () => { + const response = createDeferred() + let payload: unknown + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + payload = await request.json() + return HttpResponse.json(await response.promise) + }), + ) + const { onDone } = await renderDialog() + + await fireEvent.click(screen.getByRole('button', { name: '立即整理' })) + await waitFor(() => expect(mocks.progressControllers).toHaveLength(1)) + mocks.progressControllers[0].handler( + new MessageEvent('message', { + data: JSON.stringify({ text: 'fallback', text_i18n: '正在写入媒体库', value: 65 }), + }), + ) + await waitFor(() => expect(screen.getByTestId('transfer-progress')).toHaveTextContent('正在写入媒体库:65')) + + response.resolve({ data: undefined, success: true }) + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)) + expect(payload).toEqual( + expect.objectContaining({ + fileitems: [expect.objectContaining({ path: '/downloads/Movie.mkv' })], + target_path: null, + target_storage: null, + }), + ) + expect(mocks.progressControllers[0].stop).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId('transfer-progress')).not.toBeInTheDocument() + }) + + it('stops the active progress stream when unmounted during a request', async () => { + const response = createDeferred() + const requestCompleted = createDeferred() + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async () => { + const body = await response.promise + requestCompleted.resolve() + return HttpResponse.json(body) + }), + ) + const { unmount } = await renderDialog() + + await fireEvent.click(screen.getByRole('button', { name: '立即整理' })) + await waitFor(() => expect(mocks.progressControllers).toHaveLength(1)) + unmount() + + expect(mocks.progressControllers[0].stop).toHaveBeenCalledTimes(1) + response.resolve({ data: undefined, success: true }) + await requestCompleted.promise + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + it('applies a configured library target and can reset every derived field back to automatic', async () => { + const bodies: unknown[] = [] + const directory: TransferDirectoryConf = { + library_category_folder: true, + library_path: '/library/tv', + library_storage: 'rclone', + library_type_folder: true, + name: '电视剧目录', + priority: 1, + scraping: true, + storage: 'local', + transfer_type: 'copy', + } + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ success: true }) + }), + ) + const user = userEvent.setup() + await renderDialog({ + directories: [directory], + storages: [{ name: '远程存储', type: 'rclone' }], + }) + + expect(screen.getByRole('option', { name: '远程存储' })).toBeInTheDocument() + await selectOption('目的路径', 1) + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + await waitFor(() => expect(bodies).toHaveLength(1)) + + expect(bodies[0]).toEqual( + expect.objectContaining({ + library_category_folder: true, + library_type_folder: true, + scrape: true, + target_path: '/library/tv', + target_storage: 'rclone', + transfer_type: 'copy', + }), + ) + + await selectOption('目的路径', 0) + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + await waitFor(() => expect(bodies).toHaveLength(2)) + expect(bodies[1]).toEqual( + expect.objectContaining({ + library_category_folder: null, + library_type_folder: null, + scrape: null, + target_path: null, + target_storage: null, + transfer_type: null, + }), + ) + }) + + it('submits media selection, episode group, episode formatting, and folder options from the form', async () => { + const bodies: unknown[] = [] + server.use( + http.get(new URL('media/groups/600', API_BASE_URL).href, () => + HttpResponse.json([{ episode_count: 12, group_count: 1, id: 'group-1', name: '播出顺序' }]), + ), + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ success: true }) + }), + ) + const user = userEvent.setup() + await renderDialog() + + await user.click(screen.getByRole('button', { name: '查询媒体编号' })) + await user.click(screen.getByRole('button', { name: '选择电视剧' })) + await waitFor(() => expect(screen.getByRole('option', { name: '播出顺序' })).toBeInTheDocument()) + await selectOption('类型', 2) + await selectOption('剧集组', 1) + await selectOption('季', 2) + await fireEvent.input(screen.getByLabelText('集'), { target: { value: '1-3' } }) + await fireEvent.input(screen.getByLabelText('集数定位'), { target: { value: '{season_episode}' } }) + await fireEvent.input(screen.getByLabelText('集数偏移'), { target: { value: 'EP+1' } }) + await fireEvent.input(screen.getByLabelText('指定Part'), { target: { value: 'part2' } }) + await fireEvent.input(screen.getByLabelText('最小文件大小(MB)'), { target: { value: '128' } }) + await user.click(screen.getByLabelText('按类型分类')) + await user.click(screen.getByLabelText('按类别分类')) + await user.click(screen.getByLabelText('刮削元数据')) + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + + await waitFor(() => expect(bodies).toHaveLength(1)) + expect(bodies[0]).toEqual( + expect.objectContaining({ + episode_detail: '1-3', + episode_format: '{season_episode}', + episode_group: 'group-1', + episode_offset: 'EP+1', + episode_part: 'part2', + library_category_folder: true, + library_type_folder: true, + media_id: '600', + media_source: 'themoviedb', + min_filesize: 128, + scrape: true, + season: 2, + type_name: '电视剧', + }), + ) + + await selectOption('数据源', 1) + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + await waitFor(() => expect(bodies).toHaveLength(2)) + expect(bodies[1]).toEqual( + expect.objectContaining({ + episode_group: null, + media_id: null, + media_source: 'douban', + }), + ) + }) + + it('recommends an episode format and includes it in the next request', async () => { + const recommendationBodies: unknown[] = [] + const transferBodies: unknown[] = [] + server.use( + http.post(new URL('transfer/episode-format/recommend', API_BASE_URL).href, async ({ request }) => { + recommendationBodies.push(await request.json()) + return HttpResponse.json({ + data: { + episode_format: '{season_episode}', + message: '已匹配', + pattern: 'S(\\d+)E(\\d+)', + rule_name: '标准季集', + sample_file: 'Movie.mkv', + }, + success: true, + }) + }), + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + transferBodies.push(await request.json()) + return HttpResponse.json({ success: true }) + }), + ) + const user = userEvent.setup() + await renderDialog({ episodeRules: [{ name: '标准季集' }] }) + + await selectOption('类型', 2) + await user.click(screen.getByRole('button', { name: 'mdi-auto-fix' })) + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('已生成集数定位模板')) + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + + expect(recommendationBodies).toEqual([ + { + fileitem: expect.objectContaining({ path: '/downloads/Movie.mkv' }), + }, + ]) + await waitFor(() => expect(transferBodies).toHaveLength(1)) + expect(transferBodies[0]).toEqual(expect.objectContaining({ episode_format: '{season_episode}' })) + }) + + it('emits close and submits the historical recognition preference', async () => { + const bodies: unknown[] = [] + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ success: true }) + }), + ) + const user = userEvent.setup() + const { onClose } = await renderDialog({ logids: [41] }) + + await user.click(screen.getByLabelText('复用历史识别信息')) + await user.click(screen.getByRole('button', { name: '关闭' })) + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + + expect(onClose).toHaveBeenCalledTimes(1) + await waitFor(() => expect(bodies).toHaveLength(1)) + expect(bodies[0]).toEqual(expect.objectContaining({ from_history: true, logid: 41 })) + }) +}) + +describe('ReorganizeDialog preview', () => { + beforeEach(() => { + vi.clearAllMocks() + initializationRequestCount = 0 + mocks.progressControllers.length = 0 + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('keeps partial preview failures as successful response data', async () => { + const payloads: unknown[] = [] + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + payloads.push(await request.json()) + return HttpResponse.json( + previewResponse( + [ + { + source: '/downloads/Episode 1.mkv', + success: true, + target: '/library/Series/S01E01.mkv', + title: 'Series', + type: '电视剧', + }, + { + message: '未识别到集数', + source: '/downloads/Episode 2.mkv', + success: false, + target: '', + title: 'Series', + type: '电视剧', + }, + ], + '部分文件无法整理', + ), + ) + }), + ) + const user = userEvent.setup() + const { onDone } = await renderDialog({ + items: [ + createFileItem({ name: 'Episode 1.mkv', path: '/downloads/Episode 1.mkv' }), + createFileItem({ name: 'Episode 2.mkv', path: '/downloads/Episode 2.mkv' }), + ], + }) + + await user.click(screen.getByRole('button', { name: '预览' })) + + expect(await screen.findByText('部分文件无法整理')).toBeInTheDocument() + expect(screen.getByText('总数 2')).toBeInTheDocument() + expect(screen.getByText('成功 1')).toBeInTheDocument() + expect(screen.getByText('失败 1')).toBeInTheDocument() + expect(screen.getByText('未识别到集数')).toBeInTheDocument() + expect(payloads).toEqual([ + expect.objectContaining({ + fileitems: expect.arrayContaining([ + expect.objectContaining({ path: '/downloads/Episode 1.mkv' }), + expect.objectContaining({ path: '/downloads/Episode 2.mkv' }), + ]), + preview: true, + }), + ]) + expect(mocks.toastWarning).toHaveBeenCalledWith('成功 1,失败 1') + expect(mocks.toastError).not.toHaveBeenCalled() + expect(onDone).not.toHaveBeenCalled() + }) + + it('merges and deduplicates preview results from separate source requests', async () => { + const payloads: Array> = [] + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + const payload = (await request.json()) as Record + payloads.push(payload) + const source = (payload.fileitem as FileItem).path + return HttpResponse.json( + previewResponse([ + { + source: '/downloads/shared.nfo', + success: true, + target: '/library/shared.nfo', + }, + { + source, + success: true, + target: `/library/${(payload.fileitem as FileItem).name}`, + }, + ]), + ) + }), + ) + const user = userEvent.setup() + await renderDialog({ + items: [ + createFileItem({ name: 'Series', path: '/downloads/Series', type: 'dir' }), + createFileItem({ name: 'Movie.mkv', path: '/downloads/Movie.mkv' }), + ], + }) + + await user.click(screen.getByRole('button', { name: '预览' })) + + expect(await screen.findByText('总数 3')).toBeInTheDocument() + expect(screen.getByText('成功 3')).toBeInTheDocument() + expect(screen.getByText('失败 0')).toBeInTheDocument() + expect(screen.getAllByText('shared.nfo')).toHaveLength(2) + expect(payloads).toHaveLength(2) + expect(payloads).toEqual([ + expect.objectContaining({ fileitem: expect.objectContaining({ type: 'dir' }), preview: true }), + expect.objectContaining({ fileitem: expect.objectContaining({ type: 'file' }), preview: true }), + ]) + }) + + it('summarizes a business-level preview failure without closing the preview', async () => { + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, () => + HttpResponse.json({ message: '目标目录不可用', success: false }), + ), + ) + const user = userEvent.setup() + await renderDialog() + + await user.click(screen.getByRole('button', { name: '预览' })) + + expect(await screen.findByText('总数 1')).toBeInTheDocument() + expect(screen.getByText('成功 0')).toBeInTheDocument() + expect(screen.getByText('失败 1')).toBeInTheDocument() + expect(screen.getAllByText('目标目录不可用')).toHaveLength(2) + expect(mocks.toastWarning).toHaveBeenCalledWith('成功 0,失败 1') + }) + + it('does not treat empty log ids and file items as a successful empty preview', async () => { + let requestCount = 0 + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, () => { + requestCount += 1 + return HttpResponse.json(previewResponse([])) + }), + ) + const user = userEvent.setup() + await renderDialog({ items: [], logids: [] }) + + await user.click(screen.getByRole('button', { name: '预览' })) + + expect(requestCount).toBe(0) + expect(screen.queryByText('总数 0')).not.toBeInTheDocument() + expect(screen.queryByText('成功 0')).not.toBeInTheDocument() + expect(screen.queryByText('失败 0')).not.toBeInTheDocument() + }) + + it('pages through long preview results and can collapse the preview again', async () => { + const items = Array.from({ length: 21 }, (_, index) => ({ + source: `/downloads/Episode ${index + 1}.mkv`, + success: true, + target: `/library/Episode ${index + 1}.mkv`, + })) + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, () => HttpResponse.json(previewResponse(items))), + ) + const user = userEvent.setup() + const { container } = await renderDialog() + + await user.click(screen.getByRole('button', { name: '预览' })) + expect(await screen.findByText('1 / 2')).toBeInTheDocument() + const paginationButtons = container.querySelectorAll('.reorganize-preview-pane__pagination button') + expect(paginationButtons).toHaveLength(2) + + await fireEvent.click(paginationButtons[1]) + expect(await screen.findByText('2 / 2')).toBeInTheDocument() + expect(screen.getAllByText('Episode 21.mkv')).toHaveLength(2) + + await fireEvent.click(paginationButtons[0]) + expect(await screen.findByText('1 / 2')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '预览' })) + expect(screen.getByRole('button', { name: '预览' })).not.toHaveClass('reorganize-action-btn--active') + }) +}) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 5625b346..0536c8c5 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -3175,6 +3175,7 @@ export default { previewResult: 'Preview', previewLoading: 'Generating preview result...', previewRequestFailed: 'Preview request failed', + transferRequestFailed: 'Organization request failed', previewTotal: 'Total {count}', previewSuccess: 'Success {count}', previewFailed: 'Failed {count}', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 5fed9c42..91474f70 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -3118,6 +3118,7 @@ export default { previewResult: '预览', previewLoading: '正在生成预览结果...', previewRequestFailed: '预览请求失败', + transferRequestFailed: '整理请求失败', previewTotal: '总数 {count}', previewSuccess: '成功 {count}', previewFailed: '失败 {count}', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 56bbfd06..a9c3ea17 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -3117,6 +3117,7 @@ export default { previewResult: '預覽', previewLoading: '正在生成預覽結果...', previewRequestFailed: '預覽請求失敗', + transferRequestFailed: '整理請求失敗', previewTotal: '總數 {count}', previewSuccess: '成功 {count}', previewFailed: '失敗 {count}', diff --git a/vite.config.ts b/vite.config.ts index ed7a5e10..f35c99bc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -323,6 +323,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/components/dialog/SiteUserDataDialog.vue', 'src/components/dialog/AddDownloadDialog.vue', 'src/components/dialog/AddSubtitleDownloadDialog.vue', + 'src/components/dialog/ReorganizeDialog.vue', 'src/views/discover/TheMovieDbView.vue', 'src/views/discover/DoubanView.vue', 'src/views/discover/BangumiView.vue', @@ -595,6 +596,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 80, statements: 80, }, + 'src/components/dialog/ReorganizeDialog.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/views/discover/TheMovieDbView.vue': { branches: 75, functions: 80,