@morscherlab/mint-sdk 1.0.0-beta.7 → 1.0.0-rc.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 (163) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/components/LcmsSequenceTable.test.d.ts +1 -0
  3. package/dist/__tests__/components/ProgressBar.test.d.ts +1 -0
  4. package/dist/__tests__/components/RackEditor.test.d.ts +1 -0
  5. package/dist/__tests__/components/SequenceProgressBar.test.d.ts +1 -0
  6. package/dist/__tests__/composables/useExperimentSamples.test.d.ts +1 -0
  7. package/dist/__tests__/composables/useProtocolTemplates.test.d.ts +1 -0
  8. package/dist/__tests__/stores/settings.test.d.ts +1 -0
  9. package/dist/__tests__/utils/instrument.test.d.ts +1 -0
  10. package/dist/__tests__/utils/lcms.test.d.ts +1 -0
  11. package/dist/__tests__/utils/permissions.test.d.ts +1 -0
  12. package/dist/__tests__/utils/rack.test.d.ts +1 -0
  13. package/dist/{auth-QQj2kkze.js → auth-B7g4J4ZF.js} +148 -24
  14. package/dist/auth-B7g4J4ZF.js.map +1 -0
  15. package/dist/components/AutoGroupModal.vue.d.ts +1 -1
  16. package/dist/components/BaseCheckbox.vue.d.ts +1 -1
  17. package/dist/components/BaseToggle.vue.d.ts +2 -2
  18. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  19. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -1
  20. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +1 -1
  21. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +1 -1
  22. package/dist/components/FormulaInput.vue.d.ts +1 -1
  23. package/dist/components/InstrumentAlertLog.vue.d.ts +22 -0
  24. package/dist/components/InstrumentStateBadge.vue.d.ts +11 -0
  25. package/dist/components/InstrumentStatusCard.vue.d.ts +13 -0
  26. package/dist/components/LcmsSequenceTable.vue.d.ts +26 -0
  27. package/dist/components/ProgressBar.vue.d.ts +1 -0
  28. package/dist/components/RackEditor.vue.d.ts +41 -3
  29. package/dist/components/ReagentList.vue.d.ts +1 -1
  30. package/dist/components/SampleSelector.vue.d.ts +5 -2
  31. package/dist/components/SegmentedControl.vue.d.ts +2 -0
  32. package/dist/components/SequenceInput.vue.d.ts +1 -1
  33. package/dist/components/SequenceProgressBar.vue.d.ts +15 -0
  34. package/dist/components/SettingsModal.vue.d.ts +8 -1
  35. package/dist/components/TagsInput.vue.d.ts +1 -1
  36. package/dist/components/WellPlate.vue.d.ts +42 -3
  37. package/dist/components/index.d.ts +5 -0
  38. package/dist/components/index.js +3 -3
  39. package/dist/{components-DihbSJjU.js → components-BhK-dW99.js} +2135 -1075
  40. package/dist/components-BhK-dW99.js.map +1 -0
  41. package/dist/composables/experimentDesignData.d.ts +17 -0
  42. package/dist/composables/index.d.ts +2 -0
  43. package/dist/composables/index.js +4 -4
  44. package/dist/composables/useControlSchema.d.ts +11 -0
  45. package/dist/composables/useExperimentData.d.ts +11 -3
  46. package/dist/composables/useExperimentSamples.d.ts +42 -0
  47. package/dist/composables/usePlatformContext.d.ts +54 -0
  48. package/dist/{composables-BcgZ6diz.js → composables-Bg7CFuNz.js} +5 -3
  49. package/dist/composables-Bg7CFuNz.js.map +1 -0
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.js +168 -6
  52. package/dist/index.js.map +1 -0
  53. package/dist/install.js +2 -2
  54. package/dist/instrument.d.ts +7 -0
  55. package/dist/lcms.d.ts +27 -0
  56. package/dist/permissions.d.ts +46 -0
  57. package/dist/stores/auth.d.ts +74 -2
  58. package/dist/stores/index.js +1 -1
  59. package/dist/styles.css +3186 -1070
  60. package/dist/templates/builders.d.ts +7 -3
  61. package/dist/templates/index.d.ts +2 -2
  62. package/dist/templates/index.js +2 -2
  63. package/dist/templates/presets.d.ts +12 -0
  64. package/dist/templates/types.d.ts +16 -1
  65. package/dist/{templates-Cyt0Suwf.js → templates-BorLR_7p.js} +324 -10
  66. package/dist/templates-BorLR_7p.js.map +1 -0
  67. package/dist/types/auth.d.ts +2 -0
  68. package/dist/types/components.d.ts +32 -3
  69. package/dist/types/form-builder.d.ts +2 -1
  70. package/dist/types/index.d.ts +4 -1
  71. package/dist/types/instrument.d.ts +56 -0
  72. package/dist/types/platform.d.ts +3 -0
  73. package/dist/{useExperimentData-CM6Y0u5L.js → useProtocolTemplates-n6AJqSqv.js} +627 -380
  74. package/dist/useProtocolTemplates-n6AJqSqv.js.map +1 -0
  75. package/dist/utils/rack.d.ts +47 -0
  76. package/package.json +1 -1
  77. package/src/__tests__/components/AppTopBar.test.ts +15 -0
  78. package/src/__tests__/components/BaseTabs.test.ts +15 -0
  79. package/src/__tests__/components/GroupAssigner.test.ts +18 -0
  80. package/src/__tests__/components/LcmsSequenceTable.test.ts +57 -0
  81. package/src/__tests__/components/ProgressBar.test.ts +18 -0
  82. package/src/__tests__/components/RackEditor.test.ts +125 -0
  83. package/src/__tests__/components/SampleSelector.test.ts +25 -0
  84. package/src/__tests__/components/SegmentedControl.test.ts +45 -0
  85. package/src/__tests__/components/SequenceProgressBar.test.ts +39 -0
  86. package/src/__tests__/components/SettingsModal.test.ts +83 -2
  87. package/src/__tests__/composables/useApi.test.ts +45 -0
  88. package/src/__tests__/composables/useAuth.test.ts +20 -0
  89. package/src/__tests__/composables/useControlSchema.test.ts +4 -0
  90. package/src/__tests__/composables/useExperimentData.test.ts +23 -0
  91. package/src/__tests__/composables/useExperimentSamples.test.ts +91 -0
  92. package/src/__tests__/composables/useProtocolTemplates.test.ts +64 -0
  93. package/src/__tests__/stores/settings.test.ts +78 -0
  94. package/src/__tests__/templates/templates.test.ts +86 -0
  95. package/src/__tests__/utils/instrument.test.ts +47 -0
  96. package/src/__tests__/utils/lcms.test.ts +73 -0
  97. package/src/__tests__/utils/permissions.test.ts +50 -0
  98. package/src/__tests__/utils/rack.test.ts +120 -0
  99. package/src/components/AppAvatarMenu.vue +6 -3
  100. package/src/components/AppTopBar.vue +16 -10
  101. package/src/components/AuditTrail.vue +1 -1
  102. package/src/components/BaseTabs.vue +22 -1
  103. package/src/components/Calendar.vue +6 -2
  104. package/src/components/ConcentrationInput.vue +3 -2
  105. package/src/components/GroupAssigner.vue +8 -3
  106. package/src/components/InstrumentAlertLog.vue +191 -0
  107. package/src/components/InstrumentStateBadge.vue +50 -0
  108. package/src/components/InstrumentStatusCard.vue +188 -0
  109. package/src/components/LcmsSequenceTable.vue +191 -0
  110. package/src/components/NumberInput.vue +5 -3
  111. package/src/components/ProgressBar.vue +3 -0
  112. package/src/components/RackEditor.vue +73 -2
  113. package/src/components/SampleHierarchyTree.vue +3 -2
  114. package/src/components/SampleSelector.vue +28 -9
  115. package/src/components/SegmentedControl.story.vue +17 -0
  116. package/src/components/SegmentedControl.vue +14 -3
  117. package/src/components/SequenceProgressBar.vue +71 -0
  118. package/src/components/SettingsModal.vue +49 -2
  119. package/src/components/UnitInput.vue +6 -2
  120. package/src/components/WellPlate.vue +145 -24
  121. package/src/components/index.ts +5 -0
  122. package/src/components/internal/WellEditPopupInternal.vue +1 -0
  123. package/src/composables/experimentDesignData.ts +182 -0
  124. package/src/composables/index.ts +14 -0
  125. package/src/composables/useApi.ts +113 -16
  126. package/src/composables/useAuth.ts +4 -0
  127. package/src/composables/useAutoGroup.ts +18 -9
  128. package/src/composables/useControlSchema.ts +21 -0
  129. package/src/composables/useExperimentData.ts +57 -16
  130. package/src/composables/useExperimentSamples.ts +142 -0
  131. package/src/composables/useProtocolTemplates.ts +13 -1
  132. package/src/composables/useRackEditor.ts +3 -2
  133. package/src/index.ts +27 -0
  134. package/src/instrument.ts +90 -0
  135. package/src/lcms.ts +108 -0
  136. package/src/permissions.ts +143 -0
  137. package/src/stores/auth.ts +79 -26
  138. package/src/stores/settings.ts +10 -0
  139. package/src/styles/components/instrument-monitor.css +478 -0
  140. package/src/styles/components/lcms-sequence-table.css +189 -0
  141. package/src/styles/components/sequence-progress-bar.css +63 -0
  142. package/src/styles/components/settings-modal.css +9 -0
  143. package/src/styles/components/tabs.css +9 -0
  144. package/src/styles/components/well-edit-popup.css +7 -1
  145. package/src/styles/components/well-plate.css +5 -0
  146. package/src/styles/index.css +3 -0
  147. package/src/templates/builders.ts +201 -0
  148. package/src/templates/controlSchemas.ts +68 -0
  149. package/src/templates/index.ts +2 -0
  150. package/src/templates/presets.ts +23 -0
  151. package/src/templates/types.ts +17 -0
  152. package/src/types/auth.ts +3 -0
  153. package/src/types/components.ts +45 -3
  154. package/src/types/form-builder.ts +2 -1
  155. package/src/types/index.ts +35 -0
  156. package/src/types/instrument.ts +61 -0
  157. package/src/types/platform.ts +4 -0
  158. package/src/utils/rack.ts +209 -0
  159. package/dist/auth-QQj2kkze.js.map +0 -1
  160. package/dist/components-DihbSJjU.js.map +0 -1
  161. package/dist/composables-BcgZ6diz.js.map +0 -1
  162. package/dist/templates-Cyt0Suwf.js.map +0 -1
  163. package/dist/useExperimentData-CM6Y0u5L.js.map +0 -1
@@ -0,0 +1,47 @@
1
+ import { Rack, SlotPosition, WellEditData, WellPlateFormat } from '../types';
2
+ export type LcmsPlateType = '96well' | '54vial';
3
+ export type LcmsControlSampleType = 'blank' | 'qc' | 'iqc';
4
+ export interface LcmsPlateCell {
5
+ row: string;
6
+ column: number;
7
+ sample_name: string;
8
+ sample_type?: string | null;
9
+ injection_volume?: number | null;
10
+ injection_count?: number | null;
11
+ custom_method?: string | null;
12
+ slot?: SlotPosition | null;
13
+ }
14
+ export interface LcmsPlateCellsToRackOptions {
15
+ rackId?: string;
16
+ rackName?: string;
17
+ format?: WellPlateFormat;
18
+ plateType?: LcmsPlateType;
19
+ slot?: SlotPosition;
20
+ injectionVolume?: number;
21
+ }
22
+ export interface LcmsPlateCellsToRacksOptions extends Omit<LcmsPlateCellsToRackOptions, 'rackId' | 'rackName' | 'slot'> {
23
+ defaultSlot?: SlotPosition;
24
+ rackId?: (slot: SlotPosition, index: number) => string;
25
+ rackName?: (slot: SlotPosition, index: number) => string;
26
+ }
27
+ export declare const LCMS_DEFAULT_CONTROL_POSITIONS: Record<LcmsPlateType, Record<LcmsControlSampleType, {
28
+ row: string;
29
+ column: number;
30
+ }>>;
31
+ export declare function lcmsPlateTypeToRackFormat(plateType: LcmsPlateType): WellPlateFormat;
32
+ export declare function rackFormatToLcmsPlateType(format: WellPlateFormat): LcmsPlateType;
33
+ export declare function lcmsWellId(row: string, column: number): string;
34
+ export declare function parseLcmsWellId(wellId: string): {
35
+ row: string;
36
+ column: number;
37
+ };
38
+ export declare function lcmsPlateCellsToRack(cells: LcmsPlateCell[], options?: LcmsPlateCellsToRackOptions): Rack;
39
+ export declare function lcmsPlateCellsToRacks(cells: LcmsPlateCell[], options?: LcmsPlateCellsToRacksOptions): Rack[];
40
+ export declare function rackToLcmsPlateCells(rack: Rack): LcmsPlateCell[];
41
+ export declare function racksToLcmsPlateCells(racks: Rack[]): LcmsPlateCell[];
42
+ export declare function getLcmsDefaultControlWellId(plateType: LcmsPlateType, sampleType: LcmsControlSampleType): string;
43
+ export declare function createLcmsControlWellEditData(sampleType: LcmsControlSampleType, options?: {
44
+ wellId?: string;
45
+ plateType?: LcmsPlateType;
46
+ injectionVolume?: number;
47
+ }): WellEditData;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-rc.2",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -367,6 +367,21 @@ describe('AppTopBar', () => {
367
367
  expect(settingsModal.props('showAppearance')).toBe(false)
368
368
  })
369
369
 
370
+ it('should pass settings userType to SettingsModal', () => {
371
+ const settingsConfig: TopBarSettingsConfig = {
372
+ userType: 'admin',
373
+ }
374
+
375
+ const wrapper = createWrapper({
376
+ title: 'Test App',
377
+ showSettings: true,
378
+ settingsConfig,
379
+ })
380
+
381
+ const settingsModal = wrapper.findComponent(SettingsModal)
382
+ expect(settingsModal.props('userType')).toBe('admin')
383
+ })
384
+
370
385
  it('should default showAppearance to true when not specified', () => {
371
386
  const wrapper = createWrapper({
372
387
  title: 'Test App',
@@ -22,4 +22,19 @@ describe('BaseTabs', () => {
22
22
 
23
23
  expect(wrapper.emitted('update:modelValue')).toEqual([['Results']])
24
24
  })
25
+
26
+ it('renders svg path icons', () => {
27
+ const wrapper = mount(BaseTabs, {
28
+ props: {
29
+ modelValue: 'users',
30
+ tabs: [
31
+ { id: 'users', label: 'Users', icon: 'M12 4h8M12 12h8M12 20h8' },
32
+ { id: 'logs', label: 'Logs', icon: ['M4 4h16v16H4z', 'M8 8h8'] },
33
+ ],
34
+ },
35
+ })
36
+
37
+ expect(wrapper.findAll('svg.mint-tab__icon--svg')).toHaveLength(2)
38
+ expect(wrapper.findAll('path')).toHaveLength(3)
39
+ })
25
40
  })
@@ -27,4 +27,22 @@ describe('GroupAssigner', () => {
27
27
  expect(wrapper.emitted('update:group1')?.[0]).toEqual([[]])
28
28
  expect(wrapper.emitted('update:group2')).toBeUndefined()
29
29
  })
30
+
31
+ it('does not throw when drag events omit dataTransfer', async () => {
32
+ const wrapper = mount(GroupAssigner, {
33
+ props: {
34
+ groups,
35
+ group1: [],
36
+ group2: [],
37
+ },
38
+ })
39
+
40
+ await expect(
41
+ wrapper.find('.mint-group-assigner__pill--unassigned').trigger('dragstart', { dataTransfer: null }),
42
+ ).resolves.toBeUndefined()
43
+
44
+ await expect(
45
+ wrapper.find('.mint-group-assigner__zone').trigger('dragover', { dataTransfer: null }),
46
+ ).resolves.toBeUndefined()
47
+ })
30
48
  })
@@ -0,0 +1,57 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import LcmsSequenceTable from '../../components/LcmsSequenceTable.vue'
4
+ import type { LcmsSequenceItem } from '../../lcms'
5
+
6
+ function makeItems(): LcmsSequenceItem[] {
7
+ return [
8
+ {
9
+ sample_type: 'Unknown',
10
+ file_name: 'Batch_NEG_S1_001',
11
+ sample_id: 'R:A1',
12
+ path: '',
13
+ instrument_method: 'C:\\Methods\\neg.meth',
14
+ position: 'R:A1',
15
+ injection_volume: 5,
16
+ },
17
+ {
18
+ sample_type: 'Blank',
19
+ file_name: 'Batch_NEG_Blank_002',
20
+ sample_id: 'R:F9',
21
+ path: '',
22
+ instrument_method: 'C:\\Methods\\blank.meth',
23
+ position: 'R:F9',
24
+ injection_volume: 5,
25
+ },
26
+ ]
27
+ }
28
+
29
+ describe('LcmsSequenceTable', () => {
30
+ it('renders Xcalibur sequence rows with method basenames', () => {
31
+ const wrapper = mount(LcmsSequenceTable, {
32
+ props: {
33
+ items: makeItems(),
34
+ },
35
+ })
36
+
37
+ expect(wrapper.text()).toContain('Batch_NEG_S1_001')
38
+ expect(wrapper.text()).toContain('neg.meth')
39
+ expect(wrapper.text()).toContain('Blank')
40
+ })
41
+
42
+ it('emits editable row actions', async () => {
43
+ const wrapper = mount(LcmsSequenceTable, {
44
+ props: {
45
+ items: makeItems(),
46
+ editable: true,
47
+ },
48
+ })
49
+
50
+ const actions = wrapper.findAll('.mint-lcms-sequence-table__action')
51
+ await actions[0].trigger('click')
52
+ await actions[1].trigger('click')
53
+
54
+ expect(wrapper.emitted('duplicate')?.[0]).toEqual([0])
55
+ expect(wrapper.emitted('remove')?.[0]).toEqual([0])
56
+ })
57
+ })
@@ -0,0 +1,18 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import ProgressBar from '../../components/ProgressBar.vue'
4
+
5
+ describe('ProgressBar', () => {
6
+ it('clamps rail values and exposes an accessible label', () => {
7
+ const wrapper = mount(ProgressBar, {
8
+ props: {
9
+ value: 130,
10
+ ariaLabel: 'Connection pool usage',
11
+ },
12
+ })
13
+
14
+ const progress = wrapper.get('[role="progressbar"]')
15
+ expect(progress.attributes('aria-valuenow')).toBe('100')
16
+ expect(progress.attributes('aria-label')).toBe('Connection pool usage')
17
+ })
18
+ })
@@ -0,0 +1,125 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { h, nextTick } from 'vue'
4
+ import RackEditor from '../../components/RackEditor.vue'
5
+ import type { Rack, WellEditData } from '../../types'
6
+
7
+ function makeRacks(): Rack[] {
8
+ return [
9
+ {
10
+ id: 'rack-1',
11
+ name: 'Rack 1',
12
+ format: 54,
13
+ slot: 'R',
14
+ injectionVolume: 5,
15
+ wells: {
16
+ A1: {
17
+ id: 'A1',
18
+ row: 0,
19
+ col: 0,
20
+ state: 'filled',
21
+ sampleType: 'sample',
22
+ metadata: {
23
+ label: 'Original',
24
+ injectionVolume: 5,
25
+ injectionCount: 1,
26
+ },
27
+ },
28
+ },
29
+ },
30
+ ]
31
+ }
32
+
33
+ function jsonDataTransfer(data: unknown): DataTransfer {
34
+ return {
35
+ dropEffect: 'copy',
36
+ effectAllowed: 'copy',
37
+ getData: (type: string) => type === 'application/json' ? JSON.stringify(data) : '',
38
+ setData: vi.fn(),
39
+ } as unknown as DataTransfer
40
+ }
41
+
42
+ describe('RackEditor', () => {
43
+ it('passes rack-aware well editor slot props through to WellPlate', async () => {
44
+ const wrapper = mount(RackEditor, {
45
+ props: {
46
+ modelValue: makeRacks(),
47
+ },
48
+ slots: {
49
+ 'well-editor': (slotProps) => h(
50
+ 'button',
51
+ {
52
+ 'data-test': 'custom-well-editor',
53
+ onClick: () => slotProps.save({
54
+ wellId: slotProps.wellId,
55
+ label: 'Custom',
56
+ sampleType: 'iqc',
57
+ injectionVolume: 7,
58
+ injectionCount: 3,
59
+ customMethod: 'C:\\Methods\\custom.meth',
60
+ } satisfies WellEditData),
61
+ },
62
+ `${slotProps.rackId}:${slotProps.rack?.slot}:${slotProps.wellId}:${slotProps.wellData?.metadata?.label}`,
63
+ ),
64
+ },
65
+ })
66
+
67
+ await wrapper.find('.mint-well-plate__well').trigger('click')
68
+
69
+ const customEditor = wrapper.get('[data-test="custom-well-editor"]')
70
+ expect(customEditor.text()).toBe('rack-1:R:A1:Original')
71
+
72
+ await customEditor.trigger('click')
73
+ await nextTick()
74
+
75
+ const emittedRacks = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Rack[]
76
+ expect(emittedRacks[0].wells.A1).toMatchObject({
77
+ sampleType: 'iqc',
78
+ metadata: {
79
+ label: 'Custom',
80
+ injectionVolume: 7,
81
+ injectionCount: 3,
82
+ customMethod: 'C:\\Methods\\custom.meth',
83
+ },
84
+ })
85
+ })
86
+
87
+ it('assigns dropped external samples when sample drop is enabled', async () => {
88
+ const wrapper = mount(RackEditor, {
89
+ props: {
90
+ modelValue: makeRacks(),
91
+ allowSampleDrop: true,
92
+ },
93
+ })
94
+
95
+ await wrapper.findAll('.mint-well-plate__well')[1].trigger('drop', {
96
+ dataTransfer: jsonDataTransfer({
97
+ type: 'sample',
98
+ id: 'sample-2',
99
+ sampleName: 'Dragged Sample',
100
+ }),
101
+ preventDefault: vi.fn(),
102
+ })
103
+ await nextTick()
104
+
105
+ const emittedRacks = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Rack[]
106
+ expect(emittedRacks[0].wells.A2).toMatchObject({
107
+ sampleType: 'sample',
108
+ metadata: {
109
+ label: 'Dragged Sample',
110
+ injectionVolume: 5,
111
+ injectionCount: 1,
112
+ customMethod: null,
113
+ },
114
+ })
115
+
116
+ expect(wrapper.emitted('sample-drop')?.[0]?.slice(0, 3)).toEqual([
117
+ 'rack-1',
118
+ 'A2',
119
+ expect.objectContaining({
120
+ id: 'sample-2',
121
+ sampleName: 'Dragged Sample',
122
+ }),
123
+ ])
124
+ })
125
+ })
@@ -57,4 +57,29 @@ describe('SampleSelector', () => {
57
57
 
58
58
  expect(wrapper.find('.mint-sample-selector__sample-name').exists()).toBe(false)
59
59
  })
60
+
61
+ it('can derive samples directly from experiment design data', () => {
62
+ const wrapper = mount(SampleSelector, {
63
+ props: {
64
+ modelValue: [],
65
+ designData: {
66
+ samples: [
67
+ { sample_name: 'S1', sample_type: 'sample' },
68
+ { sample_name: 'Blank', sample_type: 'blank' },
69
+ { sample_name: 'S2', sample_type: 'sample' },
70
+ ],
71
+ },
72
+ enableGrouping: false,
73
+ enableSmartGroup: false,
74
+ },
75
+ global: {
76
+ stubs: {
77
+ AutoGroupModal: true,
78
+ },
79
+ },
80
+ })
81
+
82
+ expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('2 samples')
83
+ expect(wrapper.findAll('.mint-sample-selector__flat-name').map(item => item.text())).toEqual(['S1', 'S2'])
84
+ })
60
85
  })
@@ -21,4 +21,49 @@ describe('SegmentedControl', () => {
21
21
 
22
22
  expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([96])
23
23
  })
24
+
25
+ it('renders option-level disabled tabs without allowing selection', async () => {
26
+ const wrapper = mount(SegmentedControl, {
27
+ props: {
28
+ modelValue: 'active',
29
+ options: [
30
+ { value: 'active', label: 'Active' },
31
+ { value: 'archived', label: 'Archived', disabled: true },
32
+ ],
33
+ },
34
+ })
35
+
36
+ const buttons = wrapper.findAll('button')
37
+
38
+ expect(buttons).toHaveLength(2)
39
+ expect(buttons[1].attributes('disabled')).toBeDefined()
40
+ expect(buttons[1].classes()).toContain('mint-segmented-control__option--disabled')
41
+
42
+ await buttons[1].trigger('click')
43
+
44
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
45
+ })
46
+
47
+ it('can disable tabs by value while keeping them visible', async () => {
48
+ const wrapper = mount(SegmentedControl, {
49
+ props: {
50
+ modelValue: 'table',
51
+ options: [
52
+ { value: 'table', label: 'Table' },
53
+ { value: 'chart', label: 'Chart' },
54
+ ],
55
+ disabledValues: ['chart'],
56
+ },
57
+ })
58
+
59
+ const buttons = wrapper.findAll('button')
60
+
61
+ expect(buttons.map(button => button.text())).toEqual(['Table', 'Chart'])
62
+ expect(buttons[1].attributes('disabled')).toBeDefined()
63
+
64
+ await buttons[1].trigger('keydown.enter')
65
+ await buttons[1].trigger('click')
66
+
67
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
68
+ })
24
69
  })
@@ -0,0 +1,39 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import SequenceProgressBar from '../../components/SequenceProgressBar.vue'
4
+
5
+ describe('SequenceProgressBar', () => {
6
+ it('renders sequence progress and remaining time', () => {
7
+ const wrapper = mount(SequenceProgressBar, {
8
+ props: {
9
+ progress: {
10
+ current_sample: 3,
11
+ total_samples: 6,
12
+ elapsed_seconds: 180,
13
+ sample_durations: [50, 70, 60],
14
+ },
15
+ },
16
+ })
17
+
18
+ expect(wrapper.get('.mint-sequence-progress__label').text()).toBe('3 / 6 samples')
19
+ expect(wrapper.get('.mint-sequence-progress__percent').text()).toBe('50%')
20
+ expect(wrapper.text()).toContain('~3m left')
21
+ })
22
+
23
+ it('supports compact rendering without footer labels', () => {
24
+ const wrapper = mount(SequenceProgressBar, {
25
+ props: {
26
+ compact: true,
27
+ label: 'injections',
28
+ progress: {
29
+ current_sample: 2,
30
+ total_samples: 4,
31
+ estimated_remaining_seconds: 120,
32
+ },
33
+ },
34
+ })
35
+
36
+ expect(wrapper.get('.mint-sequence-progress__label').text()).toBe('2 / 4 injections')
37
+ expect(wrapper.find('.mint-sequence-progress__footer').exists()).toBe(false)
38
+ })
39
+ })
@@ -3,10 +3,11 @@ import { createPinia } from 'pinia'
3
3
  import { describe, expect, it } from 'vitest'
4
4
  import SettingsModal from '../../components/SettingsModal.vue'
5
5
  import { defineControlModel, type ControlSchema } from '../../composables/useControlSchema'
6
+ import { useAuthStore } from '../../stores/auth'
6
7
  import type { SettingsModalSchema } from '../../types'
7
8
 
8
9
  describe('SettingsModal', () => {
9
- function mountSettingsModal(props = {}, slots = {}) {
10
+ function mountSettingsModal(props = {}, slots = {}, pinia = createPinia()) {
10
11
  return mount(SettingsModal, {
11
12
  props: {
12
13
  modelValue: true,
@@ -15,7 +16,7 @@ describe('SettingsModal', () => {
15
16
  },
16
17
  slots,
17
18
  global: {
18
- plugins: [createPinia()],
19
+ plugins: [pinia],
19
20
  stubs: {
20
21
  teleport: true,
21
22
  },
@@ -293,4 +294,84 @@ describe('SettingsModal', () => {
293
294
  threshold: 0.25,
294
295
  })
295
296
  })
297
+
298
+ it('filters schema groups and fields by explicit user type', async () => {
299
+ const schema: SettingsModalSchema = {
300
+ groups: [
301
+ {
302
+ id: 'general',
303
+ label: 'General',
304
+ fields: [
305
+ { name: 'common', label: 'Common', type: 'text', defaultValue: 'shared' },
306
+ { name: 'adminSecret', label: 'Admin Secret', type: 'text', visibleFor: 'admin' },
307
+ { name: 'userNote', label: 'User Note', type: 'text', visibleFor: 'user' },
308
+ ],
309
+ },
310
+ {
311
+ id: 'admin',
312
+ label: 'Admin',
313
+ visibleFor: 'admin',
314
+ fields: [
315
+ { name: 'adminOnly', label: 'Admin Only', type: 'text' },
316
+ ],
317
+ },
318
+ ],
319
+ }
320
+
321
+ const userWrapper = mountSettingsModal({ schema, userType: 'user' })
322
+
323
+ expect(userWrapper.text()).toContain('Common')
324
+ expect(userWrapper.text()).toContain('User Note')
325
+ expect(userWrapper.text()).not.toContain('Admin Secret')
326
+ expect(userWrapper.text()).not.toContain('Admin Only')
327
+
328
+ const adminWrapper = mountSettingsModal({ schema, userType: 'admin' })
329
+
330
+ expect(adminWrapper.findAll('.mint-settings-modal__tab').map(tab => tab.text())).toEqual([
331
+ 'General',
332
+ 'Admin',
333
+ ])
334
+ expect(adminWrapper.text()).toContain('Common')
335
+ expect(adminWrapper.text()).toContain('Admin Secret')
336
+ expect(adminWrapper.text()).not.toContain('User Note')
337
+
338
+ await adminWrapper.findAll('.mint-settings-modal__tab')[1].trigger('click')
339
+
340
+ expect(adminWrapper.text()).toContain('Admin Only')
341
+ })
342
+
343
+ it('uses SDK auth permissions when no user type override is passed', () => {
344
+ const pinia = createPinia()
345
+ const auth = useAuthStore(pinia)
346
+ auth.setUserInfo({
347
+ id: '1',
348
+ username: 'admin',
349
+ shortname: null,
350
+ email: null,
351
+ role: 'member',
352
+ roleObj: {
353
+ slug: 'member',
354
+ permissions: ['plugins.configure'],
355
+ },
356
+ isActive: true,
357
+ })
358
+ const schema: SettingsModalSchema = {
359
+ groups: [
360
+ {
361
+ id: 'plugins',
362
+ label: 'Plugins',
363
+ permissions: ['plugins.configure'],
364
+ fields: [
365
+ { name: 'registry', label: 'Registry', type: 'text' },
366
+ { name: 'danger', label: 'Danger Zone', type: 'text', requiresAdmin: true },
367
+ ],
368
+ },
369
+ ],
370
+ }
371
+
372
+ const wrapper = mountSettingsModal({ schema }, {}, pinia)
373
+
374
+ expect(wrapper.text()).toContain('Registry')
375
+ expect(wrapper.text()).not.toContain('Danger Zone')
376
+ })
296
377
  })
@@ -1,7 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { createPinia, setActivePinia } from 'pinia'
3
+ import type { InternalAxiosRequestConfig } from 'axios'
3
4
 
4
5
  import { useApi } from '../../composables/useApi'
6
+ import { useAuthStore } from '../../stores/auth'
7
+
8
+ function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers']): unknown {
9
+ const bag = headers as Record<string, unknown> & { get?: (header: string) => unknown }
10
+ return (typeof bag.get === 'function' ? bag.get('Authorization') : undefined)
11
+ ?? bag.Authorization
12
+ ?? bag.authorization
13
+ }
5
14
 
6
15
  describe('useApi URL helpers', () => {
7
16
  beforeEach(() => {
@@ -9,6 +18,7 @@ describe('useApi URL helpers', () => {
9
18
  })
10
19
 
11
20
  afterEach(() => {
21
+ vi.restoreAllMocks()
12
22
  vi.unstubAllEnvs()
13
23
  })
14
24
 
@@ -27,4 +37,39 @@ describe('useApi URL helpers', () => {
27
37
  expect(api.buildWsUrl('/events')).toMatch(/\/api\/events$/)
28
38
  expect(api.buildWsUrl('/events')).not.toContain('/api//events')
29
39
  })
40
+
41
+ it('normalizes paths that already include the configured API prefix', async () => {
42
+ const api = useApi({ baseUrl: '/api' })
43
+ const getSpy = vi.spyOn(api.client, 'get').mockResolvedValue({ data: { ok: true } })
44
+
45
+ expect(api.buildUrl('/api/projects')).toBe('/api/projects')
46
+ expect(api.buildUrl('/api')).toBe('/api')
47
+ expect(api.buildUrl('/api?active=true')).toBe('/api?active=true')
48
+
49
+ await expect(api.get<{ ok: boolean }>('/api/projects')).resolves.toEqual({ ok: true })
50
+ expect(getSpy).toHaveBeenCalledWith('/projects', expect.objectContaining({ baseURL: '/api' }))
51
+ })
52
+
53
+ it('honors withAuth=false even when the SDK auth store has a token', async () => {
54
+ const authStore = useAuthStore()
55
+ authStore.setToken('secret-token', 3600)
56
+ const api = useApi({ baseUrl: '/api', withAuth: false })
57
+
58
+ let authorization: unknown
59
+ const result = await api.get<{ ok: boolean }>('/public', {
60
+ adapter: async (config) => {
61
+ authorization = readAuthorizationHeader(config.headers)
62
+ return {
63
+ data: { ok: true },
64
+ status: 200,
65
+ statusText: 'OK',
66
+ headers: {},
67
+ config,
68
+ }
69
+ },
70
+ })
71
+
72
+ expect(result).toEqual({ ok: true })
73
+ expect(authorization).toBeUndefined()
74
+ })
30
75
  })
@@ -94,6 +94,26 @@ describe('useAuth', () => {
94
94
  })
95
95
  })
96
96
 
97
+ describe('storage fallback', () => {
98
+ it('keeps auth usable when localStorage operations throw', () => {
99
+ vi.stubGlobal('localStorage', {
100
+ getItem: vi.fn(() => { throw new Error('blocked') }),
101
+ setItem: vi.fn(() => { throw new Error('blocked') }),
102
+ removeItem: vi.fn(() => { throw new Error('blocked') }),
103
+ })
104
+
105
+ const authStore = useAuthStore()
106
+
107
+ expect(() => authStore.initialize()).not.toThrow()
108
+
109
+ authStore.setToken('memory-token', 3600)
110
+ expect(authStore.token).toBe('memory-token')
111
+
112
+ authStore.clearToken()
113
+ expect(authStore.token).toBeNull()
114
+ })
115
+ })
116
+
97
117
  describe('refreshToken - race condition', () => {
98
118
  it('should deduplicate concurrent refresh calls', async () => {
99
119
  const authStore = useAuthStore()
@@ -478,6 +478,8 @@ describe('useControlSchema', () => {
478
478
  icon: '<svg viewBox="0 0 24 24"></svg>',
479
479
  columns: 2,
480
480
  condition,
481
+ visibleFor: 'admin',
482
+ permissions: ['plugins.configure'],
481
483
  },
482
484
  },
483
485
  })
@@ -490,6 +492,8 @@ describe('useControlSchema', () => {
490
492
  icon: '<svg viewBox="0 0 24 24"></svg>',
491
493
  columns: 2,
492
494
  condition,
495
+ visibleFor: 'admin',
496
+ permissions: ['plugins.configure'],
493
497
  })
494
498
  expect(schema.groups[0].fields.map(field => field.name)).toEqual(['threshold', 'method'])
495
499
  expect(schema.groups[1].label).toBe('Output')