@morscherlab/mint-sdk 1.3.0 → 1.3.2

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 (70) hide show
  1. package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
  2. package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
  3. package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
  4. package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
  5. package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
  6. package/dist/components/PlateEditor.vue.d.ts +2 -0
  7. package/dist/components/SmartGroup.types.d.ts +22 -0
  8. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
  9. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
  10. package/dist/components/index.js +1 -1
  11. package/dist/{components-vHfxV2PB.js → components-CEePoD3W.js} +502 -264
  12. package/dist/components-CEePoD3W.js.map +1 -0
  13. package/dist/composables/autoGroup/classify.d.ts +7 -1
  14. package/dist/composables/autoGroup/columns.d.ts +16 -1
  15. package/dist/composables/autoGroup/index.d.ts +5 -7
  16. package/dist/composables/autoGroup/tokenize.d.ts +36 -1
  17. package/dist/composables/index.js +2 -2
  18. package/dist/composables/useAutoGroup.d.ts +13 -2
  19. package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
  20. package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
  21. package/dist/index.js +3 -3
  22. package/dist/install.js +1 -1
  23. package/dist/permissions.js +2 -2
  24. package/dist/permissions.js.map +1 -1
  25. package/dist/styles.css +71 -82
  26. package/dist/types/auto-group.d.ts +1 -1
  27. package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
  28. package/dist/validation-CukfLjy-.js.map +1 -0
  29. package/package.json +2 -2
  30. package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
  31. package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
  32. package/src/__tests__/components/PlateEditor.test.ts +10 -0
  33. package/src/__tests__/components/SampleSelector.test.ts +14 -16
  34. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
  35. package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
  36. package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
  37. package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
  38. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
  39. package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
  40. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
  41. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
  42. package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
  43. package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
  44. package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
  45. package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
  46. package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
  47. package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
  48. package/src/__tests__/utils/permissions.test.ts +15 -0
  49. package/src/components/AutoGroupModal.adapter.ts +80 -9
  50. package/src/components/AutoGroupModal.vue +41 -8
  51. package/src/components/PlateEditor.vue +3 -0
  52. package/src/components/SmartGroup.types.ts +23 -0
  53. package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
  54. package/src/components/SmartGroupFieldRecipe.vue +289 -169
  55. package/src/composables/autoGroup/classify.ts +26 -7
  56. package/src/composables/autoGroup/columns.ts +144 -36
  57. package/src/composables/autoGroup/compose.ts +8 -10
  58. package/src/composables/autoGroup/fingerprint.ts +1 -1
  59. package/src/composables/autoGroup/index.ts +5 -12
  60. package/src/composables/autoGroup/tokenLength.ts +8 -0
  61. package/src/composables/autoGroup/tokenize.ts +148 -1
  62. package/src/composables/autoGroup/vocab.json +5 -11
  63. package/src/composables/useAutoGroup.ts +92 -79
  64. package/src/permissions.ts +3 -2
  65. package/src/styles/components/smart-group.css +71 -82
  66. package/src/types/auto-group.ts +1 -0
  67. package/dist/components-vHfxV2PB.js.map +0 -1
  68. package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
  69. package/dist/validation-Dmp9Q5W0.js.map +0 -1
  70. package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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",
@@ -85,7 +85,7 @@
85
85
  },
86
86
  "devDependencies": {
87
87
  "@histoire/plugin-vue": "1.0.0-beta.1",
88
- "@types/node": "22.20.4",
88
+ "@types/node": "26.6.3",
89
89
  "@vitejs/plugin-vue": "6.0.9",
90
90
  "@vue/language-core": "3.3.11",
91
91
  "@vue/test-utils": "2.5.1",
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ engineAnatomy,
4
+ engineQcChips,
5
+ engineSchemasToCards,
6
+ experimentLabel,
7
+ previewValues,
8
+ } from '../../components/AutoGroupModal.adapter'
9
+ import { useAutoGroup } from '../../composables/useAutoGroup'
10
+
11
+ const tracingBatch = [
12
+ 'LT_13102025_212_WT_Glu_3_20_S_091123_T1_33', 'LT_13102025_213_WT_Glu_3_20_S_091123_T1_34',
13
+ 'LT_13102025_214_KI_Glu_3_20_S_091123_T1_35', 'LT_13102025_215_KI_Glu_6_40_L_091123_T1_36',
14
+ 'LT_13102025_216_WT_Glu_6_40_L_091123_T1_37', 'LT_13102025_217_WT_Glu_6_40_L_091123_T2_38',
15
+ 'LT_13102025_218_KI_Glu_3_20_S_091123_T2_39', 'LT_13102025_219_KI_Glu_6_40_L_091123_T2_40',
16
+ 'LT_13102025_EQC_Jurkat_1_2', 'LT_13102025_EQC_Jurkat_2_8', 'LT_13102025_IQC_Pool_1_5',
17
+ ]
18
+
19
+ function anatomyOf(samples: string[]) {
20
+ const engine = useAutoGroup()
21
+ engine.rawText.value = samples.join('\n')
22
+ engine.parseInput()
23
+ const cards = engineSchemasToCards(engine.schemas.value, engine.activeClassKey.value)
24
+ return { engine, anatomy: engineAnatomy(engine.classes.value, engine.schemas.value, engine.tokenized.value, cards) }
25
+ }
26
+
27
+ describe('previewValues', () => {
28
+ it('should list all values when there are three or fewer', () => {
29
+ expect(previewValues(['a', 'b', 'c'])).toBe('a, b, c')
30
+ })
31
+
32
+ it('should list three values and the rest as a count when there are more', () => {
33
+ expect(previewValues(['212', '213', '214', '215', '216'])).toBe('212, 213, 214 … +2')
34
+ })
35
+ })
36
+
37
+ describe('engineAnatomy', () => {
38
+ it('should show the tokens of one study sample in name order when the batch is parsed', () => {
39
+ expect(anatomyOf(tracingBatch).anatomy.map(token => token.text)).toEqual(
40
+ ['212', 'WT', 'Glu', '3', '20', 'S', '091123', 'T1', '33'],
41
+ )
42
+ })
43
+
44
+ it('should mark a grouped field with its layer when the field is on', () => {
45
+ expect(anatomyOf(tracingBatch).anatomy[1]).toEqual({ text: 'WT', caption: '1', state: 'on' })
46
+ })
47
+
48
+ it('should mark a field that is off by default with its name when the field is a sample ID', () => {
49
+ expect(anatomyOf(tracingBatch).anatomy[0]).toEqual({ text: '212', caption: 'Sample ID', state: 'off' })
50
+ })
51
+
52
+ it('should mark a constant token as fixed when it is not a grouping field', () => {
53
+ expect(anatomyOf(tracingBatch).anatomy[2]).toEqual({ text: 'Glu', caption: 'constant', state: 'fixed' })
54
+ })
55
+
56
+ it('should return no tokens when the batch has no study sample', () => {
57
+ expect(anatomyOf(['QC_01', 'QC_02']).anatomy).toEqual([])
58
+ })
59
+ })
60
+
61
+ describe('engineQcChips', () => {
62
+ it('should list each QC class with its sample count when the batch has QC samples', () => {
63
+ expect(engineQcChips(anatomyOf(tracingBatch).engine.classes.value)).toEqual([
64
+ { name: 'EQC', count: 2 },
65
+ { name: 'IQC', count: 1 },
66
+ ])
67
+ })
68
+ })
69
+
70
+ describe('experimentLabel', () => {
71
+ it('should join the header fields when the batch has them', () => {
72
+ expect(experimentLabel({ code: 'exp157', date: '2025-10-13', operator: 'LT' })).toBe('exp157 · 2025-10-13 · LT')
73
+ })
74
+
75
+ it('should return an empty label when the batch has no header', () => {
76
+ expect(experimentLabel({})).toBe('')
77
+ })
78
+ })
79
+
80
+ describe('engineSchemasToCards value preview', () => {
81
+ it('should shorten the value list of a card when the field has more than three values', () => {
82
+ const { engine } = anatomyOf(tracingBatch)
83
+ const cards = engineSchemasToCards(engine.schemas.value, engine.activeClassKey.value)
84
+ expect(cards.fields.find(field => field.key === 'Sample ID')?.vals).toBe('212, 213, 214 … +5')
85
+ })
86
+ })
@@ -0,0 +1,53 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { createPinia } from 'pinia'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { nextTick } from 'vue'
5
+ import AutoGroupModal from '../../components/AutoGroupModal.vue'
6
+ import SmartGroupFieldRecipe from '../../components/SmartGroupFieldRecipe.vue'
7
+
8
+ const replicated = ['WT', 'KO'].flatMap(genotype =>
9
+ ['Vehicle', 'Drug'].flatMap(treatment => ['1', '2', '3'].map(rep => `exp031_250318_SU_${genotype}_${treatment}_${rep}`)),
10
+ )
11
+ // One sample for each animal and time point: no replicate in the name.
12
+ const unreplicated = ['m1', 'm2', 'm3'].flatMap(animal => ['0min', '20min'].map(time => `${animal}_${time}`))
13
+
14
+ function recipeFor(samples: string[]) {
15
+ const wrapper = mount(AutoGroupModal, {
16
+ props: { modelValue: true, samples },
17
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
18
+ })
19
+ return wrapper.findComponent(SmartGroupFieldRecipe)
20
+ }
21
+
22
+ describe('AutoGroupModal field recipe wiring', () => {
23
+ it('should pass the tokens of an example name when samples are pasted', () => {
24
+ const anatomy = recipeFor(replicated).props('anatomy') as { text: string }[]
25
+ expect(anatomy.map(token => token.text)).toEqual(['WT', 'Vehicle', '1'])
26
+ })
27
+
28
+ it('should show the experiment header in the label when all names share it', () => {
29
+ expect(recipeFor(replicated).props('experimentLabel')).toBe('exp031 · 2025-03-18 · SU')
30
+ })
31
+
32
+ it('should rename the field when the recipe emits rename', async () => {
33
+ const recipe = recipeFor(replicated)
34
+ recipe.vm.$emit('rename', 0, 'Genotype')
35
+ await nextTick()
36
+ expect((recipe.props('fields') as { key: string }[])[0].key).toBe('Genotype')
37
+ })
38
+
39
+ it('should give no hints when the groups have replicates', () => {
40
+ expect((recipeFor(replicated).props('hints') as (string | null)[]).every(hint => hint === null)).toBe(true)
41
+ })
42
+
43
+ it('should hint how many groups remain for each field when every group has one sample', () => {
44
+ expect(recipeFor(unreplicated).props('hints')).toEqual(['off → 2 groups', 'off → 3 groups'])
45
+ })
46
+ })
47
+
48
+ describe('AutoGroupModal reserved names', () => {
49
+ it('should reserve the name of a column that is not a field when samples are pasted', () => {
50
+ const recipe = recipeFor(['Liver_A_rep1', 'Liver_A_rep2', 'Liver_B_rep1', 'Liver_B_rep2'])
51
+ expect(recipe.props('reservedNames')).toContain('Matrix')
52
+ })
53
+ })
@@ -359,6 +359,16 @@ describe('PlateEditor — wells', () => {
359
359
  expect(plate(wrapper).props('selectionMode')).toBe('drag')
360
360
  })
361
361
 
362
+ it('should show the hint prop in the footer until drag mode replaces it', async () => {
363
+ const wrapper = mountEditor({ wellDrag: true, hint: 'Drop samples here' })
364
+
365
+ expect(wrapper.get('.mint-plate-editor__hint').text()).toBe('Drop samples here')
366
+
367
+ await wrapper.get('[data-action="drag"]').trigger('click')
368
+
369
+ expect(wrapper.get('.mint-plate-editor__hint').text()).not.toBe('Drop samples here')
370
+ })
371
+
362
372
  it('should emit well-move with the active rack id', () => {
363
373
  const wrapper = mountEditor({ wellDrag: true })
364
374
 
@@ -277,7 +277,11 @@ describe('AutoGroupModal multi-schema auto workflow', () => {
277
277
  },
278
278
  })
279
279
 
280
- await wrapper.find('.sg-switch').trigger('click')
280
+ // Switch the sample-ID field of the first class on, then the only field of
281
+ // the second class off: one class still has a grouping field.
282
+ await wrapper.find('[data-test="field-off"] [role="switch"]').trigger('click')
283
+ const otherField = wrapper.findAll('[data-test="field-on"]').find(row => !row.text().includes('Sample ID'))
284
+ await otherField!.find('[role="switch"]').trigger('click')
281
285
 
282
286
  expect(wrapper.find('.sg-foot .mint-button--primary').attributes('disabled')).toBeUndefined()
283
287
  })
@@ -400,30 +404,24 @@ describe('AutoGroupModal CSV upload', () => {
400
404
  })
401
405
 
402
406
  describe('SmartGroupFieldRecipe CSV menu', () => {
403
- it('emits download-template from its menu item', async () => {
407
+ const menuOf = (wrapper: ReturnType<typeof mount>) => wrapper.findComponent({ name: 'ActionMenu' })
408
+
409
+ it('should emit download-template when the user picks that menu item', () => {
404
410
  const wrapper = mount(SmartGroupFieldRecipe)
405
- await wrapper.find('.fr-upload').trigger('click')
406
- const items = wrapper.findAll('.fr-csv-item')
407
- expect(items).toHaveLength(3)
408
- await items[1].trigger('click')
411
+ menuOf(wrapper).vm.$emit('select', { id: 'template', label: 'Download template' })
409
412
  expect(wrapper.emitted('download-template')).toHaveLength(1)
410
413
  })
411
414
 
412
- it('emits download-prefilled from its menu item', async () => {
415
+ it('should emit download-prefilled when the user picks that menu item', () => {
413
416
  const wrapper = mount(SmartGroupFieldRecipe)
414
- await wrapper.find('.fr-upload').trigger('click')
415
- await wrapper.findAll('.fr-csv-item')[2].trigger('click')
417
+ menuOf(wrapper).vm.$emit('select', { id: 'prefilled', label: 'Download pre-filled' })
416
418
  expect(wrapper.emitted('download-prefilled')).toHaveLength(1)
417
419
  })
418
420
 
419
- it('disables the download items (and emits nothing) when canDownloadTemplate is false', async () => {
421
+ it('should disable the two download items when canDownloadTemplate is false', () => {
420
422
  const wrapper = mount(SmartGroupFieldRecipe, { props: { canDownloadTemplate: false } })
421
- await wrapper.find('.fr-upload').trigger('click')
422
- const items = wrapper.findAll('.fr-csv-item')
423
- expect(items[1].attributes('disabled')).toBeDefined()
424
- expect(items[2].attributes('disabled')).toBeDefined()
425
- await items[1].trigger('click')
426
- expect(wrapper.emitted('download-template')).toBeUndefined()
423
+ const items = menuOf(wrapper).props('items') as Array<{ id: string; disabled?: boolean }>
424
+ expect(items.filter(item => item.disabled).map(item => item.id)).toEqual(['template', 'prefilled'])
427
425
  })
428
426
  })
429
427
 
@@ -99,3 +99,26 @@ describe('fieldRecipeSummary', () => {
99
99
  ).toBe('info')
100
100
  })
101
101
  })
102
+
103
+ describe('fieldRecipeSummary for groups of one', () => {
104
+ const group = (name: string, count: number) => ({ name, count, color: '#000' })
105
+
106
+ it('should report singletons when every group has one sample', () => {
107
+ const summary = fieldRecipeSummary({ activeFactors: ['A'], repOn: false, baseGroups: [group('a', 1), group('b', 1)] })
108
+ expect(summary.kind).toBe('singletons')
109
+ })
110
+ })
111
+
112
+ describe('fieldRecipeSummary for a batch with some replicates', () => {
113
+ const group = (name: string, count: number) => ({ name, count, color: '#000' })
114
+
115
+ it('should report singletons when half or more of the groups have one sample', () => {
116
+ const baseGroups = [group('a', 1), group('b', 1), group('c', 2)]
117
+ expect(fieldRecipeSummary({ activeFactors: ['A'], repOn: false, baseGroups }).kind).toBe('singletons')
118
+ })
119
+
120
+ it('should report info when fewer than half of the groups have one sample', () => {
121
+ const baseGroups = [group('a', 2), group('b', 2), group('c', 1)]
122
+ expect(fieldRecipeSummary({ activeFactors: ['A'], repOn: false, baseGroups }).kind).toBe('info')
123
+ })
124
+ })
@@ -1,6 +1,30 @@
1
1
  import { mount } from '@vue/test-utils'
2
2
  import { describe, expect, it } from 'vitest'
3
3
  import SmartGroupFieldRecipe from '../../components/SmartGroupFieldRecipe.vue'
4
+ import type { SmartGroupFieldRecipeProps } from '../../components/SmartGroup.types'
5
+
6
+ const fields = [
7
+ { key: 'Sample ID', role: 'Sample ID', vals: '212, 213, 214 … +7', rep: true },
8
+ { key: 'Genotype', role: 'Factor', vals: 'WT, KI', rep: false },
9
+ { key: 'Dose', role: 'Factor', vals: '3, 6', rep: false },
10
+ ]
11
+
12
+ function mountControlled(extra: Partial<SmartGroupFieldRecipeProps> = {}) {
13
+ return mount(SmartGroupFieldRecipe, {
14
+ props: {
15
+ fields,
16
+ enabled: [false, true, true],
17
+ layers: [null, 2, 1],
18
+ groups: [{ name: 'WT / 3', count: 3, color: '#3b82f6' }],
19
+ route: 'Overlay only',
20
+ headerCount: 15,
21
+ qcCount: 5,
22
+ experimentLabel: '',
23
+ ...extra,
24
+ },
25
+ attachTo: document.body,
26
+ })
27
+ }
4
28
 
5
29
  describe('SmartGroupFieldRecipe', () => {
6
30
  it('should draw the QC chip dot as the neutral QC marker when a qcColor is set', () => {
@@ -9,4 +33,156 @@ describe('SmartGroupFieldRecipe', () => {
9
33
  const dot = wrapper.find('.sg-qc__chip .sg-dot').element as HTMLElement
10
34
  expect(dot.style.borderColor).toBe('var(--text-secondary-strong)')
11
35
  })
36
+
37
+ it('should list the grouped fields in layer order when layers are given', () => {
38
+ const names = mountControlled().findAll('[data-test="field-on"] .sg-field__name').map(node => node.text())
39
+ expect(names).toEqual(['Dose', 'Genotype'])
40
+ })
41
+
42
+ it('should list a field under "Left off" when it is not grouped by', () => {
43
+ const names = mountControlled().findAll('[data-test="field-off"] .sg-field__name').map(node => node.text())
44
+ expect(names).toEqual(['Sample ID'])
45
+ })
46
+
47
+ it('should emit toggle with the card index when the user switches a field', async () => {
48
+ const wrapper = mountControlled()
49
+ await wrapper.find('[data-test="field-off"] [role="switch"]').trigger('click')
50
+ expect(wrapper.emitted('toggle')).toEqual([[0]])
51
+ })
52
+
53
+ it('should emit move up when the user presses Alt+ArrowUp on the handle of the second layer', async () => {
54
+ const wrapper = mountControlled()
55
+ await wrapper.findAll('[data-test="field-on"] .fr-row__grip')[1].trigger('keydown', { key: 'ArrowUp', altKey: true })
56
+ expect(wrapper.emitted('move')).toEqual([[1, 'up']])
57
+ })
58
+
59
+ it('should emit rename with the new name when the user commits the name input', async () => {
60
+ const wrapper = mountControlled({ renamable: true })
61
+ await wrapper.find('[data-test="field-on"] .fr-row__rename').trigger('click')
62
+ const input = wrapper.find('[data-test="field-on"] input')
63
+ await input.setValue('Tracer dose')
64
+ await input.trigger('keydown', { key: 'Enter' })
65
+ expect(wrapper.emitted('rename')).toEqual([[2, 'Tracer dose']])
66
+ })
67
+
68
+ it('should show no rename control when the fields are not renamable', () => {
69
+ expect(mountControlled().find('.fr-row__rename').exists()).toBe(false)
70
+ })
71
+
72
+ it('should show the hint of a field when hints are given', () => {
73
+ const wrapper = mountControlled({ hints: [null, 'off → 2 groups', null] })
74
+ expect(wrapper.findAll('[data-test="field-on"]')[1].find('.fr-row__hint').text()).toBe('off → 2 groups')
75
+ })
76
+
77
+ it('should show the tokens of the example name when the anatomy is given', () => {
78
+ const wrapper = mountControlled({
79
+ anatomy: [
80
+ { text: '212', caption: 'Sample ID', state: 'off' },
81
+ { text: 'WT', caption: '2', state: 'on' },
82
+ ],
83
+ })
84
+ expect(wrapper.findAll('.fr-token__text').map(node => node.text())).toEqual(['212', 'WT'])
85
+ })
86
+
87
+ it('should show the empty state when there are no fields', () => {
88
+ const wrapper = mountControlled({ fields: [], enabled: [], layers: [], groups: [], headerCount: 0 })
89
+ expect(wrapper.find('[data-test="recipe-empty"]').exists()).toBe(true)
90
+ })
91
+
92
+ it('should show one chip for each QC class when qcChips are given', () => {
93
+ const wrapper = mountControlled({ qcChips: [{ name: 'EQC', count: 3 }, { name: 'Blank', count: 2 }] })
94
+ expect(wrapper.findAll('.sg-qc__chip').map(node => node.text())).toEqual(['EQC ×3', 'Blank ×2'])
95
+ })
96
+
97
+ it('should emit update:route when the user picks another QC routing', async () => {
98
+ const wrapper = mountControlled()
99
+ const exclude = wrapper.findAll('.sg-qc [role="radio"]').find(node => node.text() === 'Exclude')
100
+ await exclude!.trigger('click')
101
+ expect(wrapper.emitted('update:route')).toEqual([['Exclude']])
102
+ })
103
+
104
+ it('should warn that every group has one sample when the groups are all singletons', () => {
105
+ const wrapper = mountControlled({
106
+ groups: [{ name: 'a', count: 1, color: '#000' }, { name: 'b', count: 1, color: '#000' }],
107
+ })
108
+ expect(wrapper.find('.sg-summary--warn').text()).toContain('one sample')
109
+ })
110
+ })
111
+
112
+ describe('SmartGroupFieldRecipe keyboard and edge cases', () => {
113
+ const switchOf = (wrapper: ReturnType<typeof mount>, name: string) =>
114
+ wrapper.findAll('.fr-row').find(row => row.find('.sg-field__name').text() === name)!.find('[role="switch"]')
115
+
116
+ it('should keep focus on the switch of a field when the field moves to the other list', async () => {
117
+ const wrapper = mount(SmartGroupFieldRecipe, { attachTo: document.body })
118
+ await switchOf(wrapper, 'Dose').trigger('click')
119
+ await new Promise(resolve => setTimeout(resolve))
120
+ expect(document.activeElement).toBe(switchOf(wrapper, 'Dose').element)
121
+ })
122
+
123
+ it('should keep focus on the handle when a field moves up with the keyboard', async () => {
124
+ const wrapper = mount(SmartGroupFieldRecipe, { attachTo: document.body })
125
+ const grip = wrapper.findAll('[data-test="field-on"] .fr-row__grip')[1]
126
+ ;(grip.element as HTMLElement).focus()
127
+ await grip.trigger('keydown', { key: 'ArrowUp', altKey: true })
128
+ await new Promise(resolve => setTimeout(resolve))
129
+ expect(document.activeElement).toBe(wrapper.findAll('[data-test="field-on"] .fr-row__grip')[0].element)
130
+ })
131
+
132
+ it('should keep the name input open and emit nothing when another field has the new name', async () => {
133
+ const wrapper = mountControlled({ renamable: true })
134
+ await wrapper.find('[data-test="field-on"] .fr-row__rename').trigger('click')
135
+ const input = wrapper.find('[data-test="field-on"] input')
136
+ await input.setValue('Genotype')
137
+ await input.trigger('keydown', { key: 'Enter' })
138
+ expect([wrapper.emitted('rename'), wrapper.find('[data-test="field-on"] input').attributes('aria-invalid')]).toEqual([undefined, 'true'])
139
+ })
140
+
141
+ it('should not let the row be dragged when its name is being edited', async () => {
142
+ const wrapper = mountControlled({ renamable: true })
143
+ await wrapper.find('[data-test="field-on"] .fr-row__rename').trigger('click')
144
+ expect(wrapper.find('[data-test="field-on"]').attributes('draggable')).toBe('false')
145
+ })
146
+
147
+ it('should not show the empty state when the batch has samples but no field', () => {
148
+ const wrapper = mountControlled({ fields: [], enabled: [], layers: [], groups: [], headerCount: 2 })
149
+ expect(wrapper.find('[data-test="recipe-empty"]').exists()).toBe(false)
150
+ })
151
+
152
+ it('should move a field to "Left off" when layers are given but the toggles are not controlled', async () => {
153
+ const wrapper = mount(SmartGroupFieldRecipe, { props: { fields, layers: [null, 2, 1] }, attachTo: document.body })
154
+ await switchOf(wrapper, 'Dose').trigger('click')
155
+ const names = wrapper.findAll('[data-test="field-on"] .sg-field__name').map(node => node.text())
156
+ expect(names).toEqual(['Genotype'])
157
+ })
158
+ })
159
+
160
+ describe('SmartGroupFieldRecipe second review', () => {
161
+ const twoFields = [
162
+ { key: 'A', role: 'Factor', vals: 'a1, a2', rep: false },
163
+ { key: 'B', role: 'Factor', vals: 'b1, b2', rep: false },
164
+ ]
165
+ const onNames = (wrapper: ReturnType<typeof mount>) =>
166
+ wrapper.findAll('[data-test="field-on"] .sg-field__name').map(node => node.text())
167
+
168
+ it('should reorder the rows when the parent updates layers and the toggles are not controlled', async () => {
169
+ const wrapper = mount(SmartGroupFieldRecipe, { props: { fields: twoFields, layers: [1, 2] } })
170
+ await wrapper.setProps({ layers: [2, 1] })
171
+ expect(onNames(wrapper)).toEqual(['B', 'A'])
172
+ })
173
+
174
+ it('should emit move when layers are given and the toggles are not controlled', async () => {
175
+ const wrapper = mount(SmartGroupFieldRecipe, { props: { fields: twoFields, layers: [1, 2] } })
176
+ await wrapper.findAll('.fr-row__grip')[1].trigger('keydown', { key: 'ArrowUp', altKey: true })
177
+ expect(wrapper.emitted('move')).toEqual([[1, 'up']])
178
+ })
179
+
180
+ it('should keep the name input open when the new name is a reserved name', async () => {
181
+ const wrapper = mountControlled({ renamable: true, reservedNames: ['Matrix'] })
182
+ await wrapper.find('[data-test="field-on"] .fr-row__rename').trigger('click')
183
+ const input = wrapper.find('[data-test="field-on"] input')
184
+ await input.setValue('Matrix')
185
+ await input.trigger('keydown', { key: 'Enter' })
186
+ expect([wrapper.emitted('rename'), wrapper.find('[data-test="field-on"] input').attributes('aria-invalid')]).toEqual([undefined, 'true'])
187
+ })
12
188
  })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { inferRole, parseNumeric, buildClassSchema } from '../../../composables/autoGroup/columns'
2
+ import { inferRole, parseNumeric, buildClassSchema, splitColumns } from '../../../composables/autoGroup/columns'
3
3
 
4
4
  describe('parseNumeric', () => {
5
5
  it('parses value+unit tokens', () => {
@@ -58,17 +58,36 @@ describe('inferRole', () => {
58
58
  expect(inferRole(make(['5nM', '10nM', '20nM']))).toBe('factor')
59
59
  })
60
60
 
61
- it('returns replicate ONLY for explicit replicate-named tokens (rep1, Rep2, replicate3, R1)', () => {
62
- // The replicate pre-grouping pass collapses real replicates by name
63
- // before tokenisation, so the cardinality-based ID_PATTERN heuristic
64
- // (M1, M2, …) was removed — those reach inferRole now only when they
65
- // represent distinct conditions, and should be treated as factors.
61
+ it('should return id when each sample has its own numbered value', () => {
66
62
  expect(inferRole({
67
63
  values: ['M1', 'M2', 'M3', 'M4', 'M5'],
68
64
  position: 1,
69
65
  totalPositions: 3,
70
66
  classSize: 5,
71
- })).toBe('factor')
67
+ })).toBe('id')
68
+ })
69
+
70
+ it('should return factor when a unique column is in a class of fewer than four samples', () => {
71
+ expect(inferRole({ values: ['M1', 'M2', 'M3'], position: 1, totalPositions: 3, classSize: 3 })).toBe('factor')
72
+ })
73
+
74
+ it('should return replicate when all values are T<n> tokens', () => {
75
+ expect(inferRole({ values: ['T1', 'T2', 'T1', 'T2'], position: 1, totalPositions: 3, classSize: 4 })).toBe('replicate')
76
+ })
77
+
78
+ it('should return factor when the values are B<n> label tokens', () => {
79
+ expect(inferRole({ values: ['B13', 'S13', 'B13', 'S13'], position: 1, totalPositions: 3, classSize: 4 })).toBe('factor')
80
+ })
81
+
82
+ it('should return replicate when a small counter repeats at the end of a name', () => {
83
+ expect(inferRole({ values: ['1', '2', '3', '1', '2', '3'], position: 2, totalPositions: 3, classSize: 6 })).toBe('replicate')
84
+ })
85
+
86
+ it('should return factor when a small number repeats in the middle of a name', () => {
87
+ expect(inferRole({ values: ['1', '2', '3', '1', '2', '3'], position: 1, totalPositions: 3, classSize: 6 })).toBe('factor')
88
+ })
89
+
90
+ it('should return replicate when all values are replicate-named tokens', () => {
72
91
  expect(inferRole({
73
92
  values: ['rep1', 'rep2', 'rep3'],
74
93
  position: 1,
@@ -87,12 +106,50 @@ describe('inferRole', () => {
87
106
  expect(inferRole(make(['Kidney', 'Liver', 'Tumor', 'Kidney', 'Liver']))).toBe('factor')
88
107
  })
89
108
 
90
- it('returns ignore for very-high-cardinality opaque values', () => {
109
+ it('should return ignore when more than 50 values each repeat', () => {
91
110
  // Real-world LC-MS batches routinely have 30–50 distinct conditions, so the
92
- // factor cap was raised to 50 (was 20). 80 unique values still reads as
93
- // ignore — likely a sample-ID column rather than a meaningful factor.
111
+ // factor cap is 50. 80 values that each occur twice are not a sample ID
112
+ // and are too many for a factor.
113
+ const values = Array.from({ length: 160 }, (_, i) => `opaque-${i % 80}`)
114
+ expect(inferRole({ values, position: 0, totalPositions: 1, classSize: 160 })).toBe('ignore')
115
+ })
116
+
117
+ it('should return id when 80 opaque values are all unique', () => {
94
118
  const values = Array.from({ length: 80 }, (_, i) => `opaque-${i}`)
95
- expect(inferRole({ values, position: 0, totalPositions: 1, classSize: 80 })).toBe('ignore')
119
+ expect(inferRole({ values, position: 0, totalPositions: 1, classSize: 80 })).toBe('id')
120
+ })
121
+ })
122
+
123
+ describe('splitColumns', () => {
124
+ it('should split on the hyphen when a column holds condition-replicate tokens', () => {
125
+ expect(splitColumns([['HMW', '1-1'], ['HMW', '1-2'], ['MMW', '2-1']]).rows[0]).toEqual(['HMW', '1', '1'])
126
+ })
127
+
128
+ it('should split stem and counter when two stems repeat', () => {
129
+ expect(splitColumns([['C1'], ['C2'], ['H1'], ['H2']]).rows[0]).toEqual(['C', '1'])
130
+ })
131
+
132
+ it('should keep a token whole when the column has one stem', () => {
133
+ expect(splitColumns([['D1'], ['D3'], ['D6'], ['D1']]).rows[0]).toEqual(['D1'])
134
+ })
135
+
136
+ it('should keep a token whole when each stem has one counter', () => {
137
+ expect(splitColumns([['B13'], ['S13'], ['B13'], ['S13']]).rows[0]).toEqual(['B13'])
138
+ })
139
+
140
+ it('should pad a name when it does not match the split of its column', () => {
141
+ const rows = [...Array.from({ length: 9 }, (_, i) => ['HMW', `${i + 1}-1`]), ['HMW', 'DigestControl']]
142
+ expect(splitColumns(rows).rows[9]).toEqual(['HMW', 'DigestControl', ''])
143
+ })
144
+
145
+ it('should report the source column of each new column when a column splits', () => {
146
+ expect(splitColumns([['HMW', '1-1'], ['MMW', '2-2']]).origin).toEqual([0, 1, 1])
147
+ })
148
+ })
149
+
150
+ describe('parseNumeric decimals', () => {
151
+ it('should read the value when the token has a decimal point', () => {
152
+ expect(parseNumeric('0.1uM')).toEqual({ value: 0.1, unit: 'uM', raw: '0.1uM' })
96
153
  })
97
154
  })
98
155
 
@@ -133,3 +190,35 @@ describe('buildClassSchema', () => {
133
190
  expect(schema.groupBy).toEqual(factorIndices)
134
191
  })
135
192
  })
193
+
194
+ describe('column roles review regressions', () => {
195
+ const dose = (levels: string[]) =>
196
+ levels.flatMap((level, i) => [['Drug', level, `0${2 * i + 1}`], ['Drug', level, `0${2 * i + 2}`]])
197
+
198
+ it('should group by a bare-number dose when it sits before the injection number', () => {
199
+ expect(buildClassSchema(dose(['1', '5', '10']), 'unknown', undefined, []).groupBy).toEqual([1])
200
+ })
201
+
202
+ it('should leave a counter out of the group key when it sits before the injection number', () => {
203
+ expect(buildClassSchema(dose(['1', '2', '3']), 'unknown', undefined, []).groupBy).toEqual([])
204
+ })
205
+
206
+ it('should return factor when each sample has its own word with no digit', () => {
207
+ expect(inferRole({ values: ['Ctrl', 'DrugA', 'DrugB', 'DrugC'], position: 0, totalPositions: 2, classSize: 4 })).toBe('factor')
208
+ })
209
+
210
+ it('should return replicate when a zero-padded counter repeats at the end of a name', () => {
211
+ const values = ['01', '02', '03', '04', '01', '02', '03', '04']
212
+ expect(inferRole({ values, position: 2, totalPositions: 3, classSize: 8 })).toBe('replicate')
213
+ })
214
+
215
+ it('should return replicate when one counter of the run is missing', () => {
216
+ const values = ['1', '2', '4', '1', '2', '4']
217
+ expect(inferRole({ values, position: 2, totalPositions: 3, classSize: 6 })).toBe('replicate')
218
+ })
219
+
220
+ it('should return id when one value of a sample-number column has no digit', () => {
221
+ const values = [...Array.from({ length: 19 }, (_, i) => String(i + 11)), 'pool']
222
+ expect(inferRole({ values, position: 1, totalPositions: 3, classSize: 20 })).toBe('id')
223
+ })
224
+ })