diff --git a/src/@core/utils/__tests__/workflow.spec.ts b/src/@core/utils/__tests__/workflow.spec.ts new file mode 100644 index 00000000..b8e7e9a7 --- /dev/null +++ b/src/@core/utils/__tests__/workflow.spec.ts @@ -0,0 +1,199 @@ +import useDragAndDrop from '@core/utils/workflow' +import { mount } from '@vue/test-utils' +import { defineComponent, h, nextTick } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + addNodes: vi.fn(), + screenToFlowCoordinate: vi.fn(), + onNodesInitialized: vi.fn(), + updateNode: vi.fn(), + off: vi.fn(), + onNodesInitializedCallback: undefined as (() => void) | undefined, +})) + +vi.mock('@vue-flow/core', () => ({ + useVueFlow: () => ({ + addNodes: mocks.addNodes, + screenToFlowCoordinate: mocks.screenToFlowCoordinate, + onNodesInitialized: mocks.onNodesInitialized, + updateNode: mocks.updateNode, + }), +})) + +type DragAndDrop = ReturnType + +let current: DragAndDrop | undefined +let wrapper: ReturnType | undefined + +const Harness = defineComponent({ + name: 'WorkflowDragAndDropHarness', + setup() { + current = useDragAndDrop() + return () => h('div') + }, +}) + +function mountHarness() { + wrapper = mount(Harness) + return current! +} + +function createDataTransfer() { + return { + effectAllowed: '', + dropEffect: '', + setData: vi.fn(), + } +} + +describe('useDragAndDrop', () => { + beforeEach(() => { + mocks.addNodes.mockReset() + mocks.screenToFlowCoordinate.mockReset().mockImplementation(({ x, y }: { x: number; y: number }) => ({ x, y })) + mocks.onNodesInitialized.mockReset().mockImplementation((callback: () => void) => { + mocks.onNodesInitializedCallback = callback + return { off: mocks.off } + }) + mocks.updateNode.mockReset() + mocks.off.mockReset() + mocks.onNodesInitializedCallback = undefined + document.body.style.userSelect = '' + }) + + afterEach(() => { + wrapper?.unmount() + wrapper = undefined + current = undefined + document.body.style.userSelect = '' + }) + + it('does not enter drag-over or create a node before a drag starts', async () => { + const dnd = mountHarness() + const preventDefault = vi.fn() + + dnd.onDragOver({ preventDefault, dataTransfer: createDataTransfer() }) + dnd.onDrop({ clientX: 10, clientY: 20 }) + await nextTick() + + expect(preventDefault).toHaveBeenCalledOnce() + expect(dnd.isDragOver.value).toBe(false) + expect(mocks.addNodes).not.toHaveBeenCalled() + }) + + it('tracks drag state, prevents selection, and clears everything on document drop', async () => { + const dnd = mountHarness() + const dataTransfer = createDataTransfer() + const data = { type: 'ScanFileAction', name: '扫描目录' } + const addEventListener = vi.spyOn(document, 'addEventListener') + const removeEventListener = vi.spyOn(document, 'removeEventListener') + + dnd.onDragStart({ dataTransfer }, data) + await nextTick() + expect(dataTransfer.setData).toHaveBeenCalledWith('application/vueflow', data) + expect(dataTransfer.effectAllowed).toBe('move') + expect(dnd.draggedData.value).toEqual(data) + expect(dnd.isDragging.value).toBe(true) + expect(document.body.style.userSelect).toBe('none') + expect(addEventListener).toHaveBeenCalledWith('drop', expect.any(Function)) + expect(addEventListener).toHaveBeenCalledWith('dragend', expect.any(Function)) + + const dragOverTransfer = createDataTransfer() + const preventDefault = vi.fn() + dnd.onDragOver({ preventDefault, dataTransfer: dragOverTransfer }) + await nextTick() + expect(dnd.isDragOver.value).toBe(true) + expect(dragOverTransfer.dropEffect).toBe('move') + dnd.onDragLeave() + expect(dnd.isDragOver.value).toBe(false) + + document.dispatchEvent(new Event('drop')) + await nextTick() + expect(dnd.draggedData.value).toBeNull() + expect(dnd.isDragging.value).toBe(false) + expect(dnd.isDragOver.value).toBe(false) + expect(document.body.style.userSelect).toBe('') + expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function)) + expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function)) + }) + + it('clears the drag state when the browser cancels the native drag', async () => { + const dnd = mountHarness() + const removeEventListener = vi.spyOn(document, 'removeEventListener') + + dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' }) + await nextTick() + dnd.onDragOver({ preventDefault: vi.fn(), dataTransfer: createDataTransfer() }) + await nextTick() + + document.dispatchEvent(new Event('dragend')) + await nextTick() + + expect(dnd.draggedData.value).toBeNull() + expect(dnd.isDragging.value).toBe(false) + expect(dnd.isDragOver.value).toBe(false) + expect(document.body.style.userSelect).toBe('') + expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function)) + expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function)) + }) + + it('deep clones dropped data and centers the node after Vue Flow initializes it', async () => { + const dnd = mountHarness() + const data = { + type: 'ScanFileAction', + name: '扫描目录', + description: '扫描媒体目录', + data: { nested: { enabled: true } }, + } + + dnd.onDragStart({ dataTransfer: createDataTransfer() }, data) + dnd.onDrop({ clientX: 80, clientY: 120 }) + + expect(mocks.screenToFlowCoordinate).toHaveBeenCalledWith({ x: 80, y: 120 }) + expect(mocks.addNodes).toHaveBeenCalledOnce() + const node = mocks.addNodes.mock.calls[0][0] + expect(node).toMatchObject({ + type: 'ScanFileAction', + name: '扫描目录', + description: '扫描媒体目录', + position: { x: 80, y: 120 }, + data: { nested: { enabled: true } }, + }) + expect(node.id).toMatch(/^act_/) + expect(node.data).not.toBe(data.data) + + data.data.nested.enabled = false + expect(node.data.nested.enabled).toBe(true) + + expect(mocks.onNodesInitialized).toHaveBeenCalledOnce() + mocks.onNodesInitializedCallback?.() + expect(mocks.updateNode).toHaveBeenCalledWith(node.id, expect.any(Function)) + const update = mocks.updateNode.mock.calls[0][1] as (node: { + position: { x: number; y: number } + dimensions: { width: number; height: number } + }) => { position: { x: number; y: number } } + expect(update({ position: { x: 80, y: 120 }, dimensions: { width: 40, height: 20 } })).toEqual({ + position: { x: 60, y: 110 }, + }) + expect(mocks.off).toHaveBeenCalledOnce() + }) + + it('cleans the global selection style and drop listener when unmounted during a drag', async () => { + const dnd = mountHarness() + const removeEventListener = vi.spyOn(document, 'removeEventListener') + + dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' }) + await nextTick() + expect(document.body.style.userSelect).toBe('none') + + wrapper!.unmount() + await nextTick() + + expect(document.body.style.userSelect).toBe('') + expect(dnd.isDragging.value).toBe(false) + expect(dnd.isDragOver.value).toBe(false) + expect(dnd.draggedData.value).toBeNull() + expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function)) + expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function)) + }) +}) diff --git a/src/@core/utils/workflow.ts b/src/@core/utils/workflow.ts index e6ccdf0d..199fe554 100644 --- a/src/@core/utils/workflow.ts +++ b/src/@core/utils/workflow.ts @@ -1,5 +1,5 @@ import { useVueFlow } from '@vue-flow/core' -import { ref, watch } from 'vue' +import { onUnmounted, ref, watch } from 'vue' import { cloneDeep } from 'lodash-es' /** @@ -18,7 +18,7 @@ const state = { /** * The type of the node being dragged. */ - draggedData: ref({}), + draggedData: ref(null), isDragOver: ref(false), isDragging: ref(false), } @@ -27,6 +27,7 @@ export default function useDragAndDrop() { const { draggedData, isDragOver, isDragging } = state const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow() + let ownsDragListeners = false watch(isDragging, dragging => { document.body.style.userSelect = dragging ? 'none' : '' @@ -42,6 +43,8 @@ export default function useDragAndDrop() { isDragging.value = true document.addEventListener('drop', onDragEnd) + document.addEventListener('dragend', onDragEnd) + ownsDragListeners = true } /** @@ -70,6 +73,8 @@ export default function useDragAndDrop() { isDragOver.value = false draggedData.value = null document.removeEventListener('drop', onDragEnd) + document.removeEventListener('dragend', onDragEnd) + ownsDragListeners = false } /** @@ -78,6 +83,8 @@ export default function useDragAndDrop() { * @param {DragEvent} event */ function onDrop(event: any) { + if (!draggedData.value) return + const position = screenToFlowCoordinate({ x: event.clientX, y: event.clientY, @@ -110,6 +117,12 @@ export default function useDragAndDrop() { addNodes(newNode) } + onUnmounted(() => { + if (!ownsDragListeners) return + onDragEnd() + document.body.style.userSelect = '' + }) + return { draggedData, isDragOver, diff --git a/src/components/workflow/WorkflowSidebar.vue b/src/components/workflow/WorkflowSidebar.vue index c2b9142f..bda2e781 100644 --- a/src/components/workflow/WorkflowSidebar.vue +++ b/src/components/workflow/WorkflowSidebar.vue @@ -102,7 +102,7 @@ watch( // 获取动作步骤文本 function getActionStepText(type: string | undefined) { if (!type) return '' - return actionStepDict[type] + return actionStepDict[type] || type } onMounted(() => { diff --git a/src/components/workflow/__tests__/WorkflowSidebar.spec.ts b/src/components/workflow/__tests__/WorkflowSidebar.spec.ts new file mode 100644 index 00000000..e607612c --- /dev/null +++ b/src/components/workflow/__tests__/WorkflowSidebar.spec.ts @@ -0,0 +1,175 @@ +import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue' +import i18n from '@/plugins/i18n' +import { fireEvent, waitFor } from '@testing-library/vue' +import { mount, type VueWrapper } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), + onDragStart: vi.fn(), + smAndDown: { value: false }, + appMode: { value: false }, +})) + +vi.mock('@/api', () => ({ + default: { + get: (...args: unknown[]) => mocks.apiGet(...args), + }, +})) + +vi.mock('@core/utils/workflow', () => ({ + default: () => ({ onDragStart: mocks.onDragStart }), +})) + +vi.mock('@/composables/usePWA', () => ({ + usePWA: () => ({ appMode: mocks.appMode }), +})) + +vi.mock('vuetify', async importOriginal => ({ + ...(await importOriginal()), + useDisplay: () => ({ smAndDown: mocks.smAndDown }), +})) + +const ButtonStub = defineComponent({ + name: 'VBtn', + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h('button', { ...attrs, type: 'button' }, slots.default?.()) + }, +}) + +const IconButtonStub = defineComponent({ + name: 'IconBtn', + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h('button', { ...attrs, type: 'button' }, slots.default?.()) + }, +}) + +const IconStub = defineComponent({ + name: 'VIcon', + inheritAttrs: false, + props: { + icon: { + type: String, + default: '', + }, + }, + setup(props, { attrs }) { + return () => h('span', { ...attrs, 'data-icon': props.icon }) + }, +}) + +const BoxStub = defineComponent({ + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h('div', attrs, slots.default?.()) + }, +}) + +function mountSidebar(): VueWrapper { + const wrapper = mount(WorkflowSidebar, { + global: { + plugins: [i18n], + stubs: { + VBtn: ButtonStub, + VIcon: IconStub, + VAvatar: BoxStub, + VCard: BoxStub, + IconBtn: IconButtonStub, + }, + }, + }) + mountedWrappers.push(wrapper) + return wrapper +} + +let mountedWrappers: VueWrapper[] = [] + +describe('WorkflowSidebar', () => { + beforeEach(() => { + mocks.apiGet.mockReset().mockResolvedValue([ + { name: '扫描目录', type: 'ScanFileAction' }, + { name: 'UnknownActionName', type: 'UnknownAction' }, + ]) + mocks.onDragStart.mockReset() + mocks.smAndDown.value = false + mocks.appMode.value = false + }) + + afterEach(() => { + mountedWrappers.forEach(wrapper => wrapper.unmount()) + mountedWrappers = [] + }) + + it('loads actions, maps known icons, and keeps unknown action names visible', async () => { + const wrapper = mountSidebar() + + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('workflow/actions')) + await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2)) + + const items = wrapper.findAll('.component-item') + expect(items).toHaveLength(2) + expect(items[0].find('.component-name').text()).toBe('Scan Directory') + expect(items[0].find('[data-icon]').attributes('data-icon')).toBe('mdi-folder-search') + expect(items[1].find('.component-name').text()).toBe('UnknownActionName') + expect(items[1].find('[data-icon]').attributes('data-icon')).toBe('mdi-puzzle-outline') + }) + + it('keeps an API loading failure from rendering stale or malformed actions', async () => { + const error = new Error('workflow actions unavailable') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.apiGet.mockRejectedValueOnce(error) + + const wrapper = mountSidebar() + + await waitFor(() => expect(consoleError).toHaveBeenCalledWith(error)) + expect(wrapper.findAll('.component-item')).toHaveLength(0) + + consoleError.mockRestore() + }) + + it('collapses only on desktop and starts drag without treating a click as a drag', async () => { + const wrapper = mountSidebar() + await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2)) + + expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-collapsed') + await fireEvent.click(wrapper.find('.collapse-btn').element) + expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-collapsed') + expect(wrapper.findAll('.component-info')).toHaveLength(0) + + await fireEvent.click(wrapper.find('.component-item').element) + expect(mocks.onDragStart).not.toHaveBeenCalled() + + const dataTransfer = { effectAllowed: '', setData: vi.fn() } + await fireEvent.dragStart(wrapper.find('.component-item').element, { dataTransfer }) + expect(mocks.onDragStart).toHaveBeenCalledWith(expect.any(Event), expect.objectContaining({ name: '扫描目录' })) + }) + + it('opens the mobile sidebar, emits the selected action, and closes after selection', async () => { + mocks.smAndDown.value = true + mocks.appMode.value = true + const wrapper = mountSidebar() + await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2)) + + const trigger = wrapper.find('.workflow-sidebar-trigger') + expect(trigger.exists()).toBe(true) + expect(trigger.classes()).toContain('bottom-28') + expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-mobile') + expect(wrapper.find('.component-item').attributes('draggable')).toBe('false') + + await fireEvent.click(trigger.element) + expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-mobile-open') + expect(wrapper.find('.workflow-sidebar-fab [data-icon]').attributes('data-icon')).toBe('mdi-close') + + await fireEvent.click(wrapper.find('.component-item').element) + expect(wrapper.emitted('component-click')).toEqual([[{ name: '扫描目录', type: 'ScanFileAction' }]]) + expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-mobile-open') + expect(mocks.onDragStart).not.toHaveBeenCalled() + + mocks.smAndDown.value = false + await wrapper.vm.$nextTick() + expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-mobile-open') + }) +}) diff --git a/src/pages/__tests__/music-detail.spec.ts b/src/pages/__tests__/music-detail.spec.ts index d30d73f0..13ac30c0 100644 --- a/src/pages/__tests__/music-detail.spec.ts +++ b/src/pages/__tests__/music-detail.spec.ts @@ -21,6 +21,12 @@ vi.mock('@/composables/useSharedDialog', () => ({ openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), })) +vi.mock('@/api/nprogress', () => ({ + configureNProgress: vi.fn(), + doneNProgress: vi.fn(), + startNProgress: vi.fn(), +})) + vi.mock('@/api', () => ({ default: createDataApiMock({ get: (...args: unknown[]) => mocks.apiGet(...args), diff --git a/vite.config.ts b/vite.config.ts index be66ebc0..8002d7f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -309,6 +309,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/utils/permission.ts', 'src/utils/pluginSidebarNav.ts', 'src/utils/requestOptimizer.ts', + 'src/@core/utils/workflow.ts', 'src/utils/backgroundManager.ts', 'src/utils/sseManager.ts', 'src/utils/federationLoader.ts', @@ -363,6 +364,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/components/dialog/UserAddEditDialog.vue', 'src/components/dialog/WorkflowAddEditDialog.vue', 'src/components/dialog/WorkflowActionsDialog.vue', + 'src/components/workflow/WorkflowSidebar.vue', 'src/components/dialog/OTPAuthDialog.vue', 'src/components/dialog/PasskeyDialog.vue', 'src/components/dialog/SiteCookieUpdateDialog.vue', @@ -571,6 +573,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 90, statements: 90, }, + 'src/components/workflow/WorkflowSidebar.vue': { + branches: 80, + functions: 85, + lines: 85, + statements: 85, + }, + 'src/@core/utils/workflow.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, 'src/components/dialog/OTPAuthDialog.vue': { branches: 80, functions: 85,