import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { createPinia } from 'pinia'
import AppTopBar from '../../components/AppTopBar.vue'
import ExperimentPopover from '../../components/ExperimentPopover.vue'
import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vue'
import { usePlatformContext } from '../../composables/usePlatformContext'
import { APP_EXPERIMENT_KEY, type AppExperimentState } from '../../composables/useAppExperiment'
import { defineControlModel, defineControls } from '../../composables/useControlSchema'
import ThemeToggle from '../../components/ThemeToggle.vue'
import SettingsModal from '../../components/SettingsModal.vue'
import type { TopBarSettingsConfig } from '../../types/components'
// Mock the usePlatformContext composable
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(),
})),
}))
// Mock the useTheme composable (used by ThemeToggle)
vi.mock('../../composables/useTheme', () => ({
useTheme: vi.fn(() => ({
isDark: ref(false),
theme: ref('light'),
toggleTheme: vi.fn(),
})),
}))
// Helper function to create test wrapper with all necessary stubs and plugins
function createWrapper(props = {}, slots = {}) {
const pinia = createPinia()
return mount(AppTopBar, {
props,
slots,
global: {
plugins: [pinia],
stubs: {
'router-link': {
props: ['to', 'custom'],
template: `
`,
},
BaseModal: {
template: '
',
},
},
},
})
}
async function waitForAsyncComponents(wrapper: VueWrapper) {
await flushPromises()
await wrapper.vm.$nextTick()
}
async function openSettingsModal(wrapper: VueWrapper) {
await wrapper.get('.mint-topbar__settings-btn').trigger('click')
await waitForAsyncComponents(wrapper)
return wrapper.findComponent(SettingsModal)
}
enableAutoUnmount(afterEach)
describe('AppTopBar', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('default props behavior', () => {
it('should not expose the retired profile-button prop', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.props()).not.toHaveProperty('showProfile')
})
it('should not expose the retired pluginSwitcher prop (5a)', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.props()).not.toHaveProperty('pluginSwitcher')
})
it('should render without ThemeToggle by default', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(false)
})
it('should render without settings button by default', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(false)
})
it('should render without SettingsModal by default', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.findComponent(SettingsModal).exists()).toBe(false)
})
it('should show standalone badge by default when not integrated', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
expect(wrapper.find('.mint-topbar__standalone-badge').text()).toBe('Standalone')
})
})
describe('showThemeToggle prop', () => {
it('should not render ThemeToggle when showThemeToggle is false', () => {
const wrapper = createWrapper({
title: 'Test App',
showThemeToggle: false,
})
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(false)
})
it('should render ThemeToggle when showThemeToggle is true', () => {
const wrapper = createWrapper({
title: 'Test App',
showThemeToggle: true,
})
const themeToggle = wrapper.findComponent(ThemeToggle)
expect(themeToggle.exists()).toBe(true)
})
it('should pass size="sm" to ThemeToggle', () => {
const wrapper = createWrapper({
title: 'Test App',
showThemeToggle: true,
})
const themeToggle = wrapper.findComponent(ThemeToggle)
expect(themeToggle.props('size')).toBe('sm')
})
it('should render ThemeToggle in right section', () => {
const wrapper = createWrapper({
title: 'Test App',
showThemeToggle: true,
})
const rightSection = wrapper.find('.mint-topbar__right')
expect(rightSection.findComponent(ThemeToggle).exists()).toBe(true)
})
})
describe('showSettings prop', () => {
it('should not render settings button when showSettings is false', () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: false,
})
expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(false)
})
it('should render settings button when showSettings is true', () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
})
const settingsBtn = wrapper.find('.mint-topbar__settings-btn')
expect(settingsBtn.exists()).toBe(true)
})
it('should render SettingsModal after settings is opened', async () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
})
expect(wrapper.findComponent(SettingsModal).exists()).toBe(false)
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.exists()).toBe(true)
})
it('should have aria-label on settings button', () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
})
const settingsBtn = wrapper.find('.mint-topbar__settings-btn')
expect(settingsBtn.attributes('aria-label')).toBe('Open settings')
})
it('should render settings icon SVG', () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
})
const settingsBtn = wrapper.find('.mint-topbar__settings-btn')
const icon = settingsBtn.find('.mint-topbar__settings-icon')
expect(icon.exists()).toBe(true)
expect(icon.element.tagName.toLowerCase()).toBe('svg')
})
})
describe('settings button interaction', () => {
it('should open SettingsModal when settings button is clicked', async () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('modelValue')).toBe(true)
})
it('should not mount SettingsModal while closed', () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
})
expect(wrapper.findComponent(SettingsModal).exists()).toBe(false)
})
})
describe('settingsConfig prop', () => {
it('should pass title to SettingsModal', async () => {
const settingsConfig: TopBarSettingsConfig = {
title: 'Custom Settings',
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('title')).toBe('Custom Settings')
})
it('should pass tabs to SettingsModal', async () => {
const settingsConfig: TopBarSettingsConfig = {
tabs: [
{ id: 'general', label: 'General' },
{ id: 'advanced', label: 'Advanced' },
],
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('tabs')).toEqual(settingsConfig.tabs)
})
it('should normalize shorthand settings tabs for SettingsModal', async () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig: {
tabs: ['General', 'Advanced'],
showAppearance: false,
},
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('tabs')).toEqual([
{ id: 'General', label: 'General' },
{ id: 'Advanced', label: 'Advanced' },
])
})
it('should sync external schema values into SettingsModal fields', async () => {
const schema = {
groups: [
{
id: 'general',
label: 'General',
fields: [
{ name: 'threshold', label: 'Threshold', type: 'number' as const },
{
name: 'method',
label: 'Method',
type: 'select' as const,
props: {
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'logistic', label: 'Logistic' },
],
},
},
],
},
],
}
const settingsConfig: TopBarSettingsConfig = {
showAppearance: false,
schema,
values: { threshold: 0.25, method: 'linear', externalContext: 'experiment-1' },
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
await openSettingsModal(wrapper)
await wrapper.setProps({
settingsConfig: {
...settingsConfig,
values: { threshold: 0.75, method: 'logistic', externalContext: 'experiment-2' },
},
})
await waitForAsyncComponents(wrapper)
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.75')
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('logistic')
})
it('should render compact controls in SettingsModal', async () => {
const controls = defineControls({
threshold: {
label: 'Threshold',
default: 0.25,
section: 'parameters',
sectionLabel: 'Parameters',
},
})
const settingsConfig: TopBarSettingsConfig = {
showAppearance: false,
controls,
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('controls')).toStrictEqual(controls)
expect(wrapper.text()).toContain('Threshold')
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.25')
})
it('should pass control model to SettingsModal', async () => {
const model = defineControlModel({
controls: {
threshold: {
label: 'Threshold',
default: 0.25,
section: 'parameters',
sectionLabel: 'Parameters',
},
},
})
const settingsConfig: TopBarSettingsConfig = {
showAppearance: false,
model,
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('model')).toStrictEqual(model)
expect(wrapper.text()).toContain('Threshold')
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('0.25')
})
it('should pass showAppearance to SettingsModal', async () => {
const settingsConfig: TopBarSettingsConfig = {
showAppearance: false,
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('showAppearance')).toBe(false)
})
it('should pass settings userType to SettingsModal', async () => {
const settingsConfig: TopBarSettingsConfig = {
userType: 'admin',
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('userType')).toBe('admin')
})
it('should default showAppearance to true when not specified', async () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig: {},
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('showAppearance')).toBe(true)
})
it('should pass size to SettingsModal', async () => {
const settingsConfig: TopBarSettingsConfig = {
size: 'xl',
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('size')).toBe('xl')
})
it('should use default size when not specified', async () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
})
const settingsModal = await openSettingsModal(wrapper)
// SettingsModal defaults to 'lg' size
expect(settingsModal.props('size')).toBe('lg')
})
})
describe('settings tab slot forwarding', () => {
it('should forward settings-tab slots to SettingsModal', async () => {
const settingsConfig: TopBarSettingsConfig = {
tabs: [{ id: 'general', label: 'General' }],
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
}, {
'settings-tab-general': 'Custom General Tab
',
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.vm.$slots['tab-general']).toBeDefined()
})
it('should forward shorthand settings-tab slots to SettingsModal', async () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig: {
tabs: ['General'],
showAppearance: false,
},
}, {
'settings-tab-General': 'General Content
',
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.vm.$slots['tab-General']).toBeDefined()
})
it('should forward multiple settings-tab slots', async () => {
const settingsConfig: TopBarSettingsConfig = {
tabs: [
{ id: 'general', label: 'General' },
{ id: 'advanced', label: 'Advanced' },
],
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
}, {
'settings-tab-general': 'General Content
',
'settings-tab-advanced': 'Advanced Content
',
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.vm.$slots['tab-general']).toBeDefined()
expect(settingsModal.vm.$slots['tab-advanced']).toBeDefined()
})
it('should forward settings-appearance slot', async () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
}, {
'settings-appearance': 'Custom Appearance
',
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.vm.$slots.appearance).toBeDefined()
})
})
describe('showStandaloneLabel prop', () => {
it('should show standalone badge when showStandaloneLabel is true (default)', () => {
const wrapper = createWrapper({
title: 'Test App',
showStandaloneLabel: true,
})
expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
})
it('should hide standalone badge when showStandaloneLabel is false', () => {
const wrapper = createWrapper({
title: 'Test App',
showStandaloneLabel: false,
})
expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(false)
})
})
describe('standaloneLabel prop', () => {
it('should display default "Standalone" label', () => {
const wrapper = createWrapper({
title: 'Test App',
})
const badge = wrapper.find('.mint-topbar__standalone-badge')
expect(badge.text()).toBe('Standalone')
})
it('should display custom standalone label', () => {
const wrapper = createWrapper({
title: 'Test App',
standaloneLabel: 'Demo Mode',
})
const badge = wrapper.find('.mint-topbar__standalone-badge')
expect(badge.text()).toBe('Demo Mode')
})
it('should support empty string as label', () => {
const wrapper = createWrapper({
title: 'Test App',
standaloneLabel: '',
})
const badge = wrapper.find('.mint-topbar__standalone-badge')
expect(badge.text()).toBe('')
})
})
describe('standalone detection with platform integration', () => {
it('should hide standalone badge when integrated with platform', () => {
// This test verifies the component logic when isIntegrated changes
// Since we can't easily change the mock mid-test, we'll skip the runtime test
// The component code correctly checks isStandalone computed property
const wrapper = createWrapper({
title: 'Test App',
showStandaloneLabel: true,
})
// In non-integrated mode (default mock), badge should be visible
expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
})
})
describe('combined features', () => {
it('does not bind an experiment when the integrated plugin is static', async () => {
vi.mocked(usePlatformContext).mockReturnValueOnce({
isIntegrated: computed(() => true),
context: ref({ isIntegrated: true, theme: 'system' }),
plugin: computed(() => ({
id: 'hmdb',
name: 'HMDB',
version: '1.0.0',
route_prefix: '/hmdb',
api_prefix: '/api/hmdb',
plugin_type: 'static',
})),
user: computed(() => undefined),
theme: computed(() => 'system' as const),
features: computed(() => undefined),
navigate: vi.fn(),
notify: vi.fn(),
sendToPlatform: vi.fn(),
})
const wrapper = mount(AppTopBar, {
props: { title: 'HMDB' },
global: {
plugins: [createPinia()],
provide: {
[APP_EXPERIMENT_KEY as symbol]: {} as AppExperimentState,
},
stubs: {
'router-link': { template: '' },
},
},
})
await waitForAsyncComponents(wrapper)
expect([
wrapper.findComponent(ExperimentPopover).exists(),
wrapper.findComponent(ExperimentSelectorModal).exists(),
]).toEqual([false, false])
})
it('passes injected app experiment bindings to the popover and selector modal', async () => {
vi.mocked(usePlatformContext).mockReturnValueOnce({
isIntegrated: computed(() => true),
context: ref({ isIntegrated: true, 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(),
})
const appExperiment: AppExperimentState = {
experimentName: computed(() => 'Dose response'),
experimentCode: computed(() => 'EXP-012'),
experimentStatus: computed(() => 'ongoing' as const),
experimentId: computed(() => 12),
isResolving: computed(() => false),
error: computed(() => null),
showModal: ref(false),
saveLoading: ref(false),
saveSuccessMessage: ref('Saved'),
showSave: ref(true),
showDetach: computed(() => true),
saveDisabled: computed(() => true),
saveDisabledMessage: computed(() => 'Locked'),
popover: computed(() => ({
experimentName: 'Dose response',
experimentCode: 'EXP-012',
experimentStatus: 'ongoing' as const,
isResolving: false,
error: undefined,
showSave: true,
showDetach: true,
saveDisabled: true,
saveDisabledMessage: 'Locked',
saveLoading: false,
saveSuccessMessage: 'Saved',
})),
selectorModal: computed(() => ({
modelValue: true,
currentExperimentId: 12,
})),
openModal: vi.fn(),
closeModal: vi.fn(),
handleSave: vi.fn(),
handleDetach: vi.fn(),
}
const wrapper = mount(AppTopBar, {
props: { title: 'Test App' },
global: {
plugins: [createPinia()],
provide: {
[APP_EXPERIMENT_KEY as symbol]: appExperiment,
},
stubs: {
'router-link': {
template: '',
},
BaseModal: {
template: '
',
},
},
},
})
await waitForAsyncComponents(wrapper)
expect(wrapper.findComponent(ExperimentPopover).props()).toMatchObject({
experimentName: 'Dose response',
experimentCode: 'EXP-012',
experimentStatus: 'ongoing',
isResolving: false,
showSave: true,
showDetach: true,
saveDisabled: true,
saveDisabledMessage: 'Locked',
saveLoading: false,
saveSuccessMessage: 'Saved',
})
expect(wrapper.findComponent(ExperimentSelectorModal).props()).toMatchObject({
modelValue: true,
currentExperimentId: 12,
})
})
it('should render ThemeToggle and settings button together', () => {
const wrapper = createWrapper({
title: 'Test App',
showThemeToggle: true,
showSettings: true,
})
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(true)
expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(true)
})
it('should render standalone badge, ThemeToggle, and settings button together', () => {
const wrapper = createWrapper({
title: 'Test App',
showThemeToggle: true,
showSettings: true,
showStandaloneLabel: true,
standaloneLabel: 'Offline',
})
expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
expect(wrapper.find('.mint-topbar__standalone-badge').text()).toBe('Offline')
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(true)
expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(true)
})
it('should maintain existing topbar functionality with new features', () => {
const wrapper = createWrapper({
title: 'My Plugin',
subtitle: 'Version 1.0',
showThemeToggle: true,
showSettings: true,
showStandaloneLabel: true,
})
// Verify title and subtitle still render
expect(wrapper.text()).toContain('My Plugin')
expect(wrapper.text()).toContain('Version 1.0')
// Verify new features render
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(true)
expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(true)
expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
})
it('should render all elements in correct order in right section', () => {
const wrapper = createWrapper({
title: 'Test App',
showThemeToggle: true,
showSettings: true,
showStandaloneLabel: true,
}, {
actions: '',
})
const rightSection = wrapper.find('.mint-topbar__right')
expect(rightSection.exists()).toBe(true)
// Verify all expected elements are present
expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
expect(wrapper.find('.custom-action').exists()).toBe(true)
expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(true)
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(true)
})
})
describe('navigation prop surface', () => {
it('renders a title-only layout without navigation props', () => {
const wrapper = createWrapper({
title: 'Simple App',
subtitle: 'Current API',
})
expect(wrapper.find('.mint-topbar').exists()).toBe(true)
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(false)
expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(false)
// Standalone badge still shows by default in standalone mode
expect(wrapper.find('.mint-topbar__standalone-badge').exists()).toBe(true)
})
it('composes page selector, pill nav, theme, and settings controls', () => {
const wrapper = createWrapper({
title: 'Dashboard',
pageSelector: [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'settings', label: 'Settings' },
],
currentPageSelectorId: 'dashboard',
pillNav: [
{ id: 'overview', label: 'Overview' },
{ id: 'details', label: 'Details' },
],
currentPillId: 'overview',
showThemeToggle: true,
showSettings: true,
})
expect(wrapper.find('.mint-page-selector__label').text()).toBe('Dashboard')
expect(wrapper.findAll('.mint-pill-nav__item').map(item => item.text())).toEqual([
'Overview',
'Details',
])
expect(wrapper.findAll('.mint-pill-nav__item')[0].classes()).toContain('mint-pill-nav__item--active')
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(true)
expect(wrapper.find('.mint-topbar__settings-btn').exists()).toBe(true)
})
it('renders pill nav items with icon metadata', async () => {
const runIcon = 'M8 5v14l11-7z'
const wrapper = createWrapper({
pillNav: [
{ id: 'analysis', label: 'Run', icon: runIcon },
{ id: 'results', label: 'Results' },
],
currentPillId: 'analysis',
})
const pills = wrapper.findAll('.mint-pill-nav__item')
expect(pills.map(pill => pill.text())).toEqual(['Run', 'Results'])
expect(pills[0].classes()).toContain('mint-pill-nav__item--active')
expect(pills[0].get('.mint-pill-nav__icon path').attributes('d')).toBe(runIcon)
expect(pills[1].find('.mint-pill-nav__icon').exists()).toBe(false)
await pills[1].trigger('click')
expect(wrapper.emitted('pill-select')?.[0][0]).toMatchObject({ id: 'results', label: 'Results' })
})
it('uses shared dropdown state for the page selector menu', async () => {
const wrapper = createWrapper({
pageSelector: [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'settings', label: 'Settings' },
],
currentPageSelectorId: 'dashboard',
})
const dropdown = () => wrapper.find('.mint-page-selector__menu')
expect(dropdown().exists()).toBe(false)
await wrapper.get('.mint-page-selector__trigger').trigger('click')
expect(dropdown().exists()).toBe(true)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await wrapper.vm.$nextTick()
expect(dropdown().exists()).toBe(false)
await wrapper.get('.mint-page-selector__trigger').trigger('click')
expect(dropdown().exists()).toBe(true)
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await wrapper.vm.$nextTick()
expect(dropdown().exists()).toBe(false)
wrapper.unmount()
})
it('renders PluginIcon-compatible metadata icons in the page selector menu', async () => {
const iconPath = 'M4 19h16M7 16V8m5 8V4m5 12v-6'
const pngIcon = 'data:image/png;base64,iVBORw0KGgo='
const wrapper = createWrapper({
pageSelector: [
{ id: 'dashboard', label: 'Dashboard', to: '/', icon: iconPath, hint: 'Overview' },
{ id: 'image', label: 'Image', to: '/image', icon: pngIcon },
{ id: 'text', label: 'Text', icon: 'home' },
],
currentPageSelectorId: 'dashboard',
})
await wrapper.get('.mint-page-selector__trigger').trigger('click')
const icons = wrapper
.findAllComponents({ name: 'PluginIcon' })
.filter(icon => icon.classes().includes('mint-page-selector__metadata-icon'))
expect(icons).toHaveLength(2)
expect(icons[0].props('icon')).toBe(iconPath)
expect(icons[1].props('icon')).toBe(pngIcon)
expect(icons.every((icon) => icon.props('variant') === 'solid')).toBe(true)
const pathIcons = wrapper.findAll('.mint-plugin-icon__svg')
expect(pathIcons.map(icon => icon.find('path').attributes('d'))).toEqual(
expect.arrayContaining([iconPath]),
)
const imageIcons = wrapper.findAll('.mint-plugin-icon__img')
expect(imageIcons).toHaveLength(1)
expect(imageIcons[0].attributes('src')).toBe(pngIcon)
expect(wrapper.find('.mint-page-selector__item-hint').exists()).toBe(false)
})
})
describe('edge cases', () => {
it('should handle empty settingsConfig object', async () => {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig: {},
})
const settingsModal = await openSettingsModal(wrapper)
// SettingsModal has default values for these props
expect(settingsModal.props('title')).toBe('Settings')
expect(settingsModal.props('tabs')).toEqual([])
expect(settingsModal.props('showAppearance')).toBe(true)
expect(settingsModal.props('size')).toBe('lg')
})
it('should handle settingsConfig with only some fields', async () => {
const settingsConfig: TopBarSettingsConfig = {
title: 'Partial Config',
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('title')).toBe('Partial Config')
// SettingsModal defaults tabs to empty array
expect(settingsModal.props('tabs')).toEqual([])
expect(settingsModal.props('showAppearance')).toBe(true)
// SettingsModal defaults size to 'lg'
expect(settingsModal.props('size')).toBe('lg')
})
it('should handle standalone label with special characters', () => {
const wrapper = createWrapper({
title: 'Test App',
standaloneLabel: '🚀 Preview',
})
const badge = wrapper.find('.mint-topbar__standalone-badge')
expect(badge.text()).toBe('🚀 Preview')
})
it('should handle very long standalone label', () => {
const longLabel = 'This is a very long standalone mode label text'
const wrapper = createWrapper({
title: 'Test App',
standaloneLabel: longLabel,
})
const badge = wrapper.find('.mint-topbar__standalone-badge')
expect(badge.text()).toBe(longLabel)
})
})
describe('type safety', () => {
it('should accept valid settingsConfig with all fields', async () => {
const settingsConfig: TopBarSettingsConfig = {
title: 'Complete Settings',
tabs: [
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'advanced', label: 'Advanced' },
],
showAppearance: true,
size: 'lg',
}
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig,
})
expect((await openSettingsModal(wrapper)).exists()).toBe(true)
})
it('should accept all valid size options', async () => {
const sizes: Array<'md' | 'lg' | 'xl'> = ['md', 'lg', 'xl']
for (const size of sizes) {
const wrapper = createWrapper({
title: 'Test App',
showSettings: true,
settingsConfig: { size },
})
const settingsModal = await openSettingsModal(wrapper)
expect(settingsModal.props('size')).toBe(size)
}
})
})
describe('plugin metadata icon fallback', () => {
function mockPlatformCtx(overrides: Record = {}) {
const defaults = {
isIntegrated: false,
plugin: undefined as unknown,
user: undefined as unknown,
theme: 'system' as const,
features: undefined as unknown,
}
const merged = { ...defaults, ...overrides }
vi.mocked(usePlatformContext).mockReturnValue({
isIntegrated: computed(() => merged.isIntegrated as boolean),
context: ref({ isIntegrated: merged.isIntegrated as boolean, theme: merged.theme }),
plugin: computed(() => merged.plugin as any),
user: computed(() => merged.user as any),
theme: computed(() => merged.theme as any),
features: computed(() => merged.features as any),
navigate: vi.fn(),
notify: vi.fn(),
sendToPlatform: vi.fn(),
})
}
afterEach(() => {
mockPlatformCtx()
})
it('should render default M logo when no plugin icon and no #icon slot', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(true)
expect(wrapper.find('.mint-topbar__logo-text').text()).toBe('M')
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(false)
})
it('should render PluginIcon when plugin metadata has an icon', () => {
const iconPath = 'M13 10V3L4 14h7v7l9-11h-7z'
mockPlatformCtx({
isIntegrated: true,
plugin: { id: 'test', name: 'Test', version: '1.0', icon: iconPath, route_prefix: '/test', api_prefix: '/api/test' },
})
const wrapper = createWrapper({ title: 'Test App' })
const pluginIcon = wrapper.findComponent({ name: 'PluginIcon' })
expect(pluginIcon.exists()).toBe(true)
expect(pluginIcon.props('icon')).toBe(iconPath)
expect(pluginIcon.props('variant')).toBe('solid')
expect(pluginIcon.props('size')).toBe('topbar')
expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(false)
})
it('should render the icon inside the shared identity canvas', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.find('.mint-topbar__identity').exists()).toBe(true)
})
it('should pass plugin.color to PluginIcon as tone', () => {
const iconPath = 'M13 10V3L4 14h7v7l9-11h-7z'
mockPlatformCtx({
isIntegrated: true,
plugin: {
id: 'test', name: 'Test', version: '1.0',
icon: iconPath, color: '#22C55E',
route_prefix: '/test', api_prefix: '/api/test',
},
})
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.findComponent({ name: 'PluginIcon' }).props('tone')).toBe('#22C55E')
})
it('should ignore the custom icon slot when integrated', () => {
const iconPath = 'M13 10V3L4 14h7v7l9-11h-7z'
mockPlatformCtx({
isIntegrated: true,
plugin: { id: 'test', name: 'Test', version: '1.0', icon: iconPath, route_prefix: '/test', api_prefix: '/api/test' },
})
const wrapper = createWrapper({ title: 'Test App' }, {
icon: 'Custom
',
})
expect(wrapper.find('.custom-icon').exists()).toBe(false)
})
it('should keep the custom icon slot when standalone', () => {
const wrapper = createWrapper({ title: 'Test App' }, {
icon: 'Custom
',
})
expect(wrapper.find('.custom-icon').exists()).toBe(true)
})
it('should ignore the custom icon slot when integrated metadata is unavailable', () => {
mockPlatformCtx({ isIntegrated: true, plugin: undefined })
const wrapper = createWrapper({ title: 'Test App' }, {
icon: 'Custom
',
})
expect(wrapper.find('.custom-icon').exists()).toBe(false)
})
it('should use the shared fallback when integrated metadata is unavailable', () => {
mockPlatformCtx({ isIntegrated: true, plugin: undefined })
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(true)
})
it('should still render PluginIcon when icon does not start with M (PluginIcon shows fallback)', () => {
mockPlatformCtx({
isIntegrated: true,
plugin: { id: 'test', name: 'Test', version: '1.0', icon: 'invalid-path', route_prefix: '/test', api_prefix: '/api/test' },
})
const wrapper = createWrapper({ title: 'Test App' })
// Topbar passes any non-empty icon string to PluginIcon, which decides what to render.
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(true)
expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(false)
})
it('should render the shared PluginIcon fallback when integrated metadata has no icon', () => {
mockPlatformCtx({
isIntegrated: true,
plugin: { id: 'test', name: 'Test', version: '1.0', icon: '', route_prefix: '/test', api_prefix: '/api/test' },
})
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(true)
})
it('uses platform plugin nav metadata as the page selector when pageSelector is omitted', async () => {
const previousPath = window.location.pathname
window.history.pushState({}, '', '/test/analysis')
try {
const pluginIcon = 'M13 10V3L4 14h7v7l9-11h-7z'
const analysisIcon = 'M4 19h16M7 16V8m5 8V4m5 12v-6'
mockPlatformCtx({
isIntegrated: true,
plugin: {
id: 'test',
name: 'Dose Designer',
version: '1.0',
icon: pluginIcon,
route_prefix: '/test',
api_prefix: '/api/test',
nav_items: [
{ path: '/', label: 'Dashboard' },
{ path: '/analysis', label: 'Analysis', icon: analysisIcon },
],
},
})
const wrapper = createWrapper()
await wrapper.get('.mint-page-selector__trigger').trigger('click')
expect(wrapper.get('.mint-page-selector__label').text()).toBe('Analysis')
expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual([
'Dashboard',
'Analysis',
])
expect(wrapper.find('.mint-page-selector__item-hint').exists()).toBe(false)
const dropdownIcons = wrapper
.findAllComponents({ name: 'PluginIcon' })
.filter(icon => icon.classes().includes('mint-page-selector__metadata-icon'))
expect(dropdownIcons.map(icon => icon.props('icon'))).toEqual([pluginIcon, analysisIcon])
expect(wrapper.findAll('.mint-page-selector__item')[1].classes()).toContain('mint-page-selector__item--active')
} finally {
window.history.pushState({}, '', previousPath)
}
})
it('keeps explicit pageSelector ahead of platform plugin nav metadata', async () => {
mockPlatformCtx({
isIntegrated: true,
plugin: {
id: 'test',
name: 'Dose Designer',
version: '1.0',
route_prefix: '/test',
api_prefix: '/api/test',
nav_items: [
{ path: '/stale', label: 'Stale' },
{ path: '/stale-two', label: 'Stale Two' },
],
},
})
const wrapper = createWrapper({
pageSelector: [
{ id: 'overview', label: 'Overview', to: '/' },
{ id: 'reports', label: 'Reports', to: '/reports' },
],
currentPageSelectorId: 'overview',
})
await wrapper.get('.mint-page-selector__trigger').trigger('click')
expect(wrapper.get('.mint-page-selector__label').text()).toBe('Overview')
expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual(['Overview', 'Reports'])
})
describe('static plugin identity (5a)', () => {
const integratedPlugin = {
id: 'lcms', name: 'LC-MS Analysis', version: '2.4.0',
route_prefix: '/lcms', api_prefix: '/api/lcms',
}
const pages = [
{ id: 'peaks', label: 'Peaks', to: '/' },
{ id: 'report', label: 'Report', to: '/report' },
]
it('should render the plugin name when integrated', () => {
mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
const wrapper = createWrapper()
expect(wrapper.get('.mint-topbar__plugin-name').text()).toBe('LC-MS Analysis')
})
it('should render the platform display name when integrated', () => {
mockPlatformCtx({ isIntegrated: true, plugin: { ...integratedPlugin, display_name: 'LC-MS Peak Review' } })
const wrapper = createWrapper()
expect(wrapper.get('.mint-topbar__plugin-name').text()).toBe('LC-MS Peak Review')
})
it('should not render the plugin version when integrated', () => {
mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
const wrapper = createWrapper()
expect(wrapper.text()).not.toContain('v2.4.0')
})
it('should not render the plugin identity text when standalone', () => {
const wrapper = createWrapper({ title: 'Test App' })
expect(wrapper.find('.mint-topbar__plugin-name').exists()).toBe(false)
})
it('should hide the plugin name when a page selector is shown', () => {
mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
const wrapper = createWrapper({ pageSelector: pages, currentPageSelectorId: 'peaks' })
expect(wrapper.find('.mint-topbar__plugin-name').exists()).toBe(false)
})
it('should hide the plugin name when the plugin declares several nav items', () => {
mockPlatformCtx({
isIntegrated: true,
plugin: {
...integratedPlugin,
nav_items: [
{ id: 'peaks', path: '/', label: 'Peaks' },
{ id: 'report', path: '/report', label: 'Report' },
],
},
})
const wrapper = createWrapper()
expect(wrapper.find('.mint-topbar__plugin-name').exists()).toBe(false)
})
it('should not repeat the title next to the integrated identity', () => {
mockPlatformCtx({ isIntegrated: true, plugin: integratedPlugin })
const wrapper = createWrapper({ title: 'LC-MS Analysis' })
expect(wrapper.find('.mint-topbar__title-only').exists()).toBe(false)
})
})
})
describe('shorthand navigation items', () => {
it('accepts string shorthand for pageSelector', async () => {
const wrapper = createWrapper({
currentPageSelectorId: 'Workspace',
pageSelector: ['Workspace', 'Results'],
})
expect(wrapper.find('.mint-page-selector__label').text()).toBe('Workspace')
await wrapper.get('.mint-page-selector__trigger').trigger('click')
await wrapper.findAll('.mint-page-selector__item')[1].trigger('click')
expect(wrapper.emitted('page-selector-select')).toEqual([
[{ id: 'Results', label: 'Results' }],
])
})
it('accepts string shorthand for pillNav', async () => {
const wrapper = createWrapper({
currentPillId: 'Run',
pillNav: ['Run', 'Results'],
})
expect(wrapper.findAll('.mint-pill-nav__item').map(item => item.text())).toEqual([
'Run',
'Results',
])
await wrapper.findAll('.mint-pill-nav__item')[1].trigger('click')
expect(wrapper.emitted('pill-select')).toEqual([
[{ id: 'Results', label: 'Results' }],
])
})
it('emits pill-option-select for pillNav dropdown children', async () => {
const wrapper = createWrapper({
currentPillId: 'pca',
pillNav: [
{ id: 'charts', label: 'Charts' },
{
id: 'visualize',
label: 'Visualize',
children: [
{ id: 'pca', label: 'PCA', description: 'Principal component analysis' },
{ id: 'heatmap', label: 'Heatmap', description: 'Metabolite heatmap' },
],
},
],
})
await wrapper.findAll('.mint-pill-nav__item')[1].trigger('click')
await wrapper.findAll('.mint-pill-nav__dropdown-item')[1].trigger('click')
expect(wrapper.emitted('pill-option-select')?.[0]?.[0]).toEqual({
id: 'heatmap',
label: 'Heatmap',
description: 'Metabolite heatmap',
})
expect(wrapper.emitted('pill-option-select')?.[0]?.[1]).toMatchObject({
id: 'visualize',
label: 'Visualize',
})
})
})
describe('toast offset', () => {
const property = '--mint-toast-offset-top'
let rects: Record = {}
function toastOffset() {
return document.documentElement.style.getPropertyValue(property)
}
beforeEach(() => {
rects = {}
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const title = this.querySelector('.mint-topbar__title-only')?.textContent ?? ''
return (rects[title] ?? { top: 16, bottom: 72 }) as DOMRect
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('should set the toast offset below the top bar when mounted', () => {
createWrapper({ title: 'Test App' })
expect(toastOffset()).toBe('calc(72px + 1rem)')
})
it('should remove the toast offset when the top bar unmounts', () => {
createWrapper({ title: 'Test App' }).unmount()
expect(toastOffset()).toBe('')
})
it('should keep the toast offset when another top bar is still mounted', () => {
createWrapper({ title: 'Remaining' })
createWrapper({ title: 'Removed' }).unmount()
expect(toastOffset()).toBe('calc(72px + 1rem)')
})
it('should clamp the toast offset at 0 when the top bar is scrolled above the viewport', () => {
rects['Test App'] = { top: -96, bottom: -40 }
createWrapper({ title: 'Test App' })
expect(toastOffset()).toBe('calc(0px + 1rem)')
})
it('should follow the top-most top bar when several are mounted', () => {
rects.Header = { top: 16, bottom: 72 }
rects.Lower = { top: 200, bottom: 260 }
createWrapper({ title: 'Lower' })
createWrapper({ title: 'Header' })
expect(toastOffset()).toBe('calc(72px + 1rem)')
})
it('should update the toast offset when the page scrolls', () => {
createWrapper({ title: 'Test App' })
rects['Test App'] = { top: -26, bottom: 30 }
window.dispatchEvent(new Event('scroll'))
expect(toastOffset()).toBe('calc(30px + 1rem)')
})
it('should update the toast offset when the window resizes', () => {
createWrapper({ title: 'Test App' })
rects['Test App'] = { top: 16, bottom: 96 }
window.dispatchEvent(new Event('resize'))
expect(toastOffset()).toBe('calc(96px + 1rem)')
})
it('should update the toast offset when the top bar resizes', () => {
let onResize: (() => void) | undefined
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { onResize = callback }
observe() {}
unobserve() {}
disconnect() {}
})
createWrapper({ title: 'Test App' })
rects['Test App'] = { top: 16, bottom: 120 }
onResize?.()
expect(toastOffset()).toBe('calc(120px + 1rem)')
})
it('should stop listening for scroll when the last top bar unmounts', () => {
const removeListener = vi.spyOn(window, 'removeEventListener')
createWrapper({ title: 'Test App' }).unmount()
expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.objectContaining({ capture: true }))
})
})
})