@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.
- package/dist/{ExperimentPopover-DEzCbTqo.js → ExperimentPopover-8A4Rhffp.js} +1 -1
- package/dist/{ExperimentPopover-mzmSfAUp.js → ExperimentPopover-BbPkIFsI.js} +8 -2
- package/dist/ExperimentPopover-BbPkIFsI.js.map +1 -0
- package/dist/{ExperimentSelectorModal-Bn0Hmg07.js → ExperimentSelectorModal-B2qek_YG.js} +91 -46
- package/dist/ExperimentSelectorModal-B2qek_YG.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BAIlIybO.js → ExperimentSelectorModal-BwPbQN1g.js} +1 -1
- package/dist/__tests__/components/AutoGroupModal.preview.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/classKey.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/groupTree.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/tokenLength.test.d.ts +1 -0
- package/dist/components/index.js +3 -3
- package/dist/{components-Cyi0IfRl.js → components-CJ2--4Ex.js} +5606 -5592
- package/dist/components-CJ2--4Ex.js.map +1 -0
- package/dist/composables/autoGroup/classKey.d.ts +1 -0
- package/dist/composables/autoGroup/index.d.ts +2 -1
- package/dist/composables/autoGroup/replicatePreGroup.d.ts +10 -12
- package/dist/composables/autoGroup/tokenLength.d.ts +17 -0
- package/dist/composables/index.js +2 -2
- package/dist/composables/useAutoGroup.d.ts +2 -0
- package/dist/{composables-CFSn4NN3.js → composables-DrE6OcZZ.js} +2 -2
- package/dist/{composables-CFSn4NN3.js.map → composables-DrE6OcZZ.js.map} +1 -1
- package/dist/index.js +5 -5
- package/dist/install.js +3 -3
- package/dist/styles.css +1497 -1453
- package/dist/types/auto-group.d.ts +19 -0
- package/dist/{useProtocolTemplates-CXP2ZosM.js → useProtocolTemplates-BbvlHoPD.js} +218 -90
- package/dist/useProtocolTemplates-BbvlHoPD.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AutoGroupModal.preview.test.ts +46 -0
- package/src/__tests__/composables/autoGroup/classKey.test.ts +25 -0
- package/src/__tests__/composables/autoGroup/fingerprint.test.ts +72 -0
- package/src/__tests__/composables/autoGroup/groupTree.test.ts +99 -0
- package/src/__tests__/composables/autoGroup/tokenLength.test.ts +85 -0
- package/src/__tests__/composables/useAutoGroup.test.ts +111 -19
- package/src/components/AutoGroupModal.vue +23 -19
- package/src/components/BaseModal.story.vue +7 -15
- package/src/components/ExperimentDataViewer.vue +1 -0
- package/src/components/ExperimentPopover.vue +6 -4
- package/src/components/ExperimentSelectorModal.vue +30 -3
- package/src/components/IconButton.story.vue +5 -0
- package/src/components/SampleSelector.vue +3 -2
- package/src/components/SampleSelectorSampleRow.vue +4 -2
- package/src/composables/autoGroup/classKey.ts +5 -2
- package/src/composables/autoGroup/columns.ts +2 -2
- package/src/composables/autoGroup/compose.ts +56 -0
- package/src/composables/autoGroup/fingerprint.ts +15 -1
- package/src/composables/autoGroup/index.ts +2 -0
- package/src/composables/autoGroup/replicatePreGroup.ts +34 -0
- package/src/composables/autoGroup/template.ts +2 -2
- package/src/composables/autoGroup/tokenLength.ts +53 -0
- package/src/composables/autoGroup/vocab.json +1 -2
- package/src/composables/useAutoGroup.ts +34 -13
- package/src/styles/components/auto-group-modal.css +7 -11
- package/src/styles/components/button.css +10 -3
- package/src/styles/components/modal.css +3 -0
- package/src/styles/components/sample-selector.css +17 -0
- package/src/styles/variables.css +8 -0
- package/src/types/auto-group.ts +19 -0
- package/dist/ExperimentPopover-mzmSfAUp.js.map +0 -1
- package/dist/ExperimentSelectorModal-Bn0Hmg07.js.map +0 -1
- package/dist/components-Cyi0IfRl.js.map +0 -1
- 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.
|
|
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
|
|
288
|
-
// The
|
|
289
|
-
//
|
|
290
|
-
// (
|
|
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
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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
|
-
|
|
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]
|
|
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.
|
|
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
|
-
<
|
|
1048
|
-
v-
|
|
1049
|
-
:
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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">
|