@morscherlab/mint-sdk 1.1.8 → 1.1.9

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 (90) hide show
  1. package/dist/__tests__/components/RetiredStyles.test.d.ts +1 -0
  2. package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
  4. package/dist/components/AppTopBar.vue.d.ts +6 -11
  5. package/dist/components/AutoGroupModal.adapter.d.ts +10 -6
  6. package/dist/components/PluginIcon.vue.d.ts +2 -2
  7. package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
  8. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
  9. package/dist/components/SampleSelector.groups.d.ts +7 -7
  10. package/dist/components/SampleSelector.selection.d.ts +6 -12
  11. package/dist/components/SampleSelector.tree.d.ts +39 -0
  12. package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
  13. package/dist/components/SmartGroup.types.d.ts +5 -0
  14. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
  15. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
  16. package/dist/components/index.js +1 -1
  17. package/dist/{components-CDomhGSj.js → components-BbtUpCTv.js} +913 -943
  18. package/dist/components-BbtUpCTv.js.map +1 -0
  19. package/dist/composables/index.d.ts +1 -1
  20. package/dist/composables/index.js +1 -1
  21. package/dist/composables/useAutoGroup.d.ts +3 -0
  22. package/dist/composables/usePlatformContext.d.ts +3 -0
  23. package/dist/composables/useSampleGroups.d.ts +25 -0
  24. package/dist/index.js +2 -2
  25. package/dist/install.js +1 -1
  26. package/dist/styles.css +130 -557
  27. package/dist/types/platform.d.ts +1 -0
  28. package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-BWfNaynA.js} +199 -24
  29. package/dist/useProtocolTemplates-BWfNaynA.js.map +1 -0
  30. package/dist/utils/pluginIcon.d.ts +1 -1
  31. package/dist/utils/sampleGroupPath.d.ts +6 -0
  32. package/package.json +1 -1
  33. package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
  34. package/src/__tests__/components/AppTopBar.test.ts +84 -10
  35. package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
  36. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +16 -6
  37. package/src/__tests__/components/PluginIcon.test.ts +19 -0
  38. package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
  39. package/src/__tests__/components/RetiredStyles.test.ts +82 -0
  40. package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
  41. package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
  42. package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
  43. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
  44. package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
  45. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +249 -0
  46. package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
  47. package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
  48. package/src/components/AppTopBar.story.vue +9 -7
  49. package/src/components/AppTopBar.vue +79 -72
  50. package/src/components/AutoGroupModal.adapter.ts +17 -12
  51. package/src/components/AutoGroupModal.story.vue +89 -0
  52. package/src/components/AutoGroupModal.vue +16 -8
  53. package/src/components/PluginIcon.story.vue +11 -9
  54. package/src/components/PluginIcon.vue +3 -3
  55. package/src/components/PluginWorkspaceView.props.ts +5 -7
  56. package/src/components/PluginWorkspaceView.vue +0 -4
  57. package/src/components/SampleSelector.drag.ts +5 -5
  58. package/src/components/SampleSelector.groups.ts +15 -15
  59. package/src/components/SampleSelector.selection.ts +7 -46
  60. package/src/components/SampleSelector.story.vue +37 -0
  61. package/src/components/SampleSelector.tree.ts +48 -0
  62. package/src/components/SampleSelector.vue +52 -320
  63. package/src/components/SampleSelectorGroupNode.vue +197 -0
  64. package/src/components/SmartGroup.types.ts +5 -0
  65. package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
  66. package/src/components/SmartGroupFieldRecipe.vue +76 -4
  67. package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
  68. package/src/composables/autoGroup/compose.ts +31 -10
  69. package/src/composables/index.ts +1 -0
  70. package/src/composables/useAutoGroup.ts +108 -5
  71. package/src/composables/useSampleGroups.ts +118 -33
  72. package/src/styles/components/app-avatar-menu.css +2 -1
  73. package/src/styles/components/app-plugin-switcher.css +0 -13
  74. package/src/styles/components/app-top-bar.css +13 -54
  75. package/src/styles/components/dose-calculator.css +0 -4
  76. package/src/styles/components/experiment-popover.css +2 -104
  77. package/src/styles/components/experiment-selector-modal.css +0 -7
  78. package/src/styles/components/plugin-icon.css +3 -0
  79. package/src/styles/components/protocol-step-editor.css +0 -6
  80. package/src/styles/components/reagent-editor.css +0 -5
  81. package/src/styles/components/sample-hierarchy-tree.css +0 -4
  82. package/src/styles/components/sample-selector.css +0 -96
  83. package/src/styles/components/schedule-calendar.css +0 -6
  84. package/src/styles/components/smart-group.css +22 -0
  85. package/src/styles/components/well-plate.css +1 -27
  86. package/src/types/platform.ts +1 -0
  87. package/src/utils/pluginIcon.ts +5 -2
  88. package/src/utils/sampleGroupPath.ts +19 -0
  89. package/dist/components-CDomhGSj.js.map +0 -1
  90. package/dist/useProtocolTemplates-BMt0LlXf.js.map +0 -1
@@ -0,0 +1,249 @@
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 converge previously divergent class orders to the active schema order', () => {
119
+ const auto = threeFactorClassEngine()
120
+ const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)!
121
+ auto.moveGroupBy(inactiveKey, 3, 'up')
122
+ auto.moveGroupBy(inactiveKey, 3, 'up')
123
+
124
+ auto.moveGroupByField('Token 4', 'up')
125
+
126
+ expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([
127
+ [1, 3, 2],
128
+ [1, 3, 2],
129
+ ])
130
+ })
131
+
132
+ it('should keep class-only fields after shared fields in their existing order', () => {
133
+ const auto = threeFactorClassEngine()
134
+ const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)!
135
+ const inactive = auto.schemas.value[inactiveKey]
136
+ auto.schemas.value = {
137
+ ...auto.schemas.value,
138
+ [inactiveKey]: {
139
+ ...inactive,
140
+ columns: [
141
+ ...inactive.columns,
142
+ {
143
+ index: 99,
144
+ name: 'Class-only',
145
+ sourceIndices: [],
146
+ uniqueValues: [],
147
+ cardinality: 0,
148
+ role: 'factor',
149
+ },
150
+ ],
151
+ groupBy: [3, 99, 1, 2],
152
+ },
153
+ }
154
+
155
+ auto.moveGroupByField('Token 4', 'up')
156
+
157
+ expect(auto.schemas.value[inactiveKey].groupBy).toEqual([1, 3, 2, 99])
158
+ })
159
+
160
+ it('should compose consistently ordered names across classes after a field move', () => {
161
+ const auto = twoClassEngine()
162
+ auto.moveGroupByField('Token 3', 'up')
163
+ expect(auto.groups.value.map(g => g.name).sort()).toEqual([
164
+ 'Kidney / High / 10min',
165
+ 'Kidney / High / 5min',
166
+ 'Kidney / Low / 10min',
167
+ 'Kidney / Low / 5min',
168
+ 'Liver / High / 10min',
169
+ 'Liver / High / 5min',
170
+ 'Liver / Low / 10min',
171
+ 'Liver / Low / 5min',
172
+ ])
173
+ })
174
+
175
+ it('should disable a field in every class when toggling it off by name', () => {
176
+ const auto = twoClassEngine()
177
+ auto.toggleGroupByField('Timepoint')
178
+ expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2], [2]])
179
+ })
180
+
181
+ it('should drive every class to the active class state rather than flipping each independently', () => {
182
+ const auto = twoClassEngine()
183
+ // Diverge the classes: disable Timepoint in the non-active class only.
184
+ const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)!
185
+ auto.toggleGroupBy(inactiveKey, 1)
186
+ // The card reads ON (from the active class), so toggling means OFF everywhere.
187
+ auto.toggleGroupByField('Timepoint')
188
+ expect(Object.values(auto.schemas.value).every(s => !s.groupBy.includes(1))).toBe(true)
189
+ })
190
+
191
+ it('should append a re-enabled field as the last layer in every class', () => {
192
+ const auto = twoClassEngine()
193
+ auto.toggleGroupByField('Timepoint')
194
+ auto.toggleGroupByField('Timepoint')
195
+ expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2, 1], [2, 1]])
196
+ })
197
+
198
+ it('should ignore a field name that no class exposes', () => {
199
+ const auto = twoClassEngine()
200
+ auto.moveGroupByField('Nonexistent', 'up')
201
+ expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[1, 2], [1, 2]])
202
+ })
203
+ })
204
+
205
+ describe('useAutoGroup — class prefix only when disambiguation is needed', () => {
206
+ it('should not prefix group names when a single experimental class emits groups', () => {
207
+ const auto = useAutoGroup()
208
+ auto.rawText.value = [
209
+ 'Liver_5min_High_rep1',
210
+ 'Liver_5min_High_rep2',
211
+ 'Liver_10min_Low_rep1',
212
+ 'Liver_10min_Low_rep2',
213
+ ].join('\n')
214
+ auto.parseInput()
215
+ expect(auto.groups.value.map(g => g.name).sort()).toEqual([
216
+ '10min / Low',
217
+ '5min / High',
218
+ ])
219
+ })
220
+
221
+ it('should prefix each class with its short distinguishing label when several experimental classes emit groups', () => {
222
+ const auto = useAutoGroup()
223
+ auto.rawText.value = [
224
+ 'Liver_5min_High_rep1',
225
+ 'Liver_10min_Low_rep1',
226
+ 'Kidney_5min_High_rep1',
227
+ 'Kidney_10min_Low_rep1',
228
+ ].join('\n')
229
+ auto.parseInput()
230
+ expect(auto.groups.value.map(g => g.name).sort()).toEqual([
231
+ 'Kidney / 10min / Low',
232
+ 'Kidney / 5min / High',
233
+ 'Liver / 10min / Low',
234
+ 'Liver / 5min / High',
235
+ ])
236
+ })
237
+
238
+ it('should keep overlay QC groups unprefixed and unchanged', () => {
239
+ const auto = useAutoGroup()
240
+ auto.rawText.value = [
241
+ 'Liver_5min_rep1',
242
+ 'Liver_10min_rep1',
243
+ 'IQC_001',
244
+ 'IQC_002',
245
+ ].join('\n')
246
+ auto.parseInput()
247
+ expect(auto.qcGroups.value.map(g => g.name)).toEqual(['IQC'])
248
+ })
249
+ })
@@ -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
- // Class-prefixed: "Biological / Tissues / <organ>" disambiguates from
72
- // other classes that might also produce a Kidney/Liver/Tumor factor.
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
- <!-- ─────────────── NEW DESIGN (v5) — Estrella-approved layout ─────────────── -->
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
- <!-- ─────────────── CLASSIC VARIANTS ─────────────── -->
196
+ <!-- ─────────────── BASE COMPOSITION EXAMPLES ─────────────── -->
195
197
 
196
- <Variant title="Classic · Playground">
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="Classic · Title + Subtitle">
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="Classic · Minimal (logo only)">
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="Classic · Experiment selector integration">
267
+ <Variant title="Base · Experiment selector integration">
266
268
  <div style="padding: 2rem;">
267
269
  <ExperimentProvider>
268
270
  <AppTopBar