@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
@@ -70,6 +70,29 @@ describe('useExperimentData', () => {
70
70
  expect(hook2.treeData.value).toEqual([{ id: '2' }])
71
71
  })
72
72
 
73
+ it('unwraps platform design-data responses and exposes sample helpers', async () => {
74
+ const response = {
75
+ experiment_id: 42,
76
+ plugin_id: 'ms-designer',
77
+ schema_version: '1.0',
78
+ data: {
79
+ tree_data: [{ id: 'design' }],
80
+ samples: [
81
+ { sample_name: 'S1', sample_type: 'sample', conditions: { group: 'A' } },
82
+ { sample_name: 'Blank', sample_type: 'blank', conditions: { group: 'QC' } },
83
+ ],
84
+ },
85
+ }
86
+ fakeResponse = response
87
+ const hook = useExperimentData()
88
+ await hook.fetch(42)
89
+
90
+ expect(hook.designData.value).toEqual(response.data)
91
+ expect(hook.treeData.value).toEqual([{ id: 'design' }])
92
+ expect(hook.sampleNames.value).toEqual(['S1'])
93
+ expect(hook.sampleOptions.value).toEqual([{ value: 'S1', label: 'S1' }])
94
+ })
95
+
73
96
  it('tableData falls back to empty array when absent', async () => {
74
97
  fakeResponse = {}
75
98
  const hook = useExperimentData()
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import axios, { type AxiosRequestConfig } from 'axios'
4
+
5
+ import { useExperimentSamples } from '../../composables/useExperimentSamples'
6
+
7
+ let fakeResponse: unknown = null
8
+ let fakeError: Error | null = null
9
+ let recordedUrls: string[] = []
10
+
11
+ describe('useExperimentSamples', () => {
12
+ beforeEach(() => {
13
+ setActivePinia(createPinia())
14
+ fakeResponse = {}
15
+ fakeError = null
16
+ recordedUrls = []
17
+ vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
18
+ this: unknown,
19
+ url: string,
20
+ _config?: AxiosRequestConfig,
21
+ ) {
22
+ recordedUrls.push(url)
23
+ if (fakeError) throw fakeError
24
+ return { data: fakeResponse }
25
+ })
26
+ })
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks()
30
+ })
31
+
32
+ it('derives samples from pre-fetched MS Designer design_data', () => {
33
+ const hook = useExperimentSamples({
34
+ syncAppExperiment: false,
35
+ designData: {
36
+ samples: [
37
+ { sample_name: 'S1', sample_type: 'sample', conditions: { group: 'A' } },
38
+ { sample_name: 'QC1', sample_type: 'qc', conditions: { group: 'QC' } },
39
+ ],
40
+ },
41
+ })
42
+
43
+ expect(hook.samples.value).toEqual(['S1'])
44
+ expect(hook.sampleOptions.value).toEqual([{ value: 'S1', label: 'S1' }])
45
+ expect(recordedUrls).toEqual([])
46
+ })
47
+
48
+ it('loads and unwraps platform design_data for an experiment id', async () => {
49
+ fakeResponse = {
50
+ experiment_id: 7,
51
+ plugin_id: 'ms-designer',
52
+ data: {
53
+ samples: [
54
+ { sample_name: 'S1', sample_type: 'sample' },
55
+ { sample_name: 'S2', sample_type: 'sample' },
56
+ ],
57
+ },
58
+ schema_version: '1.0',
59
+ }
60
+ const hook = useExperimentSamples({ syncAppExperiment: false, immediate: false })
61
+
62
+ await hook.fetch(7)
63
+
64
+ expect(recordedUrls).toEqual(['/experiments/7/data'])
65
+ expect(hook.samples.value).toEqual(['S1', 'S2'])
66
+ })
67
+
68
+ it('extracts options from template collection sample sheets', () => {
69
+ const hook = useExperimentSamples({
70
+ syncAppExperiment: false,
71
+ designData: {
72
+ templates: {
73
+ 'sample-sheet': {
74
+ data: {
75
+ samples: [
76
+ { sampleId: 'ctrl', name: 'Control', group: 'baseline' },
77
+ { sampleId: 'drug', name: 'Drug', group: 'treated' },
78
+ ],
79
+ },
80
+ },
81
+ },
82
+ },
83
+ })
84
+
85
+ expect(hook.samples.value).toEqual(['ctrl', 'drug'])
86
+ expect(hook.sampleOptions.value).toEqual([
87
+ { value: 'ctrl', label: 'Control', description: 'baseline' },
88
+ { value: 'drug', label: 'Drug', description: 'treated' },
89
+ ])
90
+ })
91
+ })
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { useProtocolTemplates } from '../../composables/useProtocolTemplates'
3
+
4
+ const STORAGE_KEY = 'mint-custom-protocol-templates'
5
+
6
+ function installLocalStorage() {
7
+ const storage: Record<string, string> = {}
8
+ vi.stubGlobal('localStorage', {
9
+ get length() {
10
+ return Object.keys(storage).length
11
+ },
12
+ key: vi.fn((index: number) => Object.keys(storage)[index] ?? null),
13
+ getItem: vi.fn((key: string) => storage[key] ?? null),
14
+ setItem: vi.fn((key: string, value: string) => {
15
+ storage[key] = value
16
+ }),
17
+ removeItem: vi.fn((key: string) => {
18
+ delete storage[key]
19
+ }),
20
+ clear: vi.fn(() => {
21
+ for (const key of Object.keys(storage)) delete storage[key]
22
+ }),
23
+ } as unknown as Storage)
24
+ }
25
+
26
+ describe('useProtocolTemplates', () => {
27
+ beforeEach(() => {
28
+ vi.restoreAllMocks()
29
+ vi.unstubAllGlobals()
30
+ installLocalStorage()
31
+ })
32
+
33
+ it('ignores corrupted custom template storage', () => {
34
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ invalid: true }))
35
+
36
+ const templates = useProtocolTemplates()
37
+
38
+ expect(templates.customTemplates.value).toEqual([])
39
+ expect(templates.allTemplates.value.length).toBeGreaterThan(0)
40
+ })
41
+
42
+ it('filters invalid custom template entries', () => {
43
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([
44
+ { id: 'bad', name: 'Missing parameters', type: 'custom' },
45
+ {
46
+ id: 'custom-valid',
47
+ name: 'Valid custom step',
48
+ type: 'custom',
49
+ parameters: [],
50
+ },
51
+ ]))
52
+
53
+ const templates = useProtocolTemplates()
54
+
55
+ expect(templates.customTemplates.value).toEqual([
56
+ {
57
+ id: 'custom-valid',
58
+ name: 'Valid custom step',
59
+ type: 'custom',
60
+ parameters: [],
61
+ },
62
+ ])
63
+ })
64
+ })
@@ -0,0 +1,78 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import { useSettingsStore } from '../../stores/settings'
4
+
5
+ function mockMatchMedia(matches = false) {
6
+ Object.defineProperty(window, 'matchMedia', {
7
+ configurable: true,
8
+ writable: true,
9
+ value: vi.fn(() => ({
10
+ matches,
11
+ media: '(prefers-color-scheme: dark)',
12
+ onchange: null,
13
+ addEventListener: vi.fn(),
14
+ removeEventListener: vi.fn(),
15
+ addListener: vi.fn(),
16
+ removeListener: vi.fn(),
17
+ dispatchEvent: vi.fn(),
18
+ })),
19
+ })
20
+ }
21
+
22
+ function installLocalStorage() {
23
+ const storage: Record<string, string> = {}
24
+ vi.stubGlobal('localStorage', {
25
+ get length() {
26
+ return Object.keys(storage).length
27
+ },
28
+ key: vi.fn((index: number) => Object.keys(storage)[index] ?? null),
29
+ getItem: vi.fn((key: string) => storage[key] ?? null),
30
+ setItem: vi.fn((key: string, value: string) => {
31
+ storage[key] = value
32
+ }),
33
+ removeItem: vi.fn((key: string) => {
34
+ delete storage[key]
35
+ }),
36
+ clear: vi.fn(() => {
37
+ for (const key of Object.keys(storage)) delete storage[key]
38
+ }),
39
+ } as unknown as Storage)
40
+ }
41
+
42
+ describe('useSettingsStore', () => {
43
+ beforeEach(() => {
44
+ vi.restoreAllMocks()
45
+ vi.unstubAllGlobals()
46
+ setActivePinia(createPinia())
47
+ installLocalStorage()
48
+ document.documentElement.classList.remove('dark')
49
+ mockMatchMedia(false)
50
+ })
51
+
52
+ it('migrates legacy MLD settings into the MINT settings key', () => {
53
+ localStorage.setItem('mld-settings', JSON.stringify({
54
+ serverHost: '127.0.0.1',
55
+ serverPort: 8001,
56
+ theme: 'dark',
57
+ colorPalette: 'viridis',
58
+ tableDensity: 'compact',
59
+ }))
60
+
61
+ const settings = useSettingsStore()
62
+ settings.initialize()
63
+
64
+ expect(settings.serverHost).toBe('127.0.0.1')
65
+ expect(settings.serverPort).toBe(8001)
66
+ expect(settings.theme).toBe('dark')
67
+ expect(settings.colorPalette).toBe('viridis')
68
+ expect(settings.tableDensity).toBe('compact')
69
+ expect(localStorage.getItem('mld-settings')).toBeNull()
70
+ expect(JSON.parse(localStorage.getItem('mint-settings') || '{}')).toEqual(expect.objectContaining({
71
+ serverHost: '127.0.0.1',
72
+ serverPort: 8001,
73
+ theme: 'dark',
74
+ colorPalette: 'viridis',
75
+ tableDensity: 'compact',
76
+ }))
77
+ })
78
+ })
@@ -20,6 +20,7 @@ import {
20
20
  createReagentListTemplate,
21
21
  createSampleSheetTemplate,
22
22
  createSamplePrepTemplate,
23
+ createTargetedMetabolomicsCollection,
23
24
  createDefaultBioTemplate,
24
25
  createTemplateCollection,
25
26
  createTimeCourseTemplate,
@@ -209,6 +210,7 @@ describe('bio data templates', () => {
209
210
  'wellplate-screen',
210
211
  'qpcr-expression',
211
212
  'lcms-batch',
213
+ 'targeted-metabolomics',
212
214
  'elisa-assay',
213
215
  'flow-cytometry-assay',
214
216
  'western-blot-assay',
@@ -224,6 +226,8 @@ describe('bio data templates', () => {
224
226
  expect(getBioTemplatePresetInfo('metabolomics')?.name).toBe('lcms-batch')
225
227
  expect(getBioTemplatePresetInfo('metabolism')?.name).toBe('lcms-batch')
226
228
  expect(getBioTemplatePresetInfo('metabolite profiling')?.name).toBe('lcms-batch')
229
+ expect(getBioTemplatePresetInfo('targeted metabolomics')?.name).toBe('targeted-metabolomics')
230
+ expect(searchBioTemplatePresets('internal standards')[0].name).toBe('targeted-metabolomics')
227
231
  expect(getBioTemplatePresetInfo('immunoassay')?.templates).toEqual([
228
232
  'plate-map',
229
233
  'sample-sheet',
@@ -275,6 +279,7 @@ describe('bio data templates', () => {
275
279
  const plateSchema = getBioTemplateControlSchema('wellplate')
276
280
  const presetSchema = getBioTemplateControlSchema('wellplate-screen')
277
281
  const defaults = getBioTemplateControlDefaults('lcms-batch')
282
+ const targetedDefaults = getBioTemplateControlDefaults('targeted-metabolomics')
278
283
  const elisaDefaults = getBioTemplateControlDefaults('elisa-assay')
279
284
  const flowDefaults = getBioTemplateControlDefaults('flow-cytometry-assay')
280
285
  const westernDefaults = getBioTemplateControlDefaults('western-blot-assay')
@@ -284,6 +289,9 @@ describe('bio data templates', () => {
284
289
  expect(presetSchema?.replicates.default).toBe(3)
285
290
  expect(defaults.instrument).toBe('LC-MS')
286
291
  expect(defaults.featureNames).toEqual(['Glucose', 'Lactate'])
292
+ expect(targetedDefaults.metaboliteNames).toEqual(['Glucose', 'Lactate', 'Pyruvate'])
293
+ expect(targetedDefaults.internalStandards).toEqual(['Stable isotope internal standard mix'])
294
+ expect(targetedDefaults.method).toBe('Targeted metabolomics')
287
295
  expect(elisaDefaults.analyte).toBe('Analyte')
288
296
  expect(elisaDefaults.standardConcentrations).toEqual(['1000', '100', '10', '1'])
289
297
  expect(flowDefaults.markers).toEqual(['CD3', 'CD4', 'CD8'])
@@ -299,6 +307,7 @@ describe('bio data templates', () => {
299
307
  const elisaBindings = getBioTemplateComponentBindings('elisa-assay')
300
308
  const flowBindings = getBioTemplateComponentBindings('flow-cytometry-assay')
301
309
  const lcmsBindings = getBioTemplateComponentBindings('lcms-batch')
310
+ const targetedBindings = getBioTemplateComponentBindings('targeted-metabolomics')
302
311
  const westernBindings = getBioTemplateComponentBindings('western-blot-assay')
303
312
  const wellplateImports = toBioTemplateComponentImports('wellplate-screen')
304
313
 
@@ -311,6 +320,9 @@ describe('bio data templates', () => {
311
320
  expect(elisaBindings.map(binding => binding.component)).toContain('DataFrame')
312
321
  expect(lcmsBindings.map(binding => binding.component)).toContain('ScheduleCalendar')
313
322
  expect(lcmsBindings.map(binding => binding.component)).toContain('ExperimentTimeline')
323
+ expect(targetedBindings.map(binding => binding.component)).toContain('ReagentList')
324
+ expect(targetedBindings.map(binding => binding.component)).toContain('DataFrame')
325
+ expect(targetedBindings.map(binding => binding.component)).toContain('ScheduleCalendar')
314
326
  expect(flowBindings.map(binding => binding.template_id)).toEqual([
315
327
  'sample-sheet',
316
328
  'sample-sheet',
@@ -541,6 +553,13 @@ describe('bio data templates', () => {
541
553
  features: ['Glucose'],
542
554
  instrument: 'LC-MS',
543
555
  })
556
+ const targeted = createTargetedMetabolomicsCollection({
557
+ samples: ['S001', 'S002'],
558
+ metabolites: ['Glucose', 'Lactate'],
559
+ internalStandards: ['13C Glucose'],
560
+ standardConcentrations: [0.1, 1, 10],
561
+ instrument: 'Orbitrap',
562
+ })
544
563
  const flow = createFlowCytometryAssayCollection({
545
564
  samples: ['Control', 'Treatment'],
546
565
  markers: ['CD3', 'CD4'],
@@ -555,6 +574,7 @@ describe('bio data templates', () => {
555
574
  const wellplateTemplates = extractTemplateCollection(wellplate)
556
575
  const qpcrTemplates = extractTemplateCollection(qpcr)
557
576
  const lcmsTemplates = extractTemplateCollection(lcms)
577
+ const targetedTemplates = extractTemplateCollection(targeted)
558
578
  const flowTemplates = extractTemplateCollection(flow)
559
579
  const westernTemplates = extractTemplateCollection(western)
560
580
  const wellplateComponentProps = toBioTemplateComponentProps(wellplate)
@@ -572,6 +592,28 @@ describe('bio data templates', () => {
572
592
  expect(toInstrumentRunRows(lcmsTemplates['instrument-run'] as InstrumentRunTemplate)[2].sampleId).toBe('s001')
573
593
  expect(toTemplateDataFrame(lcmsTemplates['assay-matrix']).columns.map(column => column.key)).toContain('glucose')
574
594
  expect(toAssayMatrixSampleOptions(lcmsTemplates['assay-matrix'] as AssayMatrixTemplate).map(option => option.label)).toEqual(['S001', 'S002'])
595
+ expect(Object.keys(targetedTemplates)).toEqual([
596
+ 'sample-sheet',
597
+ 'sample-prep',
598
+ 'reagent-list',
599
+ 'instrument-run',
600
+ 'calibration-curve',
601
+ 'assay-matrix',
602
+ ])
603
+ expect(targeted.metadata?.preset).toBe('targeted-metabolomics')
604
+ expect(toReagentDataFrame(targetedTemplates['reagent-list'] as ReagentListTemplate).data[0].kind).toBe('compound')
605
+ expect(toTemplateDataFrame(targetedTemplates['calibration-curve']).data[0].role).toBe('blank')
606
+ expect(toInstrumentRunRows(targetedTemplates['instrument-run'] as InstrumentRunTemplate).map(row => row.kind)).toEqual([
607
+ 'blank',
608
+ 'standard',
609
+ 'standard',
610
+ 'standard',
611
+ 'qc',
612
+ 'qc',
613
+ 'sample',
614
+ 'sample',
615
+ ])
616
+ expect(toTemplateDataFrame(targetedTemplates['assay-matrix']).columns.map(column => column.key)).toContain('lactate')
575
617
  expect(Object.keys(flowTemplates)).toEqual(['sample-sheet', 'flow-cytometry-panel', 'assay-matrix'])
576
618
  expect(flow.metadata?.preset).toBe('flow-cytometry-assay')
577
619
  expect(flowTemplates['flow-cytometry-panel'].data).toMatchObject({
@@ -596,6 +638,7 @@ describe('bio data templates', () => {
596
638
  expect(Object.keys(extractTemplateCollection(aliasCollection))).toEqual(['plate-map', 'sample-sheet', 'dose-response'])
597
639
  expect(createBioTemplatePresetCollection('facs').metadata?.preset).toBe('flow-cytometry-assay')
598
640
  expect(createBioTemplatePresetCollection('immunoblot').metadata?.preset).toBe('western-blot-assay')
641
+ expect(createBioTemplatePresetCollection('targeted metabolomics').metadata?.preset).toBe('targeted-metabolomics')
599
642
  expect(() => createBioTemplatePresetCollection('unknown-preset')).toThrow("Unknown template preset 'unknown-preset'.")
600
643
  })
601
644
 
@@ -682,6 +725,49 @@ describe('bio data templates', () => {
682
725
  expect(run.data.items.map(item => item.kind)).toEqual(['blank', 'sample', 'sample'])
683
726
  expect(toTemplateDataFrame(lcmsTemplates['assay-matrix']).columns.map(column => column.key)).toContain('pyruvate')
684
727
 
728
+ const targetedOptions = bioTemplatePresetControlValuesToOptions('targeted-metabolomics', {
729
+ sampleNames: 'QC Pool, Patient A',
730
+ metaboliteNames: 'Glucose, Citrate',
731
+ internalStandards: '13C Glucose',
732
+ standardConcentrations: '0.5, 5, 50',
733
+ concentrationUnit: 'nM',
734
+ responseUnit: 'area ratio',
735
+ instrument: 'Triple quadrupole',
736
+ method: 'MRM panel',
737
+ outputVolume: '25',
738
+ includeQc: false,
739
+ })
740
+ const targetedTemplates = extractTemplateCollection(
741
+ createBioTemplatePresetCollectionFromControls('targeted-metabolomics', {
742
+ sampleNames: 'QC Pool, Patient A',
743
+ metaboliteNames: 'Glucose, Citrate',
744
+ internalStandards: '13C Glucose',
745
+ standardConcentrations: '0.5, 5, 50',
746
+ concentrationUnit: 'nM',
747
+ responseUnit: 'area ratio',
748
+ instrument: 'Triple quadrupole',
749
+ method: 'MRM panel',
750
+ outputVolume: '25',
751
+ includeQc: false,
752
+ }),
753
+ )
754
+ const targetedRun = targetedTemplates['instrument-run'] as InstrumentRunTemplate
755
+
756
+ expect(targetedOptions.metabolites).toEqual(['Glucose', 'Citrate'])
757
+ expect(targetedOptions.standardConcentrations).toEqual([0.5, 5, 50])
758
+ expect(targetedOptions.outputVolume).toBe(25)
759
+ expect(targetedRun.data.instrument).toBe('Triple quadrupole')
760
+ expect(targetedRun.data.methods[0].name).toBe('MRM panel')
761
+ expect(targetedRun.data.items.map(item => item.kind)).toEqual([
762
+ 'blank',
763
+ 'standard',
764
+ 'standard',
765
+ 'standard',
766
+ 'sample',
767
+ 'sample',
768
+ ])
769
+ expect(toTemplateDataFrame(targetedTemplates['assay-matrix']).columns.map(column => column.key)).toContain('citrate')
770
+
685
771
  const flowOptions = bioTemplatePresetControlValuesToOptions('flow-cytometry-assay', {
686
772
  sampleNames: 'Control, Treatment',
687
773
  markers: 'CD3, CD19',
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ estimateSequenceFinishDate,
4
+ estimateSequenceRemainingSeconds,
5
+ formatSequenceRemaining,
6
+ sequenceProgressPercent,
7
+ } from '../../instrument'
8
+ import type { SequenceProgress } from '../../types'
9
+
10
+ describe('instrument sequence helpers', () => {
11
+ const progress: SequenceProgress = {
12
+ current_sample: 3,
13
+ total_samples: 6,
14
+ elapsed_seconds: 180,
15
+ sample_durations: [50, 70, 60],
16
+ }
17
+
18
+ it('calculates percent and remaining time from sample durations', () => {
19
+ expect(sequenceProgressPercent(progress)).toBe(50)
20
+ expect(estimateSequenceRemainingSeconds(progress)).toBe(180)
21
+ expect(formatSequenceRemaining(progress)).toBe('~3m left')
22
+ })
23
+
24
+ it('falls back to elapsed average when sample durations are unavailable', () => {
25
+ expect(estimateSequenceRemainingSeconds({
26
+ current_sample: 2,
27
+ total_samples: 5,
28
+ elapsed_seconds: 120,
29
+ })).toBe(180)
30
+ })
31
+
32
+ it('uses explicit finish time or builds one from remaining seconds', () => {
33
+ const now = new Date('2026-05-15T12:00:00.000Z')
34
+
35
+ expect(estimateSequenceFinishDate(progress, now)?.toISOString()).toBe('2026-05-15T12:03:00.000Z')
36
+ expect(estimateSequenceFinishDate({
37
+ current_sample: 1,
38
+ total_samples: 2,
39
+ estimated_finish_time: '2026-05-15T13:00:00.000Z',
40
+ })?.toISOString()).toBe('2026-05-15T13:00:00.000Z')
41
+ })
42
+
43
+ it('clamps completed or over-complete sequences for display', () => {
44
+ expect(sequenceProgressPercent({ current_sample: 12, total_samples: 10 })).toBe(100)
45
+ expect(formatSequenceRemaining({ current_sample: 10, total_samples: 10 })).toBeNull()
46
+ })
47
+ })
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ basenameFromWindowsPath,
4
+ extractLcmsCommonPrefix,
5
+ extractLcmsSampleName,
6
+ inferLcmsPlateTypeFromWellIds,
7
+ lcmsWellIdFromPosition,
8
+ reconstructLcmsPlateCellsFromSequenceItems,
9
+ } from '../../lcms'
10
+
11
+ describe('lcms utilities', () => {
12
+ it('extracts common prefixes and sample names from Xcalibur file names', () => {
13
+ const prefix = extractLcmsCommonPrefix([
14
+ 'Batch_01_NEG_S1_001',
15
+ 'Batch_01_NEG_S2_002',
16
+ 'Batch_01_NEG_QC_003',
17
+ ])
18
+
19
+ expect(prefix).toBe('Batch_01')
20
+ expect(extractLcmsSampleName('Batch_01_NEG_S1_001', prefix)).toBe('S1')
21
+ })
22
+
23
+ it('infers plate type from well geometry', () => {
24
+ expect(lcmsWellIdFromPosition('R:A1')).toBe('A1')
25
+ expect(inferLcmsPlateTypeFromWellIds(['A1', 'F9'])).toBe('54vial')
26
+ expect(inferLcmsPlateTypeFromWellIds(['A1', 'H12'])).toBe('96well')
27
+ })
28
+
29
+ it('reconstructs plate cells from sequence items while skipping controls', () => {
30
+ const result = reconstructLcmsPlateCellsFromSequenceItems([
31
+ {
32
+ sample_type: 'Unknown',
33
+ file_name: 'Batch_NEG_S1_001',
34
+ sample_id: 'R:A1',
35
+ path: '',
36
+ instrument_method: 'C:\\Methods\\neg.meth',
37
+ position: 'R:A1',
38
+ injection_volume: 5,
39
+ },
40
+ {
41
+ sample_type: 'Unknown',
42
+ file_name: 'Batch_NEG_S2_002',
43
+ sample_id: 'R:H12',
44
+ path: '',
45
+ instrument_method: 'C:\\Methods\\neg.meth',
46
+ position: 'R:H12',
47
+ injection_volume: 5,
48
+ },
49
+ {
50
+ sample_type: 'Blank',
51
+ file_name: 'Batch_NEG_Blank_003',
52
+ sample_id: 'R:F9',
53
+ path: '',
54
+ instrument_method: 'C:\\Methods\\neg.meth',
55
+ position: 'R:F9',
56
+ injection_volume: 5,
57
+ },
58
+ ])
59
+
60
+ expect(result).toEqual({
61
+ plateType: '96well',
62
+ cells: [
63
+ { row: 'A', column: 1, sample_name: 'S1' },
64
+ { row: 'H', column: 12, sample_name: 'S2' },
65
+ ],
66
+ })
67
+ })
68
+
69
+ it('extracts basenames from Windows and POSIX paths', () => {
70
+ expect(basenameFromWindowsPath('C:\\Methods\\neg.meth')).toBe('neg.meth')
71
+ expect(basenameFromWindowsPath('/methods/pos.meth')).toBe('pos.meth')
72
+ })
73
+ })
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ canAccessAdmin,
4
+ canAccessByPolicy,
5
+ canAccessPlugin,
6
+ getAccessAudience,
7
+ hasAllPermissions,
8
+ hasAnyPermission,
9
+ isAdminUser,
10
+ } from '../../permissions'
11
+
12
+ describe('permission helpers', () => {
13
+ const member = {
14
+ role: 'member',
15
+ role_obj: {
16
+ slug: 'member',
17
+ permissions: ['experiments.view', 'plugins.configure'],
18
+ plugin_access: ['dose'],
19
+ },
20
+ }
21
+
22
+ it('treats the admin role as full access', () => {
23
+ const admin = { role: 'admin' }
24
+
25
+ expect(isAdminUser(admin)).toBe(true)
26
+ expect(getAccessAudience(admin)).toBe('admin')
27
+ expect(hasAllPermissions(admin, ['platform.configure'])).toBe(true)
28
+ expect(canAccessAdmin(admin)).toBe(true)
29
+ })
30
+
31
+ it('checks role permissions and plugin access for non-admin users', () => {
32
+ expect(isAdminUser(member)).toBe(false)
33
+ expect(getAccessAudience(member)).toBe('user')
34
+ expect(hasAllPermissions(member, ['experiments.view'])).toBe(true)
35
+ expect(hasAllPermissions(member, ['platform.configure'])).toBe(false)
36
+ expect(hasAnyPermission(member, ['platform.configure', 'plugins.configure'])).toBe(true)
37
+ expect(canAccessAdmin(member)).toBe(true)
38
+ expect(canAccessPlugin(member, 'dose')).toBe(true)
39
+ expect(canAccessPlugin(member, 'reports')).toBe(false)
40
+ })
41
+
42
+ it('evaluates access policies consistently', () => {
43
+ expect(canAccessByPolicy(member, { visibleFor: 'user' })).toBe(true)
44
+ expect(canAccessByPolicy(member, { visibleFor: 'admin' })).toBe(false)
45
+ expect(canAccessByPolicy(member, { permissions: ['experiments.view'] })).toBe(true)
46
+ expect(canAccessByPolicy(member, { anyPermissions: ['platform.configure', 'plugins.configure'] })).toBe(true)
47
+ expect(canAccessByPolicy(member, { requiresAdmin: true })).toBe(false)
48
+ expect(canAccessByPolicy(member, { plugin: 'reports' })).toBe(false)
49
+ })
50
+ })