@morscherlab/mint-sdk 1.3.0-beta.9 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
  2. package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
  3. package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
  4. package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
  5. package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
  6. package/dist/components/SmartGroup.types.d.ts +22 -0
  7. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
  8. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
  9. package/dist/components/index.js +1 -1
  10. package/dist/{components-GDDvof8D.js → components-bbanLd1u.js} +510 -287
  11. package/dist/components-bbanLd1u.js.map +1 -0
  12. package/dist/composables/autoGroup/classify.d.ts +7 -1
  13. package/dist/composables/autoGroup/columns.d.ts +16 -1
  14. package/dist/composables/autoGroup/index.d.ts +5 -7
  15. package/dist/composables/autoGroup/tokenize.d.ts +36 -1
  16. package/dist/composables/index.js +2 -2
  17. package/dist/composables/useAutoGroup.d.ts +13 -2
  18. package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
  19. package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
  20. package/dist/index.js +3 -3
  21. package/dist/install.js +1 -1
  22. package/dist/permissions.js +2 -2
  23. package/dist/permissions.js.map +1 -1
  24. package/dist/styles.css +129 -96
  25. package/dist/types/auto-group.d.ts +1 -1
  26. package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
  27. package/dist/validation-CukfLjy-.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/__tests__/components/AppTopBarPageSelector.test.ts +4 -4
  30. package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
  31. package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
  32. package/src/__tests__/components/SampleSelector.test.ts +14 -16
  33. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
  34. package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
  35. package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
  36. package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
  37. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
  38. package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
  39. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
  40. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
  41. package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
  42. package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
  43. package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
  44. package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
  45. package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
  46. package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
  47. package/src/__tests__/utils/permissions.test.ts +15 -0
  48. package/src/components/AutoGroupModal.adapter.ts +80 -9
  49. package/src/components/AutoGroupModal.vue +41 -8
  50. package/src/components/SmartGroup.types.ts +23 -0
  51. package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
  52. package/src/components/SmartGroupFieldRecipe.vue +289 -169
  53. package/src/components/internal/AppTopBarPageSelectorInternal.vue +3 -6
  54. package/src/composables/autoGroup/classify.ts +26 -7
  55. package/src/composables/autoGroup/columns.ts +144 -36
  56. package/src/composables/autoGroup/compose.ts +8 -10
  57. package/src/composables/autoGroup/fingerprint.ts +1 -1
  58. package/src/composables/autoGroup/index.ts +5 -12
  59. package/src/composables/autoGroup/tokenLength.ts +8 -0
  60. package/src/composables/autoGroup/tokenize.ts +148 -1
  61. package/src/composables/autoGroup/vocab.json +5 -11
  62. package/src/composables/useAutoGroup.ts +92 -79
  63. package/src/permissions.ts +3 -2
  64. package/src/styles/components/app-page-selector.css +58 -14
  65. package/src/styles/components/smart-group.css +71 -82
  66. package/src/types/auto-group.ts +1 -0
  67. package/dist/components-GDDvof8D.js.map +0 -1
  68. package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
  69. package/dist/validation-Dmp9Q5W0.js.map +0 -1
  70. package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { useAutoGroup } from '../../../composables/useAutoGroup'
3
+
4
+ function engineFor(samples: string[]) {
5
+ const engine = useAutoGroup()
6
+ engine.rawText.value = samples.join('\n')
7
+ engine.parseInput()
8
+ return engine
9
+ }
10
+
11
+ // Two tissue classes; fields 2-4 are factors in both.
12
+ const twoClasses = ['Liver', 'Kidney'].flatMap(tissue =>
13
+ ['A', 'B'].flatMap(first => ['C', 'D'].flatMap(second => ['E', 'F'].map(third => `${tissue}_${first}_${second}_${third}_rep1`))),
14
+ )
15
+ const designWithReplicates = ['WT', 'KO'].flatMap(genotype =>
16
+ ['Vehicle', 'Drug'].flatMap(treatment => ['1', '2', '3'].map(replicate => `${genotype}-${treatment}-${replicate}`)),
17
+ )
18
+
19
+ describe('useAutoGroup field edits', () => {
20
+ it('should name a column Field N when no name can be inferred', () => {
21
+ expect(engineFor(designWithReplicates).activeSchema.value?.columns[0].name).toBe('Field 1')
22
+ })
23
+
24
+ it('should rename the field in every class when renaming by field key', () => {
25
+ const engine = engineFor(twoClasses)
26
+ engine.renameGroupByField('Field 3', 'Dose')
27
+ const names = Object.values(engine.schemas.value).map(schema => schema.columns[2].displayName)
28
+ expect(names).toEqual(['Dose', 'Dose'])
29
+ })
30
+
31
+ it('should keep the old name when another field already has the new name', () => {
32
+ const engine = engineFor(twoClasses)
33
+ engine.renameGroupByField('Field 3', 'Field 2')
34
+ expect(engine.activeSchema.value?.columns[2].displayName).toBeUndefined()
35
+ })
36
+
37
+ it('should keep the old name when the new name is blank', () => {
38
+ const engine = engineFor(twoClasses)
39
+ engine.renameGroupByField('Field 3', ' ')
40
+ expect(engine.activeSchema.value?.columns[2].displayName).toBeUndefined()
41
+ })
42
+
43
+ it('should use the new name in the sample metadata when a field is renamed', () => {
44
+ const engine = engineFor(designWithReplicates)
45
+ engine.renameGroupByField('Field 1', 'Genotype')
46
+ expect(engine.result.value.metadata[0].fields.Genotype).toBe('WT')
47
+ })
48
+
49
+ it('should report the number of groups that remain when a field is switched off', () => {
50
+ expect(engineFor(designWithReplicates).groupCountWithout('Field 2')).toBe(2)
51
+ })
52
+
53
+ it('should leave the grouping unchanged when it reports the count for a field', () => {
54
+ const engine = engineFor(designWithReplicates)
55
+ engine.groupCountWithout('Field 2')
56
+ expect(engine.result.value.experimentalGroups).toHaveLength(4)
57
+ })
58
+ })
59
+
60
+ describe('useAutoGroup field edits across classes', () => {
61
+ // Two token-length classes. `Field 2` is a factor in the short names and a
62
+ // constant in the long names, which have their own factor `Field 3`.
63
+ const twoLengths = [
64
+ 'Liver_A_rep1', 'Liver_A_rep2', 'Liver_B_rep1', 'Liver_B_rep2',
65
+ 'Liver_c_P_rep1', 'Liver_c_P_rep2', 'Liver_c_Q_rep1', 'Liver_c_Q_rep2',
66
+ ]
67
+
68
+ it('should keep the old name when a field of another class already has the new name', () => {
69
+ const engine = engineFor(twoLengths)
70
+ engine.renameGroupByField('Field 2', 'Field 3')
71
+ const renamed = Object.values(engine.schemas.value).flatMap(schema => schema.columns).filter(c => c.displayName)
72
+ expect(renamed).toEqual([])
73
+ })
74
+ })
@@ -120,3 +120,12 @@ describe('fingerprint round-trip', () => {
120
120
  expect(five?.columns).toHaveLength(2)
121
121
  })
122
122
  })
123
+
124
+ describe('fingerprint of an overlay class', () => {
125
+ it('should restore a snapshot that holds a token length when the current overlay class has none', () => {
126
+ const column = { index: 0, name: 'A', sourceIndices: [0], uniqueValues: ['x'], cardinality: 1, role: 'constant' as const }
127
+ const saved = serializeFingerprint([{ classKind: 'iqc', tokenLength: 5, columns: [column], groupBy: [] }])
128
+ const restored = restoreFingerprint(saved, [{ classKind: 'iqc', columns: [column], groupBy: [] }])
129
+ expect(restored).toHaveLength(1)
130
+ })
131
+ })
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { useAutoGroup } from '../../../composables/useAutoGroup'
5
+
6
+ // End-to-end defaults of the paste path: sample names in, group names and sizes
7
+ // out. Fixtures `share-*.txt` are real batches from the lab's mass-spec share.
8
+
9
+ function engineFor(samples: string[]) {
10
+ const engine = useAutoGroup()
11
+ engine.rawText.value = samples.join('\n')
12
+ engine.parseInput()
13
+ return engine
14
+ }
15
+
16
+ function sizes(groups: { name: string; samples: string[] }[] | undefined): Record<string, number> {
17
+ return Object.fromEntries((groups ?? []).map(group => [group.name, group.samples.length]))
18
+ }
19
+
20
+ const experimental = (samples: string[]) => sizes(engineFor(samples).result.value.experimentalGroups)
21
+ const qc = (samples: string[]) => sizes(engineFor(samples).result.value.qcGroups)
22
+
23
+ function fixture(name: string): string[] {
24
+ return readFileSync(resolve(__dirname, `../../fixtures/auto-group/${name}.txt`), 'utf-8').trim().split('\n')
25
+ }
26
+
27
+ const cross = (a: string[], b: string[], c: string[], sep: string): string[] =>
28
+ a.flatMap(x => b.flatMap(y => c.map(z => [x, y, z].join(sep))))
29
+
30
+ const liverBatch = [
31
+ 'Ctrl_Liver_D1_Rep1', 'Ctrl_Liver_D1_Rep2',
32
+ 'Treat_Liver_D3_Rep1', 'Treat_Liver_D3_Rep2',
33
+ 'Batch_IQC_Rep1', 'Batch_IQC_Rep2', 'Batch_IQC_Rep3',
34
+ ]
35
+
36
+ const tracingBatch = [
37
+ 'LT_13102025_212_WT_Glu_3_20_S_091123_T1_33', 'LT_13102025_213_WT_Glu_3_20_S_091123_T1_34',
38
+ 'LT_13102025_214_KI_Glu_3_20_S_091123_T1_35', 'LT_13102025_215_KI_Glu_6_40_L_091123_T1_36',
39
+ 'LT_13102025_216_WT_Glu_6_40_L_091123_T1_37', 'LT_13102025_217_WT_Glu_6_40_L_091123_T2_38',
40
+ 'LT_13102025_218_KI_Glu_3_20_S_091123_T2_39', 'LT_13102025_219_KI_Glu_6_40_L_091123_T2_40',
41
+ 'LT_13102025_220_WT_Glu_3_20_S_091123_T2_41', 'LT_13102025_221_WT_Glu_6_40_L_091123_T2_42',
42
+ 'LT_13102025_EQC_Jurkat_1_2', 'LT_13102025_EQC_Jurkat_2_8', 'LT_13102025_IQC_Pool_1_5',
43
+ 'test_EQC_Jurkat_02', 'test_Blank_Run_01',
44
+ ]
45
+
46
+ const twoMatrices = [
47
+ 'Ctrl_Liver_D1_Rep1', 'Ctrl_Liver_D1_Rep2', 'Treat_Liver_D3_Rep1', 'Treat_Liver_D3_Rep2',
48
+ 'QC_Pool', 'Blank', 'ISTD_Mix',
49
+ 'Ctrl_Brain_D1_Rep1', 'Ctrl_Brain_D1_Rep2',
50
+ ]
51
+
52
+ describe('auto-group defaults: replicates', () => {
53
+ it('should keep a trailing counter out of the group key when it repeats', () => {
54
+ expect(experimental(cross(['WT', 'KO'], ['Vehicle', 'Drug'], ['1', '2', '3'], '-'))).toEqual({
55
+ 'WT / Vehicle': 3, 'WT / Drug': 3, 'KO / Vehicle': 3, 'KO / Drug': 3,
56
+ })
57
+ })
58
+
59
+ it('should keep a trailing letter counter out of the group key when it repeats', () => {
60
+ expect(experimental(cross(['WT', 'KO'], ['Vehicle', 'Drug'], ['A', 'B', 'C'], '_'))).toEqual({
61
+ 'WT / Vehicle': 3, 'WT / Drug': 3, 'KO / Vehicle': 3, 'KO / Drug': 3,
62
+ })
63
+ })
64
+
65
+ it('should keep sample numbers, replicate tokens and injection numbers out of the group key when a name carries all three', () => {
66
+ expect(experimental(tracingBatch)).toEqual({
67
+ 'WT / 3 / 20 / S': 3, 'KI / 3 / 20 / S': 2, 'KI / 6 / 40 / L': 2, 'WT / 6 / 40 / L': 3,
68
+ })
69
+ })
70
+
71
+ it('should report original sample names in metadata when replicates collapse', () => {
72
+ expect(engineFor(liverBatch).result.value.metadata.map(row => row.sampleName)).toEqual(liverBatch)
73
+ })
74
+
75
+ it('should report original sample names as excluded when a class is excluded', () => {
76
+ const engine = engineFor(liverBatch)
77
+ engine.setClassDispositions(c => c.kind === 'iqc', 'exclude')
78
+ expect(engine.result.value.excludedSamples).toEqual(['Batch_IQC_Rep1', 'Batch_IQC_Rep2', 'Batch_IQC_Rep3'])
79
+ })
80
+
81
+ it('should count each QC replicate as a class member when QC names differ only by replicate', () => {
82
+ const iqc = engineFor(liverBatch).classes.value.find(c => c.kind === 'iqc')
83
+ expect(iqc?.members.length).toBe(3)
84
+ })
85
+
86
+ it('should fill each pre-filled template row with the tokens of its own sample when replicates collapse', () => {
87
+ const { content } = engineFor(liverBatch).composeTemplateForTest({ mode: 'prefilled', format: 'csv' })
88
+ const row = content.split('\n').find(line => line.startsWith('Treat_Liver_D3_Rep1,'))
89
+ expect(row).toContain('Treat / D3')
90
+ })
91
+ })
92
+
93
+ describe('auto-group defaults: classes', () => {
94
+ it('should route bare QC and ISTD tokens to overlay classes when a name carries them', () => {
95
+ expect(Object.keys(qc(twoMatrices)).sort()).toEqual(['Blank', 'IQC', 'Standard'])
96
+ })
97
+
98
+ it('should start each group name with the class layer when the batch has two matrices', () => {
99
+ expect(experimental(twoMatrices)).toEqual({ 'Liver / Ctrl / D1': 2, 'Liver / Treat / D3': 2, 'Brain': 2 })
100
+ })
101
+
102
+ it('should keep one overlay group when a QC class has two token lengths', () => {
103
+ expect(qc(tracingBatch)).toEqual({ EQC: 3, IQC: 1, Blank: 1 })
104
+ })
105
+
106
+ it('should not name a column Username when it holds two genotypes', () => {
107
+ const engine = engineFor(cross(['WT', 'KO'], ['Vehicle', 'Drug'], ['1', '2', '3'], '-'))
108
+ expect(engine.activeSchema.value?.columns.map(column => column.name)).not.toContain('Username')
109
+ })
110
+ })
111
+
112
+ describe('auto-group defaults: tokenizer', () => {
113
+ const pairs = { WT: 2, KO: 2 }
114
+
115
+ it('should drop a file extension when all names share it', () => {
116
+ expect(experimental(['WT_Ctrl_01.raw', 'WT_Ctrl_02.raw', 'KO_Ctrl_03.raw', 'KO_Ctrl_04.raw'])).toEqual(pairs)
117
+ })
118
+
119
+ it('should split on spaces when no other delimiter is present', () => {
120
+ expect(experimental(['WT Ctrl 1', 'WT Ctrl 2', 'KO Ctrl 1', 'KO Ctrl 2'])).toEqual(pairs)
121
+ })
122
+
123
+ it('should read one delimiter when it repeats', () => {
124
+ expect(experimental(['WT__Ctrl_1', 'WT__Ctrl_2', 'KO__Ctrl_1', 'KO__Ctrl_2'])).toEqual(pairs)
125
+ })
126
+
127
+ it('should report the experiment header when all names share it', () => {
128
+ expect(engineFor(fixture('share-exp031')).experiment.value).toEqual({
129
+ code: 'exp031', date: '2025-03-18', operator: 'SU',
130
+ })
131
+ })
132
+ })
133
+
134
+ describe('auto-group defaults: lab share batches', () => {
135
+ it('should group exp031 into 8 conditions when the replicate is a letter', () => {
136
+ expect(experimental(fixture('share-exp031'))).toEqual({
137
+ '105 / DMSO': 4, '105 / TFP': 4, '270 / DMSO': 4, '270 / TFP': 4,
138
+ 'SF8628 / DMSO': 4, 'SF8628 / TFP': 4, 'SU-VI / DMSO': 4, 'SU-VI / TFP': 4,
139
+ })
140
+ })
141
+
142
+ it('should group exp114 by condition when a token holds condition and replicate', () => {
143
+ expect(experimental(fixture('share-exp114'))['HMW / 2']).toBe(3)
144
+ })
145
+
146
+ it('should group exp214 by stem when a counter is attached to it', () => {
147
+ expect(experimental(fixture('share-exp214'))['D1 / A']).toBe(3)
148
+ })
149
+
150
+ it('should group exp021 by time point when it has more than five levels', () => {
151
+ expect(Object.keys(experimental(fixture('share-exp021')))).toContain('inf2 / 1 / 0min')
152
+ })
153
+
154
+ it('should keep the label token B13 as a column when exp021 is parsed', () => {
155
+ const schemas = Object.values(engineFor(fixture('share-exp021')).schemas.value)
156
+ expect(schemas.flatMap(schema => schema.columns.flatMap(column => column.uniqueValues))).toContain('B13')
157
+ })
158
+
159
+ it('should read no QC class when the word is in the experiment title that all names share', () => {
160
+ const names = cross(['WT', 'KO'], ['Vehicle', 'Drug'], ['1', '2', '3'], '_')
161
+ .map(body => `exp137_250919_AK_betaine_std_HILIC_${body}`)
162
+ expect(qc(names)).toEqual({})
163
+ })
164
+ })
165
+
166
+ describe('auto-group defaults: second review', () => {
167
+ it('should keep a QC-only batch as one overlay when QC sits where the operator is expected', () => {
168
+ expect(qc(['exp021_250101_QC_1', 'exp021_250101_QC_2'])).toEqual({ IQC: 2 })
169
+ })
170
+
171
+ it('should keep a QC-only batch as one overlay when all names share the QC token after the header', () => {
172
+ expect(qc(['exp021_250101_SU_QC_1', 'exp021_250101_SU_QC_2'])).toEqual({ IQC: 2 })
173
+ })
174
+
175
+ it('should group by a bare-number dose when it is the last token of three samples', () => {
176
+ expect(experimental(['Drug_1', 'Drug_5', 'Drug_10'])).toEqual({ '1': 1, '5': 1, '10': 1 })
177
+ })
178
+
179
+ it('should drop known file extensions when the names carry two different ones', () => {
180
+ expect(experimental(['WT_Ctrl_1.raw', 'WT_Ctrl_2.mzML', 'KO_Ctrl_1.raw', 'KO_Ctrl_2.mzML'])).toEqual({ WT: 2, KO: 2 })
181
+ })
182
+
183
+ it('should leave a letter counter out of the group key when it sits before an unpadded injection number', () => {
184
+ const names = cross(['WT', 'KO'], ['A', 'B', 'C'], ['x'], '_').map((name, i) => name.replace('_x', `_${i + 5}`))
185
+ expect(experimental(names)).toEqual({ WT: 3, KO: 3 })
186
+ })
187
+ })
@@ -111,7 +111,7 @@ describe('useAutoGroup — field edits span every class exposing the field', ()
111
111
 
112
112
  it('should reorder the layer in every class when moving a field by name', () => {
113
113
  const auto = twoClassEngine()
114
- auto.moveGroupByField('Token 3', 'up')
114
+ auto.moveGroupByField('Field 3', 'up')
115
115
  expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2, 1], [2, 1]])
116
116
  })
117
117
 
@@ -120,7 +120,7 @@ describe('useAutoGroup — field edits span every class exposing the field', ()
120
120
  const activeKey = auto.activeClassKey.value
121
121
  auto.toggleGroupBy(activeKey, 2)
122
122
 
123
- auto.moveGroupByField('Token 3', 'up')
123
+ auto.moveGroupByField('Field 3', 'up')
124
124
 
125
125
  expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([[1], [2, 1]])
126
126
  })
@@ -131,7 +131,7 @@ describe('useAutoGroup — field edits span every class exposing the field', ()
131
131
  auto.moveGroupBy(inactiveKey, 3, 'up')
132
132
  auto.moveGroupBy(inactiveKey, 3, 'up')
133
133
 
134
- auto.moveGroupByField('Token 4', 'up')
134
+ auto.moveGroupByField('Field 4', 'up')
135
135
 
136
136
  expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([
137
137
  [1, 3, 2],
@@ -162,14 +162,14 @@ describe('useAutoGroup — field edits span every class exposing the field', ()
162
162
  },
163
163
  }
164
164
 
165
- auto.moveGroupByField('Token 4', 'up')
165
+ auto.moveGroupByField('Field 4', 'up')
166
166
 
167
167
  expect(auto.schemas.value[inactiveKey].groupBy).toEqual([1, 3, 2, 99])
168
168
  })
169
169
 
170
170
  it('should compose consistently ordered names across classes after a field move', () => {
171
171
  const auto = twoClassEngine()
172
- auto.moveGroupByField('Token 3', 'up')
172
+ auto.moveGroupByField('Field 3', 'up')
173
173
  expect(auto.groups.value.map(g => g.name).sort()).toEqual([
174
174
  'Kidney / High / 10min',
175
175
  'Kidney / High / 5min',
@@ -218,8 +218,8 @@ describe('useAutoGroup — field edits span every class exposing the field', ()
218
218
  },
219
219
  }
220
220
 
221
- auto.toggleGroupByField('Token 3')
222
- auto.toggleGroupByField('Token 3')
221
+ auto.toggleGroupByField('Field 3')
222
+ auto.toggleGroupByField('Field 3')
223
223
 
224
224
  expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([[1], [1, 2]])
225
225
  })
@@ -83,3 +83,10 @@ describe('splitByTokenLength', () => {
83
83
  expect(out.map(c => c.label)).toEqual(['Unknown', 'Biological / Plasma'])
84
84
  })
85
85
  })
86
+
87
+ describe('splitByTokenLength for QC-kind classes', () => {
88
+ it('should keep a QC-kind class whole when its members have two token lengths', () => {
89
+ const iqc = cls({ kind: 'iqc', label: 'IQC', subKind: undefined, members: [0, 1] })
90
+ expect(splitByTokenLength([iqc], [['IQC', '1'], ['x', 'IQC', '2']])).toHaveLength(1)
91
+ })
92
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { splitMulti, pickPrimaryDelimiter } from '../../../composables/autoGroup/tokenize'
2
+ import { splitMulti, pickPrimaryDelimiter, parseDateToken, tokenizeBatch } from '../../../composables/autoGroup/tokenize'
3
3
 
4
4
  describe('pickPrimaryDelimiter', () => {
5
5
  it('should pick underscore when underscore field counts are most consistent', () => {
@@ -31,3 +31,80 @@ describe('splitMulti', () => {
31
31
  expect(splitMulti('', '_')).toEqual([''])
32
32
  })
33
33
  })
34
+
35
+ describe('parseDateToken', () => {
36
+ it('should read the date when the token is YYMMDD', () => {
37
+ expect(parseDateToken('250919')).toBe('2025-09-19')
38
+ })
39
+
40
+ it('should read the date when the token is YYYYMMDD', () => {
41
+ expect(parseDateToken('20250211')).toBe('2025-02-11')
42
+ })
43
+
44
+ it('should read DDMMYYYY when the first four digits are not a year', () => {
45
+ expect(parseDateToken('13102025')).toBe('2025-10-13')
46
+ })
47
+
48
+ it('should read DDMMYY when the year-first reading is outside the plausible window', () => {
49
+ expect(parseDateToken('091123')).toBe('2023-11-09')
50
+ })
51
+
52
+ it('should return undefined when the token is not a date', () => {
53
+ expect(parseDateToken('999999')).toBeUndefined()
54
+ })
55
+ })
56
+
57
+ describe('tokenizeBatch', () => {
58
+ it('should move the header out of the rows when all names share it', () => {
59
+ const batch = tokenizeBatch(['exp031_250318_SU_105_DMSO_A_23', 'exp031_250318_SU_270_TFP_B_09'])
60
+ expect(batch.rows).toEqual([['105', 'DMSO', 'A', '23'], ['270', 'TFP', 'B', '09']])
61
+ })
62
+
63
+ it('should read the header when the operator is before the date', () => {
64
+ const batch = tokenizeBatch(['exp157_LT_13102025_214_KI_13', 'exp157_LT_13102025_215_WT_35'])
65
+ expect(batch.experiment).toEqual({ code: 'exp157', date: '2025-10-13', operator: 'LT' })
66
+ })
67
+
68
+ it('should keep a header field in the row when the batch has two values', () => {
69
+ const batch = tokenizeBatch(['exp280_260413_YS_Plasma_M10_031', 'exp281_260413_YS_Tissues_M10_085'])
70
+ expect(batch.rows[0]).toEqual(['exp280', 'Plasma', 'M10', '031'])
71
+ })
72
+
73
+ it('should read no header when a name has no experiment code and no date', () => {
74
+ expect(tokenizeBatch(['WT_Ctrl_1', 'KO_Ctrl_2']).rows[0]).toEqual(['WT', 'Ctrl', '1'])
75
+ })
76
+
77
+ it('should drop the last token when it is a 14-digit acquisition stamp', () => {
78
+ const batch = tokenizeBatch(['WT_blank_011_20251003200301', 'KO_blank_012'])
79
+ expect(batch.rows[0]).toEqual(['WT', 'blank', '011'])
80
+ })
81
+
82
+ it('should count the shared tokens when all names share a title after the header', () => {
83
+ const batch = tokenizeBatch(['exp114_250730_YY_HEK_Put_HMW_1-1_35', 'exp114_250730_YY_HEK_Put_MMW_2-1_36'])
84
+ expect(batch.sharedPrefix).toBe(2)
85
+ })
86
+
87
+ it('should keep a decimal point inside a token when the delimiter is an underscore', () => {
88
+ expect(tokenizeBatch(['exp_drugA_0.1uM', 'exp_drugB_1uM']).rows[0]).toEqual(['exp', 'drugA', '0.1uM'])
89
+ })
90
+ })
91
+
92
+ describe('tokenizeBatch review regressions', () => {
93
+ it('should keep a caps token in the row when only some names carry it after the date', () => {
94
+ const batch = tokenizeBatch(['exp021_250101_QC_1', 'exp021_250101_QC_2', 'exp021_250101_Ctrl_1', 'exp021_250101_Ctrl_2'])
95
+ expect(batch.rows[0]).toEqual(['QC', '1'])
96
+ })
97
+
98
+ it('should keep rows aligned when one genotype looks like operator initials', () => {
99
+ const batch = tokenizeBatch(['exp021_250101_WT_1', 'exp021_250101_WT_2', 'exp021_250101_Het_1', 'exp021_250101_Het_2'])
100
+ expect(batch.rows.map(row => row.length)).toEqual([2, 2, 2, 2])
101
+ })
102
+
103
+ it('should count no shared prefix when the names have no experiment header', () => {
104
+ expect(tokenizeBatch(['QC_01', 'QC_02', 'QC_03']).sharedPrefix).toBe(0)
105
+ })
106
+
107
+ it('should reject a date that is not in the calendar when the token has six digits', () => {
108
+ expect(parseDateToken('250231')).toBeUndefined()
109
+ })
110
+ })
@@ -346,8 +346,9 @@ describe('useAutoGroup auto-disable degenerate columns', () => {
346
346
  ].join('\n')
347
347
  auto.parseInput()
348
348
 
349
+ // Replicate columns are visible fields too, but they are off by default.
349
350
  const goodCols = auto.fields.value.filter(
350
- f => f.cardinality > 1 && f.cardinality < auto.samples.value.length,
351
+ f => f.role !== 'replicate' && f.cardinality > 1 && f.cardinality < auto.samples.value.length,
351
352
  )
352
353
  for (const col of goodCols) {
353
354
  expect(auto.enabledFields.value.has(col.index)).toBe(true)
@@ -0,0 +1,103 @@
1
+ exp021_20250305_AK_blank_001
2
+ exp021_20250305_AK_blank_005
3
+ exp021_20250305_AK_blank_009
4
+ exp021_20250305_AK_blank_013
5
+ exp021_20250305_AK_blank_017
6
+ exp021_20250305_AK_blank_021
7
+ exp021_20250305_AK_blank_025
8
+ exp021_20250305_AK_blank_029
9
+ exp021_20250305_AK_blank_033
10
+ exp021_20250305_AK_blank_037
11
+ exp021_20250305_AK_blank_041
12
+ exp021_20250305_AK_blank_045
13
+ exp021_20250305_AK_blank_049
14
+ exp021_20250305_AK_blank_053
15
+ exp021_20250305_AK_blank_057
16
+ exp021_20250305_AK_blank_061
17
+ exp021_20250305_AK_blank_065
18
+ exp021_20250305_AK_blank_066
19
+ exp021_20250305_AK_ksEx5_inf2_B13_1_0min_022
20
+ exp021_20250305_AK_ksEx5_inf2_B13_1_120min_052
21
+ exp021_20250305_AK_ksEx5_inf2_B13_1_20min_042
22
+ exp021_20250305_AK_ksEx5_inf2_B13_1_240min_015
23
+ exp021_20250305_AK_ksEx5_inf2_B13_1_360min_031
24
+ exp021_20250305_AK_ksEx5_inf2_B13_1_40min_059
25
+ exp021_20250305_AK_ksEx5_inf2_B13_2_0min_050
26
+ exp021_20250305_AK_ksEx5_inf2_B13_2_120min_016
27
+ exp021_20250305_AK_ksEx5_inf2_B13_2_20min_006
28
+ exp021_20250305_AK_ksEx5_inf2_B13_2_240min_035
29
+ exp021_20250305_AK_ksEx5_inf2_B13_2_360min_038
30
+ exp021_20250305_AK_ksEx5_inf2_B13_2_40min_055
31
+ exp021_20250305_AK_ksEx5_inf2_B13_6_0min_036
32
+ exp021_20250305_AK_ksEx5_inf2_B13_6_120min_002
33
+ exp021_20250305_AK_ksEx5_inf2_B13_6_20min_034
34
+ exp021_20250305_AK_ksEx5_inf2_B13_6_240min_039
35
+ exp021_20250305_AK_ksEx5_inf2_B13_6_360min_010
36
+ exp021_20250305_AK_ksEx5_inf2_B13_6_40min_043
37
+ exp021_20250305_AK_ksEx5_inf2_B13_7_0min_062
38
+ exp021_20250305_AK_ksEx5_inf2_B13_7_120min_028
39
+ exp021_20250305_AK_ksEx5_inf2_B13_7_20min_058
40
+ exp021_20250305_AK_ksEx5_inf2_B13_7_240min_018
41
+ exp021_20250305_AK_ksEx5_inf2_B13_7_360min_026
42
+ exp021_20250305_AK_ksEx5_inf2_B13_7_40min_060
43
+ exp021_20250305_AK_ksEx5_inf3_B13_1_0min_019
44
+ exp021_20250305_AK_ksEx5_inf3_B13_1_120min_032
45
+ exp021_20250305_AK_ksEx5_inf3_B13_1_20min_064
46
+ exp021_20250305_AK_ksEx5_inf3_B13_1_240min_024
47
+ exp021_20250305_AK_ksEx5_inf3_B13_1_360min_014
48
+ exp021_20250305_AK_ksEx5_inf3_B13_1_40min_040
49
+ exp021_20250305_AK_ksEx5_inf3_B13_2_0min_063
50
+ exp021_20250305_AK_ksEx5_inf3_B13_2_120min_012
51
+ exp021_20250305_AK_ksEx5_inf3_B13_2_20min_047
52
+ exp021_20250305_AK_ksEx5_inf3_B13_2_240min_007
53
+ exp021_20250305_AK_ksEx5_inf3_B13_2_360min_023
54
+ exp021_20250305_AK_ksEx5_inf3_B13_2_40min_003
55
+ exp021_20250305_AK_ksEx5_inf3_B13_6_0min_004
56
+ exp021_20250305_AK_ksEx5_inf3_B13_6_120min_044
57
+ exp021_20250305_AK_ksEx5_inf3_B13_6_20min_030
58
+ exp021_20250305_AK_ksEx5_inf3_B13_6_240min_048
59
+ exp021_20250305_AK_ksEx5_inf3_B13_6_360min_011
60
+ exp021_20250305_AK_ksEx5_inf3_B13_6_40min_054
61
+ exp021_20250305_AK_ksEx5_inf3_B13_7_0min_046
62
+ exp021_20250305_AK_ksEx5_inf3_B13_7_120min_020
63
+ exp021_20250305_AK_ksEx5_inf3_B13_7_20min_056
64
+ exp021_20250305_AK_ksEx5_inf3_B13_7_240min_008
65
+ exp021_20250305_AK_ksEx5_inf3_B13_7_360min_051
66
+ exp021_20250305_AK_ksEx5_inf3_B13_7_40min_027
67
+ ksEx5_inf2_B13_1_120min
68
+ ksEx5_inf2_S13_8_120min
69
+ ksEx5_inf2_S13_8_120min__MS1_AGC100_70-1000_02
70
+ ksEx5_inf2_S13_8_120min_MS1_AGC100_70-500_03
71
+ ksEx5_inf2_S13_8_120min_MS1_AGC100_70-500_shape_g_04
72
+ ksEx5_inf2_S13_8_120min_MS1_AGC100_70-500_stead_g_05
73
+ ksEx7a_Betaine_10nM_std_in_442
74
+ ksEx7a_Betaine_Valine_Mix_10nM_std_in_442
75
+ ksEx7a_Blank_442
76
+ ksEx7a_FullLabBetaine_10nM_std_in_442
77
+ ksEx7a_FullLabBetaine_Valine_Mix_10nM_std_in_442
78
+ ksEx7a_Valine_10nM_std_in_442
79
+ ksEx7b_Betaine_10nM_std_in_442_MS1_AGC100_70-1000_02
80
+ ksEx7b_Betaine_10nM_std_in_442_MS1_AGC100_70-500_03
81
+ ksEx7b_Betaine_10nM_std_in_442_MS1_AGC100_70-500_shape_g_04
82
+ ksEx7b_Betaine_10nM_std_in_442_MS1_AGC100_70-500_stead_g_05
83
+ ksEx7b_Betaine_Valine_Mix_10nM_std_in_442__MS1_AGC100_70-1000_02
84
+ ksEx7b_Betaine_Valine_Mix_10nM_std_in_442_MS1_AGC100_70-500_03
85
+ ksEx7b_Betaine_Valine_Mix_10nM_std_in_442_MS1_AGC100_70-500_shape_g_04
86
+ ksEx7b_Betaine_Valine_Mix_10nM_std_in_442_MS1_AGC100_70-500_stead_g_05
87
+ ksEx7b_Blank_442_02
88
+ ksEx7b_Blank_442_03
89
+ ksEx7b_Blank_442_04
90
+ ksEx7b_Blank_442_05
91
+ ksEx7b_Blank_442_06
92
+ ksEx7b_FullLabBetaine_10nM_std_in_442__MS1_AGC100_70-1000_02
93
+ ksEx7b_FullLabBetaine_10nM_std_in_442_MS1_AGC100_70-500_03
94
+ ksEx7b_FullLabBetaine_10nM_std_in_442_MS1_AGC100_70-500_shape_g_04
95
+ ksEx7b_FullLabBetaine_10nM_std_in_442_MS1_AGC100_70-500_stead_g_05
96
+ ksEx7b_FullLabBetaine_Valine_Mix_10nM_std_in_442__MS1_AGC100_70-1000_02
97
+ ksEx7b_FullLabBetaine_Valine_Mix_10nM_std_in_442_MS1_AGC100_70-500_03
98
+ ksEx7b_FullLabBetaine_Valine_Mix_10nM_std_in_442_MS1_AGC100_70-500_shape_g_04
99
+ ksEx7b_FullLabBetaine_Valine_Mix_10nM_std_in_442_MS1_AGC100_70-500_stead_g_05
100
+ ksEx7b_Valine_10nM_std_in_442__MS1_AGC100_70-1000_02
101
+ ksEx7b_Valine_10nM_std_in_442_MS1_AGC100_70-500_03
102
+ ksEx7b_Valine_10nM_std_in_442_MS1_AGC100_70-500_shape_g_04
103
+ ksEx7b_Valine_10nM_std_in_442_MS1_AGC100_70-500_stead_g_05
@@ -0,0 +1,39 @@
1
+ exp031_250318_SU_105_media_DMSO_72_A_23
2
+ exp031_250318_SU_105_media_DMSO_72_B_17
3
+ exp031_250318_SU_105_media_DMSO_72_C_33
4
+ exp031_250318_SU_105_media_DMSO_72_D_02
5
+ exp031_250318_SU_105_media_TFP_72_A_20
6
+ exp031_250318_SU_105_media_TFP_72_B_34
7
+ exp031_250318_SU_105_media_TFP_72_C_30
8
+ exp031_250318_SU_105_media_TFP_72_D_18
9
+ exp031_250318_SU_270_media_DMSO_72_A_35
10
+ exp031_250318_SU_270_media_DMSO_72_B_11
11
+ exp031_250318_SU_270_media_DMSO_72_C_05
12
+ exp031_250318_SU_270_media_DMSO_72_D_37
13
+ exp031_250318_SU_270_media_TFP_72_A_09
14
+ exp031_250318_SU_270_media_TFP_72_B_31
15
+ exp031_250318_SU_270_media_TFP_72_C_13
16
+ exp031_250318_SU_270_media_TFP_72_D_21
17
+ exp031_250318_SU_blank_01
18
+ exp031_250318_SU_blank_06
19
+ exp031_250318_SU_blank_10
20
+ exp031_250318_SU_blank_12
21
+ exp031_250318_SU_blank_24
22
+ exp031_250318_SU_blank_32
23
+ exp031_250318_SU_blank_39
24
+ exp031_250318_SU_SF8628_media_DMSO_72_A_16
25
+ exp031_250318_SU_SF8628_media_DMSO_72_B_03
26
+ exp031_250318_SU_SF8628_media_DMSO_72_C_14
27
+ exp031_250318_SU_SF8628_media_DMSO_72_D_19
28
+ exp031_250318_SU_SF8628_media_TFP_72_A_28
29
+ exp031_250318_SU_SF8628_media_TFP_72_B_38
30
+ exp031_250318_SU_SF8628_media_TFP_72_C_08
31
+ exp031_250318_SU_SF8628_media_TFP_72_D_36
32
+ exp031_250318_SU_SU-VI_media_DMSO_72_A_26
33
+ exp031_250318_SU_SU-VI_media_DMSO_72_B_27
34
+ exp031_250318_SU_SU-VI_media_DMSO_72_C_29
35
+ exp031_250318_SU_SU-VI_media_DMSO_72_D_15
36
+ exp031_250318_SU_SU-VI_media_TFP_72_A_22
37
+ exp031_250318_SU_SU-VI_media_TFP_72_B_07
38
+ exp031_250318_SU_SU-VI_media_TFP_72_C_25
39
+ exp031_250318_SU_SU-VI_media_TFP_72_D_04