mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 16:56:40 +08:00
test(history): cover transfer history workflows (#613)
This commit is contained in:
@@ -3660,6 +3660,10 @@ export default {
|
|||||||
listMode: 'List Mode',
|
listMode: 'List Mode',
|
||||||
deleteConfirm: 'Confirm delete {title} {seasons}{episodes}?',
|
deleteConfirm: 'Confirm delete {title} {seasons}{episodes}?',
|
||||||
deleteConfirmBatch: 'Confirm delete {count} records?',
|
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',
|
deleteRecordOnly: 'Delete Record Only',
|
||||||
deleteSourceOnly: 'Delete Record and Source File',
|
deleteSourceOnly: 'Delete Record and Source File',
|
||||||
deleteDestOnly: 'Delete Record and Media Library File',
|
deleteDestOnly: 'Delete Record and Media Library File',
|
||||||
|
|||||||
@@ -3598,6 +3598,10 @@ export default {
|
|||||||
listMode: '列表模式',
|
listMode: '列表模式',
|
||||||
deleteConfirm: '确认删除 {title} {seasons}{episodes} ?',
|
deleteConfirm: '确认删除 {title} {seasons}{episodes} ?',
|
||||||
deleteConfirmBatch: '确认删除 {count} 条记录 ?',
|
deleteConfirmBatch: '确认删除 {count} 条记录 ?',
|
||||||
|
deleteFailed: '删除失败:{message}',
|
||||||
|
deleteRequestFailed: '删除失败:请求异常',
|
||||||
|
batchDeleteFailed: '删除失败:{failed}/{total}',
|
||||||
|
deleting: '正在删除 {name}...',
|
||||||
deleteRecordOnly: '仅删除转移记录',
|
deleteRecordOnly: '仅删除转移记录',
|
||||||
deleteSourceOnly: '删除转移记录和源文件',
|
deleteSourceOnly: '删除转移记录和源文件',
|
||||||
deleteDestOnly: '删除转移记录和媒体库文件',
|
deleteDestOnly: '删除转移记录和媒体库文件',
|
||||||
|
|||||||
@@ -3595,6 +3595,10 @@ export default {
|
|||||||
listMode: '列表模式',
|
listMode: '列表模式',
|
||||||
deleteConfirm: '確認刪除 {title} {seasons}{episodes}?',
|
deleteConfirm: '確認刪除 {title} {seasons}{episodes}?',
|
||||||
deleteConfirmBatch: '確認刪除 {count} 條記錄?',
|
deleteConfirmBatch: '確認刪除 {count} 條記錄?',
|
||||||
|
deleteFailed: '刪除失敗:{message}',
|
||||||
|
deleteRequestFailed: '刪除失敗:請求異常',
|
||||||
|
batchDeleteFailed: '刪除失敗:{failed}/{total}',
|
||||||
|
deleting: '正在刪除 {name}...',
|
||||||
deleteRecordOnly: '僅刪除轉移記錄',
|
deleteRecordOnly: '僅刪除轉移記錄',
|
||||||
deleteSourceOnly: '刪除轉移記錄和源文件',
|
deleteSourceOnly: '刪除轉移記錄和源文件',
|
||||||
deleteDestOnly: '刪除轉移記錄和媒體庫文件',
|
deleteDestOnly: '刪除轉移記錄和媒體庫文件',
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import type { StorageConf, TransferHistory } from '@/api/types'
|
|||||||
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
|
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
|
||||||
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
|
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
|
||||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import router from '@/router'
|
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -48,6 +47,7 @@ const $toast = useToast()
|
|||||||
|
|
||||||
// 路由
|
// 路由
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const canManage = computed(() =>
|
const canManage = computed(() =>
|
||||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
||||||
@@ -55,6 +55,7 @@ const canManage = computed(() =>
|
|||||||
let syncingRouteQuery = false
|
let syncingRouteQuery = false
|
||||||
let fetchDataRequestSeed = 0
|
let fetchDataRequestSeed = 0
|
||||||
let mobileFetchDataRequestSeed = 0
|
let mobileFetchDataRequestSeed = 0
|
||||||
|
let componentUnmounted = false
|
||||||
|
|
||||||
// 组合式输入法状态
|
// 组合式输入法状态
|
||||||
const isComposing = ref(false)
|
const isComposing = ref(false)
|
||||||
@@ -396,9 +397,9 @@ const debouncedReloadSearchPage = debounce(() => {
|
|||||||
void reloadPage(true)
|
void reloadPage(true)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
// 延迟刷新移动端无限列表,输入完成后再从第一页重新加载。
|
// 延迟同步移动端搜索参数,路由监听会按新查询重置无限列表。
|
||||||
const debouncedReloadMobileSearchPage = debounce(() => {
|
const debouncedReloadMobileSearchPage = debounce(() => {
|
||||||
resetMobileHistory()
|
void reloadMobileSearchPage()
|
||||||
}, 600)
|
}, 600)
|
||||||
|
|
||||||
// 切换页签
|
// 切换页签
|
||||||
@@ -499,8 +500,15 @@ function updateSearchHintList(list: TransferHistory[]) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MobileHistoryResetOptions {
|
||||||
|
/** 是否保持批量模式,通常用于失败项重试。 */
|
||||||
|
preserveBatchMode?: boolean
|
||||||
|
/** 重置分页时仍需保持选中的记录。 */
|
||||||
|
selectedItems?: TransferHistory[]
|
||||||
|
}
|
||||||
|
|
||||||
// 重置移动端无限列表,让 VInfiniteScroll 从第一页重新触发加载。
|
// 重置移动端无限列表,让 VInfiniteScroll 从第一页重新触发加载。
|
||||||
function resetMobileHistory() {
|
function resetMobileHistory(options: MobileHistoryResetOptions = {}) {
|
||||||
mobileFetchDataRequestSeed++
|
mobileFetchDataRequestSeed++
|
||||||
mobileDataList.value = []
|
mobileDataList.value = []
|
||||||
mobileCurrentPage.value = 1
|
mobileCurrentPage.value = 1
|
||||||
@@ -509,8 +517,8 @@ function resetMobileHistory() {
|
|||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
totalItems.value = 0
|
totalItems.value = 0
|
||||||
mobileExpandedPathIds.value = []
|
mobileExpandedPathIds.value = []
|
||||||
selected.value = []
|
selected.value = options.selectedItems ?? []
|
||||||
mobileBatchMode.value = false
|
mobileBatchMode.value = Boolean(options.preserveBatchMode && selected.value.length > 0)
|
||||||
mobileInfiniteKey.value++
|
mobileInfiniteKey.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +592,10 @@ function appendMobileHistory(list: TransferHistory[], total: number) {
|
|||||||
const newItems = list.filter(item => !existingIds.has(item.id))
|
const newItems = list.filter(item => !existingIds.has(item.id))
|
||||||
|
|
||||||
mobileDataList.value = [...mobileDataList.value, ...newItems]
|
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++
|
mobileCurrentPage.value++
|
||||||
mobileHasMore.value = mobileDataList.value.length < total && list.length >= mobilePageSize
|
mobileHasMore.value = mobileDataList.value.length < total && list.length >= mobilePageSize
|
||||||
updateSearchHintList(mobileDataList.value)
|
updateSearchHintList(mobileDataList.value)
|
||||||
@@ -619,9 +631,12 @@ async function refreshDataFromRouteQuery(options: { silent?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 操作完成后刷新列表;如果当前页被删空,则跳回最后一个有效页。
|
// 操作完成后刷新列表;如果当前页被删空,则跳回最后一个有效页。
|
||||||
async function refreshDataAfterOperation() {
|
async function refreshDataAfterOperation(mobileSelection: TransferHistory[] = []) {
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
resetMobileHistory()
|
resetMobileHistory({
|
||||||
|
preserveBatchMode: mobileSelection.length > 0,
|
||||||
|
selectedItems: mobileSelection,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,7 +647,6 @@ async function refreshDataAfterOperation() {
|
|||||||
if (currentPage.value <= lastAvailablePage) return
|
if (currentPage.value <= lastAvailablePage) return
|
||||||
|
|
||||||
await router.replace(createHistoryUrl(false, lastAvailablePage))
|
await router.replace(createHistoryUrl(false, lastAvailablePage))
|
||||||
await refreshDataFromRouteQuery()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 type 返回不同的图标
|
// 根据 type 返回不同的图标
|
||||||
@@ -666,7 +680,7 @@ async function removeHistory(item: TransferHistory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用API删除记录
|
// 调用API删除记录
|
||||||
async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean) {
|
async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean, notifyError = true) {
|
||||||
try {
|
try {
|
||||||
// 调用删除API
|
// 调用删除API
|
||||||
const result: {
|
const result: {
|
||||||
@@ -675,9 +689,19 @@ async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boo
|
|||||||
data: item,
|
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) {
|
} catch (error) {
|
||||||
console.error(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
|
let handled = 0
|
||||||
|
const failedItems: TransferHistory[] = []
|
||||||
// 显示进度条
|
// 显示进度条
|
||||||
openProgressDialog()
|
openProgressDialog()
|
||||||
// 循环调用removeHistory
|
// 循环调用removeHistory
|
||||||
for (const item of selected.value) {
|
for (const item of selected.value) {
|
||||||
// 开始删除
|
// 开始删除
|
||||||
progressText.value = `正在删除 ${item.title} ${item.seasons}${item.episodes} ...`
|
const seasonEpisode = `${item.seasons || ''}${item.episodes || ''}`
|
||||||
await remove(item, deleteSrc, deleteDest)
|
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++
|
handled++
|
||||||
progressValue.value = (handled / total) * 100
|
progressValue.value = (handled / total) * 100
|
||||||
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
|
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
|
||||||
}
|
}
|
||||||
// 清空选中项
|
// 失败项保持选中,方便用户修正条件后重试。
|
||||||
selected.value = []
|
selected.value = failedItems
|
||||||
if (isMobile.value) {
|
if (isMobile.value && failedItems.length === 0) {
|
||||||
mobileBatchMode.value = false
|
mobileBatchMode.value = false
|
||||||
}
|
}
|
||||||
// 隐藏进度条
|
// 隐藏进度条
|
||||||
closeProgressDialog()
|
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
|
let progressStarted = false
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.post(`history/transfer/${item.id}/ai-redo`)
|
const result: { [key: string]: any } = await api.post(`history/transfer/${item.id}/ai-redo`)
|
||||||
|
if (componentUnmounted) return
|
||||||
|
|
||||||
const progressKey = result.data?.progress_key
|
const progressKey = result.data?.progress_key
|
||||||
|
|
||||||
@@ -876,7 +910,9 @@ async function triggerAiRedo(item: TransferHistory) {
|
|||||||
progressStarted = true
|
progressStarted = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
$toast.error(t('transferHistory.aiRedoFailed'))
|
if (!componentUnmounted) {
|
||||||
|
$toast.error(t('transferHistory.aiRedoFailed'))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!progressStarted) {
|
if (!progressStarted) {
|
||||||
aiRedoIds.value = aiRedoIds.value.filter(id => id !== item.id)
|
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', {
|
const result: { [key: string]: any } = await api.post('history/transfer/ai-redo', {
|
||||||
history_ids: historyIds,
|
history_ids: historyIds,
|
||||||
})
|
})
|
||||||
|
if (componentUnmounted) return
|
||||||
|
|
||||||
const progressKey = result.data?.progress_key
|
const progressKey = result.data?.progress_key
|
||||||
const acceptedIds = (result.data?.history_ids as number[] | undefined) ?? historyIds
|
const acceptedIds = (result.data?.history_ids as number[] | undefined) ?? historyIds
|
||||||
@@ -917,7 +954,9 @@ async function triggerBatchAiRedo() {
|
|||||||
progressStarted = true
|
progressStarted = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
$toast.error(t('transferHistory.aiRedoFailed'))
|
if (!componentUnmounted) {
|
||||||
|
$toast.error(t('transferHistory.aiRedoFailed'))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!progressStarted) {
|
if (!progressStarted) {
|
||||||
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIds.includes(id))
|
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIds.includes(id))
|
||||||
@@ -993,6 +1032,11 @@ async function reloadPage(resetPage = false) {
|
|||||||
await router.push(createHistoryUrl(resetPage))
|
await router.push(createHistoryUrl(resetPage))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 移动端搜索同样以 URL 为持久事实源,刷新和断点切换后可恢复同一查询。
|
||||||
|
async function reloadMobileSearchPage() {
|
||||||
|
await router.push(createHistoryUrl(true))
|
||||||
|
}
|
||||||
|
|
||||||
// 确保值为number类型
|
// 确保值为number类型
|
||||||
function ensureNumber(value: any, defaultValue: number = 0) {
|
function ensureNumber(value: any, defaultValue: number = 0) {
|
||||||
value = Number(value)
|
value = Number(value)
|
||||||
@@ -1354,6 +1398,7 @@ onActivated(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
componentUnmounted = true
|
||||||
debouncedReloadPage.cancel()
|
debouncedReloadPage.cancel()
|
||||||
debouncedReloadSearchPage.cancel()
|
debouncedReloadSearchPage.cancel()
|
||||||
debouncedReloadMobileSearchPage.cancel()
|
debouncedReloadMobileSearchPage.cancel()
|
||||||
@@ -1704,14 +1749,15 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #error>
|
<template #error>
|
||||||
<VImg :src="noImage" cover :alt="item.title" class="transfer-history-mobile-record__poster-fallback" />
|
<VImg
|
||||||
|
:src="noImage"
|
||||||
|
cover
|
||||||
|
:alt="item.title"
|
||||||
|
class="transfer-history-mobile-record__poster-fallback"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VImg>
|
</VImg>
|
||||||
<VIcon
|
<VIcon class="transfer-history-mobile-record__poster-type" :icon="getIcon(item.type || '')" size="14" />
|
||||||
class="transfer-history-mobile-record__poster-type"
|
|
||||||
:icon="getIcon(item.type || '')"
|
|
||||||
size="14"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="transfer-history-mobile-record__heading">
|
<div class="transfer-history-mobile-record__heading">
|
||||||
|
|||||||
@@ -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<string, unknown> | 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<typeof import('vuetify')>()
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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<TransferHistory[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Array as PropType<TransferHistory[]>,
|
||||||
|
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<TransferHistory[]>,
|
||||||
|
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> = {}): 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<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((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<string, unknown>
|
||||||
|
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<string, unknown>,
|
||||||
|
Record<string, (...args: unknown[]) => unknown>,
|
||||||
|
Record<string, unknown>,
|
||||||
|
]
|
||||||
|
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<Record<string, unknown>> = []
|
||||||
|
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
|
||||||
|
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<ReturnType<typeof historyResponse>>()
|
||||||
|
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
||||||
|
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<ReturnType<typeof historyResponse>>()
|
||||||
|
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
||||||
|
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<typeof vi.fn> }
|
||||||
|
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<typeof vi.fn> }
|
||||||
|
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<typeof vi.fn> }
|
||||||
|
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<typeof vi.fn>
|
||||||
|
updateProps: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -334,6 +334,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/components/cards/DownloadingCard.vue',
|
'src/components/cards/DownloadingCard.vue',
|
||||||
'src/components/slide/VirtualSlideView.vue',
|
'src/components/slide/VirtualSlideView.vue',
|
||||||
'src/views/discover/PersonCardSlideView.vue',
|
'src/views/discover/PersonCardSlideView.vue',
|
||||||
|
'src/views/reorganize/TransferHistoryView.vue',
|
||||||
'src/utils/mediaStatusCache.ts',
|
'src/utils/mediaStatusCache.ts',
|
||||||
'src/utils/searchStream.ts',
|
'src/utils/searchStream.ts',
|
||||||
'src/views/site/SiteCardListView.vue',
|
'src/views/site/SiteCardListView.vue',
|
||||||
@@ -432,6 +433,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/views/reorganize/TransferHistoryView.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
'src/components/dialog/SubscribeShareStatisticsDialog.vue': {
|
'src/components/dialog/SubscribeShareStatisticsDialog.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user