@morscherlab/mint-sdk 1.0.39 → 1.0.42

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 (78) 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/AppTopBar.navigation.d.ts +0 -1
  12. package/dist/components/index.js +3 -3
  13. package/dist/{components-Cyi0IfRl.js → components-BGVwavdd.js} +5632 -5629
  14. package/dist/components-BGVwavdd.js.map +1 -0
  15. package/dist/composables/autoGroup/classKey.d.ts +1 -0
  16. package/dist/composables/autoGroup/index.d.ts +2 -1
  17. package/dist/composables/autoGroup/replicatePreGroup.d.ts +10 -12
  18. package/dist/composables/autoGroup/tokenLength.d.ts +17 -0
  19. package/dist/composables/index.d.ts +1 -1
  20. package/dist/composables/index.js +3 -3
  21. package/dist/composables/useAutoGroup.d.ts +2 -0
  22. package/dist/composables/usePluginClient.d.ts +82 -5
  23. package/dist/{composables-CFSn4NN3.js → composables-C_hPF0Gn.js} +256 -9
  24. package/dist/{composables-CFSn4NN3.js.map → composables-C_hPF0Gn.js.map} +1 -1
  25. package/dist/index.js +6 -6
  26. package/dist/install.js +3 -3
  27. package/dist/styles.css +602 -555
  28. package/dist/types/auto-group.d.ts +19 -0
  29. package/dist/{useProtocolTemplates-CXP2ZosM.js → useProtocolTemplates-BbvlHoPD.js} +218 -90
  30. package/dist/useProtocolTemplates-BbvlHoPD.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/__tests__/components/AppTopBar.navigation.test.ts +3 -5
  33. package/src/__tests__/components/AppTopBar.test.ts +2 -5
  34. package/src/__tests__/components/AppTopBarPageSelector.test.ts +22 -0
  35. package/src/__tests__/components/AutoGroupModal.preview.test.ts +46 -0
  36. package/src/__tests__/components/PluginWorkspaceView.test.ts +18 -0
  37. package/src/__tests__/composables/autoGroup/classKey.test.ts +25 -0
  38. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +72 -0
  39. package/src/__tests__/composables/autoGroup/groupTree.test.ts +99 -0
  40. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +85 -0
  41. package/src/__tests__/composables/useAutoGroup.test.ts +111 -19
  42. package/src/__tests__/composables/usePluginClient.test.ts +129 -3
  43. package/src/components/AppTopBar.navigation.ts +0 -2
  44. package/src/components/AppTopBar.story.vue +5 -5
  45. package/src/components/AppTopBar.vue +0 -1
  46. package/src/components/AutoGroupModal.vue +23 -19
  47. package/src/components/BaseModal.story.vue +7 -15
  48. package/src/components/ExperimentDataViewer.vue +1 -0
  49. package/src/components/ExperimentPopover.vue +6 -4
  50. package/src/components/ExperimentSelectorModal.vue +30 -3
  51. package/src/components/IconButton.story.vue +5 -0
  52. package/src/components/PluginWorkspaceView.vue +5 -1
  53. package/src/components/SampleSelector.vue +3 -2
  54. package/src/components/SampleSelectorSampleRow.vue +4 -2
  55. package/src/components/internal/AppTopBarPageSelectorInternal.vue +0 -1
  56. package/src/composables/autoGroup/classKey.ts +5 -2
  57. package/src/composables/autoGroup/columns.ts +2 -2
  58. package/src/composables/autoGroup/compose.ts +56 -0
  59. package/src/composables/autoGroup/fingerprint.ts +15 -1
  60. package/src/composables/autoGroup/index.ts +2 -0
  61. package/src/composables/autoGroup/replicatePreGroup.ts +34 -0
  62. package/src/composables/autoGroup/template.ts +2 -2
  63. package/src/composables/autoGroup/tokenLength.ts +53 -0
  64. package/src/composables/autoGroup/vocab.json +1 -2
  65. package/src/composables/index.ts +6 -0
  66. package/src/composables/useAutoGroup.ts +34 -13
  67. package/src/composables/usePluginClient.ts +453 -8
  68. package/src/styles/components/app-page-selector.css +3 -5
  69. package/src/styles/components/auto-group-modal.css +7 -11
  70. package/src/styles/components/button.css +14 -4
  71. package/src/styles/components/modal.css +3 -0
  72. package/src/styles/components/sample-selector.css +17 -0
  73. package/src/styles/variables.css +8 -0
  74. package/src/types/auto-group.ts +19 -0
  75. package/dist/ExperimentPopover-mzmSfAUp.js.map +0 -1
  76. package/dist/ExperimentSelectorModal-Bn0Hmg07.js.map +0 -1
  77. package/dist/components-Cyi0IfRl.js.map +0 -1
  78. 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.39",
3
+ "version": "1.0.42",
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",
@@ -26,18 +26,17 @@ describe('AppTopBar navigation helpers', () => {
26
26
  pluginNavItemToPageSelectorItem(
27
27
  { label: 'Results', path: 'results' },
28
28
  1,
29
- { pluginIcon: 'flask', pluginName: 'Assay Plugin' },
29
+ { pluginIcon: 'flask' },
30
30
  ),
31
31
  ).toEqual({
32
32
  id: 'results',
33
33
  label: 'Results',
34
34
  to: '/results',
35
35
  icon: 'flask',
36
- hint: 'Assay Plugin',
37
36
  })
38
37
  })
39
38
 
40
- it('prefers explicit nav item id, icon, and description', () => {
39
+ it('prefers explicit nav item id and icon without exposing descriptions as hints', () => {
41
40
  expect(
42
41
  pluginNavItemToPageSelectorItem(
43
42
  {
@@ -48,12 +47,11 @@ describe('AppTopBar navigation helpers', () => {
48
47
  description: 'Custom page',
49
48
  },
50
49
  0,
51
- { pluginIcon: 'flask', pluginName: 'Assay Plugin' },
50
+ { pluginIcon: 'flask' },
52
51
  ),
53
52
  ).toMatchObject({
54
53
  id: 'custom',
55
54
  icon: 'sparkles',
56
- hint: 'Custom page',
57
55
  })
58
56
  })
59
57
 
@@ -830,7 +830,7 @@ describe('AppTopBar', () => {
830
830
  const imageIcons = wrapper.findAll('.mint-plugin-icon__img')
831
831
  expect(imageIcons).toHaveLength(1)
832
832
  expect(imageIcons[0].attributes('src')).toBe(pngIcon)
833
- expect(wrapper.find('.mint-page-selector__item-hint').text()).toBe('Overview')
833
+ expect(wrapper.find('.mint-page-selector__item-hint').exists()).toBe(false)
834
834
  })
835
835
  })
836
836
 
@@ -1068,10 +1068,7 @@ describe('AppTopBar', () => {
1068
1068
  'Dashboard',
1069
1069
  'Analysis',
1070
1070
  ])
1071
- expect(wrapper.findAll('.mint-page-selector__item-hint').map(item => item.text())).toEqual([
1072
- 'Dose Designer',
1073
- 'Dose Designer',
1074
- ])
1071
+ expect(wrapper.find('.mint-page-selector__item-hint').exists()).toBe(false)
1075
1072
  const dropdownIcons = wrapper
1076
1073
  .findAllComponents({ name: 'PluginIcon' })
1077
1074
  .filter(icon => icon.classes().includes('mint-page-selector__metadata-icon'))
@@ -24,6 +24,28 @@ describe('AppTopBarPageSelectorInternal', () => {
24
24
  expect(wrapper.emitted('select')).toEqual([[{ id: 'Results', label: 'Results' }]])
25
25
  })
26
26
 
27
+ it('omits page hints from the menu for compact scanning', async () => {
28
+ const wrapper = mount(AppTopBarPageSelectorInternal, {
29
+ props: {
30
+ currentPageId: 'downloads',
31
+ pages: [
32
+ { id: 'downloads', label: 'Downloads', hint: 'Paste links and queue downloads' },
33
+ { id: 'settings', label: 'Settings', hint: 'Credentials and destination' },
34
+ ],
35
+ },
36
+ })
37
+
38
+ await wrapper.get('.mint-page-selector__trigger').trigger('click')
39
+
40
+ expect(wrapper.findAll('.mint-page-selector__item-label').map(item => item.text())).toEqual([
41
+ 'Downloads',
42
+ 'Settings',
43
+ ])
44
+ expect(wrapper.find('.mint-page-selector__item-hint').exists()).toBe(false)
45
+ expect(wrapper.text()).not.toContain('Paste links')
46
+ expect(wrapper.text()).not.toContain('Credentials')
47
+ })
48
+
27
49
  it('closes linked page actions without emitting select', async () => {
28
50
  const wrapper = mount(AppTopBarPageSelectorInternal, {
29
51
  props: {
@@ -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
+ })
@@ -146,6 +146,24 @@ describe('PluginWorkspaceView', () => {
146
146
  expect(wrapper.emitted('pill-select')).toEqual([[{ id: 'results', label: 'Results' }]])
147
147
  })
148
148
 
149
+ it('hides topbar page selector until multiple pages are registered', () => {
150
+ const wrapper = mount(PluginWorkspaceView, {
151
+ props: {
152
+ title: 'Downloader',
153
+ subtitle: 'Downloads',
154
+ pageSelector: [
155
+ { id: 'downloads', label: 'Downloads', to: '/' },
156
+ ],
157
+ currentPageSelectorId: 'downloads',
158
+ },
159
+ global: globalOptions,
160
+ })
161
+
162
+ expect(wrapper.findComponent(AppTopBar).props('pageSelector')).toBeUndefined()
163
+ expect(wrapper.find('.mint-page-selector').exists()).toBe(false)
164
+ expect(wrapper.find('.mint-topbar-title-group').text()).toContain('Downloader')
165
+ })
166
+
149
167
  it('passes compact control models to the sidebar for generated forms', () => {
150
168
  const wrapper = mount(PluginWorkspaceView, {
151
169
  props: {
@@ -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
+ })