@morscherlab/mint-sdk 1.1.0-beta.2 → 1.1.0-beta.20

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 (89) hide show
  1. package/README.md +18 -4
  2. package/dist/{EmptyState-D1c4RdC7.js → EmptyState-DgBRoYAy.js} +22 -13
  3. package/dist/EmptyState-DgBRoYAy.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-B7w6k3Y8.js → ExperimentSelectorModal-BBWXC7KW.js} +3 -3
  5. package/dist/{ExperimentSelectorModal-B7w6k3Y8.js.map → ExperimentSelectorModal-BBWXC7KW.js.map} +1 -1
  6. package/dist/{SettingsModal-DPDwCbXK.js → SettingsModal-Dpg-X1OM.js} +3 -3
  7. package/dist/{SettingsModal-DPDwCbXK.js.map → SettingsModal-Dpg-X1OM.js.map} +1 -1
  8. package/dist/__tests__/components/BaseToggle.test.d.ts +1 -0
  9. package/dist/__tests__/components/SectionCard.test.d.ts +1 -0
  10. package/dist/__tests__/composables/analysisArtifactTarget.test.d.ts +1 -0
  11. package/dist/__tests__/types/GeneratedJsonSchema.test.d.ts +1 -0
  12. package/dist/analysisArtifactTypes-ejqR8MX3.js.map +1 -1
  13. package/dist/components/BaseSelect.vue.d.ts +1 -1
  14. package/dist/components/BaseToggle.vue.d.ts +2 -0
  15. package/dist/components/FitPanel.vue.d.ts +1 -1
  16. package/dist/components/PluginWorkspaceView.props.d.ts +1 -1
  17. package/dist/components/SectionCard.vue.d.ts +47 -0
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.js +6 -6
  20. package/dist/{components-UfkPN1bK.js → components-Ci_BTx00.js} +711 -633
  21. package/dist/components-Ci_BTx00.js.map +1 -0
  22. package/dist/composables/analysisArtifactTarget.d.ts +5 -0
  23. package/dist/composables/index.d.ts +3 -1
  24. package/dist/composables/index.js +8 -8
  25. package/dist/composables/platformContextHelpers.d.ts +2 -0
  26. package/dist/composables/useAnalysisArtifacts.d.ts +3 -1
  27. package/dist/composables/useGeneratedAnalysis.d.ts +1 -1
  28. package/dist/composables/usePluginClient.d.ts +11 -5
  29. package/dist/{composables-B512bWJP.js → composables-C0VEJ73O.js} +40 -5
  30. package/dist/composables-C0VEJ73O.js.map +1 -0
  31. package/dist/index.js +11 -11
  32. package/dist/install.js +5 -5
  33. package/dist/styles.css +314 -17
  34. package/dist/tailwind.preset.d.ts +5 -0
  35. package/dist/tailwind.preset.js +7 -2
  36. package/dist/tailwind.preset.js.map +1 -1
  37. package/dist/templates/index.js +3 -3
  38. package/dist/{templates-Cttbjbyc.js → templates-Cdj93S9C.js} +2 -2
  39. package/dist/{templates-Cttbjbyc.js.map → templates-Cdj93S9C.js.map} +1 -1
  40. package/dist/types/analysisArtifactTypes.d.ts +8 -0
  41. package/dist/types/components.d.ts +2 -0
  42. package/dist/types/generated-ui.d.ts +13 -0
  43. package/dist/types/index.d.ts +2 -2
  44. package/dist/{useControlSchema-Dallso-L.js → useControlSchema-B_qgU4rW.js} +48 -19
  45. package/dist/useControlSchema-B_qgU4rW.js.map +1 -0
  46. package/dist/{useExperimentSelector-BMjzNcf2.js → useExperimentSelector-BuQZRgMt.js} +2 -2
  47. package/dist/{useExperimentSelector-BMjzNcf2.js.map → useExperimentSelector-BuQZRgMt.js.map} +1 -1
  48. package/dist/{useFormBuilder-BXaaDI41.js → useFormBuilder-D1G5J5Z5.js} +2 -2
  49. package/dist/{useFormBuilder-BXaaDI41.js.map → useFormBuilder-D1G5J5Z5.js.map} +1 -1
  50. package/dist/{useProtocolTemplates-BP7_4RGQ.js → useProtocolTemplates-DeaOmMaw.js} +4 -4
  51. package/dist/{useProtocolTemplates-BP7_4RGQ.js.map → useProtocolTemplates-DeaOmMaw.js.map} +1 -1
  52. package/package.json +1 -1
  53. package/src/__tests__/components/BaseSelect.test.ts +52 -0
  54. package/src/__tests__/components/BaseToggle.test.ts +40 -0
  55. package/src/__tests__/components/GeneratedPluginView.test.ts +10 -0
  56. package/src/__tests__/components/PluginWorkspaceView.test.ts +68 -14
  57. package/src/__tests__/components/SectionCard.test.ts +181 -0
  58. package/src/__tests__/composables/analysisArtifactTarget.test.ts +77 -0
  59. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +74 -1
  60. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +2 -0
  61. package/src/__tests__/composables/usePluginClient.test.ts +36 -1
  62. package/src/__tests__/types/GeneratedJsonSchema.test.ts +62 -0
  63. package/src/components/BaseSelect.vue +16 -2
  64. package/src/components/BaseToggle.vue +3 -1
  65. package/src/components/GeneratedPluginView.vue +1 -1
  66. package/src/components/PluginWorkspaceView.props.ts +1 -1
  67. package/src/components/PluginWorkspaceView.vue +1 -5
  68. package/src/components/SectionCard.story.vue +163 -0
  69. package/src/components/SectionCard.vue +98 -0
  70. package/src/components/index.ts +1 -0
  71. package/src/composables/analysisArtifactTarget.ts +53 -0
  72. package/src/composables/index.ts +6 -0
  73. package/src/composables/platformContextHelpers.ts +10 -3
  74. package/src/composables/useAnalysisArtifacts.ts +48 -0
  75. package/src/composables/useGeneratedAnalysis.ts +30 -12
  76. package/src/composables/usePluginClient.ts +12 -5
  77. package/src/styles/components/button.css +1 -3
  78. package/src/styles/components/section-card.css +161 -0
  79. package/src/styles/index.css +1 -0
  80. package/src/styles/variables.css +25 -0
  81. package/src/tailwind.preset.ts +5 -0
  82. package/src/types/analysisArtifactTypes.ts +9 -0
  83. package/src/types/components.ts +12 -0
  84. package/src/types/generated-ui.ts +13 -0
  85. package/src/types/index.ts +3 -0
  86. package/dist/EmptyState-D1c4RdC7.js.map +0 -1
  87. package/dist/components-UfkPN1bK.js.map +0 -1
  88. package/dist/composables-B512bWJP.js.map +0 -1
  89. package/dist/useControlSchema-Dallso-L.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.1.0-beta.2",
3
+ "version": "1.1.0-beta.20",
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",
@@ -3,6 +3,58 @@ import { describe, expect, it } from 'vitest'
3
3
  import BaseSelect from '../../components/BaseSelect.vue'
4
4
 
5
5
  describe('BaseSelect', () => {
6
+ it('forwards native attributes to the select instead of the wrapper', () => {
7
+ const wrapper = mount(BaseSelect, {
8
+ props: {
9
+ options: ['linear'],
10
+ },
11
+ attrs: {
12
+ id: 'curve-model',
13
+ name: 'curve-model',
14
+ 'aria-label': 'Curve model',
15
+ 'data-testid': 'curve-model-select',
16
+ class: 'curve-model-layout',
17
+ style: 'width: 12rem;',
18
+ },
19
+ })
20
+
21
+ const select = wrapper.get('select')
22
+
23
+ expect({
24
+ select: {
25
+ id: select.attributes('id'),
26
+ name: select.attributes('name'),
27
+ ariaLabel: select.attributes('aria-label'),
28
+ testId: select.attributes('data-testid'),
29
+ layoutClass: select.classes().includes('curve-model-layout'),
30
+ },
31
+ wrapper: {
32
+ id: wrapper.attributes('id'),
33
+ name: wrapper.attributes('name'),
34
+ ariaLabel: wrapper.attributes('aria-label'),
35
+ testId: wrapper.attributes('data-testid'),
36
+ layoutClass: wrapper.classes().includes('curve-model-layout'),
37
+ style: wrapper.attributes('style'),
38
+ },
39
+ }).toEqual({
40
+ select: {
41
+ id: 'curve-model',
42
+ name: 'curve-model',
43
+ ariaLabel: 'Curve model',
44
+ testId: 'curve-model-select',
45
+ layoutClass: false,
46
+ },
47
+ wrapper: {
48
+ id: undefined,
49
+ name: undefined,
50
+ ariaLabel: undefined,
51
+ testId: undefined,
52
+ layoutClass: true,
53
+ style: 'width: 12rem;',
54
+ },
55
+ })
56
+ })
57
+
6
58
  it('accepts string and number shorthand options', async () => {
7
59
  const wrapper = mount(BaseSelect, {
8
60
  props: {
@@ -0,0 +1,40 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import BaseToggle from '../../components/BaseToggle.vue'
4
+
5
+ describe('BaseToggle', () => {
6
+ it('uses ariaLabel for the switch name without changing the visual label', () => {
7
+ const wrapper = mount(BaseToggle, {
8
+ props: {
9
+ label: 'Enabled',
10
+ ariaLabel: 'Email notifications',
11
+ },
12
+ })
13
+
14
+ expect({
15
+ accessibleName: wrapper.get('[role="switch"]').attributes('aria-label'),
16
+ visualText: wrapper.text(),
17
+ wrapperAriaLabel: wrapper.attributes('aria-label'),
18
+ }).toEqual({
19
+ accessibleName: 'Email notifications',
20
+ visualText: 'Enabled',
21
+ wrapperAriaLabel: undefined,
22
+ })
23
+ })
24
+
25
+ it('keeps using the visual label as the default switch name', () => {
26
+ const wrapper = mount(BaseToggle, {
27
+ props: {
28
+ label: 'Enabled',
29
+ },
30
+ })
31
+
32
+ expect(wrapper.get('[role="switch"]').attributes('aria-label')).toBe('Enabled')
33
+ })
34
+
35
+ it('keeps the existing fallback name when no labels are provided', () => {
36
+ const wrapper = mount(BaseToggle)
37
+
38
+ expect(wrapper.get('[role="switch"]').attributes('aria-label')).toBe('Toggle')
39
+ })
40
+ })
@@ -190,6 +190,16 @@ describe('GeneratedPluginView', () => {
190
190
  expect(wrapper.find('[aria-label="Job resource requirements"]').text()).toBe('Host service')
191
191
  })
192
192
 
193
+ it('describes process CPU declarations as an upper bound', async () => {
194
+ const wrapper = mount(GeneratedPluginView, {
195
+ props: { baseUrl: '/api/demo' },
196
+ global: { plugins: [createPinia()], stubs: { 'router-link': true, Teleport: true } },
197
+ })
198
+ await flushPromises()
199
+
200
+ expect(wrapper.find('[aria-label="Job resource requirements"]').text()).toBe('Up to 2 CPU')
201
+ })
202
+
193
203
  it('should pass the host transport to the generated analysis composable', async () => {
194
204
  const transport = vi.fn()
195
205
  mount(GeneratedPluginView, {
@@ -1,4 +1,5 @@
1
1
  import { flushPromises, mount } from '@vue/test-utils'
2
+ import type { VueWrapper } from '@vue/test-utils'
2
3
  import { createPinia } from 'pinia'
3
4
  import { computed, h, nextTick, ref } from 'vue'
4
5
  import { describe, expect, it, vi } from 'vitest'
@@ -10,7 +11,7 @@ import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vu
10
11
  import PluginWorkspaceView from '../../components/PluginWorkspaceView.vue'
11
12
  import { defineDoseDesignControlModel } from '../../composables/useControlSchema'
12
13
  import { usePlatformContext } from '../../composables/usePlatformContext'
13
- import type { PillNavItem, SidebarToolSection } from '../../types'
14
+ import type { PageSelectorItemInput, PillNavItem, SidebarToolSection } from '../../types'
14
15
 
15
16
  vi.mock('../../composables/usePlatformContext', () => ({
16
17
  usePlatformContext: vi.fn(() => ({
@@ -79,6 +80,44 @@ const globalOptions = {
79
80
  },
80
81
  }
81
82
 
83
+ interface PageSelectorWorkspaceProps {
84
+ pageSelector?: PageSelectorItemInput[]
85
+ currentPageSelectorId?: string
86
+ }
87
+
88
+ function mountIntegratedPageSelectorWorkspace(
89
+ props: PageSelectorWorkspaceProps = {},
90
+ ): VueWrapper {
91
+ vi.mocked(usePlatformContext).mockReturnValueOnce({
92
+ isIntegrated: computed(() => true),
93
+ context: ref({ isIntegrated: true, theme: 'system' }),
94
+ plugin: computed(() => ({
95
+ id: 'workspace-plugin',
96
+ name: 'Workspace Plugin',
97
+ version: '1.0.0',
98
+ route_prefix: '/workspace-plugin',
99
+ api_prefix: '/api/workspace-plugin',
100
+ nav_items: [
101
+ { path: '/platform-one', label: 'Platform One' },
102
+ { path: '/platform-two', label: 'Platform Two' },
103
+ ],
104
+ })),
105
+ user: computed(() => undefined),
106
+ theme: computed(() => 'system' as const),
107
+ features: computed(() => undefined),
108
+ navigate: vi.fn(),
109
+ notify: vi.fn(),
110
+ sendToPlatform: vi.fn(),
111
+ })
112
+ return mount(PluginWorkspaceView, {
113
+ props: {
114
+ title: 'Workspace',
115
+ ...props,
116
+ },
117
+ global: globalOptions,
118
+ })
119
+ }
120
+
82
121
  describe('PluginWorkspaceView', () => {
83
122
  it('renders the standard AppLayout, AppTopBar, and AppSidebar plugin shell', () => {
84
123
  const wrapper = mount(PluginWorkspaceView, {
@@ -146,22 +185,37 @@ describe('PluginWorkspaceView', () => {
146
185
  expect(wrapper.emitted('pill-select')).toEqual([[{ id: 'results', label: 'Results' }]])
147
186
  })
148
187
 
149
- it('hides topbar page selector until multiple pages are registered', () => {
150
- const wrapper = mount(PluginWorkspaceView, {
151
- props: {
152
- title: 'Downloader',
153
- subtitle: 'Downloads',
154
- pageSelector: [
155
- { id: 'downloads', label: 'Downloads', to: '/' },
156
- ],
157
- currentPageSelectorId: 'downloads',
158
- },
159
- global: globalOptions,
188
+ it('should use platform pages when pageSelector is omitted', () => {
189
+ const wrapper = mountIntegratedPageSelectorWorkspace()
190
+
191
+ expect(wrapper.find('.mint-page-selector').exists()).toBe(true)
192
+ })
193
+
194
+ it('should suppress platform pages when pageSelector is explicitly empty', () => {
195
+ const wrapper = mountIntegratedPageSelectorWorkspace({ pageSelector: [] })
196
+
197
+ expect(wrapper.find('.mint-page-selector').exists()).toBe(false)
198
+ })
199
+
200
+ it('should hide one explicit page without falling back to platform pages', () => {
201
+ const wrapper = mountIntegratedPageSelectorWorkspace({
202
+ pageSelector: [{ id: 'downloads', label: 'Downloads', to: '/' }],
203
+ currentPageSelectorId: 'downloads',
160
204
  })
161
205
 
162
- expect(wrapper.findComponent(AppTopBar).props('pageSelector')).toBeUndefined()
163
206
  expect(wrapper.find('.mint-page-selector').exists()).toBe(false)
164
- expect(wrapper.find('.mint-topbar-title-group').text()).toContain('Downloader')
207
+ })
208
+
209
+ it('should render multiple explicit pages ahead of platform pages', () => {
210
+ const wrapper = mountIntegratedPageSelectorWorkspace({
211
+ pageSelector: [
212
+ { id: 'overview', label: 'Overview', to: '/' },
213
+ { id: 'reports', label: 'Reports', to: '/reports' },
214
+ ],
215
+ currentPageSelectorId: 'overview',
216
+ })
217
+
218
+ expect(wrapper.get('.mint-page-selector__label').text()).toBe('Overview')
165
219
  })
166
220
 
167
221
  it('passes compact control models to the sidebar for generated forms', () => {
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import SectionCard from '../../components/SectionCard.vue'
4
+
5
+ describe('SectionCard', () => {
6
+ describe('basic rendering', () => {
7
+ it('should render container, header, and title when given the required title prop', () => {
8
+ const wrapper = mount(SectionCard, {
9
+ props: { title: 'Experiments' },
10
+ })
11
+ expect(wrapper.find('.mint-section-card').exists()).toBe(true)
12
+ expect(wrapper.find('.mint-section-card__header').exists()).toBe(true)
13
+ expect(wrapper.find('.mint-section-card__title').text()).toBe('Experiments')
14
+ })
15
+
16
+ it('should render default slot content in the body', () => {
17
+ const wrapper = mount(SectionCard, {
18
+ props: { title: 'Experiments' },
19
+ slots: { default: '<p class="body-probe">rows</p>' },
20
+ })
21
+ expect(wrapper.find('.mint-section-card__body .body-probe').exists()).toBe(true)
22
+ })
23
+ })
24
+
25
+ describe('count meta', () => {
26
+ it('should render the count in the header when provided', () => {
27
+ const wrapper = mount(SectionCard, {
28
+ props: { title: 'Plugins', count: '10 enabled' },
29
+ })
30
+ expect(wrapper.find('.mint-section-card__count').text()).toBe('10 enabled')
31
+ })
32
+
33
+ it('should not render a count element when count is omitted', () => {
34
+ const wrapper = mount(SectionCard, {
35
+ props: { title: 'Plugins' },
36
+ })
37
+ expect(wrapper.find('.mint-section-card__count').exists()).toBe(false)
38
+ })
39
+
40
+ it('should accept a numeric count', () => {
41
+ const wrapper = mount(SectionCard, {
42
+ props: { title: 'Artifacts', count: 4 },
43
+ })
44
+ expect(wrapper.find('.mint-section-card__count').text()).toBe('4')
45
+ })
46
+ })
47
+
48
+ describe('title style variants', () => {
49
+ it('should apply the uppercase modifier when uppercase is true', () => {
50
+ const wrapper = mount(SectionCard, {
51
+ props: { title: 'Experiments', uppercase: true },
52
+ })
53
+ expect(wrapper.find('.mint-section-card__title--uppercase').exists()).toBe(true)
54
+ })
55
+
56
+ it('should not apply the uppercase modifier by default', () => {
57
+ const wrapper = mount(SectionCard, {
58
+ props: { title: 'Design data' },
59
+ })
60
+ expect(wrapper.find('.mint-section-card__title--uppercase').exists()).toBe(false)
61
+ })
62
+ })
63
+
64
+ describe('icon chip', () => {
65
+ it('should render an icon chip with an SVG path when icon is provided', () => {
66
+ const wrapper = mount(SectionCard, {
67
+ props: { title: 'Experiments', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7' },
68
+ })
69
+ expect(wrapper.find('.mint-section-card__icon-chip').exists()).toBe(true)
70
+ expect(wrapper.find('.mint-section-card__icon-chip path').exists()).toBe(true)
71
+ })
72
+
73
+ it('should render one path per entry when icon is an array of paths', () => {
74
+ const wrapper = mount(SectionCard, {
75
+ props: { title: 'Experiments', icon: ['M4 7v10', 'M4 7c0 2.21'] },
76
+ })
77
+ expect(wrapper.findAll('.mint-section-card__icon-chip path')).toHaveLength(2)
78
+ })
79
+
80
+ it('should not render an icon chip when icon is omitted', () => {
81
+ const wrapper = mount(SectionCard, {
82
+ props: { title: 'Experiments' },
83
+ })
84
+ expect(wrapper.find('.mint-section-card__icon-chip').exists()).toBe(false)
85
+ })
86
+
87
+ it('should apply the accent modifier class matching the accent prop', () => {
88
+ const wrapper = mount(SectionCard, {
89
+ props: { title: 'Projects', icon: 'M3 7v10', accent: 'project' },
90
+ })
91
+ expect(wrapper.find('.mint-section-card__icon-chip--project').exists()).toBe(true)
92
+ })
93
+
94
+ it('should default to the primary accent when accent is omitted', () => {
95
+ const wrapper = mount(SectionCard, {
96
+ props: { title: 'Projects', icon: 'M3 7v10' },
97
+ })
98
+ expect(wrapper.find('.mint-section-card__icon-chip--primary').exists()).toBe(true)
99
+ })
100
+ })
101
+
102
+ describe('header divider', () => {
103
+ it('should use the strong divider by default', () => {
104
+ const wrapper = mount(SectionCard, {
105
+ props: { title: 'Experiments' },
106
+ })
107
+ expect(wrapper.find('.mint-section-card__header--divider-light').exists()).toBe(false)
108
+ })
109
+
110
+ it('should apply the light divider modifier when divider is light', () => {
111
+ const wrapper = mount(SectionCard, {
112
+ props: { title: 'Design data', divider: 'light' },
113
+ })
114
+ expect(wrapper.find('.mint-section-card__header--divider-light').exists()).toBe(true)
115
+ })
116
+ })
117
+
118
+ describe('fill mode', () => {
119
+ it('should not stretch by default', () => {
120
+ const wrapper = mount(SectionCard, {
121
+ props: { title: 'Experiments' },
122
+ })
123
+ expect(wrapper.find('.mint-section-card--fill').exists()).toBe(false)
124
+ })
125
+
126
+ it('should apply the fill modifier when fill is true', () => {
127
+ const wrapper = mount(SectionCard, {
128
+ props: { title: 'Experiments', fill: true },
129
+ })
130
+ expect(wrapper.find('.mint-section-card--fill').exists()).toBe(true)
131
+ })
132
+
133
+ it('should render the toolbar slot outside the scrolling body', () => {
134
+ const wrapper = mount(SectionCard, {
135
+ props: { title: 'Experiments', fill: true },
136
+ slots: { toolbar: '<div class="toolbar-probe">chips</div>', default: '<p>rows</p>' },
137
+ })
138
+ expect(wrapper.find('.mint-section-card__toolbar .toolbar-probe').exists()).toBe(true)
139
+ expect(wrapper.find('.mint-section-card__body .toolbar-probe').exists()).toBe(false)
140
+ })
141
+
142
+ it('should not render a toolbar region when the slot is unused', () => {
143
+ const wrapper = mount(SectionCard, {
144
+ props: { title: 'Experiments' },
145
+ })
146
+ expect(wrapper.find('.mint-section-card__toolbar').exists()).toBe(false)
147
+ })
148
+
149
+ it('should keep the header, body and footer structure when filling', () => {
150
+ const wrapper = mount(SectionCard, {
151
+ props: { title: 'Experiments', fill: true },
152
+ slots: { default: '<p>rows</p>', footer: '<span>1 / 3</span>' },
153
+ })
154
+ expect(wrapper.find('.mint-section-card__header').exists()).toBe(true)
155
+ expect(wrapper.find('.mint-section-card__body').exists()).toBe(true)
156
+ expect(wrapper.find('.mint-section-card__footer').exists()).toBe(true)
157
+ })
158
+ })
159
+
160
+ describe('slots', () => {
161
+ it('should render the actions slot at the end of the header', () => {
162
+ const wrapper = mount(SectionCard, {
163
+ props: { title: 'Experiments' },
164
+ slots: { actions: '<a class="actions-probe" href="#">All →</a>' },
165
+ })
166
+ expect(wrapper.find('.mint-section-card__header .actions-probe').exists()).toBe(true)
167
+ })
168
+
169
+ it('should render the footer strip only when the footer slot is provided', () => {
170
+ const withFooter = mount(SectionCard, {
171
+ props: { title: 'Experiments' },
172
+ slots: { footer: '<span class="footer-probe">Showing 1–18 of 42</span>' },
173
+ })
174
+ const withoutFooter = mount(SectionCard, {
175
+ props: { title: 'Experiments' },
176
+ })
177
+ expect(withFooter.find('.mint-section-card__footer .footer-probe').exists()).toBe(true)
178
+ expect(withoutFooter.find('.mint-section-card__footer').exists()).toBe(false)
179
+ })
180
+ })
181
+ })
@@ -0,0 +1,77 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import {
4
+ buildAnalysisArtifactTargetQuery,
5
+ parseAnalysisArtifactTarget,
6
+ } from '../../composables/analysisArtifactTarget'
7
+ import {
8
+ currentExperimentIdFromUrl,
9
+ resolveCurrentPluginId,
10
+ } from '../../composables/platformContextHelpers'
11
+
12
+ describe('analysis artifact target', () => {
13
+ afterEach(() => {
14
+ delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
15
+ vi.unstubAllGlobals()
16
+ })
17
+
18
+ it('should resolve the canonical injected plugin id', () => {
19
+ ;(window as unknown as { __MINT_PLATFORM__: unknown }).__MINT_PLATFORM__ = {
20
+ isIntegrated: true,
21
+ plugin: { id: 'LEAF', name: 'Leaf UI' },
22
+ theme: 'system',
23
+ }
24
+
25
+ expect(resolveCurrentPluginId()).toBe('LEAF')
26
+ })
27
+
28
+ it('should tolerate a host that provides a partial location object', () => {
29
+ vi.stubGlobal('window', {
30
+ location: { search: '' },
31
+ })
32
+
33
+ expect(currentExperimentIdFromUrl()).toBeUndefined()
34
+ })
35
+
36
+ it('should build the canonical readable query when an artifact id hint is present', () => {
37
+ const query = buildAnalysisArtifactTargetQuery({
38
+ experimentId: 66,
39
+ artifactPluginId: 'LEAF',
40
+ artifactKey: 'exp316_260703_ys_rna_ide_scr',
41
+ artifactId: 206,
42
+ })
43
+
44
+ expect(query.toString()).toBe(
45
+ 'experiment_id=66&artifact_plugin_id=LEAF'
46
+ + '&artifact_key=exp316_260703_ys_rna_ide_scr&artifact_id=206',
47
+ )
48
+ })
49
+
50
+ it('should parse the canonical identity without requiring an artifact id hint', () => {
51
+ const target = parseAnalysisArtifactTarget(new URLSearchParams(
52
+ 'experiment_id=66&artifact_plugin_id=LEAF&artifact_key=result',
53
+ ))
54
+
55
+ expect(target).toEqual({
56
+ experimentId: 66,
57
+ artifactPluginId: 'LEAF',
58
+ artifactKey: 'result',
59
+ })
60
+ })
61
+
62
+ it('should reject an incomplete target instead of guessing another artifact', () => {
63
+ const target = parseAnalysisArtifactTarget(new URLSearchParams(
64
+ 'experiment_id=66&artifact_key=result',
65
+ ))
66
+
67
+ expect(target).toBeNull()
68
+ })
69
+
70
+ it('should reject non-positive numeric identifiers', () => {
71
+ const target = parseAnalysisArtifactTarget(new URLSearchParams(
72
+ 'experiment_id=0&artifact_plugin_id=LEAF&artifact_key=result&artifact_id=-1',
73
+ ))
74
+
75
+ expect(target).toBeNull()
76
+ })
77
+ })
@@ -19,7 +19,10 @@ vi.mock('../../composables/useApi', () => ({
19
19
  }))
20
20
 
21
21
  import { useAnalysisArtifacts } from '../../composables/useAnalysisArtifacts'
22
- import type { AnalysisArtifactSummary } from '../../types/analysisArtifactTypes'
22
+ import type {
23
+ AnalysisArtifactDetail,
24
+ AnalysisArtifactSummary,
25
+ } from '../../types/analysisArtifactTypes'
23
26
 
24
27
  // ---------------------------------------------------------------------------
25
28
  // Fixtures
@@ -177,6 +180,76 @@ describe('useAnalysisArtifacts', () => {
177
180
  })
178
181
  })
179
182
 
183
+ describe('resolveTarget', () => {
184
+ it('should resolve the exact stable identity when the numeric hint is stale', async () => {
185
+ const detail = {
186
+ ...makeArtifact({
187
+ id: 2,
188
+ experiment_id: 42,
189
+ plugin_id: 'plugin-b',
190
+ artifact_key: 'peaks',
191
+ }),
192
+ result: { schema_version: 'mint.analysis_file.v1' },
193
+ tree: [],
194
+ summary: null,
195
+ } satisfies AnalysisArtifactDetail
196
+ mockGet
197
+ .mockResolvedValueOnce({ artifacts: ARTIFACTS })
198
+ .mockResolvedValueOnce(detail)
199
+ const { resolveTarget } = setup()
200
+
201
+ const resolved = await resolveTarget({
202
+ experimentId: 42,
203
+ artifactPluginId: 'plugin-b',
204
+ artifactKey: 'peaks',
205
+ artifactId: 999,
206
+ })
207
+
208
+ expect({
209
+ resolvedId: resolved?.id,
210
+ detailPath: mockGet.mock.calls[1]?.[0],
211
+ }).toEqual({
212
+ resolvedId: 2,
213
+ detailPath: '/experiments/42/analysis-artifacts/2',
214
+ })
215
+ })
216
+
217
+ it('should fail explicitly when the stable identity is not available', async () => {
218
+ const { resolveTarget, error } = setup()
219
+
220
+ const resolved = await resolveTarget({
221
+ experimentId: 42,
222
+ artifactPluginId: 'LEAF',
223
+ artifactKey: 'missing',
224
+ })
225
+
226
+ expect({ resolved, error: error.value }).toEqual({
227
+ resolved: null,
228
+ error: 'Analysis artifact LEAF/missing is not available.',
229
+ })
230
+ })
231
+
232
+ it('should reject a target from another experiment before making a request', async () => {
233
+ const { resolveTarget, error } = setup()
234
+
235
+ const resolved = await resolveTarget({
236
+ experimentId: 66,
237
+ artifactPluginId: 'LEAF',
238
+ artifactKey: 'result',
239
+ })
240
+
241
+ expect({
242
+ resolved,
243
+ error: error.value,
244
+ requests: mockGet.mock.calls,
245
+ }).toEqual({
246
+ resolved: null,
247
+ error: 'Analysis artifact target belongs to experiment 66, not 42.',
248
+ requests: [],
249
+ })
250
+ })
251
+ })
252
+
180
253
  // -------------------------------------------------------------------------
181
254
  // experiment id resolution
182
255
  // -------------------------------------------------------------------------
@@ -407,6 +407,7 @@ describe('useGeneratedAnalysis', () => {
407
407
  uploadUrl: upload?.[0],
408
408
  uploadMethod: upload?.[1]?.method,
409
409
  uploadSessionHeader: new Headers(upload?.[1]?.headers).get('X-Mint-Job-Session'),
410
+ uploadDefinitionHeader: new Headers(upload?.[1]?.headers).get('X-Mint-Job-Definition'),
410
411
  sessionId: form.get('session_id'),
411
412
  relativePaths: form.getAll('relative_paths'),
412
413
  submittedSource: submit.inputs.source,
@@ -414,6 +415,7 @@ describe('useGeneratedAnalysis', () => {
414
415
  uploadUrl: '/api/demo/jobs/uploads',
415
416
  uploadMethod: 'POST',
416
417
  uploadSessionHeader: 'session-1',
418
+ uploadDefinitionHeader: 'run',
417
419
  sessionId: 'session-1',
418
420
  relativePaths: ['data.csv'],
419
421
  submittedSource: { token: 'upload-token' },
@@ -22,12 +22,37 @@ import { AuthenticationRequiredError } from '../../composables/useApi'
22
22
  import { useAuthStore } from '../../stores/auth'
23
23
 
24
24
  const contract: PluginContract = {
25
- schemaVersion: 1,
25
+ schemaVersion: 2,
26
26
  plugin: {
27
27
  routesPrefix: '/drp',
28
28
  apiPrefix: '/api/drp',
29
29
  type: 'analysis',
30
30
  },
31
+ settings: {
32
+ modelName: 'PluginSettings',
33
+ valueSchema: {
34
+ type: 'object',
35
+ properties: {
36
+ threshold: { type: 'number', default: 0.05 },
37
+ },
38
+ },
39
+ formSchema: {
40
+ groups: [
41
+ {
42
+ id: 'settings',
43
+ label: 'Settings',
44
+ fields: [
45
+ {
46
+ name: 'threshold',
47
+ label: 'Threshold',
48
+ type: 'number',
49
+ defaultValue: 0.05,
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ },
55
+ },
31
56
  endpoints: [],
32
57
  hash: 'sha256:test',
33
58
  }
@@ -818,6 +843,16 @@ describe('usePluginClient', () => {
818
843
  })
819
844
  })
820
845
 
846
+ it('should preserve the contract v2 form schema in settingsConfig', () => {
847
+ const settings = usePluginSettings({
848
+ apiBaseUrl: '/api/drp',
849
+ loadOnMount: false,
850
+ schema: contract.settings.formSchema,
851
+ })
852
+
853
+ expect(settings.settingsConfig.value.schema).toBe(contract.settings.formSchema)
854
+ })
855
+
821
856
  it('should use the platform facade for an explicit different plugin name', async () => {
822
857
  ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
823
858
  isIntegrated: true,