@morscherlab/mint-sdk 1.2.0 → 1.2.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 (152) hide show
  1. package/dist/{ExperimentSelectorModal-DCXmwKNS.js → ExperimentSelectorModal-DHE9k8TP.js} +3 -3
  2. package/dist/{ExperimentSelectorModal-DCXmwKNS.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
  3. package/dist/{SettingsModal-BYR20I4c.js → SettingsModal-DELMkUFR.js} +39 -16
  4. package/dist/SettingsModal-DELMkUFR.js.map +1 -0
  5. package/dist/{Skeleton-BAF3CKY7.js → Skeleton-jhF9wUkU.js} +17 -7
  6. package/dist/Skeleton-jhF9wUkU.js.map +1 -0
  7. package/dist/__tests__/components/ChartContainer.test.d.ts +1 -0
  8. package/dist/__tests__/components/ChemicalFormula.test.d.ts +1 -0
  9. package/dist/__tests__/components/FormField.test.d.ts +1 -0
  10. package/dist/__tests__/components/LayoutResizeHandle.test.d.ts +1 -0
  11. package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
  12. package/dist/__tests__/components/SearchableSelect.test.d.ts +1 -0
  13. package/dist/__tests__/components/WellPlate.decoration.test.d.ts +1 -0
  14. package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
  15. package/dist/__tests__/composables/useManualLayoutResize.test.d.ts +1 -0
  16. package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
  17. package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
  18. package/dist/components/AdductText.vue.d.ts +17 -0
  19. package/dist/components/AppSidebar.vue.d.ts +7 -7
  20. package/dist/components/BasePill.vue.d.ts +3 -0
  21. package/dist/components/BaseTabs.vue.d.ts +1 -4
  22. package/dist/components/ChartContainer.vue.d.ts +19 -5
  23. package/dist/components/ChemicalFormula.vue.d.ts +28 -2
  24. package/dist/components/DataFrame.vue.d.ts +1 -1
  25. package/dist/components/FileUploader.vue.d.ts +3 -0
  26. package/dist/components/FormField.vue.d.ts +8 -1
  27. package/dist/components/LayoutResizeHandle.vue.d.ts +23 -0
  28. package/dist/components/PlotlyChart.vue.d.ts +34 -6
  29. package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
  30. package/dist/components/SearchableSelect.vue.d.ts +57 -0
  31. package/dist/components/WellPlate.vue.d.ts +5 -1
  32. package/dist/components/index.d.ts +3 -1
  33. package/dist/components/index.js +6 -6
  34. package/dist/{components-DIum5hkx.js → components-CxQVQCpP.js} +1215 -546
  35. package/dist/components-CxQVQCpP.js.map +1 -0
  36. package/dist/composables/index.d.ts +1 -0
  37. package/dist/composables/index.js +8 -8
  38. package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
  39. package/dist/composables/useManualLayoutResize.d.ts +56 -0
  40. package/dist/composables/useToast.d.ts +22 -1
  41. package/dist/{composables-mFXsTA4g.js → composables-DAqOU8Aq.js} +97 -8
  42. package/dist/composables-DAqOU8Aq.js.map +1 -0
  43. package/dist/index.js +11 -11
  44. package/dist/install.js +2 -2
  45. package/dist/styles.css +855 -320
  46. package/dist/templates/index.js +2 -2
  47. package/dist/{templates-CmGfmBRA.js → templates-NuaNHohp.js} +2 -2
  48. package/dist/{templates-CmGfmBRA.js.map → templates-NuaNHohp.js.map} +1 -1
  49. package/dist/types/componentLabTypes.d.ts +3 -0
  50. package/dist/types/components.d.ts +26 -1
  51. package/dist/types/form-builder.d.ts +2 -0
  52. package/dist/types/generated-ui.d.ts +2 -0
  53. package/dist/types/index.d.ts +1 -1
  54. package/dist/{useControlSchema-DldpxNOP.js → useControlSchema-vLiRPUmj.js} +181 -71
  55. package/dist/useControlSchema-vLiRPUmj.js.map +1 -0
  56. package/dist/{useExperimentSelector-CEcIaueJ.js → useExperimentSelector-G_CAOFTX.js} +2 -2
  57. package/dist/{useExperimentSelector-CEcIaueJ.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
  58. package/dist/{useFormBuilder-z5L_dUIy.js → useFormBuilder-D37ajgS8.js} +2 -2
  59. package/dist/{useFormBuilder-z5L_dUIy.js.map → useFormBuilder-D37ajgS8.js.map} +1 -1
  60. package/dist/{validation-uwoJGEb6.js → validation-ChMyixAf.js} +21 -9
  61. package/dist/validation-ChMyixAf.js.map +1 -0
  62. package/package.json +1 -1
  63. package/src/__tests__/components/AppSidebar.test.ts +84 -94
  64. package/src/__tests__/components/AppToastContainer.test.ts +55 -1
  65. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
  66. package/src/__tests__/components/ChartContainer.test.ts +24 -0
  67. package/src/__tests__/components/ChemicalFormula.test.ts +27 -0
  68. package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
  69. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
  70. package/src/__tests__/components/FormBuilder.test.ts +26 -0
  71. package/src/__tests__/components/FormField.test.ts +26 -0
  72. package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
  73. package/src/__tests__/components/LayoutResizeHandle.test.ts +19 -0
  74. package/src/__tests__/components/NumberInput.test.ts +149 -0
  75. package/src/__tests__/components/PlotlyChart.test.ts +48 -0
  76. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
  77. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
  78. package/src/__tests__/components/RackEditor.test.ts +9 -0
  79. package/src/__tests__/components/SearchableSelect.test.ts +61 -0
  80. package/src/__tests__/components/WellPlate.decoration.test.ts +28 -0
  81. package/src/__tests__/composables/localFiles.test.ts +57 -0
  82. package/src/__tests__/composables/useManualLayoutResize.test.ts +21 -0
  83. package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
  84. package/src/__tests__/generatedUi.test.ts +111 -0
  85. package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
  86. package/src/components/AdductText.story.vue +15 -0
  87. package/src/components/AdductText.vue +40 -0
  88. package/src/components/AppLayout.story.vue +1 -1
  89. package/src/components/AppSidebar.story.vue +7 -10
  90. package/src/components/AppSidebar.vue +178 -58
  91. package/src/components/AppToastContainer.vue +65 -23
  92. package/src/components/BasePill.story.vue +2 -0
  93. package/src/components/BasePill.vue +4 -0
  94. package/src/components/BaseTabs.story.vue +0 -42
  95. package/src/components/BaseTabs.vue +3 -6
  96. package/src/components/ChartContainer.story.vue +21 -0
  97. package/src/components/ChartContainer.vue +27 -6
  98. package/src/components/ChemicalFormula.story.vue +7 -0
  99. package/src/components/ChemicalFormula.vue +40 -11
  100. package/src/components/FileUploader.vue +10 -1
  101. package/src/components/FormField.vue +10 -3
  102. package/src/components/GeneratedPathInput.vue +11 -7
  103. package/src/components/LayoutResizeHandle.story.vue +26 -0
  104. package/src/components/LayoutResizeHandle.vue +62 -0
  105. package/src/components/NumberInput.story.vue +32 -13
  106. package/src/components/NumberInput.vue +145 -56
  107. package/src/components/PlotlyChart.vue +77 -11
  108. package/src/components/PluginWorkspaceView.props.ts +3 -3
  109. package/src/components/RackEditor.vue +38 -32
  110. package/src/components/SearchableSelect.story.vue +24 -0
  111. package/src/components/SearchableSelect.vue +223 -0
  112. package/src/components/WellPlate.story.vue +16 -0
  113. package/src/components/WellPlate.vue +17 -8
  114. package/src/components/index.ts +3 -1
  115. package/src/components/internal/FormFieldRendererInternal.vue +17 -0
  116. package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
  117. package/src/composables/filePicker/localFiles.ts +21 -14
  118. package/src/composables/filePicker/usePickerSelection.ts +15 -4
  119. package/src/composables/index.ts +10 -0
  120. package/src/composables/pluginWorkspaceModel.ts +0 -3
  121. package/src/composables/useManualLayoutResize.ts +165 -0
  122. package/src/composables/usePluginWorkspace.ts +1 -2
  123. package/src/composables/useToast.ts +21 -5
  124. package/src/generated/schema.ts +58 -18
  125. package/src/styles/components/adduct-text.css +45 -0
  126. package/src/styles/components/app-sidebar.css +282 -121
  127. package/src/styles/components/chart-container.css +92 -0
  128. package/src/styles/components/chemical-formula.css +34 -4
  129. package/src/styles/components/form-builder.css +1 -1
  130. package/src/styles/components/form-field.css +28 -0
  131. package/src/styles/components/layout-resize-handle.css +60 -0
  132. package/src/styles/components/number-input.css +75 -91
  133. package/src/styles/components/pill.css +49 -51
  134. package/src/styles/components/rack-editor.css +31 -9
  135. package/src/styles/components/searchable-select.css +63 -0
  136. package/src/styles/components/segmented-control.css +16 -11
  137. package/src/styles/components/tabs.css +18 -34
  138. package/src/styles/components/toast.css +136 -27
  139. package/src/styles/components/well-plate.css +9 -0
  140. package/src/styles/index.css +3 -0
  141. package/src/styles/variables.css +25 -5
  142. package/src/types/componentLabTypes.ts +6 -0
  143. package/src/types/components.ts +30 -0
  144. package/src/types/form-builder.ts +2 -0
  145. package/src/types/generated-ui.ts +2 -0
  146. package/src/types/index.ts +5 -0
  147. package/dist/SettingsModal-BYR20I4c.js.map +0 -1
  148. package/dist/Skeleton-BAF3CKY7.js.map +0 -1
  149. package/dist/components-DIum5hkx.js.map +0 -1
  150. package/dist/composables-mFXsTA4g.js.map +0 -1
  151. package/dist/useControlSchema-DldpxNOP.js.map +0 -1
  152. package/dist/validation-uwoJGEb6.js.map +0 -1
@@ -4,6 +4,8 @@ import { describe, expect, it } from 'vitest'
4
4
  import FormBuilder from '../../components/FormBuilder.vue'
5
5
  import FormActions from '../../components/FormActions.vue'
6
6
  import Tooltip from '../../components/Tooltip.vue'
7
+ import { generatedJobFormSchema } from '../../generated/schema'
8
+ import type { GeneratedJsonSchema } from '../../types/generated-ui'
7
9
  import { defineControlModel, useControlWorkspace, type ControlSchema } from '../../composables/useControlSchema'
8
10
  import type { FormSchema } from '../../types/form-builder'
9
11
 
@@ -31,6 +33,30 @@ const controls: ControlSchema = {
31
33
  }
32
34
 
33
35
  describe('FormBuilder', () => {
36
+ it.each([
37
+ { name: 'nullable tags', items: { type: 'string' }, value: null, component: 'TagsInput', displayed: [], updated: ['1'] },
38
+ { name: 'nullable enum array', items: { type: 'string', enum: ['a', 'b'] }, value: null, component: 'MultiSelect', displayed: [], updated: ['1'] },
39
+ { name: 'numeric tags', items: { type: 'number' }, value: [1, 2], component: 'TagsInput', displayed: ['1', '2'], updated: [1] },
40
+ ])('renders $name without changing its submitted default', async ({ items, value, component, displayed, updated }) => {
41
+ const field: GeneratedJsonSchema = {
42
+ anyOf: [{ type: 'array', items }, { type: 'null' }], default: value,
43
+ }
44
+ const schema = generatedJobFormSchema({
45
+ id: 'arrays', title: 'Arrays', description: '', cpu: 1,
46
+ input_schema: { properties: { values: field } }, output_schema: {},
47
+ })
48
+ const wrapper = mount(FormBuilder, { props: { schema } })
49
+ const control = wrapper.findComponent({ name: component })
50
+ expect(control.props('modelValue')).toEqual(displayed)
51
+ wrapper.findComponent(FormActions).vm.$emit('submit')
52
+ await flushPromises()
53
+ expect(wrapper.emitted('submit')?.at(-1)?.[0]).toEqual({ values: value })
54
+
55
+ control.vm.$emit('update:modelValue', ['1'])
56
+ await nextTick()
57
+ expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual({ values: updated })
58
+ })
59
+
34
60
  it('renders compact controls without a prebuilt FormSchema', () => {
35
61
  const wrapper = mount(FormBuilder, {
36
62
  props: { controls },
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import FormField from '../../components/FormField.vue'
4
+
5
+ describe('FormField', () => {
6
+ it('should stack label above control by default', () => {
7
+ const wrapper = mount(FormField, {
8
+ props: { label: 'Threshold' },
9
+ slots: { default: '<input class="ctl" />' },
10
+ })
11
+ expect(wrapper.classes()).not.toContain('mint-form-field--row')
12
+ expect(wrapper.find('.mint-form-field__control').exists()).toBe(false)
13
+ })
14
+
15
+ it('should wrap the control in a fixed column when layout is row', () => {
16
+ const wrapper = mount(FormField, {
17
+ props: { label: 'Threshold', layout: 'row' },
18
+ slots: { default: '<input class="ctl" />' },
19
+ })
20
+ expect(wrapper.classes()).toContain('mint-form-field--row')
21
+ const children = wrapper.element.children
22
+ expect(children[0].className).toContain('mint-form-field__label-row')
23
+ expect(children[1].className).toContain('mint-form-field__control')
24
+ expect(wrapper.find('.mint-form-field__control .ctl').exists()).toBe(true)
25
+ })
26
+ })
@@ -45,6 +45,24 @@ describe('GeneratedPathInput', () => {
45
45
  })
46
46
  })
47
47
 
48
+ it('restores existing selections and keeps the visible files synchronized with the model', async () => {
49
+ const file = new File(['a'], 'a.txt')
50
+ const wrapper = mount(GeneratedPathInput, {
51
+ props: { modelValue: { kind: 'path', files: [file], relativePaths: ['a.txt'] } },
52
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
53
+ })
54
+ expect(wrapper.find('button[aria-label="Remove a.txt"]').exists()).toBe(true)
55
+ await wrapper.setProps({
56
+ modelValue: { kind: 'path', files: [file], rootName: 'dataset', relativePaths: ['dataset/a.txt'] },
57
+ })
58
+ expect(wrapper.findComponent(FileUploader).props('mode')).toBe('folder')
59
+ expect(wrapper.text()).toContain('dataset')
60
+ await wrapper.get('button[aria-label="Clear folder"]').trigger('click')
61
+ expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toBeNull()
62
+ await wrapper.setProps({ modelValue: null })
63
+ expect(wrapper.find('button[aria-label="Clear folder"]').exists()).toBe(false)
64
+ })
65
+
48
66
  it('should emit one path selection per file when multiple paths are requested', async () => {
49
67
  const wrapper = mount(GeneratedPathInput, {
50
68
  props: { multiple: true },
@@ -0,0 +1,19 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import LayoutResizeHandle from '../../components/LayoutResizeHandle.vue'
5
+
6
+ describe('LayoutResizeHandle', () => {
7
+ it('exposes splitter values and supports arrow, Home, and End keys', async () => {
8
+ const wrapper = mount(LayoutResizeHandle, {
9
+ props: { modelValue: 320, min: 240, max: 480, step: 20, label: 'Resize sidebar', orientation: 'vertical' },
10
+ })
11
+
12
+ expect(wrapper.attributes('role')).toBe('separator')
13
+ expect(wrapper.attributes('aria-valuenow')).toBe('320')
14
+ await wrapper.trigger('keydown', { key: 'ArrowRight' })
15
+ await wrapper.trigger('keydown', { key: 'Home' })
16
+ await wrapper.trigger('keydown', { key: 'End' })
17
+ expect(wrapper.emitted('update:modelValue')?.map(event => event[0])).toEqual([340, 240, 480])
18
+ })
19
+ })
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import NumberInput from '../../components/NumberInput.vue'
4
+
5
+ function lastEmitted(wrapper: ReturnType<typeof mount>) {
6
+ return wrapper.emitted('update:modelValue')?.at(-1)?.[0]
7
+ }
8
+
9
+ describe('NumberInput', () => {
10
+ it('should never render a range input, with or without min/max', () => {
11
+ for (const props of [{}, { min: 0 }, { max: 10 }, { min: 0, max: 10 }]) {
12
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, ...props } })
13
+ expect(wrapper.find('input[type="range"]').exists()).toBe(false)
14
+ }
15
+ })
16
+
17
+ it('should draw the range fill only when both min and max are set', () => {
18
+ expect(mount(NumberInput, { props: { modelValue: 5, min: 0 } }).find('.mint-number-input__rule').exists()).toBe(false)
19
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10 } })
20
+ expect(wrapper.find('.mint-number-input__rule').exists()).toBe(true)
21
+ expect(wrapper.find('.mint-number-input__scrub').attributes('style')).toContain('--mint-number-input-fill: 50%')
22
+ })
23
+
24
+ it('should expose the scrub area as a slider when min and max are set', () => {
25
+ const scrub = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10 } }).find('.mint-number-input__scrub')
26
+ expect(scrub.attributes('role')).toBe('slider')
27
+ expect(scrub.attributes('aria-valuenow')).toBe('5')
28
+ expect(scrub.attributes('tabindex')).toBe('0')
29
+ })
30
+
31
+ it('should always render both steppers', () => {
32
+ const wrapper = mount(NumberInput, { props: { modelValue: 5 } })
33
+ expect(wrapper.findAll('.mint-number-input__stepper')).toHaveLength(2)
34
+ })
35
+
36
+ it('should disable the steppers at min and max', () => {
37
+ const atMin = mount(NumberInput, { props: { modelValue: 0, min: 0, max: 10 } })
38
+ expect(atMin.find('.mint-number-input__stepper--down').attributes('disabled')).toBeDefined()
39
+ const atMax = mount(NumberInput, { props: { modelValue: 10, min: 0, max: 10 } })
40
+ expect(atMax.find('.mint-number-input__stepper--up').attributes('disabled')).toBeDefined()
41
+ })
42
+
43
+ it('should emit the step-snapped value when dragging at 25% of the scrub width', async () => {
44
+ const wrapper = mount(NumberInput, { props: { modelValue: 0, min: 0, max: 50, step: 0.5 } })
45
+ const scrub = wrapper.find('.mint-number-input__scrub')
46
+ scrub.element.getBoundingClientRect = () => ({ left: 100, width: 200 } as DOMRect)
47
+ await scrub.trigger('pointerdown', { clientX: 150, pointerId: 1 })
48
+ expect(lastEmitted(wrapper)).toBe(12.5)
49
+ })
50
+
51
+ it('should leave clicks and small pointer movements on the number available for typing', async () => {
52
+ const wrapper = mount(NumberInput, { props: { modelValue: 0, min: 0, max: 50 } })
53
+ await wrapper.find('input').trigger('pointerdown', { clientX: 150, pointerId: 1 })
54
+ await wrapper.find('input').trigger('pointermove', { clientX: 152, pointerId: 1 })
55
+ await wrapper.find('input').trigger('pointerup', { pointerId: 1 })
56
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
57
+ await wrapper.find('input').setValue('12')
58
+ expect(lastEmitted(wrapper)).toBe(12)
59
+ })
60
+
61
+ it('should drag the number relative to its starting value and stop on release', async () => {
62
+ const wrapper = mount(NumberInput, { props: { modelValue: 10, min: 0, max: 50, step: 0.5 } })
63
+ const input = wrapper.find('input')
64
+ wrapper.find('.mint-number-input__scrub').element.getBoundingClientRect = () => ({ left: 100, width: 200 } as DOMRect)
65
+ await input.trigger('pointerdown', { clientX: 120, pointerId: 1 })
66
+ await input.trigger('pointermove', { clientX: 170, pointerId: 1 })
67
+ expect(lastEmitted(wrapper)).toBe(22.5)
68
+ await input.trigger('pointermove', { clientX: 500, pointerId: 1 })
69
+ expect(lastEmitted(wrapper)).toBe(50)
70
+ await input.trigger('pointerup', { pointerId: 1 })
71
+ const count = wrapper.emitted('update:modelValue')?.length
72
+ await input.trigger('pointermove', { clientX: 100, pointerId: 1 })
73
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(count!)
74
+ })
75
+
76
+ it('should drag numbers without a full range in step increments', async () => {
77
+ const wrapper = mount(NumberInput, { props: { modelValue: 2.5, min: 0, step: 1 } })
78
+ const input = wrapper.find('input')
79
+ await input.trigger('pointerdown', { clientX: 100, pointerId: 1 })
80
+ await input.trigger('pointermove', { clientX: 116, pointerId: 1 })
81
+ expect(lastEmitted(wrapper)).toBe(4.5)
82
+ await input.trigger('pointermove', { clientX: 60, pointerId: 1 })
83
+ expect(lastEmitted(wrapper)).toBe(0)
84
+ await input.trigger('pointercancel', { pointerId: 1 })
85
+ const count = wrapper.emitted('update:modelValue')?.length
86
+ await input.trigger('pointermove', { clientX: 120, pointerId: 1 })
87
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(count!)
88
+ })
89
+
90
+ it('should ignore disabled inputs and non-primary buttons when dragging', async () => {
91
+ for (const props of [{ disabled: true }, { disabled: false }]) {
92
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10, ...props } })
93
+ const input = wrapper.find('input')
94
+ await input.trigger('pointerdown', { clientX: 100, pointerId: 1, button: props.disabled ? 0 : 2 })
95
+ await input.trigger('pointermove', { clientX: 150, pointerId: 1 })
96
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
97
+ wrapper.unmount()
98
+ }
99
+ })
100
+
101
+ it('should step with the arrow keys', async () => {
102
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, step: 2 } })
103
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
104
+ expect(lastEmitted(wrapper)).toBe(7)
105
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' })
106
+ expect(lastEmitted(wrapper)).toBe(3)
107
+ })
108
+
109
+ it('should step by ten steps with PageUp and PageDown', async () => {
110
+ const wrapper = mount(NumberInput, { props: { modelValue: 50, step: 1, min: 0, max: 100 } })
111
+ await wrapper.find('input').trigger('keydown', { key: 'PageUp' })
112
+ expect(lastEmitted(wrapper)).toBe(60)
113
+ await wrapper.find('input').trigger('keydown', { key: 'PageDown' })
114
+ expect(lastEmitted(wrapper)).toBe(40)
115
+ })
116
+
117
+ it('should jump to min and max with Home and End on the slider surface', async () => {
118
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10 } })
119
+ const scrub = wrapper.find('.mint-number-input__scrub')
120
+ await scrub.trigger('keydown', { key: 'Home' })
121
+ expect(lastEmitted(wrapper)).toBe(0)
122
+ await scrub.trigger('keydown', { key: 'End' })
123
+ expect(lastEmitted(wrapper)).toBe(10)
124
+ })
125
+
126
+ it('should leave Home and End to the caret inside the text input', async () => {
127
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10 } })
128
+ await wrapper.find('input').trigger('keydown', { key: 'Home' })
129
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
130
+ })
131
+
132
+ it('should emit once per arrow press in slider mode', async () => {
133
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10 } })
134
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
135
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
136
+ })
137
+
138
+ it('should keep a fractional value precision when stepping by an integer', async () => {
139
+ const wrapper = mount(NumberInput, { props: { modelValue: 2.5, step: 1 } })
140
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
141
+ expect(lastEmitted(wrapper)).toBe(3.5)
142
+ })
143
+
144
+ it('should trim float noise when stepping by a decimal', async () => {
145
+ const wrapper = mount(NumberInput, { props: { modelValue: 0.1, step: 0.2 } })
146
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
147
+ expect(lastEmitted(wrapper)).toBe(0.3)
148
+ })
149
+ })
@@ -302,4 +302,52 @@ describe('PlotlyChart', () => {
302
302
 
303
303
  expect(plotly.purge).toHaveBeenCalledTimes(1)
304
304
  })
305
+
306
+ it('should use an injected Plotly build and forward point clicks', async () => {
307
+ let clickHandler: ((event: unknown) => void) | undefined
308
+ const custom = {
309
+ Plots: { resize: vi.fn() },
310
+ purge: vi.fn(),
311
+ react: vi.fn(async (element: HTMLElement) => {
312
+ Object.assign(element, {
313
+ on: (_name: string, handler: (event: unknown) => void) => { clickHandler = handler },
314
+ removeAllListeners: vi.fn(),
315
+ })
316
+ }),
317
+ }
318
+ const wrapper = mount(PlotlyChart, {
319
+ props: {
320
+ data: [{ x: [1] }],
321
+ plotly: custom as never,
322
+ clickEvents: true,
323
+ height: 320,
324
+ variant: 'frame',
325
+ },
326
+ slots: { header: 'Chart header', footer: 'Time (min)' },
327
+ })
328
+ await flushPromises()
329
+
330
+ expect(custom.react).toHaveBeenCalledOnce()
331
+ expect(plotly.react).not.toHaveBeenCalled()
332
+ expect(wrapper.get('.mint-plotly-chart__plot').attributes('style')).toContain('height: 320px')
333
+ expect(wrapper.get('.mint-chart').classes()).toContain('mint-chart--frame')
334
+ expect(wrapper.text()).toContain('Chart header')
335
+ expect(wrapper.text()).toContain('Time (min)')
336
+
337
+ const event = { points: [] }
338
+ clickHandler?.(event)
339
+ expect(wrapper.emitted('plotly-click')?.[0]?.[0]).toBe(event)
340
+ })
341
+
342
+ it('should wait to render until an inactive chart becomes active', async () => {
343
+ const wrapper = mount(PlotlyChart, {
344
+ props: { data: [{ x: [1] }], active: false },
345
+ })
346
+ await flushPromises()
347
+ expect(plotly.react).not.toHaveBeenCalled()
348
+
349
+ await wrapper.setProps({ active: true })
350
+ await flushPromises()
351
+ expect(plotly.react).toHaveBeenCalledOnce()
352
+ })
305
353
  })
@@ -114,9 +114,8 @@ describe('PluginWorkspaceView internal split', () => {
114
114
  activeView: 'data',
115
115
  title: 'Tools',
116
116
  subtitle: 'Current file',
117
- variant: 'analysis',
118
117
  floating: false,
119
- dense: true,
118
+ density: 'compact',
120
119
  })
121
120
  expect(wrapper.get('[data-test="topbar-action"]').text()).toBe('Run')
122
121
  expect(wrapper.get('[data-test="import-action"]').text()).toBe('Upload')
@@ -182,9 +182,8 @@ describe('PluginWorkspaceView', () => {
182
182
  activeView: 'data',
183
183
  title: 'Tools',
184
184
  subtitle: 'Current file',
185
- variant: 'analysis',
186
185
  floating: false,
187
- dense: true,
186
+ density: 'compact',
188
187
  })
189
188
  expect(wrapper.get('[data-test="topbar-action"]').text()).toBe('Run')
190
189
  expect(wrapper.get('[data-test="import-action"]').text()).toBe('Upload')
@@ -40,6 +40,15 @@ function jsonDataTransfer(data: unknown): DataTransfer {
40
40
  }
41
41
 
42
42
  describe('RackEditor', () => {
43
+ it('keeps rack selection and removal as sibling buttons', () => {
44
+ const racks = makeRacks()
45
+ racks.push({ ...racks[0], id: 'rack-2', name: 'Rack 2', wells: {} })
46
+ const wrapper = mount(RackEditor, { props: { modelValue: racks } })
47
+
48
+ expect(wrapper.find('.mint-rack-editor__tab button').exists()).toBe(false)
49
+ expect(wrapper.get('.mint-rack-editor__tab-item').findAll('button')).toHaveLength(2)
50
+ })
51
+
43
52
  it('passes rack-aware well editor slot props through to WellPlate', async () => {
44
53
  const wrapper = mount(RackEditor, {
45
54
  props: {
@@ -0,0 +1,61 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import SearchableSelect from '../../components/SearchableSelect.vue'
5
+
6
+ const options = [
7
+ { value: 'alpha', label: 'Alpha', description: 'First option' },
8
+ { value: 'beta', label: 'Beta', disabled: true, disabledReason: 'Unavailable' },
9
+ { value: 'gamma', label: 'Gamma', meta: 'count' },
10
+ ]
11
+
12
+ describe('SearchableSelect', () => {
13
+ it('filters rich options and explains disabled choices', async () => {
14
+ const wrapper = mount(SearchableSelect, { props: { options } })
15
+ await wrapper.get('.mint-searchable-select__trigger').trigger('click')
16
+ await wrapper.get('input').setValue('bet')
17
+
18
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
19
+ expect(wrapper.get('[role="option"]').text()).toContain('Unavailable')
20
+ expect(wrapper.get('[role="option"]').attributes('aria-disabled')).toBe('true')
21
+ })
22
+
23
+ it('skips disabled options during keyboard selection', async () => {
24
+ const wrapper = mount(SearchableSelect, { props: { options } })
25
+ await wrapper.get('.mint-searchable-select__trigger').trigger('click')
26
+ await flushPromises()
27
+ await wrapper.get('input').trigger('keydown', { key: 'ArrowDown' })
28
+ await wrapper.get('input').trigger('keydown', { key: 'Enter' })
29
+
30
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['gamma'])
31
+ expect(wrapper.get('.mint-searchable-select__trigger').text()).toContain('Select an option')
32
+ })
33
+
34
+ it('keeps footer actions reachable until focus leaves the select', async () => {
35
+ const wrapper = mount(SearchableSelect, {
36
+ attachTo: document.body,
37
+ props: { options },
38
+ slots: { footer: '<button type="button">Add option</button>' },
39
+ })
40
+ const outside = document.createElement('button')
41
+ document.body.append(outside)
42
+ try {
43
+ await wrapper.get('.mint-searchable-select__trigger').trigger('click')
44
+ await wrapper.get('input').trigger('keydown', { key: 'Tab' })
45
+ expect(wrapper.find('.mint-searchable-select__footer').exists()).toBe(true)
46
+
47
+ const footer = wrapper.get<HTMLButtonElement>('.mint-searchable-select__footer button')
48
+ footer.element.focus()
49
+ await flushPromises()
50
+ expect(document.activeElement).toBe(footer.element)
51
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
52
+
53
+ outside.focus()
54
+ await flushPromises()
55
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
56
+ } finally {
57
+ wrapper.unmount()
58
+ outside.remove()
59
+ }
60
+ })
61
+ })
@@ -0,0 +1,28 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import WellPlate from '../../components/WellPlate.vue'
5
+
6
+ describe('WellPlate per-well decoration', () => {
7
+ it('applies custom classes and lets custom styles override heatmap borders', () => {
8
+ const wrapper = mount(WellPlate, {
9
+ props: {
10
+ format: 6,
11
+ wells: {
12
+ A1: { id: 'A1', row: 0, col: 0, state: 'filled', value: 0.5 },
13
+ },
14
+ heatmap: { enabled: true, min: 0, max: 1 },
15
+ wellClass: well => well.id === 'A1' ? 'mint-well-plate__well--outlier' : undefined,
16
+ wellStyle: well => well.id === 'A1'
17
+ ? { borderWidth: '2px', borderStyle: 'solid', borderColor: 'var(--mint-warning)' }
18
+ : undefined,
19
+ },
20
+ })
21
+
22
+ const well = wrapper.findAll('.mint-well-plate__well')[0]
23
+ expect(well.classes()).toContain('mint-well-plate__well--outlier')
24
+ const style = (well.element as HTMLElement).style
25
+ expect([style.borderWidth, style.borderStyle, style.borderColor])
26
+ .toEqual(['2px', 'solid', 'var(--mint-warning)'])
27
+ })
28
+ })
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { localFilesAdapter } from '../../composables/filePicker/localFiles'
3
+
4
+ describe('localFilesAdapter', () => {
5
+ it('preserves order, duplicate files, nested statistics and scoped search', async () => {
6
+ const files = [
7
+ new File(['abc'], 'run.raw'),
8
+ new File(['12'], 'run.raw'),
9
+ new File(['1234'], 'sample.mzML.gz'),
10
+ new File(['x'], 'other.raw'),
11
+ ]
12
+ const adapter = localFilesAdapter(files, [
13
+ 'study/batch/run.raw', 'study/batch/run.raw', 'study/sample.mzML.gz', 'study2/other.raw',
14
+ ])
15
+ expect(await adapter.listRoot()).toMatchObject([
16
+ { path: 'study', fileCount: 3, bytes: 9, formats: { raw: 2, 'mzML.gz': 1 } },
17
+ { path: 'study2', fileCount: 1, bytes: 1, formats: { raw: 1 } },
18
+ ])
19
+ expect(await adapter.listChildren('study')).toMatchObject([
20
+ { path: 'study/batch', fileCount: 2, bytes: 5, formats: { raw: 2 } },
21
+ { path: 'study/sample.mzML.gz', file: files[2] },
22
+ ])
23
+ expect(await adapter.listChildren('study/batch')).toMatchObject([
24
+ { path: 'study/batch/run.raw', file: files[0] },
25
+ { path: 'study/batch/run.raw (2)', file: files[1] },
26
+ ])
27
+ expect(await adapter.search('RUN', { path: 'study', recursive: true })).toHaveLength(2)
28
+ expect(await adapter.search('RUN', { path: 'study', recursive: false })).toEqual([])
29
+ expect(await adapter.listChildren('missing')).toEqual([])
30
+ const roots = await adapter.listRoot()
31
+ roots.pop()
32
+ expect(await adapter.listRoot()).toHaveLength(2)
33
+ })
34
+
35
+ it('builds and lists many sibling folders without repeatedly scanning all nodes', async () => {
36
+ const files = Array.from({ length: 200 }, (_, i) => new File(['x'], `${i}.raw`))
37
+ const paths = files.map((file, i) => `folder-${i}/${file.name}`)
38
+ const originalValues = Map.prototype.values
39
+ let visited = 0
40
+ const values = vi.spyOn(Map.prototype, 'values').mockImplementation(function* (this: Map<unknown, unknown>) {
41
+ for (const value of originalValues.call(this)) {
42
+ visited++
43
+ yield value
44
+ }
45
+ } as typeof Map.prototype.values)
46
+ try {
47
+ const adapter = localFilesAdapter(files, paths)
48
+ await adapter.listRoot()
49
+ for (let i = 0; i < files.length; i++) {
50
+ expect(await adapter.listChildren(`folder-${i}`)).toHaveLength(1)
51
+ }
52
+ expect(visited).toBeLessThanOrEqual(files.length * 8)
53
+ } finally {
54
+ values.mockRestore()
55
+ }
56
+ })
57
+ })
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ resizedLeadingPanelWidth,
4
+ resizedTrailingPanelWidth,
5
+ resizedVerticalSplitPercent,
6
+ } from '../../composables/useManualLayoutResize'
7
+
8
+ describe('manual layout resize helpers', () => {
9
+ const limits = { minWidth: 260, maxWidth: 480, mainMinWidth: 560, layoutGap: 16 }
10
+
11
+ it('clamps leading, trailing, and vertical splits', () => {
12
+ expect(resizedLeadingPanelWidth(320, 400, 900, limits)).toBe(324)
13
+ expect(resizedTrailingPanelWidth(320, -400, 1400, limits)).toBe(480)
14
+ expect(resizedVerticalSplitPercent(60, 400, 800, { minPercent: 35, maxPercent: 75 })).toBe(75)
15
+ })
16
+
17
+ it('preserves the panel minimum when the container cannot fit both panes', () => {
18
+ expect(resizedLeadingPanelWidth(320, 0, 500, limits)).toBe(260)
19
+ expect(resizedTrailingPanelWidth(320, 0, 800, limits)).toBe(260)
20
+ })
21
+ })
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { effectScope, ref, type EffectScope } from 'vue'
3
+ import { usePickerNavigation } from '../../composables/filePicker/usePickerNavigation'
4
+ import { usePickerSelection } from '../../composables/filePicker/usePickerSelection'
5
+ import type { PickerCapability, PickerNode, PickerSelectionMode } from '../../types/filePicker'
6
+
7
+ const scopes: EffectScope[] = []
8
+ afterEach(() => scopes.splice(0).forEach(scope => scope.stop()))
9
+
10
+ function setup(nodes: PickerNode[]) {
11
+ const scope = effectScope()
12
+ scopes.push(scope)
13
+ return scope.run(() => {
14
+ const capability = ref<PickerCapability>({ formats: ['raw'] })
15
+ const hidden = ref('')
16
+ const mode = ref<PickerSelectionMode>('folder+files')
17
+ const nav = usePickerNavigation(() => ({
18
+ listRoot: async () => [], listChildren: async () => [], search: async () => [],
19
+ }), () => ({}), node => node.path !== hidden.value)
20
+ nav.register(nodes)
21
+ const selection = usePickerSelection(nav, () => mode.value, () => capability.value, () => false)
22
+ const progress = (path: string) => selection.folderProgress(nav.nodes.value.get(path)!)
23
+ return { nav, selection, capability, hidden, mode, progress }
24
+ })!
25
+ }
26
+
27
+ describe('file picker folder progress', () => {
28
+ it('tracks exclusions, selection modes, replaced paths and changing ancestors', () => {
29
+ const { nav, selection, capability, hidden, mode, progress } = setup([
30
+ { kind: 'folder', path: 'root', name: 'root', fileCount: 2 },
31
+ { kind: 'folder', path: 'a', name: 'a', parentPath: 'root', fileCount: 2 },
32
+ { kind: 'folder', path: 'b', name: 'b', parentPath: 'root', fileCount: 0 },
33
+ { kind: 'file', path: 'f', name: 'f.raw', format: 'raw', parentPath: 'a' },
34
+ { kind: 'file', path: 'g', name: 'g.txt', format: 'txt', parentPath: 'a' },
35
+ ])
36
+ selection.keys.value.add('root')
37
+ expect(progress('root')).toBe('1 / 2')
38
+ expect(progress('a')).toBe('1 / 2')
39
+ expect(progress('b')).toBe('0 / 0')
40
+ selection.dropped.value.add('a')
41
+ expect(progress('root')).toBe('0 / 2')
42
+ expect(progress('a')).toBe('0 / 2')
43
+ selection.dropped.value.clear()
44
+ capability.value.formats.push('txt')
45
+ expect(progress('a')).toBe('2 / 2')
46
+ nav.register([{ kind: 'file', path: 'f', name: 'f.raw', format: 'raw', parentPath: 'b' }])
47
+ expect(progress('a')).toBe('1 / 2')
48
+ expect(progress('b')).toBe('1 / 0')
49
+ expect(progress('root')).toBe('2 / 2')
50
+ nav.nodes.value.get('b')!.parentPath = null
51
+ expect(progress('root')).toBe('1 / 2')
52
+ selection.keys.value.add('b')
53
+ expect(progress('b')).toBe('1 / 0')
54
+ hidden.value = 'b'
55
+ expect(progress('b')).toBe('0 / 0')
56
+ mode.value = 'folder'
57
+ expect(progress('a')).toBe('0 / 2')
58
+ expect(progress('root')).toBe('1 / 2')
59
+ nav.nodes.value.delete('g')
60
+ expect(progress('root')).toBe('0 / 2')
61
+ expect(selection.folderProgress({ kind: 'folder', path: 'unknown', name: 'unknown' })).toBe('')
62
+ expect(selection.folderProgress(nav.nodes.value.get('f')!)).toBe('')
63
+ })
64
+
65
+ it('counts each file once for cyclic or missing ancestors', () => {
66
+ const { nav, selection, progress } = setup([
67
+ { kind: 'folder', path: 'a', name: 'a', parentPath: 'b', fileCount: 1 },
68
+ { kind: 'folder', path: 'b', name: 'b', parentPath: 'a', fileCount: 1 },
69
+ { kind: 'file', path: 'f', name: 'f.raw', format: 'raw', parentPath: 'a' },
70
+ ])
71
+ selection.keys.value.add('a')
72
+ expect(progress('a')).toBe('1 / 1')
73
+ expect(progress('b')).toBe('1 / 1')
74
+ nav.nodes.value.delete('a')
75
+ expect(selection.folderProgress({ kind: 'folder', path: 'a', name: 'a', fileCount: 1 })).toBe('1 / 1')
76
+ expect(progress('b')).toBe('0 / 1')
77
+ })
78
+
79
+ it('does not rescan every selected file for each folder row', () => {
80
+ const count = 200
81
+ const { nav, selection, progress } = setup([
82
+ { kind: 'folder', path: 'root', name: 'root', fileCount: count },
83
+ ])
84
+ let parentReads = 0
85
+ for (let i = 0; i < count; i++) {
86
+ nav.register([{ kind: 'folder', path: `d${i}`, name: `d${i}`, parentPath: 'root', fileCount: 1 }])
87
+ nav.nodes.value.set(`f${i}`, {
88
+ kind: 'file', path: `f${i}`, name: `${i}.raw`, format: 'raw',
89
+ get parentPath() { parentReads++; return `d${i}` },
90
+ })
91
+ }
92
+ selection.keys.value.add('root')
93
+ expect(selection.chosen.value.files).toHaveLength(count)
94
+ parentReads = 0
95
+ for (let i = 0; i < count; i++) expect(progress(`d${i}`)).toBe('1 / 1')
96
+ expect(parentReads).toBeLessThanOrEqual(count * 2)
97
+ })
98
+ })