test(workflow): cover dynamic action options (#712)

This commit is contained in:
InfinityPacer
2026-08-24 21:58:34 +08:00
committed by GitHub
parent cf61a42749
commit f6a5d1dfa0
6 changed files with 342 additions and 0 deletions
@@ -0,0 +1,58 @@
import FetchTorrentsAction from '@/components/workflow/FetchTorrentsAction.vue'
import { waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSelectItems, workflowActionStubs } from './workflowActionTestUtils'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: (...args: unknown[]) => mocks.apiGet(...args),
}),
}))
async function renderAction() {
return renderWithProviders(FetchTorrentsAction, {
props: {
id: 'fetch-torrents',
data: { search_type: 'media', sites: [] },
},
global: { stubs: workflowActionStubs },
})
}
describe('FetchTorrentsAction', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
})
it('keeps active sites and maps their names and ids to options', async () => {
mocks.apiGet.mockResolvedValue({
success: true,
message: '',
data: [
{ id: 1, name: '启用站点', is_active: true },
{ id: 2, name: '停用站点', is_active: false },
],
})
const { container } = await renderAction()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('site/rss'))
expect(getSelectItems(container, '站点')).toEqual([{ title: '启用站点', value: 1 }])
})
it('keeps site options empty when loading fails', async () => {
const error = new Error('site list unavailable')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mocks.apiGet.mockRejectedValue(error)
const { container } = await renderAction()
await waitFor(() => expect(consoleError).toHaveBeenCalled())
expect(getSelectItems(container, '站点')).toEqual([])
})
})
@@ -0,0 +1,65 @@
import FilterTorrentsAction from '@/components/workflow/FilterTorrentsAction.vue'
import { waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSelectItems, workflowActionStubs } from './workflowActionTestUtils'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: (...args: unknown[]) => mocks.apiGet(...args),
}),
}))
async function renderAction(initialState: Record<string, Record<string, unknown>> = {}) {
return renderWithProviders(FilterTorrentsAction, {
props: {
id: 'filter-torrents',
data: { rule_groups: [] },
},
initialState,
global: { stubs: workflowActionStubs },
})
}
describe('FilterTorrentsAction', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
})
it('does not request admin filter groups for regular users', async () => {
const { container } = await renderAction()
expect(mocks.apiGet).not.toHaveBeenCalled()
expect(getSelectItems(container, '过滤规则组')).toEqual([])
})
it('maps unwrapped admin filter groups to name options', async () => {
mocks.apiGet.mockResolvedValue({
success: true,
message: '',
data: { value: [{ name: '高清规则' }, { name: '字幕规则' }] },
})
const { container } = await renderAction({ user: { superUser: true } })
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/UserFilterRuleGroups'))
expect(getSelectItems(container, '过滤规则组')).toEqual([
{ title: '高清规则', value: '高清规则' },
{ title: '字幕规则', value: '字幕规则' },
])
})
it('keeps filter group options empty when loading fails', async () => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
mocks.apiGet.mockRejectedValue(new Error('rule groups unavailable'))
const { container } = await renderAction({ user: { superUser: true } })
await waitFor(() => expect(consoleLog).toHaveBeenCalled())
expect(getSelectItems(container, '过滤规则组')).toEqual([])
})
})
@@ -0,0 +1,52 @@
import ScanFileAction from '@/components/workflow/ScanFileAction.vue'
import { waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSelectItems, workflowActionStubs } from './workflowActionTestUtils'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: (...args: unknown[]) => mocks.apiGet(...args),
}),
}))
async function renderAction() {
return renderWithProviders(ScanFileAction, {
props: {
id: 'scan-file',
data: { storage: '', directory: '' },
},
global: { stubs: workflowActionStubs },
})
}
describe('ScanFileAction', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
})
it('maps unwrapped storage names and types to options', async () => {
mocks.apiGet.mockResolvedValue({
success: true,
message: '',
data: {
value: [
{ name: '本地存储', type: 'local' },
{ name: '阿里云盘', type: 'alipan' },
],
},
})
const { container } = await renderAction()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/public/Storages'))
expect(getSelectItems(container, '存储')).toEqual([
{ title: '本地存储', value: 'local' },
{ title: '阿里云盘', value: 'alipan' },
])
})
})
@@ -0,0 +1,70 @@
import SendMessageAction from '@/components/workflow/SendMessageAction.vue'
import { waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSelectItems, workflowActionStubs } from './workflowActionTestUtils'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: (...args: unknown[]) => mocks.apiGet(...args),
}),
}))
async function renderAction(initialState: Record<string, Record<string, unknown>> = {}) {
return renderWithProviders(SendMessageAction, {
props: {
id: 'send-message',
data: { client: [], userid: '' },
},
initialState,
global: { stubs: workflowActionStubs },
})
}
describe('SendMessageAction', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
})
it('does not request admin notification channels for regular users', async () => {
const { container } = await renderAction()
expect(mocks.apiGet).not.toHaveBeenCalled()
expect(getSelectItems(container, '渠道')).toEqual([])
})
it('maps unwrapped admin notification channels to name options', async () => {
mocks.apiGet.mockResolvedValue({
success: true,
message: '',
data: {
value: [
{ name: 'Telegram', type: 'telegram', enabled: true },
{ name: '企业微信', type: 'wechat', enabled: false },
],
},
})
const { container } = await renderAction({ user: { superUser: true } })
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/Notifications'))
expect(getSelectItems(container, '渠道')).toEqual([
{ title: 'Telegram', value: 'Telegram' },
{ title: '企业微信', value: '企业微信' },
])
})
it('keeps notification options empty when loading fails', async () => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
mocks.apiGet.mockRejectedValue(new Error('notifications unavailable'))
const { container } = await renderAction({ user: { superUser: true } })
await waitFor(() => expect(consoleLog).toHaveBeenCalled())
expect(getSelectItems(container, '渠道')).toEqual([])
})
})
@@ -0,0 +1,69 @@
import { defineComponent, h } from 'vue'
/** 将布局和连接器替换为可预测的容器,测试只观察动作配置数据。 */
export const BoxStub = defineComponent({
name: 'WorkflowActionBoxStub',
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('div', attrs, slots.default?.())
},
})
/** 暴露 VSelect 的 items,避免测试依赖 Vuetify 菜单和 Teleport 实现。 */
export const SelectStub = defineComponent({
name: 'WorkflowActionSelectStub',
inheritAttrs: false,
props: {
label: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
},
setup(props) {
return () =>
h('div', {
'data-select-label': props.label,
'data-select-items': JSON.stringify(props.items),
})
},
})
/** 输入控件仅保留可渲染形状;v-model 和 Vuetify 内部行为不属于本组契约。 */
export const InputStub = defineComponent({
name: 'WorkflowActionInputStub',
inheritAttrs: false,
setup(_, { attrs }) {
return () => h('input', { 'aria-label': typeof attrs.label === 'string' ? attrs.label : undefined })
},
})
export const workflowActionStubs = {
Handle: BoxStub,
VAvatar: BoxStub,
VCard: BoxStub,
VCardItem: BoxStub,
VCardSubtitle: BoxStub,
VCardText: BoxStub,
VCardTitle: BoxStub,
VCol: BoxStub,
VDivider: BoxStub,
VIcon: BoxStub,
VPathField: InputStub,
VRow: BoxStub,
VSelect: SelectStub,
VSwitch: InputStub,
VTextField: InputStub,
}
/** 按业务字段读取稳定 stub 暴露的选项。 */
export function getSelectItems(container: ParentNode, label: string): unknown[] {
const element = [...container.querySelectorAll<HTMLElement>('[data-select-items]')].find(
item => item.dataset.selectLabel === label,
)
if (!element) throw new Error(`Select stub not found: ${label}`)
return JSON.parse(element.dataset.selectItems ?? '[]') as unknown[]
}
+28
View File
@@ -366,6 +366,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/dialog/WorkflowActionsDialog.vue',
'src/components/workflow/InvokePluginAction.vue',
'src/components/workflow/WorkflowSidebar.vue',
'src/components/workflow/FetchTorrentsAction.vue',
'src/components/workflow/FilterTorrentsAction.vue',
'src/components/workflow/ScanFileAction.vue',
'src/components/workflow/SendMessageAction.vue',
'src/components/dialog/OTPAuthDialog.vue',
'src/components/dialog/PasskeyDialog.vue',
'src/components/dialog/SiteCookieUpdateDialog.vue',
@@ -608,6 +612,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 85,
statements: 85,
},
'src/components/workflow/FetchTorrentsAction.vue': {
branches: 90,
functions: 30,
lines: 90,
statements: 90,
},
'src/components/workflow/FilterTorrentsAction.vue': {
branches: 85,
functions: 10,
lines: 90,
statements: 90,
},
'src/components/workflow/ScanFileAction.vue': {
branches: 80,
functions: 30,
lines: 90,
statements: 90,
},
'src/components/workflow/SendMessageAction.vue': {
branches: 85,
functions: 30,
lines: 90,
statements: 90,
},
'src/@core/utils/workflow.ts': {
branches: 85,
functions: 90,