@morscherlab/mint-sdk 1.1.7 → 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.
Files changed (90) hide show
  1. package/dist/__tests__/components/RetiredStyles.test.d.ts +1 -0
  2. package/dist/__tests__/composables/autoGroup/groupByOrder.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useSampleGroups.tree.test.d.ts +1 -0
  4. package/dist/components/AppTopBar.vue.d.ts +6 -11
  5. package/dist/components/AutoGroupModal.adapter.d.ts +10 -6
  6. package/dist/components/PluginIcon.vue.d.ts +2 -2
  7. package/dist/components/PluginWorkspaceView.props.d.ts +5 -7
  8. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -3
  9. package/dist/components/SampleSelector.groups.d.ts +7 -7
  10. package/dist/components/SampleSelector.selection.d.ts +6 -12
  11. package/dist/components/SampleSelector.tree.d.ts +39 -0
  12. package/dist/components/SampleSelectorGroupNode.vue.d.ts +9 -0
  13. package/dist/components/SmartGroup.types.d.ts +5 -0
  14. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +3 -2
  15. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -0
  16. package/dist/components/index.js +1 -1
  17. package/dist/{components-CDomhGSj.js → components-BbtUpCTv.js} +913 -943
  18. package/dist/components-BbtUpCTv.js.map +1 -0
  19. package/dist/composables/index.d.ts +1 -1
  20. package/dist/composables/index.js +1 -1
  21. package/dist/composables/useAutoGroup.d.ts +3 -0
  22. package/dist/composables/usePlatformContext.d.ts +3 -0
  23. package/dist/composables/useSampleGroups.d.ts +25 -0
  24. package/dist/index.js +2 -2
  25. package/dist/install.js +1 -1
  26. package/dist/styles.css +130 -557
  27. package/dist/types/platform.d.ts +1 -0
  28. package/dist/{useProtocolTemplates-BMt0LlXf.js → useProtocolTemplates-BWfNaynA.js} +199 -24
  29. package/dist/useProtocolTemplates-BWfNaynA.js.map +1 -0
  30. package/dist/utils/pluginIcon.d.ts +1 -1
  31. package/dist/utils/sampleGroupPath.d.ts +6 -0
  32. package/package.json +1 -1
  33. package/src/__tests__/components/AppAvatarMenu.test.ts +11 -0
  34. package/src/__tests__/components/AppTopBar.test.ts +84 -10
  35. package/src/__tests__/components/AppTopBarPageSelector.test.ts +1 -1
  36. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +16 -6
  37. package/src/__tests__/components/PluginIcon.test.ts +19 -0
  38. package/src/__tests__/components/PluginWorkspaceView.test.ts +8 -0
  39. package/src/__tests__/components/RetiredStyles.test.ts +82 -0
  40. package/src/__tests__/components/SampleSelector.drag.test.ts +26 -0
  41. package/src/__tests__/components/SampleSelector.groups.test.ts +19 -6
  42. package/src/__tests__/components/SampleSelector.selection.test.ts +9 -23
  43. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +5 -0
  44. package/src/__tests__/composables/autoGroup/compose.test.ts +9 -10
  45. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +249 -0
  46. package/src/__tests__/composables/autoGroup/integration.test.ts +3 -7
  47. package/src/__tests__/composables/useSampleGroups.tree.test.ts +145 -0
  48. package/src/components/AppTopBar.story.vue +9 -7
  49. package/src/components/AppTopBar.vue +79 -72
  50. package/src/components/AutoGroupModal.adapter.ts +17 -12
  51. package/src/components/AutoGroupModal.story.vue +89 -0
  52. package/src/components/AutoGroupModal.vue +16 -8
  53. package/src/components/PluginIcon.story.vue +11 -9
  54. package/src/components/PluginIcon.vue +3 -3
  55. package/src/components/PluginWorkspaceView.props.ts +5 -7
  56. package/src/components/PluginWorkspaceView.vue +0 -4
  57. package/src/components/SampleSelector.drag.ts +5 -5
  58. package/src/components/SampleSelector.groups.ts +15 -15
  59. package/src/components/SampleSelector.selection.ts +7 -46
  60. package/src/components/SampleSelector.story.vue +37 -0
  61. package/src/components/SampleSelector.tree.ts +48 -0
  62. package/src/components/SampleSelector.vue +52 -320
  63. package/src/components/SampleSelectorGroupNode.vue +197 -0
  64. package/src/components/SmartGroup.types.ts +5 -0
  65. package/src/components/SmartGroupFieldRecipe.groups.ts +6 -2
  66. package/src/components/SmartGroupFieldRecipe.vue +76 -4
  67. package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -2
  68. package/src/composables/autoGroup/compose.ts +31 -10
  69. package/src/composables/index.ts +1 -0
  70. package/src/composables/useAutoGroup.ts +108 -5
  71. package/src/composables/useSampleGroups.ts +118 -33
  72. package/src/styles/components/app-avatar-menu.css +2 -1
  73. package/src/styles/components/app-plugin-switcher.css +0 -13
  74. package/src/styles/components/app-top-bar.css +13 -54
  75. package/src/styles/components/dose-calculator.css +0 -4
  76. package/src/styles/components/experiment-popover.css +2 -104
  77. package/src/styles/components/experiment-selector-modal.css +0 -7
  78. package/src/styles/components/plugin-icon.css +3 -0
  79. package/src/styles/components/protocol-step-editor.css +0 -6
  80. package/src/styles/components/reagent-editor.css +0 -5
  81. package/src/styles/components/sample-hierarchy-tree.css +0 -4
  82. package/src/styles/components/sample-selector.css +0 -96
  83. package/src/styles/components/schedule-calendar.css +0 -6
  84. package/src/styles/components/smart-group.css +22 -0
  85. package/src/styles/components/well-plate.css +1 -27
  86. package/src/types/platform.ts +1 -0
  87. package/src/utils/pluginIcon.ts +5 -2
  88. package/src/utils/sampleGroupPath.ts +19 -0
  89. package/dist/components-CDomhGSj.js.map +0 -1
  90. package/dist/useProtocolTemplates-BMt0LlXf.js.map +0 -1
@@ -1,4 +1,4 @@
1
- export type PluginIconFormat = 'path' | 'data-url' | 'https-url' | 'structured' | 'fallback';
1
+ export type PluginIconFormat = 'path' | 'data-url' | 'asset-url' | 'structured' | 'fallback';
2
2
  export interface StructuredPluginIconStop {
3
3
  offset: string;
4
4
  color: string;
@@ -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.7",
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: 'Classic App',
721
- subtitle: 'Old API',
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') === 'tinted')).toBe(true)
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('md')
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 not render PluginIcon when #icon slot is provided', () => {
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
- expect(wrapper.findComponent({ name: 'PluginIcon' }).exists()).toBe(false)
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 not render PluginIcon when icon is empty string', () => {
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(false)
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') === 'tinted')).toBe(true)
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
- candidateColumnIndices,
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('candidateColumnIndices', () => {
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(candidateColumnIndices(columns, new Set())).toEqual([0, 2])
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(candidateColumnIndices(columns, new Set([1]))).toEqual([0, 1])
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, new Set([0]))
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, new Set()).fields[0].key).toBe('Dose')
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
- removeSampleGroup,
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(removeSampleGroup(groups, 'Treatment/B').map(group => group.name)).toEqual([
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
- removeSampleMajorGroup(groups, {
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 groups and exposes group selection state', () => {
34
+ it('toggles an arbitrary sample set and exposes its selection state', () => {
43
35
  const { emitted, selection } = createSelection(['S1'])
44
36
 
45
- expect(selection.isGroupPartiallySelected('Control/A')).toBe(true)
46
- expect(selection.isGroupFullySelected('Control/A')).toBe(false)
37
+ expect(selection.isPartiallySelected(['S1', 'S2'])).toBe(true)
38
+ expect(selection.isFullySelected(['S1', 'S2'])).toBe(false)
47
39
 
48
- selection.toggleGroupSamples('Control/A')
40
+ selection.toggleSamplesSelection(['S1', 'S2'])
49
41
 
50
42
  expect(emitted[0]).toEqual(['S1', 'S2'])
51
- expect(selection.isGroupFullySelected('Control/A')).toBe(true)
43
+ expect(selection.isFullySelected(['S1', 'S2'])).toBe(true)
52
44
  })
53
45
 
54
- it('handles major groups through their aggregate sample list', () => {
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.isMajorGroupFullySelected(majorGroup)).toBe(true)
49
+ expect(selection.isFullySelected(['S1', 'S2'])).toBe(true)
64
50
 
65
- selection.toggleMajorGroupSamples(majorGroup)
51
+ selection.toggleSamplesSelection(['S1', 'S2'])
66
52
 
67
53
  expect(emitted[0]).toEqual([])
68
- expect(selection.isMajorGroupPartiallySelected(majorGroup)).toBe(false)
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
- // Group names carry class context: "Biological / Tissues / <factor>".
46
- // The class label is prepended so labels from different classes don't
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
- 'Biological / Tissues / Kidney',
50
- 'Biological / Tissues / Liver',
48
+ 'Kidney',
49
+ 'Liver',
51
50
  ])
52
51
  expect(
53
- result.experimentalGroups!.find(g => g.name === 'Biological / Tissues / Kidney')!.samples,
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
- // Class-prefixed group name (cls.label === 'Biological' "Biological / Drug-A").
188
- expect(result.experimentalGroups![0].name).toBe('Biological / Drug-A')
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
- // Group label is class-prefixed: cls.label ("Biological") + post-alias value.
225
- expect(result.metadata[0].group).toBe('Biological / kidney_panel')
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
  })