test(filebrowser): cover file list operations (#621)

This commit is contained in:
InfinityPacer
2026-08-01 07:26:59 +08:00
committed by GitHub
parent 36b901f717
commit 9e35378ab4
10 changed files with 838 additions and 72 deletions

View File

@@ -445,9 +445,6 @@
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"prefer-const": {
"count": 1
},
"vue/no-v-text-v-html-on-component": {
"count": 2
}

View File

@@ -20,8 +20,9 @@ const props = defineProps({
storages: Array as PropType<StorageConf[]>,
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
axios: {
type: Object as PropType<AxiosInstance>,
type: Function as PropType<AxiosInstance>,
required: true,
},
axiosconfig: Object,

View File

@@ -4,7 +4,7 @@ import type { PropType } from 'vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from 'vue-toastification'
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 { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
@@ -36,8 +36,9 @@ const { availableHeight: listAvailableHeight } = useAvailableHeight(100, 300)
const inProps = defineProps({
icons: Object,
endpoints: Object as PropType<EndPoints>,
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
axios: {
type: Object as PropType<AxiosInstance>,
type: Function as PropType<AxiosInstance>,
required: true,
},
refreshpending: Boolean,
@@ -225,6 +226,11 @@ const transferItems = ref<FileItem[]>([])
// 当前图片地址
const currentImgLink = ref('')
let imageRequestSeed = 0
// request seed 决定响应提交和内部 loading 归属;外部 loading 按非静默调用配对,重叠时也分别完成事件收口。
let listLoadingRequestSeed = 0
let listRequestSeed = 0
// 释放当前图片预览使用的临时对象地址。
function revokeCurrentImgLink() {
if (!currentImgLink.value) return
@@ -253,11 +259,11 @@ function exitSelectMode() {
// 调API加载文件夹内的内容
async function list_files(context: KeepAliveRefreshContext = {}) {
const requestSeed = ++listRequestSeed
const silentRefresh = Boolean(context.silent && items.value.length > 0)
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/')
const prevURI = takeURISnapshot()
if (!silentRefresh) {
listLoadingRequestSeed = requestSeed
loading.value = true
emit('loading', true)
}
@@ -274,10 +280,8 @@ async function list_files(context: KeepAliveRefreshContext = {}) {
// 加载数据
const data = (await inProps.axios.request<FileItem[], FileItem[]>(config)) ?? []
// 如果当前路径已经变化,则放弃此次加载结果
if (prevURI !== takeURISnapshot()) {
return
}
if (requestSeed !== listRequestSeed) return
items.value = data
syncSelectedItems(data)
@@ -288,11 +292,21 @@ async function list_files(context: KeepAliveRefreshContext = {}) {
} finally {
if (!silentRefresh) {
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) {
if (confirm) {
@@ -309,21 +323,23 @@ async function deleteItem(item: FileItem, confirm: boolean = true) {
// 加载中
emit('loading', true)
// 请求API
const url = inProps.endpoints?.delete.url
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.delete.method || 'post',
data: item,
try {
const result = await requestDeleteItem(item)
if (!result.success) {
$toast.error(result.message || t('common.error'))
return false
}
emit('filedeleted')
await list_files()
return true
} catch (error) {
console.error(error)
$toast.error(error instanceof Error ? error.message : t('common.error'))
return false
} finally {
emit('loading', false)
}
await inProps.axios.request(config)
// 删除完成
emit('loading', false)
emit('filedeleted')
// 重新加载
list_files()
}
// 批量删除
@@ -340,24 +356,42 @@ async function batchDelete() {
// 显示进度条
progressValue.value = 0
openProgressDialog(progressText.value, progressValue.value)
emit('loading', true)
try {
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 })
progressDialogController?.updateProps({ text: progressText.value })
await deleteItem(item, false)
progressValue.value = Math.round(((index + 1) / selectedItems.length) * 100)
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)
}
}
exitSelectMode()
if (failedItems.length) {
selected.value = failedItems
$toast.error(`${t('common.error')}: ${failedItems.map(item => item.name).join(', ')}`)
} else {
exitSelectMode()
}
} finally {
// 关闭进度条
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) {
let url = inProps.endpoints?.image.url
const requestSeed = ++imageRequestSeed
const url = inProps.endpoints?.image.url
// 下载文件
const config: AxiosRequestConfig<FileItem> = {
url,
@@ -409,7 +444,7 @@ async function getImgLink(item: FileItem) {
}
// 加载二进制数据
const result: Blob = await inProps.axios.request<Blob, Blob>(config)
if (result) {
if (result && requestSeed === imageRequestSeed) {
// 创建图片地址
revokeCurrentImgLink()
currentImgLink.value = URL.createObjectURL(result)
@@ -420,12 +455,11 @@ async function getImgLink(item: FileItem) {
watch(
() => inProps.item,
async () => {
imageRequestSeed += 1
revokeCurrentImgLink()
if (isImage.value && isFile.value) {
await getImgLink(inProps.item)
return
}
revokeCurrentImgLink()
},
{ immediate: true },
)
@@ -487,51 +521,51 @@ async function get_recommend_name() {
renameDialogController?.updateProps({ loading: false, name: newName.value })
}
// 重命名
// 仅在后端确认成功后关闭编辑弹窗并通知刷新;失败时保留输入以便重试。
async function rename() {
emit('loading', true)
const recursive = renameAll.value
// 显示进度条
progressValue.value = 0
if (renameAll.value) {
if (recursive) {
progressText.value = t('file.renamingAll', { path: currentItem.value?.path })
} else {
progressText.value = t('file.renaming', { name: currentItem.value?.name })
}
openProgressDialog(progressText.value, progressValue.value)
if (renameAll.value) {
if (recursive) {
startLoadingProgress()
}
// 调API
let url = inProps.endpoints?.rename.url.replace(/{newname}/g, encodeURIComponent(newName.value))
if (renameAll.value) {
url += '&recursive=true'
}
try {
let url = inProps.endpoints?.rename.url.replace(/{newname}/g, encodeURIComponent(newName.value))
if (recursive) url += '&recursive=true'
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.rename.method || 'post',
data: currentItem.value,
}
const result: { [key: string]: any } = await inProps.axios?.request<any, { [key: string]: any }>(config)
if (!result.success) {
$toast.error(result.message)
}
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.rename.method || 'post',
data: currentItem.value,
}
const result: { [key: string]: any } = await inProps.axios.request<any, { [key: string]: any }>(config)
if (!result.success) {
$toast.error(result.message || t('common.error'))
return
}
// 关闭进度条
if (renameAll.value) {
stopLoadingProgress()
newName.value = ''
renameAll.value = false
renameDialogController?.close()
renameDialogController = null
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)
}
closeProgressDialog()
// 通知重新加载
newName.value = ''
renameAll.value = false
renameDialogController?.close()
renameDialogController = null
emit('loading', false)
emit('renamed')
}
// 显示整理对话框
@@ -590,7 +624,7 @@ watch(
// 监听item变化
watch(
[() => inProps.item],
() => inProps.item,
async () => {
// 清空列表
items.value = []
@@ -776,6 +810,8 @@ useKeepAliveRefresh(list_files, {
})
onUnmounted(() => {
imageRequestSeed += 1
listLoadingRequestSeed = ++listRequestSeed
revokeCurrentImgLink()
stopLoadingProgress()
closeProgressDialog()

View File

@@ -34,8 +34,9 @@ const props = defineProps({
default: () => [],
},
endpoints: Object,
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
axios: {
type: Object as PropType<AxiosInstance>,
type: Function as PropType<AxiosInstance>,
required: true,
},
})

View File

@@ -31,8 +31,9 @@ const inProps = defineProps({
required: true,
},
endpoints: Object as PropType<EndPoints>,
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
axios: {
type: Object as PropType<AxiosInstance>,
type: Function as PropType<AxiosInstance>,
required: true,
},
sort: {

View File

@@ -79,7 +79,7 @@ const FileListStub = defineComponent({
function createBrowserProps() {
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 endpoints: EndPoints = {
delete: endpoint,

View File

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

View File

@@ -43,7 +43,7 @@ function mountNavigator(
request = vi.fn().mockResolvedValue([]),
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 endpoints: EndPoints = {
delete: endpoint,

View File

@@ -41,7 +41,7 @@ function mountToolbar(
{ 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 endpoints: EndPoints = {
delete: endpoint,

View File

@@ -318,6 +318,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/dialog/SubscribeSeasonDialog.vue',
'src/components/dialog/SubscribeShareDialog.vue',
'src/components/dialog/SubscribeShareStatisticsDialog.vue',
'src/components/filebrowser/FileList.vue',
'src/components/dialog/DiscoverTabOrderDialog.vue',
'src/components/dialog/SiteResourceDialog.vue',
'src/components/dialog/SiteUserDataDialog.vue',
@@ -397,6 +398,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 85,
statements: 85,
},
'src/components/filebrowser/FileList.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/utils/torrentDownloadCache.ts': {
branches: 85,
functions: 90,