@morscherlab/mint-sdk 1.0.44 → 1.0.46
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/SmartGroupFieldRecipe.groups.test.d.ts +1 -0
- package/dist/__tests__/components/SmartGroupManual.cohorts.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.adapter.d.ts +42 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -105
- package/dist/components/DataFrame.vue.d.ts +15 -0
- package/dist/components/SmartGroup.types.d.ts +71 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +53 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +37 -0
- package/dist/components/SmartGroupManual.cohorts.d.ts +52 -0
- package/dist/components/SmartGroupManual.vue.d.ts +17 -0
- package/dist/components/SmartGroupModal.vue.d.ts +27 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-BGVwavdd.js → components-C7UFkNIp.js} +3036 -2751
- package/dist/components-C7UFkNIp.js.map +1 -0
- package/dist/composables/index.js +2 -2
- package/dist/{composables-C_hPF0Gn.js → composables-CpBhNKHf.js} +7 -147
- package/dist/composables-CpBhNKHf.js.map +1 -0
- package/dist/index.js +4 -4
- package/dist/install.js +1 -1
- package/dist/styles.css +12824 -12195
- package/dist/{useProtocolTemplates-BbvlHoPD.js → useProtocolTemplates-C8-YlHj1.js} +206 -66
- package/dist/useProtocolTemplates-C8-YlHj1.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AutoGroupModal.adapter.test.ts +164 -0
- package/src/__tests__/components/DataFrame.test.ts +100 -0
- package/src/__tests__/components/SampleSelector.test.ts +176 -16
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +96 -0
- package/src/__tests__/components/SmartGroupManual.cohorts.test.ts +97 -0
- package/src/components/AutoGroupModal.adapter.ts +147 -0
- package/src/components/AutoGroupModal.vue +176 -1321
- package/src/components/DataFrame.vue +97 -5
- package/src/components/SampleSelector.vue +8 -23
- package/src/components/SmartGroup.types.ts +93 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +105 -0
- package/src/components/SmartGroupFieldRecipe.story.vue +58 -0
- package/src/components/SmartGroupFieldRecipe.vue +427 -0
- package/src/components/SmartGroupManual.cohorts.ts +112 -0
- package/src/components/SmartGroupManual.story.vue +55 -0
- package/src/components/SmartGroupManual.vue +398 -0
- package/src/components/SmartGroupModal.story.vue +61 -0
- package/src/components/SmartGroupModal.vue +61 -0
- package/src/components/index.ts +3 -0
- package/src/styles/components/dataframe.css +79 -0
- package/src/styles/components/sample-selector.css +1 -5
- package/src/styles/components/smart-group.css +708 -0
- package/src/styles/index.css +1 -1
- package/dist/components-BGVwavdd.js.map +0 -1
- package/dist/composables-C_hPF0Gn.js.map +0 -1
- package/dist/useProtocolTemplates-BbvlHoPD.js.map +0 -1
- package/src/__tests__/components/AutoGroupModal.preview.test.ts +0 -46
- package/src/styles/components/auto-group-modal.css +0 -1336
- /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.
|
|
3
|
+
"version": "1.0.46",
|
|
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
|
+
})
|
|
@@ -812,4 +812,104 @@ describe('DataFrame', () => {
|
|
|
812
812
|
expect(wrapper.emitted('row-click')).toBeUndefined()
|
|
813
813
|
})
|
|
814
814
|
})
|
|
815
|
+
|
|
816
|
+
describe('row deletion', () => {
|
|
817
|
+
it('should not show an actions column by default', () => {
|
|
818
|
+
const wrapper = mount(DataFrame, {
|
|
819
|
+
props: { data: mockData, columns: mockColumns },
|
|
820
|
+
})
|
|
821
|
+
expect(wrapper.find('.mint-dataframe__td--actions').exists()).toBe(false)
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it('should show a delete button per row when deletable', () => {
|
|
825
|
+
const wrapper = mount(DataFrame, {
|
|
826
|
+
props: { data: mockData, columns: mockColumns, deletable: true },
|
|
827
|
+
})
|
|
828
|
+
expect(wrapper.find('.mint-dataframe__th--actions').exists()).toBe(true)
|
|
829
|
+
expect(wrapper.findAll('.mint-dataframe__delete-btn')).toHaveLength(mockData.length)
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
it('should emit delete-row with the row and index', async () => {
|
|
833
|
+
const wrapper = mount(DataFrame, {
|
|
834
|
+
props: { data: mockData, columns: mockColumns, deletable: true },
|
|
835
|
+
})
|
|
836
|
+
await wrapper.findAll('.mint-dataframe__delete-btn')[1].trigger('click')
|
|
837
|
+
|
|
838
|
+
expect(wrapper.emitted('delete-row')).toHaveLength(1)
|
|
839
|
+
const [row, index] = wrapper.emitted('delete-row')?.[0] as [Record<string, unknown>, number]
|
|
840
|
+
expect(row.name).toBe('Bob')
|
|
841
|
+
expect(index).toBe(1)
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('should not emit row-click when the delete button is clicked', async () => {
|
|
845
|
+
const wrapper = mount(DataFrame, {
|
|
846
|
+
props: { data: mockData, columns: mockColumns, deletable: true },
|
|
847
|
+
})
|
|
848
|
+
await wrapper.find('.mint-dataframe__delete-btn').trigger('click')
|
|
849
|
+
expect(wrapper.emitted('row-click')).toBeUndefined()
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
it('should use a custom delete label', () => {
|
|
853
|
+
const wrapper = mount(DataFrame, {
|
|
854
|
+
props: { data: mockData, columns: mockColumns, deletable: true, deleteLabel: 'Remove' },
|
|
855
|
+
})
|
|
856
|
+
expect(wrapper.find('.mint-dataframe__delete-btn').attributes('aria-label')).toBe('Remove')
|
|
857
|
+
})
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
describe('column resizing', () => {
|
|
861
|
+
it('should not show resize handles by default', () => {
|
|
862
|
+
const wrapper = mount(DataFrame, {
|
|
863
|
+
props: { data: mockData, columns: mockColumns },
|
|
864
|
+
})
|
|
865
|
+
expect(wrapper.find('.mint-dataframe__resize-handle').exists()).toBe(false)
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
it('should show a resize handle per column when resizable', () => {
|
|
869
|
+
const wrapper = mount(DataFrame, {
|
|
870
|
+
props: { data: mockData, columns: mockColumns, resizable: true },
|
|
871
|
+
})
|
|
872
|
+
expect(wrapper.findAll('.mint-dataframe__resize-handle')).toHaveLength(mockColumns.length)
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
it('should emit update:columnWidths while dragging a handle', async () => {
|
|
876
|
+
const wrapper = mount(DataFrame, {
|
|
877
|
+
props: { data: mockData, columns: mockColumns, resizable: true },
|
|
878
|
+
})
|
|
879
|
+
const handle = wrapper.findAll('.mint-dataframe__resize-handle')[0]
|
|
880
|
+
await handle.trigger('mousedown', { clientX: 100 })
|
|
881
|
+
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 220 }))
|
|
882
|
+
window.dispatchEvent(new MouseEvent('mouseup'))
|
|
883
|
+
|
|
884
|
+
const events = wrapper.emitted('update:columnWidths')
|
|
885
|
+
expect(events).toBeTruthy()
|
|
886
|
+
const widths = events?.[events.length - 1]?.[0] as Record<string, number>
|
|
887
|
+
expect(widths.name).toBeGreaterThanOrEqual(48) // MIN_COL_WIDTH floor
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('should honor controlled columnWidths', () => {
|
|
891
|
+
const wrapper = mount(DataFrame, {
|
|
892
|
+
props: {
|
|
893
|
+
data: mockData,
|
|
894
|
+
columns: mockColumns,
|
|
895
|
+
resizable: true,
|
|
896
|
+
columnWidths: { name: 321 },
|
|
897
|
+
},
|
|
898
|
+
})
|
|
899
|
+
const header = wrapper.find('.mint-dataframe__th')
|
|
900
|
+
expect(header.attributes('style')).toContain('width: 321px')
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
it('should not trigger sort when the resize handle is clicked', async () => {
|
|
904
|
+
const wrapper = mount(DataFrame, {
|
|
905
|
+
props: {
|
|
906
|
+
data: mockData,
|
|
907
|
+
columns: [{ key: 'name', label: 'Name', sortable: true }],
|
|
908
|
+
resizable: true,
|
|
909
|
+
},
|
|
910
|
+
})
|
|
911
|
+
await wrapper.find('.mint-dataframe__resize-handle').trigger('click')
|
|
912
|
+
expect(wrapper.emitted('update:sort')).toBeUndefined()
|
|
913
|
+
})
|
|
914
|
+
})
|
|
815
915
|
})
|
|
@@ -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('.
|
|
133
|
-
const fields = wrapper.findAll('.
|
|
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('.
|
|
137
|
-
await wrapper.find('.
|
|
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.
|
|
167
|
-
await wrapper.findAll('.
|
|
168
|
-
await wrapper.
|
|
169
|
-
await wrapper.find('.mint-
|
|
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('.
|
|
198
|
-
|
|
199
|
-
await
|
|
200
|
-
await wrapper.find('.mint-
|
|
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
|
+
})
|