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