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')
})
})
})