test(history): cover transfer history workflows (#613)

This commit is contained in:
InfinityPacer
2026-07-31 16:39:48 +08:00
committed by GitHub
parent 39395ca6b6
commit eaf6b5427c
6 changed files with 915 additions and 26 deletions

View File

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

View File

@@ -3598,6 +3598,10 @@ export default {
listMode: '列表模式',
deleteConfirm: '确认删除 {title} {seasons}{episodes} ?',
deleteConfirmBatch: '确认删除 {count} 条记录 ?',
deleteFailed: '删除失败:{message}',
deleteRequestFailed: '删除失败:请求异常',
batchDeleteFailed: '删除失败:{failed}/{total}',
deleting: '正在删除 {name}...',
deleteRecordOnly: '仅删除转移记录',
deleteSourceOnly: '删除转移记录和源文件',
deleteDestOnly: '删除转移记录和媒体库文件',

View File

@@ -3595,6 +3595,10 @@ export default {
listMode: '列表模式',
deleteConfirm: '確認刪除 {title} {seasons}{episodes}?',
deleteConfirmBatch: '確認刪除 {count} 條記錄?',
deleteFailed: '刪除失敗:{message}',
deleteRequestFailed: '刪除失敗:請求異常',
batchDeleteFailed: '刪除失敗:{failed}/{total}',
deleting: '正在刪除 {name}...',
deleteRecordOnly: '僅刪除轉移記錄',
deleteSourceOnly: '刪除轉移記錄和源文件',
deleteDestOnly: '刪除轉移記錄和媒體庫文件',

View File

@@ -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(() => {
</div>
</template>
<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>
</VImg>
<VIcon
class="transfer-history-mobile-record__poster-type"
:icon="getIcon(item.type || '')"
size="14"
/>
<VIcon class="transfer-history-mobile-record__poster-type" :icon="getIcon(item.type || '')" size="14" />
</div>
<div class="transfer-history-mobile-record__heading">

View File

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

View File

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