@morscherlab/mint-sdk 1.1.8 → 1.1.9
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/RetiredStyles.test.d.ts +1 -0
- package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
- package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
- package/dist/components/AppTopBar.vue.d.ts +6 -11
- package/dist/components/AutoGroupModal.adapter.d.ts +10 -6
- package/dist/components/PluginIcon.vue.d.ts +2 -2
- package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
- package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
- package/dist/components/SampleSelector.groups.d.ts +7 -7
- package/dist/components/SampleSelector.selection.d.ts +6 -12
- package/dist/components/SampleSelector.tree.d.ts +39 -0
- package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
- package/dist/components/SmartGroup.types.d.ts +5 -0
- package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-CDomhGSj.js → components-BbtUpCTv.js} +913 -943
- package/dist/components-BbtUpCTv.js.map +1 -0
- package/dist/composables/index.d.ts +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/composables/useAutoGroup.d.ts +3 -0
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/composables/useSampleGroups.d.ts +25 -0
- package/dist/index.js +2 -2
- package/dist/install.js +1 -1
- package/dist/styles.css +130 -557
- package/dist/types/platform.d.ts +1 -0
- package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-BWfNaynA.js} +199 -24
- package/dist/useProtocolTemplates-BWfNaynA.js.map +1 -0
- package/dist/utils/pluginIcon.d.ts +1 -1
- package/dist/utils/sampleGroupPath.d.ts +6 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
- package/src/__tests__/components/AppTopBar.test.ts +84 -10
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
- package/src/__tests__/components/AutoGroupModal.adapter.test.ts +16 -6
- package/src/__tests__/components/PluginIcon.test.ts +19 -0
- package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
- package/src/__tests__/components/RetiredStyles.test.ts +82 -0
- package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
- package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
- package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
- package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
- package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
- package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +249 -0
- package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
- package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
- package/src/components/AppTopBar.story.vue +9 -7
- package/src/components/AppTopBar.vue +79 -72
- package/src/components/AutoGroupModal.adapter.ts +17 -12
- package/src/components/AutoGroupModal.story.vue +89 -0
- package/src/components/AutoGroupModal.vue +16 -8
- package/src/components/PluginIcon.story.vue +11 -9
- package/src/components/PluginIcon.vue +3 -3
- package/src/components/PluginWorkspaceView.props.ts +5 -7
- package/src/components/PluginWorkspaceView.vue +0 -4
- package/src/components/SampleSelector.drag.ts +5 -5
- package/src/components/SampleSelector.groups.ts +15 -15
- package/src/components/SampleSelector.selection.ts +7 -46
- package/src/components/SampleSelector.story.vue +37 -0
- package/src/components/SampleSelector.tree.ts +48 -0
- package/src/components/SampleSelector.vue +52 -320
- package/src/components/SampleSelectorGroupNode.vue +197 -0
- package/src/components/SmartGroup.types.ts +5 -0
- package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
- package/src/components/SmartGroupFieldRecipe.vue +76 -4
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
- package/src/composables/autoGroup/compose.ts +31 -10
- package/src/composables/index.ts +1 -0
- package/src/composables/useAutoGroup.ts +108 -5
- package/src/composables/useSampleGroups.ts +118 -33
- package/src/styles/components/app-avatar-menu.css +2 -1
- package/src/styles/components/app-plugin-switcher.css +0 -13
- package/src/styles/components/app-top-bar.css +13 -54
- package/src/styles/components/dose-calculator.css +0 -4
- package/src/styles/components/experiment-popover.css +2 -104
- package/src/styles/components/experiment-selector-modal.css +0 -7
- package/src/styles/components/plugin-icon.css +3 -0
- package/src/styles/components/protocol-step-editor.css +0 -6
- package/src/styles/components/reagent-editor.css +0 -5
- package/src/styles/components/sample-hierarchy-tree.css +0 -4
- package/src/styles/components/sample-selector.css +0 -96
- package/src/styles/components/schedule-calendar.css +0 -6
- package/src/styles/components/smart-group.css +22 -0
- package/src/styles/components/well-plate.css +1 -27
- package/src/types/platform.ts +1 -0
- package/src/utils/pluginIcon.ts +5 -2
- package/src/utils/sampleGroupPath.ts +19 -0
- package/dist/components-CDomhGSj.js.map +0 -1
- package/dist/useProtocolTemplates-BMt0LlXf.js.map +0 -1
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Split a group name into normalized hierarchy segments. */
|
|
2
|
+
export declare function splitSampleGroupPath(groupName: string, separator: string): string[];
|
|
3
|
+
/** First normalized hierarchy segment, used as the reorderable family key. */
|
|
4
|
+
export declare function getSampleGroupMajorPrefix(groupName: string, separator: string): string;
|
|
5
|
+
/** Normalized path above a concrete leaf group. */
|
|
6
|
+
export declare function getSampleGroupParentPath(groupName: string, separator: string): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.9",
|
|
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",
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
1
3
|
import { mount } from '@vue/test-utils'
|
|
2
4
|
import { describe, expect, it } from 'vitest'
|
|
3
5
|
import AppAvatarMenu from '../../components/AppAvatarMenu.vue'
|
|
4
6
|
|
|
5
7
|
describe('AppAvatarMenu', () => {
|
|
8
|
+
it('should vertically center avatar initials with a compact line box', () => {
|
|
9
|
+
const styles = readFileSync(
|
|
10
|
+
resolve(process.cwd(), 'src/styles/components/app-avatar-menu.css'),
|
|
11
|
+
'utf8',
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
expect(styles).toMatch(/\.mint-avatar-menu__avatar\s*\{[^}]*line-height:\s*1;/s)
|
|
15
|
+
})
|
|
16
|
+
|
|
6
17
|
it('renders mixed menu actions and emits selected items', async () => {
|
|
7
18
|
const docs = { id: 'docs', label: 'Docs', href: '/docs' }
|
|
8
19
|
const profile = { id: 'profile', label: 'Profile' }
|
|
@@ -81,6 +81,12 @@ describe('AppTopBar', () => {
|
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
describe('default props behavior', () => {
|
|
84
|
+
it('should not expose the retired profile-button prop', () => {
|
|
85
|
+
const wrapper = createWrapper({ title: 'Test App' })
|
|
86
|
+
|
|
87
|
+
expect(wrapper.props()).not.toHaveProperty('showProfile')
|
|
88
|
+
})
|
|
89
|
+
|
|
84
90
|
it('should render without ThemeToggle by default', () => {
|
|
85
91
|
const wrapper = createWrapper({ title: 'Test App' })
|
|
86
92
|
expect(wrapper.findComponent(ThemeToggle).exists()).toBe(false)
|
|
@@ -563,6 +569,46 @@ describe('AppTopBar', () => {
|
|
|
563
569
|
})
|
|
564
570
|
|
|
565
571
|
describe('combined features', () => {
|
|
572
|
+
it('does not bind an experiment when the integrated plugin is static', async () => {
|
|
573
|
+
vi.mocked(usePlatformContext).mockReturnValueOnce({
|
|
574
|
+
isIntegrated: computed(() => true),
|
|
575
|
+
context: ref({ isIntegrated: true, theme: 'system' }),
|
|
576
|
+
plugin: computed(() => ({
|
|
577
|
+
id: 'hmdb',
|
|
578
|
+
name: 'HMDB',
|
|
579
|
+
version: '1.0.0',
|
|
580
|
+
route_prefix: '/hmdb',
|
|
581
|
+
api_prefix: '/api/hmdb',
|
|
582
|
+
plugin_type: 'static',
|
|
583
|
+
})),
|
|
584
|
+
user: computed(() => undefined),
|
|
585
|
+
theme: computed(() => 'system' as const),
|
|
586
|
+
features: computed(() => undefined),
|
|
587
|
+
navigate: vi.fn(),
|
|
588
|
+
notify: vi.fn(),
|
|
589
|
+
sendToPlatform: vi.fn(),
|
|
590
|
+
})
|
|
591
|
+
const wrapper = mount(AppTopBar, {
|
|
592
|
+
props: { title: 'HMDB' },
|
|
593
|
+
global: {
|
|
594
|
+
plugins: [createPinia()],
|
|
595
|
+
provide: {
|
|
596
|
+
[APP_EXPERIMENT_KEY as symbol]: {} as AppExperimentState,
|
|
597
|
+
},
|
|
598
|
+
stubs: {
|
|
599
|
+
'router-link': { template: '<a><slot /></a>' },
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
await waitForAsyncComponents(wrapper)
|
|
605
|
+
|
|
606
|
+
expect([
|
|
607
|
+
wrapper.findComponent(ExperimentPopover).exists(),
|
|
608
|
+
wrapper.findComponent(ExperimentSelectorModal).exists(),
|
|
609
|
+
]).toEqual([false, false])
|
|
610
|
+
})
|
|
611
|
+
|
|
566
612
|
it('passes injected app experiment bindings to the popover and selector modal', async () => {
|
|
567
613
|
vi.mocked(usePlatformContext).mockReturnValueOnce({
|
|
568
614
|
isIntegrated: computed(() => true),
|
|
@@ -717,8 +763,8 @@ describe('AppTopBar', () => {
|
|
|
717
763
|
describe('navigation prop surface', () => {
|
|
718
764
|
it('renders a title-only layout without navigation props', () => {
|
|
719
765
|
const wrapper = createWrapper({
|
|
720
|
-
title: '
|
|
721
|
-
subtitle: '
|
|
766
|
+
title: 'Simple App',
|
|
767
|
+
subtitle: 'Current API',
|
|
722
768
|
})
|
|
723
769
|
|
|
724
770
|
expect(wrapper.find('.mint-topbar').exists()).toBe(true)
|
|
@@ -824,7 +870,7 @@ describe('AppTopBar', () => {
|
|
|
824
870
|
expect(icons).toHaveLength(2)
|
|
825
871
|
expect(icons[0].props('icon')).toBe(iconPath)
|
|
826
872
|
expect(icons[1].props('icon')).toBe(pngIcon)
|
|
827
|
-
expect(icons.every((icon) => icon.props('variant') === '
|
|
873
|
+
expect(icons.every((icon) => icon.props('variant') === 'solid')).toBe(true)
|
|
828
874
|
|
|
829
875
|
const pathIcons = wrapper.findAll('.mint-plugin-icon__svg')
|
|
830
876
|
expect(pathIcons.map(icon => icon.find('path').attributes('d'))).toEqual(
|
|
@@ -981,10 +1027,16 @@ describe('AppTopBar', () => {
|
|
|
981
1027
|
expect(pluginIcon.exists()).toBe(true)
|
|
982
1028
|
expect(pluginIcon.props('icon')).toBe(iconPath)
|
|
983
1029
|
expect(pluginIcon.props('variant')).toBe('solid')
|
|
984
|
-
expect(pluginIcon.props('size')).toBe('
|
|
1030
|
+
expect(pluginIcon.props('size')).toBe('topbar')
|
|
985
1031
|
expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(false)
|
|
986
1032
|
})
|
|
987
1033
|
|
|
1034
|
+
it('should render the icon inside the shared identity canvas', () => {
|
|
1035
|
+
const wrapper = createWrapper({ title: 'Test App' })
|
|
1036
|
+
|
|
1037
|
+
expect(wrapper.find('.mint-topbar__identity').exists()).toBe(true)
|
|
1038
|
+
})
|
|
1039
|
+
|
|
988
1040
|
it('should pass plugin.color to PluginIcon as tone', () => {
|
|
989
1041
|
const iconPath = 'M13 10V3L4 14h7v7l9-11h-7z'
|
|
990
1042
|
mockPlatformCtx({
|
|
@@ -1001,7 +1053,7 @@ describe('AppTopBar', () => {
|
|
|
1001
1053
|
expect(wrapper.findComponent({ name: 'PluginIcon' }).props('tone')).toBe('#22C55E')
|
|
1002
1054
|
})
|
|
1003
1055
|
|
|
1004
|
-
it('should
|
|
1056
|
+
it('should ignore the custom icon slot when integrated', () => {
|
|
1005
1057
|
const iconPath = 'M13 10V3L4 14h7v7l9-11h-7z'
|
|
1006
1058
|
mockPlatformCtx({
|
|
1007
1059
|
isIntegrated: true,
|
|
@@ -1012,8 +1064,32 @@ describe('AppTopBar', () => {
|
|
|
1012
1064
|
icon: '<div class="custom-icon">Custom</div>',
|
|
1013
1065
|
})
|
|
1014
1066
|
|
|
1067
|
+
expect(wrapper.find('.custom-icon').exists()).toBe(false)
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
it('should keep the custom icon slot when standalone', () => {
|
|
1071
|
+
const wrapper = createWrapper({ title: 'Test App' }, {
|
|
1072
|
+
icon: '<div class="custom-icon">Custom</div>',
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1015
1075
|
expect(wrapper.find('.custom-icon').exists()).toBe(true)
|
|
1016
|
-
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
it('should ignore the custom icon slot when integrated metadata is unavailable', () => {
|
|
1079
|
+
mockPlatformCtx({ isIntegrated: true, plugin: undefined })
|
|
1080
|
+
const wrapper = createWrapper({ title: 'Test App' }, {
|
|
1081
|
+
icon: '<div class="custom-icon">Custom</div>',
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
expect(wrapper.find('.custom-icon').exists()).toBe(false)
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
it('should use the shared fallback when integrated metadata is unavailable', () => {
|
|
1088
|
+
mockPlatformCtx({ isIntegrated: true, plugin: undefined })
|
|
1089
|
+
|
|
1090
|
+
const wrapper = createWrapper({ title: 'Test App' })
|
|
1091
|
+
|
|
1092
|
+
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(true)
|
|
1017
1093
|
})
|
|
1018
1094
|
|
|
1019
1095
|
it('should still render PluginIcon when icon does not start with M (PluginIcon shows fallback)', () => {
|
|
@@ -1029,7 +1105,7 @@ describe('AppTopBar', () => {
|
|
|
1029
1105
|
expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(false)
|
|
1030
1106
|
})
|
|
1031
1107
|
|
|
1032
|
-
it('should
|
|
1108
|
+
it('should render the shared PluginIcon fallback when integrated metadata has no icon', () => {
|
|
1033
1109
|
mockPlatformCtx({
|
|
1034
1110
|
isIntegrated: true,
|
|
1035
1111
|
plugin: { id: 'test', name: 'Test', version: '1.0', icon: '', route_prefix: '/test', api_prefix: '/api/test' },
|
|
@@ -1037,9 +1113,7 @@ describe('AppTopBar', () => {
|
|
|
1037
1113
|
|
|
1038
1114
|
const wrapper = createWrapper({ title: 'Test App' })
|
|
1039
1115
|
|
|
1040
|
-
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(
|
|
1041
|
-
expect(wrapper.find('.mint-topbar__logo-text').exists()).toBe(true)
|
|
1042
|
-
expect(wrapper.find('.mint-topbar__logo-text').text()).toBe('M')
|
|
1116
|
+
expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(true)
|
|
1043
1117
|
})
|
|
1044
1118
|
|
|
1045
1119
|
it('uses platform plugin nav metadata as the page selector when pageSelector is omitted', async () => {
|
|
@@ -122,7 +122,7 @@ describe('AppTopBarPageSelectorInternal', () => {
|
|
|
122
122
|
expect(icons[0].props('icon')).toBe(iconPath)
|
|
123
123
|
expect(icons[1].props('icon')).toBe(iconPath)
|
|
124
124
|
expect(icons[2].props('icon')).toBe(pngIcon)
|
|
125
|
-
expect(icons.every((icon) => icon.props('variant') === '
|
|
125
|
+
expect(icons.every((icon) => icon.props('variant') === 'solid')).toBe(true)
|
|
126
126
|
|
|
127
127
|
const pathIcons = wrapper.findAll('.mint-plugin-icon__svg')
|
|
128
128
|
expect(pathIcons).toHaveLength(2)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
candidateColumns,
|
|
4
4
|
engineFieldsToCards,
|
|
5
5
|
engineGroupsToRail,
|
|
6
6
|
engineToQcRoute,
|
|
@@ -35,7 +35,7 @@ describe('roleLabel', () => {
|
|
|
35
35
|
})
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
-
describe('
|
|
38
|
+
describe('candidateColumns', () => {
|
|
39
39
|
it('should include factors and replicates and drop constants/ignore', () => {
|
|
40
40
|
const columns = [
|
|
41
41
|
col({ index: 0, role: 'factor' }),
|
|
@@ -43,7 +43,7 @@ describe('candidateColumnIndices', () => {
|
|
|
43
43
|
col({ index: 2, role: 'replicate' }),
|
|
44
44
|
col({ index: 3, role: 'ignore' }),
|
|
45
45
|
]
|
|
46
|
-
expect(
|
|
46
|
+
expect(candidateColumns(columns, []).map(c => c.index)).toEqual([0, 2])
|
|
47
47
|
})
|
|
48
48
|
|
|
49
49
|
it('should also include a non-factor column already enabled in groupBy', () => {
|
|
@@ -51,7 +51,7 @@ describe('candidateColumnIndices', () => {
|
|
|
51
51
|
col({ index: 0, role: 'factor' }),
|
|
52
52
|
col({ index: 1, role: 'numeric' }),
|
|
53
53
|
]
|
|
54
|
-
expect(
|
|
54
|
+
expect(candidateColumns(columns, [1]).map(c => c.index)).toEqual([0, 1])
|
|
55
55
|
})
|
|
56
56
|
})
|
|
57
57
|
|
|
@@ -61,7 +61,7 @@ describe('engineFieldsToCards', () => {
|
|
|
61
61
|
col({ index: 0, name: 'Condition', role: 'factor', uniqueValues: ['Control', 'Treatment'] }),
|
|
62
62
|
col({ index: 1, name: 'Replicate', role: 'replicate', uniqueValues: ['Rep1', 'Rep2'] }),
|
|
63
63
|
]
|
|
64
|
-
const { fields, enabled } = engineFieldsToCards(columns,
|
|
64
|
+
const { fields, enabled } = engineFieldsToCards(columns, [0])
|
|
65
65
|
expect(fields).toEqual([
|
|
66
66
|
{ key: 'Condition', role: 'Factor', vals: 'Control, Treatment', rep: false },
|
|
67
67
|
{ key: 'Replicate', role: 'Replicate', vals: 'Rep1, Rep2', rep: true },
|
|
@@ -71,7 +71,17 @@ describe('engineFieldsToCards', () => {
|
|
|
71
71
|
|
|
72
72
|
it('should prefer displayName over name when present', () => {
|
|
73
73
|
const columns = [col({ index: 0, name: 'c0', displayName: 'Dose', role: 'factor', uniqueValues: ['Low'] })]
|
|
74
|
-
expect(engineFieldsToCards(columns,
|
|
74
|
+
expect(engineFieldsToCards(columns, []).fields[0].key).toBe('Dose')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should map each card to its 1-based hierarchy layer from the groupBy order', () => {
|
|
78
|
+
const columns = [
|
|
79
|
+
col({ index: 0, name: 'Tissue', role: 'factor' }),
|
|
80
|
+
col({ index: 1, name: 'Timepoint', role: 'factor' }),
|
|
81
|
+
col({ index: 2, name: 'Rep', role: 'replicate' }),
|
|
82
|
+
]
|
|
83
|
+
// Layer 1 = Timepoint (column 1), layer 2 = Tissue (column 0); Rep is off.
|
|
84
|
+
expect(engineFieldsToCards(columns, [1, 0]).layers).toEqual([2, 1, null])
|
|
75
85
|
})
|
|
76
86
|
})
|
|
77
87
|
|
|
@@ -5,6 +5,7 @@ import { PLUGIN_ICON_FALLBACK_PATH } from '../../utils/pluginIcon'
|
|
|
5
5
|
|
|
6
6
|
const SAMPLE_PATH = 'M13 10V3L4 14h7v7l9-11h-7z'
|
|
7
7
|
const SAMPLE_HTTPS_URL = 'https://example.com/icon.png'
|
|
8
|
+
const SAMPLE_STATIC_SVG_URL = '/api/plugins/LEAF/assets/icon.svg'
|
|
8
9
|
const SAMPLE_STRUCTURED_ICON = JSON.stringify({
|
|
9
10
|
type: 'mint-icon',
|
|
10
11
|
viewBox: '0 0 24 24',
|
|
@@ -138,6 +139,18 @@ describe('PluginIcon', () => {
|
|
|
138
139
|
expect(img.attributes('loading')).toBe('lazy')
|
|
139
140
|
})
|
|
140
141
|
|
|
142
|
+
it('should render same-origin SVG assets as images', () => {
|
|
143
|
+
const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_STATIC_SVG_URL } })
|
|
144
|
+
|
|
145
|
+
expect(wrapper.find('img').attributes('src')).toBe(SAMPLE_STATIC_SVG_URL)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should reject protocol-relative SVG assets', () => {
|
|
149
|
+
const wrapper = mount(PluginIcon, { props: { icon: '//example.com/icon.svg' } })
|
|
150
|
+
|
|
151
|
+
expect(wrapper.find('img').exists()).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
|
|
141
154
|
it('should reject http URLs as fallback (mixed-content)', () => {
|
|
142
155
|
const wrapper = mount(PluginIcon, { props: { icon: 'http://example.com/icon.png' } })
|
|
143
156
|
expect(wrapper.find('img').exists()).toBe(false)
|
|
@@ -163,6 +176,12 @@ describe('PluginIcon', () => {
|
|
|
163
176
|
expect(wrapper.classes()).toContain('mint-plugin-icon--lg')
|
|
164
177
|
})
|
|
165
178
|
|
|
179
|
+
it('should apply the dedicated topbar size class', () => {
|
|
180
|
+
const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH, size: 'topbar' } })
|
|
181
|
+
|
|
182
|
+
expect(wrapper.classes()).toContain('mint-plugin-icon--topbar')
|
|
183
|
+
})
|
|
184
|
+
|
|
166
185
|
it('should default size to md', () => {
|
|
167
186
|
const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_PATH } })
|
|
168
187
|
expect(wrapper.classes()).toContain('mint-plugin-icon--md')
|
|
@@ -140,6 +140,14 @@ function mountIntegratedPageSelectorWorkspace(
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
describe('PluginWorkspaceView', () => {
|
|
143
|
+
it('should not forward the retired profile-button prop', () => {
|
|
144
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
145
|
+
global: globalOptions,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(wrapper.props()).not.toHaveProperty('showProfile')
|
|
149
|
+
})
|
|
150
|
+
|
|
143
151
|
it('renders the standard AppLayout, AppTopBar, and AppSidebar plugin shell', () => {
|
|
144
152
|
const wrapper = mount(PluginWorkspaceView, {
|
|
145
153
|
props: {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const retiredSelectors: Record<string, readonly string[]> = {
|
|
6
|
+
'app-plugin-switcher.css': [
|
|
7
|
+
'mint-plugin-switcher__swatch',
|
|
8
|
+
],
|
|
9
|
+
'dose-calculator.css': [
|
|
10
|
+
'mint-dose-calculator__mw-input',
|
|
11
|
+
],
|
|
12
|
+
'experiment-popover.css': [
|
|
13
|
+
'mint-experiment-popover__check-icon',
|
|
14
|
+
'mint-experiment-popover__divider',
|
|
15
|
+
'mint-experiment-popover__footer',
|
|
16
|
+
'mint-experiment-popover__save-btn',
|
|
17
|
+
'mint-experiment-popover__save-btn--loading',
|
|
18
|
+
'mint-experiment-popover__save-btn--success',
|
|
19
|
+
'mint-experiment-popover__save-hint',
|
|
20
|
+
'mint-experiment-popover__save-icon',
|
|
21
|
+
'mint-experiment-popover__spinner',
|
|
22
|
+
],
|
|
23
|
+
'experiment-selector-modal.css': [
|
|
24
|
+
'mint-experiment-selector__filters',
|
|
25
|
+
'mint-experiment-selector__status-filter',
|
|
26
|
+
],
|
|
27
|
+
'protocol-step-editor.css': [
|
|
28
|
+
'mint-protocol-editor__field-row',
|
|
29
|
+
],
|
|
30
|
+
'reagent-editor.css': [
|
|
31
|
+
'mint-reagent-editor__row',
|
|
32
|
+
],
|
|
33
|
+
'sample-hierarchy-tree.css': [
|
|
34
|
+
'mint-sample-tree__node--selected',
|
|
35
|
+
],
|
|
36
|
+
'sample-selector.css': [
|
|
37
|
+
'mint-popover-enter-active',
|
|
38
|
+
'mint-popover-enter-from',
|
|
39
|
+
'mint-popover-leave-active',
|
|
40
|
+
'mint-popover-leave-to',
|
|
41
|
+
'mint-sample-selector__level-badge',
|
|
42
|
+
'mint-sample-selector__level-btn',
|
|
43
|
+
'mint-sample-selector__level-desc',
|
|
44
|
+
'mint-sample-selector__level-example',
|
|
45
|
+
'mint-sample-selector__level-info',
|
|
46
|
+
'mint-sample-selector__level-title',
|
|
47
|
+
'mint-sample-selector__popover',
|
|
48
|
+
'mint-sample-selector__popover-body',
|
|
49
|
+
'mint-sample-selector__popover-header',
|
|
50
|
+
],
|
|
51
|
+
'schedule-calendar.css': [
|
|
52
|
+
'mint-schedule__grid-line',
|
|
53
|
+
],
|
|
54
|
+
'well-plate.css': [
|
|
55
|
+
'mint-well-plate__well--blank',
|
|
56
|
+
'mint-well-plate__well--control',
|
|
57
|
+
'mint-well-plate__well--iqc',
|
|
58
|
+
'mint-well-plate__well--qc',
|
|
59
|
+
'mint-well-plate__well--readonly',
|
|
60
|
+
'mint-well-plate__well--sample',
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const cases = Object.entries(retiredSelectors).flatMap(([file, selectors]) =>
|
|
65
|
+
selectors.map(selector => ({ file, selector })),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
function classSelectorPattern(selector: string): RegExp {
|
|
69
|
+
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
70
|
+
return new RegExp(`\\.${escaped}(?![A-Za-z0-9_-])`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('retired component styles', () => {
|
|
74
|
+
it.each(cases)('should omit $selector from $file', ({ file, selector }) => {
|
|
75
|
+
const styles = readFileSync(
|
|
76
|
+
resolve(process.cwd(), 'src/styles/components', file),
|
|
77
|
+
'utf8',
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(styles).not.toMatch(classSelectorPattern(selector))
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -97,4 +97,30 @@ describe('SampleSelector drag helpers', () => {
|
|
|
97
97
|
expect(drag.draggingGroup.value).toBeNull()
|
|
98
98
|
expect(drag.reorderTarget.value).toBeNull()
|
|
99
99
|
})
|
|
100
|
+
|
|
101
|
+
it('rejects leaf reordering across different immediate parents', () => {
|
|
102
|
+
const state = ref<SampleGroup[]>([
|
|
103
|
+
{ name: 'Liver / High / 5min', color: '#111111', samples: ['L1'] },
|
|
104
|
+
{ name: 'Liver / Low / 10min', color: '#222222', samples: ['L2'] },
|
|
105
|
+
])
|
|
106
|
+
const drag = useSampleSelectorDrag(state)
|
|
107
|
+
|
|
108
|
+
drag.handleGroupDragStart('Liver / High / 5min', 'sub', createDragEvent())
|
|
109
|
+
drag.handleGroupDragOver('Liver / Low / 10min', 'sub', createDragEvent({ clientY: 2 }))
|
|
110
|
+
|
|
111
|
+
expect(drag.reorderTarget.value).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('accepts leaf reordering within the same immediate parent', () => {
|
|
115
|
+
const state = ref<SampleGroup[]>([
|
|
116
|
+
{ name: 'Liver / High / 5min', color: '#111111', samples: ['L1'] },
|
|
117
|
+
{ name: 'Liver / High / 10min', color: '#222222', samples: ['L2'] },
|
|
118
|
+
])
|
|
119
|
+
const drag = useSampleSelectorDrag(state)
|
|
120
|
+
|
|
121
|
+
drag.handleGroupDragStart('Liver / High / 5min', 'sub', createDragEvent())
|
|
122
|
+
drag.handleGroupDragOver('Liver / High / 10min', 'sub', createDragEvent({ clientY: 2 }))
|
|
123
|
+
|
|
124
|
+
expect(drag.reorderTarget.value).toBe('Liver / High / 10min')
|
|
125
|
+
})
|
|
100
126
|
})
|
|
@@ -5,8 +5,7 @@ import {
|
|
|
5
5
|
getSampleGroupMajorPrefix,
|
|
6
6
|
moveSampleToGroup,
|
|
7
7
|
removeSampleFromGroup,
|
|
8
|
-
|
|
9
|
-
removeSampleMajorGroup,
|
|
8
|
+
removeSampleGroups,
|
|
10
9
|
reorderSampleGroup,
|
|
11
10
|
reorderSampleMajorGroup,
|
|
12
11
|
} from '../../components/SampleSelector.groups'
|
|
@@ -29,6 +28,10 @@ describe('SampleSelector group helpers', () => {
|
|
|
29
28
|
expect(getSampleGroupMajorPrefix('Ungrouped', '/')).toBe('Ungrouped')
|
|
30
29
|
})
|
|
31
30
|
|
|
31
|
+
it('normalizes whitespace around auto-group path separators', () => {
|
|
32
|
+
expect(getSampleGroupMajorPrefix('Liver / High / 5min', '/')).toBe('Liver')
|
|
33
|
+
})
|
|
34
|
+
|
|
32
35
|
it('moves a sample from one group to another without duplicating target samples', () => {
|
|
33
36
|
const moved = moveSampleToGroup(groups, 'S1', 'Treatment/A', 'Control/A')
|
|
34
37
|
|
|
@@ -37,15 +40,14 @@ describe('SampleSelector group helpers', () => {
|
|
|
37
40
|
})
|
|
38
41
|
|
|
39
42
|
it('removes groups and samples without mutating the source array', () => {
|
|
40
|
-
expect(
|
|
43
|
+
expect(removeSampleGroups(groups, ['Treatment/B']).map(group => group.name)).toEqual([
|
|
41
44
|
'Treatment/A',
|
|
42
45
|
'Control/A',
|
|
43
46
|
'Control/B',
|
|
44
47
|
])
|
|
48
|
+
// A whole family or any intermediate level removes as a batch of leaf names.
|
|
45
49
|
expect(
|
|
46
|
-
|
|
47
|
-
subGroups: [{ name: 'Treatment/A' }, { name: 'Treatment/B' }],
|
|
48
|
-
}).map(group => group.name),
|
|
50
|
+
removeSampleGroups(groups, ['Treatment/A', 'Treatment/B']).map(group => group.name),
|
|
49
51
|
).toEqual(['Control/A', 'Control/B'])
|
|
50
52
|
expect(removeSampleFromGroup(groups, 'S1', 'Treatment/A')[0].samples).toEqual([])
|
|
51
53
|
expect(groups[0].samples).toEqual(['S1'])
|
|
@@ -78,4 +80,15 @@ describe('SampleSelector group helpers', () => {
|
|
|
78
80
|
'Treatment/B',
|
|
79
81
|
])
|
|
80
82
|
})
|
|
83
|
+
|
|
84
|
+
it('reorders auto-group families whose separators include spaces', () => {
|
|
85
|
+
const autoGroups: SampleGroup[] = [
|
|
86
|
+
{ name: 'Liver / High / 5min', color: '#111111', samples: ['L1'] },
|
|
87
|
+
{ name: 'Kidney / High / 5min', color: '#222222', samples: ['K1'] },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
expect(
|
|
91
|
+
reorderSampleMajorGroup(autoGroups, 'Liver', 'Kidney', 'after').map(group => group.name),
|
|
92
|
+
).toEqual(['Kidney / High / 5min', 'Liver / High / 5min'])
|
|
93
|
+
})
|
|
81
94
|
})
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import type { SampleGroup } from '../../types'
|
|
4
|
-
import type { SampleMajorGroup } from '../../composables/useSampleGroups'
|
|
5
3
|
import { useSampleSelectorSelection } from '../../components/SampleSelector.selection'
|
|
6
4
|
|
|
7
|
-
const groups: SampleGroup[] = [
|
|
8
|
-
{ name: 'Control/A', color: '#111111', samples: ['S1', 'S2'] },
|
|
9
|
-
{ name: 'Treatment/A', color: '#222222', samples: ['S3'] },
|
|
10
|
-
]
|
|
11
|
-
|
|
12
5
|
function createSelection(selected: string[] = []) {
|
|
13
6
|
const selectedRef = ref(selected)
|
|
14
7
|
const emitted: string[][] = []
|
|
15
8
|
const selection = useSampleSelectorSelection({
|
|
16
9
|
selected: () => selectedRef.value,
|
|
17
10
|
samples: () => ['S1', 'S2', 'S3'],
|
|
18
|
-
findGroup: groupName => groups.find(group => group.name === groupName),
|
|
19
11
|
emitSelected: samples => {
|
|
20
12
|
emitted.push(samples)
|
|
21
13
|
selectedRef.value = samples
|
|
@@ -39,32 +31,26 @@ describe('SampleSelector selection helpers', () => {
|
|
|
39
31
|
expect(selection.isAllSelected.value).toBe(false)
|
|
40
32
|
})
|
|
41
33
|
|
|
42
|
-
it('toggles sample
|
|
34
|
+
it('toggles an arbitrary sample set and exposes its selection state', () => {
|
|
43
35
|
const { emitted, selection } = createSelection(['S1'])
|
|
44
36
|
|
|
45
|
-
expect(selection.
|
|
46
|
-
expect(selection.
|
|
37
|
+
expect(selection.isPartiallySelected(['S1', 'S2'])).toBe(true)
|
|
38
|
+
expect(selection.isFullySelected(['S1', 'S2'])).toBe(false)
|
|
47
39
|
|
|
48
|
-
selection.
|
|
40
|
+
selection.toggleSamplesSelection(['S1', 'S2'])
|
|
49
41
|
|
|
50
42
|
expect(emitted[0]).toEqual(['S1', 'S2'])
|
|
51
|
-
expect(selection.
|
|
43
|
+
expect(selection.isFullySelected(['S1', 'S2'])).toBe(true)
|
|
52
44
|
})
|
|
53
45
|
|
|
54
|
-
it('
|
|
55
|
-
const majorGroup: SampleMajorGroup = {
|
|
56
|
-
name: 'Control',
|
|
57
|
-
color: '#111111',
|
|
58
|
-
subGroups: [],
|
|
59
|
-
allSamples: ['S1', 'S2'],
|
|
60
|
-
}
|
|
46
|
+
it('clears a fully selected set, so a hierarchy node can deselect its subtree', () => {
|
|
61
47
|
const { emitted, selection } = createSelection(['S1', 'S2'])
|
|
62
48
|
|
|
63
|
-
expect(selection.
|
|
49
|
+
expect(selection.isFullySelected(['S1', 'S2'])).toBe(true)
|
|
64
50
|
|
|
65
|
-
selection.
|
|
51
|
+
selection.toggleSamplesSelection(['S1', 'S2'])
|
|
66
52
|
|
|
67
53
|
expect(emitted[0]).toEqual([])
|
|
68
|
-
expect(selection.
|
|
54
|
+
expect(selection.isPartiallySelected(['S1', 'S2'])).toBe(false)
|
|
69
55
|
})
|
|
70
56
|
})
|
|
@@ -52,6 +52,11 @@ describe('computeFieldGroups', () => {
|
|
|
52
52
|
const groups = computeFieldGroups(SAMPLES, FIELDS, [true, true, false], PALETTE)
|
|
53
53
|
expect(groups.map(g => g.color)).toEqual(['c1', 'c2', 'c3'])
|
|
54
54
|
})
|
|
55
|
+
|
|
56
|
+
it('should join field values following an explicit layer order', () => {
|
|
57
|
+
const groups = computeFieldGroups(SAMPLES, FIELDS, [true, true, false], PALETTE, [1, 0])
|
|
58
|
+
expect(groups.map(g => g.name)).toEqual(['Vehicle_Control', 'Low_Treatment', 'High_Treatment'])
|
|
59
|
+
})
|
|
55
60
|
})
|
|
56
61
|
|
|
57
62
|
describe('appendQcGroup', () => {
|
|
@@ -42,15 +42,14 @@ describe('composeGroups', () => {
|
|
|
42
42
|
},
|
|
43
43
|
],
|
|
44
44
|
})
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// collide when they share a factor value.
|
|
45
|
+
// A single experimental class needs no disambiguating prefix: group names
|
|
46
|
+
// start directly at the first enabled factor layer.
|
|
48
47
|
expect(result.experimentalGroups!.map(g => g.name).sort()).toEqual([
|
|
49
|
-
'
|
|
50
|
-
'
|
|
48
|
+
'Kidney',
|
|
49
|
+
'Liver',
|
|
51
50
|
])
|
|
52
51
|
expect(
|
|
53
|
-
result.experimentalGroups!.find(g => g.name === '
|
|
52
|
+
result.experimentalGroups!.find(g => g.name === 'Kidney')!.samples,
|
|
54
53
|
).toEqual(['s1', 's2'])
|
|
55
54
|
})
|
|
56
55
|
|
|
@@ -184,8 +183,8 @@ describe('composeGroups', () => {
|
|
|
184
183
|
],
|
|
185
184
|
})
|
|
186
185
|
expect(result.experimentalGroups!.length).toBe(1)
|
|
187
|
-
//
|
|
188
|
-
expect(result.experimentalGroups![0].name).toBe('
|
|
186
|
+
// Single experimental class → unprefixed post-alias factor value.
|
|
187
|
+
expect(result.experimentalGroups![0].name).toBe('Drug-A')
|
|
189
188
|
expect(result.experimentalGroups![0].samples).toEqual(['s1', 's2'])
|
|
190
189
|
expect(result.excludedSamples).toEqual(['s3'])
|
|
191
190
|
})
|
|
@@ -221,7 +220,7 @@ describe('composeGroups', () => {
|
|
|
221
220
|
],
|
|
222
221
|
})
|
|
223
222
|
expect(result.metadata[0].fields.Tissue).toBe('kidney_panel')
|
|
224
|
-
//
|
|
225
|
-
expect(result.metadata[0].group).toBe('
|
|
223
|
+
// Single experimental class → unprefixed post-alias value as the group label.
|
|
224
|
+
expect(result.metadata[0].group).toBe('kidney_panel')
|
|
226
225
|
})
|
|
227
226
|
})
|