mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 00:23:37 +08:00
test(filebrowser): cover shell workflows (#618)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
296
src/components/filebrowser/__tests__/FileBrowser.spec.ts
Normal file
296
src/components/filebrowser/__tests__/FileBrowser.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
166
src/components/filebrowser/__tests__/FileNavigator.spec.ts
Normal file
166
src/components/filebrowser/__tests__/FileNavigator.spec.ts
Normal 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', '/'])
|
||||
})
|
||||
})
|
||||
232
src/components/filebrowser/__tests__/FileToolbar.spec.ts
Normal file
232
src/components/filebrowser/__tests__/FileToolbar.spec.ts
Normal 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]])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
184
src/views/reorganize/__tests__/FileBrowserView.spec.ts
Normal file
184
src/views/reorganize/__tests__/FileBrowserView.spec.ts
Normal 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/'])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user