import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' import { computed, h, nextTick, ref } from 'vue' import { describe, expect, it, vi } from 'vitest' import AppLayout from '../../components/AppLayout.vue' import AppSidebar from '../../components/AppSidebar.vue' import AppTopBar from '../../components/AppTopBar.vue' import ControlWorkspaceView from '../../components/ControlWorkspaceView.vue' import FormActions from '../../components/FormActions.vue' import FormBuilder from '../../components/FormBuilder.vue' import { defineControlModel, defineControls, defineDoseDesignControlModel, useControlWorkspace, } from '../../composables/useControlSchema' vi.mock('../../composables/usePlatformContext', () => ({ usePlatformContext: vi.fn(() => ({ isIntegrated: computed(() => false), context: ref({ isIntegrated: false, theme: 'system' }), plugin: computed(() => undefined), user: computed(() => undefined), theme: computed(() => 'system' as const), features: computed(() => undefined), navigate: vi.fn(), notify: vi.fn(), sendToPlatform: vi.fn(), })), })) vi.mock('../../composables/useTheme', () => ({ useTheme: vi.fn(() => ({ isDark: ref(false), theme: ref('light'), toggleTheme: vi.fn(), })), })) function createWorkspace() { return useControlWorkspace(defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', view: 'analysis', }, chartScale: { default: 'linear', options: ['linear', 'log'], section: 'display', view: 'results', }, }), { views: { analysis: { label: 'Run' }, results: { label: 'Results' }, }, }) } function mountView(workspace = createWorkspace()) { return mount(ControlWorkspaceView, { props: { workspace, title: 'Analysis Workspace', }, global: { plugins: [createPinia()], stubs: { 'router-link': { template: '', }, BaseModal: { template: '
', }, }, }, }) } const globalOptions = { plugins: [createPinia()], stubs: { 'router-link': { template: '', }, BaseModal: { template: '
', }, }, } describe('ControlWorkspaceView', () => { it('renders AppTopBar, AppSidebar, and FormBuilder from one workspace object', () => { const workspace = createWorkspace() const wrapper = mountView(workspace) expect(wrapper.findComponent(AppTopBar).props('title')).toBe('Analysis Workspace') expect('tabs' in wrapper.findComponent(AppTopBar).props()).toBe(false) expect(wrapper.findComponent(AppTopBar).props('pillNav')).toEqual([ { id: 'analysis', label: 'Run' }, { id: 'results', label: 'Results' }, ]) expect(wrapper.findComponent(AppTopBar).props('currentPillId')).toBe('analysis') expect(wrapper.findComponent(AppLayout).props('responsiveSidebar')).toBe(true) expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('analysis') expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject(workspace.values) }) it('lets callers override the generated sidebar chrome', () => { const wrapper = mount(ControlWorkspaceView, { props: { workspace: createWorkspace(), sidebarVariant: 'default', responsiveSidebar: false, sidebarTitle: 'Peak Picking', sidebarSubtitle: 'Current experiment', sidebarBadge: 3, }, global: globalOptions, }) expect(wrapper.findComponent(AppLayout).props('responsiveSidebar')).toBe(false) expect(wrapper.findComponent(AppSidebar).props('title')).toBe('Peak Picking') expect(wrapper.findComponent(AppSidebar).props('subtitle')).toBe('Current experiment') expect(wrapper.findComponent(AppSidebar).props('badge')).toBe(3) }) it('can create the workspace internally from direct controls', async () => { const controls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', view: 'analysis', }, chartScale: { default: 'linear', options: ['linear', 'log'], section: 'display', view: 'results', }, }) const wrapper = mount(ControlWorkspaceView, { props: { controls, controlOptions: { views: { analysis: { label: 'Run' }, results: { label: 'Results' }, }, }, title: 'Direct Workspace', }, global: globalOptions, }) expect(wrapper.findComponent(AppTopBar).props('title')).toBe('Direct Workspace') expect(wrapper.findComponent(AppTopBar).props('pillNav')).toEqual([ { id: 'analysis', label: 'Run' }, { id: 'results', label: 'Results' }, ]) expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('analysis') expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.05, chartScale: 'linear', }) wrapper.findComponent(AppTopBar).vm.$emit('pill-select', { id: 'results', label: 'Results' }) await nextTick() expect(wrapper.findComponent(AppTopBar).props('currentPillId')).toBe('results') expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('results') }) it('accepts a nested control model through direct v-bind props', () => { const model = defineControlModel({ views: { design: { label: 'Design', sections: { dose: { label: 'Dose design', controls: { replicates: { type: 'number', default: 3, min: 1, }, }, }, }, }, results: { label: 'Results', section: 'plots', sectionLabel: 'Plots', controls: { chartScale: ['linear', 'log'], }, }, }, }) const wrapper = mount(ControlWorkspaceView, { props: { ...model, title: 'Nested Model Workspace', }, global: globalOptions, }) expect(wrapper.findComponent(AppTopBar).props('pillNav')).toEqual([ { id: 'design', label: 'Design' }, { id: 'results', label: 'Results' }, ]) expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('design') expect(wrapper.findComponent(AppSidebar).props('panels')).toMatchObject({ design: [{ id: 'design-dose', label: 'Dose design' }], results: [{ id: 'plots', label: 'Plots' }], }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ replicates: 3, chartScale: 'linear', }) }) it('accepts a nested control model through one model prop', () => { const model = defineControlModel({ views: { design: { label: 'Design', sections: { dose: { label: 'Dose design', controls: { replicates: { type: 'number', default: 3, min: 1, }, }, }, }, }, results: { label: 'Results', section: 'plots', sectionLabel: 'Plots', controls: { chartScale: ['linear', 'log'], }, }, }, }) const wrapper = mount(ControlWorkspaceView, { props: { model, title: 'Model Workspace', }, global: globalOptions, }) expect(wrapper.findComponent(AppTopBar).props('pillNav')).toEqual([ { id: 'design', label: 'Design' }, { id: 'results', label: 'Results' }, ]) expect(wrapper.findComponent(AppSidebar).props('panels')).toMatchObject({ design: [{ id: 'design-dose', label: 'Dose design' }], results: [{ id: 'plots', label: 'Plots' }], }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ replicates: 3, chartScale: 'linear', }) }) it('merges model and prop controlOptions initial values', () => { const model = defineControlModel({ initialValues: { replicates: 4, chartScale: 'log', }, views: { design: { label: 'Design', sections: { dose: { label: 'Dose design', controls: { replicates: { type: 'number', default: 3, min: 1, }, }, }, }, }, results: { label: 'Results', section: 'plots', sectionLabel: 'Plots', controls: { chartScale: ['linear', 'log'], }, }, }, }) const wrapper = mount(ControlWorkspaceView, { props: { model, controlOptions: { initialValues: { chartScale: 'linear', }, }, }, global: globalOptions, }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ replicates: 4, chartScale: 'linear', }) }) it('accepts a raw nested control model through one model prop', () => { const wrapper = mount(ControlWorkspaceView, { props: { model: { views: { setup: { label: 'Setup', sections: { parameters: { label: 'Parameters', controls: { threshold: { type: 'number', default: 0.05 }, normalize: true, }, }, }, }, review: { label: 'Review', controls: { chartScale: ['linear', 'log'], }, }, }, }, title: 'Raw Model Workspace', }, global: globalOptions, }) expect(wrapper.findComponent(AppTopBar).props('title')).toBe('Raw Model Workspace') expect(wrapper.findComponent(AppTopBar).props('pillNav')).toEqual([ { id: 'setup', label: 'Setup' }, { id: 'review', label: 'Review' }, ]) expect(wrapper.findComponent(AppSidebar).props('panels')).toMatchObject({ setup: [{ id: 'setup-parameters', label: 'Parameters' }], review: [{ id: 'review-controls', label: 'Review' }], }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.05, normalize: true, chartScale: 'linear', }) }) it('resolves component props from a dose design model prop', () => { const model = defineDoseDesignControlModel({ selectedWells: ['A1', 'A2'], includeMolecularWeight: true, }) const wrapper = mount(ControlWorkspaceView, { props: { model, }, slots: { default: ({ componentBindings, componentPropsById }) => h( 'pre', { class: 'component-props-by-id' }, JSON.stringify({ componentBindings, componentPropsById }), ), }, global: globalOptions, }) expect(JSON.parse(wrapper.find('.component-props-by-id').text())).toEqual({ componentBindings: [ { id: 'plate', component: 'WellPlate', props: { modelValue: ['A1', 'A2'], format: 96, disabled: false, }, }, { id: 'dose', component: 'DoseCalculator', props: { mode: 'serial', targetWells: ['A1', 'A2'], disabled: false, molecularWeight: 300, }, }, ], componentPropsById: { plate: { modelValue: ['A1', 'A2'], format: 96, disabled: false, }, dose: { mode: 'serial', targetWells: ['A1', 'A2'], disabled: false, molecularWeight: 300, }, }, }) }) it('accepts component prop mappings from a nested control model', async () => { const model = defineControlModel({ views: { design: { label: 'Dose design', controls: { doseMode: { default: 'serial', options: ['serial', 'dilution'], }, targetWells: { type: 'tags', default: ['A1', 'A2'], }, enabled: true, }, }, }, componentProps: { mode: 'doseMode', targetWells: 'targetWells', disabled: values => !values.enabled, }, componentPropsById: { dose: { mode: 'doseMode', targetWells: 'targetWells', disabled: values => !values.enabled, }, plate: { modelValue: 'targetWells', disabled: values => !values.enabled, }, }, componentBindings: { dose: { component: 'DoseCalculator', props: { mode: 'doseMode', targetWells: 'targetWells', disabled: values => !values.enabled, }, }, plate: { component: 'WellPlate', props: { modelValue: 'targetWells', disabled: values => !values.enabled, }, }, }, }) const wrapper = mount(ControlWorkspaceView, { props: { ...model, }, slots: { default: ({ componentBindingsById, componentProps, componentPropsById }) => h( 'pre', { class: 'component-props' }, JSON.stringify({ componentBindingsById, componentProps, componentPropsById }), ), }, global: globalOptions, }) expect(JSON.parse(wrapper.find('.component-props').text())).toEqual({ componentBindingsById: { dose: { id: 'dose', component: 'DoseCalculator', props: { mode: 'serial', targetWells: ['A1', 'A2'], disabled: false, }, }, plate: { id: 'plate', component: 'WellPlate', props: { modelValue: ['A1', 'A2'], disabled: false, }, }, }, componentProps: { mode: 'serial', targetWells: ['A1', 'A2'], disabled: false, }, componentPropsById: { dose: { mode: 'serial', targetWells: ['A1', 'A2'], disabled: false, }, plate: { modelValue: ['A1', 'A2'], disabled: false, }, }, }) wrapper.findComponent(FormBuilder).vm.$emit('update:modelValue', { doseMode: 'dilution', targetWells: ['B1'], enabled: false, }) await nextTick() expect(JSON.parse(wrapper.find('.component-props').text())).toEqual({ componentBindingsById: { dose: { id: 'dose', component: 'DoseCalculator', props: { mode: 'dilution', targetWells: ['B1'], disabled: true, }, }, plate: { id: 'plate', component: 'WellPlate', props: { modelValue: ['B1'], disabled: true, }, }, }, componentProps: { mode: 'dilution', targetWells: ['B1'], disabled: true, }, componentPropsById: { dose: { mode: 'dilution', targetWells: ['B1'], disabled: true, }, plate: { modelValue: ['B1'], disabled: true, }, }, }) }) it('accepts initial values directly for internally generated workspaces', () => { const controls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', }, chartScale: { default: 'linear', options: ['linear', 'log'], section: 'display', }, sampleNames: { type: 'tags', default: ['Control'], section: 'samples', }, enabled: true, }) const wrapper = mount(ControlWorkspaceView, { props: { controls, controlOptions: { initialValues: { threshold: 0.2, chartScale: 'linear', }, }, initialValues: { chartScale: 'log', }, }, global: globalOptions, }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.2, chartScale: 'log', enabled: true, }) }) it('syncs internally generated workspace values with v-model:values', async () => { const controls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', }, chartScale: { default: 'linear', options: ['linear', 'log'], section: 'display', }, enabled: true, }) const wrapper = mount(ControlWorkspaceView, { props: { controls, values: { threshold: 0.2, chartScale: 'log', sampleNames: ['Vehicle', 'Drug'], enabled: true, }, }, global: globalOptions, }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.2, chartScale: 'log', sampleNames: ['Vehicle', 'Drug'], enabled: true, }) wrapper.findComponent(FormBuilder).vm.$emit('update:modelValue', { threshold: 0.5, chartScale: 'linear', sampleNames: ['Vehicle', 'Dose A'], enabled: false, }) await nextTick() expect(wrapper.emitted('update:values')?.at(-1)).toEqual([{ threshold: 0.5, chartScale: 'linear', sampleNames: ['Vehicle', 'Dose A'], enabled: false, }]) await wrapper.setProps({ values: { threshold: 0.8, chartScale: 'log', sampleNames: ['Vehicle', 'Dose A'], enabled: true, }, }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.8, chartScale: 'log', sampleNames: ['Vehicle', 'Dose A'], enabled: true, }) }) it('syncs internally generated workspace values with default v-model', async () => { const controls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', }, chartScale: { default: 'linear', options: ['linear', 'log'], section: 'display', }, }) const wrapper = mount(ControlWorkspaceView, { props: { controls, modelValue: { threshold: 0.2, chartScale: 'log', }, }, global: globalOptions, }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.2, chartScale: 'log', }) wrapper.findComponent(FormBuilder).vm.$emit('update:modelValue', { threshold: 0.5, chartScale: 'linear', }) await nextTick() expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ threshold: 0.5, chartScale: 'linear', }]) expect(wrapper.emitted('update:values')?.at(-1)).toEqual([{ threshold: 0.5, chartScale: 'linear', }]) await wrapper.setProps({ modelValue: { threshold: 0.8, chartScale: 'log', }, }) expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.8, chartScale: 'log', }) }) it('activates direct controls that are loaded after mount', async () => { const controls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', view: 'analysis', }, method: { default: 'linear', options: ['linear', 'logistic'], section: 'parameters', view: 'analysis', }, }) const wrapper = mount(ControlWorkspaceView, { props: { title: 'Async Workspace', }, global: globalOptions, }) expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('') await wrapper.setProps({ controls, controlOptions: { views: { analysis: { label: 'Run' }, }, }, }) expect(wrapper.findComponent(AppTopBar).props('pillNav')).toEqual([ { id: 'analysis', label: 'Run' }, ]) expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('analysis') expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.05, method: 'linear', }) }) it('preserves generated values and active view when direct controls change', async () => { const controls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', view: 'analysis', }, chartScale: { default: 'linear', options: ['linear', 'log'], section: 'display', view: 'results', }, }) const nextControls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', view: 'analysis', }, chartScale: { default: 'linear', options: ['linear', 'log'], section: 'display', view: 'results', }, normalize: { default: true, section: 'display', view: 'results', }, }) const wrapper = mount(ControlWorkspaceView, { props: { controls, controlOptions: { views: { analysis: { label: 'Run' }, results: { label: 'Results' }, }, }, }, global: globalOptions, }) wrapper.findComponent(FormBuilder).vm.$emit('update:modelValue', { threshold: 0.5, chartScale: 'log', }) wrapper.findComponent(AppTopBar).vm.$emit('pill-select', { id: 'results', label: 'Results' }) await nextTick() await wrapper.setProps({ controls: nextControls }) expect(wrapper.findComponent(AppTopBar).props('currentPillId')).toBe('results') expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('results') expect(wrapper.findComponent(FormBuilder).props('modelValue')).toMatchObject({ threshold: 0.5, chartScale: 'log', normalize: true, }) }) it('forwards default FormBuilder submit and cancel events', async () => { const controls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', }, }) const wrapper = mount(ControlWorkspaceView, { props: { controls, controlOptions: { showCancel: true, }, showFormActions: true, }, global: globalOptions, }) wrapper.findComponent(FormActions).vm.$emit('submit') wrapper.findComponent(FormActions).vm.$emit('cancel') await nextTick() expect(wrapper.emitted('submit')).toEqual([[{ threshold: 0.05 }]]) expect(wrapper.emitted('cancel')).toEqual([[]]) }) it('passes generated form state and enhancements to sidebar and content forms', () => { const controls = defineControls({ threshold: { type: 'number', default: 0.05, section: 'parameters', view: 'analysis', }, }) const formEnhancements = { onFieldChange: vi.fn(), } const wrapper = mount(ControlWorkspaceView, { props: { controls, formEnhancements, formLoading: true, formDisabled: true, formReadonly: true, }, global: globalOptions, }) const sidebar = wrapper.findComponent(AppSidebar) expect(sidebar.props('formEnhancements')).toMatchObject(formEnhancements) expect(sidebar.props('formLoading')).toBe(true) expect(sidebar.props('formDisabled')).toBe(true) expect(sidebar.props('formReadonly')).toBe(true) for (const form of wrapper.findAllComponents(FormBuilder)) { expect(form.props('enhancements')).toMatchObject(formEnhancements) expect(form.props('loading')).toBe(true) expect(form.props('disabled')).toBe(true) expect(form.props('readonly')).toBe(true) } }) it('keeps topbar pill navigation and sidebar view synchronized', async () => { const workspace = createWorkspace() const wrapper = mountView(workspace) wrapper.findComponent(AppTopBar).vm.$emit('pill-select', { id: 'results', label: 'Results' }) await nextTick() expect(workspace.activeView.value).toBe('results') expect(wrapper.findComponent(AppTopBar).props('currentPillId')).toBe('results') expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('results') }) it('exposes workspace data to the default slot', () => { const workspace = createWorkspace() const wrapper = mount(ControlWorkspaceView, { props: { workspace }, slots: { default: '

Custom analysis content

', }, global: { plugins: [createPinia()], stubs: { 'router-link': { template: '', }, BaseModal: { template: '
', }, }, }, }) expect(wrapper.find('.custom-content').text()).toBe('Custom analysis content') expect(wrapper.findAllComponents(FormBuilder)).toHaveLength(1) }) it('exposes mapped component props to the default slot', async () => { const controls = defineControls({ sampleNames: { type: 'tags', default: ['Control', 'Treatment'], section: 'samples', }, enabled: true, }) const wrapper = mount(ControlWorkspaceView, { props: { controls, componentProps: { samples: 'sampleNames', disabled: values => !values.enabled, }, }, slots: { default: ({ componentProps }) => h( 'pre', { class: 'component-props' }, JSON.stringify(componentProps), ), }, global: globalOptions, }) expect(JSON.parse(wrapper.find('.component-props').text())).toEqual({ samples: ['Control', 'Treatment'], disabled: false, }) wrapper.findComponent(FormBuilder).vm.$emit('update:modelValue', { sampleNames: ['Vehicle'], enabled: false, }) await nextTick() expect(JSON.parse(wrapper.find('.component-props').text())).toEqual({ samples: ['Vehicle'], disabled: true, }) }) it('exposes resolved workspace bindings to custom slots', () => { const model = defineControlModel({ views: { analysis: { label: 'Run', controls: { threshold: { type: 'number', default: 0.05, }, enabled: true, }, }, }, componentProps: { score: 'threshold', disabled: values => !values.enabled, }, componentPropsById: { chart: { score: 'threshold', disabled: values => !values.enabled, }, }, componentBindings: { chart: { component: 'ResultChart', props: { score: 'threshold', disabled: values => !values.enabled, }, }, }, }) const wrapper = mount(ControlWorkspaceView, { props: { model, }, slots: { topbar: ({ bindings, topBar }) => h( 'pre', { class: 'topbar-bindings' }, JSON.stringify({ slotPillId: topBar.currentPillId, pillId: bindings.topBar.value.currentPillId, }), ), sidebar: ({ bindings, sidebar }) => h( 'pre', { class: 'sidebar-bindings' }, JSON.stringify({ activeView: sidebar.activeView, bindingActiveView: bindings.sidebar.activeView, }), ), default: ({ bindings, componentBindingsById, componentPropsById }) => h( 'pre', { class: 'default-bindings' }, JSON.stringify({ formThreshold: bindings.form.modelValue.threshold, component: componentBindingsById.chart.component, bindingComponent: bindings.componentBindingsById.value.chart.component, bindingScore: bindings.componentProps.value.score, bindingComponentScore: bindings.componentBindings.value[0].props.score, slotScore: componentPropsById.chart.score, bindingSlotScore: bindings.componentPropsById.value.chart.score, }), ), }, global: globalOptions, }) expect(JSON.parse(wrapper.find('.topbar-bindings').text())).toEqual({ slotPillId: 'analysis', pillId: 'analysis', }) expect(JSON.parse(wrapper.find('.sidebar-bindings').text())).toEqual({ activeView: 'analysis', bindingActiveView: 'analysis', }) expect(JSON.parse(wrapper.find('.default-bindings').text())).toEqual({ formThreshold: 0.05, component: 'ResultChart', bindingComponent: 'ResultChart', bindingScore: 0.05, bindingComponentScore: 0.05, slotScore: 0.05, bindingSlotScore: 0.05, }) }) })