@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.
- package/dist/{ExperimentSelectorModal-DCXmwKNS.js → ExperimentSelectorModal-DHE9k8TP.js} +3 -3
- package/dist/{ExperimentSelectorModal-DCXmwKNS.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
- package/dist/{SettingsModal-BYR20I4c.js → SettingsModal-DOcCf0Vo.js} +39 -16
- package/dist/SettingsModal-DOcCf0Vo.js.map +1 -0
- package/dist/{Skeleton-BAF3CKY7.js → Skeleton-jhF9wUkU.js} +17 -7
- package/dist/Skeleton-jhF9wUkU.js.map +1 -0
- package/dist/__tests__/components/FormField.test.d.ts +1 -0
- package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
- package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
- package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
- package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
- package/dist/components/AppSidebar.vue.d.ts +7 -7
- package/dist/components/BasePill.vue.d.ts +3 -0
- package/dist/components/BaseTabs.vue.d.ts +1 -4
- package/dist/components/FileUploader.vue.d.ts +3 -0
- package/dist/components/FormField.vue.d.ts +8 -1
- package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
- package/dist/components/index.js +5 -5
- package/dist/{components-DIum5hkx.js → components-Cp8DcNrU.js} +472 -259
- package/dist/components-Cp8DcNrU.js.map +1 -0
- package/dist/composables/index.js +7 -7
- package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
- package/dist/composables/useToast.d.ts +22 -1
- package/dist/{composables-mFXsTA4g.js → composables-D_4qqOlp.js} +6 -6
- package/dist/{composables-mFXsTA4g.js.map → composables-D_4qqOlp.js.map} +1 -1
- package/dist/index.js +10 -10
- package/dist/install.js +2 -2
- package/dist/styles.css +560 -303
- package/dist/templates/index.js +2 -2
- package/dist/{templates-CmGfmBRA.js → templates-Dd9bBSs6.js} +2 -2
- package/dist/{templates-CmGfmBRA.js.map → templates-Dd9bBSs6.js.map} +1 -1
- package/dist/types/components.d.ts +21 -0
- package/dist/types/form-builder.d.ts +2 -0
- package/dist/types/generated-ui.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/{useControlSchema-DldpxNOP.js → useControlSchema-BB2GRPhW.js} +151 -71
- package/dist/useControlSchema-BB2GRPhW.js.map +1 -0
- package/dist/{useExperimentSelector-CEcIaueJ.js → useExperimentSelector-G_CAOFTX.js} +2 -2
- package/dist/{useExperimentSelector-CEcIaueJ.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
- package/dist/{useFormBuilder-z5L_dUIy.js → useFormBuilder-BnGSdSRG.js} +2 -2
- package/dist/{useFormBuilder-z5L_dUIy.js.map → useFormBuilder-BnGSdSRG.js.map} +1 -1
- package/dist/{validation-uwoJGEb6.js → validation-CTxuzLIX.js} +21 -9
- package/dist/validation-CTxuzLIX.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppSidebar.test.ts +84 -94
- package/src/__tests__/components/AppToastContainer.test.ts +55 -1
- package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
- package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
- package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
- package/src/__tests__/components/FormBuilder.test.ts +26 -0
- package/src/__tests__/components/FormField.test.ts +26 -0
- package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
- package/src/__tests__/components/NumberInput.test.ts +105 -0
- package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
- package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
- package/src/__tests__/composables/localFiles.test.ts +57 -0
- package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
- package/src/__tests__/generatedUi.test.ts +111 -0
- package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
- package/src/components/AppLayout.story.vue +1 -1
- package/src/components/AppSidebar.story.vue +7 -10
- package/src/components/AppSidebar.vue +178 -58
- package/src/components/AppToastContainer.vue +65 -23
- package/src/components/BasePill.story.vue +2 -0
- package/src/components/BasePill.vue +4 -0
- package/src/components/BaseTabs.story.vue +0 -42
- package/src/components/BaseTabs.vue +3 -6
- package/src/components/FileUploader.vue +10 -1
- package/src/components/FormField.vue +10 -3
- package/src/components/GeneratedPathInput.vue +11 -7
- package/src/components/NumberInput.story.vue +32 -13
- package/src/components/NumberInput.vue +116 -55
- package/src/components/PluginWorkspaceView.props.ts +3 -3
- package/src/components/internal/FormFieldRendererInternal.vue +17 -0
- package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
- package/src/composables/filePicker/localFiles.ts +21 -14
- package/src/composables/filePicker/usePickerSelection.ts +15 -4
- package/src/composables/pluginWorkspaceModel.ts +0 -3
- package/src/composables/usePluginWorkspace.ts +1 -2
- package/src/composables/useToast.ts +21 -5
- package/src/generated/schema.ts +58 -18
- package/src/styles/components/app-sidebar.css +282 -121
- package/src/styles/components/form-builder.css +1 -1
- package/src/styles/components/form-field.css +28 -0
- package/src/styles/components/number-input.css +73 -91
- package/src/styles/components/pill.css +49 -51
- package/src/styles/components/segmented-control.css +16 -11
- package/src/styles/components/tabs.css +18 -34
- package/src/styles/components/toast.css +136 -27
- package/src/styles/variables.css +25 -5
- package/src/types/components.ts +23 -0
- package/src/types/form-builder.ts +2 -0
- package/src/types/generated-ui.ts +2 -0
- package/src/types/index.ts +2 -0
- package/dist/SettingsModal-BYR20I4c.js.map +0 -1
- package/dist/Skeleton-BAF3CKY7.js.map +0 -1
- package/dist/components-DIum5hkx.js.map +0 -1
- package/dist/useControlSchema-DldpxNOP.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
+
})
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
:
|
|
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.
|
|
535
|
-
title="
|
|
531
|
+
v-model="state.density"
|
|
532
|
+
title="Density"
|
|
536
533
|
:options="[
|
|
537
|
-
{ label: '
|
|
538
|
-
{ label: '
|
|
534
|
+
{ label: 'Compact', value: 'compact' },
|
|
535
|
+
{ label: 'Comfortable', value: 'comfortable' },
|
|
539
536
|
]"
|
|
540
537
|
/>
|
|
541
538
|
<HstCheckbox v-model="state.floating" title="Floating" />
|