@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.
Files changed (99) hide show
  1. package/dist/{EmptyState-DgBRoYAy.js → EmptyState-LMPS3keb.js} +3 -193
  2. package/dist/EmptyState-LMPS3keb.js.map +1 -0
  3. package/dist/{ExperimentPopover-BFw3QX8L.js → ExperimentPopover-D6_RGzTY.js} +113 -35
  4. package/dist/ExperimentPopover-D6_RGzTY.js.map +1 -0
  5. package/dist/{ExperimentSelectorModal-BBWXC7KW.js → ExperimentSelectorModal-DcQ38zua.js} +9 -11
  6. package/dist/ExperimentSelectorModal-DcQ38zua.js.map +1 -0
  7. package/dist/{SettingsModal-Dpg-X1OM.js → SettingsModal-Ccq2z-Ms.js} +9 -6
  8. package/dist/SettingsModal-Ccq2z-Ms.js.map +1 -0
  9. package/dist/__stories__/experiment-helpers.d.ts +3 -0
  10. package/dist/__tests__/stores/experiment.test.d.ts +1 -0
  11. package/dist/{analysisArtifactTypes-ejqR8MX3.js → analysisArtifactTypes-DQ0MjwHZ.js} +1 -1
  12. package/dist/{analysisArtifactTypes-ejqR8MX3.js.map → analysisArtifactTypes-DQ0MjwHZ.js.map} +1 -1
  13. package/dist/components/ExperimentPopover.vue.d.ts +5 -0
  14. package/dist/components/ExperimentSelectorModal.vue.d.ts +1 -5
  15. package/dist/components/PluginWorkspaceView.props.d.ts +1 -4
  16. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -1
  17. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  18. package/dist/components/index.js +7 -7
  19. package/dist/{components-Ci_BTx00.js → components-CV5zuWa8.js} +24 -29
  20. package/dist/components-CV5zuWa8.js.map +1 -0
  21. package/dist/composables/experiment-utils.d.ts +10 -0
  22. package/dist/composables/index.d.ts +2 -2
  23. package/dist/composables/index.js +11 -10
  24. package/dist/composables/useAppExperiment.d.ts +29 -17
  25. package/dist/composables/usePlatformContext.d.ts +3 -3
  26. package/dist/{composables-C0VEJ73O.js → composables-CW8NRXLB.js} +5 -5
  27. package/dist/{composables-C0VEJ73O.js.map → composables-CW8NRXLB.js.map} +1 -1
  28. package/dist/experiment-Y9Iz7KYi.js +95 -0
  29. package/dist/experiment-Y9Iz7KYi.js.map +1 -0
  30. package/dist/{experiment-utils-CxkruZ3G.js → experiment-utils-7Yw-h0tD.js} +16 -2
  31. package/dist/{experiment-utils-CxkruZ3G.js.map → experiment-utils-7Yw-h0tD.js.map} +1 -1
  32. package/dist/index.js +16 -15
  33. package/dist/index.js.map +1 -1
  34. package/dist/install.js +7 -7
  35. package/dist/{plotly.min-BR53hja4.js → plotly.min-c2Sez60c.js} +1 -1
  36. package/dist/{plotly.min-BR53hja4.js.map → plotly.min-c2Sez60c.js.map} +1 -1
  37. package/dist/stores/auth.d.ts +6 -6
  38. package/dist/stores/experiment.d.ts +166 -0
  39. package/dist/stores/index.d.ts +1 -0
  40. package/dist/stores/index.js +3 -2
  41. package/dist/styles.css +188 -0
  42. package/dist/templates/index.js +4 -4
  43. package/dist/{templates-Cdj93S9C.js → templates-CJRFiWUX.js} +2 -2
  44. package/dist/{templates-Cdj93S9C.js.map → templates-CJRFiWUX.js.map} +1 -1
  45. package/dist/types/auth.d.ts +1 -1
  46. package/dist/types/index.js +1 -1
  47. package/dist/types/platform.d.ts +1 -1
  48. package/dist/{auth-B87xM--s.js → useApi-CDBq6z23.js} +191 -2
  49. package/dist/useApi-CDBq6z23.js.map +1 -0
  50. package/dist/{useControlSchema-B_qgU4rW.js → useControlSchema-BEAgLyyx.js} +5 -4
  51. package/dist/{useControlSchema-B_qgU4rW.js.map → useControlSchema-BEAgLyyx.js.map} +1 -1
  52. package/dist/{useDropdownState-gVaMXtwW.js → useDropdownState-Be-ug_ci.js} +2 -2
  53. package/dist/{useDropdownState-gVaMXtwW.js.map → useDropdownState-Be-ug_ci.js.map} +1 -1
  54. package/dist/{useExperimentSelector-BuQZRgMt.js → useExperimentSelector-DFg_iEHI.js} +36 -16
  55. package/dist/useExperimentSelector-DFg_iEHI.js.map +1 -0
  56. package/dist/{useFormBuilder-D1G5J5Z5.js → useFormBuilder-CR9pBApW.js} +2 -2
  57. package/dist/{useFormBuilder-D1G5J5Z5.js.map → useFormBuilder-CR9pBApW.js.map} +1 -1
  58. package/dist/{useProtocolTemplates-DeaOmMaw.js → useProtocolTemplates-DXeIHMwV.js} +38 -42
  59. package/dist/useProtocolTemplates-DXeIHMwV.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/__stories__/experiment-helpers.ts +12 -9
  62. package/src/__tests__/components/AppTopBar.test.ts +10 -6
  63. package/src/__tests__/components/ExperimentPopover.test.ts +64 -0
  64. package/src/__tests__/components/ExperimentSelectorModal.test.ts +86 -41
  65. package/src/__tests__/components/PluginWorkspaceView.test.ts +103 -19
  66. package/src/__tests__/components/SettingsModal.test.ts +29 -1
  67. package/src/__tests__/composables/experiment-utils.test.ts +33 -0
  68. package/src/__tests__/composables/useAppExperiment.test.ts +331 -92
  69. package/src/__tests__/composables/useAuth.test.ts +2 -2
  70. package/src/__tests__/composables/useExperimentSelector.test.ts +71 -0
  71. package/src/__tests__/composables/usePlatformContext.test.ts +5 -1
  72. package/src/__tests__/stores/experiment.test.ts +300 -0
  73. package/src/components/AppTopBar.vue +0 -2
  74. package/src/components/ExperimentPopover.story.vue +59 -0
  75. package/src/components/ExperimentPopover.vue +96 -6
  76. package/src/components/ExperimentSelectorModal.story.vue +6 -16
  77. package/src/components/ExperimentSelectorModal.vue +8 -4
  78. package/src/components/PluginWorkspaceView.props.ts +1 -4
  79. package/src/components/PluginWorkspaceView.story.vue +12 -5
  80. package/src/components/PluginWorkspaceView.vue +0 -2
  81. package/src/components/SettingsModal.vue +6 -1
  82. package/src/composables/experiment-utils.ts +22 -0
  83. package/src/composables/index.ts +1 -2
  84. package/src/composables/useAppExperiment.ts +57 -67
  85. package/src/composables/useAuth.ts +1 -1
  86. package/src/composables/useExperimentSelector.ts +45 -13
  87. package/src/stores/experiment.ts +120 -0
  88. package/src/stores/index.ts +1 -0
  89. package/src/styles/components/experiment-popover.css +105 -0
  90. package/src/types/auth.ts +1 -1
  91. package/src/types/platform.ts +1 -1
  92. package/dist/EmptyState-DgBRoYAy.js.map +0 -1
  93. package/dist/ExperimentPopover-BFw3QX8L.js.map +0 -1
  94. package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +0 -1
  95. package/dist/SettingsModal-Dpg-X1OM.js.map +0 -1
  96. package/dist/auth-B87xM--s.js.map +0 -1
  97. package/dist/components-Ci_BTx00.js.map +0 -1
  98. package/dist/useExperimentSelector-BuQZRgMt.js.map +0 -1
  99. 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.0",
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 { set } = useAppExperiment({
73
+ const experimentStore = useExperimentStore()
74
+ useAppExperiment({
70
75
  onSave: async () => 'Saved!',
71
76
  })
72
- if (props.preselect) {
73
- set({
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(() => { delete (window as any).__MINT_PLATFORM__ })
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: ref('Dose response'),
580
- experimentCode: ref('EXP-012'),
581
- experimentStatus: ref('ongoing'),
582
- experimentId: ref(12),
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: ref([]),
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
- mount(ExperimentSelectorModal, {
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
- mount(ExperimentSelectorModal, {
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
- mount(ExperimentSelectorModal, {
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 type { PageSelectorItemInput, PillNavItem, SidebarToolSection } from '../../types'
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: [createPinia()],
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 shell props', async () => {
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).vm.$emit('select', {
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
- id: 13,
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: '1',
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
+ })