@morscherlab/mint-sdk 1.1.0 → 1.1.1

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 (94) 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-CDNsNudn.js} +9 -11
  6. package/dist/ExperimentSelectorModal-CDNsNudn.js.map +1 -0
  7. package/dist/{SettingsModal-Dpg-X1OM.js → SettingsModal-BGBimSIA.js} +5 -5
  8. package/dist/{SettingsModal-Dpg-X1OM.js.map → SettingsModal-BGBimSIA.js.map} +1 -1
  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-BbOyjE2T.js} +24 -29
  20. package/dist/components-BbOyjE2T.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-Dld4841S.js} +4 -3
  55. package/dist/{useExperimentSelector-BuQZRgMt.js.map → useExperimentSelector-Dld4841S.js.map} +1 -1
  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 +1 -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/usePlatformContext.test.ts +5 -1
  71. package/src/__tests__/stores/experiment.test.ts +300 -0
  72. package/src/components/AppTopBar.vue +0 -2
  73. package/src/components/ExperimentPopover.story.vue +59 -0
  74. package/src/components/ExperimentPopover.vue +96 -6
  75. package/src/components/ExperimentSelectorModal.story.vue +6 -16
  76. package/src/components/ExperimentSelectorModal.vue +8 -4
  77. package/src/components/PluginWorkspaceView.props.ts +1 -4
  78. package/src/components/PluginWorkspaceView.story.vue +12 -5
  79. package/src/components/PluginWorkspaceView.vue +0 -2
  80. package/src/composables/experiment-utils.ts +22 -0
  81. package/src/composables/index.ts +1 -2
  82. package/src/composables/useAppExperiment.ts +57 -67
  83. package/src/composables/useAuth.ts +1 -1
  84. package/src/stores/experiment.ts +120 -0
  85. package/src/stores/index.ts +1 -0
  86. package/src/styles/components/experiment-popover.css +105 -0
  87. package/src/types/auth.ts +1 -1
  88. package/src/types/platform.ts +1 -1
  89. package/dist/EmptyState-DgBRoYAy.js.map +0 -1
  90. package/dist/ExperimentPopover-BFw3QX8L.js.map +0 -1
  91. package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +0 -1
  92. package/dist/auth-B87xM--s.js.map +0 -1
  93. package/dist/components-Ci_BTx00.js.map +0 -1
  94. package/dist/useProtocolTemplates-DeaOmMaw.js.map +0 -1
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { ref, computed, nextTick } from 'vue'
3
+ import { createPinia, setActivePinia } from 'pinia'
4
+ import axios from 'axios'
3
5
 
4
6
  // Capture side-effect callbacks so tests can invoke them directly
5
7
  let capturedScopeDispose: (() => void) | null = null
@@ -22,6 +24,7 @@ vi.mock('vue', async () => {
22
24
 
23
25
  import { useAppExperiment, APP_EXPERIMENT_KEY } from '../../composables/useAppExperiment'
24
26
  import type { AppExperimentState } from '../../composables/useAppExperiment'
27
+ import { useExperimentStore } from '../../stores/experiment'
25
28
  import type { ExperimentSummary } from '../../types'
26
29
 
27
30
  // ---------------------------------------------------------------------------
@@ -45,6 +48,37 @@ function getState(): AppExperimentState {
45
48
  return capturedProvideValue as AppExperimentState
46
49
  }
47
50
 
51
+ interface PendingFetch {
52
+ promise: Promise<{ data: ExperimentSummary }>
53
+ resolveWith: (experiment: ExperimentSummary) => void
54
+ rejectWith: (error: Error) => void
55
+ }
56
+
57
+ // Read at call time, not at spy-install time: axios binds its prototype methods
58
+ // into each instance when the instance is created, so a spy installed per test
59
+ // never reaches the client the SDK already built.
60
+ let pendingFetch: PendingFetch | null = null
61
+
62
+ /**
63
+ * Holds the platform request open so tests can observe the composable while the
64
+ * store is mid-resolve — the state an id-only deep link starts in.
65
+ */
66
+ function stubExperimentFetch(): PendingFetch {
67
+ let settle!: (result: { data: ExperimentSummary } | Error) => void
68
+ const promise = new Promise<{ data: ExperimentSummary }>((resolve, reject) => {
69
+ settle = (result) => {
70
+ if (result instanceof Error) reject(result)
71
+ else resolve(result)
72
+ }
73
+ })
74
+ pendingFetch = {
75
+ promise,
76
+ resolveWith: (experiment: ExperimentSummary) => settle({ data: experiment }),
77
+ rejectWith: (error: Error) => settle(error),
78
+ }
79
+ return pendingFetch
80
+ }
81
+
48
82
  // ---------------------------------------------------------------------------
49
83
  // Tests
50
84
  // ---------------------------------------------------------------------------
@@ -54,6 +88,12 @@ describe('useAppExperiment', () => {
54
88
  capturedScopeDispose = null
55
89
  capturedProvideKey = null
56
90
  capturedProvideValue = null
91
+ setActivePinia(createPinia())
92
+ pendingFetch = null
93
+ vi.spyOn(axios.Axios.prototype, 'get').mockImplementation((() => {
94
+ if (!pendingFetch) return Promise.reject(new Error('Unexpected platform request'))
95
+ return pendingFetch.promise
96
+ }) as never)
57
97
  vi.useFakeTimers()
58
98
  })
59
99
 
@@ -79,107 +119,138 @@ describe('useAppExperiment', () => {
79
119
  })
80
120
 
81
121
  // -------------------------------------------------------------------------
82
- // set() and clear()
122
+ // Store-backed state
83
123
  // -------------------------------------------------------------------------
84
124
 
85
- describe('set', () => {
86
- it('should initialize from a direct experiment option', () => {
87
- const { experimentName, experimentCode, experimentId } = useAppExperiment({
88
- experiment: makeExperiment({ id: 8, name: 'Initial run', experiment_code: 'EXP-008' }),
89
- })
125
+ describe('store-backed experiment state', () => {
126
+ it('should mirror a record the platform already returned', () => {
127
+ const { experimentName, experimentCode, experimentId } = useAppExperiment()
90
128
 
91
- expect(experimentName.value).toBe('Initial run')
92
- expect(experimentCode.value).toBe('EXP-008')
93
- expect(experimentId.value).toBe(8)
94
- })
95
-
96
- it('should sync and clear a reactive experiment option', async () => {
97
- const source = ref<ExperimentSummary | null>(
129
+ useExperimentStore().select(
98
130
  makeExperiment({ id: 8, name: 'Initial run', experiment_code: 'EXP-008' }),
99
131
  )
100
- const { experimentName, experimentCode, experimentId } = useAppExperiment({
101
- experiment: source,
102
- })
103
132
 
104
133
  expect(experimentName.value).toBe('Initial run')
105
134
  expect(experimentCode.value).toBe('EXP-008')
106
135
  expect(experimentId.value).toBe(8)
136
+ })
107
137
 
108
- source.value = makeExperiment({ id: 9, name: 'Updated run', experiment_code: 'EXP-009' })
109
- await nextTick()
138
+ it('should leave experimentCode undefined for a record without a code', () => {
139
+ const { experimentCode } = useAppExperiment()
140
+
141
+ useExperimentStore().select(makeExperiment({ id: 7, name: 'Alpha Run' }))
142
+
143
+ expect(experimentCode.value).toBeUndefined()
144
+ })
145
+
146
+ it('should follow the store when the selection changes', () => {
147
+ const store = useExperimentStore()
148
+ const { experimentName, experimentCode, experimentId } = useAppExperiment()
149
+
150
+ store.select(makeExperiment({ id: 8, name: 'Initial run', experiment_code: 'EXP-008' }))
151
+ store.select(makeExperiment({ id: 9, name: 'Updated run', experiment_code: 'EXP-009' }))
110
152
 
111
153
  expect(experimentName.value).toBe('Updated run')
112
154
  expect(experimentCode.value).toBe('EXP-009')
113
155
  expect(experimentId.value).toBe(9)
156
+ })
114
157
 
115
- source.value = null
116
- await nextTick()
158
+ it('should expose the status from the store record', () => {
159
+ useAppExperiment()
160
+
161
+ useExperimentStore().select(makeExperiment({ status: 'completed' }))
162
+
163
+ expect(getState().experimentStatus.value).toBe('completed')
164
+ })
165
+
166
+ it('should reset when the store is cleared', () => {
167
+ const store = useExperimentStore()
168
+ const { experimentName, experimentCode, experimentStatus, experimentId } = useAppExperiment()
169
+
170
+ store.select(makeExperiment({ id: 99, name: 'To Be Cleared', experiment_code: 'EXP-099' }))
171
+ store.clear()
117
172
 
118
173
  expect(experimentName.value).toBeUndefined()
119
174
  expect(experimentCode.value).toBeUndefined()
175
+ expect(experimentStatus.value).toBeUndefined()
120
176
  expect(experimentId.value).toBeNull()
121
177
  })
122
178
 
123
- it('should update experimentName and experimentId on set', () => {
124
- const { set, experimentName, experimentCode, experimentId } = useAppExperiment()
125
- const experiment = makeExperiment({ id: 7, name: 'Alpha Run' })
179
+ it('should report isResolving while the store resolves an id', async () => {
180
+ const fetch = stubExperimentFetch()
181
+ const store = useExperimentStore()
182
+ const { isResolving } = useAppExperiment()
126
183
 
127
- set(experiment)
184
+ const pending = store.selectById(8)
185
+ expect(isResolving.value).toBe(true)
128
186
 
129
- expect(experimentName.value).toBe('Alpha Run')
130
- expect(experimentCode.value).toBeUndefined()
131
- expect(experimentId.value).toBe(7)
187
+ fetch.resolveWith(makeExperiment({ id: 8, name: 'Resolved run' }))
188
+ await pending
189
+
190
+ expect(isResolving.value).toBe(false)
132
191
  })
133
192
 
134
- it('should update experimentStatus in provided state', () => {
135
- const { set } = useAppExperiment()
136
- const experiment = makeExperiment({ status: 'completed' })
193
+ it('should expose the bound id but no name while an id-only binding resolves', async () => {
194
+ const fetch = stubExperimentFetch()
195
+ const store = useExperimentStore()
196
+ const { experimentId, experimentName, experimentCode } = useAppExperiment()
137
197
 
138
- set(experiment)
198
+ const pending = store.selectById(8)
139
199
 
140
- expect(getState().experimentStatus.value).toBe('completed')
200
+ expect(experimentId.value).toBe(8)
201
+ expect(experimentName.value).toBeUndefined()
202
+ expect(experimentCode.value).toBeUndefined()
203
+
204
+ fetch.resolveWith(makeExperiment({ id: 8, name: 'Resolved run' }))
205
+ await pending
141
206
  })
142
207
 
143
- it('should overwrite previously set values', () => {
144
- const { set, experimentName, experimentId } = useAppExperiment()
208
+ it('should take the name from the platform once an id-only binding resolves', async () => {
209
+ const fetch = stubExperimentFetch()
210
+ const store = useExperimentStore()
211
+ const { experimentName } = useAppExperiment()
145
212
 
146
- set(makeExperiment({ id: 1, name: 'First' }))
147
- set(makeExperiment({ id: 2, name: 'Second' }))
213
+ const pending = store.selectById(8)
214
+ fetch.resolveWith(makeExperiment({ id: 8, name: 'Hexokinase knockout run' }))
215
+ await pending
148
216
 
149
- expect(experimentName.value).toBe('Second')
150
- expect(experimentId.value).toBe(2)
217
+ expect(experimentName.value).toBe('Hexokinase knockout run')
151
218
  })
152
- })
153
219
 
154
- describe('clear', () => {
155
- it('should reset experimentName and experimentId to initial values', () => {
156
- const { set, clear, experimentName, experimentId } = useAppExperiment()
220
+ it('should surface the store error when resolution fails', async () => {
221
+ const fetch = stubExperimentFetch()
222
+ const store = useExperimentStore()
223
+ const { error } = useAppExperiment()
157
224
 
158
- set(makeExperiment({ id: 99, name: 'To Be Cleared' }))
159
- clear()
225
+ const pending = store.selectById(8)
226
+ fetch.rejectWith(new Error('Experiment not found'))
227
+ await pending
160
228
 
161
- expect(experimentName.value).toBeUndefined()
162
- expect(experimentId.value).toBeNull()
229
+ expect(error.value).toBe('Experiment not found')
163
230
  })
164
231
 
165
- it('should reset experimentStatus in provided state', () => {
166
- const { set, clear } = useAppExperiment()
232
+ it('should expose no name after a failed resolve, keeping the bound id', async () => {
233
+ const fetch = stubExperimentFetch()
234
+ const store = useExperimentStore()
235
+ const { experimentName, experimentCode, experimentId } = useAppExperiment()
167
236
 
168
- set(makeExperiment({ status: 'ongoing' }))
169
- clear()
237
+ const pending = store.selectById(8)
238
+ fetch.rejectWith(new Error('Experiment not found'))
239
+ await pending
170
240
 
171
- expect(getState().experimentStatus.value).toBeUndefined()
241
+ expect(experimentName.value).toBeUndefined()
242
+ expect(experimentCode.value).toBeUndefined()
243
+ expect(experimentId.value).toBe(8)
172
244
  })
173
245
  })
174
246
 
175
- describe('returned refs are readonly', () => {
176
- it('should expose experimentName as a readonly ref', () => {
247
+ describe('returned refs are read-only projections of the store', () => {
248
+ it('should expose experimentName as undefined with nothing selected', () => {
177
249
  const { experimentName } = useAppExperiment()
178
- // Readonly refs still have a .value property for reading
179
250
  expect(experimentName.value).toBeUndefined()
180
251
  })
181
252
 
182
- it('should expose experimentId as a readonly ref', () => {
253
+ it('should expose experimentId as null with nothing selected', () => {
183
254
  const { experimentId } = useAppExperiment()
184
255
  expect(experimentId.value).toBeNull()
185
256
  })
@@ -210,48 +281,75 @@ describe('useAppExperiment', () => {
210
281
  })
211
282
 
212
283
  // -------------------------------------------------------------------------
213
- // handleSelect
284
+ // onSelect — fired by the store adopting an experiment, not by the shell
214
285
  // -------------------------------------------------------------------------
215
286
 
216
- describe('handleSelect', () => {
217
- it('should call onSelect callback with the experiment', () => {
287
+ describe('onSelect', () => {
288
+ it('should call onSelect when the store adopts an experiment', async () => {
218
289
  const onSelect = vi.fn()
219
290
  useAppExperiment({ onSelect })
220
- const state = getState()
221
- const experiment = makeExperiment()
222
291
 
223
- state.handleSelect(experiment)
292
+ useExperimentStore().select(makeExperiment())
293
+ await nextTick()
224
294
 
225
295
  expect(onSelect).toHaveBeenCalledOnce()
226
- expect(onSelect).toHaveBeenCalledWith(experiment)
227
296
  })
228
297
 
229
- it('should set experimentId and experimentName via handleSelect', () => {
230
- const { experimentId, experimentName } = useAppExperiment()
231
- const state = getState()
298
+ it('should pass the platform record through unchanged', async () => {
299
+ const onSelect = vi.fn()
300
+ useAppExperiment({ onSelect })
232
301
  const experiment = makeExperiment({ id: 5, name: 'Selected Exp' })
233
302
 
234
- state.handleSelect(experiment)
303
+ useExperimentStore().select(experiment)
304
+ await nextTick()
235
305
 
236
- expect(experimentId.value).toBe(5)
237
- expect(experimentName.value).toBe('Selected Exp')
306
+ expect(onSelect).toHaveBeenCalledWith(experiment)
238
307
  })
239
308
 
240
- it('should close the modal after selection', () => {
241
- useAppExperiment()
242
- const state = getState()
309
+ it('should call onSelect when an id-only binding resolves', async () => {
310
+ const fetch = stubExperimentFetch()
311
+ const onSelect = vi.fn()
312
+ const store = useExperimentStore()
313
+ useAppExperiment({ onSelect })
243
314
 
244
- state.openModal()
245
- state.handleSelect(makeExperiment())
315
+ const pending = store.selectById(8)
316
+ fetch.resolveWith(makeExperiment({ id: 8, name: 'Resolved run' }))
317
+ await pending
318
+ await nextTick()
246
319
 
247
- expect(state.showModal.value).toBe(false)
320
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ name: 'Resolved run' }))
321
+ })
322
+
323
+ it('should not call onSelect when the store is cleared', async () => {
324
+ const onSelect = vi.fn()
325
+ const store = useExperimentStore()
326
+ useAppExperiment({ onSelect })
327
+
328
+ store.select(makeExperiment())
329
+ await nextTick()
330
+ onSelect.mockClear()
331
+
332
+ store.clear()
333
+ await nextTick()
334
+
335
+ expect(onSelect).not.toHaveBeenCalled()
336
+ })
337
+
338
+ it('should not call onSelect for an experiment already selected at setup', async () => {
339
+ const onSelect = vi.fn()
340
+ useExperimentStore().select(makeExperiment())
341
+
342
+ useAppExperiment({ onSelect })
343
+ await nextTick()
344
+
345
+ expect(onSelect).not.toHaveBeenCalled()
248
346
  })
249
347
 
250
- it('should not throw when no onSelect callback is provided', () => {
348
+ it('should not throw when no onSelect callback is provided', async () => {
251
349
  useAppExperiment()
252
- const state = getState()
253
350
 
254
- expect(() => state.handleSelect(makeExperiment())).not.toThrow()
351
+ expect(() => useExperimentStore().select(makeExperiment())).not.toThrow()
352
+ await nextTick()
255
353
  })
256
354
  })
257
355
 
@@ -391,17 +489,36 @@ describe('useAppExperiment', () => {
391
489
  // -------------------------------------------------------------------------
392
490
 
393
491
  describe('handleDetach', () => {
394
- it('should clear experiment state', () => {
395
- const { set, experimentId, experimentName } = useAppExperiment()
492
+ it('should clear the shared store', () => {
493
+ const store = useExperimentStore()
494
+ const { experimentId, experimentName } = useAppExperiment()
396
495
  const state = getState()
397
496
 
398
- set(makeExperiment({ id: 3, name: 'Detachable' }))
497
+ store.select(makeExperiment({ id: 3, name: 'Detachable' }))
399
498
  state.handleDetach()
400
499
 
500
+ expect(store.current).toBeNull()
501
+ expect(store.currentId).toBeNull()
401
502
  expect(experimentId.value).toBeNull()
402
503
  expect(experimentName.value).toBeUndefined()
403
504
  })
404
505
 
506
+ it('should clear a binding whose resolution failed', async () => {
507
+ const fetch = stubExperimentFetch()
508
+ const store = useExperimentStore()
509
+ const { experimentId, error } = useAppExperiment()
510
+ const state = getState()
511
+
512
+ const pending = store.selectById(8)
513
+ fetch.rejectWith(new Error('Experiment not found'))
514
+ await pending
515
+
516
+ state.handleDetach()
517
+
518
+ expect(experimentId.value).toBeNull()
519
+ expect(error.value).toBeNull()
520
+ })
521
+
405
522
  it('should call onDetach callback', () => {
406
523
  const onDetach = vi.fn()
407
524
  useAppExperiment({ onDetach })
@@ -421,7 +538,7 @@ describe('useAppExperiment', () => {
421
538
 
422
539
  it('should call onDetach after clearing state', () => {
423
540
  const calls: string[] = []
424
- const { set, experimentId } = useAppExperiment({
541
+ const { experimentId } = useAppExperiment({
425
542
  onDetach: () => {
426
543
  // By the time onDetach fires, experimentId should already be null
427
544
  calls.push(experimentId.value === null ? 'cleared' : 'not-cleared')
@@ -429,7 +546,7 @@ describe('useAppExperiment', () => {
429
546
  })
430
547
  const state = getState()
431
548
 
432
- set(makeExperiment())
549
+ useExperimentStore().select(makeExperiment())
433
550
  state.handleDetach()
434
551
 
435
552
  expect(calls).toEqual(['cleared'])
@@ -462,19 +579,44 @@ describe('useAppExperiment', () => {
462
579
  expect(getState().showDetach.value).toBe(false)
463
580
  })
464
581
 
465
- it('should be true after an experiment is set', () => {
466
- const { set } = useAppExperiment()
582
+ it('should be true after an experiment is selected', () => {
583
+ useAppExperiment()
467
584
 
468
- set(makeExperiment())
585
+ useExperimentStore().select(makeExperiment())
469
586
 
470
587
  expect(getState().showDetach.value).toBe(true)
471
588
  })
472
589
 
473
- it('should revert to false after clear is called', () => {
474
- const { set, clear } = useAppExperiment()
590
+ it('should be true while an id-only binding is still resolving', async () => {
591
+ const fetch = stubExperimentFetch()
592
+ const store = useExperimentStore()
593
+ useAppExperiment()
594
+
595
+ const pending = store.selectById(8)
596
+ expect(getState().showDetach.value).toBe(true)
475
597
 
476
- set(makeExperiment())
477
- clear()
598
+ fetch.resolveWith(makeExperiment({ id: 8 }))
599
+ await pending
600
+ })
601
+
602
+ it('should stay true after a failed resolve so the user can detach', async () => {
603
+ const fetch = stubExperimentFetch()
604
+ const store = useExperimentStore()
605
+ useAppExperiment()
606
+
607
+ const pending = store.selectById(8)
608
+ fetch.rejectWith(new Error('Experiment not found'))
609
+ await pending
610
+
611
+ expect(getState().showDetach.value).toBe(true)
612
+ })
613
+
614
+ it('should revert to false after the store is cleared', () => {
615
+ const store = useExperimentStore()
616
+ useAppExperiment()
617
+
618
+ store.select(makeExperiment())
619
+ store.clear()
478
620
 
479
621
  expect(getState().showDetach.value).toBe(false)
480
622
  })
@@ -558,10 +700,10 @@ describe('useAppExperiment', () => {
558
700
  const saveDisabled = ref(false)
559
701
  const saveDisabledMessage = ref<string | undefined>('Ready to save')
560
702
  const onSave = vi.fn().mockResolvedValue('Saved')
561
- const { set } = useAppExperiment({ onSave, saveDisabled, saveDisabledMessage })
703
+ useAppExperiment({ onSave, saveDisabled, saveDisabledMessage })
562
704
  const state = getState()
563
705
 
564
- set(makeExperiment({
706
+ useExperimentStore().select(makeExperiment({
565
707
  id: 12,
566
708
  name: 'Dose response',
567
709
  status: 'ongoing',
@@ -572,12 +714,14 @@ describe('useAppExperiment', () => {
572
714
  experimentName: 'Dose response',
573
715
  experimentCode: 'EXP-012',
574
716
  experimentStatus: 'ongoing',
717
+ isResolving: false,
575
718
  showSave: true,
576
719
  showDetach: true,
577
720
  saveDisabled: false,
578
721
  saveDisabledMessage: 'Ready to save',
579
722
  saveLoading: false,
580
723
  })
724
+ expect(state.popover.value.error).toBeUndefined()
581
725
 
582
726
  saveDisabled.value = true
583
727
  saveDisabledMessage.value = 'Locked'
@@ -590,6 +734,44 @@ describe('useAppExperiment', () => {
590
734
  expect(state.popover.value.saveSuccessMessage).toBe('Saved')
591
735
  })
592
736
 
737
+ it('marks the popover binding as resolving with no name to show', async () => {
738
+ const fetch = stubExperimentFetch()
739
+ const store = useExperimentStore()
740
+ useAppExperiment()
741
+ const state = getState()
742
+
743
+ const pending = store.selectById(12)
744
+
745
+ expect(state.popover.value).toMatchObject({
746
+ experimentName: undefined,
747
+ experimentCode: undefined,
748
+ isResolving: true,
749
+ showDetach: true,
750
+ })
751
+ expect(state.popover.value.error).toBeUndefined()
752
+
753
+ fetch.resolveWith(makeExperiment({ id: 12 }))
754
+ await pending
755
+ })
756
+
757
+ it('puts the resolution failure on the popover binding and keeps detach', async () => {
758
+ const fetch = stubExperimentFetch()
759
+ const store = useExperimentStore()
760
+ useAppExperiment()
761
+ const state = getState()
762
+
763
+ const pending = store.selectById(12)
764
+ fetch.rejectWith(new Error('Experiment not found'))
765
+ await pending
766
+
767
+ expect(state.popover.value).toMatchObject({
768
+ experimentName: undefined,
769
+ isResolving: false,
770
+ error: 'Experiment not found',
771
+ showDetach: true,
772
+ })
773
+ })
774
+
593
775
  it('exposes live ExperimentSelectorModal props from state and return value', () => {
594
776
  const appExperiment = useAppExperiment()
595
777
  const state = getState()
@@ -600,7 +782,7 @@ describe('useAppExperiment', () => {
600
782
  })
601
783
  expect(appExperiment.selectorModal.value).toEqual(state.selectorModal.value)
602
784
 
603
- appExperiment.set(makeExperiment({ id: 77 }))
785
+ useExperimentStore().select(makeExperiment({ id: 77 }))
604
786
  state.openModal()
605
787
 
606
788
  expect(state.selectorModal.value).toEqual({
@@ -609,6 +791,19 @@ describe('useAppExperiment', () => {
609
791
  })
610
792
  expect(appExperiment.popover.value.experimentName).toBe('Test Experiment')
611
793
  })
794
+
795
+ it('keeps the selector bound to the id after a failed resolve', async () => {
796
+ const fetch = stubExperimentFetch()
797
+ const store = useExperimentStore()
798
+ useAppExperiment()
799
+ const state = getState()
800
+
801
+ const pending = store.selectById(77)
802
+ fetch.rejectWith(new Error('Experiment not found'))
803
+ await pending
804
+
805
+ expect(state.selectorModal.value.currentExperimentId).toBe(77)
806
+ })
612
807
  })
613
808
 
614
809
  // -------------------------------------------------------------------------
@@ -658,3 +853,47 @@ describe('useAppExperiment', () => {
658
853
  })
659
854
  })
660
855
  })
856
+
857
+ describe('useAppExperiment browser storage isolation', () => {
858
+ function installLegacySelection() {
859
+ const storage: Record<string, string> = { 'mint-current-experiment-id': '42' }
860
+ vi.stubGlobal('localStorage', {
861
+ getItem: vi.fn((key: string) => storage[key] ?? null),
862
+ setItem: vi.fn((key: string, value: string) => { storage[key] = value }),
863
+ removeItem: vi.fn((key: string) => { delete storage[key] }),
864
+ clear: vi.fn(() => { for (const key of Object.keys(storage)) delete storage[key] }),
865
+ } as unknown as Storage)
866
+ }
867
+
868
+ beforeEach(() => {
869
+ setActivePinia(createPinia())
870
+ pendingFetch = null
871
+ installLegacySelection()
872
+ vi.spyOn(axios.Axios.prototype, 'get').mockRejectedValue(new Error('Unexpected platform request'))
873
+ })
874
+
875
+ afterEach(() => {
876
+ vi.restoreAllMocks()
877
+ vi.unstubAllGlobals()
878
+ })
879
+
880
+ it('should ignore a legacy persisted id when nothing is selected', () => {
881
+ const { experimentId } = useAppExperiment()
882
+
883
+ expect(experimentId.value).toBeNull()
884
+ })
885
+
886
+ it('should not request an experiment during shell setup', () => {
887
+ useAppExperiment()
888
+
889
+ expect(axios.Axios.prototype.get).not.toHaveBeenCalled()
890
+ })
891
+
892
+ it('should leave an already-bound experiment alone', () => {
893
+ useExperimentStore().select(makeExperiment({ id: 7, name: 'Already chosen' }))
894
+
895
+ const { experimentName } = useAppExperiment()
896
+
897
+ expect(experimentName.value).toBe('Already chosen')
898
+ })
899
+ })
@@ -56,7 +56,7 @@ describe('useAuth', () => {
56
56
  data: { access_token: 'test-token', expires_in: 3600, token_type: 'bearer' },
57
57
  })
58
58
  mockedAxios.get.mockResolvedValueOnce({
59
- data: { id: '1', username: 'test', shortname: null, email: null, role: 'user', is_active: true },
59
+ data: { id: 1, username: 'test', shortname: null, email: null, role: 'user', is_active: true },
60
60
  })
61
61
 
62
62
  const { login } = useAuth()
@@ -162,7 +162,7 @@ describe('useAuth', () => {
162
162
  throw new Error(`Unexpected POST ${url}`)
163
163
  })
164
164
  mockedAxios.get.mockResolvedValue({
165
- data: { id: '1', username: 'test', shortname: null, email: null, role: 'user', is_active: true },
165
+ data: { id: 1, username: 'test', shortname: null, email: null, role: 'user', is_active: true },
166
166
  })
167
167
 
168
168
  const authA = useAuth()
@@ -1,4 +1,4 @@
1
- import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
1
+ import { beforeEach, afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
2
2
 
3
3
  const unmountedCallbacks: Array<() => void> = []
4
4
 
@@ -39,6 +39,10 @@ function sendThemeMessage(origin: string, theme: 'light' | 'dark' | 'system'): v
39
39
  }
40
40
 
41
41
  describe('usePlatformContext', () => {
42
+ it('uses integer IDs for injected users', () => {
43
+ expectTypeOf<NonNullable<PlatformContext['user']>['id']>().toEqualTypeOf<number>()
44
+ })
45
+
42
46
  beforeEach(() => {
43
47
  unmountedCallbacks.length = 0
44
48
  messageHandler = null