@morscherlab/mint-sdk 1.3.0-beta.9 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
- package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
- package/dist/components/SmartGroup.types.d.ts +22 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-GDDvof8D.js → components-bbanLd1u.js} +510 -287
- package/dist/components-bbanLd1u.js.map +1 -0
- package/dist/composables/autoGroup/classify.d.ts +7 -1
- package/dist/composables/autoGroup/columns.d.ts +16 -1
- package/dist/composables/autoGroup/index.d.ts +5 -7
- package/dist/composables/autoGroup/tokenize.d.ts +36 -1
- package/dist/composables/index.js +2 -2
- package/dist/composables/useAutoGroup.d.ts +13 -2
- package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
- package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/dist/permissions.js +2 -2
- package/dist/permissions.js.map +1 -1
- package/dist/styles.css +129 -96
- package/dist/types/auto-group.d.ts +1 -1
- package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
- package/dist/validation-CukfLjy-.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +4 -4
- package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
- package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
- package/src/__tests__/components/SampleSelector.test.ts +14 -16
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
- package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
- package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
- package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
- package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
- package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
- package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
- package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
- package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
- package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
- package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
- package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
- package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
- package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
- package/src/__tests__/utils/permissions.test.ts +15 -0
- package/src/components/AutoGroupModal.adapter.ts +80 -9
- package/src/components/AutoGroupModal.vue +41 -8
- package/src/components/SmartGroup.types.ts +23 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
- package/src/components/SmartGroupFieldRecipe.vue +289 -169
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +3 -6
- package/src/composables/autoGroup/classify.ts +26 -7
- package/src/composables/autoGroup/columns.ts +144 -36
- package/src/composables/autoGroup/compose.ts +8 -10
- package/src/composables/autoGroup/fingerprint.ts +1 -1
- package/src/composables/autoGroup/index.ts +5 -12
- package/src/composables/autoGroup/tokenLength.ts +8 -0
- package/src/composables/autoGroup/tokenize.ts +148 -1
- package/src/composables/autoGroup/vocab.json +5 -11
- package/src/composables/useAutoGroup.ts +92 -79
- package/src/permissions.ts +3 -2
- package/src/styles/components/app-page-selector.css +58 -14
- package/src/styles/components/smart-group.css +71 -82
- package/src/types/auto-group.ts +1 -0
- package/dist/components-GDDvof8D.js.map +0 -1
- package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
- package/dist/validation-Dmp9Q5W0.js.map +0 -1
- 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.
|
|
3
|
+
"version": "1.3.1",
|
|
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": "
|
|
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",
|
|
@@ -154,23 +154,23 @@ describe('AppTopBarPageSelectorInternal', () => {
|
|
|
154
154
|
expect(wrapper.get('.mint-plugin-icon__img').attributes('src')).toBe(httpsIcon)
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
-
it('should render the menu
|
|
157
|
+
it('should render the menu as its own panel, not the shared popover, when opened', async () => {
|
|
158
158
|
const wrapper = mount(AppTopBarPageSelectorInternal, {
|
|
159
159
|
props: { currentPageId: 'Workspace', pages: ['Workspace', 'Results'] },
|
|
160
160
|
})
|
|
161
161
|
|
|
162
162
|
await wrapper.get('.mint-page-selector__trigger').trigger('click')
|
|
163
163
|
|
|
164
|
-
expect(wrapper.get('.mint-page-selector__menu').classes()).toContain('mint-popover')
|
|
164
|
+
expect(wrapper.get('.mint-page-selector__menu').classes()).not.toContain('mint-popover')
|
|
165
165
|
})
|
|
166
166
|
|
|
167
|
-
it('should mark the current page as
|
|
167
|
+
it('should mark the current page as the active item when opened', async () => {
|
|
168
168
|
const wrapper = mount(AppTopBarPageSelectorInternal, {
|
|
169
169
|
props: { currentPageId: 'Workspace', pages: ['Workspace', 'Results'] },
|
|
170
170
|
})
|
|
171
171
|
|
|
172
172
|
await wrapper.get('.mint-page-selector__trigger').trigger('click')
|
|
173
173
|
|
|
174
|
-
expect(wrapper.findAll('.mint-page-selector__item')[0].classes()).toContain('mint-
|
|
174
|
+
expect(wrapper.findAll('.mint-page-selector__item')[0].classes()).toContain('mint-page-selector__item--active')
|
|
175
175
|
})
|
|
176
176
|
})
|
|
@@ -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
|
+
})
|
|
@@ -277,7 +277,11 @@ describe('AutoGroupModal multi-schema auto workflow', () => {
|
|
|
277
277
|
},
|
|
278
278
|
})
|
|
279
279
|
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
415
|
+
it('should emit download-prefilled when the user picks that menu item', () => {
|
|
413
416
|
const wrapper = mount(SmartGroupFieldRecipe)
|
|
414
|
-
|
|
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('
|
|
421
|
+
it('should disable the two download items when canDownloadTemplate is false', () => {
|
|
420
422
|
const wrapper = mount(SmartGroupFieldRecipe, { props: { canDownloadTemplate: false } })
|
|
421
|
-
|
|
422
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
93
|
-
//
|
|
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('
|
|
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
|
+
})
|