@morscherlab/mint-sdk 1.0.38 → 1.0.41

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 (62) hide show
  1. package/dist/{ExperimentPopover-DEzCbTqo.js → ExperimentPopover-8A4Rhffp.js} +1 -1
  2. package/dist/{ExperimentPopover-mzmSfAUp.js → ExperimentPopover-BbPkIFsI.js} +8 -2
  3. package/dist/ExperimentPopover-BbPkIFsI.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-Bn0Hmg07.js → ExperimentSelectorModal-B2qek_YG.js} +91 -46
  5. package/dist/ExperimentSelectorModal-B2qek_YG.js.map +1 -0
  6. package/dist/{ExperimentSelectorModal-BAIlIybO.js → ExperimentSelectorModal-BwPbQN1g.js} +1 -1
  7. package/dist/__tests__/components/AutoGroupModal.preview.test.d.ts +1 -0
  8. package/dist/__tests__/composables/autoGroup/classKey.test.d.ts +1 -0
  9. package/dist/__tests__/composables/autoGroup/groupTree.test.d.ts +1 -0
  10. package/dist/__tests__/composables/autoGroup/tokenLength.test.d.ts +1 -0
  11. package/dist/components/index.js +3 -3
  12. package/dist/{components-Cyi0IfRl.js → components-CJ2--4Ex.js} +5606 -5592
  13. package/dist/components-CJ2--4Ex.js.map +1 -0
  14. package/dist/composables/autoGroup/classKey.d.ts +1 -0
  15. package/dist/composables/autoGroup/index.d.ts +2 -1
  16. package/dist/composables/autoGroup/replicatePreGroup.d.ts +10 -12
  17. package/dist/composables/autoGroup/tokenLength.d.ts +17 -0
  18. package/dist/composables/index.js +2 -2
  19. package/dist/composables/useAutoGroup.d.ts +2 -0
  20. package/dist/{composables-CFSn4NN3.js → composables-DrE6OcZZ.js} +2 -2
  21. package/dist/{composables-CFSn4NN3.js.map → composables-DrE6OcZZ.js.map} +1 -1
  22. package/dist/index.js +5 -5
  23. package/dist/install.js +3 -3
  24. package/dist/styles.css +1497 -1453
  25. package/dist/types/auto-group.d.ts +19 -0
  26. package/dist/{useProtocolTemplates-CXP2ZosM.js → useProtocolTemplates-BbvlHoPD.js} +218 -90
  27. package/dist/useProtocolTemplates-BbvlHoPD.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/__tests__/components/AutoGroupModal.preview.test.ts +46 -0
  30. package/src/__tests__/composables/autoGroup/classKey.test.ts +25 -0
  31. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +72 -0
  32. package/src/__tests__/composables/autoGroup/groupTree.test.ts +99 -0
  33. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +85 -0
  34. package/src/__tests__/composables/useAutoGroup.test.ts +111 -19
  35. package/src/components/AutoGroupModal.vue +23 -19
  36. package/src/components/BaseModal.story.vue +7 -15
  37. package/src/components/ExperimentDataViewer.vue +1 -0
  38. package/src/components/ExperimentPopover.vue +6 -4
  39. package/src/components/ExperimentSelectorModal.vue +30 -3
  40. package/src/components/IconButton.story.vue +5 -0
  41. package/src/components/SampleSelector.vue +3 -2
  42. package/src/components/SampleSelectorSampleRow.vue +4 -2
  43. package/src/composables/autoGroup/classKey.ts +5 -2
  44. package/src/composables/autoGroup/columns.ts +2 -2
  45. package/src/composables/autoGroup/compose.ts +56 -0
  46. package/src/composables/autoGroup/fingerprint.ts +15 -1
  47. package/src/composables/autoGroup/index.ts +2 -0
  48. package/src/composables/autoGroup/replicatePreGroup.ts +34 -0
  49. package/src/composables/autoGroup/template.ts +2 -2
  50. package/src/composables/autoGroup/tokenLength.ts +53 -0
  51. package/src/composables/autoGroup/vocab.json +1 -2
  52. package/src/composables/useAutoGroup.ts +34 -13
  53. package/src/styles/components/auto-group-modal.css +7 -11
  54. package/src/styles/components/button.css +10 -3
  55. package/src/styles/components/modal.css +3 -0
  56. package/src/styles/components/sample-selector.css +17 -0
  57. package/src/styles/variables.css +8 -0
  58. package/src/types/auto-group.ts +19 -0
  59. package/dist/ExperimentPopover-mzmSfAUp.js.map +0 -1
  60. package/dist/ExperimentSelectorModal-Bn0Hmg07.js.map +0 -1
  61. package/dist/components-Cyi0IfRl.js.map +0 -1
  62. package/dist/useProtocolTemplates-CXP2ZosM.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.38",
3
+ "version": "1.0.41",
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",
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import { createPinia } from 'pinia'
4
+ import AutoGroupModal from '../../components/AutoGroupModal.vue'
5
+ import SampleHierarchyTree from '../../components/SampleHierarchyTree.vue'
6
+
7
+ const SAMPLES = [
8
+ 'study_Tissues_Kidney_M1_001',
9
+ 'study_Tissues_Kidney_M2_002',
10
+ 'study_Tissues_Liver_M1_003',
11
+ 'study_Tissues_Liver_M2_004',
12
+ ]
13
+
14
+ async function openAtPreview() {
15
+ const wrapper = mount(AutoGroupModal, {
16
+ props: { modelValue: false, samples: SAMPLES },
17
+ attachTo: document.body,
18
+ global: { plugins: [createPinia()] },
19
+ })
20
+ // The open watcher (not immediate) runs parseInput and lands on Workspace.
21
+ await wrapper.setProps({ modelValue: true })
22
+ await flushPromises()
23
+ // Advance Workspace → Preview via the wizard's Next button.
24
+ for (let i = 0; i < 3; i++) {
25
+ const next = wrapper.findAll('button').find(b => b.text().trim() === 'Next')
26
+ if (!next) break
27
+ await next.trigger('click')
28
+ await flushPromises()
29
+ }
30
+ return wrapper
31
+ }
32
+
33
+ describe('AutoGroupModal preview hierarchy', () => {
34
+ it('renders the experimental groups as a collapsible SampleHierarchyTree', async () => {
35
+ const wrapper = await openAtPreview()
36
+ const tree = wrapper.findComponent(SampleHierarchyTree)
37
+ expect(tree.exists()).toBe(true)
38
+ const nodes = tree.props('nodes') as Array<{ label: string; badge?: unknown; children?: unknown[] }>
39
+ expect(nodes.length).toBeGreaterThan(0)
40
+ // Top node is the class; it carries a sample-count badge and nested children.
41
+ const root = nodes[0]
42
+ expect(root.children && (root.children as unknown[]).length).toBeGreaterThan(0)
43
+ expect(typeof root.badge).toBe('number')
44
+ wrapper.unmount()
45
+ })
46
+ })
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { classKey } from '../../../composables/autoGroup/classKey'
3
+
4
+ describe('classKey', () => {
5
+ it('should key by kind alone when there is no subKind', () => {
6
+ expect(classKey({ kind: 'unknown' })).toBe('unknown')
7
+ })
8
+
9
+ it('should key by kind:subKind when a subKind is present', () => {
10
+ expect(classKey({ kind: 'biological', subKind: 'Plasma' })).toBe('biological:Plasma')
11
+ })
12
+
13
+ it('should append the token length so length-split classes get distinct keys', () => {
14
+ expect(classKey({ kind: 'biological', subKind: 'Plasma', tokenLength: 7 })).toBe(
15
+ 'biological:Plasma#7',
16
+ )
17
+ expect(classKey({ kind: 'biological', subKind: 'Plasma', tokenLength: 8 })).toBe(
18
+ 'biological:Plasma#8',
19
+ )
20
+ })
21
+
22
+ it('should append the token length even without a subKind', () => {
23
+ expect(classKey({ kind: 'unknown', tokenLength: 3 })).toBe('unknown#3')
24
+ })
25
+ })
@@ -47,4 +47,76 @@ describe('fingerprint round-trip', () => {
47
47
  ]
48
48
  expect(() => restoreFingerprint(fp, mismatched)).toThrow(/column count/i)
49
49
  })
50
+
51
+ it('matches a pre-token-length snapshot to the schema with the same column count', () => {
52
+ // An OLD fingerprint (saved before token-length splitting) has no tokenLength.
53
+ const oldFp = {
54
+ version: 1 as const,
55
+ classes: [
56
+ {
57
+ kind: 'biological' as const,
58
+ subKind: 'Plasma',
59
+ columns: [
60
+ { name: 'A', role: 'factor' as const, sourceIndices: [0] },
61
+ ],
62
+ groupBy: [0],
63
+ },
64
+ ],
65
+ }
66
+ // Current data split into two Plasma schemas; the 2-column one is listed FIRST.
67
+ const current: ClassSchema[] = [
68
+ {
69
+ classKind: 'biological', subKind: 'Plasma', tokenLength: 5,
70
+ columns: [
71
+ { index: 0, name: 'A', sourceIndices: [0], uniqueValues: ['x'], cardinality: 1, role: 'factor' },
72
+ { index: 1, name: 'B', sourceIndices: [1], uniqueValues: ['y'], cardinality: 1, role: 'factor' },
73
+ ],
74
+ groupBy: [0, 1],
75
+ },
76
+ {
77
+ classKind: 'biological', subKind: 'Plasma', tokenLength: 4,
78
+ columns: [
79
+ { index: 0, name: 'A', sourceIndices: [0], uniqueValues: ['x'], cardinality: 1, role: 'factor' },
80
+ ],
81
+ groupBy: [0],
82
+ },
83
+ ]
84
+ // Must match the 1-column schema (same column count) rather than throwing on the 2-column first hit.
85
+ const restored = restoreFingerprint(oldFp, current)
86
+ expect(restored).toHaveLength(1)
87
+ expect(restored[0].columns).toHaveLength(1)
88
+ })
89
+
90
+ it('disambiguates schemas that share kind/subKind but differ in token length', () => {
91
+ const ragged: ClassSchema[] = [
92
+ {
93
+ classKind: 'biological',
94
+ subKind: 'Plasma',
95
+ tokenLength: 4,
96
+ columns: [
97
+ { index: 0, name: 'A', sourceIndices: [0], uniqueValues: ['x'], cardinality: 1, role: 'factor' },
98
+ ],
99
+ groupBy: [0],
100
+ },
101
+ {
102
+ classKind: 'biological',
103
+ subKind: 'Plasma',
104
+ tokenLength: 5,
105
+ columns: [
106
+ { index: 0, name: 'A', sourceIndices: [0], uniqueValues: ['x'], cardinality: 1, role: 'factor' },
107
+ { index: 1, name: 'B', sourceIndices: [1], uniqueValues: ['y'], cardinality: 1, role: 'factor' },
108
+ ],
109
+ groupBy: [0, 1],
110
+ },
111
+ ]
112
+ const fp = serializeFingerprint(ragged)
113
+ // Restore against the SAME schemas in reversed order: each snapshot must
114
+ // match its same-token-length schema, not collide on kind/subKind (which
115
+ // would mis-apply columns or throw on a column-count mismatch).
116
+ const restored = restoreFingerprint(fp, [ragged[1], ragged[0]])
117
+ const four = restored.find(s => s.tokenLength === 4)
118
+ const five = restored.find(s => s.tokenLength === 5)
119
+ expect(four?.columns).toHaveLength(1)
120
+ expect(five?.columns).toHaveLength(2)
121
+ })
50
122
  })
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { composeGroups } from '../../../composables/autoGroup/compose'
3
+ import type { ClassSchema, SampleClass } from '../../../types/auto-group'
4
+ import type { TreeNode } from '../../../types'
5
+
6
+ const tissues: ClassSchema = {
7
+ classKind: 'biological',
8
+ subKind: 'Tissues',
9
+ columns: [
10
+ { index: 0, name: 'Organ', sourceIndices: [0], uniqueValues: ['Kidney', 'Liver'], cardinality: 2, role: 'factor' },
11
+ { index: 1, name: 'Subject', sourceIndices: [1], uniqueValues: ['M1', 'M2'], cardinality: 2, role: 'factor' },
12
+ { index: 2, name: 'Injection #', sourceIndices: [2], uniqueValues: ['001', '002', '003'], cardinality: 3, role: 'run-order' },
13
+ ],
14
+ groupBy: [0, 1],
15
+ }
16
+ const tissuesClass: SampleClass = {
17
+ kind: 'biological',
18
+ subKind: 'Tissues',
19
+ label: 'Biological / Tissues',
20
+ members: [0, 1, 2],
21
+ classTagPositions: [],
22
+ disposition: 'group',
23
+ }
24
+ const input = {
25
+ tokenizedSamples: [
26
+ ['Kidney', 'M1', '001'],
27
+ ['Kidney', 'M2', '002'],
28
+ ['Liver', 'M1', '003'],
29
+ ],
30
+ sampleNames: ['k1', 'k2', 'l1'],
31
+ schemas: { 'biological:Tissues': tissues },
32
+ classes: [tissuesClass],
33
+ }
34
+
35
+ function child(node: TreeNode | undefined, label: string): TreeNode | undefined {
36
+ return node?.children?.find(c => c.label === label)
37
+ }
38
+
39
+ describe('composeGroups groupTree (nested hierarchy)', () => {
40
+ it('nests class → layer1 → layer2 → sample leaves with sample-count badges', () => {
41
+ const tree = composeGroups(input).groupTree!
42
+ expect(tree).toHaveLength(1)
43
+ const root = tree[0]
44
+ expect(root.label).toBe('Biological / Tissues')
45
+ expect(root.badge).toBe(3) // total samples under the class
46
+
47
+ const kidney = child(root, 'Kidney')
48
+ const liver = child(root, 'Liver')
49
+ expect(kidney?.badge).toBe(2)
50
+ expect(liver?.badge).toBe(1)
51
+
52
+ const kidneyM1 = child(kidney, 'M1')
53
+ const kidneyM2 = child(kidney, 'M2')
54
+ expect(kidneyM1?.badge).toBe(1)
55
+ expect(kidneyM2?.badge).toBe(1)
56
+
57
+ // Leaf = the actual sample; injection number carried as leaf metadata + badge.
58
+ const leaf = kidneyM1?.children?.[0]
59
+ expect(leaf?.label).toBe('k1')
60
+ expect(leaf?.children).toBeUndefined()
61
+ expect(leaf?.metadata?.injection).toBe('001')
62
+ expect(leaf?.badge).toBe('001')
63
+ })
64
+
65
+ it('gives every node a stable unique id derived from its path', () => {
66
+ const tree = composeGroups(input).groupTree!
67
+ const ids = new Set<string>()
68
+ const walk = (n: TreeNode) => { ids.add(n.id); n.children?.forEach(walk) }
69
+ tree.forEach(walk)
70
+ // class + Kidney + Liver + M1(×2 paths) + M2 + 3 leaves = 9 distinct ids
71
+ expect(ids.size).toBe(9)
72
+ })
73
+
74
+ it('puts samples directly under the class node when no columns are grouped', () => {
75
+ const flatSchema: ClassSchema = { ...tissues, groupBy: [] }
76
+ const tree = composeGroups({ ...input, schemas: { 'biological:Tissues': flatSchema } }).groupTree!
77
+ const root = tree[0]
78
+ expect(root.badge).toBe(3)
79
+ expect(root.children?.map(c => c.label)).toEqual(['k1', 'k2', 'l1'])
80
+ })
81
+
82
+ it('excludes overlay/QC classes from the experimental groupTree', () => {
83
+ const iqcSchema: ClassSchema = {
84
+ classKind: 'iqc',
85
+ columns: [{ index: 0, name: 'IQC', sourceIndices: [0], uniqueValues: ['IQC'], cardinality: 1, role: 'class-tag' }],
86
+ groupBy: [],
87
+ }
88
+ const iqcClass: SampleClass = {
89
+ kind: 'iqc', label: 'IQC', members: [3], classTagPositions: [0], disposition: 'overlay',
90
+ }
91
+ const tree = composeGroups({
92
+ tokenizedSamples: [...input.tokenizedSamples, ['IQC']],
93
+ sampleNames: [...input.sampleNames, 'qc1'],
94
+ schemas: { 'biological:Tissues': tissues, iqc: iqcSchema },
95
+ classes: [tissuesClass, iqcClass],
96
+ }).groupTree!
97
+ expect(tree.map(n => n.label)).toEqual(['Biological / Tissues'])
98
+ })
99
+ })
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitByTokenLength } from '../../../composables/autoGroup/tokenLength'
3
+ import type { SampleClass } from '../../../types/auto-group'
4
+
5
+ function cls(partial: Partial<SampleClass> & Pick<SampleClass, 'members'>): SampleClass {
6
+ return {
7
+ kind: 'biological',
8
+ label: 'Biological / Plasma',
9
+ subKind: 'Plasma',
10
+ classTagPositions: [],
11
+ disposition: 'group',
12
+ ...partial,
13
+ }
14
+ }
15
+
16
+ describe('splitByTokenLength', () => {
17
+ it('should split a ragged class into one class per token-length, preserving member order', () => {
18
+ const tokenized = [
19
+ ['a', 'b', 'c', '01'], // len 4
20
+ ['a', 'b', 'c', '02'], // len 4
21
+ ['a', 'b', 'c', 'd', '03'], // len 5
22
+ ]
23
+ const out = splitByTokenLength([cls({ members: [0, 1, 2] })], tokenized)
24
+
25
+ expect(out).toHaveLength(2)
26
+ const four = out.find(c => c.tokenLength === 4)
27
+ const five = out.find(c => c.tokenLength === 5)
28
+ expect(four?.members).toEqual([0, 1])
29
+ expect(five?.members).toEqual([2])
30
+ })
31
+
32
+ it('should disambiguate the label with the field count only when a class spans multiple lengths', () => {
33
+ const tokenized = [
34
+ ['a', 'b', 'c', '01'],
35
+ ['a', 'b', 'c', 'd', '02'],
36
+ ]
37
+ const out = splitByTokenLength([cls({ members: [0, 1] })], tokenized)
38
+
39
+ const labels = out.map(c => c.label).sort()
40
+ expect(labels).toEqual([
41
+ 'Biological / Plasma · 4 fields',
42
+ 'Biological / Plasma · 5 fields',
43
+ ])
44
+ })
45
+
46
+ it('should leave the label unchanged but still set tokenLength when a class has a single length', () => {
47
+ const tokenized = [
48
+ ['a', 'b', 'c'],
49
+ ['a', 'b', 'c'],
50
+ ]
51
+ const out = splitByTokenLength([cls({ members: [0, 1] })], tokenized)
52
+
53
+ expect(out).toHaveLength(1)
54
+ expect(out[0].label).toBe('Biological / Plasma')
55
+ expect(out[0].tokenLength).toBe(3)
56
+ })
57
+
58
+ it('should clamp classTagPositions to the split token-length', () => {
59
+ const tokenized = [
60
+ ['a', 'b', 'c'], // len 3
61
+ ['a', 'b', 'c', 'd', 'e'], // len 5
62
+ ]
63
+ const out = splitByTokenLength(
64
+ [cls({ members: [0, 1], classTagPositions: [1, 4] })],
65
+ tokenized,
66
+ )
67
+
68
+ const short = out.find(c => c.tokenLength === 3)
69
+ const long = out.find(c => c.tokenLength === 5)
70
+ expect(short?.classTagPositions).toEqual([1])
71
+ expect(long?.classTagPositions).toEqual([1, 4])
72
+ })
73
+
74
+ it('should keep distinct input classes separate and ordered', () => {
75
+ const tokenized = [
76
+ ['x', '01'], // class A, len 2
77
+ ['y', 'z', '02'], // class B, len 3
78
+ ]
79
+ const a = cls({ kind: 'unknown', subKind: undefined, label: 'Unknown', members: [0] })
80
+ const b = cls({ members: [1] })
81
+ const out = splitByTokenLength([a, b], tokenized)
82
+
83
+ expect(out.map(c => c.label)).toEqual(['Unknown', 'Biological / Plasma'])
84
+ })
85
+ })
@@ -4,6 +4,7 @@ import {
4
4
  extractSamplesFromDesignData,
5
5
  useAutoGroup,
6
6
  } from '../../composables/useAutoGroup'
7
+ import { classKey } from '../../composables/autoGroup/classKey'
7
8
 
8
9
  describe('parseCSV', () => {
9
10
  it('should parse simple CSV with headers', () => {
@@ -284,12 +285,10 @@ describe('useAutoGroup CSV structured hierarchy', () => {
284
285
  })
285
286
 
286
287
  describe('useAutoGroup auto-disable degenerate columns', () => {
287
- it('collapses unique-per-row injection numbers via replicate pre-grouping', () => {
288
- // The replicate pre-grouping pass strips the trailing 3-digit run-order
289
- // tokens before tokenisation, so 5 inputs collapse to 2 base names
290
- // (Ctrl_WT and Treat_WT). This supersedes the old "auto-disable
291
- // unique-per-row column" mechanism — there IS no unique-per-row column
292
- // for the schema to see, because pre-grouping already absorbed it.
288
+ it('collapses unique-per-row injection numbers into one group per condition', () => {
289
+ // The trailing injection number is detected as a run-order column and kept
290
+ // OUT of the default group key, so the 5 samples collapse to two condition
291
+ // groups (Ctrl, Treat) even though every injection number is distinct.
293
292
  const auto = useAutoGroup()
294
293
  auto.rawText.value = [
295
294
  'Ctrl_WT_001',
@@ -301,8 +300,6 @@ describe('useAutoGroup auto-disable degenerate columns', () => {
301
300
  auto.parseInput()
302
301
 
303
302
  expect(auto.groups.value).toHaveLength(2)
304
- // Ctrl has 3 samples, Treat has 2 — replicates expanded back from the
305
- // base names by expandGroupsWithReplicates in the result computed.
306
303
  const ctrl = auto.groups.value.find(g => g.name === 'Ctrl')
307
304
  const treat = auto.groups.value.find(g => g.name === 'Treat')
308
305
  expect(ctrl?.samples).toHaveLength(3)
@@ -310,10 +307,11 @@ describe('useAutoGroup auto-disable degenerate columns', () => {
310
307
  expect(auto.groups.value.every(g => g.samples.length > 1)).toBe(true)
311
308
  })
312
309
 
313
- it('the schema never sees a degenerate unique-per-row column', () => {
314
- // Same setup, asserting the contract from the other side: after the
315
- // pre-grouping pass, the active schema's columns reflect the BASE
316
- // tokens only. There's no column with cardinality === sample-count.
310
+ it('surfaces the injection number as a non-grouping run-order column', () => {
311
+ // The trailing 3-digit injection number is now SURFACED as a column rather
312
+ // than silently stripped: it appears in the active schema with role
313
+ // 'run-order' and a high cardinality, but is excluded from the default
314
+ // group key so it never fragments groups into singletons.
317
315
  const auto = useAutoGroup()
318
316
  auto.rawText.value = [
319
317
  'Ctrl_WT_001',
@@ -324,11 +322,18 @@ describe('useAutoGroup auto-disable degenerate columns', () => {
324
322
  ].join('\n')
325
323
  auto.parseInput()
326
324
 
327
- // 5 originals → 2 base names → 2 schema columns: condition (Ctrl/Treat)
328
- // and the constant WT. Neither has cardinality 5.
329
325
  const schema = auto.activeSchema.value
330
326
  expect(schema).toBeTruthy()
331
- expect(schema!.columns.every(c => c.cardinality < 5)).toBe(true)
327
+ const injection = schema!.columns.find(c => c.role === 'run-order')
328
+ expect(injection).toBeDefined()
329
+ expect(injection!.cardinality).toBe(5)
330
+ expect(injection!.name).toBe('Injection #')
331
+ // Detected but NOT used for grouping.
332
+ expect(auto.enabledFields.value.has(injection!.index)).toBe(false)
333
+ // The Ctrl/Treat condition column IS used for grouping.
334
+ const condition = schema!.columns.find(c => c.role === 'factor')
335
+ expect(condition).toBeDefined()
336
+ expect(auto.enabledFields.value.has(condition!.index)).toBe(true)
332
337
  })
333
338
 
334
339
  it('keeps useful columns (cardinality between 2 and N-1) enabled', () => {
@@ -360,6 +365,32 @@ describe('useAutoGroup auto-disable degenerate columns', () => {
360
365
  })
361
366
  })
362
367
 
368
+ describe('useAutoGroup token-length grouping', () => {
369
+ it('separates same-type samples with different token-lengths into distinct classes and groups', () => {
370
+ const auto = useAutoGroup()
371
+ // All Plasma, but the first pair has 4 tokens and the second pair has 5.
372
+ auto.rawText.value = [
373
+ 'study_Plasma_5min_001',
374
+ 'study_Plasma_5min_002',
375
+ 'study_Plasma_WT_10min_003',
376
+ 'study_Plasma_WT_10min_004',
377
+ ].join('\n')
378
+ auto.parseInput()
379
+
380
+ // One class per token-length, each tagged with its field count.
381
+ const lengths = auto.classes.value.map(c => c.tokenLength).sort()
382
+ expect(lengths).toEqual([4, 5])
383
+ expect(auto.classes.value.every(c => c.label.includes('fields'))).toBe(true)
384
+
385
+ // Samples of different token-length never land in the same group.
386
+ expect(auto.groups.value).toHaveLength(2)
387
+ for (const g of auto.groups.value) {
388
+ const tokenCounts = new Set(g.samples.map(s => s.split('_').length))
389
+ expect(tokenCounts.size).toBe(1)
390
+ }
391
+ })
392
+ })
393
+
363
394
  describe('useAutoGroup — class-first orchestration', () => {
364
395
  it('detects classes after parseInput runs', async () => {
365
396
  const ag = useAutoGroup()
@@ -382,9 +413,7 @@ describe('useAutoGroup — class-first orchestration', () => {
382
413
  'c_IQC_003',
383
414
  ].join('\n')
384
415
  ag.parseInput()
385
- const firstKey = ag.classes.value[0].subKind
386
- ? `${ag.classes.value[0].kind}:${ag.classes.value[0].subKind}`
387
- : ag.classes.value[0].kind
416
+ const firstKey = classKey(ag.classes.value[0])
388
417
  ag.activeClassKey.value = firstKey
389
418
  expect(ag.activeSchema.value!.classKind).toBe(ag.classes.value[0].kind)
390
419
  })
@@ -397,7 +426,8 @@ describe('useAutoGroup — class-first orchestration', () => {
397
426
  'exp_IQC_003',
398
427
  ].join('\n')
399
428
  ag.parseInput()
400
- ag.setClassDisposition('iqc', 'group')
429
+ const iqcKey = classKey(ag.classes.value.find(c => c.kind === 'iqc')!)
430
+ ag.setClassDisposition(iqcKey, 'group')
401
431
  expect(ag.classes.value.find(c => c.kind === 'iqc')!.disposition).toBe('group')
402
432
  })
403
433
 
@@ -430,3 +460,65 @@ describe('useAutoGroup — class-first orchestration', () => {
430
460
  expect(content.split('\n')[0]).toContain('sample_name')
431
461
  })
432
462
  })
463
+
464
+ describe('useAutoGroup — regressions from token-length classKey change', () => {
465
+ const raggedPlasma = [
466
+ 'study_Plasma_5min_001',
467
+ 'study_Plasma_5min_002',
468
+ 'study_Plasma_WT_10min_003',
469
+ 'study_Plasma_WT_10min_004',
470
+ ].join('\n')
471
+
472
+ it('round-trips a fingerprint of length-split schemas without dropping groups', () => {
473
+ const auto = useAutoGroup()
474
+ auto.rawText.value = raggedPlasma
475
+ auto.parseInput()
476
+ const before = auto.groups.value.length
477
+ expect(before).toBeGreaterThan(0)
478
+ auto.loadFingerprint(auto.fingerprint.value)
479
+ expect(auto.groups.value.length).toBe(before)
480
+ })
481
+
482
+ it('fills prefilled-template schema-column cells for length-split classes', () => {
483
+ const auto = useAutoGroup()
484
+ auto.rawText.value = raggedPlasma
485
+ auto.parseInput()
486
+ const { content } = auto.composeTemplateForTest({ mode: 'prefilled', format: 'csv' })
487
+ const lines = content.trim().split('\n')
488
+ const header = lines[0].split(',')
489
+ // Columns live between 'class' (index 1) and the trailing 'group','notes'.
490
+ const colStart = 2
491
+ const colEnd = header.length - 2
492
+ const dataRows = lines.slice(1).map(l => l.split(','))
493
+ // With the old key mismatch every schema-column cell was blank; the fix fills them.
494
+ const anyFilled = dataRows.some(r =>
495
+ r.slice(colStart, colEnd).some(c => (c ?? '').trim().length > 0),
496
+ )
497
+ expect(anyFilled).toBe(true)
498
+ })
499
+
500
+ it('expands groupTree replicate base names back to original samples', () => {
501
+ const auto = useAutoGroup()
502
+ auto.rawText.value = [
503
+ 'study_Ctrl_Rep1',
504
+ 'study_Ctrl_Rep2',
505
+ 'study_Treat_Rep1',
506
+ 'study_Treat_Rep2',
507
+ ].join('\n')
508
+ auto.parseInput()
509
+ const tree = auto.result.value.groupTree ?? []
510
+ const leaves: string[] = []
511
+ const walk = (n: { label: string; children?: unknown[] }) => {
512
+ const kids = n.children as Array<typeof n> | undefined
513
+ if (kids && kids.length) kids.forEach(walk)
514
+ else leaves.push(n.label)
515
+ }
516
+ tree.forEach(walk)
517
+ expect(leaves.sort()).toEqual([
518
+ 'study_Ctrl_Rep1',
519
+ 'study_Ctrl_Rep2',
520
+ 'study_Treat_Rep1',
521
+ 'study_Treat_Rep2',
522
+ ])
523
+ })
524
+ })
@@ -7,6 +7,7 @@ import BaseInput from './BaseInput.vue'
7
7
  import StepWizard from './StepWizard.vue'
8
8
  import LoadingSpinner from './LoadingSpinner.vue'
9
9
  import AlertBox from './AlertBox.vue'
10
+ import SampleHierarchyTree from './SampleHierarchyTree.vue'
10
11
  import { useAutoGroup, parseCSV } from '../composables/useAutoGroup'
11
12
  import { classKey } from '../composables/autoGroup'
12
13
  import { useApi } from '../composables/useApi'
@@ -113,6 +114,12 @@ const totalQc = computed(() =>
113
114
  (autoGroup.qcGroups.value ?? []).reduce((acc, g) => acc + g.samples.length, 0),
114
115
  )
115
116
 
117
+ // Nested experimental hierarchy (class → groupBy layers → sample leaves) for the
118
+ // collapsible Preview tree. Expand the class roots by default so the first
119
+ // grouping level is visible without overwhelming the panel with every sample.
120
+ const groupTree = computed(() => autoGroup.result.value.groupTree ?? [])
121
+ const defaultExpandedTreeIds = computed(() => groupTree.value.map(n => n.id))
122
+
116
123
  function cloneGroups(groups: SampleGroup[]): SampleGroup[] {
117
124
  return groups.map(group => ({
118
125
  ...group,
@@ -744,6 +751,7 @@ const isFirstStep = computed(() => currentStep.value === 0)
744
751
  class="mint-auto-group__textarea"
745
752
  rows="12"
746
753
  placeholder="Paste sample names, one per line..."
754
+ aria-label="Sample names"
747
755
  />
748
756
  <div v-if="autoGroup.samples.value.length > 0" class="mint-auto-group__sample-count">
749
757
  {{ autoGroup.samples.value.length }} samples
@@ -765,6 +773,7 @@ const isFirstStep = computed(() => currentStep.value === 0)
765
773
  type="file"
766
774
  accept=".csv,.tsv"
767
775
  class="mint-auto-group__file-input"
776
+ aria-label="Upload CSV or TSV file"
768
777
  @change="handleFileInput"
769
778
  />
770
779
  <svg class="mint-auto-group__upload-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -922,6 +931,8 @@ const isFirstStep = computed(() => currentStep.value === 0)
922
931
  :key="col.index"
923
932
  type="button"
924
933
  role="row"
934
+ :aria-expanded="openPopoverIdx === col.index"
935
+ aria-haspopup="dialog"
925
936
  :class="[
926
937
  'mint-auto-group__token-row',
927
938
  openPopoverIdx === col.index ? 'mint-auto-group__token-row--active' : '',
@@ -960,12 +971,14 @@ const isFirstStep = computed(() => currentStep.value === 0)
960
971
  <div
961
972
  v-if="openPopoverIdx !== null && activeColumn"
962
973
  class="mint-auto-group__popover"
974
+ role="dialog"
975
+ :aria-label="`${activeColumn.displayName ?? activeColumn.name} column options`"
963
976
  @click.stop
964
977
  >
965
978
  <div class="mint-auto-group__popover-head">
966
979
  <strong>{{ activeColumn.displayName ?? activeColumn.name }}</strong>
967
980
  <span class="mono">{{ activeColumn.cardinality }} unique</span>
968
- <button type="button" class="mint-auto-group__popover-close" @click="openPopoverIdx = null">×</button>
981
+ <button type="button" class="mint-auto-group__popover-close" aria-label="Close" @click="openPopoverIdx = null">×</button>
969
982
  </div>
970
983
 
971
984
  <div class="mint-auto-group__popover-section">
@@ -1044,24 +1057,15 @@ const isFirstStep = computed(() => currentStep.value === 0)
1044
1057
  <div class="mint-auto-group__preview-grid">
1045
1058
  <div class="mint-auto-group__preview-panel">
1046
1059
  <h4>Experimental groups</h4>
1047
- <details
1048
- v-for="group in (autoGroup.result.value.experimentalGroups ?? [])"
1049
- :key="group.name"
1050
- class="mint-auto-group__preview-group"
1051
- >
1052
- <summary>
1053
- <span class="mint-auto-group__preview-dot" :style="{ background: group.color }" />
1054
- <span>{{ group.name }}</span>
1055
- <span class="mint-auto-group__preview-count">{{ group.samples.length }}</span>
1056
- </summary>
1057
- <div class="mint-auto-group__preview-samples">
1058
- <span
1059
- v-for="s in group.samples"
1060
- :key="s"
1061
- class="mint-auto-group__preview-sample"
1062
- >{{ s }}</span>
1063
- </div>
1064
- </details>
1060
+ <SampleHierarchyTree
1061
+ v-if="groupTree.length"
1062
+ :nodes="groupTree"
1063
+ :default-expanded-ids="defaultExpandedTreeIds"
1064
+ :show-icons="false"
1065
+ size="sm"
1066
+ class="mint-auto-group__preview-tree"
1067
+ />
1068
+ <p v-else class="mint-auto-group__preview-empty">No experimental groups.</p>
1065
1069
  </div>
1066
1070
 
1067
1071
  <div class="mint-auto-group__preview-panel">