test(filebrowser): cover shell workflows (#618)

This commit is contained in:
InfinityPacer
2026-07-31 19:43:25 +08:00
committed by GitHub
parent 985dafe45f
commit 5acf6ee84c
9 changed files with 978 additions and 58 deletions

View File

@@ -438,11 +438,6 @@
"count": 1
}
},
"src/components/filebrowser/FileBrowser.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/components/filebrowser/FileList.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 6
@@ -457,14 +452,6 @@
"count": 2
}
},
"src/components/filebrowser/FileToolbar.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/components/input/PathInput.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
@@ -979,14 +966,6 @@
"count": 4
}
},
"src/views/reorganize/FileBrowserView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"prefer-const": {
"count": 1
}
},
"src/views/reorganize/TransferHistoryView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 11

View File

@@ -230,6 +230,21 @@ function preventSelect(event: Event) {
return false
}
interface DocumentDragStyle extends CSSStyleDeclaration {
MozUserSelect: string
webkitUserSelect: string
}
/** 拖动期间写入 document 全局样式,停止拖动或卸载时必须成对恢复。 */
function setDocumentDragStyles(active: boolean) {
const value = active ? 'none' : ''
const style = document.body.style as DocumentDragStyle
style.cursor = active ? 'col-resize' : ''
style.userSelect = value
style.webkitUserSelect = value
style.MozUserSelect = value
}
// 拖动分隔条相关方法
function startDrag(event: MouseEvent) {
event.preventDefault() // 阻止默认行为
@@ -243,10 +258,7 @@ function startDrag(event: MouseEvent) {
document.addEventListener('mouseup', stopDrag, { passive: false })
document.addEventListener('selectstart', preventSelect) // 阻止选择开始
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
;(document.body.style as any).webkitUserSelect = 'none' // Safari兼容
;(document.body.style as any).mozUserSelect = 'none' // Firefox兼容
setDocumentDragStyles(true)
}
function handleDrag(event: MouseEvent) {
@@ -270,11 +282,17 @@ function stopDrag() {
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('selectstart', preventSelect)
document.body.style.cursor = ''
document.body.style.userSelect = ''
;(document.body.style as any).webkitUserSelect = ''
;(document.body.style as any).mozUserSelect = ''
setDocumentDragStyles(false)
}
function cleanupDrag() {
if (isDragging.value) {
stopDrag()
}
}
onDeactivated(cleanupDrag)
onUnmounted(cleanupDrag)
</script>
<template>

View File

@@ -1,21 +1,27 @@
<script lang="ts" setup>
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import type { EndPoints, FileItem } from '@/api/types'
import type { ApiResponse, EndPoints, FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const FileNewFolderDialog = defineAsyncComponent(() => import('../dialog/FileNewFolderDialog.vue'))
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
/** 工具栏存储选择项,由文件浏览器从存储配置映射得到。 */
interface StorageOption {
// 存储类型对应的展示图标
icon: string
// 存储显示名称
title: string
// 存储类型标识
value: string
}
// 输入参数
const inProps = defineProps({
storages: Array as PropType<any[]>,
storages: Array as PropType<StorageOption[]>,
item: {
type: Object as PropType<FileItem>,
required: true,
@@ -95,24 +101,29 @@ function goUp() {
// 创建目录
async function mkdir() {
emit('loading', true)
const url = inProps.endpoints?.mkdir.url.replace(/{name}/g, newFolderName.value)
try {
const url = inProps.endpoints?.mkdir.url.replace(/{name}/g, newFolderName.value)
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.mkdir.method || 'post',
data: inProps.item,
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.mkdir.method || 'post',
data: inProps.item,
}
const result = await inProps.axios.request<unknown, ApiResponse<unknown>>(config)
if (!result?.success) {
return
}
newFolderDialogController?.close()
newFolderDialogController = null
newFolderName.value = ''
emit('foldercreated')
} catch (error) {
console.error('创建目录失败:', error)
} finally {
emit('loading', false)
}
// 调API
await inProps.axios.request(config)
newFolderDialogController?.close()
newFolderDialogController = null
newFolderName.value = ''
emit('loading', false)
// 通知重新加载
emit('foldercreated')
}
function openNewFolderDialog() {

View File

@@ -0,0 +1,296 @@
import FileBrowser from '@/components/filebrowser/FileBrowser.vue'
import type { EndPoints } from '@/api/types'
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { defineComponent, h, KeepAlive, nextTick, ref } from 'vue'
import type { AxiosInstance } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
dynamicButton: vi.fn(),
hasPermission: vi.fn(),
openNewFolderDialog: vi.fn(),
}))
vi.mock('@/composables/usePWA', () => ({
usePWA: () => ({ appMode: ref(false) }),
}))
vi.mock('@/composables/useDynamicButton', () => ({
useDynamicButton: (...args: unknown[]) => mocks.dynamicButton(...args),
}))
vi.mock('@/utils/permission', async importOriginal => {
const actual = await importOriginal<typeof import('@/utils/permission')>()
return {
...actual,
buildUserPermissionContext: vi.fn(() => ({})),
hasPermission: (...args: unknown[]) => mocks.hasPermission(...args),
}
})
vi.mock('vue-router', async importOriginal => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRoute: () => ({ path: '/filemanager' }),
}
})
const FileToolbarStub = defineComponent({
name: 'FileToolbar',
props: ['showNewFolderButton', 'sort'],
emits: ['foldercreated', 'pathchanged', 'sortchanged', 'storagechanged'],
setup(_props, { emit, expose }) {
expose({ openNewFolderDialog: mocks.openNewFolderDialog })
return () =>
h('div', [
h('button', { class: 'emit-sort', onClick: () => emit('sortchanged', 'time') }),
h('button', { class: 'emit-storage', onClick: () => emit('storagechanged', 'rclone') }),
h('button', {
class: 'emit-path',
onClick: () =>
emit('pathchanged', {
name: 'movies',
path: '/movies/',
storage: 'local',
type: 'dir',
}),
}),
h('button', { class: 'emit-folder', onClick: () => emit('foldercreated') }),
])
},
})
const FileNavigatorStub = defineComponent({
name: 'FileNavigator',
props: ['currentPath', 'items'],
emits: ['navigate'],
template: '<div />',
})
const FileListStub = defineComponent({
name: 'FileList',
props: ['refreshpending', 'sort', 'showTree'],
emits: ['items-updated', 'loading', 'pathchanged', 'refreshed', 'switch-tree'],
template:
'<div><button class="emit-loading" @click="$emit(`loading`, 1)" /><button class="emit-tree" @click="$emit(`switch-tree`, true)" /></div>',
})
function createBrowserProps() {
const request = vi.fn()
const axios = { request } as unknown as AxiosInstance
const endpoint = { method: 'post', url: '/unused' }
const endpoints: EndPoints = {
delete: endpoint,
download: endpoint,
image: endpoint,
list: endpoint,
mkdir: endpoint,
rename: endpoint,
}
return {
axios,
endpoints,
item: { name: '/', path: '/', storage: 'local', type: 'dir' as const },
itemstack: [],
storages: [{ name: '本地', type: 'local' }],
}
}
const browserStubs = {
FileList: FileListStub,
FileNavigator: FileNavigatorStub,
FileToolbar: FileToolbarStub,
Teleport: true,
VFab: true,
VIcon: true,
}
function mountBrowser() {
return mount(FileBrowser, {
props: createBrowserProps(),
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: browserStubs,
},
})
}
describe('FileBrowser drag lifecycle', () => {
beforeEach(() => {
mocks.hasPermission.mockReset()
mocks.hasPermission.mockReturnValue(false)
mocks.openNewFolderDialog.mockReset()
localStorage.setItem('fileBrowser.showDirTree', 'true')
})
it('removes document listeners and global selection styles when unmounted during a drag', async () => {
const removeEventListener = vi.spyOn(document, 'removeEventListener')
const wrapper = mountBrowser()
await wrapper.get('.divider').trigger('mousedown', { clientX: 320 })
expect(document.body.style.cursor).toBe('col-resize')
expect(document.body.style.userSelect).toBe('none')
expect(document.body.style.webkitUserSelect).toBe('none')
expect((document.body.style as CSSStyleDeclaration & { MozUserSelect: string }).MozUserSelect).toBe('none')
wrapper.unmount()
expect(removeEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('selectstart', expect.any(Function))
expect(document.body.style.cursor).toBe('')
expect(document.body.style.userSelect).toBe('')
expect(document.body.style.webkitUserSelect).toBe('')
expect((document.body.style as CSSStyleDeclaration & { MozUserSelect: string }).MozUserSelect).toBe('')
})
it('cleans document listeners and global selection styles when deactivated during a drag', async () => {
const removeEventListener = vi.spyOn(document, 'removeEventListener')
const active = ref(true)
const Host = defineComponent({
setup() {
return () =>
h(KeepAlive, null, {
default: () => (active.value ? h(FileBrowser, createBrowserProps()) : h('div', 'inactive')),
})
},
})
const wrapper = mount(Host, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: browserStubs,
},
})
await wrapper.get('.divider').trigger('mousedown', { clientX: 320 })
expect(document.body.style.cursor).toBe('col-resize')
expect(document.body.style.userSelect).toBe('none')
active.value = false
await nextTick()
expect(removeEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('selectstart', expect.any(Function))
expect(document.body.style.cursor).toBe('')
expect(document.body.style.userSelect).toBe('')
expect(document.body.style.webkitUserSelect).toBe('')
expect((document.body.style as CSSStyleDeclaration & { MozUserSelect: string }).MozUserSelect).toBe('')
})
it('clamps drag width, persists it, and cleans up on mouseup', async () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 })
const wrapper = mountBrowser()
await wrapper.get('.divider').trigger('mousedown', { clientX: 300 })
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 1200 }))
await nextTick()
expect(wrapper.getComponent(FileNavigatorStub).attributes('style')).toContain('width: 600px')
expect(localStorage.getItem('fileBrowser.navigatorWidth')).toBe('600')
document.dispatchEvent(new MouseEvent('mouseup'))
expect(document.body.style.cursor).toBe('')
expect(document.body.style.userSelect).toBe('')
})
it('prevents selection while dragging', async () => {
const wrapper = mountBrowser()
await wrapper.get('.divider').trigger('mousedown', { clientX: 300 })
const selectEvent = new Event('selectstart', { bubbles: true, cancelable: true })
document.dispatchEvent(selectEvent)
expect(selectEvent.defaultPrevented).toBe(true)
document.dispatchEvent(new MouseEvent('mouseup'))
})
})
describe('FileBrowser state and child contracts', () => {
beforeEach(() => {
mocks.hasPermission.mockReset()
mocks.hasPermission.mockReturnValue(false)
mocks.openNewFolderDialog.mockReset()
})
it('restores sorting and directory tree preferences from localStorage', () => {
localStorage.setItem('fileBrowser.sort', 'time')
localStorage.setItem('fileBrowser.showDirTree', 'true')
localStorage.setItem('fileBrowser.navigatorWidth', '360')
const wrapper = mountBrowser()
expect(wrapper.getComponent(FileToolbarStub).props('sort')).toBe('time')
expect(wrapper.getComponent(FileListStub).props('sort')).toBe('time')
expect(wrapper.getComponent(FileListStub).props('showTree')).toBe(true)
expect(wrapper.getComponent(FileNavigatorStub).attributes('style')).toContain('width: 360px')
})
it('persists sort and tree changes and requests a refresh after sorting', async () => {
const wrapper = mountBrowser()
await wrapper.get('.emit-sort').trigger('click')
await wrapper.get('.emit-tree').trigger('click')
await nextTick()
expect(localStorage.getItem('fileBrowser.sort')).toBe('time')
expect(localStorage.getItem('fileBrowser.showDirTree')).toBe('true')
expect(wrapper.getComponent(FileListStub).props('refreshpending')).toBe(true)
expect(wrapper.findComponent(FileNavigatorStub).exists()).toBe(true)
})
it('forwards storage and path navigation events', async () => {
const wrapper = mountBrowser()
await wrapper.get('.emit-storage').trigger('click')
await wrapper.get('.emit-path').trigger('click')
expect(wrapper.emitted('pathchanged')).toEqual([
[{ fileid: 'root', path: '/', storage: 'rclone' }],
[{ name: 'movies', path: '/movies/', storage: 'local', type: 'dir' }],
])
})
it('tracks child loading and refresh lifecycle through observable props', async () => {
const wrapper = mountBrowser()
await wrapper.get('.emit-loading').trigger('click')
expect(wrapper.get('.mx-auto').attributes('loading')).toBe('true')
await wrapper.get('.emit-folder').trigger('click')
expect(wrapper.getComponent(FileListStub).props('refreshpending')).toBe(true)
wrapper.getComponent(FileListStub).vm.$emit('refreshed')
await nextTick()
expect(wrapper.getComponent(FileListStub).props('refreshpending')).toBe(false)
})
it('forwards the latest file list snapshot to the directory navigator', async () => {
localStorage.setItem('fileBrowser.showDirTree', 'true')
const wrapper = mountBrowser()
const items = [{ name: 'shows', path: '/shows', storage: 'local', type: 'dir' }]
wrapper.getComponent(FileListStub).vm.$emit('items-updated', items)
await nextTick()
expect(wrapper.getComponent(FileNavigatorStub).props('items')).toEqual(items)
})
it('moves the new-folder entry to the permission-gated floating action', () => {
mocks.hasPermission.mockReturnValue(true)
const wrapper = mountBrowser()
expect(wrapper.getComponent(FileToolbarStub).props('showNewFolderButton')).toBe(false)
expect(wrapper.findComponent({ name: 'VFab' }).exists()).toBe(true)
})
it('connects the dynamic new-folder action to the toolbar controller', () => {
mountBrowser()
const options = mocks.dynamicButton.mock.calls.at(-1)?.[0] as { onClick: () => void }
options.onClick()
expect(mocks.openNewFolderDialog).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,166 @@
import FileNavigator from '@/components/filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem } from '@/api/types'
import i18n from '@/plugins/i18n'
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick } from 'vue'
import type { AxiosInstance } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
isMobile: { value: true },
}))
vi.mock('vuetify', async importOriginal => {
const actual = await importOriginal<typeof import('vuetify')>()
return {
...actual,
useDisplay: () => ({ smAndDown: mocks.isMobile }),
}
})
vi.mock('@/composables/useAvailableHeight', () => ({
useAvailableHeight: () => ({ availableHeight: { value: 500 } }),
}))
const VirtualScrollStub = defineComponent({
name: 'VVirtualScroll',
props: {
items: {
type: Array,
default: () => [],
},
},
setup(props, { slots }) {
return () =>
h(
'div',
props.items.map(item => slots.default?.({ item })),
)
},
})
function mountNavigator(
request = vi.fn().mockResolvedValue([]),
overrides: { currentPath?: string; items?: FileItem[] } = {},
) {
const axios = { request } as unknown as AxiosInstance
const endpoint = { method: 'post', url: '/unused' }
const endpoints: EndPoints = {
delete: endpoint,
download: endpoint,
image: endpoint,
list: { method: 'post', url: '/storage/list?sort={sort}' },
mkdir: endpoint,
rename: endpoint,
}
return mount(FileNavigator, {
props: {
axios,
currentPath: '/',
endpoints,
items: [],
storage: 'local',
...overrides,
},
global: {
plugins: [i18n],
stubs: {
VCard: { template: '<div class="file-navigator"><slot /></div>' },
VIcon: true,
VProgressCircular: true,
VVirtualScroll: VirtualScrollStub,
},
},
})
}
describe('FileNavigator directory tree', () => {
beforeEach(() => {
mocks.isMobile.value = false
})
it('does not render the directory tree on a mobile viewport', () => {
mocks.isMobile.value = true
const wrapper = mountNavigator()
expect(wrapper.find('.file-navigator').exists()).toBe(false)
})
it('renders only directories from the current file list', () => {
const wrapper = mountNavigator(vi.fn(), {
items: [
{ name: 'movies', path: '/movies', storage: 'local', type: 'dir' },
{ name: 'readme.txt', path: '/readme.txt', storage: 'local', type: 'file' },
],
})
expect(wrapper.text()).toContain('movies')
expect(wrapper.text()).not.toContain('readme.txt')
})
it('emits root and directory navigation from visible rows', async () => {
const directory = { name: 'movies', path: '/movies', storage: 'local', type: 'dir' }
const wrapper = mountNavigator(vi.fn(), { items: [directory] })
await wrapper.get('.root-item').trigger('click')
await wrapper.findAll('.folder-content')[1].trigger('click')
expect(wrapper.emitted('navigate')).toEqual([
[{ name: '/', path: '/', storage: 'local', type: 'dir' }],
[directory],
])
})
it('loads and filters child directories with the configured list endpoint', async () => {
const request = vi.fn().mockResolvedValue([
{ name: 'Season 01', path: '/shows/Season 01', storage: 'local', type: 'dir' },
{ name: 'poster.jpg', path: '/shows/poster.jpg', storage: 'local', type: 'file' },
])
const wrapper = mountNavigator(request, {
items: [{ name: 'shows', path: '/shows', storage: 'local', type: 'dir' }],
})
await wrapper.get('.folder-toggle').trigger('click')
await nextTick()
expect(request).toHaveBeenCalledWith({
data: { name: 'shows', path: '/shows', storage: 'local', type: 'dir' },
method: 'post',
url: '/storage/list?sort=name',
})
expect(wrapper.text()).toContain('Season 01')
expect(wrapper.text()).not.toContain('poster.jpg')
})
it('reuses cached children when a directory is collapsed and expanded again', async () => {
const request = vi
.fn()
.mockResolvedValue([{ name: 'Season 01', path: '/shows/Season 01', storage: 'local', type: 'dir' }])
const wrapper = mountNavigator(request, {
items: [{ name: 'shows', path: '/shows', storage: 'local', type: 'dir' }],
})
await wrapper.get('.folder-toggle').trigger('click')
await nextTick()
await wrapper.get('.folder-toggle').trigger('click')
await wrapper.get('.folder-toggle').trigger('click')
await nextTick()
expect(request).toHaveBeenCalledOnce()
})
it('loads uncached ancestors needed by the current path', async () => {
const requestedPaths: string[] = []
const request = vi.fn().mockImplementation(({ data }: { data: FileItem }) => {
requestedPaths.push(data.path)
return []
})
mountNavigator(request, { currentPath: '/shows/Season 01' })
await nextTick()
await nextTick()
expect(requestedPaths).toEqual(['/shows', '/'])
})
})

View File

@@ -0,0 +1,232 @@
import FileToolbar from '@/components/filebrowser/FileToolbar.vue'
import type { EndPoints, FileItem } from '@/api/types'
import i18n from '@/plugins/i18n'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import type { AxiosInstance } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
close: vi.fn(),
openSharedDialog: vi.fn(),
updateProps: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('vuetify', async importOriginal => {
const actual = await importOriginal<typeof import('vuetify')>()
return {
...actual,
useDisplay: () => ({ mdAndUp: { value: true } }),
}
})
const IconBtnStub = defineComponent({
name: 'IconBtn',
emits: ['click'],
setup(_props, { emit, slots }) {
return () => h('button', { class: 'icon-btn', type: 'button', onClick: () => emit('click') }, slots.default?.())
},
})
function mountToolbar(
request: ReturnType<typeof vi.fn>,
sort = 'name',
item: FileItem = { name: 'downloads', path: '/downloads/', storage: 'local', type: 'dir' },
itemstack: FileItem[] = [
{ name: '/', path: '/', storage: 'local', type: 'dir' },
{ name: 'downloads', path: '/downloads/', storage: 'local', type: 'dir' },
],
) {
const axios = { request } as unknown as AxiosInstance
const endpoint = { method: 'post', url: '/unused' }
const endpoints: EndPoints = {
delete: endpoint,
download: endpoint,
image: endpoint,
list: endpoint,
mkdir: {
method: 'post',
url: '/storage/mkdir?name={name}',
},
rename: endpoint,
}
return mount(FileToolbar, {
props: {
axios,
endpoints,
item,
itemstack,
sort,
storages: [
{ icon: 'mdi-harddisk', title: '本地', value: 'local' },
{ icon: 'mdi-cloud', title: '网盘', value: 'rclone' },
],
},
global: {
plugins: [i18n],
stubs: {
IconBtn: IconBtnStub,
VBtn: {
emits: ['click'],
template: '<button class="toolbar-button" type="button" @click="$emit(`click`)"><slot /></button>',
},
VIcon: true,
VList: { template: '<div><slot /></div>' },
VListItem: {
emits: ['click'],
props: ['disabled'],
template: '<button class="storage-item" type="button" @click="$emit(`click`)"><slot /></button>',
},
VListItemTitle: true,
VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' },
VToolbar: { template: '<div><slot /></div>' },
VToolbarItems: { template: '<div><slot /></div>' },
},
},
})
}
function openDialogAndCreate(wrapper: ReturnType<typeof mountToolbar>, name = 'new-folder') {
;(wrapper.vm as unknown as { openNewFolderDialog: () => void }).openNewFolderDialog()
const events = mocks.openSharedDialog.mock.calls[0][2] as {
create: () => Promise<void>
'update:name': (value: string) => void
}
events['update:name'](name)
return events.create()
}
describe('FileToolbar mkdir', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.openSharedDialog.mockReturnValue({
close: mocks.close,
id: 1,
updateProps: mocks.updateProps,
})
})
it('does not report creation when the API returns a business failure and always finishes loading', async () => {
const request = vi.fn().mockResolvedValue({ message: '目录已存在', success: false })
const wrapper = mountToolbar(request)
await openDialogAndCreate(wrapper)
await flushPromises()
expect(wrapper.emitted('loading')).toEqual([[true], [false]])
expect(wrapper.emitted('foldercreated')).toBeUndefined()
expect(mocks.close).not.toHaveBeenCalled()
})
it('does not report creation when the request rejects and always finishes loading', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const request = vi.fn().mockRejectedValue(new Error('network failed'))
const wrapper = mountToolbar(request)
await expect(openDialogAndCreate(wrapper)).resolves.toBeUndefined()
await flushPromises()
expect(wrapper.emitted('loading')).toEqual([[true], [false]])
expect(wrapper.emitted('foldercreated')).toBeUndefined()
expect(mocks.close).not.toHaveBeenCalled()
})
it('submits the current directory and closes only after a successful creation', async () => {
const request = vi.fn().mockResolvedValue({ success: true })
const wrapper = mountToolbar(request)
await openDialogAndCreate(wrapper, 'Season 01')
await flushPromises()
expect(request).toHaveBeenCalledWith({
data: { name: 'downloads', path: '/downloads/', storage: 'local', type: 'dir' },
method: 'post',
url: '/storage/mkdir?name=Season 01',
})
expect(wrapper.emitted('loading')).toEqual([[true], [false]])
expect(wrapper.emitted('foldercreated')).toEqual([[]])
expect(mocks.close).toHaveBeenCalledOnce()
expect(mocks.updateProps).toHaveBeenCalledWith({ name: 'Season 01' })
})
it('closes an open shared dialog when unmounted', () => {
const wrapper = mountToolbar(vi.fn())
;(wrapper.vm as unknown as { openNewFolderDialog: () => void }).openNewFolderDialog()
wrapper.unmount()
expect(mocks.close).toHaveBeenCalledOnce()
})
})
describe('FileToolbar navigation and sorting', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.openSharedDialog.mockReturnValue({
close: mocks.close,
id: 1,
updateProps: mocks.updateProps,
})
})
it('emits the opposite sort mode from the first toolbar action', async () => {
const wrapper = mountToolbar(vi.fn())
await wrapper.findAll('.icon-btn')[0].trigger('click')
expect(wrapper.emitted('sortchanged')).toEqual([['time']])
})
it('switches time sorting back to name sorting', async () => {
const wrapper = mountToolbar(vi.fn(), 'time')
await wrapper.findAll('.icon-btn')[0].trigger('click')
expect(wrapper.emitted('sortchanged')).toEqual([['name']])
})
it('emits only a changed storage selection', async () => {
const wrapper = mountToolbar(vi.fn())
await wrapper.findAll('.storage-item')[0].trigger('click')
await wrapper.findAll('.storage-item')[1].trigger('click')
expect(wrapper.emitted('storagechanged')).toEqual([['rclone']])
})
it('navigates to the parent breadcrumb from the up action', async () => {
const wrapper = mountToolbar(vi.fn())
await wrapper.findAll('.icon-btn')[1].trigger('click')
expect(wrapper.emitted('pathchanged')).toEqual([[{ name: '/', path: '/', storage: 'local', type: 'dir' }]])
})
it('navigates directly through root and path breadcrumb actions', async () => {
const wrapper = mountToolbar(vi.fn())
const navigationButtons = wrapper.findAll('.toolbar-button')
await navigationButtons[1].trigger('click')
await navigationButtons[2].trigger('click')
expect(wrapper.emitted('pathchanged')).toEqual([
[{ name: '/', path: '/', storage: 'local', type: 'dir' }],
[{ name: 'downloads', path: '/downloads/', storage: 'local', type: 'dir' }],
])
})
it('omits the parent action at storage root and keeps root navigation stable', async () => {
const root = { name: '/', path: '/', storage: 'local', type: 'dir' }
const wrapper = mountToolbar(vi.fn(), 'name', root, [root])
expect(wrapper.findAll('.icon-btn')).toHaveLength(2)
await wrapper.findAll('.toolbar-button')[1].trigger('click')
expect(wrapper.emitted('pathchanged')).toEqual([[root]])
})
})

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import { FileItem, StorageConf, TransferDirectoryConf } from '@/api/types'
import { ApiResponse, FileItem, StorageConf, TransferDirectoryConf } from '@/api/types'
import FileBrowser from '@/components/filebrowser/FileBrowser.vue'
const endpoints = {
@@ -51,7 +51,7 @@ function findCommonPath(paths: string[]): string {
} else {
const normalizedPaths = paths.map(path => path.replace(/\\/g, '/'))
const splitPaths = normalizedPaths.map(path => path.split('/'))
let commonParts: string[] = []
const commonParts: string[] = []
for (let i = 0; i < splitPaths[0].length; i++) {
const part = splitPaths[0][i]
if (splitPaths.every(pathParts => pathParts[i] === part)) {
@@ -81,7 +81,8 @@ interface BrowserInitialParams {
path: string
name: string
}
// determine which entry to select initially
/** 从可用存储和下载目录中选择初始入口,未配置有效目录时回退到存储根路径。 */
function determineBrowserInitialParams(downloadDirectories: TransferDirectoryConf[]): BrowserInitialParams {
const isAvailable = (storage: string) => storageTypes.value.includes(storage)
const buckets = downloadDirectories.reduce<Map<string, string[]>>((dict, item) => {
@@ -131,12 +132,17 @@ function determineBrowserInitialParams(downloadDirectories: TransferDirectoryCon
async function loadDownloadDirectories() {
try {
// fetch available storages
const storageResult: { [key: string]: any } = await api.get('system/setting/public/Storages')
const storageResult = await api.get<unknown, ApiResponse<{ value?: StorageConf[] | null }>>(
'system/setting/public/Storages',
)
storages.value = storageResult.data?.value ?? []
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
if (result.success && result.data?.value) {
const { storage, path, name } = determineBrowserInitialParams(result.data.value)
const result = await api.get<unknown, ApiResponse<{ value?: TransferDirectoryConf[] | null }>>(
'system/setting/public/Directories',
)
if (result.success) {
const directories = Array.isArray(result.data?.value) ? result.data.value : []
const { storage, path, name } = determineBrowserInitialParams(directories)
// operItem初始化
operItem.value = {
type: 'dir',

View File

@@ -0,0 +1,184 @@
import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: mocks.apiGet,
},
}))
const FileBrowserStub = defineComponent({
name: 'FileBrowser',
emits: ['pathchanged'],
props: ['item', 'itemstack', 'storages'],
template: '<div data-testid="file-browser" />',
})
function mockSettings(
storages: Array<{ name: string; type: string }> | null,
directories: Array<{ download_path?: string; storage: string }> | null,
) {
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'system/setting/public/Storages') {
return { data: { value: storages }, success: true }
}
if (endpoint === 'system/setting/public/Directories') {
return { data: { value: directories }, success: true }
}
throw new Error(`Unexpected GET ${endpoint}`)
})
}
async function mountView() {
const wrapper = mount(FileBrowserView, {
global: {
stubs: {
FileBrowser: FileBrowserStub,
},
},
})
await flushPromises()
return wrapper
}
describe('FileBrowserView initialization', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
})
it('falls back to the storage root when Directories is null', async () => {
mockSettings([{ name: '本地', type: 'local' }], null)
const wrapper = await mountView()
const browser = wrapper.getComponent(FileBrowserStub)
expect(browser.props('item')).toMatchObject({
name: '/',
path: '/',
storage: 'local',
type: 'dir',
})
expect(browser.props('itemstack')).toEqual([
{
fileid: 'root',
name: '/',
path: '/',
storage: 'local',
type: 'dir',
},
])
})
it('falls back to local root when Storages and Directories are null', async () => {
mockSettings(null, null)
const browser = (await mountView()).getComponent(FileBrowserStub)
expect(browser.props('storages')).toEqual([])
expect(browser.props('item')).toMatchObject({
name: '/',
path: '/',
storage: 'local',
type: 'dir',
})
})
it('uses the cached available storage and the common configured path', async () => {
localStorage.setItem('fileBrowserView.activeStorage', 'rclone')
mockSettings(
[
{ name: '本地', type: 'local' },
{ name: '网盘', type: 'rclone' },
],
[
{ download_path: '/media/movies', storage: 'rclone' },
{ download_path: '/media/tv', storage: 'rclone' },
{ download_path: '/downloads', storage: 'local' },
],
)
const browser = (await mountView()).getComponent(FileBrowserStub)
expect(browser.props('item')).toMatchObject({
name: 'media',
path: '/media/',
storage: 'rclone',
})
expect(browser.props('itemstack')).toHaveLength(2)
})
it('filters unavailable storage entries and selects the most populated available storage', async () => {
mockSettings(
[
{ name: '本地', type: 'local' },
{ name: '115', type: 'u115' },
],
[
{ download_path: '/ignored', storage: 'missing' },
{ download_path: '/one', storage: 'local' },
{ download_path: '/shows/a', storage: 'u115' },
{ download_path: '/shows/b', storage: 'u115' },
{ storage: 'u115' },
],
)
const browser = (await mountView()).getComponent(FileBrowserStub)
expect(browser.props('item')).toMatchObject({
name: 'shows',
path: '/shows/',
storage: 'u115',
})
})
it('updates storage persistence and rebuilds the root breadcrumb on navigation', async () => {
mockSettings(
[
{ name: '本地', type: 'local' },
{ name: '网盘', type: 'rclone' },
],
[{ download_path: '/downloads/tv', storage: 'local' }],
)
const browser = (await mountView()).getComponent(FileBrowserStub)
browser.vm.$emit('pathchanged', {
fileid: 'remote-root',
name: '/',
path: '/',
storage: 'rclone',
type: 'dir',
})
await flushPromises()
expect(localStorage.getItem('fileBrowserView.activeStorage')).toBe('rclone')
expect(browser.props('item')).toMatchObject({ path: '/', storage: 'rclone' })
expect(browser.props('itemstack')).toEqual([
{
fileid: 'remote-root',
name: '/',
path: '/',
storage: 'rclone',
type: 'dir',
},
])
})
it('truncates the breadcrumb stack when navigating to an existing ancestor', async () => {
mockSettings([{ name: '本地', type: 'local' }], [{ download_path: '/downloads/tv', storage: 'local' }])
const browser = (await mountView()).getComponent(FileBrowserStub)
browser.vm.$emit('pathchanged', {
name: 'downloads',
path: '/downloads/',
storage: 'local',
type: 'dir',
})
await flushPromises()
expect(browser.props('itemstack').map((item: { path: string }) => item.path)).toEqual(['/', '/downloads/'])
})
})

View File

@@ -325,6 +325,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/dialog/AddSubtitleDownloadDialog.vue',
'src/components/dialog/ReorganizeDialog.vue',
'src/components/dialog/TransferQueueDialog.vue',
'src/views/reorganize/FileBrowserView.vue',
'src/components/filebrowser/FileBrowser.vue',
'src/components/filebrowser/FileToolbar.vue',
'src/components/filebrowser/FileNavigator.vue',
'src/views/discover/TheMovieDbView.vue',
'src/views/discover/DoubanView.vue',
'src/views/discover/BangumiView.vue',
@@ -369,6 +373,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 80,
statements: 80,
},
'src/views/reorganize/FileBrowserView.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/components/filebrowser/FileBrowser.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/components/filebrowser/FileToolbar.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/components/filebrowser/FileNavigator.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/utils/torrentDownloadCache.ts': {
branches: 85,
functions: 90,