@morscherlab/mint-sdk 1.2.0 → 1.2.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 (99) 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-DOcCf0Vo.js} +39 -16
  4. package/dist/SettingsModal-DOcCf0Vo.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/FormField.test.d.ts +1 -0
  8. package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
  9. package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
  10. package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
  11. package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
  12. package/dist/components/AppSidebar.vue.d.ts +7 -7
  13. package/dist/components/BasePill.vue.d.ts +3 -0
  14. package/dist/components/BaseTabs.vue.d.ts +1 -4
  15. package/dist/components/FileUploader.vue.d.ts +3 -0
  16. package/dist/components/FormField.vue.d.ts +8 -1
  17. package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
  18. package/dist/components/index.js +5 -5
  19. package/dist/{components-DIum5hkx.js → components-Cp8DcNrU.js} +472 -259
  20. package/dist/components-Cp8DcNrU.js.map +1 -0
  21. package/dist/composables/index.js +7 -7
  22. package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
  23. package/dist/composables/useToast.d.ts +22 -1
  24. package/dist/{composables-mFXsTA4g.js → composables-D_4qqOlp.js} +6 -6
  25. package/dist/{composables-mFXsTA4g.js.map → composables-D_4qqOlp.js.map} +1 -1
  26. package/dist/index.js +10 -10
  27. package/dist/install.js +2 -2
  28. package/dist/styles.css +560 -303
  29. package/dist/templates/index.js +2 -2
  30. package/dist/{templates-CmGfmBRA.js → templates-Dd9bBSs6.js} +2 -2
  31. package/dist/{templates-CmGfmBRA.js.map → templates-Dd9bBSs6.js.map} +1 -1
  32. package/dist/types/components.d.ts +21 -0
  33. package/dist/types/form-builder.d.ts +2 -0
  34. package/dist/types/generated-ui.d.ts +2 -0
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/{useControlSchema-DldpxNOP.js → useControlSchema-BB2GRPhW.js} +151 -71
  37. package/dist/useControlSchema-BB2GRPhW.js.map +1 -0
  38. package/dist/{useExperimentSelector-CEcIaueJ.js → useExperimentSelector-G_CAOFTX.js} +2 -2
  39. package/dist/{useExperimentSelector-CEcIaueJ.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
  40. package/dist/{useFormBuilder-z5L_dUIy.js → useFormBuilder-BnGSdSRG.js} +2 -2
  41. package/dist/{useFormBuilder-z5L_dUIy.js.map → useFormBuilder-BnGSdSRG.js.map} +1 -1
  42. package/dist/{validation-uwoJGEb6.js → validation-CTxuzLIX.js} +21 -9
  43. package/dist/validation-CTxuzLIX.js.map +1 -0
  44. package/package.json +1 -1
  45. package/src/__tests__/components/AppSidebar.test.ts +84 -94
  46. package/src/__tests__/components/AppToastContainer.test.ts +55 -1
  47. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
  48. package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
  49. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
  50. package/src/__tests__/components/FormBuilder.test.ts +26 -0
  51. package/src/__tests__/components/FormField.test.ts +26 -0
  52. package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
  53. package/src/__tests__/components/NumberInput.test.ts +105 -0
  54. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
  55. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
  56. package/src/__tests__/composables/localFiles.test.ts +57 -0
  57. package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
  58. package/src/__tests__/generatedUi.test.ts +111 -0
  59. package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
  60. package/src/components/AppLayout.story.vue +1 -1
  61. package/src/components/AppSidebar.story.vue +7 -10
  62. package/src/components/AppSidebar.vue +178 -58
  63. package/src/components/AppToastContainer.vue +65 -23
  64. package/src/components/BasePill.story.vue +2 -0
  65. package/src/components/BasePill.vue +4 -0
  66. package/src/components/BaseTabs.story.vue +0 -42
  67. package/src/components/BaseTabs.vue +3 -6
  68. package/src/components/FileUploader.vue +10 -1
  69. package/src/components/FormField.vue +10 -3
  70. package/src/components/GeneratedPathInput.vue +11 -7
  71. package/src/components/NumberInput.story.vue +32 -13
  72. package/src/components/NumberInput.vue +116 -55
  73. package/src/components/PluginWorkspaceView.props.ts +3 -3
  74. package/src/components/internal/FormFieldRendererInternal.vue +17 -0
  75. package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
  76. package/src/composables/filePicker/localFiles.ts +21 -14
  77. package/src/composables/filePicker/usePickerSelection.ts +15 -4
  78. package/src/composables/pluginWorkspaceModel.ts +0 -3
  79. package/src/composables/usePluginWorkspace.ts +1 -2
  80. package/src/composables/useToast.ts +21 -5
  81. package/src/generated/schema.ts +58 -18
  82. package/src/styles/components/app-sidebar.css +282 -121
  83. package/src/styles/components/form-builder.css +1 -1
  84. package/src/styles/components/form-field.css +28 -0
  85. package/src/styles/components/number-input.css +73 -91
  86. package/src/styles/components/pill.css +49 -51
  87. package/src/styles/components/segmented-control.css +16 -11
  88. package/src/styles/components/tabs.css +18 -34
  89. package/src/styles/components/toast.css +136 -27
  90. package/src/styles/variables.css +25 -5
  91. package/src/types/components.ts +23 -0
  92. package/src/types/form-builder.ts +2 -0
  93. package/src/types/generated-ui.ts +2 -0
  94. package/src/types/index.ts +2 -0
  95. package/dist/SettingsModal-BYR20I4c.js.map +0 -1
  96. package/dist/Skeleton-BAF3CKY7.js.map +0 -1
  97. package/dist/components-DIum5hkx.js.map +0 -1
  98. package/dist/useControlSchema-DldpxNOP.js.map +0 -1
  99. package/dist/validation-uwoJGEb6.js.map +0 -1
@@ -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,105 @@
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 not scrub when the pointer goes down on the input itself', 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
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
55
+ })
56
+
57
+ it('should step with the arrow keys', async () => {
58
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, step: 2 } })
59
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
60
+ expect(lastEmitted(wrapper)).toBe(7)
61
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' })
62
+ expect(lastEmitted(wrapper)).toBe(3)
63
+ })
64
+
65
+ it('should step by ten steps with PageUp and PageDown', async () => {
66
+ const wrapper = mount(NumberInput, { props: { modelValue: 50, step: 1, min: 0, max: 100 } })
67
+ await wrapper.find('input').trigger('keydown', { key: 'PageUp' })
68
+ expect(lastEmitted(wrapper)).toBe(60)
69
+ await wrapper.find('input').trigger('keydown', { key: 'PageDown' })
70
+ expect(lastEmitted(wrapper)).toBe(40)
71
+ })
72
+
73
+ it('should jump to min and max with Home and End on the slider surface', async () => {
74
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10 } })
75
+ const scrub = wrapper.find('.mint-number-input__scrub')
76
+ await scrub.trigger('keydown', { key: 'Home' })
77
+ expect(lastEmitted(wrapper)).toBe(0)
78
+ await scrub.trigger('keydown', { key: 'End' })
79
+ expect(lastEmitted(wrapper)).toBe(10)
80
+ })
81
+
82
+ it('should leave Home and End to the caret inside the text input', async () => {
83
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10 } })
84
+ await wrapper.find('input').trigger('keydown', { key: 'Home' })
85
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
86
+ })
87
+
88
+ it('should emit once per arrow press in slider mode', async () => {
89
+ const wrapper = mount(NumberInput, { props: { modelValue: 5, min: 0, max: 10 } })
90
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
91
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
92
+ })
93
+
94
+ it('should keep a fractional value precision when stepping by an integer', async () => {
95
+ const wrapper = mount(NumberInput, { props: { modelValue: 2.5, step: 1 } })
96
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
97
+ expect(lastEmitted(wrapper)).toBe(3.5)
98
+ })
99
+
100
+ it('should trim float noise when stepping by a decimal', async () => {
101
+ const wrapper = mount(NumberInput, { props: { modelValue: 0.1, step: 0.2 } })
102
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
103
+ expect(lastEmitted(wrapper)).toBe(0.3)
104
+ })
105
+ })
@@ -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')
@@ -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,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
+ })
@@ -6,6 +6,8 @@ import {
6
6
  normalizeGeneratedJobInput,
7
7
  } from '../generated/schema'
8
8
  import type { GeneratedJobDefinition } from '../types/generated-ui'
9
+ import { validateFieldValue } from '../composables/useFormValidation'
10
+ import { buildRules } from '../composables/formBuilderSchema'
9
11
 
10
12
  const definition: GeneratedJobDefinition = {
11
13
  id: 'summarize',
@@ -30,6 +32,115 @@ const definition: GeneratedJobDefinition = {
30
32
  }
31
33
 
32
34
  describe('generated UI schema adapter', () => {
35
+ it('allows present empty strings, arrays, nullable values and false', () => {
36
+ const requiredDefinition: GeneratedJobDefinition = {
37
+ ...definition,
38
+ input_schema: {
39
+ properties: {
40
+ nullable: { anyOf: [{ type: 'string' }, { type: 'null' }] },
41
+ text: { type: 'string' },
42
+ labels: { type: 'array', items: { type: 'string' } },
43
+ enabled: { type: 'boolean' },
44
+ nonempty: { type: 'string', minLength: 1 },
45
+ },
46
+ required: ['nullable', 'text', 'labels', 'enabled', 'nonempty'],
47
+ },
48
+ }
49
+ const fields = generatedJobFormSchema(requiredDefinition).sections[0]!.fields
50
+ const rules = buildRules(fields)
51
+ const values = { nullable: null, text: '', labels: [], enabled: false, nonempty: 'valid' }
52
+ expect(fields.map(field => validateFieldValue(values[field.name as keyof typeof values], rules[field.name], values)))
53
+ .toEqual([null, null, null, null, null])
54
+ expect(generatedJobDefaults(requiredDefinition)).toEqual({ nullable: null })
55
+ expect(validateFieldValue('', rules.nonempty, {})).not.toBeNull()
56
+ expect(normalizeGeneratedJobInput(requiredDefinition, values)).toEqual(values)
57
+ })
58
+
59
+ it('supplies fixed literal values and resolves array enums and numeric items', () => {
60
+ const arrayDefinition: GeneratedJobDefinition = {
61
+ ...definition,
62
+ input_schema: {
63
+ $defs: { Choice: { type: 'integer', enum: [1, 2] } },
64
+ properties: {
65
+ kind: { type: 'string', const: 'fixed' },
66
+ choices: { type: 'array', items: { $ref: '#/$defs/Choice' } },
67
+ counts: { type: 'array', items: { type: 'integer' } },
68
+ scales: { type: 'array', items: { type: 'number' } },
69
+ labels: { type: 'array', items: { type: 'string' } },
70
+ flags: { type: 'array', items: { type: 'boolean' } },
71
+ },
72
+ },
73
+ }
74
+ expect(generatedJobDefaults(arrayDefinition)).toEqual({ kind: 'fixed' })
75
+ expect(generatedJobFormSchema(arrayDefinition).sections[0]!.fields).toMatchObject([
76
+ { name: 'kind', defaultValue: 'fixed', disabled: true },
77
+ { name: 'choices', type: 'multiselect', props: { options: [1, 2] } },
78
+ { name: 'counts', type: 'tags' },
79
+ { name: 'scales', type: 'tags' },
80
+ { name: 'labels', type: 'tags', props: { allowDuplicates: true } },
81
+ { name: 'flags', type: 'tags' },
82
+ ])
83
+ expect(normalizeGeneratedJobInput(arrayDefinition, {
84
+ counts: ['1', '2'], scales: ['0.5', '1.5'], choices: [1, 2], labels: ['1', '1'],
85
+ })).toEqual({ counts: [1, 2], scales: [0.5, 1.5], choices: [1, 2], labels: ['1', '1'] })
86
+ expect(normalizeGeneratedJobInput(arrayDefinition, { counts: ['bad', '1.5'] }))
87
+ .toEqual({ counts: ['bad', 1.5] })
88
+ expect(normalizeGeneratedJobInput(arrayDefinition, { flags: ['true', 'false', true, 'bad'] }))
89
+ .toEqual({ flags: [true, false, true, 'bad'] })
90
+ })
91
+
92
+ it('applies integer bounds and numeric steps to the existing controls', () => {
93
+ const boundedDefinition: GeneratedJobDefinition = {
94
+ ...definition,
95
+ input_schema: { properties: {
96
+ count: { type: 'integer', exclusiveMinimum: 0, exclusiveMaximum: 10, multipleOf: 2 },
97
+ fraction: { type: 'number', minimum: 0, maximum: 1, multipleOf: 0.1 },
98
+ } },
99
+ }
100
+ const fields = generatedJobFormSchema(boundedDefinition).sections[0]!.fields
101
+ const rules = buildRules(fields)
102
+ expect(fields).toMatchObject([
103
+ { validation: { min: 2, max: 8 }, props: { min: 2, max: 8, step: 2 } },
104
+ { validation: { min: 0, max: 1 }, props: { min: 0, max: 1, step: 0.1 } },
105
+ ])
106
+ expect(validateFieldValue(0, rules.count, {})).not.toBeNull()
107
+ expect(validateFieldValue(10, rules.count, {})).not.toBeNull()
108
+ })
109
+
110
+ it('preserves field labels and hints outside nullable schemas and enum references', () => {
111
+ const annotatedDefinition: GeneratedJobDefinition = {
112
+ ...definition,
113
+ input_schema: {
114
+ type: 'object',
115
+ $defs: { Method: { type: 'string', enum: ['fast', 'robust'], title: 'Method' } },
116
+ properties: {
117
+ count: {
118
+ anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }],
119
+ title: 'Optional sample count',
120
+ description: 'Leave empty to use all samples',
121
+ default: null,
122
+ },
123
+ method: {
124
+ $ref: '#/$defs/Method',
125
+ title: 'Analysis method',
126
+ description: 'Choose the analysis method',
127
+ },
128
+ },
129
+ },
130
+ }
131
+
132
+ expect(generatedJobFormSchema(annotatedDefinition).sections[0]?.fields).toMatchObject([
133
+ {
134
+ name: 'count', type: 'number', label: 'Optional sample count',
135
+ hint: 'Leave empty to use all samples', defaultValue: null, validation: { min: 1 },
136
+ },
137
+ {
138
+ name: 'method', type: 'select', label: 'Analysis method',
139
+ hint: 'Choose the analysis method', props: { options: ['fast', 'robust'] },
140
+ },
141
+ ])
142
+ })
143
+
33
144
  it('maps the supported JSON Schema subset to standard FormBuilder fields', () => {
34
145
  expect(generatedJobFormSchema(definition).sections[0]?.fields).toMatchObject([
35
146
  { name: 'threshold', type: 'number', validation: { required: true, min: 0, max: 10 } },
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url))
7
+ const componentsDir = resolve(here, '../../styles/components')
8
+
9
+ // Every color in these sheets must resolve from a token so dark mode remaps it.
10
+ const TOKEN_ONLY = ['pill.css', 'tabs.css', 'segmented-control.css', 'toast.css']
11
+
12
+ describe('token-only component stylesheets', () => {
13
+ it.each(TOKEN_ONLY)('%s has no rgba() literal', (file) => {
14
+ expect(readFileSync(join(componentsDir, file), 'utf-8')).not.toMatch(/rgba?\(/)
15
+ })
16
+ })
@@ -229,7 +229,7 @@ const logScale = ref(false)
229
229
  </template>
230
230
  <template #sidebar>
231
231
  <AppSidebar
232
- variant="analysis"
232
+ :floating="false"
233
233
  title="Feature Filters"
234
234
  subtitle="12 active samples"
235
235
  :badge="2"
@@ -16,7 +16,7 @@ import { useBioTemplateWorkspace } from '../composables/useBioTemplateWorkspace'
16
16
  import { createWellPlateScreenCollection } from '../templates'
17
17
  import type { SidebarToolSection } from '../types'
18
18
 
19
- type SidebarVariant = 'analysis' | 'default'
19
+ type SidebarDensity = 'compact' | 'comfortable'
20
20
 
21
21
  /* --- Icon paths (Lucide-style) --- */
22
22
  const icons = {
@@ -256,7 +256,7 @@ function handleToggle(id: string, value: boolean) {
256
256
  function initSimpleToolkit() {
257
257
  return {
258
258
  activeView: 'analysis',
259
- variant: 'analysis' as SidebarVariant,
259
+ density: 'compact' as SidebarDensity,
260
260
  floating: false,
261
261
  width: '20rem',
262
262
  side: 'left' as const,
@@ -351,7 +351,6 @@ function initSimpleToolkit() {
351
351
  <Variant title="Schema Driven Controls">
352
352
  <div style="padding: 2rem; height: 520px; position: relative; background: var(--bg-primary, #f1f5f9);">
353
353
  <AppSidebar
354
- variant="analysis"
355
354
  title="Peak Picking"
356
355
  subtitle="Current experiment"
357
356
  :controls="schemaControls"
@@ -365,7 +364,6 @@ function initSimpleToolkit() {
365
364
  <div style="padding: 2rem; height: 560px; position: relative; background: var(--bg-primary, #f1f5f9);">
366
365
  <AppSidebar
367
366
  v-model="modelDrivenValues"
368
- variant="analysis"
369
367
  title="Peak Picking"
370
368
  subtitle="Current experiment"
371
369
  :model="modelDrivenSidebarModel"
@@ -376,7 +374,6 @@ function initSimpleToolkit() {
376
374
  <Variant title="Route Owned Shell">
377
375
  <div style="padding: 2rem; height: 560px; display: flex; gap: 1rem; background: var(--bg-primary, #f1f5f9);">
378
376
  <AppSidebar
379
- variant="analysis"
380
377
  title="Sequence"
381
378
  subtitle="Acquisition run"
382
379
  content-id="seqgen-sidebar"
@@ -466,7 +463,7 @@ function initSimpleToolkit() {
466
463
  </div>
467
464
  <AppSidebar
468
465
  :panels="simplePanels"
469
- :variant="state.variant"
466
+ :density="state.density"
470
467
  :active-view="state.activeView"
471
468
  :floating="state.floating"
472
469
  :width="state.width"
@@ -531,11 +528,11 @@ function initSimpleToolkit() {
531
528
  :options="['analysis', 'results', 'settings'].map(v => ({ label: v, value: v }))"
532
529
  />
533
530
  <HstSelect
534
- v-model="state.variant"
535
- title="Variant"
531
+ v-model="state.density"
532
+ title="Density"
536
533
  :options="[
537
- { label: 'Analysis', value: 'analysis' },
538
- { label: 'Default', value: 'default' },
534
+ { label: 'Compact', value: 'compact' },
535
+ { label: 'Comfortable', value: 'comfortable' },
539
536
  ]"
540
537
  />
541
538
  <HstCheckbox v-model="state.floating" title="Floating" />