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