import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import AppSidebar from '../../components/AppSidebar.vue' import type { SidebarToolSection } from '../../types' import type { FormSchema } from '../../types/form-builder' import { defineControlModel, useControlSchema, useControlWorkspace, type ControlSchema, } from '../../composables/useControlSchema' const samplePanels: Record = { analysis: [ { id: 'parameters', label: 'Parameters', icon: '⚙' }, { id: 'filters', label: 'Filters', icon: '🔍', defaultOpen: false }, ], results: [ { id: 'display', label: 'Display Options' }, ], } const sampleForms: Record = { parameters: { sections: [ { id: 'parameters', title: '', fields: [ { name: 'threshold', label: 'Threshold', type: 'text', defaultValue: '0.05' }, ], }, ], }, } const sampleControls: ControlSchema = { threshold: { label: 'Threshold', default: '0.05', section: 'parameters', sectionLabel: 'Parameters', view: 'analysis', }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'], section: 'parameters', view: 'analysis', }, saveResults: { label: 'Save results', default: true, section: 'output', sectionLabel: 'Output', view: 'analysis', sidebar: { defaultOpen: false }, }, } describe('AppSidebar', () => { describe('rendering with default props', () => { it('should render sidebar with default classes', () => { const wrapper = mount(AppSidebar) expect(wrapper.find('.mint-sidebar').exists()).toBe(true) expect(wrapper.find('.mint-sidebar--left').exists()).toBe(true) expect(wrapper.find('.mint-sidebar--floating').exists()).toBe(true) }) it('should apply default width', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) expect(wrapper.find('.mint-sidebar').attributes('style')).toContain('width: 280px') }) it('should be hidden when no panels match active view', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'settings' }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(true) }) it('should be hidden when activeView is empty', () => { const wrapper = mount(AppSidebar) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(true) }) }) describe('side prop', () => { it('should default to left side', () => { const wrapper = mount(AppSidebar) expect(wrapper.find('.mint-sidebar--left').exists()).toBe(true) expect(wrapper.find('.mint-sidebar--right').exists()).toBe(false) }) it('should apply right side class', () => { const wrapper = mount(AppSidebar, { props: { side: 'right' }, }) expect(wrapper.find('.mint-sidebar--right').exists()).toBe(true) expect(wrapper.find('.mint-sidebar--left').exists()).toBe(false) }) }) describe('floating prop', () => { it('should apply floating class by default', () => { const wrapper = mount(AppSidebar) expect(wrapper.find('.mint-sidebar--floating').exists()).toBe(true) expect(wrapper.find('.mint-sidebar--static').exists()).toBe(false) }) it('should apply static class when floating is false', () => { const wrapper = mount(AppSidebar, { props: { floating: false }, }) expect(wrapper.find('.mint-sidebar--static').exists()).toBe(true) expect(wrapper.find('.mint-sidebar--floating').exists()).toBe(false) }) }) describe('width prop', () => { it('should apply custom width', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', width: '300px', }, }) expect(wrapper.find('.mint-sidebar').attributes('style')).toContain('width: 300px') }) it('should handle different CSS units', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', width: '20rem', }, }) expect(wrapper.find('.mint-sidebar').attributes('style')).toContain('width: 20rem') }) }) describe('sidebar chrome and collapse state', () => { it('renders sections as flat groups with no nested card', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(2) expect(wrapper.find('.mint-collapsible-card').exists()).toBe(false) }) it('defaults to compact density', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) expect(wrapper.find('.mint-sidebar--comfortable').exists()).toBe(false) }) it('applies the comfortable density class', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', density: 'comfortable' }, }) expect(wrapper.find('.mint-sidebar--comfortable').exists()).toBe(true) }) it('maps the deprecated dense=false to comfortable density', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', dense: false }, }) expect(wrapper.find('.mint-sidebar--comfortable').exists()).toBe(true) }) it('renders title, subtitle, and badge in the built-in header', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', title: 'Peak Picking', subtitle: 'run-001.msd', badge: 3, }, }) expect(wrapper.find('.mint-sidebar__title').text()).toBe('Peak Picking') expect(wrapper.find('.mint-sidebar__subtitle').text()).toBe('run-001.msd') expect(wrapper.find('.mint-sidebar__badge').text()).toBe('3') }) it('toggles uncontrolled collapsed state and emits the update', async () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', title: 'Peak Picking', collapsible: true, width: '18rem', collapsedWidth: '3rem', }, }) expect(wrapper.find('.mint-sidebar').attributes('style')).toContain('width: 18rem') expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(2) await wrapper.get('.mint-sidebar__collapse-button').trigger('click') expect(wrapper.find('.mint-sidebar--collapsed').exists()).toBe(true) expect(wrapper.find('.mint-sidebar').attributes('style')).toContain('width: 3rem') expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(0) expect(wrapper.emitted('update:collapsed')).toEqual([[true]]) }) it('supports controlled collapsed state and a collapsed slot', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', collapsible: true, collapsed: true, }, slots: { collapsed: '', }, }) expect(wrapper.find('.mint-sidebar--collapsed').exists()).toBe(true) expect(wrapper.find('.collapsed-action').text()).toBe('2') expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(0) }) it('renders a default collapsed rail from active sections', () => { const wrapper = mount(AppSidebar, { props: { panels: { analysis: [ { id: 'filters', label: 'Feature Filters', icon: 'M3 4.5h18m-15 6h12m-9 6h6', badge: 2, badgeTone: 'warning', }, ], }, activeView: 'analysis', collapsible: true, collapsed: true, }, }) expect(wrapper.find('.mint-sidebar__rail-item').exists()).toBe(true) expect(wrapper.find('.mint-sidebar__rail-svg').exists()).toBe(true) expect(wrapper.find('.mint-sidebar__rail-badge').text()).toBe('2') expect(wrapper.find('.mint-sidebar__rail-badge').classes()).toContain('mint-sidebar__rail-badge--warning') }) it('expands when a default collapsed rail item is clicked', async () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', collapsible: true, defaultCollapsed: true, }, }) await wrapper.get('.mint-sidebar__rail-item').trigger('click') expect(wrapper.emitted('update:collapsed')).toEqual([[false]]) }) it('renders an empty shell for route-owned sidebar content', () => { const wrapper = mount(AppSidebar, { props: { title: 'Sequence', contentId: 'seqgen-sidebar', showWhenEmpty: true, }, slots: { footer: '', }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(false) expect(wrapper.find('.mint-sidebar__sections').attributes('id')).toBe('seqgen-sidebar') expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(0) expect(wrapper.find('.generate-action').exists()).toBe(true) }) }) describe('panels and activeView', () => { it('should render sections for active view', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(cards).toHaveLength(2) }) it('should render one group for each section', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(cards).toHaveLength(2) }) it('should render the section label in the group header', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(cards[0].find('.mint-sidebar__group-label').text()).toBe('Parameters') expect(cards[1].find('.mint-sidebar__group-label').text()).toBe('Filters') }) it('should render the section icon in the group header', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(cards[0].find('.mint-sidebar__group-text-icon').text()).toBe('⚙') }) it('renders section badges and actions and re-emits actions', async () => { const wrapper = mount(AppSidebar, { props: { panels: { analysis: [ { id: 'filters', label: 'Feature Filters', badge: 4, badgeTone: 'cta', actions: [{ id: 'reset', label: 'Reset filters' }], }, ], }, activeView: 'analysis', }, }) const tag = wrapper.get('.mint-sidebar__tag') expect(tag.text()).toBe('4') expect(tag.classes()).toContain('mint-sidebar__tag--cta') await wrapper.get('.mint-sidebar__group-action').trigger('click') expect(wrapper.emitted('section-action')).toEqual([['filters', 'reset']]) }) it('should respect defaultOpen on sections', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(cards[0].classes()).toContain('mint-sidebar__group--open') expect(cards[1].classes()).toContain('mint-sidebar__group--closed') }) it('toggles a section open state from its header', async () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) await wrapper.findAll('.mint-sidebar__group-toggle')[1].trigger('click') expect(wrapper.findAll('.mint-sidebar__group')[1].classes()).toContain('mint-sidebar__group--open') }) it('emits update:toggle from the inline section switch', async () => { const wrapper = mount(AppSidebar, { props: { panels: { analysis: [{ id: 'filters', label: 'Filters', showToggle: true }] }, activeView: 'analysis', toggleState: { filters: false }, }, }) await wrapper.get('.mint-sidebar__switch').trigger('click') expect(wrapper.emitted('update:toggle')).toEqual([['filters', true]]) }) it('should switch sections when activeView changes', async () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(2) await wrapper.setProps({ activeView: 'results' }) expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(1) }) it('should hide when switching to view with no panels', async () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(false) await wrapper.setProps({ activeView: 'settings' }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(true) }) }) describe('section slots', () => { it('should render named section slots', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, slots: { 'section-parameters': '
Parameters content
', 'section-filters': '
Filters content
', }, }) expect(wrapper.find('.params-content').exists()).toBe(true) expect(wrapper.find('.filters-content').exists()).toBe(true) }) it('should not render section slots for inactive view', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'results' }, slots: { 'section-parameters': '
Parameters content
', 'section-display': '
Display content
', }, }) expect(wrapper.find('.params-content').exists()).toBe(false) expect(wrapper.find('.display-content').exists()).toBe(true) }) }) describe('auto-rendered section forms', () => { it('renders compact controls without prebuilt sidebar bindings', () => { const wrapper = mount(AppSidebar, { props: { controls: sampleControls, activeView: 'analysis', }, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(cards).toHaveLength(2) expect(cards[0].find('.mint-sidebar__group-label').text()).toBe('Parameters') expect(cards[1].find('.mint-sidebar__group-label').text()).toBe('Output') expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.05') }) it('defaults to the first generated control view when activeView is omitted', () => { const wrapper = mount(AppSidebar, { props: { controls: sampleControls, }, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(false) expect(cards).toHaveLength(2) expect(cards[0].find('.mint-sidebar__group-label').text()).toBe('Parameters') }) it('accepts the full useControlSchema sidebar binding without activeView', () => { const toolkit = useControlSchema(sampleControls) const wrapper = mount(AppSidebar, { props: toolkit.sidebar, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(false) expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(2) expect(wrapper.attributes('viewids')).toBeUndefined() expect(wrapper.attributes('defaultview')).toBeUndefined() }) it('renders forms from a complete control model binding', async () => { const model = defineControlModel({ views: { setup: { label: 'Setup', sections: { parameters: { label: 'Parameters', controls: { threshold: { label: 'Threshold', default: '0.05' }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'] }, }, }, }, }, }, }) const wrapper = mount(AppSidebar, { props: { model, modelValue: { threshold: '0.25' }, }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(false) expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(1) expect(wrapper.find('.mint-sidebar__group-label').text()).toBe('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: { setup: { label: 'Setup', sections: { parameters: { label: 'Parameters', controls: { threshold: { label: 'Threshold', default: '0.05' }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'] }, }, }, }, }, }, }) const wrapper = mount(AppSidebar, { 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('merges compact control defaults into section form updates', async () => { const wrapper = mount(AppSidebar, { props: { controls: sampleControls, activeView: 'analysis', values: { untouched: 'keep' }, }, }) await wrapper.find('input').setValue('0.5') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ threshold: '0.5', method: 'linear', saveResults: true, untouched: 'keep', }) }) it('works with direct useControlWorkspace sidebar bindings', async () => { const workspace = useControlWorkspace(sampleControls) const wrapper = mount(AppSidebar, { props: workspace.sidebar, }) expect(wrapper.props('modelValue')).toBe(workspace.values) expect(wrapper.props('values')).toBe(workspace.values) await wrapper.find('input').setValue('0.5') expect(workspace.values.threshold).toBe('0.5') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toMatchObject({ threshold: '0.5', }) workspace.setValues({ method: 'logistic' }) await wrapper.vm.$nextTick() expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('logistic') }) it('activates generated panels and forms when direct controls load after mount', async () => { const wrapper = mount(AppSidebar, { props: { activeView: 'analysis', values: { threshold: '0.25', }, }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(true) await wrapper.setProps({ controls: sampleControls, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(cards).toHaveLength(2) expect(cards[0].find('.mint-sidebar__group-label').text()).toBe('Parameters') expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.25') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('linear') }) it('lets explicit panels override compact control panel metadata by section', () => { const wrapper = mount(AppSidebar, { props: { controls: sampleControls, activeView: 'analysis', panels: { analysis: [ { id: 'parameters', label: 'Manual Parameters' }, ], }, }, }) const cards = wrapper.findAll('.mint-sidebar__group') expect(cards).toHaveLength(2) expect(cards[0].find('.mint-sidebar__group-label').text()).toBe('Manual Parameters') expect(cards[1].find('.mint-sidebar__group-label').text()).toBe('Output') }) it('renders FormBuilder schemas when a section has no custom slot', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', forms: sampleForms, values: { threshold: '0.25' }, }, }) expect(wrapper.find('.mint-form-builder').exists()).toBe(true) expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.25') expect(wrapper.find('.mint-form-actions').exists()).toBe(false) }) it('uses controlOptions initialValues for generated section forms', async () => { const wrapper = mount(AppSidebar, { props: { controls: sampleControls, activeView: 'analysis', 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') await wrapper.find('input').setValue('0.5') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ threshold: '0.5', method: 'logistic', saveResults: true, }) }) it('can show FormBuilder actions for generated forms', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', forms: sampleForms, showFormActions: true, }, }) expect(wrapper.find('.mint-form-actions').exists()).toBe(true) }) it('merges section form updates into shared values', async () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', forms: sampleForms, values: { threshold: '0.25', untouched: 'keep' }, }, }) await wrapper.find('input').setValue('0.5') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toEqual({ threshold: '0.5', untouched: 'keep', }) }) it('supports default v-model for generated section form values', async () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', forms: sampleForms, modelValue: { threshold: '0.25', untouched: 'keep' }, }, }) expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.25') await wrapper.find('input').setValue('0.5') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual({ threshold: '0.5', untouched: 'keep', }) expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toEqual({ threshold: '0.5', untouched: 'keep', }) }) it('keeps explicit section slots as the override for generated forms', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis', forms: sampleForms, }, slots: { 'section-parameters': '
Manual controls
', }, }) expect(wrapper.find('.manual-controls').exists()).toBe(true) expect(wrapper.find('.mint-form-builder').exists()).toBe(false) }) }) describe('slots', () => { it('renders the default slot as sidebar content without panels', () => { const wrapper = mount(AppSidebar, { slots: { default: '
Route controls
', }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(false) expect(wrapper.find('.route-sidebar-content').text()).toBe('Route controls') }) it('should render header slot', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, slots: { header: '
Header
', }, }) expect(wrapper.find('.mint-sidebar__header').exists()).toBe(true) expect(wrapper.find('.custom-header').text()).toBe('Header') }) it('should render footer slot', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, slots: { footer: '', }, }) expect(wrapper.find('.mint-sidebar__footer').exists()).toBe(true) expect(wrapper.find('.custom-footer').text()).toBe('Footer') }) it('should not render header when slot is not provided', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) expect(wrapper.find('.mint-sidebar__header').exists()).toBe(false) }) it('should not render footer when slot is not provided', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, }) expect(wrapper.find('.mint-sidebar__footer').exists()).toBe(false) }) }) describe('edge cases', () => { it('should handle empty panels object', () => { const wrapper = mount(AppSidebar, { props: { panels: {}, activeView: 'analysis' }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(true) }) it('should handle view with empty sections array', () => { const wrapper = mount(AppSidebar, { props: { panels: { empty: [] }, activeView: 'empty' }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(true) }) it('uses the first non-empty panel view when activeView is omitted', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels }, }) expect(wrapper.find('.mint-sidebar--hidden').exists()).toBe(false) expect(wrapper.findAll('.mint-sidebar__group')).toHaveLength(2) expect(wrapper.find('.mint-sidebar__sections').exists()).toBe(true) }) }) describe('integration: multiple classes', () => { it('should apply multiple classes correctly', () => { const wrapper = mount(AppSidebar, { props: { side: 'left', floating: false, }, }) const sidebar = wrapper.find('.mint-sidebar') expect(sidebar.classes()).toContain('mint-sidebar--left') expect(sidebar.classes()).toContain('mint-sidebar--static') }) it('should render all slots with sections', () => { const wrapper = mount(AppSidebar, { props: { panels: samplePanels, activeView: 'analysis' }, slots: { header: '
Header
', footer: '', 'section-parameters': '
Params
', }, }) expect(wrapper.find('.header').exists()).toBe(true) expect(wrapper.find('.footer').exists()).toBe(true) expect(wrapper.find('.mint-sidebar__sections').exists()).toBe(true) }) }) })