@morscherlab/mint-sdk 1.0.44 → 1.0.45

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 (49) hide show
  1. package/dist/__tests__/components/SmartGroupFieldRecipe.groups.test.d.ts +1 -0
  2. package/dist/__tests__/components/SmartGroupManual.cohorts.test.d.ts +1 -0
  3. package/dist/components/AutoGroupModal.adapter.d.ts +42 -0
  4. package/dist/components/AutoGroupModal.vue.d.ts +1 -105
  5. package/dist/components/SmartGroup.types.d.ts +71 -0
  6. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +53 -0
  7. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +37 -0
  8. package/dist/components/SmartGroupManual.cohorts.d.ts +52 -0
  9. package/dist/components/SmartGroupManual.vue.d.ts +17 -0
  10. package/dist/components/SmartGroupModal.vue.d.ts +27 -0
  11. package/dist/components/index.d.ts +3 -0
  12. package/dist/components/index.js +2 -2
  13. package/dist/{components-BGVwavdd.js → components-DjfatNJv.js} +2519 -2335
  14. package/dist/components-DjfatNJv.js.map +1 -0
  15. package/dist/composables/index.js +2 -2
  16. package/dist/{composables-C_hPF0Gn.js → composables-CpBhNKHf.js} +7 -147
  17. package/dist/composables-CpBhNKHf.js.map +1 -0
  18. package/dist/index.js +4 -4
  19. package/dist/install.js +1 -1
  20. package/dist/styles.css +12711 -12222
  21. package/dist/{useProtocolTemplates-BbvlHoPD.js → useProtocolTemplates-C8-YlHj1.js} +206 -66
  22. package/dist/useProtocolTemplates-C8-YlHj1.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +164 -0
  25. package/src/__tests__/components/SampleSelector.test.ts +176 -16
  26. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +96 -0
  27. package/src/__tests__/components/SmartGroupManual.cohorts.test.ts +97 -0
  28. package/src/components/AutoGroupModal.adapter.ts +147 -0
  29. package/src/components/AutoGroupModal.vue +176 -1321
  30. package/src/components/SampleSelector.vue +8 -23
  31. package/src/components/SmartGroup.types.ts +93 -0
  32. package/src/components/SmartGroupFieldRecipe.groups.ts +105 -0
  33. package/src/components/SmartGroupFieldRecipe.story.vue +58 -0
  34. package/src/components/SmartGroupFieldRecipe.vue +427 -0
  35. package/src/components/SmartGroupManual.cohorts.ts +112 -0
  36. package/src/components/SmartGroupManual.story.vue +55 -0
  37. package/src/components/SmartGroupManual.vue +398 -0
  38. package/src/components/SmartGroupModal.story.vue +61 -0
  39. package/src/components/SmartGroupModal.vue +61 -0
  40. package/src/components/index.ts +3 -0
  41. package/src/styles/components/sample-selector.css +1 -5
  42. package/src/styles/components/smart-group.css +708 -0
  43. package/src/styles/index.css +1 -1
  44. package/dist/components-BGVwavdd.js.map +0 -1
  45. package/dist/composables-C_hPF0Gn.js.map +0 -1
  46. package/dist/useProtocolTemplates-BbvlHoPD.js.map +0 -1
  47. package/src/__tests__/components/AutoGroupModal.preview.test.ts +0 -46
  48. package/src/styles/components/auto-group-modal.css +0 -1336
  49. /package/dist/__tests__/components/{AutoGroupModal.preview.test.d.ts → AutoGroupModal.adapter.test.d.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.44",
3
+ "version": "1.0.45",
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,164 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ candidateColumnIndices,
4
+ engineFieldsToCards,
5
+ engineGroupsToRail,
6
+ engineToQcRoute,
7
+ manualSamplesToResult,
8
+ qcRouteToEngine,
9
+ roleLabel,
10
+ seedFromGroups,
11
+ } from '../../components/AutoGroupModal.adapter'
12
+ import type { ColumnInfo } from '../../types/auto-group'
13
+ import type { SampleGroup } from '../../types'
14
+ import type { ManualSample } from '../../components/SmartGroupManual.cohorts'
15
+
16
+ function col(partial: Partial<ColumnInfo> & { index: number }): ColumnInfo {
17
+ return {
18
+ name: `c${partial.index}`,
19
+ sourceIndices: [partial.index],
20
+ uniqueValues: [],
21
+ cardinality: 0,
22
+ ...partial,
23
+ }
24
+ }
25
+
26
+ describe('roleLabel', () => {
27
+ it('should map known roles to their display label', () => {
28
+ expect(roleLabel('factor')).toBe('Factor')
29
+ expect(roleLabel('replicate')).toBe('Replicate')
30
+ expect(roleLabel('run-order')).toBe('Run')
31
+ })
32
+
33
+ it('should default to Factor when the role is undefined', () => {
34
+ expect(roleLabel(undefined)).toBe('Factor')
35
+ })
36
+ })
37
+
38
+ describe('candidateColumnIndices', () => {
39
+ it('should include factors and replicates and drop constants/ignore', () => {
40
+ const columns = [
41
+ col({ index: 0, role: 'factor' }),
42
+ col({ index: 1, role: 'constant' }),
43
+ col({ index: 2, role: 'replicate' }),
44
+ col({ index: 3, role: 'ignore' }),
45
+ ]
46
+ expect(candidateColumnIndices(columns, new Set())).toEqual([0, 2])
47
+ })
48
+
49
+ it('should also include a non-factor column already enabled in groupBy', () => {
50
+ const columns = [
51
+ col({ index: 0, role: 'factor' }),
52
+ col({ index: 1, role: 'numeric' }),
53
+ ]
54
+ expect(candidateColumnIndices(columns, new Set([1]))).toEqual([0, 1])
55
+ })
56
+ })
57
+
58
+ describe('engineFieldsToCards', () => {
59
+ it('should build cards with role label, joined values, rep flag, and aligned enabled state', () => {
60
+ const columns = [
61
+ col({ index: 0, name: 'Condition', role: 'factor', uniqueValues: ['Control', 'Treatment'] }),
62
+ col({ index: 1, name: 'Replicate', role: 'replicate', uniqueValues: ['Rep1', 'Rep2'] }),
63
+ ]
64
+ const { fields, enabled } = engineFieldsToCards(columns, new Set([0]))
65
+ expect(fields).toEqual([
66
+ { key: 'Condition', role: 'Factor', vals: 'Control, Treatment', rep: false },
67
+ { key: 'Replicate', role: 'Replicate', vals: 'Rep1, Rep2', rep: true },
68
+ ])
69
+ expect(enabled).toEqual([true, false])
70
+ })
71
+
72
+ it('should prefer displayName over name when present', () => {
73
+ const columns = [col({ index: 0, name: 'c0', displayName: 'Dose', role: 'factor', uniqueValues: ['Low'] })]
74
+ expect(engineFieldsToCards(columns, new Set()).fields[0].key).toBe('Dose')
75
+ })
76
+ })
77
+
78
+ describe('engineGroupsToRail', () => {
79
+ it('should map groups to {name, count, color} using sample length', () => {
80
+ const groups: SampleGroup[] = [{ name: 'Control', color: '#111', samples: ['a', 'b'] }]
81
+ expect(engineGroupsToRail(groups)).toEqual([{ name: 'Control', count: 2, color: '#111' }])
82
+ })
83
+ })
84
+
85
+ describe('QC route mapping', () => {
86
+ it('should round-trip between Field Recipe labels and engine routing', () => {
87
+ expect(qcRouteToEngine('Overlay only')).toBe('overlay')
88
+ expect(qcRouteToEngine('Exclude')).toBe('exclude')
89
+ expect(qcRouteToEngine('Mix into groups')).toBe('mix')
90
+ expect(engineToQcRoute('overlay')).toBe('Overlay only')
91
+ expect(engineToQcRoute('exclude')).toBe('Exclude')
92
+ expect(engineToQcRoute('mix')).toBe('Mix into groups')
93
+ })
94
+ })
95
+
96
+ describe('seedFromGroups', () => {
97
+ it('should split a hierarchical group name into group/sub/sub2 per sample', () => {
98
+ const groups: SampleGroup[] = [
99
+ { name: 'Responder/Day 7/Batch 1', color: '#0EA5A4', samples: ['s1'] },
100
+ { name: 'Control', color: '#3B82F6', samples: ['s2', 's3'] },
101
+ ]
102
+ expect(seedFromGroups(groups)).toEqual({
103
+ s1: { group: 'Responder', sub: 'Day 7', sub2: 'Batch 1', color: '#0EA5A4' },
104
+ s2: { group: 'Control', sub: undefined, sub2: undefined, color: '#3B82F6' },
105
+ s3: { group: 'Control', sub: undefined, sub2: undefined, color: '#3B82F6' },
106
+ })
107
+ })
108
+ })
109
+
110
+ describe('manualSamplesToResult', () => {
111
+ function sample(name: string, group: string | null, sub: string | null = null, color = 'var(--grp-1)'): ManualSample {
112
+ return { name, group, sub, sub2: null, color: group ? color : null }
113
+ }
114
+
115
+ it('should build a hierarchical group name and wrap as an AutoGroupResult', () => {
116
+ const samples: ManualSample[] = [
117
+ { name: 'S1', group: 'Treatment', sub: 'Baseline', sub2: null, color: 'var(--grp-1)' },
118
+ sample('S2', null),
119
+ ]
120
+ const result = manualSamplesToResult(samples)
121
+ expect(result.groups).toHaveLength(1)
122
+ expect(result.groups[0]).toMatchObject({ name: 'Treatment/Baseline', samples: ['S1'] })
123
+ expect(result.experimentalGroups).toEqual(result.groups)
124
+ expect(result.qcGroups).toEqual([])
125
+ })
126
+
127
+ it('should round-trip a 4-segment group name through seedFromGroups without losing segments', () => {
128
+ const groups: SampleGroup[] = [{ name: 'a/b/c/d', color: '#123456', samples: ['s1'] }]
129
+ const seeded = seedFromGroups(groups).s1
130
+ const samples: ManualSample[] = [
131
+ {
132
+ name: 's1',
133
+ group: seeded.group ?? null,
134
+ sub: seeded.sub ?? null,
135
+ sub2: seeded.sub2 ?? null,
136
+ color: seeded.color ?? null,
137
+ },
138
+ ]
139
+ // No baseGroups, so the name is reconstructed purely from the seeded levels.
140
+ const result = manualSamplesToResult(samples, [])
141
+ expect(result.groups).toHaveLength(1)
142
+ expect(result.groups[0]).toMatchObject({ name: 'a/b/c/d', samples: ['s1'] })
143
+ })
144
+
145
+ it('should preserve base group order and colour, leaving emptied groups in place', () => {
146
+ const samples: ManualSample[] = [sample('S1', 'Treatment'), sample('S2', null)]
147
+ const base: SampleGroup[] = [
148
+ { name: 'Control', color: '#3B82F6', samples: ['S1'] },
149
+ { name: 'Treatment', color: '#10B981', samples: [] },
150
+ ]
151
+ expect(manualSamplesToResult(samples, base).groups).toEqual([
152
+ { name: 'Control', color: '#3B82F6', samples: [] },
153
+ { name: 'Treatment', color: '#10B981', samples: ['S1'] },
154
+ ])
155
+ })
156
+
157
+ it('should keep a base group colour when samples are added to it', () => {
158
+ const samples: ManualSample[] = [sample('S1', 'Control', null, '#10B981'), sample('S2', 'Control', null, '#10B981')]
159
+ const base: SampleGroup[] = [{ name: 'Control', color: '#10B981', samples: ['S1'] }]
160
+ expect(manualSamplesToResult(samples, base).groups).toEqual([
161
+ { name: 'Control', color: '#10B981', samples: ['S1', 'S2'] },
162
+ ])
163
+ })
164
+ })
@@ -1,10 +1,20 @@
1
- import { mount } from '@vue/test-utils'
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
2
  import { createPinia } from 'pinia'
3
- import { describe, expect, it } from 'vitest'
3
+ import { describe, expect, it, vi } from 'vitest'
4
4
  import AutoGroupModal from '../../components/AutoGroupModal.vue'
5
5
  import SampleSelector from '../../components/SampleSelector.vue'
6
+ import SmartGroupFieldRecipe from '../../components/SmartGroupFieldRecipe.vue'
6
7
  import type { AutoGroupResult, SampleGroup } from '../../types'
7
8
 
9
+ // Wait for the FileReader-backed CSV read (a macrotask, not a microtask) plus
10
+ // the ensuing reactive renders to settle.
11
+ async function settle() {
12
+ for (let i = 0; i < 3; i++) {
13
+ await new Promise(resolve => setTimeout(resolve, 0))
14
+ await flushPromises()
15
+ }
16
+ }
17
+
8
18
  describe('SampleSelector', () => {
9
19
  it('uses shared list selection for select-all and individual sample toggles', async () => {
10
20
  const wrapper = mount(SampleSelector, {
@@ -89,6 +99,30 @@ describe('SampleSelector', () => {
89
99
  expect(wrapper.findAll('.mint-sample-selector__flat-name').map(item => item.text())).toEqual(['S1', 'S2'])
90
100
  })
91
101
 
102
+ it('merges smart and manual grouping into one launcher', async () => {
103
+ const wrapper = mount(SampleSelector, {
104
+ props: {
105
+ samples: ['S1', 'S2'],
106
+ modelValue: [],
107
+ groups: [],
108
+ },
109
+ global: {
110
+ stubs: {
111
+ AutoGroupModal: true,
112
+ },
113
+ },
114
+ })
115
+
116
+ const labels = wrapper.findAll('.mint-sample-selector__actions-row .mint-sample-selector__action-label')
117
+ expect(labels.map(label => label.text())).toEqual(['Grouping'])
118
+ expect(wrapper.findAll('.mint-sample-selector__actions-row > .mint-sample-selector__action-btn')).toHaveLength(2)
119
+
120
+ await wrapper.findAllComponents({ name: 'BaseButton' })[0].trigger('click')
121
+
122
+ const modal = wrapper.findComponent({ name: 'AutoGroupModal' })
123
+ expect(modal.props('initialMode')).toBe('auto')
124
+ })
125
+
92
126
  it('opens the grouping modal in manual mode', async () => {
93
127
  const wrapper = mount(SampleSelector, {
94
128
  props: {
@@ -104,6 +138,9 @@ describe('SampleSelector', () => {
104
138
  },
105
139
  })
106
140
 
141
+ const labels = wrapper.findAll('.mint-sample-selector__actions-row .mint-sample-selector__action-label')
142
+ expect(labels.map(label => label.text())).toEqual(['Grouping'])
143
+
107
144
  await wrapper.findAllComponents({ name: 'BaseButton' })[0].trigger('click')
108
145
 
109
146
  const modal = wrapper.findComponent({ name: 'AutoGroupModal' })
@@ -129,18 +166,22 @@ describe('AutoGroupModal manual workflow', () => {
129
166
  },
130
167
  })
131
168
 
132
- await wrapper.findAll('.mint-auto-group__manual-sample input')[0].trigger('change')
133
- const fields = wrapper.findAll('.mint-auto-group__manual-field input')
169
+ await wrapper.findAll('.mg2-row')[0].trigger('click')
170
+ const fields = wrapper.findAll('.mg2-bar .mg2-combo input')
134
171
  await fields[0].setValue('Treatment')
135
172
  await fields[1].setValue('Baseline')
136
- await wrapper.find('.mint-auto-group__manual-assign').trigger('click')
137
- await wrapper.find('.mint-auto-group__nav--manual .mint-button--primary').trigger('click')
173
+ await wrapper.find('.mg2-bar .sg-btn--primary').trigger('click')
174
+ await wrapper.find('.sg-foot .mint-button--primary').trigger('click')
138
175
 
139
176
  const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult
140
177
  expect(result.groups).toHaveLength(1)
141
178
  expect(result.groups[0]).toMatchObject({
142
179
  name: 'Treatment/Baseline',
143
180
  samples: ['S1'],
181
+ // A newly-created manual cohort must carry a concrete hex colour (the
182
+ // first SAMPLE_GROUP_COLOR_OPTIONS swatch), not a `var(--grp-*)` string
183
+ // that breaks SampleSelector's hex8 tint concatenation.
184
+ color: '#3B82F6',
144
185
  })
145
186
  })
146
187
 
@@ -163,11 +204,10 @@ describe('AutoGroupModal manual workflow', () => {
163
204
  },
164
205
  })
165
206
 
166
- await wrapper.find('.mint-auto-group__manual-filter input').setValue(false)
167
- await wrapper.findAll('.mint-auto-group__manual-sample input')[0].trigger('change')
168
- await wrapper.findAll('.mint-auto-group__manual-chip')[1].trigger('click')
169
- await wrapper.find('.mint-auto-group__manual-assign').trigger('click')
170
- await wrapper.find('.mint-auto-group__nav--manual .mint-button--primary').trigger('click')
207
+ await wrapper.findAll('.mg2-row')[0].trigger('click')
208
+ await wrapper.findAll('.mg2-bar .mg2-combo input')[0].setValue('Treatment')
209
+ await wrapper.find('.mg2-bar .sg-btn--primary').trigger('click')
210
+ await wrapper.find('.sg-foot .mint-button--primary').trigger('click')
171
211
 
172
212
  const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult
173
213
  expect(result.groups).toEqual([
@@ -194,11 +234,10 @@ describe('AutoGroupModal manual workflow', () => {
194
234
  },
195
235
  })
196
236
 
197
- await wrapper.findAll('.mint-auto-group__manual-sample input')[0].trigger('change')
198
- const fields = wrapper.findAll('.mint-auto-group__manual-field input')
199
- await fields[0].setValue('Control')
200
- await wrapper.find('.mint-auto-group__manual-assign').trigger('click')
201
- await wrapper.find('.mint-auto-group__nav--manual .mint-button--primary').trigger('click')
237
+ await wrapper.findAll('.mg2-row')[1].trigger('click')
238
+ await wrapper.findAll('.mg2-bar .mg2-combo input')[0].setValue('Control')
239
+ await wrapper.find('.mg2-bar .sg-btn--primary').trigger('click')
240
+ await wrapper.find('.sg-foot .mint-button--primary').trigger('click')
202
241
 
203
242
  const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult
204
243
  expect(result.groups).toEqual([
@@ -237,3 +276,124 @@ describe('SampleSelector — QC overlay regression', () => {
237
276
  expect(allSamples).toContain('iqc1')
238
277
  })
239
278
  })
279
+
280
+ describe('AutoGroupModal CSV upload', () => {
281
+ it('loads an uploaded CSV into the engine so samples reach both auto and manual modes', async () => {
282
+ const wrapper = mount(AutoGroupModal, {
283
+ props: { modelValue: true, samples: [], groups: [] },
284
+ global: {
285
+ plugins: [createPinia()],
286
+ stubs: { Teleport: true },
287
+ },
288
+ })
289
+
290
+ const csv = 'sample_name,Condition\nS1,Control\nS2,Control\nS3,Treatment\n'
291
+ const file = new File([csv], 'samples.csv', { type: 'text/csv' })
292
+ wrapper.findComponent(SmartGroupFieldRecipe).vm.$emit('csv-file', file)
293
+ await settle()
294
+
295
+ // Auto mode: the CSV populated the engine's field cards + grouped all 3 samples.
296
+ const recipe = wrapper.findComponent(SmartGroupFieldRecipe)
297
+ expect((recipe.props('fields') as unknown[]).length).toBeGreaterThan(0)
298
+ const rail = recipe.props('groups') as Array<{ count: number }>
299
+ expect(rail.reduce((acc, g) => acc + g.count, 0)).toBe(3)
300
+
301
+ // Manual mode: the CSV sample names populate the manual list (sourced from
302
+ // the engine, not props.samples — which is empty here).
303
+ recipe.vm.$emit('manual')
304
+ await flushPromises()
305
+ const names = wrapper.findAll('.mg2-row__name').map(n => n.text())
306
+ expect(names).toEqual(['S1', 'S2', 'S3'])
307
+ })
308
+
309
+ it('surfaces an error (without throwing) for an empty/header-only CSV, then loads a valid one', async () => {
310
+ const wrapper = mount(AutoGroupModal, {
311
+ props: { modelValue: true, samples: [], groups: [] },
312
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
313
+ })
314
+ const recipe = wrapper.findComponent(SmartGroupFieldRecipe)
315
+
316
+ // Header-only CSV → parseCSV throws; onCsvFile must catch + surface the error.
317
+ recipe.vm.$emit('csv-file', new File(['sample_name\n'], 'bad.csv', { type: 'text/csv' }))
318
+ await settle()
319
+ expect(wrapper.find('.sg-host__error').exists()).toBe(true)
320
+ // Engine left un-mutated: no groups loaded from the bad file.
321
+ expect((recipe.props('groups') as unknown[]).length).toBe(0)
322
+
323
+ // A subsequent valid CSV clears the error and loads.
324
+ recipe.vm.$emit(
325
+ 'csv-file',
326
+ new File(['sample_name,Condition\nS1,Control\nS2,Treatment\n'], 'good.csv', { type: 'text/csv' }),
327
+ )
328
+ await settle()
329
+ expect(wrapper.find('.sg-host__error').exists()).toBe(false)
330
+ const rail = recipe.props('groups') as Array<{ count: number }>
331
+ expect(rail.reduce((acc, g) => acc + g.count, 0)).toBe(2)
332
+ })
333
+ })
334
+
335
+ describe('SmartGroupFieldRecipe CSV menu', () => {
336
+ it('emits download-template from its menu item', async () => {
337
+ const wrapper = mount(SmartGroupFieldRecipe)
338
+ await wrapper.find('.fr-upload').trigger('click')
339
+ const items = wrapper.findAll('.fr-csv-item')
340
+ expect(items).toHaveLength(3)
341
+ await items[1].trigger('click')
342
+ expect(wrapper.emitted('download-template')).toHaveLength(1)
343
+ })
344
+
345
+ it('emits download-prefilled from its menu item', async () => {
346
+ const wrapper = mount(SmartGroupFieldRecipe)
347
+ await wrapper.find('.fr-upload').trigger('click')
348
+ await wrapper.findAll('.fr-csv-item')[2].trigger('click')
349
+ expect(wrapper.emitted('download-prefilled')).toHaveLength(1)
350
+ })
351
+
352
+ it('disables the download items (and emits nothing) when canDownloadTemplate is false', async () => {
353
+ const wrapper = mount(SmartGroupFieldRecipe, { props: { canDownloadTemplate: false } })
354
+ await wrapper.find('.fr-upload').trigger('click')
355
+ const items = wrapper.findAll('.fr-csv-item')
356
+ expect(items[1].attributes('disabled')).toBeDefined()
357
+ expect(items[2].attributes('disabled')).toBeDefined()
358
+ await items[1].trigger('click')
359
+ expect(wrapper.emitted('download-template')).toBeUndefined()
360
+ })
361
+ })
362
+
363
+ describe('AutoGroupModal template download wiring', () => {
364
+ it('maps the menu actions to engine downloadTemplate (blank vs prefilled)', async () => {
365
+ const blobs: Blob[] = []
366
+ const origCreate = URL.createObjectURL
367
+ const origRevoke = URL.revokeObjectURL
368
+ URL.createObjectURL = vi.fn((b: Blob) => {
369
+ blobs.push(b)
370
+ return 'blob:mock'
371
+ }) as typeof URL.createObjectURL
372
+ URL.revokeObjectURL = vi.fn() as typeof URL.revokeObjectURL
373
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
374
+ try {
375
+ const wrapper = mount(AutoGroupModal, {
376
+ props: { modelValue: true, samples: ['Ctrl_R1', 'Ctrl_R2', 'Drug_R1', 'Drug_R2'], groups: [] },
377
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
378
+ })
379
+ const recipe = wrapper.findComponent(SmartGroupFieldRecipe)
380
+ recipe.vm.$emit('download-template')
381
+ await flushPromises()
382
+ recipe.vm.$emit('download-prefilled')
383
+ await flushPromises()
384
+
385
+ expect(blobs).toHaveLength(2)
386
+ const blankText = await blobs[0].text()
387
+ const prefilledText = await blobs[1].text()
388
+ // download-template → blank mode: the canonical generic DEFAULT_BLANK_HEADERS.
389
+ // download-prefilled → prefilled mode: a different, detected-column header.
390
+ expect(blankText.split('\n')[0]).toBe('sample_name,group,class,replicate,notes')
391
+ expect(prefilledText.split('\n')[0]).not.toBe(blankText.split('\n')[0])
392
+ expect(blankText).not.toBe(prefilledText)
393
+ } finally {
394
+ URL.createObjectURL = origCreate
395
+ URL.revokeObjectURL = origRevoke
396
+ clickSpy.mockRestore()
397
+ }
398
+ })
399
+ })
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ appendQcGroup,
4
+ computeFieldGroups,
5
+ fieldRecipeSummary,
6
+ sampleTotalFor,
7
+ type SmartGroupField,
8
+ type SmartGroupSampleRecord,
9
+ } from '../../components/SmartGroupFieldRecipe.groups'
10
+
11
+ const FIELDS: SmartGroupField[] = [
12
+ { key: 'Condition', role: 'Factor', vals: 'Control, Treatment', rep: false },
13
+ { key: 'Dose', role: 'Factor', vals: 'Vehicle, Low, High', rep: false },
14
+ { key: 'Replicate', role: 'Replicate', vals: 'Rep1, Rep2, Rep3', rep: true },
15
+ ]
16
+
17
+ const BASE = [
18
+ { Condition: 'Control', Dose: 'Vehicle' },
19
+ { Condition: 'Treatment', Dose: 'Low' },
20
+ { Condition: 'Treatment', Dose: 'High' },
21
+ ]
22
+ const SAMPLES: SmartGroupSampleRecord[] = BASE.flatMap(base =>
23
+ [1, 2, 3].map(rep => ({ ...base, Replicate: `Rep${rep}` })),
24
+ )
25
+
26
+ const PALETTE = ['c1', 'c2', 'c3', 'c4', 'c5']
27
+
28
+ describe('computeFieldGroups', () => {
29
+ it('should form three groups of three when grouping by Condition and Dose', () => {
30
+ const groups = computeFieldGroups(SAMPLES, FIELDS, [true, true, false], PALETTE)
31
+ expect(groups.map(g => g.name)).toEqual(['Control_Vehicle', 'Treatment_Low', 'Treatment_High'])
32
+ expect(groups.map(g => g.count)).toEqual([3, 3, 3])
33
+ })
34
+
35
+ it('should shatter into nine singleton groups when Replicate is included', () => {
36
+ const groups = computeFieldGroups(SAMPLES, FIELDS, [true, true, true], PALETTE)
37
+ expect(groups).toHaveLength(9)
38
+ expect(groups.every(g => g.count === 1)).toBe(true)
39
+ })
40
+
41
+ it('should return no groups when no field is active', () => {
42
+ expect(computeFieldGroups(SAMPLES, FIELDS, [false, false, false], PALETTE)).toEqual([])
43
+ })
44
+
45
+ it('should collapse to Condition only when just the first field is active', () => {
46
+ const groups = computeFieldGroups(SAMPLES, FIELDS, [true, false, false], PALETTE)
47
+ expect(groups.map(g => g.name)).toEqual(['Control', 'Treatment'])
48
+ expect(groups.map(g => g.count)).toEqual([3, 6])
49
+ })
50
+
51
+ it('should cycle colours through the palette in group order', () => {
52
+ const groups = computeFieldGroups(SAMPLES, FIELDS, [true, true, false], PALETTE)
53
+ expect(groups.map(g => g.color)).toEqual(['c1', 'c2', 'c3'])
54
+ })
55
+ })
56
+
57
+ describe('appendQcGroup', () => {
58
+ const base = computeFieldGroups(SAMPLES, FIELDS, [true, true, false], PALETTE)
59
+
60
+ it('should append a QC_Pool group of three when QC is mixed into groups', () => {
61
+ const result = appendQcGroup(base, 'Mix into groups', 'qc')
62
+ expect(result).toHaveLength(base.length + 1)
63
+ expect(result.at(-1)).toEqual({ name: 'QC_Pool', count: 3, color: 'qc' })
64
+ })
65
+
66
+ it('should leave groups untouched when QC overlays or is excluded', () => {
67
+ expect(appendQcGroup(base, 'Overlay only', 'qc')).toEqual(base)
68
+ expect(appendQcGroup(base, 'Exclude', 'qc')).toEqual(base)
69
+ })
70
+ })
71
+
72
+ describe('sampleTotalFor', () => {
73
+ it('should drop the QC pool only when QC is excluded', () => {
74
+ expect(sampleTotalFor('Exclude')).toBe(9)
75
+ expect(sampleTotalFor('Overlay only')).toBe(12)
76
+ expect(sampleTotalFor('Mix into groups')).toBe(12)
77
+ })
78
+ })
79
+
80
+ describe('fieldRecipeSummary', () => {
81
+ const baseGroups = computeFieldGroups(SAMPLES, FIELDS, [true, true, false], PALETTE)
82
+
83
+ it('should be muted when no factor is active', () => {
84
+ expect(fieldRecipeSummary({ activeFactors: [], repOn: false, baseGroups: [] }).kind).toBe('mute')
85
+ })
86
+
87
+ it('should warn when a replicate field is switched on', () => {
88
+ expect(fieldRecipeSummary({ activeFactors: ['Replicate'], repOn: true, baseGroups }).kind).toBe('warn')
89
+ })
90
+
91
+ it('should inform when grouping by factors only', () => {
92
+ expect(
93
+ fieldRecipeSummary({ activeFactors: ['Condition', 'Dose'], repOn: false, baseGroups }).kind,
94
+ ).toBe('info')
95
+ })
96
+ })
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ assignSamples,
4
+ buildCohortTree,
5
+ filterSamples,
6
+ type ManualSample,
7
+ } from '../../components/SmartGroupManual.cohorts'
8
+
9
+ /** Mirrors the design prototype's seeded default sample list. */
10
+ function seededSamples(): ManualSample[] {
11
+ const blank = { group: null, sub: null, sub2: null, color: null } as const
12
+ const seed: Record<string, Partial<ManualSample>> = {
13
+ Pt001_TumorA_d7_rep1: { group: 'Responder', sub: 'Day 7', sub2: 'Batch 1', color: 'c-teal' },
14
+ Pt001_TumorA_d14_rep1: { group: 'Responder', sub: 'Day 14', color: 'c-teal' },
15
+ Pt002_TumorB_d7_rep1: { group: 'Non-responder', sub: 'Day 7', sub2: 'Batch 2', color: 'c-rose' },
16
+ }
17
+ return ['Pt001_TumorA_d7_rep1', 'Pt001_TumorA_d14_rep1', 'Pt002_TumorB_d7_rep1', 'QC_pool_01'].map(
18
+ name => ({ name, ...blank, ...(seed[name] ?? {}) }),
19
+ )
20
+ }
21
+
22
+ describe('buildCohortTree', () => {
23
+ const tree = buildCohortTree(seededSamples())
24
+
25
+ it('should ignore unassigned samples', () => {
26
+ expect(tree.size).toBe(2)
27
+ expect(tree.has('Responder')).toBe(true)
28
+ expect(tree.has('Non-responder')).toBe(true)
29
+ })
30
+
31
+ it('should total Responder across its two subgroups', () => {
32
+ const responder = tree.get('Responder')!
33
+ expect(responder.total).toBe(2)
34
+ expect(responder.subs.get('Day 7')!.total).toBe(1)
35
+ expect(responder.subs.get('Day 7')!.subs2.get('Batch 1')).toBe(1)
36
+ expect(responder.subs.get('Day 14')!.total).toBe(1)
37
+ expect(responder.subs.get('Day 14')!.subs2.get('—')).toBe(1)
38
+ })
39
+
40
+ it('should nest Non-responder under Day 7 / Batch 2', () => {
41
+ const nonResponder = tree.get('Non-responder')!
42
+ expect(nonResponder.total).toBe(1)
43
+ expect(nonResponder.subs.get('Day 7')!.subs2.get('Batch 2')).toBe(1)
44
+ })
45
+ })
46
+
47
+ describe('filterSamples', () => {
48
+ const samples = seededSamples()
49
+
50
+ it('should match names case-insensitively on a substring query', () => {
51
+ const matches = filterSamples(samples, 'tumorb', 'all')
52
+ expect(matches.map(m => m.sample.name)).toEqual(['Pt002_TumorB_d7_rep1'])
53
+ })
54
+
55
+ it('should preserve the original index for each match', () => {
56
+ const matches = filterSamples(samples, 'QC', 'all')
57
+ expect(matches).toEqual([{ sample: samples[3], index: 3 }])
58
+ })
59
+
60
+ it('should show only unassigned samples when filtered to unassigned', () => {
61
+ const matches = filterSamples(samples, '', 'unassigned')
62
+ expect(matches.map(m => m.sample.name)).toEqual(['QC_pool_01'])
63
+ })
64
+
65
+ it('should show only assigned samples when filtered to assigned', () => {
66
+ const matches = filterSamples(samples, '', 'assigned')
67
+ expect(matches).toHaveLength(3)
68
+ expect(matches.every(m => !!m.sample.group)).toBe(true)
69
+ })
70
+ })
71
+
72
+ describe('assignSamples', () => {
73
+ const samples = seededSamples()
74
+
75
+ it('should assign group and colour to the selected indices only', () => {
76
+ const next = assignSamples(samples, new Set([3]), {
77
+ grp: 'Pooled',
78
+ sub: '',
79
+ sub2: '',
80
+ color: 'c-slate',
81
+ })
82
+ expect(next[3]).toMatchObject({ group: 'Pooled', color: 'c-slate' })
83
+ expect(next[0]).toEqual(samples[0])
84
+ })
85
+
86
+ it('should null out blank subgroup and sub-subgroup values', () => {
87
+ const next = assignSamples(samples, new Set([3]), {
88
+ grp: ' Pooled ',
89
+ sub: ' ',
90
+ sub2: '',
91
+ color: 'c-slate',
92
+ })
93
+ expect(next[3].group).toBe('Pooled')
94
+ expect(next[3].sub).toBeNull()
95
+ expect(next[3].sub2).toBeNull()
96
+ })
97
+ })