mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 01:06:41 +08:00
test(filebrowser): cover file list operations (#621)
This commit is contained in:
@@ -445,9 +445,6 @@
|
|||||||
"@typescript-eslint/no-unused-vars": {
|
"@typescript-eslint/no-unused-vars": {
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
"prefer-const": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"vue/no-v-text-v-html-on-component": {
|
"vue/no-v-text-v-html-on-component": {
|
||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ const props = defineProps({
|
|||||||
storages: Array as PropType<StorageConf[]>,
|
storages: Array as PropType<StorageConf[]>,
|
||||||
tree: Boolean,
|
tree: Boolean,
|
||||||
endpoints: Object as PropType<EndPoints>,
|
endpoints: Object as PropType<EndPoints>,
|
||||||
|
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
|
||||||
axios: {
|
axios: {
|
||||||
type: Object as PropType<AxiosInstance>,
|
type: Function as PropType<AxiosInstance>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
axiosconfig: Object,
|
axiosconfig: Object,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { PropType } from 'vue'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { formatBytes } from '@core/utils/formatters'
|
import { formatBytes } from '@core/utils/formatters'
|
||||||
import type { Context, EndPoints, FileItem, ManualScrapeOptions } from '@/api/types'
|
import type { ApiResponse, Context, EndPoints, FileItem, ManualScrapeOptions } from '@/api/types'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -36,8 +36,9 @@ const { availableHeight: listAvailableHeight } = useAvailableHeight(100, 300)
|
|||||||
const inProps = defineProps({
|
const inProps = defineProps({
|
||||||
icons: Object,
|
icons: Object,
|
||||||
endpoints: Object as PropType<EndPoints>,
|
endpoints: Object as PropType<EndPoints>,
|
||||||
|
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
|
||||||
axios: {
|
axios: {
|
||||||
type: Object as PropType<AxiosInstance>,
|
type: Function as PropType<AxiosInstance>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
refreshpending: Boolean,
|
refreshpending: Boolean,
|
||||||
@@ -225,6 +226,11 @@ const transferItems = ref<FileItem[]>([])
|
|||||||
// 当前图片地址
|
// 当前图片地址
|
||||||
const currentImgLink = ref('')
|
const currentImgLink = ref('')
|
||||||
|
|
||||||
|
let imageRequestSeed = 0
|
||||||
|
// request seed 决定响应提交和内部 loading 归属;外部 loading 按非静默调用配对,重叠时也分别完成事件收口。
|
||||||
|
let listLoadingRequestSeed = 0
|
||||||
|
let listRequestSeed = 0
|
||||||
|
|
||||||
// 释放当前图片预览使用的临时对象地址。
|
// 释放当前图片预览使用的临时对象地址。
|
||||||
function revokeCurrentImgLink() {
|
function revokeCurrentImgLink() {
|
||||||
if (!currentImgLink.value) return
|
if (!currentImgLink.value) return
|
||||||
@@ -253,11 +259,11 @@ function exitSelectMode() {
|
|||||||
|
|
||||||
// 调API加载文件夹内的内容
|
// 调API加载文件夹内的内容
|
||||||
async function list_files(context: KeepAliveRefreshContext = {}) {
|
async function list_files(context: KeepAliveRefreshContext = {}) {
|
||||||
|
const requestSeed = ++listRequestSeed
|
||||||
const silentRefresh = Boolean(context.silent && items.value.length > 0)
|
const silentRefresh = Boolean(context.silent && items.value.length > 0)
|
||||||
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/')
|
|
||||||
const prevURI = takeURISnapshot()
|
|
||||||
|
|
||||||
if (!silentRefresh) {
|
if (!silentRefresh) {
|
||||||
|
listLoadingRequestSeed = requestSeed
|
||||||
loading.value = true
|
loading.value = true
|
||||||
emit('loading', true)
|
emit('loading', true)
|
||||||
}
|
}
|
||||||
@@ -274,10 +280,8 @@ async function list_files(context: KeepAliveRefreshContext = {}) {
|
|||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const data = (await inProps.axios.request<FileItem[], FileItem[]>(config)) ?? []
|
const data = (await inProps.axios.request<FileItem[], FileItem[]>(config)) ?? []
|
||||||
// 如果当前路径已经变化,则放弃此次加载结果
|
if (requestSeed !== listRequestSeed) return
|
||||||
if (prevURI !== takeURISnapshot()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items.value = data
|
items.value = data
|
||||||
syncSelectedItems(data)
|
syncSelectedItems(data)
|
||||||
|
|
||||||
@@ -288,11 +292,21 @@ async function list_files(context: KeepAliveRefreshContext = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!silentRefresh) {
|
if (!silentRefresh) {
|
||||||
emit('loading', false)
|
emit('loading', false)
|
||||||
loading.value = false
|
if (requestSeed === listLoadingRequestSeed) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求删除文件项,由单项或批量操作分别决定反馈和刷新策略。
|
||||||
|
async function requestDeleteItem(item: FileItem) {
|
||||||
|
const config: AxiosRequestConfig<FileItem> = {
|
||||||
|
url: inProps.endpoints?.delete.url,
|
||||||
|
method: inProps.endpoints?.delete.method || 'post',
|
||||||
|
data: item,
|
||||||
|
}
|
||||||
|
return inProps.axios.request<ApiResponse<unknown>, ApiResponse<unknown>>(config)
|
||||||
|
}
|
||||||
|
|
||||||
// 删除项目
|
// 删除项目
|
||||||
async function deleteItem(item: FileItem, confirm: boolean = true) {
|
async function deleteItem(item: FileItem, confirm: boolean = true) {
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
@@ -309,21 +323,23 @@ async function deleteItem(item: FileItem, confirm: boolean = true) {
|
|||||||
// 加载中
|
// 加载中
|
||||||
emit('loading', true)
|
emit('loading', true)
|
||||||
|
|
||||||
// 请求API
|
try {
|
||||||
const url = inProps.endpoints?.delete.url
|
const result = await requestDeleteItem(item)
|
||||||
const config: AxiosRequestConfig<FileItem> = {
|
if (!result.success) {
|
||||||
url,
|
$toast.error(result.message || t('common.error'))
|
||||||
method: inProps.endpoints?.delete.method || 'post',
|
return false
|
||||||
data: item,
|
|
||||||
}
|
}
|
||||||
await inProps.axios.request(config)
|
|
||||||
|
|
||||||
// 删除完成
|
|
||||||
emit('loading', false)
|
|
||||||
emit('filedeleted')
|
emit('filedeleted')
|
||||||
|
await list_files()
|
||||||
// 重新加载
|
return true
|
||||||
list_files()
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
$toast.error(error instanceof Error ? error.message : t('common.error'))
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
emit('loading', false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量删除
|
// 批量删除
|
||||||
@@ -340,24 +356,42 @@ async function batchDelete() {
|
|||||||
// 显示进度条
|
// 显示进度条
|
||||||
progressValue.value = 0
|
progressValue.value = 0
|
||||||
openProgressDialog(progressText.value, progressValue.value)
|
openProgressDialog(progressText.value, progressValue.value)
|
||||||
|
emit('loading', true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedItems = dedupeFileItems(selected.value)
|
const selectedItems = dedupeFileItems(selected.value)
|
||||||
|
const failedItems: FileItem[] = []
|
||||||
|
|
||||||
// 删除选中的项目
|
// 删除选中的项目
|
||||||
for (const item of selectedItems) {
|
for (const [index, item] of selectedItems.entries()) {
|
||||||
progressText.value = t('file.deleting', { name: item.name })
|
progressText.value = t('file.deleting', { name: item.name })
|
||||||
progressDialogController?.updateProps({ text: progressText.value })
|
progressValue.value = Math.round(((index + 1) / selectedItems.length) * 100)
|
||||||
await deleteItem(item, false)
|
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
|
||||||
|
try {
|
||||||
|
const result = await requestDeleteItem(item)
|
||||||
|
if (!result.success) failedItems.push(item)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
failedItems.push(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (failedItems.length) {
|
||||||
|
selected.value = failedItems
|
||||||
|
$toast.error(`${t('common.error')}: ${failedItems.map(item => item.name).join(', ')}`)
|
||||||
|
} else {
|
||||||
exitSelectMode()
|
exitSelectMode()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// 关闭进度条
|
// 关闭进度条
|
||||||
closeProgressDialog()
|
closeProgressDialog()
|
||||||
|
|
||||||
// 重新加载
|
// 重新加载
|
||||||
list_files()
|
try {
|
||||||
|
await list_files({ silent: true })
|
||||||
|
} finally {
|
||||||
|
emit('loading', false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +433,8 @@ async function download(item: FileItem) {
|
|||||||
|
|
||||||
// 获取图片地址
|
// 获取图片地址
|
||||||
async function getImgLink(item: FileItem) {
|
async function getImgLink(item: FileItem) {
|
||||||
let url = inProps.endpoints?.image.url
|
const requestSeed = ++imageRequestSeed
|
||||||
|
const url = inProps.endpoints?.image.url
|
||||||
// 下载文件
|
// 下载文件
|
||||||
const config: AxiosRequestConfig<FileItem> = {
|
const config: AxiosRequestConfig<FileItem> = {
|
||||||
url,
|
url,
|
||||||
@@ -409,7 +444,7 @@ async function getImgLink(item: FileItem) {
|
|||||||
}
|
}
|
||||||
// 加载二进制数据
|
// 加载二进制数据
|
||||||
const result: Blob = await inProps.axios.request<Blob, Blob>(config)
|
const result: Blob = await inProps.axios.request<Blob, Blob>(config)
|
||||||
if (result) {
|
if (result && requestSeed === imageRequestSeed) {
|
||||||
// 创建图片地址
|
// 创建图片地址
|
||||||
revokeCurrentImgLink()
|
revokeCurrentImgLink()
|
||||||
currentImgLink.value = URL.createObjectURL(result)
|
currentImgLink.value = URL.createObjectURL(result)
|
||||||
@@ -420,12 +455,11 @@ async function getImgLink(item: FileItem) {
|
|||||||
watch(
|
watch(
|
||||||
() => inProps.item,
|
() => inProps.item,
|
||||||
async () => {
|
async () => {
|
||||||
|
imageRequestSeed += 1
|
||||||
|
revokeCurrentImgLink()
|
||||||
if (isImage.value && isFile.value) {
|
if (isImage.value && isFile.value) {
|
||||||
await getImgLink(inProps.item)
|
await getImgLink(inProps.item)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revokeCurrentImgLink()
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -487,51 +521,51 @@ async function get_recommend_name() {
|
|||||||
renameDialogController?.updateProps({ loading: false, name: newName.value })
|
renameDialogController?.updateProps({ loading: false, name: newName.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重命名
|
// 仅在后端确认成功后关闭编辑弹窗并通知刷新;失败时保留输入以便重试。
|
||||||
async function rename() {
|
async function rename() {
|
||||||
emit('loading', true)
|
emit('loading', true)
|
||||||
|
const recursive = renameAll.value
|
||||||
|
|
||||||
// 显示进度条
|
// 显示进度条
|
||||||
progressValue.value = 0
|
progressValue.value = 0
|
||||||
if (renameAll.value) {
|
if (recursive) {
|
||||||
progressText.value = t('file.renamingAll', { path: currentItem.value?.path })
|
progressText.value = t('file.renamingAll', { path: currentItem.value?.path })
|
||||||
} else {
|
} else {
|
||||||
progressText.value = t('file.renaming', { name: currentItem.value?.name })
|
progressText.value = t('file.renaming', { name: currentItem.value?.name })
|
||||||
}
|
}
|
||||||
openProgressDialog(progressText.value, progressValue.value)
|
openProgressDialog(progressText.value, progressValue.value)
|
||||||
if (renameAll.value) {
|
if (recursive) {
|
||||||
startLoadingProgress()
|
startLoadingProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调API
|
try {
|
||||||
let url = inProps.endpoints?.rename.url.replace(/{newname}/g, encodeURIComponent(newName.value))
|
let url = inProps.endpoints?.rename.url.replace(/{newname}/g, encodeURIComponent(newName.value))
|
||||||
if (renameAll.value) {
|
if (recursive) url += '&recursive=true'
|
||||||
url += '&recursive=true'
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: AxiosRequestConfig<FileItem> = {
|
const config: AxiosRequestConfig<FileItem> = {
|
||||||
url,
|
url,
|
||||||
method: inProps.endpoints?.rename.method || 'post',
|
method: inProps.endpoints?.rename.method || 'post',
|
||||||
data: currentItem.value,
|
data: currentItem.value,
|
||||||
}
|
}
|
||||||
const result: { [key: string]: any } = await inProps.axios?.request<any, { [key: string]: any }>(config)
|
const result: { [key: string]: any } = await inProps.axios.request<any, { [key: string]: any }>(config)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
$toast.error(result.message)
|
$toast.error(result.message || t('common.error'))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭进度条
|
|
||||||
if (renameAll.value) {
|
|
||||||
stopLoadingProgress()
|
|
||||||
}
|
|
||||||
closeProgressDialog()
|
|
||||||
|
|
||||||
// 通知重新加载
|
|
||||||
newName.value = ''
|
newName.value = ''
|
||||||
renameAll.value = false
|
renameAll.value = false
|
||||||
renameDialogController?.close()
|
renameDialogController?.close()
|
||||||
renameDialogController = null
|
renameDialogController = null
|
||||||
emit('loading', false)
|
|
||||||
emit('renamed')
|
emit('renamed')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
$toast.error(error instanceof Error ? error.message : t('common.error'))
|
||||||
|
} finally {
|
||||||
|
if (recursive) stopLoadingProgress()
|
||||||
|
closeProgressDialog()
|
||||||
|
emit('loading', false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示整理对话框
|
// 显示整理对话框
|
||||||
@@ -590,7 +624,7 @@ watch(
|
|||||||
|
|
||||||
// 监听item变化
|
// 监听item变化
|
||||||
watch(
|
watch(
|
||||||
[() => inProps.item],
|
() => inProps.item,
|
||||||
async () => {
|
async () => {
|
||||||
// 清空列表
|
// 清空列表
|
||||||
items.value = []
|
items.value = []
|
||||||
@@ -776,6 +810,8 @@ useKeepAliveRefresh(list_files, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
imageRequestSeed += 1
|
||||||
|
listLoadingRequestSeed = ++listRequestSeed
|
||||||
revokeCurrentImgLink()
|
revokeCurrentImgLink()
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
closeProgressDialog()
|
closeProgressDialog()
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ const props = defineProps({
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
endpoints: Object,
|
endpoints: Object,
|
||||||
|
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
|
||||||
axios: {
|
axios: {
|
||||||
type: Object as PropType<AxiosInstance>,
|
type: Function as PropType<AxiosInstance>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ const inProps = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
endpoints: Object as PropType<EndPoints>,
|
endpoints: Object as PropType<EndPoints>,
|
||||||
|
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
|
||||||
axios: {
|
axios: {
|
||||||
type: Object as PropType<AxiosInstance>,
|
type: Function as PropType<AxiosInstance>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const FileListStub = defineComponent({
|
|||||||
|
|
||||||
function createBrowserProps() {
|
function createBrowserProps() {
|
||||||
const request = vi.fn()
|
const request = vi.fn()
|
||||||
const axios = { request } as unknown as AxiosInstance
|
const axios = Object.assign(vi.fn(), { request }) as unknown as AxiosInstance
|
||||||
const endpoint = { method: 'post', url: '/unused' }
|
const endpoint = { method: 'post', url: '/unused' }
|
||||||
const endpoints: EndPoints = {
|
const endpoints: EndPoints = {
|
||||||
delete: endpoint,
|
delete: endpoint,
|
||||||
|
|||||||
@@ -0,0 +1,723 @@
|
|||||||
|
import type { EndPoints, FileItem } from '@/api/types'
|
||||||
|
import FileList from '@/components/filebrowser/FileList.vue'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||||
|
import { defineComponent, nextTick } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
confirm: vi.fn(),
|
||||||
|
keepAliveOptions: undefined as { active?: { value: boolean } } | undefined,
|
||||||
|
keepAliveRefresh: undefined as ((context?: { silent?: boolean }) => Promise<void>) | undefined,
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
|
progressHandler: undefined as ((event: MessageEvent) => void) | undefined,
|
||||||
|
progressStart: vi.fn(),
|
||||||
|
progressStop: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||||
|
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useBackground', () => ({
|
||||||
|
useBackground: () => ({
|
||||||
|
useProgressSSE: (_url: string, handler: (event: MessageEvent) => void) => {
|
||||||
|
mocks.progressHandler = handler
|
||||||
|
return { start: mocks.progressStart, stop: mocks.progressStop }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePWA', () => ({
|
||||||
|
usePWA: () => ({ appMode: { value: false } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useAvailableHeight', () => ({
|
||||||
|
useAvailableHeight: () => ({ availableHeight: { value: 480 } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useKeepAliveRefresh', () => ({
|
||||||
|
useKeepAliveRefresh: (
|
||||||
|
refresh: (context?: { silent?: boolean }) => Promise<void>,
|
||||||
|
options: { active?: { value: boolean } },
|
||||||
|
) => {
|
||||||
|
mocks.keepAliveRefresh = refresh
|
||||||
|
mocks.keepAliveOptions = options
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const IconBtnStub = defineComponent({
|
||||||
|
template: '<button type="button"><slot /></button>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const IconStub = defineComponent({
|
||||||
|
props: ['icon'],
|
||||||
|
template: '<span class="test-icon" :data-icon="icon"><slot /></span>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const HoverStub = defineComponent({
|
||||||
|
template: '<div><slot v-bind="{ props: {}, isHovering: true }" /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const VirtualScrollStub = defineComponent({
|
||||||
|
props: ['items'],
|
||||||
|
template: '<div><slot v-for="item in items" :item="item" /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ListItemStub = defineComponent({
|
||||||
|
template: '<div role="button" class="test-list-item"><slot name="prepend" /><slot /><slot name="append" /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TextFieldStub = defineComponent({
|
||||||
|
props: ['modelValue', 'placeholder'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template:
|
||||||
|
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ImageStub = defineComponent({
|
||||||
|
props: ['src'],
|
||||||
|
template: '<img :src="src" />',
|
||||||
|
})
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
IconBtn: IconBtnStub,
|
||||||
|
LoadingBanner: true,
|
||||||
|
VCheckbox: true,
|
||||||
|
VHover: HoverStub,
|
||||||
|
VIcon: IconStub,
|
||||||
|
VImg: ImageStub,
|
||||||
|
VListItem: ListItemStub,
|
||||||
|
VListItemAction: true,
|
||||||
|
VListItemSubtitle: defineComponent({ template: '<span><slot /></span>' }),
|
||||||
|
VListItemTitle: defineComponent({ template: '<span><slot /></span>' }),
|
||||||
|
VTextField: TextFieldStub,
|
||||||
|
VVirtualScroll: VirtualScrollStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoints: EndPoints = {
|
||||||
|
delete: { method: 'post', url: '/storage/delete' },
|
||||||
|
download: { method: 'post', url: '/storage/download' },
|
||||||
|
image: { method: 'post', url: '/storage/image' },
|
||||||
|
list: { method: 'post', url: '/storage/list/{sort}' },
|
||||||
|
mkdir: { method: 'post', url: '/storage/mkdir' },
|
||||||
|
rename: { method: 'post', url: '/storage/rename?name={newname}' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function createItem(overrides: Partial<FileItem> = {}): FileItem {
|
||||||
|
return {
|
||||||
|
name: 'item.mkv',
|
||||||
|
path: '/media/',
|
||||||
|
storage: 'local',
|
||||||
|
type: 'file',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve
|
||||||
|
reject = promiseReject
|
||||||
|
})
|
||||||
|
return { promise, reject, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconButton(icon: string) {
|
||||||
|
const iconElement = document.querySelector(`[data-icon="${icon}"]`)
|
||||||
|
expect(iconElement).not.toBeNull()
|
||||||
|
const button = iconElement?.closest('button')
|
||||||
|
expect(button).not.toBeNull()
|
||||||
|
return button as HTMLButtonElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlotIconButton(text: string) {
|
||||||
|
const iconElement = Array.from(document.querySelectorAll('.test-icon')).find(element =>
|
||||||
|
element.textContent?.includes(text),
|
||||||
|
)
|
||||||
|
expect(iconElement).toBeDefined()
|
||||||
|
const button = iconElement?.closest('button')
|
||||||
|
expect(button).not.toBeNull()
|
||||||
|
return button as HTMLButtonElement
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderList(
|
||||||
|
request: (config: AxiosRequestConfig) => unknown,
|
||||||
|
options: {
|
||||||
|
active?: boolean
|
||||||
|
item?: FileItem
|
||||||
|
refreshpending?: boolean
|
||||||
|
sort?: string
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const axios = Object.assign(vi.fn(), { request: vi.fn(request) }) as unknown as AxiosInstance
|
||||||
|
const result = await renderWithProviders(FileList, {
|
||||||
|
global: { stubs },
|
||||||
|
props: {
|
||||||
|
active: options.active ?? true,
|
||||||
|
axios,
|
||||||
|
endpoints,
|
||||||
|
item: options.item ?? createItem({ name: 'media', path: '/media/', type: 'dir' }),
|
||||||
|
refreshpending: options.refreshpending ?? false,
|
||||||
|
sort: options.sort ?? 'name',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ...result, axios }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestConfig(axios: AxiosInstance, index: number) {
|
||||||
|
return vi.mocked(axios.request).mock.calls[index]?.[0] as AxiosRequestConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FileList list state', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the endpoint method, sort URL and current file item payload, then renders directories before files', async () => {
|
||||||
|
const directory = createItem({ name: 'z-directory', path: '/media/z-directory/', type: 'dir' })
|
||||||
|
const file = createItem({ name: 'a-file.mkv', path: '/media/a-file.mkv' })
|
||||||
|
const { axios } = await renderList(() => Promise.resolve([file, directory]))
|
||||||
|
|
||||||
|
await screen.findByText('z-directory')
|
||||||
|
const config = getRequestConfig(axios, 0)
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
data: expect.objectContaining({ path: '/media/', storage: 'local', type: 'dir' }),
|
||||||
|
method: 'post',
|
||||||
|
url: '/storage/list/name',
|
||||||
|
})
|
||||||
|
const renderedNames = Array.from(document.querySelectorAll('.test-list-item')).map(item => item.textContent)
|
||||||
|
expect(renderedNames[0]).toContain('z-directory')
|
||||||
|
expect(renderedNames[1]).toContain('a-file.mkv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by substring, wildcard and case sensitivity', async () => {
|
||||||
|
await renderList(() =>
|
||||||
|
Promise.resolve([
|
||||||
|
createItem({ name: 'Alpha.MKV' }),
|
||||||
|
createItem({ name: 'beta.mp4' }),
|
||||||
|
createItem({ name: 'notes.txt' }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const filter = await screen.findByPlaceholderText(/搜索/)
|
||||||
|
|
||||||
|
await fireEvent.update(filter, 'alpha')
|
||||||
|
expect(screen.getByText('Alpha.MKV')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('beta.mp4')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.update(filter, '*.mp4')
|
||||||
|
expect(screen.getByText('beta.mp4')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Alpha.MKV')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-format-letter-case'))
|
||||||
|
await fireEvent.update(filter, 'BETA')
|
||||||
|
expect(screen.queryByText('beta.mp4')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes once when the parent changes sort and requests a refresh, then ignores the stale response', async () => {
|
||||||
|
const first = deferred<FileItem[]>()
|
||||||
|
const second = deferred<FileItem[]>()
|
||||||
|
const request = vi.fn()
|
||||||
|
request.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||||
|
const { axios, rerender } = await renderList(request, { sort: 'name' })
|
||||||
|
|
||||||
|
await rerender({ refreshpending: true, sort: 'time' })
|
||||||
|
await waitFor(() => expect(axios.request).toHaveBeenCalledTimes(2))
|
||||||
|
expect(getRequestConfig(axios, 1).url).toBe('/storage/list/time')
|
||||||
|
|
||||||
|
second.resolve([createItem({ name: 'newest.mkv' })])
|
||||||
|
await screen.findByText('newest.mkv')
|
||||||
|
first.resolve([createItem({ name: 'stale.mkv' })])
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(screen.getByText('newest.mkv')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('stale.mkv')).not.toBeInTheDocument()
|
||||||
|
expect(axios.request).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads when a remote file identity changes without changing its path', async () => {
|
||||||
|
const request = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([createItem({ name: 'first-drive.mkv' })])
|
||||||
|
.mockResolvedValueOnce([createItem({ name: 'second-drive.mkv' })])
|
||||||
|
const { axios, rerender } = await renderList(request, {
|
||||||
|
item: createItem({ drive_id: 'drive-a', fileid: 'folder-a', name: 'media', path: '/media/', type: 'dir' }),
|
||||||
|
})
|
||||||
|
await screen.findByText('first-drive.mkv')
|
||||||
|
|
||||||
|
await rerender({
|
||||||
|
item: createItem({ drive_id: 'drive-b', fileid: 'folder-b', name: 'media', path: '/media/', type: 'dir' }),
|
||||||
|
})
|
||||||
|
await screen.findByText('second-drive.mkv')
|
||||||
|
|
||||||
|
expect(axios.request).toHaveBeenCalledTimes(2)
|
||||||
|
expect(getRequestConfig(axios, 1).data).toMatchObject({ drive_id: 'drive-b', fileid: 'folder-b' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isolates consecutive refreshes even when path and sort stay unchanged', async () => {
|
||||||
|
const initial = Promise.resolve([createItem({ name: 'initial.mkv' })])
|
||||||
|
const olderRefresh = deferred<FileItem[]>()
|
||||||
|
const newerRefresh = deferred<FileItem[]>()
|
||||||
|
const request = vi.fn()
|
||||||
|
request
|
||||||
|
.mockReturnValueOnce(initial)
|
||||||
|
.mockReturnValueOnce(olderRefresh.promise)
|
||||||
|
.mockReturnValueOnce(newerRefresh.promise)
|
||||||
|
await renderList(request)
|
||||||
|
await screen.findByText('initial.mkv')
|
||||||
|
|
||||||
|
const refresh = getSlotIconButton('mdi-refresh')
|
||||||
|
await fireEvent.click(refresh)
|
||||||
|
await fireEvent.click(refresh)
|
||||||
|
expect(request).toHaveBeenCalledTimes(3)
|
||||||
|
|
||||||
|
newerRefresh.resolve([createItem({ name: 'newer-refresh.mkv' })])
|
||||||
|
await screen.findByText('newer-refresh.mkv')
|
||||||
|
olderRefresh.resolve([createItem({ name: 'older-refresh.mkv' })])
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(screen.queryByText('older-refresh.mkv')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pairs external loading events for overlapping refreshes without committing the stale response', async () => {
|
||||||
|
const olderRefresh = deferred<FileItem[]>()
|
||||||
|
const newerRefresh = deferred<FileItem[]>()
|
||||||
|
const request = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([createItem({ name: 'initial.mkv' })])
|
||||||
|
.mockReturnValueOnce(olderRefresh.promise)
|
||||||
|
.mockReturnValueOnce(newerRefresh.promise)
|
||||||
|
const { emitted } = await renderList(request)
|
||||||
|
await screen.findByText('initial.mkv')
|
||||||
|
const loadingEventCount = emitted().loading?.length ?? 0
|
||||||
|
|
||||||
|
const refresh = getSlotIconButton('mdi-refresh')
|
||||||
|
await fireEvent.click(refresh)
|
||||||
|
await fireEvent.click(refresh)
|
||||||
|
olderRefresh.resolve([createItem({ name: 'stale.mkv' })])
|
||||||
|
await waitFor(() => expect(document.querySelector('loading-banner-stub')).not.toBeNull())
|
||||||
|
newerRefresh.resolve([createItem({ name: 'newest.mkv' })])
|
||||||
|
await screen.findByText('newest.mkv')
|
||||||
|
|
||||||
|
expect(emitted().loading?.slice(loadingEventCount)).toEqual([[true], [true], [false], [false]])
|
||||||
|
expect(document.querySelector('loading-banner-stub')).toBeNull()
|
||||||
|
expect(screen.getByText('newest.mkv')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('stale.mkv')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes through the KeepAlive boundary only while the component is active', async () => {
|
||||||
|
const request = vi.fn().mockResolvedValue([createItem()])
|
||||||
|
const { rerender } = await renderList(request, { active: false })
|
||||||
|
await waitFor(() => expect(request).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
expect(mocks.keepAliveOptions?.active?.value).toBe(false)
|
||||||
|
await rerender({ active: true })
|
||||||
|
expect(mocks.keepAliveOptions?.active?.value).toBe(true)
|
||||||
|
await mocks.keepAliveRefresh?.({ silent: true })
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits navigation paths and acknowledges refresh requests', async () => {
|
||||||
|
const directory = createItem({ name: 'shows', path: '/media/shows/', type: 'dir' })
|
||||||
|
const request = vi.fn().mockResolvedValue([directory])
|
||||||
|
const { emitted, rerender } = await renderList(request)
|
||||||
|
await screen.findByText('shows')
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByText('shows').closest('[role="button"]') as Element)
|
||||||
|
const pathChangedEvents = emitted().pathchanged as Array<[FileItem]> | undefined
|
||||||
|
expect(pathChangedEvents?.at(-1)?.[0]).toMatchObject({
|
||||||
|
name: 'shows',
|
||||||
|
path: '/media/shows/',
|
||||||
|
storage: 'local',
|
||||||
|
type: 'dir',
|
||||||
|
})
|
||||||
|
|
||||||
|
await rerender({ refreshpending: true })
|
||||||
|
await waitFor(() => expect(request).toHaveBeenCalledTimes(2))
|
||||||
|
expect(emitted().refreshed).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('synchronizes selected entries to the newest file objects after refresh', async () => {
|
||||||
|
const original = createItem({ name: 'selected.mkv', path: '/media/selected.mkv', size: 10 })
|
||||||
|
const refreshed = createItem({ name: 'selected.mkv', path: '/media/selected.mkv', size: 20 })
|
||||||
|
const request = vi.fn().mockResolvedValueOnce([original]).mockResolvedValueOnce([refreshed])
|
||||||
|
await renderList(request)
|
||||||
|
await screen.findByText('selected.mkv')
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-select'))
|
||||||
|
await fireEvent.click(screen.getByText('selected.mkv').closest('[role="button"]') as Element)
|
||||||
|
await mocks.keepAliveRefresh?.({ silent: true })
|
||||||
|
await fireEvent.click(getIconButton('mdi-folder-arrow-right'))
|
||||||
|
|
||||||
|
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||||
|
items: [expect.objectContaining({ size: 20 })],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileList destructive operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit deletion success for a business failure and always closes loading', async () => {
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) => {
|
||||||
|
if (config.url === '/storage/delete') return Promise.resolve({ message: 'delete denied', success: false })
|
||||||
|
return Promise.resolve([createItem({ name: 'failed.mkv' })])
|
||||||
|
})
|
||||||
|
const { emitted } = await renderList(request)
|
||||||
|
await screen.findByText('failed.mkv')
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-delete-outline'))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('delete denied'))
|
||||||
|
|
||||||
|
expect(emitted().filedeleted).toBeUndefined()
|
||||||
|
expect(emitted().loading?.at(-1)).toEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes loading and reports no success when deletion throws', async () => {
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) => {
|
||||||
|
if (config.url === '/storage/delete') return Promise.reject(new Error('network down'))
|
||||||
|
return Promise.resolve([createItem({ name: 'throwing.mkv' })])
|
||||||
|
})
|
||||||
|
const { emitted } = await renderList(request)
|
||||||
|
await screen.findByText('throwing.mkv')
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-delete-outline'))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
|
||||||
|
|
||||||
|
expect(emitted().filedeleted).toBeUndefined()
|
||||||
|
expect(emitted().loading?.at(-1)).toEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits one success event, refreshes once and closes loading after a successful single delete', async () => {
|
||||||
|
const item = createItem({ name: 'deleted.mkv', path: '/media/deleted.mkv' })
|
||||||
|
let listCount = 0
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) => {
|
||||||
|
if (config.url?.startsWith('/storage/list/')) {
|
||||||
|
listCount += 1
|
||||||
|
return Promise.resolve(listCount === 1 ? [item] : [])
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true })
|
||||||
|
})
|
||||||
|
const { emitted } = await renderList(request)
|
||||||
|
await screen.findByText('deleted.mkv')
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-delete-outline'))
|
||||||
|
await waitFor(() => expect(listCount).toBe(2))
|
||||||
|
|
||||||
|
expect(request.mock.calls.filter(([config]) => config.url === '/storage/delete')).toHaveLength(1)
|
||||||
|
expect(emitted().filedeleted).toHaveLength(1)
|
||||||
|
expect(emitted().loading?.at(-1)).toEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates batch selection, refreshes once, summarizes failures and keeps failed items selected', async () => {
|
||||||
|
const first = createItem({ name: 'first.mkv', path: '/media/first.mkv' })
|
||||||
|
const failed = createItem({ name: 'failed.mkv', path: '/media/failed.mkv' })
|
||||||
|
let listCount = 0
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) => {
|
||||||
|
if (config.url?.startsWith('/storage/list/')) {
|
||||||
|
listCount += 1
|
||||||
|
return Promise.resolve(listCount === 1 ? [first, failed] : [failed])
|
||||||
|
}
|
||||||
|
if ((config.data as FileItem).name === 'failed.mkv') {
|
||||||
|
return Promise.resolve({ message: 'permission denied', success: false })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true })
|
||||||
|
})
|
||||||
|
const { emitted } = await renderList(request)
|
||||||
|
await screen.findByText('first.mkv')
|
||||||
|
const loadingEventCount = emitted().loading?.length ?? 0
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-select'))
|
||||||
|
await fireEvent.click(screen.getByText('first.mkv').closest('[role="button"]') as Element)
|
||||||
|
await fireEvent.click(screen.getByText('failed.mkv').closest('[role="button"]') as Element)
|
||||||
|
await fireEvent.click(getIconButton('mdi-delete-outline'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(listCount).toBe(2))
|
||||||
|
expect(request.mock.calls.filter(([config]) => config.url === '/storage/delete')).toHaveLength(2)
|
||||||
|
expect(request.mock.calls.filter(([config]) => config.url?.startsWith('/storage/list/'))).toHaveLength(2)
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('failed.mkv'))
|
||||||
|
expect(emitted().loading?.slice(loadingEventCount)).toEqual([[true], [false]])
|
||||||
|
await fireEvent.click(getIconButton('mdi-folder-arrow-right'))
|
||||||
|
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[1]).toMatchObject({ items: [failed] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileList dialogs, download and lifecycle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes single and selected items to scrape and reorganize boundary dialogs', async () => {
|
||||||
|
const first = createItem({ name: 'first.mkv', path: '/media/first.mkv' })
|
||||||
|
const second = createItem({ name: 'second.mkv', path: '/media/second.mkv' })
|
||||||
|
await renderList(() => Promise.resolve([first, second]))
|
||||||
|
await screen.findByText('first.mkv')
|
||||||
|
|
||||||
|
const firstRow = screen.getByText('first.mkv').closest('[role="button"]') as Element
|
||||||
|
await fireEvent.click(firstRow.querySelector('[data-icon="mdi-auto-fix"]')?.closest('button') as Element)
|
||||||
|
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[1]).toMatchObject({ items: [first] })
|
||||||
|
|
||||||
|
await fireEvent.click(firstRow.querySelector('[data-icon="mdi-folder-arrow-right"]')?.closest('button') as Element)
|
||||||
|
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||||
|
items: [first],
|
||||||
|
target_storage: 'local',
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-select'))
|
||||||
|
await fireEvent.click(firstRow)
|
||||||
|
await fireEvent.click(screen.getByText('second.mkv').closest('[role="button"]') as Element)
|
||||||
|
await fireEvent.click(getIconButton('mdi-auto-fix'))
|
||||||
|
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[1]).toMatchObject({ items: [first, second] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('executes selected scrape requests sequentially, reports results and refreshes once', async () => {
|
||||||
|
const first = createItem({ name: 'first.mkv', path: '/media/first.mkv' })
|
||||||
|
const second = createItem({ name: 'second.mkv', path: '/media/second.mkv' })
|
||||||
|
const request = vi.fn().mockResolvedValue([first, second])
|
||||||
|
mocks.apiPost.mockResolvedValue({ success: true })
|
||||||
|
await renderList(request)
|
||||||
|
await screen.findByText('first.mkv')
|
||||||
|
|
||||||
|
await fireEvent.click(getIconButton('mdi-select'))
|
||||||
|
await fireEvent.click(screen.getByText('first.mkv').closest('[role="button"]') as Element)
|
||||||
|
await fireEvent.click(screen.getByText('second.mkv').closest('[role="button"]') as Element)
|
||||||
|
await fireEvent.click(getIconButton('mdi-auto-fix'))
|
||||||
|
const scrapeEvents = mocks.openSharedDialog.mock.calls.at(-1)?.[2] as Record<
|
||||||
|
string,
|
||||||
|
(options: Record<string, unknown>) => Promise<void>
|
||||||
|
>
|
||||||
|
await scrapeEvents.scrape({ season: 2 })
|
||||||
|
|
||||||
|
expect(mocks.apiPost).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'media/scrape/local',
|
||||||
|
first,
|
||||||
|
expect.objectContaining({ params: { season: 2 } }),
|
||||||
|
)
|
||||||
|
expect(mocks.apiPost).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'media/scrape/local',
|
||||||
|
second,
|
||||||
|
expect.objectContaining({ params: { season: 2 } }),
|
||||||
|
)
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledTimes(2)
|
||||||
|
expect(request).toHaveBeenCalledTimes(2)
|
||||||
|
expect(getIconButton('mdi-select')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a temporary download URL and revokes it after the retention window', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const blob = new Blob(['download'])
|
||||||
|
const createObjectURL = vi.fn().mockReturnValue('blob:download')
|
||||||
|
const revokeObjectURL = vi.fn()
|
||||||
|
const open = vi.fn()
|
||||||
|
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||||
|
vi.stubGlobal('open', open)
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) =>
|
||||||
|
config.url === '/storage/download' ? Promise.resolve(blob) : Promise.resolve([createItem()]),
|
||||||
|
)
|
||||||
|
await renderList(request, { item: createItem({ name: 'selected.mkv', path: '/media/selected.mkv' }) })
|
||||||
|
await waitFor(() => expect(request).toHaveBeenCalled())
|
||||||
|
|
||||||
|
await fireEvent.click(getSlotIconButton('mdi-download'))
|
||||||
|
await waitFor(() => expect(open).toHaveBeenCalledWith('blob:download', '_blank'))
|
||||||
|
expect(createObjectURL).toHaveBeenCalledWith(blob)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:download')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only commits the latest image response and revokes URLs when switching files and unmounting', async () => {
|
||||||
|
const secondImage = deferred<Blob>()
|
||||||
|
const thirdImage = deferred<Blob>()
|
||||||
|
const createObjectURL = vi.fn().mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:third')
|
||||||
|
const revokeObjectURL = vi.fn()
|
||||||
|
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) => {
|
||||||
|
if (config.url === '/storage/image' && (config.data as FileItem).name === 'first.jpg') {
|
||||||
|
return Promise.resolve(new Blob(['first']))
|
||||||
|
}
|
||||||
|
if (config.url === '/storage/image' && (config.data as FileItem).name === 'second.jpg') return secondImage.promise
|
||||||
|
if (config.url === '/storage/image') return thirdImage.promise
|
||||||
|
return Promise.resolve([config.data as FileItem])
|
||||||
|
})
|
||||||
|
const { rerender, unmount } = await renderList(request, {
|
||||||
|
item: createItem({ name: 'first.jpg', path: '/media/first.jpg' }),
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(document.querySelector('img')).toHaveAttribute('src', 'blob:first'))
|
||||||
|
|
||||||
|
await rerender({ item: createItem({ name: 'second.jpg', path: '/media/second.jpg' }) })
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:first')
|
||||||
|
await rerender({ item: createItem({ name: 'third.jpg', path: '/media/third.jpg' }) })
|
||||||
|
thirdImage.resolve(new Blob(['third']))
|
||||||
|
await waitFor(() => expect(document.querySelector('img')).toHaveAttribute('src', 'blob:third'))
|
||||||
|
|
||||||
|
secondImage.resolve(new Blob(['second']))
|
||||||
|
await nextTick()
|
||||||
|
expect(createObjectURL).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:third')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads an image when its remote identity changes at the same path', async () => {
|
||||||
|
const createObjectURL = vi.fn().mockReturnValueOnce('blob:first-drive').mockReturnValueOnce('blob:second-drive')
|
||||||
|
const revokeObjectURL = vi.fn()
|
||||||
|
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) =>
|
||||||
|
config.url === '/storage/image'
|
||||||
|
? Promise.resolve(new Blob([(config.data as FileItem).fileid ?? '']))
|
||||||
|
: Promise.resolve([config.data as FileItem]),
|
||||||
|
)
|
||||||
|
const { rerender } = await renderList(request, {
|
||||||
|
item: createItem({ drive_id: 'drive-a', fileid: 'image-a', name: 'poster.jpg', path: '/media/poster.jpg' }),
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(document.querySelector('img')).toHaveAttribute('src', 'blob:first-drive'))
|
||||||
|
|
||||||
|
await rerender({
|
||||||
|
item: createItem({ drive_id: 'drive-b', fileid: 'image-b', name: 'poster.jpg', path: '/media/poster.jpg' }),
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(document.querySelector('img')).toHaveAttribute('src', 'blob:second-drive'))
|
||||||
|
|
||||||
|
const imageRequests = request.mock.calls.filter(([config]) => config.url === '/storage/image')
|
||||||
|
expect(imageRequests).toHaveLength(2)
|
||||||
|
expect(imageRequests[1]?.[0].data).toMatchObject({ drive_id: 'drive-b', fileid: 'image-b' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops an image response that arrives after unmount without creating an object URL', async () => {
|
||||||
|
const image = deferred<Blob>()
|
||||||
|
const createObjectURL = vi.fn()
|
||||||
|
const revokeObjectURL = vi.fn()
|
||||||
|
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) =>
|
||||||
|
config.url === '/storage/image' ? image.promise : Promise.resolve([config.data as FileItem]),
|
||||||
|
)
|
||||||
|
const { unmount } = await renderList(request, {
|
||||||
|
item: createItem({ name: 'late.jpg', path: '/media/late.jpg' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
image.resolve(new Blob(['late']))
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(createObjectURL).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.progressStop).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wires recursive rename progress SSE and closes resources on unmount', async () => {
|
||||||
|
const controller = { close: vi.fn(), id: 1, updateProps: vi.fn() }
|
||||||
|
mocks.openSharedDialog.mockReturnValue(controller)
|
||||||
|
const renameResult = deferred<{ success: boolean }>()
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) =>
|
||||||
|
config.url?.startsWith('/storage/rename')
|
||||||
|
? renameResult.promise
|
||||||
|
: Promise.resolve([createItem({ name: 'rename.mkv' })]),
|
||||||
|
)
|
||||||
|
const { unmount } = await renderList(request)
|
||||||
|
await screen.findByText('rename.mkv')
|
||||||
|
|
||||||
|
const row = screen.getByText('rename.mkv').closest('[role="button"]') as Element
|
||||||
|
await fireEvent.click(row.querySelector('[data-icon="mdi-rename"]')?.closest('button') as Element)
|
||||||
|
const renameEvents = mocks.openSharedDialog.mock.calls.at(-1)?.[2] as Record<string, (value?: unknown) => void>
|
||||||
|
renameEvents['update:name']?.('renamed.mkv')
|
||||||
|
renameEvents['update:recursive']?.(true)
|
||||||
|
const renamePromise = renameEvents.rename?.()
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ url: '/storage/rename?name=renamed.mkv&recursive=true' }),
|
||||||
|
)
|
||||||
|
expect(mocks.progressStart).toHaveBeenCalledOnce()
|
||||||
|
mocks.progressHandler?.(new MessageEvent('message', { data: JSON.stringify({ text_i18n: '重命名中', value: 50 }) }))
|
||||||
|
expect(controller.updateProps).toHaveBeenCalledWith(expect.objectContaining({ text: '重命名中', value: 50 }))
|
||||||
|
renameResult.resolve({ success: true })
|
||||||
|
await renamePromise
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
expect(mocks.progressStop).toHaveBeenCalled()
|
||||||
|
expect(controller.close).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the rename dialog open and does not emit success for a business failure', async () => {
|
||||||
|
const renameController = { close: vi.fn(), id: 1, updateProps: vi.fn() }
|
||||||
|
const progressController = { close: vi.fn(), id: 2, updateProps: vi.fn() }
|
||||||
|
mocks.openSharedDialog.mockReturnValueOnce(renameController).mockReturnValueOnce(progressController)
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) =>
|
||||||
|
config.url?.startsWith('/storage/rename')
|
||||||
|
? Promise.resolve({ message: 'rename denied', success: false })
|
||||||
|
: Promise.resolve([createItem({ name: 'rename.mkv' })]),
|
||||||
|
)
|
||||||
|
const { emitted } = await renderList(request)
|
||||||
|
await screen.findByText('rename.mkv')
|
||||||
|
|
||||||
|
const row = screen.getByText('rename.mkv').closest('[role="button"]') as Element
|
||||||
|
await fireEvent.click(row.querySelector('[data-icon="mdi-rename"]')?.closest('button') as Element)
|
||||||
|
const renameEvents = mocks.openSharedDialog.mock.calls[0]?.[2] as Record<string, (value?: unknown) => void>
|
||||||
|
renameEvents['update:name']?.('renamed.mkv')
|
||||||
|
renameEvents['update:recursive']?.(true)
|
||||||
|
await renameEvents.rename?.()
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('rename denied')
|
||||||
|
expect(emitted().renamed).toBeUndefined()
|
||||||
|
expect(emitted().loading?.at(-1)).toEqual([false])
|
||||||
|
expect(mocks.progressStop).toHaveBeenCalledOnce()
|
||||||
|
expect(progressController.close).toHaveBeenCalledOnce()
|
||||||
|
expect(renameController.close).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes rename progress resources without emitting success when the request throws', async () => {
|
||||||
|
const renameController = { close: vi.fn(), id: 1, updateProps: vi.fn() }
|
||||||
|
const progressController = { close: vi.fn(), id: 2, updateProps: vi.fn() }
|
||||||
|
mocks.openSharedDialog.mockReturnValueOnce(renameController).mockReturnValueOnce(progressController)
|
||||||
|
const request = vi.fn((config: AxiosRequestConfig) =>
|
||||||
|
config.url?.startsWith('/storage/rename')
|
||||||
|
? Promise.reject(new Error('network down'))
|
||||||
|
: Promise.resolve([createItem({ name: 'rename.mkv' })]),
|
||||||
|
)
|
||||||
|
const { emitted } = await renderList(request)
|
||||||
|
await screen.findByText('rename.mkv')
|
||||||
|
|
||||||
|
const row = screen.getByText('rename.mkv').closest('[role="button"]') as Element
|
||||||
|
await fireEvent.click(row.querySelector('[data-icon="mdi-rename"]')?.closest('button') as Element)
|
||||||
|
const renameEvents = mocks.openSharedDialog.mock.calls[0]?.[2] as Record<string, (value?: unknown) => void>
|
||||||
|
renameEvents['update:name']?.('renamed.mkv')
|
||||||
|
renameEvents['update:recursive']?.(true)
|
||||||
|
await renameEvents.rename?.()
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('network down')
|
||||||
|
expect(emitted().renamed).toBeUndefined()
|
||||||
|
expect(emitted().loading?.at(-1)).toEqual([false])
|
||||||
|
expect(mocks.progressStop).toHaveBeenCalledOnce()
|
||||||
|
expect(progressController.close).toHaveBeenCalledOnce()
|
||||||
|
expect(renameController.close).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -43,7 +43,7 @@ function mountNavigator(
|
|||||||
request = vi.fn().mockResolvedValue([]),
|
request = vi.fn().mockResolvedValue([]),
|
||||||
overrides: { currentPath?: string; items?: FileItem[] } = {},
|
overrides: { currentPath?: string; items?: FileItem[] } = {},
|
||||||
) {
|
) {
|
||||||
const axios = { request } as unknown as AxiosInstance
|
const axios = Object.assign(vi.fn(), { request }) as unknown as AxiosInstance
|
||||||
const endpoint = { method: 'post', url: '/unused' }
|
const endpoint = { method: 'post', url: '/unused' }
|
||||||
const endpoints: EndPoints = {
|
const endpoints: EndPoints = {
|
||||||
delete: endpoint,
|
delete: endpoint,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function mountToolbar(
|
|||||||
{ name: 'downloads', path: '/downloads/', storage: 'local', type: 'dir' },
|
{ name: 'downloads', path: '/downloads/', storage: 'local', type: 'dir' },
|
||||||
],
|
],
|
||||||
) {
|
) {
|
||||||
const axios = { request } as unknown as AxiosInstance
|
const axios = Object.assign(vi.fn(), { request }) as unknown as AxiosInstance
|
||||||
const endpoint = { method: 'post', url: '/unused' }
|
const endpoint = { method: 'post', url: '/unused' }
|
||||||
const endpoints: EndPoints = {
|
const endpoints: EndPoints = {
|
||||||
delete: endpoint,
|
delete: endpoint,
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/components/dialog/SubscribeSeasonDialog.vue',
|
'src/components/dialog/SubscribeSeasonDialog.vue',
|
||||||
'src/components/dialog/SubscribeShareDialog.vue',
|
'src/components/dialog/SubscribeShareDialog.vue',
|
||||||
'src/components/dialog/SubscribeShareStatisticsDialog.vue',
|
'src/components/dialog/SubscribeShareStatisticsDialog.vue',
|
||||||
|
'src/components/filebrowser/FileList.vue',
|
||||||
'src/components/dialog/DiscoverTabOrderDialog.vue',
|
'src/components/dialog/DiscoverTabOrderDialog.vue',
|
||||||
'src/components/dialog/SiteResourceDialog.vue',
|
'src/components/dialog/SiteResourceDialog.vue',
|
||||||
'src/components/dialog/SiteUserDataDialog.vue',
|
'src/components/dialog/SiteUserDataDialog.vue',
|
||||||
@@ -397,6 +398,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 85,
|
lines: 85,
|
||||||
statements: 85,
|
statements: 85,
|
||||||
},
|
},
|
||||||
|
'src/components/filebrowser/FileList.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
'src/utils/torrentDownloadCache.ts': {
|
'src/utils/torrentDownloadCache.ts': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
|
|||||||
Reference in New Issue
Block a user