@morscherlab/mint-sdk 1.1.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.1.9",
3
+ "version": "1.1.10",
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",
@@ -3,13 +3,14 @@ import {
3
3
  candidateColumns,
4
4
  engineFieldsToCards,
5
5
  engineGroupsToRail,
6
+ engineSchemasToCards,
6
7
  engineToQcRoute,
7
8
  manualSamplesToResult,
8
9
  qcRouteToEngine,
9
10
  roleLabel,
10
11
  seedFromGroups,
11
12
  } from '../../components/AutoGroupModal.adapter'
12
- import type { ColumnInfo } from '../../types/auto-group'
13
+ import type { ClassSchema, ColumnInfo } from '../../types/auto-group'
13
14
  import type { SampleGroup } from '../../types'
14
15
  import type { ManualSample } from '../../components/SmartGroupManual.cohorts'
15
16
 
@@ -85,6 +86,35 @@ describe('engineFieldsToCards', () => {
85
86
  })
86
87
  })
87
88
 
89
+ describe('engineSchemasToCards', () => {
90
+ it('should expose enabled fields from another schema when the active schema has none enabled', () => {
91
+ const schemas: Record<string, ClassSchema> = {
92
+ active: {
93
+ classKind: 'unknown',
94
+ columns: [col({ index: 0, name: 'Token 1', role: 'factor', uniqueValues: ['5916', '5918'] })],
95
+ groupBy: [],
96
+ },
97
+ other: {
98
+ classKind: 'unknown',
99
+ columns: [
100
+ col({ index: 0, name: 'Token 1', role: 'constant', uniqueValues: ['sample'] }),
101
+ col({ index: 1, name: 'Token 2', role: 'factor', uniqueValues: ['1x', '2x'] }),
102
+ ],
103
+ groupBy: [1],
104
+ },
105
+ }
106
+
107
+ expect(engineSchemasToCards(schemas, 'active')).toEqual({
108
+ fields: [
109
+ { key: 'Token 1', role: 'Factor', vals: '5916, 5918', rep: false },
110
+ { key: 'Token 2', role: 'Factor', vals: '1x, 2x', rep: false },
111
+ ],
112
+ enabled: [false, true],
113
+ layers: [null, 1],
114
+ })
115
+ })
116
+ })
117
+
88
118
  describe('engineGroupsToRail', () => {
89
119
  it('should map groups to {name, count, color} using sample length', () => {
90
120
  const groups: SampleGroup[] = [{ name: 'Control', color: '#111', samples: ['a', 'b'] }]
@@ -246,6 +246,41 @@ describe('AutoGroupModal manual workflow', () => {
246
246
  })
247
247
  })
248
248
 
249
+ describe('AutoGroupModal multi-schema auto workflow', () => {
250
+ it('should keep apply enabled when another sample schema still has a grouping field', async () => {
251
+ const samples = [
252
+ ...['5916', '5918', '5919', '5920', '5921'].map((id, index) =>
253
+ `${id}_batch_project_matrix_subject_${String(index + 1).padStart(3, '0')}`,
254
+ ),
255
+ ...Array.from({ length: 5 }, (_, index) =>
256
+ `sample_1x_${String(index + 1).padStart(3, '0')}`,
257
+ ),
258
+ ...Array.from({ length: 5 }, (_, index) =>
259
+ `sample_2x_${String(index + 6).padStart(3, '0')}`,
260
+ ),
261
+ 'IQC_Pool_001',
262
+ 'IQC_Pool_002',
263
+ 'IQC_Pool_003',
264
+ ]
265
+ const wrapper = mount(AutoGroupModal, {
266
+ props: {
267
+ modelValue: true,
268
+ samples,
269
+ },
270
+ global: {
271
+ plugins: [createPinia()],
272
+ stubs: {
273
+ Teleport: true,
274
+ },
275
+ },
276
+ })
277
+
278
+ await wrapper.find('.sg-switch').trigger('click')
279
+
280
+ expect(wrapper.find('.sg-foot .mint-button--primary').attributes('disabled')).toBeUndefined()
281
+ })
282
+ })
283
+
249
284
  describe('SampleSelector — QC overlay regression', () => {
250
285
  it('keeps QC samples in the emitted groups via the concat compat rule', async () => {
251
286
  const exp: SampleGroup[] = [
@@ -115,6 +115,16 @@ describe('useAutoGroup — field edits span every class exposing the field', ()
115
115
  expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2, 1], [2, 1]])
116
116
  })
117
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
+
118
128
  it('should converge previously divergent class orders to the active schema order', () => {
119
129
  const auto = threeFactorClassEngine()
120
130
  const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)!
@@ -178,12 +188,12 @@ describe('useAutoGroup — field edits span every class exposing the field', ()
178
188
  expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2], [2]])
179
189
  })
180
190
 
181
- it('should drive every class to the active class state rather than flipping each independently', () => {
191
+ it('should disable a batch field when any class still has it enabled', () => {
182
192
  const auto = twoClassEngine()
183
193
  // Diverge the classes: disable Timepoint in the non-active class only.
184
194
  const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)!
185
195
  auto.toggleGroupBy(inactiveKey, 1)
186
- // The card reads ON (from the active class), so toggling means OFF everywhere.
196
+ // The batch card reads ON while either class uses Timepoint.
187
197
  auto.toggleGroupByField('Timepoint')
188
198
  expect(Object.values(auto.schemas.value).every(s => !s.groupBy.includes(1))).toBe(true)
189
199
  })
@@ -195,6 +205,25 @@ describe('useAutoGroup — field edits span every class exposing the field', ()
195
205
  expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2, 1], [2, 1]])
196
206
  })
197
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
+
198
227
  it('should ignore a field name that no class exposes', () => {
199
228
  const auto = twoClassEngine()
200
229
  auto.moveGroupByField('Nonexistent', 'up')
@@ -4,7 +4,7 @@
4
4
  * `AutoGroupResult`. Framework-free so the mapping is unit-testable in isolation
5
5
  * from `AutoGroupModal.vue`.
6
6
  */
7
- import type { AutoGroupResult, ColumnInfo, ColumnRole } from '../types/auto-group'
7
+ import type { AutoGroupResult, ClassSchema, ColumnInfo, ColumnRole } from '../types/auto-group'
8
8
  import type { SampleGroup } from '../types'
9
9
  import type { FieldRecipeGroup, QcRoute, SmartGroupField } from './SmartGroupFieldRecipe.groups'
10
10
  import type { ManualSample } from './SmartGroupManual.cohorts'
@@ -61,6 +61,57 @@ export function engineFieldsToCards(
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * Build the batch-wide field recipe from every class schema. The active schema
66
+ * stays first for a stable card order; fields and enabled hierarchy layers that
67
+ * only exist in another token-length/class split are appended instead of being
68
+ * hidden from the controlled UI.
69
+ */
70
+ export function engineSchemasToCards(
71
+ schemas: Record<string, ClassSchema>,
72
+ activeClassKey: string,
73
+ ): { fields: SmartGroupField[]; enabled: boolean[]; layers: (number | null)[] } {
74
+ const entries = Object.entries(schemas)
75
+ const active = entries.find(([key]) => key === activeClassKey)
76
+ const ordered = active
77
+ ? [active, ...entries.filter(([key]) => key !== activeClassKey)]
78
+ : entries
79
+ const candidates = new Map<string, ColumnInfo>()
80
+ const enabledOrder: string[] = []
81
+ const enabled = new Set<string>()
82
+
83
+ for (const [, schema] of ordered) {
84
+ for (const index of schema.groupBy) {
85
+ const column = schema.columns.find(candidate => candidate.index === index)
86
+ if (!column) continue
87
+ const identity = column.displayName ?? column.name
88
+ if (!enabled.has(identity)) {
89
+ enabled.add(identity)
90
+ enabledOrder.push(identity)
91
+ }
92
+ }
93
+ for (const column of candidateColumns(schema.columns, schema.groupBy)) {
94
+ const identity = column.displayName ?? column.name
95
+ if (!candidates.has(identity)) candidates.set(identity, column)
96
+ }
97
+ }
98
+
99
+ const cards = [...candidates.entries()]
100
+ return {
101
+ fields: cards.map(([key, column]) => ({
102
+ key,
103
+ role: roleLabel(column.role),
104
+ vals: column.uniqueValues.join(', '),
105
+ rep: column.role === 'replicate',
106
+ })),
107
+ enabled: cards.map(([key]) => enabled.has(key)),
108
+ layers: cards.map(([key]) => {
109
+ const position = enabledOrder.indexOf(key)
110
+ return position === -1 ? null : position + 1
111
+ }),
112
+ }
113
+ }
114
+
64
115
  /** Map engine sample groups to the live preview rail shape. */
65
116
  export function engineGroupsToRail(groups: SampleGroup[]): FieldRecipeGroup[] {
66
117
  return groups.map(g => ({ name: g.name, count: g.samples.length, color: g.color }))
@@ -17,8 +17,8 @@ import { useAutoGroup, parseCSV } from '../composables/useAutoGroup'
17
17
  import { useApi } from '../composables/useApi'
18
18
  import { readFileAsText } from '../composables/useFileImport'
19
19
  import {
20
- engineFieldsToCards,
21
20
  engineGroupsToRail,
21
+ engineSchemasToCards,
22
22
  engineToQcRoute,
23
23
  manualSamplesToResult,
24
24
  qcRouteToEngine,
@@ -82,9 +82,11 @@ function setQcRouting(value: QcRouting) {
82
82
  }
83
83
 
84
84
  // ── Auto mode (Field Recipe) ─────────────────────────────────────────
85
- // The schema's ordered groupBy IS the hierarchy layer order (layer 1 first).
86
- const groupByOrder = computed(() => autoGroup.activeSchema.value?.groupBy ?? [])
87
- const fieldCards = computed(() => engineFieldsToCards(autoGroup.fields.value, groupByOrder.value))
85
+ // The recipe represents the whole batch, including fields that only exist in
86
+ // another class or token-length schema split.
87
+ const fieldCards = computed(() =>
88
+ engineSchemasToCards(autoGroup.schemas.value, autoGroup.activeClassKey.value),
89
+ )
88
90
  const railGroups = computed(() =>
89
91
  engineGroupsToRail(autoGroup.result.value.experimentalGroups ?? autoGroup.groups.value),
90
92
  )
@@ -311,7 +311,10 @@ export function useAutoGroup() {
311
311
  // the same field at a different position.
312
312
  function schemasExposing(fieldKey: string): { key: string; index: number }[] {
313
313
  return Object.entries(schemas.value).flatMap(([key, schema]) => {
314
- const col = schema.columns.find(c => (c.displayName ?? c.name) === fieldKey)
314
+ const col = schema.columns.find(c =>
315
+ (c.displayName ?? c.name) === fieldKey
316
+ && (c.role === 'factor' || c.role === 'replicate' || schema.groupBy.includes(c.index)),
317
+ )
315
318
  return col ? [{ key, index: col.index }] : []
316
319
  })
317
320
  }
@@ -322,15 +325,14 @@ export function useAutoGroup() {
322
325
  }
323
326
 
324
327
  /**
325
- * Toggle a grouping field across every class exposing it. The target state
326
- * comes from the active class (what the card shows), so already-diverged
327
- * classes converge instead of flipping in opposite directions.
328
+ * Toggle a grouping field across every class exposing it. The batch card is
329
+ * on when any schema groups by the field, so clicking it disables all such
330
+ * schemas; only a fully disabled field is enabled across the batch.
328
331
  */
329
332
  function toggleGroupByField(fieldKey: string) {
330
333
  const targets = schemasExposing(fieldKey)
331
334
  if (targets.length === 0) return
332
- const active = targets.find(t => t.key === activeClassKey.value) ?? targets[0]
333
- const enable = !(schemas.value[active.key]?.groupBy.includes(active.index) ?? false)
335
+ const enable = !targets.some(({ key, index }) => schemas.value[key]?.groupBy.includes(index))
334
336
  for (const { key, index } of targets) {
335
337
  const schema = schemas.value[key]
336
338
  if (!schema || schema.groupBy.includes(index) === enable) continue
@@ -347,7 +349,9 @@ export function useAutoGroup() {
347
349
  function moveGroupByField(fieldKey: string, direction: 'up' | 'down') {
348
350
  const targets = schemasExposing(fieldKey)
349
351
  if (targets.length === 0) return
350
- const active = targets.find(target => target.key === activeClassKey.value) ?? targets[0]
352
+ const active = targets.find(target =>
353
+ target.key === activeClassKey.value && schemas.value[target.key]?.groupBy.includes(target.index),
354
+ ) ?? targets.find(target => schemas.value[target.key]?.groupBy.includes(target.index)) ?? targets[0]
351
355
  const activeSchema = schemas.value[active.key]
352
356
  if (!activeSchema) return
353
357
  const from = activeSchema.groupBy.indexOf(active.index)