@morscherlab/mint-sdk 1.1.8 → 1.1.10
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/__tests__/components/RetiredStyles.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
- package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
- package/dist/components/AppTopBar.vue.d.ts +6 -11
- package/dist/components/AutoGroupModal.adapter.d.ts +22 -7
- package/dist/components/PluginIcon.vue.d.ts +2 -2
- package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
- package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
- package/dist/components/SampleSelector.groups.d.ts +7 -7
- package/dist/components/SampleSelector.selection.d.ts +6 -12
- package/dist/components/SampleSelector.tree.d.ts +39 -0
- package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
- package/dist/components/SmartGroup.types.d.ts +5 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-CDomhGSj.js → components-HXV-ZkBg.js} +939 -948
- package/dist/components-HXV-ZkBg.js.map +1 -0
- package/dist/composables/index.d.ts +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/composables/useAutoGroup.d.ts +3 -0
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/composables/useSampleGroups.d.ts +25 -0
- package/dist/index.js +2 -2
- package/dist/install.js +1 -1
- package/dist/styles.css +130 -557
- package/dist/types/platform.d.ts +1 -0
- package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-D3IZKLqr.js} +198 -24
- package/dist/useProtocolTemplates-D3IZKLqr.js.map +1 -0
- package/dist/utils/pluginIcon.d.ts +1 -1
- package/dist/utils/sampleGroupPath.d.ts +6 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
- package/src/__tests__/components/AppTopBar.test.ts +84 -10
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
- package/src/__tests__/components/AutoGroupModal.adapter.test.ts +47 -7
- package/src/__tests__/components/PluginIcon.test.ts +19 -0
- package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
- package/src/__tests__/components/RetiredStyles.test.ts +82 -0
- package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
- package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
- package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
- package/src/__tests__/components/SampleSelector.test.ts +35 -0
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
- package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
- package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +278 -0
- package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
- package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
- package/src/components/AppTopBar.story.vue +9 -7
- package/src/components/AppTopBar.vue +79 -72
- package/src/components/AutoGroupModal.adapter.ts +69 -13
- package/src/components/AutoGroupModal.story.vue +89 -0
- package/src/components/AutoGroupModal.vue +18 -8
- package/src/components/PluginIcon.story.vue +11 -9
- package/src/components/PluginIcon.vue +3 -3
- package/src/components/PluginWorkspaceView.props.ts +5 -7
- package/src/components/PluginWorkspaceView.vue +0 -4
- package/src/components/SampleSelector.drag.ts +5 -5
- package/src/components/SampleSelector.groups.ts +15 -15
- package/src/components/SampleSelector.selection.ts +7 -46
- package/src/components/SampleSelector.story.vue +37 -0
- package/src/components/SampleSelector.tree.ts +48 -0
- package/src/components/SampleSelector.vue +52 -320
- package/src/components/SampleSelectorGroupNode.vue +197 -0
- package/src/components/SmartGroup.types.ts +5 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
- package/src/components/SmartGroupFieldRecipe.vue +76 -4
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
- package/src/composables/autoGroup/compose.ts +31 -10
- package/src/composables/index.ts +1 -0
- package/src/composables/useAutoGroup.ts +112 -5
- package/src/composables/useSampleGroups.ts +118 -33
- package/src/styles/components/app-avatar-menu.css +2 -1
- package/src/styles/components/app-plugin-switcher.css +0 -13
- package/src/styles/components/app-top-bar.css +13 -54
- package/src/styles/components/dose-calculator.css +0 -4
- package/src/styles/components/experiment-popover.css +2 -104
- package/src/styles/components/experiment-selector-modal.css +0 -7
- package/src/styles/components/plugin-icon.css +3 -0
- package/src/styles/components/protocol-step-editor.css +0 -6
- package/src/styles/components/reagent-editor.css +0 -5
- package/src/styles/components/sample-hierarchy-tree.css +0 -4
- package/src/styles/components/sample-selector.css +0 -96
- package/src/styles/components/schedule-calendar.css +0 -6
- package/src/styles/components/smart-group.css +22 -0
- package/src/styles/components/well-plate.css +1 -27
- package/src/types/platform.ts +1 -0
- package/src/utils/pluginIcon.ts +5 -2
- package/src/utils/sampleGroupPath.ts +19 -0
- package/dist/components-CDomhGSj.js.map +0 -1
- package/dist/useProtocolTemplates-BMt0LlXf.js.map +0 -1
|
@@ -42,15 +42,14 @@ describe('composeGroups', () => {
|
|
|
42
42
|
},
|
|
43
43
|
],
|
|
44
44
|
})
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// collide when they share a factor value.
|
|
45
|
+
// A single experimental class needs no disambiguating prefix: group names
|
|
46
|
+
// start directly at the first enabled factor layer.
|
|
48
47
|
expect(result.experimentalGroups!.map(g => g.name).sort()).toEqual([
|
|
49
|
-
'
|
|
50
|
-
'
|
|
48
|
+
'Kidney',
|
|
49
|
+
'Liver',
|
|
51
50
|
])
|
|
52
51
|
expect(
|
|
53
|
-
result.experimentalGroups!.find(g => g.name === '
|
|
52
|
+
result.experimentalGroups!.find(g => g.name === 'Kidney')!.samples,
|
|
54
53
|
).toEqual(['s1', 's2'])
|
|
55
54
|
})
|
|
56
55
|
|
|
@@ -184,8 +183,8 @@ describe('composeGroups', () => {
|
|
|
184
183
|
],
|
|
185
184
|
})
|
|
186
185
|
expect(result.experimentalGroups!.length).toBe(1)
|
|
187
|
-
//
|
|
188
|
-
expect(result.experimentalGroups![0].name).toBe('
|
|
186
|
+
// Single experimental class → unprefixed post-alias factor value.
|
|
187
|
+
expect(result.experimentalGroups![0].name).toBe('Drug-A')
|
|
189
188
|
expect(result.experimentalGroups![0].samples).toEqual(['s1', 's2'])
|
|
190
189
|
expect(result.excludedSamples).toEqual(['s3'])
|
|
191
190
|
})
|
|
@@ -221,7 +220,7 @@ describe('composeGroups', () => {
|
|
|
221
220
|
],
|
|
222
221
|
})
|
|
223
222
|
expect(result.metadata[0].fields.Tissue).toBe('kidney_panel')
|
|
224
|
-
//
|
|
225
|
-
expect(result.metadata[0].group).toBe('
|
|
223
|
+
// Single experimental class → unprefixed post-alias value as the group label.
|
|
224
|
+
expect(result.metadata[0].group).toBe('kidney_panel')
|
|
226
225
|
})
|
|
227
226
|
})
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { useAutoGroup, parseCSV } from '../../../composables/useAutoGroup'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Layer-order behaviour of the auto-group engine: `groupBy` is an ORDERED list
|
|
6
|
+
* of hierarchy layers (first entry = the sample selector's major group), user
|
|
7
|
+
* toggles append at the end, and `moveGroupBy` reorders layers explicitly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function structuredEngine() {
|
|
11
|
+
const auto = useAutoGroup()
|
|
12
|
+
auto.inputMode.value = 'csv'
|
|
13
|
+
auto.csvData.value = parseCSV([
|
|
14
|
+
'Sample,Tissue,Timepoint,Dose',
|
|
15
|
+
'S1,Kidney,5min,High',
|
|
16
|
+
'S2,Kidney,10min,Low',
|
|
17
|
+
'S3,Liver,5min,High',
|
|
18
|
+
].join('\n'))
|
|
19
|
+
auto.parseInput()
|
|
20
|
+
return auto
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('useAutoGroup — groupBy layer order', () => {
|
|
24
|
+
it('should append a re-enabled column at the end of the layer order when toggled back on', () => {
|
|
25
|
+
const auto = structuredEngine()
|
|
26
|
+
const key = auto.activeClassKey.value
|
|
27
|
+
auto.toggleGroupBy(key, 0)
|
|
28
|
+
auto.toggleGroupBy(key, 0)
|
|
29
|
+
expect(auto.activeSchema.value?.groupBy).toEqual([1, 2, 0])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should compose group names following the toggle-defined layer order', () => {
|
|
33
|
+
const auto = structuredEngine()
|
|
34
|
+
const key = auto.activeClassKey.value
|
|
35
|
+
auto.toggleGroupBy(key, 0)
|
|
36
|
+
auto.toggleGroupBy(key, 0)
|
|
37
|
+
expect(auto.groups.value.map(g => g.name).sort()).toEqual([
|
|
38
|
+
'10min / Low / Kidney',
|
|
39
|
+
'5min / High / Kidney',
|
|
40
|
+
'5min / High / Liver',
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should move a layer toward the front when moveGroupBy direction is up', () => {
|
|
45
|
+
const auto = structuredEngine()
|
|
46
|
+
const key = auto.activeClassKey.value
|
|
47
|
+
auto.moveGroupBy(key, 1, 'up')
|
|
48
|
+
expect(auto.activeSchema.value?.groupBy).toEqual([1, 0, 2])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should compose group names following the moved layer order', () => {
|
|
52
|
+
const auto = structuredEngine()
|
|
53
|
+
const key = auto.activeClassKey.value
|
|
54
|
+
auto.moveGroupBy(key, 1, 'up')
|
|
55
|
+
expect(auto.groups.value.map(g => g.name).sort()).toEqual([
|
|
56
|
+
'10min / Kidney / Low',
|
|
57
|
+
'5min / Kidney / High',
|
|
58
|
+
'5min / Liver / High',
|
|
59
|
+
])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should ignore moveGroupBy up when the layer is already first', () => {
|
|
63
|
+
const auto = structuredEngine()
|
|
64
|
+
const key = auto.activeClassKey.value
|
|
65
|
+
auto.moveGroupBy(key, 0, 'up')
|
|
66
|
+
expect(auto.activeSchema.value?.groupBy).toEqual([0, 1, 2])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should ignore moveGroupBy down when the layer is already last', () => {
|
|
70
|
+
const auto = structuredEngine()
|
|
71
|
+
const key = auto.activeClassKey.value
|
|
72
|
+
auto.moveGroupBy(key, 2, 'down')
|
|
73
|
+
expect(auto.activeSchema.value?.groupBy).toEqual([0, 1, 2])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should ignore moveGroupBy for a column that is not part of groupBy', () => {
|
|
77
|
+
const auto = structuredEngine()
|
|
78
|
+
const key = auto.activeClassKey.value
|
|
79
|
+
auto.toggleGroupBy(key, 1) // disable Timepoint
|
|
80
|
+
auto.moveGroupBy(key, 1, 'up')
|
|
81
|
+
expect(auto.activeSchema.value?.groupBy).toEqual([0, 2])
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('useAutoGroup — field edits span every class exposing the field', () => {
|
|
86
|
+
/** Two tissue classes sharing a Timepoint × Dose schema. */
|
|
87
|
+
function twoClassEngine() {
|
|
88
|
+
const auto = useAutoGroup()
|
|
89
|
+
const names: string[] = []
|
|
90
|
+
for (const tissue of ['Liver', 'Kidney'])
|
|
91
|
+
for (const time of ['5min', '10min'])
|
|
92
|
+
for (const dose of ['High', 'Low'])
|
|
93
|
+
for (const rep of [1, 2, 3]) names.push(`${tissue}_${time}_${dose}_rep${rep}`)
|
|
94
|
+
auto.rawText.value = names.join('\n')
|
|
95
|
+
auto.parseInput()
|
|
96
|
+
return auto
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function threeFactorClassEngine() {
|
|
100
|
+
const auto = useAutoGroup()
|
|
101
|
+
const names: string[] = []
|
|
102
|
+
for (const tissue of ['Liver', 'Kidney'])
|
|
103
|
+
for (const first of ['A', 'B'])
|
|
104
|
+
for (const second of ['C', 'D'])
|
|
105
|
+
for (const third of ['E', 'F'])
|
|
106
|
+
names.push(`${tissue}_${first}_${second}_${third}_rep1`)
|
|
107
|
+
auto.rawText.value = names.join('\n')
|
|
108
|
+
auto.parseInput()
|
|
109
|
+
return auto
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
it('should reorder the layer in every class when moving a field by name', () => {
|
|
113
|
+
const auto = twoClassEngine()
|
|
114
|
+
auto.moveGroupByField('Token 3', 'up')
|
|
115
|
+
expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2, 1], [2, 1]])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('should move a field from a non-active schema when the active schema has it disabled', () => {
|
|
119
|
+
const auto = twoClassEngine()
|
|
120
|
+
const activeKey = auto.activeClassKey.value
|
|
121
|
+
auto.toggleGroupBy(activeKey, 2)
|
|
122
|
+
|
|
123
|
+
auto.moveGroupByField('Token 3', 'up')
|
|
124
|
+
|
|
125
|
+
expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([[1], [2, 1]])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should converge previously divergent class orders to the active schema order', () => {
|
|
129
|
+
const auto = threeFactorClassEngine()
|
|
130
|
+
const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)!
|
|
131
|
+
auto.moveGroupBy(inactiveKey, 3, 'up')
|
|
132
|
+
auto.moveGroupBy(inactiveKey, 3, 'up')
|
|
133
|
+
|
|
134
|
+
auto.moveGroupByField('Token 4', 'up')
|
|
135
|
+
|
|
136
|
+
expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([
|
|
137
|
+
[1, 3, 2],
|
|
138
|
+
[1, 3, 2],
|
|
139
|
+
])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should keep class-only fields after shared fields in their existing order', () => {
|
|
143
|
+
const auto = threeFactorClassEngine()
|
|
144
|
+
const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)!
|
|
145
|
+
const inactive = auto.schemas.value[inactiveKey]
|
|
146
|
+
auto.schemas.value = {
|
|
147
|
+
...auto.schemas.value,
|
|
148
|
+
[inactiveKey]: {
|
|
149
|
+
...inactive,
|
|
150
|
+
columns: [
|
|
151
|
+
...inactive.columns,
|
|
152
|
+
{
|
|
153
|
+
index: 99,
|
|
154
|
+
name: 'Class-only',
|
|
155
|
+
sourceIndices: [],
|
|
156
|
+
uniqueValues: [],
|
|
157
|
+
cardinality: 0,
|
|
158
|
+
role: 'factor',
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
groupBy: [3, 99, 1, 2],
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
auto.moveGroupByField('Token 4', 'up')
|
|
166
|
+
|
|
167
|
+
expect(auto.schemas.value[inactiveKey].groupBy).toEqual([1, 3, 2, 99])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('should compose consistently ordered names across classes after a field move', () => {
|
|
171
|
+
const auto = twoClassEngine()
|
|
172
|
+
auto.moveGroupByField('Token 3', 'up')
|
|
173
|
+
expect(auto.groups.value.map(g => g.name).sort()).toEqual([
|
|
174
|
+
'Kidney / High / 10min',
|
|
175
|
+
'Kidney / High / 5min',
|
|
176
|
+
'Kidney / Low / 10min',
|
|
177
|
+
'Kidney / Low / 5min',
|
|
178
|
+
'Liver / High / 10min',
|
|
179
|
+
'Liver / High / 5min',
|
|
180
|
+
'Liver / Low / 10min',
|
|
181
|
+
'Liver / Low / 5min',
|
|
182
|
+
])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('should disable a field in every class when toggling it off by name', () => {
|
|
186
|
+
const auto = twoClassEngine()
|
|
187
|
+
auto.toggleGroupByField('Timepoint')
|
|
188
|
+
expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2], [2]])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('should disable a batch field when any class still has it enabled', () => {
|
|
192
|
+
const auto = twoClassEngine()
|
|
193
|
+
// Diverge the classes: disable Timepoint in the non-active class only.
|
|
194
|
+
const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)!
|
|
195
|
+
auto.toggleGroupBy(inactiveKey, 1)
|
|
196
|
+
// The batch card reads ON while either class uses Timepoint.
|
|
197
|
+
auto.toggleGroupByField('Timepoint')
|
|
198
|
+
expect(Object.values(auto.schemas.value).every(s => !s.groupBy.includes(1))).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should append a re-enabled field as the last layer in every class', () => {
|
|
202
|
+
const auto = twoClassEngine()
|
|
203
|
+
auto.toggleGroupByField('Timepoint')
|
|
204
|
+
auto.toggleGroupByField('Timepoint')
|
|
205
|
+
expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2, 1], [2, 1]])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('should not enable a same-named constant column when re-enabling a batch field', () => {
|
|
209
|
+
const auto = twoClassEngine()
|
|
210
|
+
const activeKey = auto.activeClassKey.value
|
|
211
|
+
const active = auto.schemas.value[activeKey]
|
|
212
|
+
auto.schemas.value = {
|
|
213
|
+
...auto.schemas.value,
|
|
214
|
+
[activeKey]: {
|
|
215
|
+
...active,
|
|
216
|
+
columns: active.columns.map(column => column.index === 2 ? { ...column, role: 'constant' } : column),
|
|
217
|
+
groupBy: active.groupBy.filter(index => index !== 2),
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
auto.toggleGroupByField('Token 3')
|
|
222
|
+
auto.toggleGroupByField('Token 3')
|
|
223
|
+
|
|
224
|
+
expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([[1], [1, 2]])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should ignore a field name that no class exposes', () => {
|
|
228
|
+
const auto = twoClassEngine()
|
|
229
|
+
auto.moveGroupByField('Nonexistent', 'up')
|
|
230
|
+
expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[1, 2], [1, 2]])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('useAutoGroup — class prefix only when disambiguation is needed', () => {
|
|
235
|
+
it('should not prefix group names when a single experimental class emits groups', () => {
|
|
236
|
+
const auto = useAutoGroup()
|
|
237
|
+
auto.rawText.value = [
|
|
238
|
+
'Liver_5min_High_rep1',
|
|
239
|
+
'Liver_5min_High_rep2',
|
|
240
|
+
'Liver_10min_Low_rep1',
|
|
241
|
+
'Liver_10min_Low_rep2',
|
|
242
|
+
].join('\n')
|
|
243
|
+
auto.parseInput()
|
|
244
|
+
expect(auto.groups.value.map(g => g.name).sort()).toEqual([
|
|
245
|
+
'10min / Low',
|
|
246
|
+
'5min / High',
|
|
247
|
+
])
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('should prefix each class with its short distinguishing label when several experimental classes emit groups', () => {
|
|
251
|
+
const auto = useAutoGroup()
|
|
252
|
+
auto.rawText.value = [
|
|
253
|
+
'Liver_5min_High_rep1',
|
|
254
|
+
'Liver_10min_Low_rep1',
|
|
255
|
+
'Kidney_5min_High_rep1',
|
|
256
|
+
'Kidney_10min_Low_rep1',
|
|
257
|
+
].join('\n')
|
|
258
|
+
auto.parseInput()
|
|
259
|
+
expect(auto.groups.value.map(g => g.name).sort()).toEqual([
|
|
260
|
+
'Kidney / 10min / Low',
|
|
261
|
+
'Kidney / 5min / High',
|
|
262
|
+
'Liver / 10min / Low',
|
|
263
|
+
'Liver / 5min / High',
|
|
264
|
+
])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('should keep overlay QC groups unprefixed and unchanged', () => {
|
|
268
|
+
const auto = useAutoGroup()
|
|
269
|
+
auto.rawText.value = [
|
|
270
|
+
'Liver_5min_rep1',
|
|
271
|
+
'Liver_10min_rep1',
|
|
272
|
+
'IQC_001',
|
|
273
|
+
'IQC_002',
|
|
274
|
+
].join('\n')
|
|
275
|
+
auto.parseInput()
|
|
276
|
+
expect(auto.qcGroups.value.map(g => g.name)).toEqual(['IQC'])
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -68,12 +68,8 @@ describe('mixed LC-MS batch integration', () => {
|
|
|
68
68
|
expect(result.experimentalGroups!.length).toBe(3)
|
|
69
69
|
expect(result.experimentalGroups!.every(g => g.samples.length === 27)).toBe(true)
|
|
70
70
|
const names = result.experimentalGroups!.map(g => g.name).sort()
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
expect(names).toEqual([
|
|
74
|
-
'Biological / Tissues / Kidney',
|
|
75
|
-
'Biological / Tissues / Liver',
|
|
76
|
-
'Biological / Tissues / Tumor',
|
|
77
|
-
])
|
|
71
|
+
// Only the Tissues class is passed here, so no disambiguating class prefix
|
|
72
|
+
// is applied: the organ factor is the first (major) hierarchy layer.
|
|
73
|
+
expect(names).toEqual(['Kidney', 'Liver', 'Tumor'])
|
|
78
74
|
})
|
|
79
75
|
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { useSampleGroups } from '../../composables/useSampleGroups'
|
|
3
|
+
import type { SampleGroup } from '../../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `groupTree` is the full N-layer display model behind the sample selector:
|
|
7
|
+
* interior nodes are path prefixes, leaves carry the concrete `SampleGroup`
|
|
8
|
+
* (whose `name` stays the full path, since every mutation keys off it).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function labelsAtDepth(nodes: ReturnType<typeof useSampleGroups>['groupTree']['value'], depth: number): string[] {
|
|
12
|
+
let level = nodes
|
|
13
|
+
for (let i = 0; i < depth; i++) level = level.flatMap(n => n.children)
|
|
14
|
+
return level.map(n => n.label)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('useSampleGroups groupTree', () => {
|
|
18
|
+
const threeLayer: SampleGroup[] = [
|
|
19
|
+
{ name: 'Liver / High / 5min', color: '#3B82F6', samples: ['L1', 'L2'] },
|
|
20
|
+
{ name: 'Liver / High / 10min', color: '#3B82F6', samples: ['L3'] },
|
|
21
|
+
{ name: 'Liver / Low / 5min', color: '#3B82F6', samples: ['L4'] },
|
|
22
|
+
{ name: 'Kidney / High / 5min', color: '#F43F5E', samples: ['K1'] },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
it('should build one level per name segment', () => {
|
|
26
|
+
const { groupTree } = useSampleGroups({ groups: threeLayer })
|
|
27
|
+
expect(labelsAtDepth(groupTree.value, 0)).toEqual(['Liver', 'Kidney'])
|
|
28
|
+
expect(labelsAtDepth(groupTree.value, 1)).toEqual(['High', 'Low', 'High'])
|
|
29
|
+
expect(labelsAtDepth(groupTree.value, 2)).toEqual(['5min', '10min', '5min', '5min'])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should label each node with its own segment rather than the full path', () => {
|
|
33
|
+
const { groupTree } = useSampleGroups({ groups: threeLayer })
|
|
34
|
+
expect(groupTree.value[0].children[0].label).toBe('High')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should keep the full path on the leaf group so mutations still resolve it', () => {
|
|
38
|
+
const { groupTree } = useSampleGroups({ groups: threeLayer })
|
|
39
|
+
expect(groupTree.value[0].children[0].children[0].group?.name).toBe('Liver / High / 5min')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should aggregate every descendant sample onto an interior node', () => {
|
|
43
|
+
const { groupTree } = useSampleGroups({ groups: threeLayer })
|
|
44
|
+
expect(groupTree.value[0].allSamples).toEqual(['L1', 'L2', 'L3', 'L4'])
|
|
45
|
+
expect(groupTree.value[0].children[0].allSamples).toEqual(['L1', 'L2', 'L3'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should mark only childless nodes as leaves', () => {
|
|
49
|
+
const { groupTree } = useSampleGroups({ groups: threeLayer })
|
|
50
|
+
expect(groupTree.value[0].children).not.toHaveLength(0)
|
|
51
|
+
expect(groupTree.value[0].group).toBeUndefined()
|
|
52
|
+
expect(groupTree.value[0].children[0].children[0].children).toHaveLength(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should give every node a unique key so expansion state cannot collide', () => {
|
|
56
|
+
const { groupTree } = useSampleGroups({ groups: threeLayer })
|
|
57
|
+
const keys: string[] = []
|
|
58
|
+
const walk = (nodes: typeof groupTree.value) => {
|
|
59
|
+
for (const n of nodes) {
|
|
60
|
+
keys.push(n.key)
|
|
61
|
+
walk(n.children)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
walk(groupTree.value)
|
|
65
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should keep two-segment names at two levels', () => {
|
|
69
|
+
const { groupTree } = useSampleGroups({
|
|
70
|
+
groups: [{ name: 'Control/A', color: '#3B82F6', samples: ['S1'] }],
|
|
71
|
+
})
|
|
72
|
+
expect(labelsAtDepth(groupTree.value, 0)).toEqual(['Control'])
|
|
73
|
+
expect(labelsAtDepth(groupTree.value, 1)).toEqual(['A'])
|
|
74
|
+
expect(labelsAtDepth(groupTree.value, 2)).toEqual([])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should keep an unsegmented name as a single root leaf', () => {
|
|
78
|
+
const { groupTree } = useSampleGroups({
|
|
79
|
+
groups: [{ name: 'Control', color: '#3B82F6', samples: ['S1'] }],
|
|
80
|
+
})
|
|
81
|
+
expect(groupTree.value).toHaveLength(1)
|
|
82
|
+
expect(groupTree.value[0].children).toHaveLength(0)
|
|
83
|
+
expect(groupTree.value[0].group?.name).toBe('Control')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should nest mixed-depth siblings under the same parent', () => {
|
|
87
|
+
const { groupTree } = useSampleGroups({
|
|
88
|
+
groups: [
|
|
89
|
+
{ name: 'Liver / High / 5min', color: '#3B82F6', samples: ['S1'] },
|
|
90
|
+
{ name: 'Liver / Low', color: '#3B82F6', samples: ['S2'] },
|
|
91
|
+
],
|
|
92
|
+
})
|
|
93
|
+
expect(labelsAtDepth(groupTree.value, 1)).toEqual(['High', 'Low'])
|
|
94
|
+
// "Low" is a leaf at depth 1; "High" is interior with a leaf beneath it.
|
|
95
|
+
expect(groupTree.value[0].children[1].group?.name).toBe('Liver / Low')
|
|
96
|
+
expect(groupTree.value[0].children[0].children[0].group?.name).toBe('Liver / High / 5min')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should expose a group whose path duplicates an interior node as its own leaf', () => {
|
|
100
|
+
// "Liver" is both a standalone group and the prefix of another: the
|
|
101
|
+
// standalone group must stay reachable as a leaf, or its samples vanish.
|
|
102
|
+
const { groupTree } = useSampleGroups({
|
|
103
|
+
groups: [
|
|
104
|
+
{ name: 'Liver', color: '#3B82F6', samples: ['S1'] },
|
|
105
|
+
{ name: 'Liver / High', color: '#3B82F6', samples: ['S2'] },
|
|
106
|
+
],
|
|
107
|
+
})
|
|
108
|
+
const allLeafGroups: string[] = []
|
|
109
|
+
const walk = (nodes: typeof groupTree.value) => {
|
|
110
|
+
for (const n of nodes) {
|
|
111
|
+
if (n.group) allLeafGroups.push(n.group.name)
|
|
112
|
+
walk(n.children)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
walk(groupTree.value)
|
|
116
|
+
expect(allLeafGroups.sort()).toEqual(['Liver', 'Liver / High'])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should tolerate whitespace around separators when segmenting', () => {
|
|
120
|
+
const { groupTree } = useSampleGroups({
|
|
121
|
+
groups: [{ name: 'Liver / High', color: '#3B82F6', samples: ['S1'] }],
|
|
122
|
+
})
|
|
123
|
+
expect(groupTree.value[0].label).toBe('Liver')
|
|
124
|
+
expect(groupTree.value[0].children[0].label).toBe('High')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('useSampleGroups hierarchy depth', () => {
|
|
129
|
+
it('should report the deepest layer count', () => {
|
|
130
|
+
const { maxDepth } = useSampleGroups({
|
|
131
|
+
groups: [
|
|
132
|
+
{ name: 'Liver / High / 5min', color: '#3B82F6', samples: ['S1'] },
|
|
133
|
+
{ name: 'Kidney / Low', color: '#F43F5E', samples: ['S2'] },
|
|
134
|
+
],
|
|
135
|
+
})
|
|
136
|
+
expect(maxDepth.value).toBe(3)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should report depth 1 for flat groups', () => {
|
|
140
|
+
const { maxDepth } = useSampleGroups({
|
|
141
|
+
groups: [{ name: 'Control', color: '#3B82F6', samples: ['S1'] }],
|
|
142
|
+
})
|
|
143
|
+
expect(maxDepth.value).toBe(1)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -30,14 +30,13 @@ const samplePageSelector: PageSelectorItem[] = [
|
|
|
30
30
|
const sampleAccountMenu: AccountMenuItem[] = [
|
|
31
31
|
{ id: 'profile', label: 'Profile' },
|
|
32
32
|
{ id: 'settings', label: 'Settings' },
|
|
33
|
-
{ id: 'admin', label: 'Admin', to: '/admin' },
|
|
34
33
|
]
|
|
35
34
|
|
|
36
35
|
</script>
|
|
37
36
|
|
|
38
37
|
<template>
|
|
39
38
|
<Story title="Layout/AppTopBar">
|
|
40
|
-
<!-- ───────────────
|
|
39
|
+
<!-- ─────────────── UNIFIED PLATFORM LAYOUTS ─────────────── -->
|
|
41
40
|
|
|
42
41
|
<Variant title="Refresh · Experiments-view layout">
|
|
43
42
|
<!-- Exact layout used by the platform's Experiments and Projects pages:
|
|
@@ -51,6 +50,7 @@ const sampleAccountMenu: AccountMenuItem[] = [
|
|
|
51
50
|
:pill-nav="samplePillNav.slice(1, 3)"
|
|
52
51
|
current-pill-id="experiments"
|
|
53
52
|
show-theme-toggle
|
|
53
|
+
show-admin
|
|
54
54
|
:show-standalone-label="false"
|
|
55
55
|
:account-menu="sampleAccountMenu"
|
|
56
56
|
user-name="Estrella"
|
|
@@ -84,6 +84,7 @@ const sampleAccountMenu: AccountMenuItem[] = [
|
|
|
84
84
|
:pill-nav="samplePillNav"
|
|
85
85
|
current-pill-id="workspace"
|
|
86
86
|
show-theme-toggle
|
|
87
|
+
show-admin
|
|
87
88
|
:account-menu="sampleAccountMenu"
|
|
88
89
|
user-name="Estrella"
|
|
89
90
|
user-initial="ES"
|
|
@@ -104,6 +105,7 @@ const sampleAccountMenu: AccountMenuItem[] = [
|
|
|
104
105
|
:pill-nav="samplePillNav"
|
|
105
106
|
current-pill-id="experiments"
|
|
106
107
|
show-theme-toggle
|
|
108
|
+
show-admin
|
|
107
109
|
:account-menu="sampleAccountMenu"
|
|
108
110
|
user-name="Estrella"
|
|
109
111
|
user-initial="ES"
|
|
@@ -191,9 +193,9 @@ const sampleAccountMenu: AccountMenuItem[] = [
|
|
|
191
193
|
</div>
|
|
192
194
|
</Variant>
|
|
193
195
|
|
|
194
|
-
<!-- ───────────────
|
|
196
|
+
<!-- ─────────────── BASE COMPOSITION EXAMPLES ─────────────── -->
|
|
195
197
|
|
|
196
|
-
<Variant title="
|
|
198
|
+
<Variant title="Base · Playground">
|
|
197
199
|
<template #default="{ state }">
|
|
198
200
|
<div style="padding: 2rem;">
|
|
199
201
|
<AppTopBar
|
|
@@ -221,7 +223,7 @@ const sampleAccountMenu: AccountMenuItem[] = [
|
|
|
221
223
|
</template>
|
|
222
224
|
</Variant>
|
|
223
225
|
|
|
224
|
-
<Variant title="
|
|
226
|
+
<Variant title="Base · Title + Subtitle">
|
|
225
227
|
<div style="padding: 2rem;">
|
|
226
228
|
<AppTopBar
|
|
227
229
|
title="Dose-Response Analysis"
|
|
@@ -256,13 +258,13 @@ const sampleAccountMenu: AccountMenuItem[] = [
|
|
|
256
258
|
</div>
|
|
257
259
|
</Variant>
|
|
258
260
|
|
|
259
|
-
<Variant title="
|
|
261
|
+
<Variant title="Base · Minimal (logo only)">
|
|
260
262
|
<div style="padding: 2rem;">
|
|
261
263
|
<AppTopBar :show-logo="true" home-path="/" />
|
|
262
264
|
</div>
|
|
263
265
|
</Variant>
|
|
264
266
|
|
|
265
|
-
<Variant title="
|
|
267
|
+
<Variant title="Base · Experiment selector integration">
|
|
266
268
|
<div style="padding: 2rem;">
|
|
267
269
|
<ExperimentProvider>
|
|
268
270
|
<AppTopBar
|