// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h } from 'vue' import { mount, flushPromises } from '@vue/test-utils' import ArcoVue from '@arco-design/web-vue' const mockCreateTask = vi.hoisted(() => vi.fn()) const mockMessage = vi.hoisted(() => ({ warning: vi.fn(), success: vi.fn(), error: vi.fn(), })) const mockFetchSkills = vi.hoisted(() => vi.fn(async () => ({ categories: [ { name: 'local', description: '', skills: [ { name: 'planner', description: 'Plan work' }, { name: 'reviewer', description: 'Review work' }, { name: 'disabled-skill', description: 'Disabled', enabled: false }, ], }, ], archived: [], }))) vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, }), })) vi.mock('@/stores/hermes/kanban', () => ({ useKanbanStore: () => ({ assignees: [{ name: 'alice', counts: { todo: 1 } }], createTask: mockCreateTask, }), })) vi.mock('@/api/hermes/skills', () => ({ fetchSkills: mockFetchSkills, })) // KanbanCreateForm reads messages via the Arco adapter layer, not naive-ui. vi.mock('@/composables/useMessage', () => ({ useMessage: () => mockMessage })) /** * Arco's a-select/a-input-number render custom widgets (no native form * controls) and a-modal teleports to body in jsdom, so they are stubbed under * Arco's *internal* component names — stubs key off those, not the `a-*` * template tags, because Arco is registered globally via `app.use(ArcoVue)`. */ const ASelectStub = defineComponent({ name: 'Select', props: { modelValue: { default: null }, options: { type: Array, default: () => [] }, multiple: { type: Boolean, default: false }, }, emits: ['update:modelValue', 'change'], setup(props, { emit }) { return () => h('select', { class: 'arco-select-stub', multiple: props.multiple, onChange: (event: Event) => { const target = event.target as HTMLSelectElement const raw = props.multiple ? Array.from(target.selectedOptions).map(option => option.value) : target.value // Options carry non-string values (e.g. numeric priority); restore the // declared value type so the component receives what it would in prod. const options = props.options as Array<{ label: string; value: unknown }> const typed = (value: string) => options.find(option => String(option.value) === value)?.value ?? value const value = props.multiple ? (raw as string[]).map(typed) : typed(raw as string) emit('update:modelValue', value) emit('change', value) }, }, [ h('option', { value: '' }), ...(props.options as Array<{ label: string; value: unknown }>).map(option => h('option', { key: String(option.value), value: String(option.value) }, option.label), ), ]) }, }) const AInputNumberStub = defineComponent({ name: 'InputNumber', props: { modelValue: { type: [Number, String], default: undefined } }, emits: ['update:modelValue', 'change'], setup(_props, { emit }) { return () => h('input', { class: 'arco-input-number-stub', type: 'number', onChange: (event: Event) => { const raw = (event.target as HTMLInputElement).value const value = raw === '' ? undefined : Number(raw) emit('update:modelValue', value) emit('change', value) }, }) }, }) const ACheckboxStub = defineComponent({ name: 'Checkbox', props: { modelValue: Boolean }, emits: ['update:modelValue'], setup(props, { emit }) { return () => h('label', { class: 'arco-checkbox-stub' }, [ h('input', { type: 'checkbox', checked: props.modelValue, onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked), }), h('span', {}, 'checkbox-slot'), ]) }, }) const AModalStub = defineComponent({ name: 'Modal', props: ['visible'], template: '
', }) const arcoGlobal = { plugins: [ArcoVue], stubs: { Select: ASelectStub, InputNumber: AInputNumberStub, Checkbox: ACheckboxStub, Modal: AModalStub, }, } /** * Locate a form control by its Arco form-item label instead of by DOM index, so * the assertion keeps targeting the intended field even if field order changes. */ function fieldByLabel(wrapper: ReturnType, label: string) { const item = wrapper .findAll('.arco-form-item') .find(node => node.find('.arco-form-item-label').text().trim() === label) if (!item) throw new Error(`form item not found: ${label}`) return item } function selectByLabel(wrapper: ReturnType, label: string) { return fieldByLabel(wrapper, label).get('select.arco-select-stub') } function inputNumberByLabel(wrapper: ReturnType, label: string) { return fieldByLabel(wrapper, label).get('input.arco-input-number-stub') } function buttonByText(wrapper: ReturnType, text: string) { const button = wrapper.findAll('button').find(node => node.text().trim() === text) if (!button) throw new Error(`button not found: ${text}`) return button } import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue' describe('KanbanCreateForm', () => { beforeEach(() => { vi.clearAllMocks() }) it('validates required title before submit', async () => { const wrapper = mount(KanbanCreateForm, { global: arcoGlobal }) await buttonByText(wrapper, 'common.create').trigger('click') expect(mockMessage.warning).toHaveBeenCalledWith('kanban.form.titleRequired') expect(mockCreateTask).not.toHaveBeenCalled() }) it('submits trimmed values and emits created/close', async () => { mockCreateTask.mockResolvedValue({ id: 'task-1' }) const wrapper = mount(KanbanCreateForm, { global: arcoGlobal }) await fieldByLabel(wrapper, 'kanban.form.title').get('input').setValue(' Ship kanban ') await fieldByLabel(wrapper, 'kanban.form.body').get('textarea').setValue(' write tests ') await selectByLabel(wrapper, 'kanban.form.assignee').setValue('alice') await selectByLabel(wrapper, 'kanban.form.priority').setValue('3') await buttonByText(wrapper, 'common.create').trigger('click') await flushPromises() expect(mockCreateTask).toHaveBeenCalledWith({ title: 'Ship kanban', body: 'write tests', assignee: 'alice', priority: 3, }) expect(mockMessage.success).toHaveBeenCalledWith('kanban.message.taskCreated') expect(wrapper.emitted('created')).toBeTruthy() expect(wrapper.emitted('close')).toBeTruthy() }) it('submits advanced dispatcher options', async () => { mockCreateTask.mockResolvedValue({ id: 'task-1' }) const wrapper = mount(KanbanCreateForm, { global: arcoGlobal }) await flushPromises() await fieldByLabel(wrapper, 'kanban.form.title').get('input').setValue('Build parity') await fieldByLabel(wrapper, 'kanban.form.body').get('textarea').setValue('cover cli create flags') await selectByLabel(wrapper, 'kanban.form.workspace').setValue('worktree') await flushPromises() await fieldByLabel(wrapper, 'kanban.form.tenant').get('input').setValue('ops') await fieldByLabel(wrapper, 'kanban.form.workspacePath').get('input').setValue('/repo') await fieldByLabel(wrapper, 'kanban.form.branch').get('input').setValue('kanban-ui') await selectByLabel(wrapper, 'kanban.form.skills').setValue(['planner', 'reviewer']) await fieldByLabel(wrapper, 'kanban.form.maxRuntime').get('input').setValue('2h') await inputNumberByLabel(wrapper, 'kanban.form.maxRetries').setValue('3') // triage and goalMode are the two checkboxes in the advanced row. const checkboxes = wrapper.findAll('.advanced-row input[type="checkbox"]') await checkboxes[0].setValue(true) await checkboxes[1].setValue(true) await flushPromises() await inputNumberByLabel(wrapper, 'kanban.form.goalMaxTurns').setValue('12') await buttonByText(wrapper, 'common.create').trigger('click') await flushPromises() expect(mockCreateTask).toHaveBeenCalledWith({ title: 'Build parity', body: 'cover cli create flags', tenant: 'ops', triage: true, workspace: 'worktree:/repo', branch: 'kanban-ui', skills: ['planner', 'reviewer'], maxRuntime: '2h', maxRetries: 3, goalMode: true, goalMaxTurns: 12, }) }) it('uses compact profile names for assignee options', () => { const wrapper = mount(KanbanCreateForm, { global: arcoGlobal }) expect(wrapper.text()).toContain('alice') expect(wrapper.text()).not.toContain('default') expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks') }) })