import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' import { describe, expect, it } from 'vitest' import SettingsModal from '../../components/SettingsModal.vue' import { defineControlModel, type ControlSchema } from '../../composables/useControlSchema' import { useAuthStore } from '../../stores/auth' import { colorPalettes, useSettingsStore } from '../../stores/settings' import type { SettingsModalSchema } from '../../types' describe('SettingsModal', () => { function mountSettingsModal(props = {}, slots = {}, pinia = createPinia()) { return mount(SettingsModal, { props: { modelValue: true, showAppearance: false, ...props, }, slots, global: { plugins: [pinia], stubs: { teleport: true, }, }, }) } it('accepts string shorthand manual tabs', async () => { const wrapper = mountSettingsModal({ tabs: ['General', 'Advanced'], layout: 'horizontal', }, { 'tab-General': '
General Content
', 'tab-Advanced': '
Advanced Content
', }) const tabs = wrapper.findAll('.mint-modal__tab') expect(tabs.map(tab => tab.text())).toEqual(['General', 'Advanced']) expect(tabs[0].classes()).toContain('mint-modal__tab--active') expect(wrapper.find('.general-tab').exists()).toBe(true) await tabs[1].trigger('click') // Re-query: the teleport stub rebuilds the strip on re-render, so the // pre-click wrappers point at detached nodes. expect(wrapper.findAll('.mint-modal__tab')[1].classes()).toContain('mint-modal__tab--active') }) it('syncs schema changes without remounting the modal', async () => { const baseSchema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', fields: [ { name: 'threshold', label: 'Threshold', type: 'text', defaultValue: '0.1' }, ], }, ], } const nextSchema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', fields: [ { name: 'threshold', label: 'Threshold', type: 'text', defaultValue: '0.1' }, ], }, { id: 'advanced', label: 'Advanced', fields: [ { name: 'method', label: 'Method', type: 'text', defaultValue: 'linear' }, ], }, ], } const wrapper = mountSettingsModal({ schema: baseSchema, layout: 'horizontal' }) await wrapper.find('.mint-settings-modal input').setValue('0.5') await wrapper.setProps({ schema: nextSchema }) // "1" = the tab's modified-from-default count (threshold 0.1 → 0.5). const tabs = wrapper.findAll('.mint-modal__tab') expect(tabs.map(tab => tab.text())).toEqual(['General 1', 'Advanced']) expect((wrapper.find('.mint-settings-modal input').element as HTMLInputElement).value).toBe('0.5') await tabs[1].trigger('click') expect(wrapper.text()).toContain('Method') expect((wrapper.find('.mint-settings-modal input').element as HTMLInputElement).value).toBe('linear') }) it('uses controlled values when a schema changes', async () => { const baseSchema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', fields: [ { name: 'threshold', label: 'Threshold', type: 'text', defaultValue: '0.1' }, ], }, ], } const nextSchema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', fields: [ { name: 'threshold', label: 'Threshold', type: 'text', defaultValue: '0.1' }, { name: 'method', label: 'Method', type: 'text', defaultValue: 'linear' }, ], }, ], } const wrapper = mountSettingsModal({ schema: baseSchema, values: { threshold: '0.25' }, }) await wrapper.find('.mint-settings-modal input').setValue('0.5') await wrapper.setProps({ schema: nextSchema }) const inputs = wrapper.findAll('.mint-settings-modal input') expect((inputs[0].element as HTMLInputElement).value).toBe('0.25') expect((inputs[1].element as HTMLInputElement).value).toBe('linear') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ threshold: '0.25', method: 'linear', }) }) it('preserves controlled values that are not represented in the form schema', async () => { const schema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', fields: [ { name: 'threshold', label: 'Threshold', type: 'number' }, ], }, ], } const wrapper = mountSettingsModal({ schema, values: { threshold: 0.25, database_url: '**********', }, }) await wrapper.find('.mint-settings-modal input').setValue('0.5') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toEqual({ threshold: 0.5, database_url: '**********', }) }) it('renders compact controls as schema-driven settings', async () => { const controls: ControlSchema = { threshold: { label: 'Threshold', default: 0.25, section: 'parameters', sectionLabel: 'Parameters', }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'], section: 'advanced', sectionLabel: 'Advanced', }, } const wrapper = mountSettingsModal({ controls, layout: 'horizontal' }) const tabs = wrapper.findAll('.mint-modal__tab') expect(tabs.map(tab => tab.text())).toEqual(['Parameters', 'Advanced']) expect((wrapper.find('.mint-settings-modal input').element as HTMLInputElement).value).toBe('0.25') await wrapper.find('.mint-settings-modal input').setValue('0.5') await tabs[1].trigger('click') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('linear') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ threshold: 0.5, method: 'linear', }) }) it('uses controlOptions initialValues for compact control settings', async () => { const controls: ControlSchema = { threshold: { label: 'Threshold', default: 0.25, section: 'parameters', sectionLabel: 'Parameters', }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'], section: 'parameters', }, } const wrapper = mountSettingsModal({ controls, controlOptions: { initialValues: { threshold: 0.35, method: 'logistic', }, }, }) expect((wrapper.find('.mint-settings-modal input').element as HTMLInputElement).value).toBe('0.35') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('logistic') await wrapper.find('.mint-settings-modal input').setValue('0.5') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ threshold: 0.5, method: 'logistic', }) }) it('renders a complete control model as schema-driven settings', async () => { const model = defineControlModel({ views: { run: { label: 'Run', sections: { parameters: { label: 'Parameters', controls: { threshold: { label: 'Threshold', default: 0.25 }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'] }, }, }, }, }, }, }) const wrapper = mountSettingsModal({ model }) expect(wrapper.text()).toContain('Threshold') expect(wrapper.text()).toContain('Method') expect((wrapper.find('.mint-settings-modal input').element as HTMLInputElement).value).toBe('0.25') await wrapper.find('.mint-settings-modal input').setValue('0.5') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ threshold: 0.5, method: 'linear', }) }) it('uses initialValues from a complete control model as schema-driven settings', () => { const model = defineControlModel({ initialValues: { threshold: 0.75, method: 'logistic', }, views: { run: { label: 'Run', sections: { parameters: { label: 'Parameters', controls: { threshold: { label: 'Threshold', default: 0.25 }, method: { label: 'Method', default: 'linear', options: ['linear', 'logistic'] }, }, }, }, }, }, }) const wrapper = mountSettingsModal({ model, controlOptions: { initialValues: { method: 'linear', }, }, }) expect((wrapper.find('.mint-settings-modal input').element as HTMLInputElement).value).toBe('0.75') expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('linear') }) it('activates compact controls that are loaded after mount', async () => { const controls: ControlSchema = { threshold: { label: 'Threshold', default: 0.25, section: 'parameters', sectionLabel: 'Parameters', }, } const wrapper = mountSettingsModal({ tabs: ['General'], }, { 'tab-General': '
Manual settings
', }) expect(wrapper.find('.manual-tab').exists()).toBe(true) await wrapper.setProps({ controls }) expect(wrapper.text()).toContain('Threshold') expect(wrapper.find('.manual-tab').exists()).toBe(false) expect((wrapper.find('.mint-settings-modal input').element as HTMLInputElement).value).toBe('0.25') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ threshold: 0.25, }) }) it('filters schema groups and fields by explicit user type', async () => { const schema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', fields: [ { name: 'common', label: 'Common', type: 'text', defaultValue: 'shared' }, { name: 'adminSecret', label: 'Admin Secret', type: 'text', access: { visibleFor: 'admin' } }, { name: 'userNote', label: 'User Note', type: 'text', access: { visibleFor: 'user' } }, ], }, { id: 'admin', label: 'Admin', access: { visibleFor: 'admin' }, fields: [ { name: 'adminOnly', label: 'Admin Only', type: 'text' }, ], }, ], } const userWrapper = mountSettingsModal({ schema, userType: 'user' }) expect(userWrapper.text()).toContain('Common') expect(userWrapper.text()).toContain('User Note') expect(userWrapper.text()).not.toContain('Admin Secret') expect(userWrapper.text()).not.toContain('Admin Only') const adminWrapper = mountSettingsModal({ schema, userType: 'admin', layout: 'horizontal' }) expect(adminWrapper.findAll('.mint-modal__tab').map(tab => tab.text())).toEqual([ 'General', 'Admin', ]) expect(adminWrapper.text()).toContain('Common') expect(adminWrapper.text()).toContain('Admin Secret') expect(adminWrapper.text()).not.toContain('User Note') await adminWrapper.findAll('.mint-modal__tab')[1].trigger('click') expect(adminWrapper.text()).toContain('Admin Only') }) it('uses SDK auth permissions when no user type override is passed', () => { const pinia = createPinia() const auth = useAuthStore(pinia) auth.setUserInfo({ id: 1, username: 'admin', shortname: null, email: null, role: 'member', roleObj: { slug: 'member', permissions: ['plugins.configure'], }, isActive: true, }) const schema: SettingsModalSchema = { groups: [ { id: 'plugins', label: 'Plugins', access: { permissions: ['plugins.configure'] }, fields: [ { name: 'registry', label: 'Registry', type: 'text' }, { name: 'danger', label: 'Danger Zone', type: 'text', access: { requiresAdmin: true } }, ], }, ], } const wrapper = mountSettingsModal({ schema }, {}, pinia) expect(wrapper.text()).toContain('Registry') expect(wrapper.text()).not.toContain('Danger Zone') }) it('should hide an admin-only group from a non-admin when the Python SDK marks it admin', () => { const pinia = createPinia() useAuthStore(pinia).setUserInfo({ id: 2, username: 'member', shortname: null, email: null, role: 'member', roleObj: { slug: 'member', permissions: ['plugins.configure'] }, isActive: true, }) // The shape mint_sdk.schema.apply_settings_form_admin_policy emits for config_requires_admin plugins. const schema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', fields: [{ name: 'note', label: 'Note', type: 'text' }] }, { id: 'storage', label: 'Storage', access: { requiresAdmin: true }, fields: [{ name: 'nasRoot', label: 'NAS root', type: 'text' }], }, ], } const wrapper = mountSettingsModal({ schema }, {}, pinia) expect(wrapper.text()).not.toContain('NAS root') }) it('should read access rules only from the nested access policy', () => { // The flat duplicates were removed in 1.3; a leftover flat key no longer gates anything. const schema = { groups: [ { id: 'legacy', label: 'Legacy', visibleFor: 'admin', fields: [{ name: 'legacyField', label: 'Legacy field', type: 'text' }], }, ], } as unknown as SettingsModalSchema const wrapper = mountSettingsModal({ schema, userType: 'user' }) expect(wrapper.text()).toContain('Legacy field') }) describe('redesign (5d)', () => { const railSchema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', description: 'Name and locale', fields: [ { name: 'pluginName', label: 'Plugin name', type: 'text', defaultValue: 'IC50', hint: 'Shown in breadcrumbs' }, ], }, { id: 'model', label: 'Model', description: 'Curve model and bounds', fields: [ { name: 'bottom', label: 'Bottom', type: 'number', defaultValue: 0 }, { name: 'top', label: 'Top', type: 'number', defaultValue: 100 }, ], }, { id: 'qc', label: 'Quality control', fields: [ { name: 'minR2', label: 'Minimum R²', type: 'number', defaultValue: 0.95, hint: 'Fits below this threshold are flagged for review' }, ], }, { id: 'notifications', label: 'Notifications', fields: [ { name: 'emailQc', label: 'Email on QC failure', type: 'checkbox', defaultValue: false }, ], }, ], } const shortSchema: SettingsModalSchema = { groups: [ { id: 'general', label: 'General', fields: [ { name: 'pluginName', label: 'Plugin name', type: 'text', defaultValue: 'IC50' }, { name: 'note', label: 'Note', type: 'text' }, ], }, { id: 'notifications', label: 'Notifications', fields: [ { name: 'emailQc', label: 'Email on QC failure', type: 'checkbox', defaultValue: false }, ], }, ], } function railItem(wrapper: ReturnType, label: string) { return wrapper.findAll('.mint-modal__rail-item').find(item => item.find('.mint-modal__rail-item-label').text() === label)! } it('should render the rail when the schema has more than three groups', () => { const wrapper = mountSettingsModal({ schema: railSchema }) expect(wrapper.find('.mint-modal__rail').exists()).toBe(true) }) it('should stack every group without a rail when the schema has three groups or fewer', () => { const wrapper = mountSettingsModal({ schema: shortSchema }) expect(wrapper.findAll('.mint-settings-modal__group')).toHaveLength(2) }) it('should keep the tab strip when the layout is horizontal', () => { const wrapper = mountSettingsModal({ schema: railSchema, layout: 'horizontal' }) expect(wrapper.findAll('.mint-modal__tab')).toHaveLength(4) }) it('should split the rail into Plugin and Personal sections when appearance is shown', () => { const wrapper = mountSettingsModal({ schema: railSchema, showAppearance: true }) expect(wrapper.findAll('.mint-modal__rail-section').map(heading => heading.text())).toEqual(['Plugin', 'Personal']) }) it('should list and open Appearance first when appearanceFirst is set', () => { const wrapper = mountSettingsModal({ tabs: [{ id: 'plugins', label: 'Plugins' }], showAppearance: true, appearanceFirst: true, layout: 'vertical', }) expect(wrapper.get('.mint-modal__rail-item--active').text()).toContain('Appearance') }) it('should label a manual tab rail section from its section', () => { const wrapper = mountSettingsModal({ tabs: [{ id: 'plugins', label: 'Plugins', section: 'Platform' }], showAppearance: true, appearanceFirst: true, layout: 'vertical', }) expect(wrapper.findAll('.mint-modal__rail-section').map(heading => heading.text())).toEqual(['Personal', 'Platform']) }) it('should not render rail section headings when appearance is hidden', () => { const wrapper = mountSettingsModal({ schema: railSchema }) expect(wrapper.find('.mint-modal__rail-section').exists()).toBe(false) }) it('should show the group description in the pane header when a group is active', () => { const wrapper = mountSettingsModal({ schema: railSchema }) expect(wrapper.get('.mint-modal__pane-header').text()).toContain('Name and locale') }) it('should show the modified count in the rail when a group has changed fields', () => { const wrapper = mountSettingsModal({ schema: railSchema, values: { bottom: 5, top: 50 } }) expect(railItem(wrapper, 'Model').get('.mint-modal__rail-item-meta').text()).toBe('2') }) it('should mark a row modified when its value differs from the default', () => { const wrapper = mountSettingsModal({ schema: railSchema, values: { pluginName: 'Dose' } }) expect(wrapper.find('.mint-settings-modal__row--modified').exists()).toBe(true) }) it('should not mark a row modified when the field has no default', () => { const wrapper = mountSettingsModal({ schema: shortSchema, values: { note: 'hello' } }) expect(wrapper.find('.mint-settings-modal__row--modified').exists()).toBe(false) }) it('should not mark a stored secret modified when its default is null', () => { const wrapper = mountSettingsModal({ schema: { groups: [{ id: 'auth', label: 'Auth', fields: [{ name: 'apiKey', label: 'API key', type: 'secret', defaultValue: null }] }] }, values: { apiKey: { $secret: 'sec_1', set: true } }, }) expect(wrapper.find('.mint-settings-modal__row--modified').exists()).toBe(false) }) it('should restore the field default when the row reset button is clicked', async () => { const wrapper = mountSettingsModal({ schema: railSchema, values: { pluginName: 'Dose' } }) await wrapper.get('.mint-settings-modal__reset').trigger('click') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ pluginName: 'IC50' }) }) it('should place the row reset in the field cell when the row is modified', () => { const wrapper = mountSettingsModal({ schema: railSchema, values: { pluginName: 'Dose' } }) expect(wrapper.find('.mint-settings-modal__row-field > .mint-settings-modal__reset').exists()).toBe(true) }) it('should restore every field in the group when Restore group defaults is clicked', async () => { const wrapper = mountSettingsModal({ schema: railSchema, values: { bottom: 5, top: 50 } }) await railItem(wrapper, 'Model').trigger('click') await wrapper.get('.mint-settings-modal__restore-group').trigger('click') expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toMatchObject({ bottom: 0, top: 100 }) }) it('should disable Restore group defaults when nothing in the group is modified', () => { const wrapper = mountSettingsModal({ schema: railSchema }) expect(wrapper.get('.mint-settings-modal__restore-group').attributes('disabled')).toBeDefined() }) it('should list matching fields from every group when searching by hint', async () => { const wrapper = mountSettingsModal({ schema: railSchema }) await wrapper.get('.mint-settings-modal__search input').setValue('flagged') expect(wrapper.findAll('.mint-settings-modal__row')).toHaveLength(1) }) it('should label each search result with its group', async () => { const wrapper = mountSettingsModal({ schema: railSchema }) await wrapper.get('.mint-settings-modal__search input').setValue('flagged') expect(wrapper.get('.mint-settings-modal__row-group').text()).toBe('Quality control') }) it('should show the result count in the pane header when searching', async () => { const wrapper = mountSettingsModal({ schema: railSchema }) await wrapper.get('.mint-settings-modal__search input').setValue('o') expect(wrapper.get('.mint-modal__pane-header').text()).toContain('5 results') }) it('should show an empty state when nothing matches the search', async () => { const wrapper = mountSettingsModal({ schema: railSchema }) await wrapper.get('.mint-settings-modal__search input').setValue('zzz') expect(wrapper.find('.mint-settings-modal__empty').exists()).toBe(true) }) it('should clear the search when a rail item is clicked', async () => { const wrapper = mountSettingsModal({ schema: railSchema }) await wrapper.get('.mint-settings-modal__search input').setValue('flagged') await railItem(wrapper, 'Model').trigger('click') expect((wrapper.get('.mint-settings-modal__search input').element as HTMLInputElement).value).toBe('') }) it('should render a checkbox field as a toggle inside settings rows', () => { const wrapper = mountSettingsModal({ schema: shortSchema }) expect(wrapper.find('.mint-settings-modal__row .mint-toggle').exists()).toBe(true) }) it('should print a toggle hint under the row when the boolean field has one', () => { const wrapper = mountSettingsModal({ schema: { groups: [{ id: 'g', label: 'G', fields: [{ name: 'weighted', label: 'Weighted', type: 'toggle', hint: 'Use when variance differs' }] }] }, }) expect(wrapper.get('.mint-settings-modal__row-hint').text()).toBe('Use when variance differs') }) it('should say changes take effect immediately before anything changes', () => { const wrapper = mountSettingsModal({ schema: shortSchema }) expect(wrapper.get('.mint-settings-modal__status').text()).toBe('Changes take effect immediately') }) it('should show the applied status after a value changes', async () => { const wrapper = mountSettingsModal({ schema: shortSchema }) await wrapper.get('.mint-settings-modal input').setValue('Dose') expect(wrapper.get('.mint-settings-modal__status').text()).toBe('Applied · changes take effect immediately') }) it('should keep the not-yet-applied status when the parent replaces the values', async () => { const wrapper = mountSettingsModal({ schema: shortSchema, values: { pluginName: 'IC50' } }) await wrapper.setProps({ values: { pluginName: 'Loaded' } }) expect(wrapper.get('.mint-settings-modal__status').text()).toBe('Changes take effect immediately') }) it('should close when Done is clicked', async () => { const wrapper = mountSettingsModal({ schema: shortSchema }) await wrapper.get('.mint-settings-modal__done').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]) }) it('should render a custom footer slot in place of the Done button', () => { const wrapper = mountSettingsModal({ schema: shortSchema }, { footer: '' }) expect(wrapper.find('.mint-settings-modal__done').exists()).toBe(false) }) it('should render a preview card for each theme when appearance is shown', () => { const wrapper = mountSettingsModal({ showAppearance: true }) expect(wrapper.findAll('.mint-settings-modal__theme')).toHaveLength(3) }) it('should set the theme when a theme card is clicked', async () => { const pinia = createPinia() const wrapper = mountSettingsModal({ showAppearance: true }, {}, pinia) await wrapper.findAll('.mint-settings-modal__theme')[1].trigger('click') expect(useSettingsStore(pinia).theme).toBe('dark') }) it('should render a swatch strip for each palette when appearance is shown', () => { const wrapper = mountSettingsModal({ showAppearance: true }) expect(wrapper.findAll('.mint-settings-modal__palette')).toHaveLength(Object.keys(colorPalettes).length) }) it('should resize the density preview when the density changes', async () => { const pinia = createPinia() const wrapper = mountSettingsModal({ showAppearance: true }, {}, pinia) useSettingsStore(pinia).tableDensity = 'compact' await wrapper.vm.$nextTick() expect(wrapper.get('.mint-settings-modal__density-preview').classes()).toContain('mint-settings-modal__density-preview--compact') }) }) })