import { flushPromises, mount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { computed, h, nextTick, ref } from 'vue' import { describe, expect, it, vi } from 'vitest' import AppLayout from '../../components/AppLayout.vue' import AppSidebar from '../../components/AppSidebar.vue' import AppTopBar from '../../components/AppTopBar.vue' import ExperimentPopover from '../../components/ExperimentPopover.vue' import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vue' import PluginWorkspaceView from '../../components/PluginWorkspaceView.vue' import { defineDoseDesignControlModel } from '../../composables/useControlSchema' import { usePlatformContext } from '../../composables/usePlatformContext' import { useExperimentStore } from '../../stores/experiment' import type { ExperimentSummary, PageSelectorItemInput, PillNavItem, SidebarToolSection, } from '../../types' 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(), })), })) vi.mock('../../composables/useTheme', () => ({ useTheme: vi.fn(() => ({ isDark: ref(false), theme: ref('light'), toggleTheme: vi.fn(), })), })) const pillNav: PillNavItem[] = [ { id: 'data', label: 'Data' }, { id: 'results', label: 'Results' }, ] const panels: Record = { data: [ { id: 'import', label: 'Import', defaultOpen: true }, ], results: [ { id: 'display', label: 'Display', defaultOpen: true }, ], } interface WorkspaceSlotProps { activeView: string setActiveView: (viewId: string) => void } interface WorkspaceComponentSlotProps { values: Record componentBindingsById: Record }> componentPropsById: Record> } const workspacePinia = createPinia() function makeExperiment(overrides: Partial = {}): ExperimentSummary { return { id: 12, name: 'Dose response', status: 'ongoing', experiment_type: 'screen', created_at: '2026-07-01T08:00:00Z', updated_at: '2026-07-01T08:00:00Z', has_design_data: false, ...overrides, } } const globalOptions = { plugins: [workspacePinia], stubs: { 'router-link': { props: ['to', 'custom'], template: ` `, }, BaseModal: { template: '
', }, }, } interface PageSelectorWorkspaceProps { pageSelector?: PageSelectorItemInput[] currentPageSelectorId?: string } function mountIntegratedPageSelectorWorkspace( props: PageSelectorWorkspaceProps = {}, ): VueWrapper { vi.mocked(usePlatformContext).mockReturnValueOnce({ isIntegrated: computed(() => true), context: ref({ isIntegrated: true, theme: 'system' }), plugin: computed(() => ({ id: 'workspace-plugin', name: 'Workspace Plugin', version: '1.0.0', route_prefix: '/workspace-plugin', api_prefix: '/api/workspace-plugin', nav_items: [ { path: '/platform-one', label: 'Platform One' }, { path: '/platform-two', label: 'Platform Two' }, ], })), user: computed(() => undefined), theme: computed(() => 'system' as const), features: computed(() => undefined), navigate: vi.fn(), notify: vi.fn(), sendToPlatform: vi.fn(), }) return mount(PluginWorkspaceView, { props: { title: 'Workspace', ...props, }, global: globalOptions, }) } describe('PluginWorkspaceView', () => { it('should not forward the retired profile-button prop', () => { const wrapper = mount(PluginWorkspaceView, { global: globalOptions, }) expect(wrapper.props()).not.toHaveProperty('showProfile') }) it('renders the standard AppLayout, AppTopBar, and AppSidebar plugin shell', () => { const wrapper = mount(PluginWorkspaceView, { props: { title: 'MS Uploader', subtitle: 'Import and visualize', pillNav, panels, sidebarTitle: 'Tools', sidebarSubtitle: 'Current file', }, slots: { actions: () => h('button', { 'data-test': 'topbar-action' }, 'Run'), 'section-import': () => h('button', { 'data-test': 'import-action' }, 'Upload'), default: ({ activeView }: WorkspaceSlotProps) => h('main', { 'data-test': 'content' }, activeView), }, global: globalOptions, }) expect(wrapper.findComponent(AppLayout).props()).toMatchObject({ floating: true, responsiveSidebar: true, sidebarPosition: 'left', }) expect(wrapper.findComponent(AppTopBar).props()).toMatchObject({ title: 'MS Uploader', subtitle: 'Import and visualize', pillNav, currentPillId: 'data', }) expect(wrapper.findComponent(AppSidebar).props()).toMatchObject({ panels, activeView: 'data', title: 'Tools', subtitle: 'Current file', floating: false, density: 'compact', }) expect(wrapper.get('[data-test="topbar-action"]').text()).toBe('Run') expect(wrapper.get('[data-test="import-action"]').text()).toBe('Upload') expect(wrapper.get('[data-test="content"]').text()).toBe('data') }) it('keeps topbar navigation and sidebar panels synchronized in uncontrolled mode', async () => { const wrapper = mount(PluginWorkspaceView, { props: { title: 'Analysis', pillNav, panels, }, slots: { default: ({ activeView }: WorkspaceSlotProps) => h('main', { 'data-test': 'content' }, activeView), }, global: globalOptions, }) wrapper.findComponent(AppTopBar).vm.$emit('pill-select', { id: 'results', label: 'Results' }) await nextTick() expect(wrapper.findComponent(AppTopBar).props('currentPillId')).toBe('results') expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('results') expect(wrapper.get('[data-test="content"]').text()).toBe('results') expect(wrapper.emitted('update:activeView')).toEqual([['results']]) expect(wrapper.emitted('pill-select')).toEqual([[{ id: 'results', label: 'Results' }]]) }) it('should use platform pages when pageSelector is omitted', () => { const wrapper = mountIntegratedPageSelectorWorkspace() expect(wrapper.find('.mint-page-selector').exists()).toBe(true) }) it('should suppress platform pages when pageSelector is explicitly empty', () => { const wrapper = mountIntegratedPageSelectorWorkspace({ pageSelector: [] }) expect(wrapper.find('.mint-page-selector').exists()).toBe(false) }) it('should hide one explicit page without falling back to platform pages', () => { const wrapper = mountIntegratedPageSelectorWorkspace({ pageSelector: [{ id: 'downloads', label: 'Downloads', to: '/' }], currentPageSelectorId: 'downloads', }) expect(wrapper.find('.mint-page-selector').exists()).toBe(false) }) it('should render multiple explicit pages ahead of platform pages', () => { const wrapper = mountIntegratedPageSelectorWorkspace({ pageSelector: [ { id: 'overview', label: 'Overview', to: '/' }, { id: 'reports', label: 'Reports', to: '/reports' }, ], currentPageSelectorId: 'overview', }) expect(wrapper.get('.mint-page-selector__label').text()).toBe('Overview') }) it('passes compact control models to the sidebar for generated forms', () => { const wrapper = mount(PluginWorkspaceView, { props: { title: 'Generated Controls', controls: { threshold: { label: 'Threshold', default: 0.05, section: 'parameters', sectionLabel: 'Parameters', view: 'analysis', }, }, defaultActiveView: 'analysis', values: { threshold: 0.2 }, }, global: globalOptions, }) expect(wrapper.findComponent(AppSidebar).props()).toMatchObject({ controls: { threshold: expect.objectContaining({ label: 'Threshold', default: 0.05, }), }, activeView: 'analysis', values: { threshold: 0.2 }, }) }) it('derives topbar navigation and active view from compact control models', async () => { const wrapper = mount(PluginWorkspaceView, { props: { title: 'Generated Workspace', model: { views: { design: { label: 'Design', sections: { plate: { label: 'Plate setup', controls: { plateFormat: { label: 'Plate format', default: 96, options: [96, 384], }, }, }, }, }, run: { label: 'Run', sections: { parameters: { label: 'Parameters', controls: { threshold: { label: 'Threshold', default: 0.05, }, }, }, }, }, }, }, }, slots: { default: ({ activeView }: WorkspaceSlotProps) => h('main', { 'data-test': 'content' }, activeView), }, global: globalOptions, }) const topBar = wrapper.findComponent(AppTopBar) expect(topBar.props('pillNav')).toEqual([ expect.objectContaining({ id: 'design', label: 'Design' }), expect.objectContaining({ id: 'run', label: 'Run' }), ]) expect(topBar.props('currentPillId')).toBe('design') expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('design') expect(wrapper.get('[data-test="content"]').text()).toBe('design') topBar.vm.$emit('pill-select', { id: 'run', label: 'Run' }) await nextTick() expect(wrapper.findComponent(AppTopBar).props('currentPillId')).toBe('run') expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('run') expect(wrapper.get('[data-test="content"]').text()).toBe('run') expect(wrapper.emitted('update:activeView')).toEqual([['run']]) }) it('can render without a sidebar when callers only need topbar and content', () => { const wrapper = mount(PluginWorkspaceView, { props: { title: 'Simple Plugin', showSidebar: false, }, slots: { default: () => h('main', { 'data-test': 'content' }, 'Ready'), }, global: globalOptions, }) expect(wrapper.findComponent(AppTopBar).props('title')).toBe('Simple Plugin') expect(wrapper.findComponent(AppSidebar).exists()).toBe(false) expect(wrapper.get('[data-test="content"]').text()).toBe('Ready') }) it('supports route-owned sidebar content through an AppSidebar content target', () => { const wrapper = mount(PluginWorkspaceView, { props: { title: 'Route Tools', sidebarContentId: 'route-sidebar', sidebarCollapsible: false, sidebarCollapsedWidth: '3.5rem', sidebarCollapseButtonLabel: 'Hide route tools', sidebarExpandButtonLabel: 'Show route tools', }, slots: { footer: () => h('button', { 'data-test': 'sidebar-footer' }, 'Run'), }, global: globalOptions, }) const sidebar = wrapper.findComponent(AppSidebar) expect(sidebar.props()).toMatchObject({ contentId: 'route-sidebar', showWhenEmpty: true, collapsible: false, collapsedWidth: '3.5rem', collapseButtonLabel: 'Hide route tools', expandButtonLabel: 'Show route tools', }) expect(wrapper.find('#route-sidebar').exists()).toBe(true) expect(wrapper.get('[data-test="sidebar-footer"]').text()).toBe('Run') }) it('can provide AppTopBar experiment selection and save wiring from the shared store', 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(), }) setActivePinia(workspacePinia) const store = useExperimentStore() store.clear() store.select(makeExperiment({ id: 12, name: 'Dose response', status: 'ongoing', experiment_code: 'EXP-012', })) const experimentSave = vi.fn().mockResolvedValue('Saved shell') const wrapper = mount(PluginWorkspaceView, { props: { title: 'Experiment Shell', experimentShell: true, experimentSave, experimentSaveDisabled: true, experimentSaveDisabledMessage: 'Upload data first', showSidebar: false, }, global: globalOptions, }) await flushPromises() await nextTick() expect(wrapper.findComponent(ExperimentPopover).props()).toMatchObject({ experimentName: 'Dose response', experimentCode: 'EXP-012', experimentStatus: 'ongoing', isResolving: false, showSave: true, showDetach: true, saveDisabled: true, saveDisabledMessage: 'Upload data first', saveLoading: false, }) wrapper.findComponent(ExperimentPopover).vm.$emit('select') await flushPromises() await nextTick() expect(wrapper.findComponent(ExperimentSelectorModal).props()).toMatchObject({ modelValue: true, currentExperimentId: 12, }) // The selector modal commits to the store instead of emitting; the shell // re-emits what the platform returned. const selected = makeExperiment({ id: 13, name: 'Selected experiment', status: 'planned', experiment_code: 'EXP-013', }) store.select(selected) await nextTick() expect(wrapper.emitted('experiment-select')).toEqual([[selected]]) expect(wrapper.findComponent(ExperimentPopover).props('experimentName')) .toBe('Selected experiment') wrapper.findComponent(ExperimentPopover).vm.$emit('save') await nextTick() expect(experimentSave).toHaveBeenCalledTimes(1) wrapper.findComponent(ExperimentPopover).vm.$emit('detach') await nextTick() expect(wrapper.emitted('experiment-detach')).toEqual([[]]) expect(store.current).toBeNull() expect(store.currentId).toBeNull() }) it('shows the unresolved chip states rather than inventing a name from an id', 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(), }) setActivePinia(workspacePinia) const store = useExperimentStore() store.clear() const wrapper = mount(PluginWorkspaceView, { props: { title: 'Experiment Shell', experimentShell: true, showSidebar: false, }, global: globalOptions, }) await flushPromises() await nextTick() // Bound by id, record still in flight. store.currentId = 12 store.isResolving = true await nextTick() expect(wrapper.findComponent(ExperimentPopover).props()).toMatchObject({ experimentName: undefined, experimentCode: undefined, isResolving: true, showDetach: true, }) // Resolution failed — the binding survives so the user can detach. store.isResolving = false store.error = 'Experiment not found' await nextTick() expect(wrapper.findComponent(ExperimentPopover).props()).toMatchObject({ experimentName: undefined, isResolving: false, error: 'Experiment not found', showDetach: true, }) store.clear() }) it('exposes model-derived component bindings to workspace slots', async () => { const model = defineDoseDesignControlModel({ selectedWells: ['A1', 'A2'], includeMolecularWeight: true, }) const wrapper = mount(PluginWorkspaceView, { props: { title: 'Dose Design', model, modelValue: { selectedWells: ['C1'], plateFormat: 384, doseMode: 'dilution', disabled: true, molecularWeight: 450, }, }, slots: { default: ({ values, componentBindingsById, componentPropsById, }: WorkspaceComponentSlotProps) => h('pre', { 'data-test': 'component-bindings' }, JSON.stringify({ values, plate: componentBindingsById.plate.props, dose: componentPropsById.dose, })), }, global: globalOptions, }) expect(JSON.parse(wrapper.get('[data-test="component-bindings"]').text())).toEqual({ values: { selectedWells: ['C1'], plateFormat: 384, doseMode: 'dilution', disabled: true, molecularWeight: 450, }, plate: { modelValue: ['C1'], format: 384, disabled: true, }, dose: { mode: 'dilution', targetWells: ['C1'], disabled: true, molecularWeight: 450, }, }) await wrapper.setProps({ modelValue: { selectedWells: ['D1', 'D2'], plateFormat: 96, doseMode: 'serial', disabled: false, molecularWeight: 300, }, }) expect(JSON.parse(wrapper.get('[data-test="component-bindings"]').text())).toMatchObject({ values: { selectedWells: ['D1', 'D2'], plateFormat: 96, doseMode: 'serial', disabled: false, molecularWeight: 300, }, plate: { modelValue: ['D1', 'D2'], format: 96, disabled: false, }, dose: { mode: 'serial', targetWells: ['D1', 'D2'], disabled: false, molecularWeight: 300, }, }) }) it('keeps generated component bindings and sidebar forms on one workspace value model', async () => { const model = defineDoseDesignControlModel({ selectedWells: ['A1', 'A2'], includeMolecularWeight: true, }) let latestSlotProps: WorkspaceComponentSlotProps | undefined const wrapper = mount(PluginWorkspaceView, { props: { title: 'Dose Design', model, }, slots: { default: (slotProps: WorkspaceComponentSlotProps) => { latestSlotProps = slotProps return h('pre', { 'data-test': 'workspace-values' }, JSON.stringify({ values: slotProps.values, plate: slotProps.componentBindingsById.plate.props, dose: slotProps.componentPropsById.dose, })) }, }, global: globalOptions, }) expect(JSON.parse(wrapper.get('[data-test="workspace-values"]').text())).toMatchObject({ values: { selectedWells: ['A1', 'A2'], plateFormat: 96, doseMode: 'serial', disabled: false, molecularWeight: 300, }, plate: { modelValue: ['A1', 'A2'], format: 96, disabled: false, }, }) const updateSelectedWells = latestSlotProps?.componentBindingsById.plate.props[ 'onUpdate:modelValue' ] as ((wellIds: string[]) => void) | undefined updateSelectedWells?.(['E1', 'E2']) await nextTick() expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ selectedWells: ['E1', 'E2'], plateFormat: 96, doseMode: 'serial', disabled: false, molecularWeight: 300, }]) expect(JSON.parse(wrapper.get('[data-test="workspace-values"]').text())).toMatchObject({ values: { selectedWells: ['E1', 'E2'], plateFormat: 96, doseMode: 'serial', disabled: false, molecularWeight: 300, }, plate: { modelValue: ['E1', 'E2'], format: 96, }, dose: { targetWells: ['E1', 'E2'], }, }) wrapper.findComponent(AppSidebar).vm.$emit('update:modelValue', { selectedWells: ['F1'], plateFormat: 384, doseMode: 'dilution', disabled: true, molecularWeight: 450, }) await nextTick() expect(wrapper.emitted('update:values')?.at(-1)).toEqual([{ selectedWells: ['F1'], plateFormat: 384, doseMode: 'dilution', disabled: true, molecularWeight: 450, }]) expect(JSON.parse(wrapper.get('[data-test="workspace-values"]').text())).toMatchObject({ values: { selectedWells: ['F1'], plateFormat: 384, doseMode: 'dilution', disabled: true, molecularWeight: 450, }, plate: { modelValue: ['F1'], format: 384, disabled: true, }, dose: { mode: 'dilution', targetWells: ['F1'], disabled: true, molecularWeight: 450, }, }) }) })