import { flushPromises, mount } from '@vue/test-utils' import { nextTick } from 'vue' import { describe, expect, it } from 'vitest' import FormBuilder from '../../components/FormBuilder.vue' import FormActions from '../../components/FormActions.vue' import Tooltip from '../../components/Tooltip.vue' import { generatedJobFormSchema } from '../../generated/schema' import type { GeneratedJsonSchema } from '../../types/generated-ui' import { defineControlModel, useControlWorkspace, type ControlSchema } from '../../composables/useControlSchema' import type { FormSchema } from '../../types/form-builder' const controls: ControlSchema = { threshold: { label: 'Threshold', default: 0.05, min: 0, max: 1, section: 'parameters', sectionLabel: 'Parameters', }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'], section: 'parameters', }, notes: { label: 'Notes', placeholder: 'Optional notes', section: 'metadata', sectionLabel: 'Metadata', }, } describe('FormBuilder', () => { it.each([ { name: 'nullable tags', items: { type: 'string' }, value: null, component: 'TagsInput', displayed: [], updated: ['1'] }, { name: 'nullable enum array', items: { type: 'string', enum: ['a', 'b'] }, value: null, component: 'MultiSelect', displayed: [], updated: ['1'] }, { name: 'numeric tags', items: { type: 'number' }, value: [1, 2], component: 'TagsInput', displayed: ['1', '2'], updated: [1] }, ])('renders $name without changing its submitted default', async ({ items, value, component, displayed, updated }) => { const field: GeneratedJsonSchema = { anyOf: [{ type: 'array', items }, { type: 'null' }], default: value, } const schema = generatedJobFormSchema({ id: 'arrays', title: 'Arrays', description: '', cpu: 1, input_schema: { properties: { values: field } }, output_schema: {}, }) const wrapper = mount(FormBuilder, { props: { schema } }) const control = wrapper.findComponent({ name: component }) expect(control.props('modelValue')).toEqual(displayed) wrapper.findComponent(FormActions).vm.$emit('submit') await flushPromises() expect(wrapper.emitted('submit')?.at(-1)?.[0]).toEqual({ values: value }) control.vm.$emit('update:modelValue', ['1']) await nextTick() expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual({ values: updated }) }) it('renders compact controls without a prebuilt FormSchema', () => { const wrapper = mount(FormBuilder, { props: { controls }, }) expect(wrapper.find('.mint-form-builder').exists()).toBe(true) expect(wrapper.text()).toContain('Parameters') expect(wrapper.text()).toContain('Metadata') expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.05') expect(wrapper.find('select').exists()).toBe(true) }) it('merges compact control defaults with incoming model values', async () => { const wrapper = mount(FormBuilder, { props: { controls, modelValue: { threshold: 0.25 }, }, }) await wrapper.find('input').setValue('0.5') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toMatchObject({ threshold: 0.5, method: 'linear', notes: '', }) }) it('uses controlOptions initialValues over compact control defaults', () => { const wrapper = mount(FormBuilder, { props: { controls, controlOptions: { initialValues: { threshold: 0.35, method: 'logistic', }, }, }, }) expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.35') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('logistic') }) it('syncs external modelValue changes into rendered fields', async () => { const wrapper = mount(FormBuilder, { props: { controls, modelValue: { threshold: 0.25 }, }, }) await wrapper.setProps({ modelValue: { threshold: 0.75, method: 'logistic' } }) expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.75') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('logistic') }) it('syncs compact controls schema changes into rendered fields and submit data', async () => { const thresholdOnly: ControlSchema = { threshold: controls.threshold, } const wrapper = mount(FormBuilder, { props: { controls: thresholdOnly, }, }) await wrapper.find('input').setValue('0.5') await wrapper.setProps({ controls: { threshold: controls.threshold, method: controls.method, }, }) expect(wrapper.find('select').exists()).toBe(true) expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.5') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('linear') wrapper.findComponent(FormActions).vm.$emit('submit') await flushPromises() expect(wrapper.emitted('submit')?.at(-1)?.[0]).toMatchObject({ threshold: 0.5, method: 'linear', }) }) it('removes stale field data when compact controls are removed', async () => { const wrapper = mount(FormBuilder, { props: { controls }, }) await wrapper.setProps({ controls: { method: controls.method, }, }) expect(wrapper.find('input').exists()).toBe(false) wrapper.findComponent(FormActions).vm.$emit('submit') await flushPromises() const submitted = wrapper.emitted('submit')?.at(-1)?.[0] as Record expect(submitted).toMatchObject({ method: 'linear' }) expect(submitted).not.toHaveProperty('threshold') }) it('renders fields from a complete control model binding', async () => { const model = defineControlModel({ views: { run: { label: 'Run', sections: { parameters: { label: 'Parameters', controls: { threshold: { label: 'Threshold', default: 0.05, min: 0, max: 1 }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'] }, }, }, }, }, }, }) const wrapper = mount(FormBuilder, { props: { model, modelValue: { threshold: 0.25 }, }, }) expect(wrapper.text()).toContain('Parameters') expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.25') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('linear') await wrapper.find('input').setValue('0.5') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toMatchObject({ threshold: 0.5, method: 'linear', }) }) it('uses initialValues from a complete control model binding', () => { const model = defineControlModel({ initialValues: { threshold: 0.25, method: 'logistic', }, views: { run: { label: 'Run', sections: { parameters: { label: 'Parameters', controls: { threshold: { label: 'Threshold', default: 0.05, min: 0, max: 1 }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'] }, }, }, }, }, }, }) const wrapper = mount(FormBuilder, { props: { model, controlOptions: { initialValues: { method: 'linear', }, }, }, }) expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.25') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('linear') }) it('ignores external modelValue keys that are not part of the form schema', async () => { const wrapper = mount(FormBuilder, { props: { controls, modelValue: { threshold: 0.25, externalContext: 'experiment-1' }, }, }) await wrapper.find('input').setValue('0.5') await wrapper.setProps({ modelValue: { threshold: 0.5, method: 'linear', externalContext: 'experiment-2' }, }) expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.5') expect((wrapper.findComponent(FormBuilder).vm as unknown as { form: { dirty: Record } }).form.dirty.threshold).toBe(true) }) it('works with direct useControlWorkspace form bindings', async () => { const workspace = useControlWorkspace({ threshold: 0.05, method: ['linear', 'logistic'], }) const wrapper = mount(FormBuilder, { props: workspace.form, }) await wrapper.find('input').setValue('0.5') expect(workspace.values.threshold).toBe(0.5) workspace.setValues({ method: 'logistic' }) await nextTick() expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('logistic') }) it('uses explicit schema when both schema and controls are provided', () => { const schema: FormSchema = { sections: [ { id: 'explicit', title: 'Explicit Schema', fields: [ { name: 'explicitField', label: 'Explicit Field', type: 'text', defaultValue: 'from schema', }, ], }, ], } const wrapper = mount(FormBuilder, { props: { controls, schema }, }) expect(wrapper.text()).toContain('Explicit Schema') expect(wrapper.text()).not.toContain('Parameters') expect((wrapper.find('input').element as HTMLInputElement).value).toBe('from schema') }) it('can hide the default actions for embedded forms', () => { const wrapper = mount(FormBuilder, { props: { controls, showActions: false, }, }) expect(wrapper.find('.mint-form-actions').exists()).toBe(false) }) it('renders boolean fields as compact choice rows', async () => { const schema: FormSchema = { sections: [ { id: 'display', title: 'Display', fields: [ { name: 'showOutliers', label: 'Show outliers', type: 'toggle', defaultValue: true, hint: 'Keep flagged peaks visible', icon: ['M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'], }, { name: 'includeMetadata', label: 'Include metadata', type: 'checkbox', defaultValue: false, hint: 'Add method context', icon: ['M12 3c4.418 0 8 1.343 8 3s-3.582 3-8 3-8-1.343-8-3z'], }, ], }, ], } const wrapper = mount(FormBuilder, { props: { schema, showActions: false }, }) const toggle = wrapper.get('.mint-toggle--row') const checkbox = wrapper.get('.mint-checkbox--row') expect(toggle.text()).toContain('Show outliers') expect(toggle.text()).not.toContain('Keep flagged peaks visible') expect(toggle.find('.mint-toggle__row-svg').exists()).toBe(true) expect(wrapper.findAll('.mint-form-field__label')).toHaveLength(0) expect(checkbox.text()).toContain('Include metadata') expect(checkbox.text()).not.toContain('Add method context') expect(checkbox.find('.mint-checkbox__row-svg').exists()).toBe(true) expect(wrapper.findAllComponents(Tooltip).map(tooltip => tooltip.props('text'))).toEqual([ 'Keep flagged peaks visible', 'Add method context', ]) await toggle.trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toMatchObject({ showOutliers: false, includeMetadata: false, }) }) it('renders default actions by default', () => { const wrapper = mount(FormBuilder, { props: { controls }, }) expect(wrapper.find('.mint-form-actions').exists()).toBe(true) }) })