@morscherlab/mint-sdk 1.4.0-beta.7 → 1.4.0-beta.9

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 (257) hide show
  1. package/dist/{ActionMenu-CScINahU.js → AlertBox-B2Bgr3ei.js} +40 -209
  2. package/dist/AlertBox-B2Bgr3ei.js.map +1 -0
  3. package/dist/{AppSidebar-BmiiPfz9.js → AppSidebar-Dq9W1jE2.js} +160 -109
  4. package/dist/AppSidebar-Dq9W1jE2.js.map +1 -0
  5. package/dist/{ConfirmDialog-CJ7hqYUP.js → ConfirmDialog-nbJj51is.js} +2 -2
  6. package/dist/{ConfirmDialog-CJ7hqYUP.js.map → ConfirmDialog-nbJj51is.js.map} +1 -1
  7. package/dist/ExperimentPopover-5T1fAq2L.js +337 -0
  8. package/dist/ExperimentPopover-5T1fAq2L.js.map +1 -0
  9. package/dist/{ExperimentSelectorModal-D-9z7cNx.js → ExperimentSelectorModal-65peG4Up.js} +198 -181
  10. package/dist/ExperimentSelectorModal-65peG4Up.js.map +1 -0
  11. package/dist/{SegmentedControl-DERBN-Xd.js → SegmentedControl-BtnnjWzs.js} +2 -2
  12. package/dist/{SegmentedControl-DERBN-Xd.js.map → SegmentedControl-BtnnjWzs.js.map} +1 -1
  13. package/dist/{SettingsModal-CSLE31yg.js → SettingsModal-483nyI6b.js} +9 -9
  14. package/dist/SettingsModal-483nyI6b.js.map +1 -0
  15. package/dist/components/AppSidebar.vue.d.ts +1 -1
  16. package/dist/components/ArtifactSaveDialog.vue.d.ts +9 -1
  17. package/dist/components/AuditTrail.vue.d.ts +1 -1
  18. package/dist/components/BaseSelect.vue.d.ts +2 -2
  19. package/dist/components/ChartContainer.vue.d.ts +2 -0
  20. package/dist/components/DatePicker.vue.d.ts +1 -1
  21. package/dist/components/ExperimentPopover.vue.d.ts +18 -0
  22. package/dist/components/ExperimentSelectorModal.vue.d.ts +0 -4
  23. package/dist/components/FilePicker.vue.d.ts +1 -1
  24. package/dist/components/FileUploader.vue.d.ts +2 -2
  25. package/dist/components/PluginIcon.vue.d.ts +2 -2
  26. package/dist/components/SampleSelector.vue.d.ts +6 -6
  27. package/dist/components/SequenceProgressBar.vue.d.ts +3 -0
  28. package/dist/components/StepWizard.vue.d.ts +3 -0
  29. package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +1 -1
  30. package/dist/components/internal/DatePickerTimeInternal.vue.d.ts +1 -1
  31. package/dist/components/internal/ResourcePickerListInternal.vue.d.ts +2 -7
  32. package/dist/components/internal/SelectMultipleInternal.vue.d.ts +4 -0
  33. package/dist/composables/createClient.d.ts +2 -0
  34. package/dist/composables/index.d.ts +1 -0
  35. package/dist/composables/pluginIdentity.d.ts +24 -0
  36. package/dist/composables/useAppExperiment.d.ts +8 -0
  37. package/dist/composables/useCalendarGrid.d.ts +1 -1
  38. package/dist/composables/useExperimentSelector.d.ts +6 -2
  39. package/dist/composables/usePlatform.d.ts +1 -0
  40. package/dist/{controlSchemaModel-CP5-_nAj.js → controlSchemaModel-B8CTXagP.js} +2 -2
  41. package/dist/{controlSchemaModel-CP5-_nAj.js.map → controlSchemaModel-B8CTXagP.js.map} +1 -1
  42. package/dist/{data-C2PwI_dn.js → data-CgILxDLW.js} +178 -124
  43. package/dist/data-CgILxDLW.js.map +1 -0
  44. package/dist/data.js +8 -8
  45. package/dist/{experimentStatus-BZmU4czV.js → experimentStatus-Cqp6qNrR.js} +3 -3
  46. package/dist/{experimentStatus-BZmU4czV.js.map → experimentStatus-Cqp6qNrR.js.map} +1 -1
  47. package/dist/index.js +19 -19
  48. package/dist/{install-DAID6c2-.js → install-BtLWTk6H.js} +15 -14
  49. package/dist/{install-DAID6c2-.js.map → install-BtLWTk6H.js.map} +1 -1
  50. package/dist/install.js +1 -1
  51. package/dist/{lab-DLwRl_Ql.js → lab-MHzp-_J8.js} +128 -147
  52. package/dist/lab-MHzp-_J8.js.map +1 -0
  53. package/dist/lab.js +4 -3
  54. package/dist/{records-BzTcxekG.js → records-B6gpM_3e.js} +472 -274
  55. package/dist/records-B6gpM_3e.js.map +1 -0
  56. package/dist/{runtime-D6gVat6r.js → runtime-7vfl_bI3.js} +27 -31
  57. package/dist/runtime-7vfl_bI3.js.map +1 -0
  58. package/dist/runtime.js +6 -6
  59. package/dist/{shell-5ju97nOS.js → shell-Cs9OJMnm.js} +5 -5
  60. package/dist/{shell-5ju97nOS.js.map → shell-Cs9OJMnm.js.map} +1 -1
  61. package/dist/shell.js +5 -5
  62. package/dist/styles.css +1606 -1666
  63. package/dist/types/componentWorkflowTypes.d.ts +2 -0
  64. package/dist/types/generated/platformContext.d.ts +1 -0
  65. package/dist/types/generated-ui.d.ts +4 -0
  66. package/dist/{BaseModal-DfRIuVoj.js → useDropdownState-8yWMu-7_.js} +60 -2
  67. package/dist/useDropdownState-8yWMu-7_.js.map +1 -0
  68. package/dist/{useExperimentSamples-CxcnxZ8h.js → useExperimentSamples-DHnxKFAF.js} +3 -3
  69. package/dist/{useExperimentSamples-CxcnxZ8h.js.map → useExperimentSamples-DHnxKFAF.js.map} +1 -1
  70. package/dist/{useListReorder-CcbEYxX-.js → useListReorder-BitdUhxn.js} +109 -277
  71. package/dist/useListReorder-BitdUhxn.js.map +1 -0
  72. package/dist/{useRackEditor-BD5NIo5s.js → useRackEditor-DiwCt77M.js} +71 -57
  73. package/dist/useRackEditor-DiwCt77M.js.map +1 -0
  74. package/dist/{useRealtime-PalKue84.js → useRealtime-C3iy9QkU.js} +7 -4
  75. package/dist/useRealtime-C3iy9QkU.js.map +1 -0
  76. package/dist/useRequest-D506eHi8.js +244 -0
  77. package/dist/useRequest-D506eHi8.js.map +1 -0
  78. package/dist/utils/time.d.ts +7 -0
  79. package/package.json +1 -1
  80. package/src/__tests__/components/AppTopBar.test.ts +51 -2
  81. package/src/__tests__/components/AppTopBarPillNav.test.ts +35 -0
  82. package/src/__tests__/components/ArtifactSaveDialog.test.ts +78 -0
  83. package/src/__tests__/components/BaseSelect.multiple.test.ts +87 -32
  84. package/src/__tests__/components/BaseSelect.test.ts +2 -1
  85. package/src/__tests__/components/BatchProgressList.test.ts +16 -0
  86. package/src/__tests__/components/CalendarGridPanel.test.ts +2 -2
  87. package/src/__tests__/components/ChartContainer.test.ts +11 -0
  88. package/src/__tests__/components/ChemicalFormula.test.ts +6 -3
  89. package/src/__tests__/components/DatePicker.datetime.test.ts +6 -0
  90. package/src/__tests__/components/DatePicker.test.ts +21 -0
  91. package/src/__tests__/components/DatePicker.time.test.ts +2 -2
  92. package/src/__tests__/components/EmptyState.test.ts +1 -1
  93. package/src/__tests__/components/ExperimentPopover.test.ts +125 -16
  94. package/src/__tests__/components/ExperimentSelectorModal.test.ts +119 -13
  95. package/src/__tests__/components/GeneratedPluginView.test.ts +22 -1
  96. package/src/__tests__/components/InstrumentStateBadge.test.ts +4 -4
  97. package/src/__tests__/components/ResourcePickerListInternal.test.ts +0 -20
  98. package/src/__tests__/components/SampleSelector.test.ts +13 -8
  99. package/src/__tests__/components/SequenceProgressBar.test.ts +12 -0
  100. package/src/__tests__/components/StepWizard.test.ts +11 -0
  101. package/src/__tests__/components/WellPlate.decoration.test.ts +11 -4
  102. package/src/__tests__/components/deprecationTags.test.ts +1 -1
  103. package/src/__tests__/composables/useAppExperiment.test.ts +27 -1
  104. package/src/__tests__/composables/useCalendarGrid.test.ts +4 -2
  105. package/src/__tests__/composables/useExperimentSelector.test.ts +111 -0
  106. package/src/__tests__/styles/baseControlsRules.test.ts +6 -2
  107. package/src/__tests__/styles/controlRefreshRules.test.ts +0 -6
  108. package/src/__tests__/styles/cssTokens.test.ts +2 -2
  109. package/src/__tests__/styles/menusShellRefresh.test.ts +14 -10
  110. package/src/__tests__/utils/plotlyTemplate.test.ts +4 -4
  111. package/src/__tests__/utils/time.test.ts +18 -1
  112. package/src/components/AdductText.story.vue +7 -0
  113. package/src/components/AdductText.vue +1 -1
  114. package/src/components/AlertBox.story.vue +2 -2
  115. package/src/components/AlertBox.vue +18 -16
  116. package/src/components/AppSidebar.story.vue +0 -28
  117. package/src/components/AppTopBar.story.vue +47 -12
  118. package/src/components/AppTopBar.vue +118 -114
  119. package/src/components/ArtifactSaveDialog.story.vue +23 -0
  120. package/src/components/ArtifactSaveDialog.vue +102 -12
  121. package/src/components/AuditTrail.story.vue +2 -1
  122. package/src/components/AuditTrail.vue +2 -21
  123. package/src/components/Avatar.vue +10 -14
  124. package/src/components/BaseSelect.multiple.story.vue +13 -2
  125. package/src/components/BaseSelect.vue +4 -3
  126. package/src/components/BatchProgressList.vue +35 -25
  127. package/src/components/ChartContainer.story.vue +2 -1
  128. package/src/components/ChartContainer.vue +8 -6
  129. package/src/components/ConfirmDialog.story.vue +1 -1
  130. package/src/components/EmptyState.vue +1 -1
  131. package/src/components/ExperimentPopover.story.vue +165 -53
  132. package/src/components/ExperimentPopover.vue +169 -187
  133. package/src/components/ExperimentSelectorModal.story.vue +40 -36
  134. package/src/components/ExperimentSelectorModal.vue +124 -110
  135. package/src/components/FormField.story.vue +8 -48
  136. package/src/components/GeneratedPluginView.vue +7 -1
  137. package/src/components/InstrumentStateBadge.vue +1 -1
  138. package/src/components/JobsStatusTray.vue +1 -1
  139. package/src/components/LoadingSpinner.vue +1 -1
  140. package/src/components/NumberInput.vue +3 -6
  141. package/src/components/PluginIcon.story.vue +2 -10
  142. package/src/components/PluginIcon.vue +2 -2
  143. package/src/components/PluginWorkspaceView.story.vue +1 -1
  144. package/src/components/RuntimeUpdateNotice.vue +1 -1
  145. package/src/components/SampleSelector.story.vue +1 -1
  146. package/src/components/SampleSelector.vue +27 -37
  147. package/src/components/SampleSelectorGroupNode.vue +1 -1
  148. package/src/components/SectionCard.story.vue +2 -2
  149. package/src/components/SequenceProgressBar.story.vue +9 -0
  150. package/src/components/SequenceProgressBar.vue +6 -1
  151. package/src/components/SettingsModal.schema.ts +1 -1
  152. package/src/components/SettingsModal.vue +2 -2
  153. package/src/components/SmartGroupFieldRecipe.story.vue +1 -1
  154. package/src/components/SmartGroupManual.story.vue +1 -1
  155. package/src/components/StepWizard.story.vue +33 -0
  156. package/src/components/StepWizard.vue +8 -1
  157. package/src/components/WellPlate.story.vue +3 -1
  158. package/src/components/WellPlate.vue +7 -0
  159. package/src/components/internal/AppTopBarPillNavInternal.vue +33 -2
  160. package/src/components/internal/DatePickerDateInternal.vue +16 -40
  161. package/src/components/internal/DatePickerDateTimeInternal.vue +3 -13
  162. package/src/components/internal/DatePickerTimeInternal.vue +5 -8
  163. package/src/components/internal/ResourcePickerListInternal.vue +45 -85
  164. package/src/components/internal/SelectMultipleInternal.vue +182 -65
  165. package/src/components/internal/SelectSearchableInternal.vue +2 -3
  166. package/src/composables/createClient.ts +2 -0
  167. package/src/composables/index.ts +1 -0
  168. package/src/composables/pluginIdentity.ts +31 -0
  169. package/src/composables/useAppExperiment.ts +20 -0
  170. package/src/composables/useCalendarGrid.ts +4 -3
  171. package/src/composables/useExperimentSelector.ts +77 -31
  172. package/src/styles/components/adduct-text.css +13 -5
  173. package/src/styles/components/alert-box.css +39 -58
  174. package/src/styles/components/app-layout.css +6 -5
  175. package/src/styles/components/app-page-selector.css +3 -1
  176. package/src/styles/components/app-pill-nav.css +11 -12
  177. package/src/styles/components/app-sidebar.css +9 -18
  178. package/src/styles/components/app-top-bar.css +57 -55
  179. package/src/styles/components/artifact-save-dialog.css +153 -2
  180. package/src/styles/components/audit-trail.css +16 -19
  181. package/src/styles/components/avatar.css +6 -4
  182. package/src/styles/components/batch-progress-list.css +38 -36
  183. package/src/styles/components/button.css +28 -59
  184. package/src/styles/components/calendar.css +12 -12
  185. package/src/styles/components/chart-container.css +34 -23
  186. package/src/styles/components/checkbox.css +2 -12
  187. package/src/styles/components/collapsible-card.css +27 -29
  188. package/src/styles/components/confirm-dialog.css +13 -14
  189. package/src/styles/components/dataframe.css +12 -4
  190. package/src/styles/components/date-picker.css +80 -92
  191. package/src/styles/components/datetime-picker.css +31 -43
  192. package/src/styles/components/divider.css +20 -20
  193. package/src/styles/components/dose-calculator.css +3 -3
  194. package/src/styles/components/empty-state.css +20 -17
  195. package/src/styles/components/experiment-code-badge.css +2 -0
  196. package/src/styles/components/experiment-popover.css +174 -329
  197. package/src/styles/components/experiment-selector-modal.css +75 -56
  198. package/src/styles/components/file-picker.css +3 -3
  199. package/src/styles/components/file-uploader.css +38 -49
  200. package/src/styles/components/form-builder.css +3 -3
  201. package/src/styles/components/formula-input.css +1 -1
  202. package/src/styles/components/icon-button.css +18 -24
  203. package/src/styles/components/input.css +5 -4
  204. package/src/styles/components/instrument-monitor.css +13 -15
  205. package/src/styles/components/jobs-status-tray.css +31 -25
  206. package/src/styles/components/modal.css +2 -4
  207. package/src/styles/components/molecule-input.css +2 -2
  208. package/src/styles/components/multi-select.css +65 -117
  209. package/src/styles/components/number-input.css +26 -19
  210. package/src/styles/components/pill.css +3 -2
  211. package/src/styles/components/plate-editor.css +0 -1
  212. package/src/styles/components/plugin-icon.css +26 -23
  213. package/src/styles/components/popover.css +2 -2
  214. package/src/styles/components/presence-avatars.css +1 -1
  215. package/src/styles/components/protocol-step-editor.css +1 -3
  216. package/src/styles/components/resource-card.css +2 -2
  217. package/src/styles/components/resource-picker-list.css +6 -57
  218. package/src/styles/components/sample-hierarchy-tree.css +4 -4
  219. package/src/styles/components/sample-selector.css +95 -79
  220. package/src/styles/components/schedule-calendar.css +8 -8
  221. package/src/styles/components/searchable-select.css +12 -12
  222. package/src/styles/components/section-card.css +28 -47
  223. package/src/styles/components/segmented-control.css +18 -15
  224. package/src/styles/components/select.css +3 -3
  225. package/src/styles/components/sequence-input.css +2 -2
  226. package/src/styles/components/sequence-progress-bar.css +17 -8
  227. package/src/styles/components/smart-group.css +18 -18
  228. package/src/styles/components/step-wizard.css +52 -16
  229. package/src/styles/components/tabs.css +2 -2
  230. package/src/styles/components/textarea.css +3 -3
  231. package/src/styles/components/time-picker.css +36 -21
  232. package/src/styles/components/toast.css +12 -10
  233. package/src/styles/components/toggle.css +1 -11
  234. package/src/styles/components/tooltip.css +10 -9
  235. package/src/styles/components/unit-input.css +8 -7
  236. package/src/styles/components/well-plate.css +9 -2
  237. package/src/styles/variables.css +106 -75
  238. package/src/types/componentWorkflowTypes.ts +2 -0
  239. package/src/types/generated/platformContext.ts +1 -0
  240. package/src/types/generated-ui.ts +4 -0
  241. package/src/utils/time.ts +21 -0
  242. package/dist/ActionMenu-CScINahU.js.map +0 -1
  243. package/dist/AppSidebar-BmiiPfz9.js.map +0 -1
  244. package/dist/BaseModal-DfRIuVoj.js.map +0 -1
  245. package/dist/ExperimentPopover-C4sGw-is.js +0 -359
  246. package/dist/ExperimentPopover-C4sGw-is.js.map +0 -1
  247. package/dist/ExperimentSelectorModal-D-9z7cNx.js.map +0 -1
  248. package/dist/SettingsModal-CSLE31yg.js.map +0 -1
  249. package/dist/data-C2PwI_dn.js.map +0 -1
  250. package/dist/lab-DLwRl_Ql.js.map +0 -1
  251. package/dist/records-BzTcxekG.js.map +0 -1
  252. package/dist/runtime-D6gVat6r.js.map +0 -1
  253. package/dist/useListReorder-CcbEYxX-.js.map +0 -1
  254. package/dist/useRackEditor-BD5NIo5s.js.map +0 -1
  255. package/dist/useRealtime-PalKue84.js.map +0 -1
  256. package/dist/useRequest-D5JDfsgZ.js +0 -76
  257. package/dist/useRequest-D5JDfsgZ.js.map +0 -1
@@ -196,4 +196,39 @@ describe('AppTopBarPillNavInternal', () => {
196
196
 
197
197
  expect(wrapper.find('.mint-pill-nav__item').text()).toBe('Records')
198
198
  })
199
+ const SIX = ['One', 'Two', 'Three', 'Four', 'Five', { id: 'grp', label: 'Group', children: ['Six'] }]
200
+
201
+ it('should fold the items after the fourth into a More dropdown', async () => {
202
+ const wrapper = mount(AppTopBarPillNavInternal, { props: { currentItemId: 'One', items: SIX } })
203
+
204
+ expect(wrapper.findAll('.mint-pill-nav__item').map(item => item.text())).toEqual([
205
+ 'One', 'Two', 'Three', 'Four', 'More',
206
+ ])
207
+ await wrapper.findAll('.mint-pill-nav__item')[4].trigger('click')
208
+ expect(wrapper.findAll('.mint-pill-nav__dropdown-label').map(option => option.text())).toEqual(['Five', 'Six'])
209
+ })
210
+
211
+ it('should emit the original item or group when a folded entry is chosen', async () => {
212
+ const wrapper = mount(AppTopBarPillNavInternal, { props: { currentItemId: 'One', items: SIX } })
213
+
214
+ await wrapper.findAll('.mint-pill-nav__item')[4].trigger('click')
215
+ await wrapper.findAll('.mint-pill-nav__dropdown-item')[0].trigger('click')
216
+ await wrapper.findAll('.mint-pill-nav__item')[4].trigger('click')
217
+ await wrapper.findAll('.mint-pill-nav__dropdown-item')[1].trigger('click')
218
+
219
+ expect(wrapper.emitted('select')).toEqual([[{ id: 'Five', label: 'Five' }]])
220
+ expect(wrapper.emitted('option-select')?.[0]).toEqual([
221
+ { id: 'Six', label: 'Six' },
222
+ { id: 'grp', label: 'Group', children: [{ id: 'Six', label: 'Six' }] },
223
+ ])
224
+ })
225
+
226
+ it('should label the More pill with a folded item when that item is current', () => {
227
+ const wrapper = mount(AppTopBarPillNavInternal, { props: { currentItemId: 'Five', items: SIX } })
228
+
229
+ const more = wrapper.findAll('.mint-pill-nav__item')[4]
230
+ expect(more.text()).toBe('Five')
231
+ expect(more.find('.mint-pill-nav__chevron').exists()).toBe(true)
232
+ expect(more.classes()).toContain('mint-pill-nav__item--active')
233
+ })
199
234
  })
@@ -565,4 +565,82 @@ describe('ArtifactSaveDialog', () => {
565
565
  expect(wrapper.find('.mint-artifact-save-dialog__error').exists()).toBe(true)
566
566
  })
567
567
  })
568
+
569
+ // -------------------------------------------------------------------------
570
+ // Save mode (replace vs new) and experiment strip
571
+ // -------------------------------------------------------------------------
572
+
573
+ describe('save mode', () => {
574
+ it.each([
575
+ [null, false],
576
+ [makeDetail(), true],
577
+ ])('should offer the replace/new cards only when an artifact can be replaced (%#)', (existingArtifact, shown) => {
578
+ const { wrapper } = mountDialog({ result: { x: 1 }, existingArtifact })
579
+
580
+ expect(wrapper.find('.mint-artifact-save-dialog__modes').exists()).toBe(shown)
581
+ })
582
+
583
+ it('should send a create payload when save as new is picked over an existing artifact', async () => {
584
+ const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 }, existingArtifact: makeDetail() })
585
+ expect(wrapper.get('.mint-artifact-save-dialog__submit').text()).toBe('Replace result')
586
+
587
+ await wrapper.findAll('.mint-artifact-save-dialog__mode')[1].trigger('click')
588
+ expect(wrapper.find('.mint-artifact-save-dialog__name').exists()).toBe(true)
589
+ expect(wrapper.get('.mint-artifact-save-dialog__submit').text()).toBe('Save result')
590
+ await submit(wrapper)
591
+
592
+ expect(saveHandler.mock.calls[0][0]).toMatchObject({ kind: 'json', mode: 'create', artifactKey: 'default' })
593
+ })
594
+
595
+ // An explicit "new result" must never upsert over an existing JSON artifact.
596
+ it.each(['fit', 'other'])('should block a json save as new when the key %s exists', async (key) => {
597
+ const { wrapper, saveHandler } = mountDialog({
598
+ mode: 'json',
599
+ result: { x: 1 },
600
+ existingArtifact: makeDetail(),
601
+ existingArtifactKeys: ['other'],
602
+ })
603
+
604
+ await wrapper.findAll('.mint-artifact-save-dialog__mode')[1].trigger('click')
605
+ await wrapper.get('.mint-artifact-save-dialog__key input').setValue(key)
606
+ await submit(wrapper)
607
+
608
+ expect(saveHandler).not.toHaveBeenCalled()
609
+ expect(wrapper.get('.mint-artifact-save-dialog__submit').attributes('disabled')).toBeDefined()
610
+ expect(wrapper.get('.mint-artifact-save-dialog__key').text()).toContain('already used')
611
+ })
612
+
613
+ it('should show the experiment strip with code and name when the experiment is known', () => {
614
+ const { wrapper } = mountDialog({
615
+ experiment: { id: 42, experiment_code: 'EXP-042', name: 'Dose response' },
616
+ })
617
+
618
+ expect(wrapper.get('.mint-artifact-save-dialog__experiment').text()).toContain('EXP-042')
619
+ expect(wrapper.get('.mint-artifact-save-dialog__experiment-name').text()).toBe('Dose response')
620
+ })
621
+
622
+ it.each([
623
+ [false, false],
624
+ [true, true],
625
+ ])('should show the Change link only when a change-experiment listener is bound (%s)', (bound, shown) => {
626
+ const listener = vi.fn()
627
+ const { wrapper } = mountDialog({
628
+ experiment: { id: 42, name: 'Dose response' },
629
+ ...(bound ? { onChangeExperiment: listener } : {}),
630
+ })
631
+
632
+ expect(wrapper.find('.mint-artifact-save-dialog__change').exists()).toBe(shown)
633
+ })
634
+
635
+ it('should emit change-experiment when Change is clicked', async () => {
636
+ const { wrapper } = mountDialog({
637
+ experiment: { id: 42, name: 'Dose response' },
638
+ onChangeExperiment: vi.fn(),
639
+ })
640
+
641
+ await wrapper.get('.mint-artifact-save-dialog__change').trigger('click')
642
+
643
+ expect(wrapper.emitted('change-experiment')).toHaveLength(1)
644
+ })
645
+ })
568
646
  })
@@ -8,56 +8,111 @@ const options = [
8
8
  { value: 'c', label: 'C' },
9
9
  ]
10
10
 
11
+ function mountMultiple(props: Record<string, unknown> = {}) {
12
+ return mount(BaseSelect, { props: { multiple: true, modelValue: ['a'], options, ...props } })
13
+ }
14
+
15
+ async function openPanel(wrapper: ReturnType<typeof mountMultiple>) {
16
+ await wrapper.find('.mint-multi-select__field').trigger('click')
17
+ return wrapper
18
+ }
19
+
11
20
  describe('BaseSelect multiple', () => {
12
- it('should render the chip list when multiple is a bare template attribute', () => {
21
+ it('should render the chip field when multiple is a bare template attribute', () => {
13
22
  // A compiled `<BaseSelect multiple>` passes '' because `multiple` has no runtime Boolean type.
14
23
  const wrapper = mount(BaseSelect, { props: { multiple: '' as unknown as true, modelValue: [], options } })
15
24
 
16
- expect(wrapper.find('.mint-multi-select').exists()).toBe(true)
25
+ expect(wrapper.find('.mint-multi-select__field').exists()).toBe(true)
17
26
  })
18
27
 
19
28
  it('accepts string and number shorthand options', async () => {
20
- const wrapper = mount(BaseSelect, {
21
- props: {
22
- multiple: true,
23
- modelValue: ['glucose', 2],
24
- options: ['glucose', 2, { value: 'dox', label: 'Doxorubicin' }],
25
- },
26
- })
29
+ const wrapper = await openPanel(mountMultiple({
30
+ modelValue: ['glucose', 2],
31
+ options: ['glucose', 2, { value: 'dox', label: 'Doxorubicin' }],
32
+ }))
27
33
 
28
- expect(wrapper.findAll('button').map(button => button.text())).toEqual([
29
- 'glucose',
30
- '2',
31
- 'Doxorubicin',
32
- ])
33
- expect(wrapper.find('.mint-multi-select__summary').text()).toBe('glucose, 2')
34
+ expect(wrapper.findAll('.mint-multi-select__chip').map(chip => chip.text())).toEqual(['glucose', '2'])
35
+ expect(wrapper.findAll('[role="option"]').map(row => row.text())).toEqual(['glucose', '2', 'Doxorubicin'])
34
36
 
35
- await wrapper.findAll('button')[2].trigger('click')
37
+ await wrapper.findAll('[role="option"]')[2].trigger('click')
36
38
 
37
- expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([
38
- ['glucose', 2, 'dox'],
39
- ])
39
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['glucose', 2, 'dox']])
40
40
  })
41
41
 
42
42
  it('uses the shared selection limit for max', async () => {
43
- const wrapper = mount(BaseSelect, {
44
- props: {
45
- multiple: true,
46
- modelValue: ['a'],
47
- options,
48
- max: 1,
49
- },
50
- })
43
+ const wrapper = await openPanel(mountMultiple({ max: 1 }))
51
44
 
52
- await wrapper.findAll('button')[1].trigger('click')
45
+ await wrapper.findAll('[role="option"]')[1].trigger('click')
53
46
 
54
47
  expect(wrapper.emitted('update:modelValue')).toBeUndefined()
55
- expect(wrapper.findAll('button')[1].attributes('disabled')).toBeDefined()
48
+ expect(wrapper.findAll('[role="option"]')[1].attributes('aria-disabled')).toBe('true')
49
+ })
50
+
51
+ it('should reserve the check box on every row when only one is selected', async () => {
52
+ const wrapper = await openPanel(mountMultiple())
53
+
54
+ expect(wrapper.findAll('.mint-multi-select__check')).toHaveLength(3)
55
+ expect(wrapper.findAll('.mint-multi-select__check--on')).toHaveLength(1)
56
+ })
57
+
58
+ it.each(['Enter', ' ', 'ArrowDown'])('should open the listbox when %j is pressed on the field', async (key) => {
59
+ const wrapper = mountMultiple()
60
+
61
+ await wrapper.find('.mint-multi-select__field').trigger('keydown', { key })
62
+
63
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
64
+ expect(wrapper.find('.mint-multi-select__field').attributes('aria-expanded')).toBe('true')
65
+ })
66
+
67
+ it('should close the listbox when the field is clicked while it is open', async () => {
68
+ const wrapper = await openPanel(mountMultiple())
69
+
70
+ await wrapper.find('.mint-multi-select__field').trigger('click')
71
+
72
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
73
+ expect(wrapper.find('.mint-multi-select__field').attributes('aria-expanded')).toBe('false')
74
+ })
75
+
76
+ it('should close the listbox when Escape is pressed in the filter', async () => {
77
+ const wrapper = await openPanel(mountMultiple())
78
+
79
+ await wrapper.find('input[type="search"]').trigger('keydown', { key: 'Escape' })
80
+
81
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
82
+ })
83
+
84
+ it('should remove the last chip when Backspace is pressed in an empty filter', async () => {
85
+ const wrapper = await openPanel(mountMultiple({ modelValue: ['a', 'b'] }))
86
+
87
+ await wrapper.find('input[type="search"]').trigger('keydown', { key: 'Backspace' })
88
+
89
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['a']])
90
+ })
91
+
92
+ it('should remove a chip without opening the listbox when its remove button is clicked', async () => {
93
+ const wrapper = mountMultiple({ modelValue: ['a', 'b'] })
94
+
95
+ await wrapper.find('button[aria-label="Remove A"]').trigger('click')
96
+
97
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['b']])
98
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
99
+ })
100
+
101
+ it('should narrow the rows and toggle the active row with Enter when the filter has text', async () => {
102
+ const wrapper = await openPanel(mountMultiple())
103
+ const filter = wrapper.find('input[type="search"]')
104
+
105
+ await filter.setValue('c')
106
+ await filter.trigger('keydown', { key: 'Enter' })
107
+
108
+ expect(wrapper.findAll('[role="option"]').map(row => row.text())).toEqual(['C'])
109
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['a', 'c']])
56
110
  })
57
111
 
58
- it('should reserve the check slot on every chip when only one is selected', () => {
59
- const wrapper = mount(BaseSelect, { props: { multiple: true, modelValue: ['a'], options } })
112
+ it('should neither open nor offer chip removal when disabled', async () => {
113
+ const wrapper = await openPanel(mountMultiple({ disabled: true }))
60
114
 
61
- expect(wrapper.findAll('.mint-multi-select__chip-check')).toHaveLength(3)
115
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
116
+ expect(wrapper.find('.mint-multi-select__chip-remove').exists()).toBe(false)
62
117
  })
63
118
  })
@@ -58,7 +58,8 @@ describe('BaseSelect', () => {
58
58
  it.each([
59
59
  ['aria-labelledby', 'native', {}, 'select'],
60
60
  ['aria-describedby', 'searchable', { searchable: true }, '.mint-searchable-select__trigger'],
61
- ['aria-describedby', 'multiple', { multiple: true, modelValue: [] as string[] }, null],
61
+ ['aria-describedby', 'multiple', { multiple: true, modelValue: [] as string[] }, '.mint-multi-select__field'],
62
+ ['aria-labelledby', 'multiple', { multiple: true, modelValue: [] as string[] }, '.mint-multi-select__field'],
62
63
  ] as const)('should forward %s when the select is %s', (attr, _, extra, selector) => {
63
64
  const wrapper = mount(BaseSelect, { props: { options: ['linear'], ...extra }, attrs: { [attr]: 'hint' } })
64
65
  const target = selector ? wrapper.get(selector) : wrapper
@@ -57,4 +57,20 @@ describe('BatchProgressList', () => {
57
57
  await wrapper.get('.mint-batch-progress__completed-toggle').trigger('click')
58
58
  expect(wrapper.findAll('.mint-batch-progress__item')).toHaveLength(3)
59
59
  })
60
+
61
+ it.each([
62
+ ['completed', undefined, '100%', '100%'],
63
+ ['processing', 40, '40%', '40%'],
64
+ ['pending', undefined, 'Queued', '0%'],
65
+ ['error', undefined, 'Failed', '0%'],
66
+ ['skipped', undefined, 'Skipped', '0%'],
67
+ ] as const)('should show %s rows with their status text and bar width', async (status, progress, text, width) => {
68
+ const wrapper = mount(BatchProgressList, {
69
+ props: { items: [{ id: 'x', label: 'X', status, progress }], autoScroll: false },
70
+ })
71
+ if (status === 'completed') await wrapper.get('.mint-batch-progress__completed-toggle').trigger('click')
72
+
73
+ expect(wrapper.get('.mint-batch-progress__item-status').text()).toBe(text)
74
+ expect(wrapper.get('.mint-batch-progress__item-progress-bar').attributes('style')).toContain(`width: ${width}`)
75
+ })
60
76
  })
@@ -13,7 +13,7 @@ describe('CalendarGridPanelInternal', () => {
13
13
  it('renders calendar state and emits navigation and selection events', async () => {
14
14
  const wrapper = mount(CalendarGridPanelInternal, {
15
15
  props: {
16
- weekDays: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
16
+ weekDays: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
17
17
  monthYear: 'April 2026',
18
18
  days,
19
19
  selectedDate: days[1].date,
@@ -38,7 +38,7 @@ describe('CalendarGridPanelInternal', () => {
38
38
  const today = new Date()
39
39
  const wrapper = mount(CalendarGridPanelInternal, {
40
40
  props: {
41
- weekDays: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
41
+ weekDays: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
42
42
  monthYear: 'Today',
43
43
  days: [{ date: today, isCurrentMonth: true, isDisabled: false }],
44
44
  selectedDate: today,
@@ -21,6 +21,17 @@ describe('ChartContainer', () => {
21
21
  expect(wrapper.text()).toContain('Chart')
22
22
  expect(wrapper.get('.mint-chart__footer').text()).toBe('Footer')
23
23
  })
24
+
25
+ it.each([
26
+ [undefined, 'No data'],
27
+ ['Pick samples to draw the distribution.', 'No dataPick samples to draw the distribution.'],
28
+ ])('should render the empty title and hint %s when empty', (emptyHint, text) => {
29
+ const wrapper = mount(ChartContainer, { props: { empty: true, emptyText: 'No data', emptyHint } })
30
+
31
+ const empty = wrapper.get('.mint-chart__empty')
32
+ expect(empty.text()).toBe(text)
33
+ expect(empty.find('.mint-chart__empty-hint').exists()).toBe(emptyHint !== undefined)
34
+ })
24
35
  })
25
36
 
26
37
  describe('ChartContainer header legend (9a)', () => {
@@ -25,9 +25,12 @@ describe('ChemicalFormula', () => {
25
25
  expect(mount(ChemicalFormula, { props: { emptyText: 'Unknown' } }).text()).toBe('Unknown')
26
26
  })
27
27
 
28
- it('supports standalone sans-serif adduct labels', () => {
29
- expect(mount(AdductText, { props: { value: '[M-H]-', font: 'sans' } }).classes())
30
- .toContain('mint-adduct-text--sans')
28
+ it.each([
29
+ [undefined, 'sans'],
30
+ ['mono', 'mono'],
31
+ ] as const)('should use the %s font prop as the %s adduct font', (font, cls) => {
32
+ expect(mount(AdductText, { props: { value: '[M-H]-', font } }).classes())
33
+ .toContain(`mint-adduct-text--${cls}`)
31
34
  })
32
35
 
33
36
  it('should render a trailing adduct charge as a superscript when the value is bracketed', () => {
@@ -28,6 +28,12 @@ describe('DatePicker type="datetime"', () => {
28
28
  expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-04-22T09:00'])
29
29
  })
30
30
 
31
+ it('should show the date as ISO YYYY-MM-DD when a value is set', () => {
32
+ const wrapper = mount(DatePicker, { props: { type: 'datetime', modelValue: '2026-04-22T14:30' } })
33
+
34
+ expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-04-22 14:30')
35
+ })
36
+
31
37
  it('uses shared dropdown state for Escape close behavior', async () => {
32
38
  const wrapper = mount(DatePicker, {
33
39
  props: {
@@ -43,6 +43,27 @@ describe('DatePicker', () => {
43
43
  wrapper.unmount()
44
44
  })
45
45
 
46
+ it('should show the bound date as ISO YYYY-MM-DD when a value is set', () => {
47
+ const wrapper = mount(DatePicker, { props: { modelValue: '2026-02-10' } })
48
+
49
+ expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-02-10')
50
+ })
51
+
52
+ it('should clear the value from the field button when clearable', async () => {
53
+ const wrapper = mount(DatePicker, { props: { modelValue: '2026-02-10' } })
54
+
55
+ await wrapper.get('[aria-label="Clear date"]').trigger('click')
56
+
57
+ expect(wrapper.emitted('update:modelValue')).toEqual([[undefined]])
58
+ expect(dropdown()).toBeNull()
59
+ })
60
+
61
+ it('should hide the clear button when the field is disabled', () => {
62
+ const wrapper = mount(DatePicker, { props: { modelValue: '2026-02-10', disabled: true } })
63
+
64
+ expect(wrapper.find('[aria-label="Clear date"]').exists()).toBe(false)
65
+ })
66
+
46
67
  it('opens from the input with Enter, Space and ArrowDown', async () => {
47
68
  const wrapper = mount(DatePicker, { attachTo: document.body })
48
69
 
@@ -114,12 +114,12 @@ describe('DatePicker type="time"', () => {
114
114
  expect(wrapper.findAll('.mint-time-picker__slot')[1].classes()).toContain('mint-menu-item--selected')
115
115
  })
116
116
 
117
- it('should reserve the check column on unselected slots when opened', async () => {
117
+ it('should mark the selected slot without a check icon when opened', async () => {
118
118
  const wrapper = mount(DatePicker, { props: { type: 'time', modelValue: '00:15' } })
119
119
 
120
120
  await wrapper.get('input').trigger('click')
121
121
 
122
- expect(wrapper.findAll('.mint-time-picker__slot')[0].find('.mint-menu-item__check').exists()).toBe(true)
122
+ expect(wrapper.findAll('.mint-time-picker__slot')[1].find('.mint-menu-item__check').exists()).toBe(false)
123
123
  })
124
124
 
125
125
  it('should mark a slot unavailable when disabledSlots returns a reason', async () => {
@@ -13,7 +13,7 @@ describe('EmptyState', () => {
13
13
 
14
14
  const button = wrapper.find('.mint-button')
15
15
  expect(button.exists()).toBe(true)
16
- expect(button.classes()).toContain('mint-button--secondary')
16
+ expect(button.classes()).toEqual(expect.arrayContaining(['mint-button--secondary', 'mint-button--sm']))
17
17
  expect(button.text()).toBe('Create experiment')
18
18
 
19
19
  await button.trigger('click')
@@ -1,5 +1,5 @@
1
- import { mount } from '@vue/test-utils'
2
- import { afterEach, describe, expect, it } from 'vitest'
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
3
  import ExperimentPopover from '../../components/ExperimentPopover.vue'
4
4
 
5
5
  function mountPopover(props = {}) {
@@ -16,15 +16,25 @@ function mountPopover(props = {}) {
16
16
  })
17
17
  }
18
18
 
19
+ function findOption(wrapper: ReturnType<typeof mountPopover>, label: string) {
20
+ return wrapper.findAll('.mint-experiment-popover__option').find(option => option.text().includes(label))
21
+ }
22
+
19
23
  describe('ExperimentPopover', () => {
20
24
  afterEach(() => {
21
25
  document.body.innerHTML = ''
26
+ vi.useRealTimers()
27
+ vi.restoreAllMocks()
22
28
  })
23
29
 
24
30
  it('requests experiment selection on the first click when no experiment is selected', async () => {
25
31
  const wrapper = mountPopover()
26
32
 
27
- await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
33
+ const trigger = wrapper.find('.mint-experiment-popover__trigger')
34
+ expect(trigger.classes()).toContain('mint-experiment-popover__trigger--empty')
35
+ expect(trigger.text()).toContain('Attach experiment')
36
+
37
+ await trigger.trigger('click')
28
38
 
29
39
  expect(wrapper.emitted('select')).toHaveLength(1)
30
40
  expect(wrapper.find('.mint-experiment-popover__panel').exists()).toBe(false)
@@ -51,15 +61,18 @@ describe('ExperimentPopover', () => {
51
61
  wrapper.unmount()
52
62
  })
53
63
 
54
- it('prefers the experiment name in the trigger when both name and code are available', () => {
64
+ it('shows the code badge and the truncated name in the trigger, with both in its title', () => {
55
65
  const wrapper = mountPopover({
56
66
  experimentName: 'Dose response run',
57
67
  experimentCode: 'EXP-001',
68
+ maxNameWidth: 120,
58
69
  })
59
70
 
60
71
  const trigger = wrapper.find('.mint-experiment-popover__trigger')
61
- expect(trigger.text()).toContain('Dose response run')
62
- expect(trigger.text()).not.toContain('EXP-001')
72
+ expect(trigger.find('.mint-exp-code').text()).toBe('EXP-001')
73
+ expect(trigger.find('.mint-experiment-popover__trigger-text').text()).toBe('Dose response run')
74
+ expect(trigger.find('.mint-experiment-popover__trigger-text').attributes('style')).toContain('max-width: 120px')
75
+ expect(trigger.attributes('title')).toBe('EXP-001 · Dose response run')
63
76
 
64
77
  wrapper.unmount()
65
78
  })
@@ -92,31 +105,48 @@ describe('ExperimentPopover', () => {
92
105
  wrapper.unmount()
93
106
  })
94
107
 
95
- it('shows a placeholder instead of "No experiment" while the record resolves', () => {
108
+ it.each([
109
+ { panelRight: 900, alignedEnd: false },
110
+ { panelRight: 1100, alignedEnd: true },
111
+ ])('should align the panel to the right edge only when it would overflow the viewport (right $panelRight)', async ({ panelRight, alignedEnd }) => {
112
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1024)
113
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ right: panelRight } as DOMRect)
114
+ const wrapper = mountPopover({ experimentName: 'Dose response run', experimentCode: 'EXP-001' })
115
+
116
+ await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
117
+ await flushPromises()
118
+
119
+ expect(wrapper.get('.mint-experiment-popover__panel').classes('mint-experiment-popover__panel--end')).toBe(alignedEnd)
120
+ wrapper.unmount()
121
+ })
122
+
123
+ it('shows a placeholder instead of the empty label while the record resolves', () => {
96
124
  const wrapper = mountPopover({ isResolving: true, showDetach: true })
97
125
 
98
126
  const trigger = wrapper.find('.mint-experiment-popover__trigger')
99
127
  expect(trigger.find('.mint-experiment-popover__trigger-skeleton').exists()).toBe(true)
100
- expect(trigger.text()).not.toContain('No experiment')
128
+ expect(trigger.text()).not.toContain('Attach experiment')
101
129
  expect(trigger.attributes('aria-busy')).toBe('true')
102
130
 
103
131
  wrapper.unmount()
104
132
  })
105
133
 
106
- it('never renders the bound id as a stand-in name while resolving', () => {
107
- const wrapper = mountPopover({ isResolving: true, showDetach: true })
134
+ it('never renders the bound id as a stand-in name while resolving', async () => {
135
+ const wrapper = mountPopover({ isResolving: true, showDetach: true, experimentId: 12 })
136
+
137
+ await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
108
138
 
109
139
  expect(wrapper.text()).not.toMatch(/\d/)
110
140
 
111
141
  wrapper.unmount()
112
142
  })
113
143
 
114
- it('shows the resolution failure instead of "No experiment"', () => {
144
+ it('shows the resolution failure instead of the empty label', () => {
115
145
  const wrapper = mountPopover({ error: 'Experiment not found', showDetach: true })
116
146
 
117
147
  const trigger = wrapper.find('.mint-experiment-popover__trigger')
118
148
  expect(trigger.text()).toContain('Experiment unavailable')
119
- expect(trigger.text()).not.toContain('No experiment')
149
+ expect(trigger.text()).not.toContain('Attach experiment')
120
150
  expect(trigger.classes()).toContain('mint-experiment-popover__trigger--error')
121
151
 
122
152
  wrapper.unmount()
@@ -130,7 +160,7 @@ describe('ExperimentPopover', () => {
130
160
  const panel = wrapper.find('.mint-experiment-popover__panel')
131
161
  expect(panel.text()).toContain('Experiment not found')
132
162
 
133
- await wrapper.find('.mint-experiment-popover__detach-btn').trigger('click')
163
+ await findOption(wrapper, 'Detach')!.trigger('click')
134
164
  expect(wrapper.emitted('detach')).toHaveLength(1)
135
165
 
136
166
  wrapper.unmount()
@@ -143,7 +173,7 @@ describe('ExperimentPopover', () => {
143
173
 
144
174
  const panel = wrapper.find('.mint-experiment-popover__panel')
145
175
  expect(panel.findAll('.mint-experiment-popover__skeleton-line').length).toBeGreaterThan(0)
146
- expect(panel.find('.mint-experiment-popover__select-btn').exists()).toBe(false)
176
+ expect(panel.find('.mint-experiment-popover__meta').exists()).toBe(false)
147
177
 
148
178
  wrapper.unmount()
149
179
  })
@@ -151,7 +181,8 @@ describe('ExperimentPopover', () => {
151
181
  it('hides the inline save action while no resolved experiment exists', () => {
152
182
  const wrapper = mountPopover({ isResolving: true, showSave: true })
153
183
 
154
- expect(wrapper.find('.mint-experiment-popover__save-trigger').exists()).toBe(false)
184
+ expect(wrapper.find('.mint-experiment-popover__save').exists()).toBe(false)
185
+ expect(wrapper.find('.mint-experiment-popover--save').exists()).toBe(false)
155
186
 
156
187
  wrapper.unmount()
157
188
  })
@@ -163,11 +194,89 @@ describe('ExperimentPopover', () => {
163
194
  })
164
195
 
165
196
  await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
166
- await wrapper.find('.mint-experiment-popover__change-btn').trigger('click')
197
+ await findOption(wrapper, 'Change experiment')!.trigger('click')
167
198
 
168
199
  expect(wrapper.emitted('select')).toHaveLength(1)
169
200
  expect(wrapper.find('.mint-experiment-popover__panel').exists()).toBe(false)
170
201
 
171
202
  wrapper.unmount()
172
203
  })
204
+
205
+ it('lists project, status, owner and relative update time in the panel', async () => {
206
+ vi.useFakeTimers({ toFake: ['Date'] })
207
+ vi.setSystemTime(new Date('2026-10-28T12:00:00Z'))
208
+ const wrapper = mountPopover({
209
+ experimentName: 'Dose response run',
210
+ experimentCode: 'EXP-001',
211
+ experimentStatus: 'ongoing',
212
+ experimentProject: 'Hypoxia atlas',
213
+ experimentOwner: 'J. Doe',
214
+ experimentUpdatedAt: '2026-10-28T10:00:00Z',
215
+ })
216
+
217
+ await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
218
+
219
+ const rows = wrapper.findAll('.mint-experiment-popover__meta dt').map(dt => [dt.text(), dt.element.nextElementSibling?.textContent])
220
+ expect(rows).toEqual([
221
+ ['Project', 'Hypoxia atlas'],
222
+ ['Status', 'Ongoing'],
223
+ ['Owner', 'J. Doe'],
224
+ ['Updated', '2 hours ago'],
225
+ ])
226
+ expect(wrapper.find('.mint-experiment-popover__panel button:not(.mint-experiment-popover__option)').exists()).toBe(false)
227
+ expect(findOption(wrapper, 'Detach')).toBeUndefined()
228
+
229
+ wrapper.unmount()
230
+ })
231
+
232
+ it.each([
233
+ { experimentId: 12, href: '/experiments/12' },
234
+ { experimentId: null, href: undefined },
235
+ ])('links "Open in MINT" to the experiment in a new tab for id $experimentId', async ({ experimentId, href }) => {
236
+ const wrapper = mountPopover({ experimentName: 'Dose response run', experimentId })
237
+
238
+ await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
239
+
240
+ const link = findOption(wrapper, 'Open in MINT')
241
+ expect(link?.attributes('href')).toBe(href)
242
+ if (link) {
243
+ expect(link.attributes('target')).toBe('_blank')
244
+ expect(link.attributes('rel')).toBe('noopener')
245
+ }
246
+
247
+ wrapper.unmount()
248
+ })
249
+
250
+ it('saves at once from the joined Save button when confirmation is off', async () => {
251
+ const wrapper = mountPopover({
252
+ experimentName: 'Dose response run',
253
+ showSave: true,
254
+ confirmSave: false,
255
+ saveLabel: 'Push',
256
+ })
257
+
258
+ expect(wrapper.find('.mint-experiment-popover--save').exists()).toBe(true)
259
+ const save = wrapper.find('.mint-experiment-popover__save')
260
+ expect(save.text()).toBe('Push')
261
+
262
+ await save.trigger('click')
263
+
264
+ expect(wrapper.emitted('save')).toHaveLength(1)
265
+
266
+ wrapper.unmount()
267
+ })
268
+
269
+ it.each([
270
+ { saveLoading: false, dirty: true, label: 'Save', disabled: false, dot: true },
271
+ { saveLoading: true, dirty: true, label: 'Saving…', disabled: true, dot: false },
272
+ ])('renders Save as "$label" with the dirty dot $dot', ({ saveLoading, dirty, label, disabled, dot }) => {
273
+ const wrapper = mountPopover({ experimentName: 'Dose response run', showSave: true, saveLoading, dirty })
274
+
275
+ const save = wrapper.find('.mint-experiment-popover__save')
276
+ expect(save.text()).toBe(label)
277
+ expect(save.attributes('disabled') !== undefined).toBe(disabled)
278
+ expect(save.find('.mint-experiment-popover__dirty').exists()).toBe(dot)
279
+
280
+ wrapper.unmount()
281
+ })
173
282
  })