From eaf6b5427c8e8f286ba2e995ef8bc79199f86429 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:39:48 +0800 Subject: [PATCH] test(history): cover transfer history workflows (#613) --- src/locales/en-US.ts | 4 + src/locales/zh-CN.ts | 4 + src/locales/zh-TW.ts | 4 + src/views/reorganize/TransferHistoryView.vue | 98 ++- .../__tests__/TransferHistoryView.spec.ts | 824 ++++++++++++++++++ vite.config.ts | 7 + 6 files changed, 915 insertions(+), 26 deletions(-) create mode 100644 src/views/reorganize/__tests__/TransferHistoryView.spec.ts diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 46d3b1ca..5625b346 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -3660,6 +3660,10 @@ export default { listMode: 'List Mode', deleteConfirm: 'Confirm delete {title} {seasons}{episodes}?', deleteConfirmBatch: 'Confirm delete {count} records?', + deleteFailed: 'Failed to delete: {message}', + deleteRequestFailed: 'Failed to delete: Request failed', + batchDeleteFailed: 'Failed to delete {failed} of {total} records', + deleting: 'Deleting {name}...', deleteRecordOnly: 'Delete Record Only', deleteSourceOnly: 'Delete Record and Source File', deleteDestOnly: 'Delete Record and Media Library File', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 3ff19913..5fed9c42 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -3598,6 +3598,10 @@ export default { listMode: '列表模式', deleteConfirm: '确认删除 {title} {seasons}{episodes} ?', deleteConfirmBatch: '确认删除 {count} 条记录 ?', + deleteFailed: '删除失败:{message}', + deleteRequestFailed: '删除失败:请求异常', + batchDeleteFailed: '删除失败:{failed}/{total}', + deleting: '正在删除 {name}...', deleteRecordOnly: '仅删除转移记录', deleteSourceOnly: '删除转移记录和源文件', deleteDestOnly: '删除转移记录和媒体库文件', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 825d256d..56bbfd06 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -3595,6 +3595,10 @@ export default { listMode: '列表模式', deleteConfirm: '確認刪除 {title} {seasons}{episodes}?', deleteConfirmBatch: '確認刪除 {count} 條記錄?', + deleteFailed: '刪除失敗:{message}', + deleteRequestFailed: '刪除失敗:請求異常', + batchDeleteFailed: '刪除失敗:{failed}/{total}', + deleting: '正在刪除 {name}...', deleteRecordOnly: '僅刪除轉移記錄', deleteSourceOnly: '刪除轉移記錄和源文件', deleteDestOnly: '刪除轉移記錄和媒體庫文件', diff --git a/src/views/reorganize/TransferHistoryView.vue b/src/views/reorganize/TransferHistoryView.vue index 791487cd..c3175b8d 100644 --- a/src/views/reorganize/TransferHistoryView.vue +++ b/src/views/reorganize/TransferHistoryView.vue @@ -6,8 +6,7 @@ import type { StorageConf, TransferHistory } from '@/api/types' import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue' import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue' import ProgressDialog from '@/components/dialog/ProgressDialog.vue' -import { useRoute } from 'vue-router' -import router from '@/router' +import { useRoute, useRouter } from 'vue-router' import { useDisplay } from 'vuetify' import { formatFileSize } from '@/@core/utils/formatters' import { useI18n } from 'vue-i18n' @@ -48,6 +47,7 @@ const $toast = useToast() // 路由 const route = useRoute() +const router = useRouter() const userStore = useUserStore() const canManage = computed(() => hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'), @@ -55,6 +55,7 @@ const canManage = computed(() => let syncingRouteQuery = false let fetchDataRequestSeed = 0 let mobileFetchDataRequestSeed = 0 +let componentUnmounted = false // 组合式输入法状态 const isComposing = ref(false) @@ -396,9 +397,9 @@ const debouncedReloadSearchPage = debounce(() => { void reloadPage(true) }, 1000) -// 延迟刷新移动端无限列表,输入完成后再从第一页重新加载。 +// 延迟同步移动端搜索参数,路由监听会按新查询重置无限列表。 const debouncedReloadMobileSearchPage = debounce(() => { - resetMobileHistory() + void reloadMobileSearchPage() }, 600) // 切换页签 @@ -499,8 +500,15 @@ function updateSearchHintList(list: TransferHistory[]) { ) } +interface MobileHistoryResetOptions { + /** 是否保持批量模式,通常用于失败项重试。 */ + preserveBatchMode?: boolean + /** 重置分页时仍需保持选中的记录。 */ + selectedItems?: TransferHistory[] +} + // 重置移动端无限列表,让 VInfiniteScroll 从第一页重新触发加载。 -function resetMobileHistory() { +function resetMobileHistory(options: MobileHistoryResetOptions = {}) { mobileFetchDataRequestSeed++ mobileDataList.value = [] mobileCurrentPage.value = 1 @@ -509,8 +517,8 @@ function resetMobileHistory() { isRefreshed.value = false totalItems.value = 0 mobileExpandedPathIds.value = [] - selected.value = [] - mobileBatchMode.value = false + selected.value = options.selectedItems ?? [] + mobileBatchMode.value = Boolean(options.preserveBatchMode && selected.value.length > 0) mobileInfiniteKey.value++ } @@ -584,6 +592,10 @@ function appendMobileHistory(list: TransferHistory[], total: number) { const newItems = list.filter(item => !existingIds.has(item.id)) mobileDataList.value = [...mobileDataList.value, ...newItems] + if (selected.value.length > 0) { + const refreshedItems = new Map(mobileDataList.value.map(item => [item.id, item])) + selected.value = selected.value.map(item => refreshedItems.get(item.id) ?? item) + } mobileCurrentPage.value++ mobileHasMore.value = mobileDataList.value.length < total && list.length >= mobilePageSize updateSearchHintList(mobileDataList.value) @@ -619,9 +631,12 @@ async function refreshDataFromRouteQuery(options: { silent?: boolean } = {}) { } // 操作完成后刷新列表;如果当前页被删空,则跳回最后一个有效页。 -async function refreshDataAfterOperation() { +async function refreshDataAfterOperation(mobileSelection: TransferHistory[] = []) { if (isMobile.value) { - resetMobileHistory() + resetMobileHistory({ + preserveBatchMode: mobileSelection.length > 0, + selectedItems: mobileSelection, + }) return } @@ -632,7 +647,6 @@ async function refreshDataAfterOperation() { if (currentPage.value <= lastAvailablePage) return await router.replace(createHistoryUrl(false, lastAvailablePage)) - await refreshDataFromRouteQuery() } // 根据 type 返回不同的图标 @@ -666,7 +680,7 @@ async function removeHistory(item: TransferHistory) { } // 调用API删除记录 -async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean) { +async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean, notifyError = true) { try { // 调用删除API const result: { @@ -675,9 +689,19 @@ async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boo data: item, }) - if (!result.success) $toast.error(`删除失败: ${result.message}`) + if (!result.success) { + if (notifyError) { + $toast.error(t('transferHistory.deleteFailed', { message: result.message || '' })) + } + return false + } + return true } catch (error) { console.error(error) + if (notifyError) { + $toast.error(t('transferHistory.deleteRequestFailed')) + } + return false } } @@ -704,27 +728,36 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) { // 已处理条数 let handled = 0 + const failedItems: TransferHistory[] = [] // 显示进度条 openProgressDialog() // 循环调用removeHistory for (const item of selected.value) { // 开始删除 - progressText.value = `正在删除 ${item.title} ${item.seasons}${item.episodes} ...` - await remove(item, deleteSrc, deleteDest) + const seasonEpisode = `${item.seasons || ''}${item.episodes || ''}` + const name = [item.title, seasonEpisode].filter(Boolean).join(' ') + progressText.value = t('transferHistory.deleting', { name }) + const success = await remove(item, deleteSrc, deleteDest, false) + if (!success) { + failedItems.push(item) + } // 删除完成 handled++ progressValue.value = (handled / total) * 100 progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value }) } - // 清空选中项 - selected.value = [] - if (isMobile.value) { + // 失败项保持选中,方便用户修正条件后重试。 + selected.value = failedItems + if (isMobile.value && failedItems.length === 0) { mobileBatchMode.value = false } // 隐藏进度条 closeProgressDialog() + if (failedItems.length > 0) { + $toast.error(t('transferHistory.batchDeleteFailed', { failed: failedItems.length, total })) + } // 重新获取数据 - await refreshDataAfterOperation() + await refreshDataAfterOperation(failedItems) } // 响应删除操作 @@ -865,6 +898,7 @@ async function triggerAiRedo(item: TransferHistory) { let progressStarted = false try { const result: { [key: string]: any } = await api.post(`history/transfer/${item.id}/ai-redo`) + if (componentUnmounted) return const progressKey = result.data?.progress_key @@ -876,7 +910,9 @@ async function triggerAiRedo(item: TransferHistory) { progressStarted = true } catch (error) { console.error(error) - $toast.error(t('transferHistory.aiRedoFailed')) + if (!componentUnmounted) { + $toast.error(t('transferHistory.aiRedoFailed')) + } } finally { if (!progressStarted) { aiRedoIds.value = aiRedoIds.value.filter(id => id !== item.id) @@ -901,6 +937,7 @@ async function triggerBatchAiRedo() { const result: { [key: string]: any } = await api.post('history/transfer/ai-redo', { history_ids: historyIds, }) + if (componentUnmounted) return const progressKey = result.data?.progress_key const acceptedIds = (result.data?.history_ids as number[] | undefined) ?? historyIds @@ -917,7 +954,9 @@ async function triggerBatchAiRedo() { progressStarted = true } catch (error) { console.error(error) - $toast.error(t('transferHistory.aiRedoFailed')) + if (!componentUnmounted) { + $toast.error(t('transferHistory.aiRedoFailed')) + } } finally { if (!progressStarted) { aiRedoIds.value = aiRedoIds.value.filter(id => !historyIds.includes(id)) @@ -993,6 +1032,11 @@ async function reloadPage(resetPage = false) { await router.push(createHistoryUrl(resetPage)) } +// 移动端搜索同样以 URL 为持久事实源,刷新和断点切换后可恢复同一查询。 +async function reloadMobileSearchPage() { + await router.push(createHistoryUrl(true)) +} + // 确保值为number类型 function ensureNumber(value: any, defaultValue: number = 0) { value = Number(value) @@ -1354,6 +1398,7 @@ onActivated(() => { }) onUnmounted(() => { + componentUnmounted = true debouncedReloadPage.cancel() debouncedReloadSearchPage.cancel() debouncedReloadMobileSearchPage.cancel() @@ -1704,14 +1749,15 @@ onUnmounted(() => { - +
diff --git a/src/views/reorganize/__tests__/TransferHistoryView.spec.ts b/src/views/reorganize/__tests__/TransferHistoryView.spec.ts new file mode 100644 index 00000000..58349a41 --- /dev/null +++ b/src/views/reorganize/__tests__/TransferHistoryView.spec.ts @@ -0,0 +1,824 @@ +import type { TransferHistory } from '@/api/types' +import i18n from '@/plugins/i18n' +import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { flushPromises } from '@vue/test-utils' +import { computed, defineComponent, h, nextTick, ref, unref, type PropType } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), + apiPost: vi.fn(), + appMode: false, + desktop: true, + dynamicButtonConfig: undefined as Record | undefined, + openSharedDialog: vi.fn(), + progressCallback: undefined as ((event: MessageEvent) => unknown) | undefined, + progressStart: vi.fn(), + progressStop: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: { + delete: (...args: unknown[]) => mocks.apiDelete(...args), + get: (...args: unknown[]) => mocks.apiGet(...args), + post: (...args: unknown[]) => mocks.apiPost(...args), + }, +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ error: mocks.toastError }), +})) + +vi.mock('vuetify', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + useDisplay: () => ({ + mdAndUp: computed(() => mocks.desktop), + smAndDown: computed(() => !mocks.desktop), + }), + } +}) + +vi.mock('@/composables/usePWA', () => ({ + usePWA: () => ({ appMode: computed(() => mocks.appMode) }), +})) + +vi.mock('@/composables/useAvailableHeight', () => ({ + useAvailableHeight: () => ({ availableHeight: ref(600) }), +})) + +vi.mock('@/composables/useBackground', () => ({ + useBackground: () => ({ + useProgressSSE: (_url: string, callback: (event: MessageEvent) => unknown) => { + mocks.progressCallback = callback + return { + start: mocks.progressStart, + stop: mocks.progressStop, + } + }, + }), +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +vi.mock('@/composables/useDynamicButton', () => ({ + useDynamicButton: (config: Record) => { + mocks.dynamicButtonConfig = config + }, +})) + +const HistoryTableStub = defineComponent({ + name: 'VDataTableVirtual', + props: { + headers: { + type: Array as PropType< + Array<{ + key: string + sortRaw?: (left: TransferHistory, right: TransferHistory) => number + }> + >, + default: () => [], + }, + items: { + type: Array as PropType, + default: () => [], + }, + modelValue: { + type: Array as PropType, + default: () => [], + }, + }, + emits: ['update:modelValue'], + setup(props, { emit, slots }) { + return () => { + const sortResults = + props.items.length >= 2 + ? Object.fromEntries( + props.headers + .filter(header => header.sortRaw) + .map(header => [header.key, header.sortRaw?.(props.items[0], props.items[1])]), + ) + : {} + + return h('section', { 'aria-label': '整理历史桌面列表' }, [ + h('output', { 'aria-label': '整理历史排序结果' }, JSON.stringify(sortResults)), + ...props.items.map(item => + h('article', { 'data-history-id': item.id }, [h('span', item.title), slots['item.actions']?.({ item })]), + ), + h( + 'button', + { + onClick: () => emit('update:modelValue', props.items), + type: 'button', + }, + '选择当前页', + ), + ]) + } + }, +}) + +type InfiniteStatus = 'empty' | 'error' | 'ok' + +const InfiniteScrollStub = defineComponent({ + name: 'VInfiniteScroll', + emits: ['load'], + setup(_props, { emit, slots }) { + const status = ref('idle') + function load() { + emit('load', { + done(nextStatus: InfiniteStatus) { + status.value = nextStatus + }, + }) + } + return () => + h('section', { 'aria-label': '整理历史无限列表' }, [ + h('output', { 'aria-label': '整理历史无限列表状态' }, status.value), + slots.default?.(), + h('button', { onClick: load, type: 'button' }, '加载下一页'), + ]) + }, +}) + +const ProgressiveGridStub = defineComponent({ + name: 'ProgressiveCardGrid', + props: { + getItemKey: { + type: Function as PropType<(item: TransferHistory) => number>, + required: true, + }, + items: { + type: Array as PropType, + default: () => [], + }, + }, + setup(props, { slots }) { + return () => { + const candidates = + props.items.length <= 7 ? props.items : [...props.items.slice(0, 3), props.items[24], ...props.items.slice(-3)] + const renderedItems = [...new Map(candidates.map(item => [item.id, item])).values()] + + return h( + 'section', + { 'aria-label': '整理历史移动列表' }, + renderedItems.flatMap(item => [ + h('output', { 'data-mobile-key': props.getItemKey(item) }, String(props.getItemKey(item))), + ...(slots.default?.({ item }) ?? []), + ]), + ) + } + }, +}) + +const SearchStub = defineComponent({ + name: 'VCombobox', + props: { + modelValue: { + type: String, + default: '', + }, + }, + emits: ['update:modelValue'], + setup(props, { attrs, emit }) { + return () => + h('input', { + ...attrs, + value: props.modelValue ?? '', + onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), + }) + }, +}) + +const PassthroughStub = defineComponent({ + inheritAttrs: false, + setup(_props, { attrs, slots }) { + return () => h('div', attrs, slots.default?.()) + }, +}) + +const EmptyStub = defineComponent({ + inheritAttrs: false, + setup() { + return () => h('div') + }, +}) + +const ListItemStub = defineComponent({ + name: 'VListItem', + inheritAttrs: false, + props: { + disabled: Boolean, + }, + emits: ['click'], + setup(props, { emit, slots }) { + return () => + h( + 'button', + { + disabled: props.disabled, + onClick: () => emit('click'), + type: 'button', + }, + [slots.prepend?.(), slots.default?.()], + ) + }, +}) + +const ListItemTitleStub = defineComponent({ + name: 'VListItemTitle', + inheritAttrs: false, + setup(_props, { attrs, slots }) { + return () => h('span', attrs, slots.default?.()) + }, +}) + +function createHistory(id: number, title: string, overrides: Partial = {}): TransferHistory { + return { + id, + title, + type: '电影', + status: true, + src: `/downloads/${title}.mkv`, + dest: `/media/${title}.mkv`, + src_storage: 'downloads', + dest_storage: 'library', + mode: 'link', + ...overrides, + } as TransferHistory +} + +function historyResponse(list: TransferHistory[], total = list.length) { + return { data: { list, total }, success: true } +} + +function storageResponse() { + return { data: { value: [] }, success: true } +} + +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +async function renderHistory(initialRoute = '/history') { + return renderWithProviders(TransferHistoryView, { + global: { + stubs: { + ProgressiveCardGrid: ProgressiveGridStub, + VCombobox: SearchStub, + VDataTableVirtual: HistoryTableStub, + VInfiniteScroll: InfiniteScrollStub, + IconBtn: PassthroughStub, + VList: PassthroughStub, + VListItem: ListItemStub, + VListItemTitle: ListItemTitleStub, + VMenu: PassthroughStub, + VPageContentTitle: true, + VPagination: EmptyStub, + VSelect: EmptyStub, + }, + }, + initialRoute, + initialState: { + globalSettings: { + data: { + AI_AGENT_ENABLE: true, + GLOBAL_IMAGE_CACHE: false, + }, + }, + user: { + permissions: ['manage'], + superUser: true, + }, + }, + }) +} + +function getDynamicMenuItems() { + const menuItems = mocks.dynamicButtonConfig?.menuItems + return unref(menuItems) as + | Array<{ + action: () => unknown + titleParams?: Record + titleKey: string + }> + | undefined +} + +function runDynamicAction(titleKey: string) { + const item = getDynamicMenuItems()?.find(menu => menu.titleKey === titleKey) + if (!item) throw new Error(`未注册动态按钮操作: ${titleKey}`) + return item.action() +} + +function getDialogCall(index = 0) { + const [component, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [ + { __name?: string; name?: string }, + Record, + Record unknown>, + Record, + ] + return { component, events, options, props } +} + +describe('TransferHistoryView', () => { + beforeEach(() => { + mocks.appMode = true + mocks.desktop = true + mocks.dynamicButtonConfig = undefined + mocks.progressCallback = undefined + mocks.apiDelete.mockResolvedValue({ success: true }) + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse([])) + }) + mocks.apiPost.mockResolvedValue({ data: { history_ids: [1], progress_key: 'progress-1' }, success: true }) + mocks.openSharedDialog.mockImplementation(() => ({ + close: vi.fn(), + id: 1, + updateProps: vi.fn(), + })) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('uses the desktop URL query as the request source and falls back from invalid pagination values', async () => { + const requests: Array> = [] + mocks.apiGet.mockImplementation((path: string, config?: { params?: Record }) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + requests.push(config?.params ?? {}) + return Promise.resolve(historyResponse([createHistory(1, '桌面结果')], 51)) + }) + + await renderHistory('/history?search=%E7%A7%91%E5%B9%BB&itemsPerPage=30¤tPage=-8&grouped=true') + + expect(await screen.findByText('桌面结果')).toBeInTheDocument() + expect(requests).toEqual([{ count: 50, page: 1, title: '科幻' }]) + }) + + it('prevents an older desktop request from replacing a newer route search', async () => { + const oldRequest = createDeferred>() + const newRequest = createDeferred>() + let historyCalls = 0 + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + historyCalls += 1 + return historyCalls === 1 ? oldRequest.promise : newRequest.promise + }) + + const { router } = await renderHistory('/history?search=old') + await router.push('/history?search=new') + newRequest.resolve(historyResponse([createHistory(2, '新结果')])) + + expect(await screen.findByText('新结果')).toBeInTheDocument() + oldRequest.resolve(historyResponse([createHistory(1, '旧结果')])) + await flushPromises() + expect(screen.queryByText('旧结果')).not.toBeInTheDocument() + }) + + it('exposes title, episode, and source-size ordering through desktop table headers', async () => { + const histories = [ + createHistory(1, '同名剧集', { + episodes: 'E02', + seasons: 'S01', + src_fileitem: { size: 100 } as TransferHistory['src_fileitem'], + type: '电视剧', + }), + createHistory(2, '同名剧集', { + episodes: 'E01', + seasons: 'S02', + src_fileitem: { size: 200 } as TransferHistory['src_fileitem'], + type: '电视剧', + }), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(histories)) + }) + + await renderHistory() + + expect(await screen.findAllByText('同名剧集')).toHaveLength(2) + const sortResults = JSON.parse(screen.getByRole('status', { name: '整理历史排序结果' }).textContent || '{}') + expect(sortResults).toEqual({ size: -100, title: -1 }) + }) + + it('persists desktop search through the same route-backed reload contract', async () => { + vi.useFakeTimers() + const { router } = await renderHistory('/history') + await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'desktop-query') + await vi.advanceTimersByTimeAsync(1000) + await flushPromises() + + expect(router.currentRoute.value.query).toMatchObject({ + currentPage: '1', + itemsPerPage: '50', + search: 'desktop-query', + }) + }) + + it('loads mobile pages with deduplication and reports empty when the last page is exhausted', async () => { + mocks.desktop = false + const firstPage = Array.from({ length: 25 }, (_, index) => createHistory(index + 1, `记录 ${index + 1}`)) + const secondPage = [ + createHistory(25, '重复记录'), + ...Array.from({ length: 4 }, (_, index) => createHistory(26 + index, `追加 ${index + 1}`)), + ] + let historyCalls = 0 + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + historyCalls += 1 + return Promise.resolve(historyResponse(historyCalls === 1 ? firstPage : secondPage, 29)) + }) + + await renderHistory('/history?search=%E7%A7%BB%E5%8A%A8') + + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + expect(await screen.findByText('记录 25')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + expect(await screen.findByText('追加 4')).toBeInTheDocument() + expect(document.querySelectorAll('[data-mobile-key="25"]')).toHaveLength(1) + expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('empty') + }) + + it('keeps the mobile infinite list retryable after a request error', async () => { + mocks.desktop = false + let historyCalls = 0 + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + historyCalls += 1 + if (historyCalls === 1) return Promise.reject(new Error('temporary failure')) + return Promise.resolve(historyResponse([createHistory(1, '重试结果')])) + }) + + await renderHistory() + + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + await waitFor(() => expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('error')) + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + expect(await screen.findByText('重试结果')).toBeInTheDocument() + }) + + it('formats mobile cards and keeps path and batch selection interactions reversible', async () => { + mocks.desktop = false + const item = createHistory(1, '移动剧集', { + category: '动画', + date: '2025-01-02 03:04:00', + episodes: 'E03', + errmsg: '目标路径不可用', + image: '/poster.jpg', + seasons: 'S01', + status: false, + type: '电视剧', + year: '2025', + }) + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse([item])) + }) + + const { container } = await renderHistory() + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + + expect(await screen.findByText('移动剧集 S01E03')).toBeInTheDocument() + expect(screen.getByText('动画 / 2025')).toBeInTheDocument() + expect(screen.getByText('目标路径不可用')).toBeInTheDocument() + const pathButton = container.querySelector('.transfer-history-mobile-record__paths') as HTMLButtonElement + await fireEvent.click(pathButton) + expect(pathButton).toHaveClass('transfer-history-mobile-record__paths--expanded') + await fireEvent.click(pathButton) + expect(pathButton).not.toHaveClass('transfer-history-mobile-record__paths--expanded') + + await fireEvent.click(screen.getByRole('button', { name: '批量选择' })) + const record = container.querySelector('.transfer-history-mobile-record') as HTMLElement + await fireEvent.click(record) + expect(record).toHaveClass('transfer-history-mobile-record--selected') + runDynamicAction('transferHistory.actions.deselectAll') + await nextTick() + expect(record).not.toHaveClass('transfer-history-mobile-record--selected') + runDynamicAction('transferHistory.actions.exitBatchMode') + await nextTick() + expect(screen.getByRole('button', { name: '批量选择' })).toBeInTheDocument() + }) + + it('prevents a mobile request invalidated by a route reset from appending stale records', async () => { + mocks.desktop = false + const oldRequest = createDeferred>() + const newRequest = createDeferred>() + let historyCalls = 0 + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + historyCalls += 1 + return historyCalls === 1 ? oldRequest.promise : newRequest.promise + }) + + const { router } = await renderHistory('/history?search=old') + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + await router.push('/history?search=new') + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + newRequest.resolve(historyResponse([createHistory(2, '移动新结果')])) + + expect(await screen.findByText('移动新结果')).toBeInTheDocument() + oldRequest.resolve(historyResponse([createHistory(1, '移动旧结果')])) + await flushPromises() + expect(screen.queryByText('移动旧结果')).not.toBeInTheDocument() + }) + + it('persists mobile search in the URL before resetting the infinite list', async () => { + vi.useFakeTimers() + mocks.desktop = false + const { router } = await renderHistory('/history?search=old') + await flushPromises() + + await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'new') + await vi.advanceTimersByTimeAsync(600) + + expect(router.currentRoute.value).toMatchObject({ + path: '/history', + query: { currentPage: '1', itemsPerPage: '50', search: 'new' }, + }) + }) + + it('summarizes batch deletion failures, retains failed selections, and never renders undefined progress text', async () => { + const histories = [createHistory(1, '成功项'), createHistory(2, '业务失败项'), createHistory(3, '异常失败项')] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(histories)) + }) + mocks.apiDelete + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ message: '记录被占用', success: false }) + .mockRejectedValueOnce(new Error('delete unavailable')) + + await renderHistory() + expect(await screen.findByText('成功项')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '选择当前页' })) + await nextTick() + runDynamicAction('transferHistory.actions.batchDelete') + + const deleteDialog = getDialogCall() + await deleteDialog.events.delete(false, false) + await flushPromises() + + const progressDialog = getDialogCall(1) + const controller = mocks.openSharedDialog.mock.results[1]?.value as { updateProps: ReturnType } + expect(progressDialog.component.__name || progressDialog.component.name).toContain('ProgressDialog') + expect(controller.updateProps).toHaveBeenCalled() + expect( + controller.updateProps.mock.calls + .flatMap(call => Object.values(call[0])) + .every(value => { + return typeof value !== 'string' || !value.includes('undefined') + }), + ).toBe(true) + expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('2')) + expect(getDynamicMenuItems()?.some(item => item.titleKey === 'transferHistory.actions.batchDelete')).toBe(true) + }) + + it('shows the existing toast-style feedback when a single deletion request throws', async () => { + const item = createHistory(1, '异常删除') + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse([item])) + }) + mocks.apiDelete.mockRejectedValueOnce(new Error('delete unavailable')) + + await renderHistory() + expect(await screen.findByText('异常删除')).toBeInTheDocument() + i18n.global.locale.value = 'en-US' + await fireEvent.click(screen.getByRole('button', { name: '删除' })) + await getDialogCall().events.delete(false, false) + + expect(mocks.toastError).toHaveBeenCalledWith('Failed to delete: Request failed') + }) + + it('releases delete-dialog ownership when either close contract fires', async () => { + const item = createHistory(1, '删除弹窗生命周期') + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse([item])) + }) + + await renderHistory() + expect(await screen.findByText('删除弹窗生命周期')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '删除' })) + const firstController = mocks.openSharedDialog.mock.results[0]?.value as { close: ReturnType } + getDialogCall().events['update:modelValue'](false) + await fireEvent.click(screen.getByRole('button', { name: '删除' })) + expect(firstController.close).not.toHaveBeenCalled() + + getDialogCall(1).events.close() + await fireEvent.click(screen.getByRole('button', { name: '删除' })) + const secondController = mocks.openSharedDialog.mock.results[1]?.value as { close: ReturnType } + expect(secondController.close).not.toHaveBeenCalled() + }) + + it('moves back to the last available page after deleting the only record on the current page', async () => { + const item = createHistory(26, '第二页唯一记录') + const requestedPages: number[] = [] + mocks.apiGet.mockImplementation((path: string, config?: { params?: { page?: number } }) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + const page = config?.params?.page ?? 1 + requestedPages.push(page) + if (requestedPages.length === 1) return Promise.resolve(historyResponse([item], 26)) + return Promise.resolve(historyResponse([], 25)) + }) + + const { router } = await renderHistory('/history?itemsPerPage=25¤tPage=2') + expect(await screen.findByText('第二页唯一记录')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '删除' })) + await getDialogCall().events.delete(false, false) + await waitFor(() => expect(router.currentRoute.value.query.currentPage).toBe('1')) + await waitFor(() => expect(requestedPages).toEqual([2, 2, 1])) + }) + + it('keeps failed mobile batch identities selected after resetting and reloading the list', async () => { + mocks.desktop = false + const histories = [createHistory(1, '删除成功'), createHistory(2, '保留甲'), createHistory(3, '保留乙')] + let historyCalls = 0 + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + historyCalls += 1 + return Promise.resolve(historyResponse(historyCalls === 1 ? histories : histories.slice(1))) + }) + mocks.apiDelete + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ message: 'occupied', success: false }) + .mockRejectedValueOnce(new Error('delete unavailable')) + + await renderHistory() + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + expect(await screen.findByText('删除成功')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '批量选择' })) + runDynamicAction('transferHistory.actions.selectAll') + await nextTick() + runDynamicAction('transferHistory.actions.batchDelete') + i18n.global.locale.value = 'zh-TW' + await getDialogCall().events.delete(false, false) + + expect(screen.getByRole('button', { name: '退出批量選擇' })).toBeInTheDocument() + expect(getDynamicMenuItems()?.find(item => item.titleKey === 'transferHistory.selectedCount')?.titleParams).toEqual( + { count: 0, total: 0 }, + ) + expect(mocks.toastError).toHaveBeenCalledWith('刪除失敗:2/3') + + await fireEvent.click(screen.getByRole('button', { name: '加载下一页' })) + expect(await screen.findByText('保留甲')).toBeInTheDocument() + expect(getDynamicMenuItems()?.find(item => item.titleKey === 'transferHistory.selectedCount')?.titleParams).toEqual( + { count: 2, total: 2 }, + ) + }) + + it('opens the transfer queue as an isolated shared-dialog boundary', async () => { + await renderHistory() + const onClick = mocks.dynamicButtonConfig?.onClick as (() => unknown) | undefined + onClick?.() + + const dialog = getDialogCall() + expect(dialog.component.__name || dialog.component.name).toContain('TransferQueueDialog') + expect(dialog.options).toEqual({ closeOn: ['close'] }) + }) + + it('opens the reorganize boundary with selected ids and refreshes after done', async () => { + const histories = [createHistory(10, '重整甲'), createHistory(11, '重整乙')] + let historyCalls = 0 + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + historyCalls += 1 + return Promise.resolve(historyResponse(histories)) + }) + + await renderHistory() + expect(await screen.findByText('重整甲')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '选择当前页' })) + await nextTick() + runDynamicAction('transferHistory.actions.batchRedo') + + const dialog = getDialogCall() + expect(dialog.props).toMatchObject({ logids: [10, 11] }) + expect(dialog.options).toEqual({ closeOn: ['close', 'done'] }) + await dialog.events.done() + expect(historyCalls).toBeGreaterThan(1) + expect(getDynamicMenuItems()).toBeUndefined() + }) + + it('owns the batch AI redo SSE lifecycle through the progress boundary', async () => { + const histories = [createHistory(1, 'AI 重整')] + let historyCalls = 0 + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + historyCalls += 1 + return Promise.resolve(historyResponse(histories)) + }) + + const { unmount } = await renderHistory() + expect(await screen.findByText('AI 重整')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '选择当前页' })) + await nextTick() + runDynamicAction('transferHistory.actions.batchAiRedo') + await flushPromises() + + expect(mocks.apiPost).toHaveBeenCalledWith('history/transfer/ai-redo', { history_ids: [1] }) + expect(mocks.progressStart).toHaveBeenCalledOnce() + expect(mocks.progressCallback).toBeTypeOf('function') + + await mocks.progressCallback?.( + new MessageEvent('message', { + data: JSON.stringify({ enable: true, text: '处理中' }), + }), + ) + const progressController = mocks.openSharedDialog.mock.results[0]?.value as { + close: ReturnType + updateProps: ReturnType + } + expect(progressController.updateProps).toHaveBeenCalledWith({ text: '处理中' }) + + await mocks.progressCallback?.( + new MessageEvent('message', { + data: JSON.stringify({ data: { error: 'AI 失败', success: false }, enable: false }), + }), + ) + expect(mocks.progressStop).toHaveBeenCalledOnce() + expect(progressController.close).toHaveBeenCalledOnce() + expect(mocks.toastError).toHaveBeenCalledWith('AI 失败') + expect(historyCalls).toBeGreaterThan(1) + + unmount() + expect(mocks.progressStop).toHaveBeenCalled() + }) + + it('starts the single AI redo progress boundary with the accepted progress key', async () => { + const item = createHistory(7, '单条 AI') + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse([item])) + }) + mocks.apiPost.mockResolvedValueOnce({ data: { progress_key: 'single-progress' }, success: true }) + + await renderHistory() + expect(await screen.findByText('单条 AI')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '智能助手整理' })) + await flushPromises() + + expect(mocks.apiPost).toHaveBeenCalledWith('history/transfer/7/ai-redo') + expect(mocks.progressStart).toHaveBeenCalledOnce() + expect(mocks.openSharedDialog).toHaveBeenCalledOnce() + }) + + it('does not start a single AI redo progress boundary when its POST resolves after unmount', async () => { + const item = createHistory(1, '卸载中的单条 AI') + const pending = createDeferred<{ data: { progress_key: string }; success: boolean }>() + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse([item])) + }) + mocks.apiPost.mockReturnValueOnce(pending.promise) + + const { unmount } = await renderHistory() + expect(await screen.findByText('卸载中的单条 AI')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '智能助手整理' })) + unmount() + pending.resolve({ data: { progress_key: 'late-single' }, success: true }) + await flushPromises() + + expect(mocks.progressStart).not.toHaveBeenCalled() + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + }) + + it('does not start a batch AI redo progress boundary when its POST resolves after unmount', async () => { + const item = createHistory(1, '卸载中的批量 AI') + const pending = createDeferred<{ + data: { history_ids: number[]; progress_key: string } + success: boolean + }>() + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse([item])) + }) + mocks.apiPost.mockReturnValueOnce(pending.promise) + + const { unmount } = await renderHistory() + expect(await screen.findByText('卸载中的批量 AI')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '选择当前页' })) + await nextTick() + runDynamicAction('transferHistory.actions.batchAiRedo') + unmount() + pending.resolve({ data: { history_ids: [1], progress_key: 'late-batch' }, success: true }) + await flushPromises() + + expect(mocks.progressStart).not.toHaveBeenCalled() + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 6d05eeb3..ed7a5e10 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -334,6 +334,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/components/cards/DownloadingCard.vue', 'src/components/slide/VirtualSlideView.vue', 'src/views/discover/PersonCardSlideView.vue', + 'src/views/reorganize/TransferHistoryView.vue', 'src/utils/mediaStatusCache.ts', 'src/utils/searchStream.ts', 'src/views/site/SiteCardListView.vue', @@ -432,6 +433,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 80, statements: 80, }, + 'src/views/reorganize/TransferHistoryView.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/components/dialog/SubscribeShareStatisticsDialog.vue': { branches: 75, functions: 80,