@morscherlab/mint-sdk 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{EmptyState-DgBRoYAy.js → EmptyState-LMPS3keb.js} +3 -193
- package/dist/EmptyState-LMPS3keb.js.map +1 -0
- package/dist/{ExperimentPopover-BFw3QX8L.js → ExperimentPopover-D6_RGzTY.js} +113 -35
- package/dist/ExperimentPopover-D6_RGzTY.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BBWXC7KW.js → ExperimentSelectorModal-DcQ38zua.js} +9 -11
- package/dist/ExperimentSelectorModal-DcQ38zua.js.map +1 -0
- package/dist/{SettingsModal-Dpg-X1OM.js → SettingsModal-Ccq2z-Ms.js} +9 -6
- package/dist/SettingsModal-Ccq2z-Ms.js.map +1 -0
- package/dist/__stories__/experiment-helpers.d.ts +3 -0
- package/dist/__tests__/stores/experiment.test.d.ts +1 -0
- package/dist/{analysisArtifactTypes-ejqR8MX3.js → analysisArtifactTypes-DQ0MjwHZ.js} +1 -1
- package/dist/{analysisArtifactTypes-ejqR8MX3.js.map → analysisArtifactTypes-DQ0MjwHZ.js.map} +1 -1
- package/dist/components/ExperimentPopover.vue.d.ts +5 -0
- package/dist/components/ExperimentSelectorModal.vue.d.ts +1 -5
- package/dist/components/PluginWorkspaceView.props.d.ts +1 -4
- package/dist/components/PluginWorkspaceView.vue.d.ts +0 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
- package/dist/components/index.js +7 -7
- package/dist/{components-Ci_BTx00.js → components-CV5zuWa8.js} +24 -29
- package/dist/components-CV5zuWa8.js.map +1 -0
- package/dist/composables/experiment-utils.d.ts +10 -0
- package/dist/composables/index.d.ts +2 -2
- package/dist/composables/index.js +11 -10
- package/dist/composables/useAppExperiment.d.ts +29 -17
- package/dist/composables/usePlatformContext.d.ts +3 -3
- package/dist/{composables-C0VEJ73O.js → composables-CW8NRXLB.js} +5 -5
- package/dist/{composables-C0VEJ73O.js.map → composables-CW8NRXLB.js.map} +1 -1
- package/dist/experiment-Y9Iz7KYi.js +95 -0
- package/dist/experiment-Y9Iz7KYi.js.map +1 -0
- package/dist/{experiment-utils-CxkruZ3G.js → experiment-utils-7Yw-h0tD.js} +16 -2
- package/dist/{experiment-utils-CxkruZ3G.js.map → experiment-utils-7Yw-h0tD.js.map} +1 -1
- package/dist/index.js +16 -15
- package/dist/index.js.map +1 -1
- package/dist/install.js +7 -7
- package/dist/{plotly.min-BR53hja4.js → plotly.min-c2Sez60c.js} +1 -1
- package/dist/{plotly.min-BR53hja4.js.map → plotly.min-c2Sez60c.js.map} +1 -1
- package/dist/stores/auth.d.ts +6 -6
- package/dist/stores/experiment.d.ts +166 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.js +3 -2
- package/dist/styles.css +188 -0
- package/dist/templates/index.js +4 -4
- package/dist/{templates-Cdj93S9C.js → templates-CJRFiWUX.js} +2 -2
- package/dist/{templates-Cdj93S9C.js.map → templates-CJRFiWUX.js.map} +1 -1
- package/dist/types/auth.d.ts +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/platform.d.ts +1 -1
- package/dist/{auth-B87xM--s.js → useApi-CDBq6z23.js} +191 -2
- package/dist/useApi-CDBq6z23.js.map +1 -0
- package/dist/{useControlSchema-B_qgU4rW.js → useControlSchema-BEAgLyyx.js} +5 -4
- package/dist/{useControlSchema-B_qgU4rW.js.map → useControlSchema-BEAgLyyx.js.map} +1 -1
- package/dist/{useDropdownState-gVaMXtwW.js → useDropdownState-Be-ug_ci.js} +2 -2
- package/dist/{useDropdownState-gVaMXtwW.js.map → useDropdownState-Be-ug_ci.js.map} +1 -1
- package/dist/{useExperimentSelector-BuQZRgMt.js → useExperimentSelector-DFg_iEHI.js} +36 -16
- package/dist/useExperimentSelector-DFg_iEHI.js.map +1 -0
- package/dist/{useFormBuilder-D1G5J5Z5.js → useFormBuilder-CR9pBApW.js} +2 -2
- package/dist/{useFormBuilder-D1G5J5Z5.js.map → useFormBuilder-CR9pBApW.js.map} +1 -1
- package/dist/{useProtocolTemplates-DeaOmMaw.js → useProtocolTemplates-DXeIHMwV.js} +38 -42
- package/dist/useProtocolTemplates-DXeIHMwV.js.map +1 -0
- package/package.json +1 -1
- package/src/__stories__/experiment-helpers.ts +12 -9
- package/src/__tests__/components/AppTopBar.test.ts +10 -6
- package/src/__tests__/components/ExperimentPopover.test.ts +64 -0
- package/src/__tests__/components/ExperimentSelectorModal.test.ts +86 -41
- package/src/__tests__/components/PluginWorkspaceView.test.ts +103 -19
- package/src/__tests__/components/SettingsModal.test.ts +29 -1
- package/src/__tests__/composables/experiment-utils.test.ts +33 -0
- package/src/__tests__/composables/useAppExperiment.test.ts +331 -92
- package/src/__tests__/composables/useAuth.test.ts +2 -2
- package/src/__tests__/composables/useExperimentSelector.test.ts +71 -0
- package/src/__tests__/composables/usePlatformContext.test.ts +5 -1
- package/src/__tests__/stores/experiment.test.ts +300 -0
- package/src/components/AppTopBar.vue +0 -2
- package/src/components/ExperimentPopover.story.vue +59 -0
- package/src/components/ExperimentPopover.vue +96 -6
- package/src/components/ExperimentSelectorModal.story.vue +6 -16
- package/src/components/ExperimentSelectorModal.vue +8 -4
- package/src/components/PluginWorkspaceView.props.ts +1 -4
- package/src/components/PluginWorkspaceView.story.vue +12 -5
- package/src/components/PluginWorkspaceView.vue +0 -2
- package/src/components/SettingsModal.vue +6 -1
- package/src/composables/experiment-utils.ts +22 -0
- package/src/composables/index.ts +1 -2
- package/src/composables/useAppExperiment.ts +57 -67
- package/src/composables/useAuth.ts +1 -1
- package/src/composables/useExperimentSelector.ts +45 -13
- package/src/stores/experiment.ts +120 -0
- package/src/stores/index.ts +1 -0
- package/src/styles/components/experiment-popover.css +105 -0
- package/src/types/auth.ts +1 -1
- package/src/types/platform.ts +1 -1
- package/dist/EmptyState-DgBRoYAy.js.map +0 -1
- package/dist/ExperimentPopover-BFw3QX8L.js.map +0 -1
- package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +0 -1
- package/dist/SettingsModal-Dpg-X1OM.js.map +0 -1
- package/dist/auth-B87xM--s.js.map +0 -1
- package/dist/components-Ci_BTx00.js.map +0 -1
- package/dist/useExperimentSelector-BuQZRgMt.js.map +0 -1
- package/dist/useProtocolTemplates-DeaOmMaw.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { defineComponent, onUnmounted } from 'vue'
|
|
6
6
|
import { useAppExperiment } from '../composables/useAppExperiment'
|
|
7
7
|
import { useApi } from '../composables/useApi'
|
|
8
|
+
import { useExperimentStore } from '../stores/experiment'
|
|
8
9
|
import type { ExperimentSummary } from '../types'
|
|
9
10
|
|
|
10
11
|
export const mockExperiments: ExperimentSummary[] = [
|
|
@@ -58,6 +59,9 @@ export function installMockInterceptor() {
|
|
|
58
59
|
* Wrapper component that fakes integrated mode and provides useAppExperiment context.
|
|
59
60
|
* Use in story files to demonstrate experiment selector integration.
|
|
60
61
|
*
|
|
62
|
+
* The preselected experiment is committed to the shared store as a full
|
|
63
|
+
* platform record — the same path the selector modal uses at runtime.
|
|
64
|
+
*
|
|
61
65
|
* Props:
|
|
62
66
|
* preselect (boolean, default true) - pre-select the first mock experiment
|
|
63
67
|
*/
|
|
@@ -66,18 +70,17 @@ export const ExperimentProvider = defineComponent({
|
|
|
66
70
|
setup(props, { slots }) {
|
|
67
71
|
;(window as any).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'system' }
|
|
68
72
|
installMockInterceptor()
|
|
69
|
-
const
|
|
73
|
+
const experimentStore = useExperimentStore()
|
|
74
|
+
useAppExperiment({
|
|
70
75
|
onSave: async () => 'Saved!',
|
|
71
76
|
})
|
|
72
|
-
if (props.preselect) {
|
|
73
|
-
|
|
74
|
-
id: 1,
|
|
75
|
-
name: 'Doxorubicin dose-response in HeLa cells',
|
|
76
|
-
experiment_code: 'DR-HeLa-DOX-001',
|
|
77
|
-
status: 'completed',
|
|
78
|
-
})
|
|
77
|
+
if (props.preselect && mockExperiments[0]) {
|
|
78
|
+
experimentStore.select(mockExperiments[0])
|
|
79
79
|
}
|
|
80
|
-
onUnmounted(() => {
|
|
80
|
+
onUnmounted(() => {
|
|
81
|
+
experimentStore.clear()
|
|
82
|
+
delete (window as any).__MINT_PLATFORM__
|
|
83
|
+
})
|
|
81
84
|
return () => slots.default?.()
|
|
82
85
|
},
|
|
83
86
|
})
|
|
@@ -576,10 +576,12 @@ describe('AppTopBar', () => {
|
|
|
576
576
|
sendToPlatform: vi.fn(),
|
|
577
577
|
})
|
|
578
578
|
const appExperiment: AppExperimentState = {
|
|
579
|
-
experimentName:
|
|
580
|
-
experimentCode:
|
|
581
|
-
experimentStatus:
|
|
582
|
-
experimentId:
|
|
579
|
+
experimentName: computed(() => 'Dose response'),
|
|
580
|
+
experimentCode: computed(() => 'EXP-012'),
|
|
581
|
+
experimentStatus: computed(() => 'ongoing' as const),
|
|
582
|
+
experimentId: computed(() => 12),
|
|
583
|
+
isResolving: computed(() => false),
|
|
584
|
+
error: computed(() => null),
|
|
583
585
|
showModal: ref(false),
|
|
584
586
|
saveLoading: ref(false),
|
|
585
587
|
saveSuccessMessage: ref('Saved'),
|
|
@@ -590,7 +592,9 @@ describe('AppTopBar', () => {
|
|
|
590
592
|
popover: computed(() => ({
|
|
591
593
|
experimentName: 'Dose response',
|
|
592
594
|
experimentCode: 'EXP-012',
|
|
593
|
-
experimentStatus: 'ongoing',
|
|
595
|
+
experimentStatus: 'ongoing' as const,
|
|
596
|
+
isResolving: false,
|
|
597
|
+
error: undefined,
|
|
594
598
|
showSave: true,
|
|
595
599
|
showDetach: true,
|
|
596
600
|
saveDisabled: true,
|
|
@@ -604,7 +608,6 @@ describe('AppTopBar', () => {
|
|
|
604
608
|
})),
|
|
605
609
|
openModal: vi.fn(),
|
|
606
610
|
closeModal: vi.fn(),
|
|
607
|
-
handleSelect: vi.fn(),
|
|
608
611
|
handleSave: vi.fn(),
|
|
609
612
|
handleDetach: vi.fn(),
|
|
610
613
|
}
|
|
@@ -631,6 +634,7 @@ describe('AppTopBar', () => {
|
|
|
631
634
|
experimentName: 'Dose response',
|
|
632
635
|
experimentCode: 'EXP-012',
|
|
633
636
|
experimentStatus: 'ongoing',
|
|
637
|
+
isResolving: false,
|
|
634
638
|
showSave: true,
|
|
635
639
|
showDetach: true,
|
|
636
640
|
saveDisabled: true,
|
|
@@ -62,6 +62,70 @@ describe('ExperimentPopover', () => {
|
|
|
62
62
|
wrapper.unmount()
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
+
it('shows a placeholder instead of "No experiment" while the record resolves', () => {
|
|
66
|
+
const wrapper = mountPopover({ isResolving: true, showDetach: true })
|
|
67
|
+
|
|
68
|
+
const trigger = wrapper.find('.mint-experiment-popover__trigger')
|
|
69
|
+
expect(trigger.find('.mint-experiment-popover__trigger-skeleton').exists()).toBe(true)
|
|
70
|
+
expect(trigger.text()).not.toContain('No experiment')
|
|
71
|
+
expect(trigger.attributes('aria-busy')).toBe('true')
|
|
72
|
+
|
|
73
|
+
wrapper.unmount()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('never renders the bound id as a stand-in name while resolving', () => {
|
|
77
|
+
const wrapper = mountPopover({ isResolving: true, showDetach: true })
|
|
78
|
+
|
|
79
|
+
expect(wrapper.text()).not.toMatch(/\d/)
|
|
80
|
+
|
|
81
|
+
wrapper.unmount()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('shows the resolution failure instead of "No experiment"', () => {
|
|
85
|
+
const wrapper = mountPopover({ error: 'Experiment not found', showDetach: true })
|
|
86
|
+
|
|
87
|
+
const trigger = wrapper.find('.mint-experiment-popover__trigger')
|
|
88
|
+
expect(trigger.text()).toContain('Experiment unavailable')
|
|
89
|
+
expect(trigger.text()).not.toContain('No experiment')
|
|
90
|
+
expect(trigger.classes()).toContain('mint-experiment-popover__trigger--error')
|
|
91
|
+
|
|
92
|
+
wrapper.unmount()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('surfaces the platform error message and keeps detach reachable on failure', async () => {
|
|
96
|
+
const wrapper = mountPopover({ error: 'Experiment not found', showDetach: true })
|
|
97
|
+
|
|
98
|
+
await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
|
|
99
|
+
|
|
100
|
+
const panel = wrapper.find('.mint-experiment-popover__panel')
|
|
101
|
+
expect(panel.text()).toContain('Experiment not found')
|
|
102
|
+
|
|
103
|
+
await wrapper.find('.mint-experiment-popover__detach-btn').trigger('click')
|
|
104
|
+
expect(wrapper.emitted('detach')).toHaveLength(1)
|
|
105
|
+
|
|
106
|
+
wrapper.unmount()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('shows skeleton placeholders in the panel while the record resolves', async () => {
|
|
110
|
+
const wrapper = mountPopover({ isResolving: true, showDetach: true })
|
|
111
|
+
|
|
112
|
+
await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
|
|
113
|
+
|
|
114
|
+
const panel = wrapper.find('.mint-experiment-popover__panel')
|
|
115
|
+
expect(panel.findAll('.mint-experiment-popover__skeleton-line').length).toBeGreaterThan(0)
|
|
116
|
+
expect(panel.find('.mint-experiment-popover__select-btn').exists()).toBe(false)
|
|
117
|
+
|
|
118
|
+
wrapper.unmount()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('hides the inline save action while no resolved experiment exists', () => {
|
|
122
|
+
const wrapper = mountPopover({ isResolving: true, showSave: true })
|
|
123
|
+
|
|
124
|
+
expect(wrapper.find('.mint-experiment-popover__save-trigger').exists()).toBe(false)
|
|
125
|
+
|
|
126
|
+
wrapper.unmount()
|
|
127
|
+
})
|
|
128
|
+
|
|
65
129
|
it('emits select and closes when changing experiment context', async () => {
|
|
66
130
|
const wrapper = mountPopover({
|
|
67
131
|
experimentName: 'Dose response run',
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
2
|
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
3
4
|
import { computed, reactive, ref } from 'vue'
|
|
4
5
|
|
|
5
6
|
import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vue'
|
|
6
7
|
import { useExperimentSelector } from '../../composables/useExperimentSelector'
|
|
8
|
+
import { useExperimentStore } from '../../stores/experiment'
|
|
9
|
+
import type { ExperimentSummary } from '../../types'
|
|
7
10
|
|
|
8
11
|
const fetchExperiments = vi.fn(async () => undefined)
|
|
9
12
|
const fetchFilterOptions = vi.fn(async () => undefined)
|
|
13
|
+
const experiments = ref<ExperimentSummary[]>([])
|
|
10
14
|
|
|
11
15
|
vi.mock('../../composables/useExperimentSelector', () => ({
|
|
12
16
|
useExperimentSelector: vi.fn(() => ({
|
|
13
|
-
experiments
|
|
17
|
+
experiments,
|
|
14
18
|
filters: reactive({
|
|
15
19
|
search: undefined,
|
|
16
20
|
status: undefined,
|
|
@@ -29,70 +33,111 @@ vi.mock('../../composables/useExperimentSelector', () => ({
|
|
|
29
33
|
})),
|
|
30
34
|
}))
|
|
31
35
|
|
|
36
|
+
function experiment(overrides: Partial<ExperimentSummary> = {}): ExperimentSummary {
|
|
37
|
+
return {
|
|
38
|
+
id: 7,
|
|
39
|
+
name: 'Hexokinase knockout run',
|
|
40
|
+
experiment_code: 'EXP-007',
|
|
41
|
+
status: 'ongoing',
|
|
42
|
+
experiment_type: 'screen',
|
|
43
|
+
created_at: '2026-07-01T08:00:00Z',
|
|
44
|
+
updated_at: '2026-07-01T08:00:00Z',
|
|
45
|
+
has_design_data: false,
|
|
46
|
+
...overrides,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ModalProps {
|
|
51
|
+
modelValue: boolean
|
|
52
|
+
allowedExperimentTypes?: string[] | null
|
|
53
|
+
currentExperimentId?: number | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mountModal(props: ModalProps) {
|
|
57
|
+
return mount(ExperimentSelectorModal, {
|
|
58
|
+
props,
|
|
59
|
+
global: {
|
|
60
|
+
stubs: {
|
|
61
|
+
BaseModal: {
|
|
62
|
+
props: ['modelValue', 'title', 'size'],
|
|
63
|
+
template: '<div><slot /></div>',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
32
70
|
describe('ExperimentSelectorModal', () => {
|
|
33
71
|
beforeEach(() => {
|
|
72
|
+
setActivePinia(createPinia())
|
|
73
|
+
experiments.value = []
|
|
34
74
|
fetchExperiments.mockClear()
|
|
35
75
|
fetchFilterOptions.mockClear()
|
|
36
76
|
vi.mocked(useExperimentSelector).mockClear()
|
|
37
77
|
})
|
|
38
78
|
|
|
39
79
|
it('fetches experiments when mounted already open', () => {
|
|
40
|
-
|
|
41
|
-
props: {
|
|
42
|
-
modelValue: true,
|
|
43
|
-
},
|
|
44
|
-
global: {
|
|
45
|
-
stubs: {
|
|
46
|
-
BaseModal: {
|
|
47
|
-
props: ['modelValue', 'title', 'size'],
|
|
48
|
-
template: '<div><slot /></div>',
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
})
|
|
80
|
+
mountModal({ modelValue: true })
|
|
53
81
|
|
|
54
82
|
expect(fetchFilterOptions).toHaveBeenCalledTimes(1)
|
|
55
83
|
expect(fetchExperiments).toHaveBeenCalledTimes(1)
|
|
56
84
|
})
|
|
57
85
|
|
|
58
86
|
it('does not fetch while mounted closed', () => {
|
|
59
|
-
|
|
60
|
-
props: {
|
|
61
|
-
modelValue: false,
|
|
62
|
-
},
|
|
63
|
-
global: {
|
|
64
|
-
stubs: {
|
|
65
|
-
BaseModal: {
|
|
66
|
-
props: ['modelValue', 'title', 'size'],
|
|
67
|
-
template: '<div><slot /></div>',
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
})
|
|
87
|
+
mountModal({ modelValue: false })
|
|
72
88
|
|
|
73
89
|
expect(fetchFilterOptions).not.toHaveBeenCalled()
|
|
74
90
|
expect(fetchExperiments).not.toHaveBeenCalled()
|
|
75
91
|
})
|
|
76
92
|
|
|
77
93
|
it('passes allowed experiment types to the selector composable', () => {
|
|
78
|
-
|
|
79
|
-
props: {
|
|
80
|
-
modelValue: false,
|
|
81
|
-
allowedExperimentTypes: ['mass_spectrometry'],
|
|
82
|
-
},
|
|
83
|
-
global: {
|
|
84
|
-
stubs: {
|
|
85
|
-
BaseModal: {
|
|
86
|
-
props: ['modelValue', 'title', 'size'],
|
|
87
|
-
template: '<div><slot /></div>',
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
})
|
|
94
|
+
mountModal({ modelValue: false, allowedExperimentTypes: ['mass_spectrometry'] })
|
|
92
95
|
|
|
93
96
|
expect(useExperimentSelector).toHaveBeenCalledWith({
|
|
94
97
|
experimentType: undefined,
|
|
95
98
|
allowedExperimentTypes: ['mass_spectrometry'],
|
|
96
99
|
})
|
|
97
100
|
})
|
|
101
|
+
|
|
102
|
+
it('commits the clicked platform record to the shared store', async () => {
|
|
103
|
+
const record = experiment()
|
|
104
|
+
experiments.value = [record]
|
|
105
|
+
const wrapper = mountModal({ modelValue: true })
|
|
106
|
+
|
|
107
|
+
await wrapper.find('.mint-experiment-selector__row').trigger('click')
|
|
108
|
+
|
|
109
|
+
const store = useExperimentStore()
|
|
110
|
+
expect(store.current).toEqual(record)
|
|
111
|
+
expect(store.currentId).toBe(7)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('does not emit a select event — the store is the single owner', async () => {
|
|
115
|
+
experiments.value = [experiment()]
|
|
116
|
+
const wrapper = mountModal({ modelValue: true })
|
|
117
|
+
|
|
118
|
+
await wrapper.find('.mint-experiment-selector__row').trigger('click')
|
|
119
|
+
|
|
120
|
+
expect(wrapper.emitted('select')).toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('closes itself after committing a selection', async () => {
|
|
124
|
+
experiments.value = [experiment()]
|
|
125
|
+
const wrapper = mountModal({ modelValue: true })
|
|
126
|
+
|
|
127
|
+
await wrapper.find('.mint-experiment-selector__row').trigger('click')
|
|
128
|
+
|
|
129
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('clears the shared store from the clear-selection footer', async () => {
|
|
133
|
+
const store = useExperimentStore()
|
|
134
|
+
store.select(experiment())
|
|
135
|
+
const wrapper = mountModal({ modelValue: true, currentExperimentId: 7 })
|
|
136
|
+
|
|
137
|
+
await wrapper.find('.mint-experiment-selector__clear-btn').trigger('click')
|
|
138
|
+
|
|
139
|
+
expect(store.current).toBeNull()
|
|
140
|
+
expect(store.currentId).toBeNull()
|
|
141
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
|
142
|
+
})
|
|
98
143
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
2
2
|
import type { VueWrapper } from '@vue/test-utils'
|
|
3
|
-
import { createPinia } from 'pinia'
|
|
3
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
4
4
|
import { computed, h, nextTick, ref } from 'vue'
|
|
5
5
|
import { describe, expect, it, vi } from 'vitest'
|
|
6
6
|
import AppLayout from '../../components/AppLayout.vue'
|
|
@@ -11,7 +11,13 @@ import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vu
|
|
|
11
11
|
import PluginWorkspaceView from '../../components/PluginWorkspaceView.vue'
|
|
12
12
|
import { defineDoseDesignControlModel } from '../../composables/useControlSchema'
|
|
13
13
|
import { usePlatformContext } from '../../composables/usePlatformContext'
|
|
14
|
-
import
|
|
14
|
+
import { useExperimentStore } from '../../stores/experiment'
|
|
15
|
+
import type {
|
|
16
|
+
ExperimentSummary,
|
|
17
|
+
PageSelectorItemInput,
|
|
18
|
+
PillNavItem,
|
|
19
|
+
SidebarToolSection,
|
|
20
|
+
} from '../../types'
|
|
15
21
|
|
|
16
22
|
vi.mock('../../composables/usePlatformContext', () => ({
|
|
17
23
|
usePlatformContext: vi.fn(() => ({
|
|
@@ -60,8 +66,23 @@ interface WorkspaceComponentSlotProps {
|
|
|
60
66
|
componentPropsById: Record<string, Record<string, unknown>>
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
const workspacePinia = createPinia()
|
|
70
|
+
|
|
71
|
+
function makeExperiment(overrides: Partial<ExperimentSummary> = {}): ExperimentSummary {
|
|
72
|
+
return {
|
|
73
|
+
id: 12,
|
|
74
|
+
name: 'Dose response',
|
|
75
|
+
status: 'ongoing',
|
|
76
|
+
experiment_type: 'screen',
|
|
77
|
+
created_at: '2026-07-01T08:00:00Z',
|
|
78
|
+
updated_at: '2026-07-01T08:00:00Z',
|
|
79
|
+
has_design_data: false,
|
|
80
|
+
...overrides,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
const globalOptions = {
|
|
64
|
-
plugins: [
|
|
85
|
+
plugins: [workspacePinia],
|
|
65
86
|
stubs: {
|
|
66
87
|
'router-link': {
|
|
67
88
|
props: ['to', 'custom'],
|
|
@@ -357,7 +378,7 @@ describe('PluginWorkspaceView', () => {
|
|
|
357
378
|
expect(wrapper.get('[data-test="sidebar-footer"]').text()).toBe('Run')
|
|
358
379
|
})
|
|
359
380
|
|
|
360
|
-
it('can provide AppTopBar experiment selection and save wiring from
|
|
381
|
+
it('can provide AppTopBar experiment selection and save wiring from the shared store', async () => {
|
|
361
382
|
vi.mocked(usePlatformContext).mockReturnValueOnce({
|
|
362
383
|
isIntegrated: computed(() => true),
|
|
363
384
|
context: ref({ isIntegrated: true, theme: 'system' }),
|
|
@@ -369,17 +390,20 @@ describe('PluginWorkspaceView', () => {
|
|
|
369
390
|
notify: vi.fn(),
|
|
370
391
|
sendToPlatform: vi.fn(),
|
|
371
392
|
})
|
|
393
|
+
setActivePinia(workspacePinia)
|
|
394
|
+
const store = useExperimentStore()
|
|
395
|
+
store.clear()
|
|
396
|
+
store.select(makeExperiment({
|
|
397
|
+
id: 12,
|
|
398
|
+
name: 'Dose response',
|
|
399
|
+
status: 'ongoing',
|
|
400
|
+
experiment_code: 'EXP-012',
|
|
401
|
+
}))
|
|
372
402
|
const experimentSave = vi.fn().mockResolvedValue('Saved shell')
|
|
373
403
|
const wrapper = mount(PluginWorkspaceView, {
|
|
374
404
|
props: {
|
|
375
405
|
title: 'Experiment Shell',
|
|
376
406
|
experimentShell: true,
|
|
377
|
-
experiment: {
|
|
378
|
-
id: 12,
|
|
379
|
-
name: 'Dose response',
|
|
380
|
-
status: 'ongoing',
|
|
381
|
-
experiment_code: 'EXP-012',
|
|
382
|
-
},
|
|
383
407
|
experimentSave,
|
|
384
408
|
experimentSaveDisabled: true,
|
|
385
409
|
experimentSaveDisabledMessage: 'Upload data first',
|
|
@@ -395,6 +419,7 @@ describe('PluginWorkspaceView', () => {
|
|
|
395
419
|
experimentName: 'Dose response',
|
|
396
420
|
experimentCode: 'EXP-012',
|
|
397
421
|
experimentStatus: 'ongoing',
|
|
422
|
+
isResolving: false,
|
|
398
423
|
showSave: true,
|
|
399
424
|
showDetach: true,
|
|
400
425
|
saveDisabled: true,
|
|
@@ -406,22 +431,25 @@ describe('PluginWorkspaceView', () => {
|
|
|
406
431
|
await flushPromises()
|
|
407
432
|
await nextTick()
|
|
408
433
|
|
|
409
|
-
wrapper.findComponent(ExperimentSelectorModal).
|
|
434
|
+
expect(wrapper.findComponent(ExperimentSelectorModal).props()).toMatchObject({
|
|
435
|
+
modelValue: true,
|
|
436
|
+
currentExperimentId: 12,
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
// The selector modal commits to the store instead of emitting; the shell
|
|
440
|
+
// re-emits what the platform returned.
|
|
441
|
+
const selected = makeExperiment({
|
|
410
442
|
id: 13,
|
|
411
443
|
name: 'Selected experiment',
|
|
412
444
|
status: 'planned',
|
|
413
445
|
experiment_code: 'EXP-013',
|
|
414
446
|
})
|
|
447
|
+
store.select(selected)
|
|
415
448
|
await nextTick()
|
|
416
449
|
|
|
417
|
-
expect(wrapper.emitted('experiment-select')).toEqual([[
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
name: 'Selected experiment',
|
|
421
|
-
status: 'planned',
|
|
422
|
-
experiment_code: 'EXP-013',
|
|
423
|
-
},
|
|
424
|
-
]])
|
|
450
|
+
expect(wrapper.emitted('experiment-select')).toEqual([[selected]])
|
|
451
|
+
expect(wrapper.findComponent(ExperimentPopover).props('experimentName'))
|
|
452
|
+
.toBe('Selected experiment')
|
|
425
453
|
|
|
426
454
|
wrapper.findComponent(ExperimentPopover).vm.$emit('save')
|
|
427
455
|
await nextTick()
|
|
@@ -432,6 +460,62 @@ describe('PluginWorkspaceView', () => {
|
|
|
432
460
|
await nextTick()
|
|
433
461
|
|
|
434
462
|
expect(wrapper.emitted('experiment-detach')).toEqual([[]])
|
|
463
|
+
expect(store.current).toBeNull()
|
|
464
|
+
expect(store.currentId).toBeNull()
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('shows the unresolved chip states rather than inventing a name from an id', async () => {
|
|
468
|
+
vi.mocked(usePlatformContext).mockReturnValueOnce({
|
|
469
|
+
isIntegrated: computed(() => true),
|
|
470
|
+
context: ref({ isIntegrated: true, theme: 'system' }),
|
|
471
|
+
plugin: computed(() => undefined),
|
|
472
|
+
user: computed(() => undefined),
|
|
473
|
+
theme: computed(() => 'system' as const),
|
|
474
|
+
features: computed(() => undefined),
|
|
475
|
+
navigate: vi.fn(),
|
|
476
|
+
notify: vi.fn(),
|
|
477
|
+
sendToPlatform: vi.fn(),
|
|
478
|
+
})
|
|
479
|
+
setActivePinia(workspacePinia)
|
|
480
|
+
const store = useExperimentStore()
|
|
481
|
+
store.clear()
|
|
482
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
483
|
+
props: {
|
|
484
|
+
title: 'Experiment Shell',
|
|
485
|
+
experimentShell: true,
|
|
486
|
+
showSidebar: false,
|
|
487
|
+
},
|
|
488
|
+
global: globalOptions,
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
await flushPromises()
|
|
492
|
+
await nextTick()
|
|
493
|
+
|
|
494
|
+
// Bound by id, record still in flight.
|
|
495
|
+
store.currentId = 12
|
|
496
|
+
store.isResolving = true
|
|
497
|
+
await nextTick()
|
|
498
|
+
|
|
499
|
+
expect(wrapper.findComponent(ExperimentPopover).props()).toMatchObject({
|
|
500
|
+
experimentName: undefined,
|
|
501
|
+
experimentCode: undefined,
|
|
502
|
+
isResolving: true,
|
|
503
|
+
showDetach: true,
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// Resolution failed — the binding survives so the user can detach.
|
|
507
|
+
store.isResolving = false
|
|
508
|
+
store.error = 'Experiment not found'
|
|
509
|
+
await nextTick()
|
|
510
|
+
|
|
511
|
+
expect(wrapper.findComponent(ExperimentPopover).props()).toMatchObject({
|
|
512
|
+
experimentName: undefined,
|
|
513
|
+
isResolving: false,
|
|
514
|
+
error: 'Experiment not found',
|
|
515
|
+
showDetach: true,
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
store.clear()
|
|
435
519
|
})
|
|
436
520
|
|
|
437
521
|
it('exposes model-derived component bindings to workspace slots', async () => {
|
|
@@ -130,6 +130,34 @@ describe('SettingsModal', () => {
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
it('preserves controlled values that are not represented in the form schema', async () => {
|
|
134
|
+
const schema: SettingsModalSchema = {
|
|
135
|
+
groups: [
|
|
136
|
+
{
|
|
137
|
+
id: 'general',
|
|
138
|
+
label: 'General',
|
|
139
|
+
fields: [
|
|
140
|
+
{ name: 'threshold', label: 'Threshold', type: 'number' },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
const wrapper = mountSettingsModal({
|
|
146
|
+
schema,
|
|
147
|
+
values: {
|
|
148
|
+
threshold: 0.25,
|
|
149
|
+
database_url: '**********',
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
await wrapper.find('input').setValue('0.5')
|
|
154
|
+
|
|
155
|
+
expect(wrapper.emitted('update:values')?.at(-1)?.[0]).toEqual({
|
|
156
|
+
threshold: 0.5,
|
|
157
|
+
database_url: '**********',
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
133
161
|
it('renders compact controls as schema-driven settings', async () => {
|
|
134
162
|
const controls: ControlSchema = {
|
|
135
163
|
threshold: {
|
|
@@ -344,7 +372,7 @@ describe('SettingsModal', () => {
|
|
|
344
372
|
const pinia = createPinia()
|
|
345
373
|
const auth = useAuthStore(pinia)
|
|
346
374
|
auth.setUserInfo({
|
|
347
|
-
id:
|
|
375
|
+
id: 1,
|
|
348
376
|
username: 'admin',
|
|
349
377
|
shortname: null,
|
|
350
378
|
email: null,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
experimentIdFromRoute,
|
|
4
5
|
formatExperimentStatus,
|
|
5
6
|
getExperimentStatusVariant,
|
|
6
7
|
resolveExperimentCode,
|
|
@@ -28,3 +29,35 @@ describe('experiment-utils', () => {
|
|
|
28
29
|
expect(resolveExperimentCode({ id: null })).toBeUndefined()
|
|
29
30
|
})
|
|
30
31
|
})
|
|
32
|
+
|
|
33
|
+
describe('experimentIdFromRoute', () => {
|
|
34
|
+
it('should read the id from the snake_case query key', () => {
|
|
35
|
+
expect(experimentIdFromRoute({ experiment_id: '42' })).toBe(42)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should read the id from the camelCase query key', () => {
|
|
39
|
+
expect(experimentIdFromRoute({ experimentId: '42' })).toBe(42)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// vue-router types repeated query params as (string | null)[], so a helper
|
|
43
|
+
// declared over string[] rejects the very type every caller passes it.
|
|
44
|
+
it('should read the id from a repeated query param containing nulls', () => {
|
|
45
|
+
expect(experimentIdFromRoute({ experiment_id: [null, '42'] })).toBe(42)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should return null when the query carries no experiment id', () => {
|
|
49
|
+
expect(experimentIdFromRoute({ other: 'value' })).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should return null for a non-numeric id', () => {
|
|
53
|
+
expect(experimentIdFromRoute({ experiment_id: 'abc' })).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should return null for a non-positive id', () => {
|
|
57
|
+
expect(experimentIdFromRoute({ experiment_id: '0' })).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should return null for a fractional id', () => {
|
|
61
|
+
expect(experimentIdFromRoute({ experiment_id: '4.2' })).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
})
|