@morscherlab/mint-sdk 1.2.0-beta.9 → 1.2.0

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 (152) hide show
  1. package/README.md +30 -0
  2. package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-CJdNlCZb.js} +21 -14
  3. package/dist/ExperimentPopover-CJdNlCZb.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-DCXmwKNS.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DCXmwKNS.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-BYR20I4c.js} +5 -5
  7. package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-BYR20I4c.js.map} +1 -1
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-BAF3CKY7.js} +3 -3
  9. package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-BAF3CKY7.js.map} +1 -1
  10. package/dist/__tests__/apiErrors.test.d.ts +1 -0
  11. package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
  12. package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
  13. package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
  14. package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
  15. package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
  16. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  17. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  18. package/dist/apiErrors.d.ts +26 -0
  19. package/dist/components/BaseInput.vue.d.ts +1 -1
  20. package/dist/components/BaseSelect.vue.d.ts +1 -1
  21. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  22. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  23. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  24. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  25. package/dist/components/DatePicker.vue.d.ts +1 -1
  26. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  27. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  28. package/dist/components/FilePicker.vue.d.ts +32 -0
  29. package/dist/components/FileUploader.vue.d.ts +5 -2
  30. package/dist/components/FormulaInput.vue.d.ts +1 -1
  31. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  32. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  33. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  34. package/dist/components/NumberInput.vue.d.ts +1 -1
  35. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  36. package/dist/components/ResourceCard.vue.d.ts +1 -1
  37. package/dist/components/SecretInput.vue.d.ts +1 -1
  38. package/dist/components/SequenceInput.vue.d.ts +1 -1
  39. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  40. package/dist/components/TagsInput.vue.d.ts +1 -1
  41. package/dist/components/TimePicker.vue.d.ts +1 -1
  42. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  43. package/dist/components/UnitInput.vue.d.ts +1 -1
  44. package/dist/components/WellPlate.vue.d.ts +1 -1
  45. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  46. package/dist/components/index.d.ts +4 -0
  47. package/dist/components/index.js +8 -8
  48. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  49. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  50. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  51. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  52. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  53. package/dist/{components-CgAUzF8F.js → components-DIum5hkx.js} +4286 -1866
  54. package/dist/components-DIum5hkx.js.map +1 -0
  55. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  56. package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
  57. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  58. package/dist/composables/filePicker/validation.d.ts +5 -0
  59. package/dist/composables/index.d.ts +2 -0
  60. package/dist/composables/index.js +10 -10
  61. package/dist/composables/useApi.d.ts +14 -7
  62. package/dist/composables/useFileBrowser.d.ts +55 -0
  63. package/dist/composables/useFilePicker.d.ts +189 -0
  64. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  65. package/dist/{composables-B_pia3hO.js → composables-mFXsTA4g.js} +300 -11
  66. package/dist/composables-mFXsTA4g.js.map +1 -0
  67. package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
  68. package/dist/errors-BLuqMee5.js.map +1 -0
  69. package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
  70. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
  71. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  72. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  73. package/dist/index.d.ts +1 -0
  74. package/dist/index.js +15 -15
  75. package/dist/install.js +2 -2
  76. package/dist/stores/index.js +2 -2
  77. package/dist/styles.css +2369 -1377
  78. package/dist/templates/index.js +2 -2
  79. package/dist/{templates-Qq113pyZ.js → templates-CmGfmBRA.js} +2 -2
  80. package/dist/{templates-Qq113pyZ.js.map → templates-CmGfmBRA.js.map} +1 -1
  81. package/dist/types/fileBrowserTypes.d.ts +77 -0
  82. package/dist/types/filePicker.d.ts +77 -0
  83. package/dist/types/index.d.ts +2 -0
  84. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-DldpxNOP.js} +42 -37
  85. package/dist/useControlSchema-DldpxNOP.js.map +1 -0
  86. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  87. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  88. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-CEcIaueJ.js} +4 -4
  89. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-CEcIaueJ.js.map} +1 -1
  90. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-z5L_dUIy.js} +2 -2
  91. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-z5L_dUIy.js.map} +1 -1
  92. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-uwoJGEb6.js} +53 -8
  93. package/dist/validation-uwoJGEb6.js.map +1 -0
  94. package/package.json +1 -1
  95. package/src/__tests__/apiErrors.test.ts +81 -0
  96. package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
  97. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  98. package/src/__tests__/components/FilePicker.state.test.ts +287 -0
  99. package/src/__tests__/components/FilePicker.test.ts +487 -0
  100. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  101. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  102. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  103. package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
  104. package/src/__tests__/composables/useApi.test.ts +92 -9
  105. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  106. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  107. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  108. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  109. package/src/apiErrors.ts +85 -0
  110. package/src/components/ExperimentPopover.vue +10 -2
  111. package/src/components/FileBrowserModal.story.vue +203 -0
  112. package/src/components/FileBrowserModal.vue +384 -0
  113. package/src/components/FilePicker.story.vue +44 -0
  114. package/src/components/FilePicker.vue +284 -0
  115. package/src/components/FileUploader.vue +32 -14
  116. package/src/components/GeneratedPlotlyResult.vue +34 -69
  117. package/src/components/GeneratedResultRenderer.vue +0 -5
  118. package/src/components/PlotlyChart.story.vue +61 -0
  119. package/src/components/PlotlyChart.vue +233 -0
  120. package/src/components/SegmentedControl.vue +2 -2
  121. package/src/components/fileBrowserPresentation.ts +39 -0
  122. package/src/components/index.ts +5 -0
  123. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  124. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  125. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  126. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  127. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  128. package/src/composables/filePicker/localFiles.ts +58 -0
  129. package/src/composables/filePicker/usePickerNavigation.ts +220 -0
  130. package/src/composables/filePicker/usePickerSelection.ts +259 -0
  131. package/src/composables/filePicker/validation.ts +41 -0
  132. package/src/composables/index.ts +7 -0
  133. package/src/composables/useApi.ts +57 -13
  134. package/src/composables/useFileBrowser.ts +314 -0
  135. package/src/composables/useFilePicker.ts +366 -0
  136. package/src/composables/useGeneratedAnalysis.ts +10 -17
  137. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  138. package/src/composables/usePluginClient.ts +3 -0
  139. package/src/index.ts +3 -0
  140. package/src/styles/components/file-browser-modal.css +489 -0
  141. package/src/styles/components/file-picker.css +502 -0
  142. package/src/styles/components/icon-button.css +9 -0
  143. package/src/styles/index.css +3 -0
  144. package/src/types/fileBrowserTypes.ts +85 -0
  145. package/src/types/filePicker.ts +72 -0
  146. package/src/types/index.ts +14 -0
  147. package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
  148. package/dist/components-CgAUzF8F.js.map +0 -1
  149. package/dist/composables-B_pia3hO.js.map +0 -1
  150. package/dist/errors-_W0-LbA6.js.map +0 -1
  151. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
  152. package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.2.0-beta.9",
3
+ "version": "1.2.0",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { MintApiError, mintApiErrorFromResponse } from '../apiErrors'
4
+
5
+ describe('MintApiError', () => {
6
+ it('should expose a typed server envelope', () => {
7
+ const error = mintApiErrorFromResponse(404, {
8
+ code: 'leaf.ARTIFACT_NOT_FOUND',
9
+ message: 'Artifact is not available',
10
+ status: 404,
11
+ request_id: 'req-typed',
12
+ details: { artifact_id: 67 },
13
+ detail: 'Artifact is not available',
14
+ })
15
+
16
+ expect({
17
+ type: error.constructor,
18
+ code: error.code,
19
+ status: error.status,
20
+ requestId: error.requestId,
21
+ details: error.details,
22
+ }).toEqual({
23
+ type: MintApiError,
24
+ code: 'leaf.ARTIFACT_NOT_FOUND',
25
+ status: 404,
26
+ requestId: 'req-typed',
27
+ details: { artifact_id: 67 },
28
+ })
29
+ })
30
+
31
+ it('should retain a legacy FastAPI validation detail list', () => {
32
+ const detail = [{ type: 'int_parsing', loc: ['body', 'count'] }]
33
+
34
+ const error = mintApiErrorFromResponse(422, { detail })
35
+
36
+ expect(error.detail).toBe(detail)
37
+ expect({ message: error.message, code: error.code, details: error.details }).toEqual({
38
+ message: 'Validation error',
39
+ code: 'unknown',
40
+ details: null,
41
+ })
42
+ })
43
+
44
+ it('preserves nested SDK errors and outer platform metadata', () => {
45
+ const detail = { error: 'NOT_FOUND', message: 'Missing artifact', details: { id: 67 }, request_id: 'inner' }
46
+ const body = { code: 'resource.not_found', message: 'Resource not found', request_id: 'outer', detail }
47
+ const error = mintApiErrorFromResponse(404, body)
48
+
49
+ expect(error).toMatchObject({
50
+ code: 'NOT_FOUND', message: 'Missing artifact', status: 404,
51
+ requestId: 'outer', details: { id: 67 }, detail, body,
52
+ })
53
+ expect(mintApiErrorFromResponse(404, { ...body, details: [] }).details).toEqual([])
54
+ expect(mintApiErrorFromResponse(404, detail).code).toBe('NOT_FOUND')
55
+ })
56
+
57
+ it('prefers canonical envelopes over legacy nested SDK error metadata', () => {
58
+ const body = {
59
+ status: 426, code: 'plugin.api_version_mismatch', message: 'Plugin API version mismatch',
60
+ detail: { error: 'PLATFORM_API_VERSION_MISMATCH', message: 'Legacy version mismatch' },
61
+ }
62
+ expect(mintApiErrorFromResponse(426, body)).toMatchObject({
63
+ status: 426, code: body.code, message: body.message, detail: body.detail,
64
+ })
65
+ expect(mintApiErrorFromResponse(502, body).status).toBe(502)
66
+ expect(mintApiErrorFromResponse(426, { ...body, status: '426' }).code)
67
+ .toBe('PLATFORM_API_VERSION_MISMATCH')
68
+ })
69
+
70
+ it('retains platform codes and falls back to legacy detail and header request IDs', () => {
71
+ expect(mintApiErrorFromResponse(403, {
72
+ code: 'auth.forbidden', message: '', detail: 'Restricted', request_id: '',
73
+ }, { requestId: 'header-id' })).toMatchObject({
74
+ code: 'auth.forbidden', message: 'Restricted', requestId: 'header-id',
75
+ })
76
+ expect(mintApiErrorFromResponse(502, '<html>Bad gateway</html>')).toMatchObject({
77
+ code: 'unknown', message: 'Bad gateway', body: '<html>Bad gateway</html>',
78
+ })
79
+ })
80
+
81
+ })
@@ -21,6 +21,36 @@ describe('ExperimentPopover', () => {
21
21
  document.body.innerHTML = ''
22
22
  })
23
23
 
24
+ it('requests experiment selection on the first click when no experiment is selected', async () => {
25
+ const wrapper = mountPopover()
26
+
27
+ await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
28
+
29
+ expect(wrapper.emitted('select')).toHaveLength(1)
30
+ expect(wrapper.find('.mint-experiment-popover__panel').exists()).toBe(false)
31
+
32
+ wrapper.unmount()
33
+ })
34
+
35
+ it.each([
36
+ { experimentName: 'Dose response run' },
37
+ { experimentCode: 'EXP-001' },
38
+ { isResolving: true },
39
+ { error: 'Experiment not found' },
40
+ ])('keeps the popover interaction for bound experiment state %o', async (props) => {
41
+ const wrapper = mountPopover(props)
42
+
43
+ await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
44
+
45
+ expect(wrapper.emitted('select')).toBeUndefined()
46
+ expect(wrapper.find('.mint-experiment-popover__panel').exists()).toBe(true)
47
+
48
+ await wrapper.find('.mint-experiment-popover__trigger').trigger('click')
49
+ expect(wrapper.find('.mint-experiment-popover__panel').exists()).toBe(false)
50
+
51
+ wrapper.unmount()
52
+ })
53
+
24
54
  it('prefers the experiment name in the trigger when both name and code are available', () => {
25
55
  const wrapper = mountPopover({
26
56
  experimentName: 'Dose response run',
@@ -0,0 +1,460 @@
1
+ import { enableAutoUnmount, mount } from '@vue/test-utils'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { nextTick } from 'vue'
4
+
5
+ enableAutoUnmount(afterEach)
6
+
7
+ import FileBrowserModal from '../../components/FileBrowserModal.vue'
8
+ import type { FileEntry, ServerMount } from '../../types/fileBrowserTypes'
9
+
10
+ const MOUNTS: ServerMount[] = [
11
+ {
12
+ id: 'qe',
13
+ label: '/mnt/instrument-qe',
14
+ count: 4,
15
+ children: [{ id: 'qe:2026-07', label: '2026-07', count: 12 }],
16
+ },
17
+ { id: 'nas', label: '/mnt/nas-shared', count: 18 },
18
+ { id: 'seq', label: '/mnt/sequencer-01', available: false },
19
+ ]
20
+
21
+ const ENTRIES: FileEntry[] = [
22
+ { name: 'calibration', path: 'raw/calibration', kind: 'directory', child_count: 2 },
23
+ { name: 'blanks', path: 'raw/blanks', kind: 'directory', child_count: 6 },
24
+ {
25
+ name: 'HeLa_norm_0h_r1.raw',
26
+ path: 'raw/HeLa_norm_0h_r1.raw',
27
+ kind: 'file',
28
+ size_bytes: 1_900_000_000,
29
+ extension: '.raw',
30
+ },
31
+ {
32
+ name: 'acquisition_log.txt',
33
+ path: 'raw/acquisition_log.txt',
34
+ kind: 'file',
35
+ size_bytes: 48_000,
36
+ extension: '.txt',
37
+ matches_type_rules: false,
38
+ },
39
+ ]
40
+
41
+ function mountBrowser(props: Record<string, unknown> = {}) {
42
+ return mount(FileBrowserModal, {
43
+ props: {
44
+ modelValue: true,
45
+ mounts: MOUNTS,
46
+ mountId: 'qe',
47
+ path: 'raw',
48
+ entries: ENTRIES,
49
+ breadcrumbs: [
50
+ { label: '/mnt/instrument-qe', path: '' },
51
+ { label: 'raw', path: 'raw' },
52
+ ],
53
+ ...props,
54
+ },
55
+ global: { stubs: { teleport: true } },
56
+ })
57
+ }
58
+
59
+ function rowAt(wrapper: ReturnType<typeof mountBrowser>, index: number) {
60
+ return wrapper.findAll('.mint-file-browser__row')[index]
61
+ }
62
+
63
+ describe('FileBrowserModal', () => {
64
+ it('should render one row per entry', () => {
65
+ expect(mountBrowser().findAll('.mint-file-browser__row')).toHaveLength(ENTRIES.length)
66
+ })
67
+
68
+ it('should render a row per configured mount', () => {
69
+ expect(mountBrowser().findAll('.mint-file-browser__mount')).toHaveLength(4)
70
+ })
71
+
72
+ it('should mark an unavailable mount as offline', () => {
73
+ expect(mountBrowser().text()).toContain('offline')
74
+ })
75
+
76
+ // The listing never lies about what is there: non-matching entries dim.
77
+ it('should keep a non-matching entry in the listing', () => {
78
+ expect(mountBrowser().text()).toContain('acquisition_log.txt')
79
+ })
80
+
81
+ it('should dim a non-matching entry when dimNonMatching is set', () => {
82
+ expect(rowAt(mountBrowser(), 3).classes()).toContain('mint-file-browser__row--dimmed')
83
+ })
84
+
85
+ it('should not dim a non-matching entry when dimNonMatching is false', () => {
86
+ const wrapper = mountBrowser({ dimNonMatching: false })
87
+
88
+ expect(rowAt(wrapper, 3).classes()).not.toContain('mint-file-browser__row--dimmed')
89
+ })
90
+
91
+ it('should not dim a matching entry', () => {
92
+ expect(rowAt(mountBrowser(), 2).classes()).not.toContain('mint-file-browser__row--dimmed')
93
+ })
94
+
95
+ // Ticking a folder takes the whole directory; clicking the row opens it.
96
+ it('should emit navigate when a directory row is clicked', async () => {
97
+ const wrapper = mountBrowser()
98
+ await rowAt(wrapper, 0).trigger('click')
99
+
100
+ expect(wrapper.emitted('navigate')?.[0]?.[0]).toMatchObject({ path: 'raw/calibration' })
101
+ })
102
+
103
+ it('should not emit navigate when a file row is clicked', async () => {
104
+ const wrapper = mountBrowser()
105
+ await rowAt(wrapper, 2).trigger('click')
106
+
107
+ expect(wrapper.emitted('navigate')).toBeUndefined()
108
+ })
109
+
110
+ it('should select the whole folder when a directory checkbox is ticked', async () => {
111
+ const wrapper = mountBrowser()
112
+ await rowAt(wrapper, 0).find('.mint-file-browser__checkbox').setValue(true)
113
+
114
+ expect(wrapper.emitted('update:selected')?.[0]?.[0]).toMatchObject([
115
+ { path: 'raw/calibration', kind: 'directory' },
116
+ ])
117
+ })
118
+
119
+ // setValue only dispatches `change`; a real user also clicks, and it is the
120
+ // @click.stop that keeps the tick from bubbling into a navigation.
121
+ it('should not navigate when a directory checkbox is clicked', async () => {
122
+ const wrapper = mountBrowser()
123
+ const checkbox = rowAt(wrapper, 0).find('.mint-file-browser__checkbox')
124
+ await checkbox.trigger('click')
125
+ await checkbox.setValue(true)
126
+
127
+ expect(wrapper.emitted('navigate')).toBeUndefined()
128
+ })
129
+
130
+ // The row is a div, so this button is the only thing Tab can reach.
131
+ it('should expose a focusable open button on a directory row', () => {
132
+ expect(rowAt(mountBrowser(), 0).find('button.mint-file-browser__open').exists()).toBe(true)
133
+ })
134
+
135
+ it('should not expose an open button on a file row', () => {
136
+ expect(rowAt(mountBrowser(), 2).find('button.mint-file-browser__open').exists()).toBe(false)
137
+ })
138
+
139
+ it('should navigate when the open button is activated', async () => {
140
+ const wrapper = mountBrowser()
141
+ await rowAt(wrapper, 0).find('button.mint-file-browser__open').trigger('click')
142
+
143
+ expect(wrapper.emitted('navigate')?.[0]?.[0]).toMatchObject({ path: 'raw/calibration' })
144
+ })
145
+
146
+ it('should not select when the open button is activated', async () => {
147
+ const wrapper = mountBrowser()
148
+ await rowAt(wrapper, 0).find('button.mint-file-browser__open').trigger('click')
149
+
150
+ expect(wrapper.emitted('update:selected')).toBeUndefined()
151
+ })
152
+
153
+ it('should label the open button with the folder name', () => {
154
+ const button = rowAt(mountBrowser(), 0).find('button.mint-file-browser__open')
155
+
156
+ expect(button.attributes('aria-label')).toBe('Open calibration')
157
+ })
158
+
159
+ it('should describe a ticked folder by its child count', async () => {
160
+ const wrapper = mountBrowser({
161
+ selected: [
162
+ { mount_id: 'qe', path: 'raw/blanks', name: 'blanks', kind: 'directory', child_count: 6 },
163
+ ],
164
+ })
165
+
166
+ // "items", not "files": the server counts every visible entry, subdirectories included.
167
+ expect(wrapper.text()).toContain('whole folder, 6 items')
168
+ })
169
+
170
+ it('should emit selectFolder for the directory being viewed', async () => {
171
+ const wrapper = mountBrowser()
172
+ await wrapper.find('.mint-file-browser__select-folder').trigger('click')
173
+
174
+ expect(wrapper.emitted('selectFolder')?.[0]?.[0]).toMatchObject({ mount_id: 'qe', path: 'raw' })
175
+ })
176
+
177
+ it('should show the local picker button by default', () => {
178
+ expect(mountBrowser().find('.mint-file-browser__local').exists()).toBe(true)
179
+ })
180
+
181
+ it('should hide the local picker when showLocalPicker is false', () => {
182
+ expect(
183
+ mountBrowser({ showLocalPicker: false }).find('.mint-file-browser__local').exists(),
184
+ ).toBe(false)
185
+ })
186
+
187
+ it('should emit chooseLocal when the local picker is clicked', async () => {
188
+ const wrapper = mountBrowser()
189
+ await wrapper.find('.mint-file-browser__local').trigger('click')
190
+
191
+ expect(wrapper.emitted('chooseLocal')).toHaveLength(1)
192
+ })
193
+
194
+ it('should render the type rules', () => {
195
+ expect(mountBrowser({ typeRules: ['.raw', '.mzML'] }).text()).toContain('.raw .mzML')
196
+ })
197
+
198
+ it('should report how many entries match the type rules', () => {
199
+ expect(mountBrowser({ totalCount: 1284, matchCount: 9 }).text()).toContain(
200
+ '9 match the type rules',
201
+ )
202
+ })
203
+
204
+ it('should summarise the selection count', () => {
205
+ const wrapper = mountBrowser({
206
+ selected: [
207
+ { mount_id: 'qe', path: 'raw/blanks', name: 'blanks', kind: 'directory' },
208
+ { mount_id: 'qe', path: 'raw/a.raw', name: 'a.raw', kind: 'file' },
209
+ ],
210
+ })
211
+
212
+ expect(wrapper.text()).toContain('2 selected')
213
+ })
214
+
215
+ it('should render a chip per selected item', () => {
216
+ const wrapper = mountBrowser({
217
+ selected: [
218
+ { mount_id: 'qe', path: 'raw/blanks', name: 'blanks', kind: 'directory' },
219
+ { mount_id: 'qe', path: 'raw/a.raw', name: 'a.raw', kind: 'file' },
220
+ ],
221
+ })
222
+
223
+ expect(wrapper.findAll('.mint-file-browser__chip')).toHaveLength(2)
224
+ })
225
+
226
+ it('should clear the selection', async () => {
227
+ const wrapper = mountBrowser({
228
+ selected: [{ mount_id: 'qe', path: 'raw/a.raw', name: 'a.raw', kind: 'file' }],
229
+ })
230
+ await wrapper.find('.mint-file-browser__clear').trigger('click')
231
+
232
+ expect(wrapper.emitted('update:selected')?.[0]?.[0]).toEqual([])
233
+ })
234
+
235
+ it('should remove one item from the selection via its chip', async () => {
236
+ const wrapper = mountBrowser({
237
+ selected: [
238
+ { mount_id: 'qe', path: 'raw/blanks', name: 'blanks', kind: 'directory' },
239
+ { mount_id: 'qe', path: 'raw/a.raw', name: 'a.raw', kind: 'file' },
240
+ ],
241
+ })
242
+ await wrapper.findAll('.mint-file-browser__chip-remove')[0].trigger('click')
243
+
244
+ expect(wrapper.emitted('update:selected')?.[0]?.[0]).toMatchObject([{ path: 'raw/a.raw' }])
245
+ })
246
+
247
+ it('should confirm with the current selection', async () => {
248
+ const wrapper = mountBrowser({
249
+ selected: [{ mount_id: 'qe', path: 'raw/a.raw', name: 'a.raw', kind: 'file' }],
250
+ })
251
+ await wrapper.find('.mint-file-browser__confirm').trigger('click')
252
+
253
+ expect(wrapper.emitted('confirm')?.[0]?.[0]).toMatchObject([{ path: 'raw/a.raw' }])
254
+ })
255
+
256
+ it('should disable confirming an empty selection', () => {
257
+ expect(mountBrowser().find('.mint-file-browser__confirm').attributes('disabled')).toBeDefined()
258
+ })
259
+
260
+ it('should emit navigate for the parent when the back button is used', async () => {
261
+ const wrapper = mountBrowser({ parent: '' })
262
+ await wrapper.find('.mint-file-browser__back').trigger('click')
263
+
264
+ expect(wrapper.emitted('navigate')?.[0]?.[0]).toMatchObject({ mount_id: 'qe', path: '' })
265
+ })
266
+
267
+ it('should disable the back button at a mount root', () => {
268
+ const wrapper = mountBrowser({ path: '', parent: null })
269
+
270
+ expect(wrapper.find('.mint-file-browser__back').attributes('disabled')).toBeDefined()
271
+ })
272
+
273
+ it('should emit navigate when a mount is chosen', async () => {
274
+ const wrapper = mountBrowser()
275
+ await wrapper.findAll('.mint-file-browser__mount')[1].trigger('click')
276
+
277
+ expect(wrapper.emitted('navigate')?.[0]?.[0]).toMatchObject({
278
+ mount_id: 'qe:2026-07',
279
+ path: '',
280
+ })
281
+ })
282
+
283
+ it('should not navigate to an unavailable mount', async () => {
284
+ const wrapper = mountBrowser()
285
+ await wrapper.findAll('.mint-file-browser__mount')[3].trigger('click')
286
+
287
+ expect(wrapper.emitted('navigate')).toBeUndefined()
288
+ })
289
+
290
+ it('should emit search updates', async () => {
291
+ const wrapper = mountBrowser()
292
+ await wrapper.find('input[aria-label="Search this folder"]').setValue('hela')
293
+
294
+ expect(wrapper.emitted('update:search')?.[0]?.[0]).toBe('hela')
295
+ })
296
+
297
+ it('should show an empty state when the directory has no entries', () => {
298
+ expect(mountBrowser({ entries: [] }).text()).toContain('This folder is empty')
299
+ })
300
+
301
+ it('should show a loading skeleton while listing', () => {
302
+ expect(mountBrowser({ loading: true }).find('.mint-file-browser__skeleton').exists()).toBe(true)
303
+ })
304
+
305
+ it('should surface a listing error', () => {
306
+ expect(mountBrowser({ error: 'Mount is offline' }).text()).toContain('Mount is offline')
307
+ })
308
+
309
+ it('should state that mounts are read-only', () => {
310
+ expect(mountBrowser().text()).toContain('Read-only · Select paths, not copies')
311
+ })
312
+ })
313
+
314
+ describe('FileBrowserModal review fixes', () => {
315
+ // A search filters server-side while totalCount stays the whole directory.
316
+ it('should say how many rows are shown when a search narrows the listing', () => {
317
+ const wrapper = mount(FileBrowserModal, {
318
+ props: {
319
+ modelValue: true,
320
+ mounts: MOUNTS,
321
+ mountId: 'qe',
322
+ path: 'raw',
323
+ entries: ENTRIES,
324
+ totalCount: 1284,
325
+ },
326
+ global: { stubs: { teleport: true } },
327
+ })
328
+
329
+ expect(wrapper.text()).toContain('4 shown')
330
+ })
331
+
332
+ it('should not claim rows are hidden when nothing is filtered', () => {
333
+ const wrapper = mount(FileBrowserModal, {
334
+ props: {
335
+ modelValue: true,
336
+ mounts: MOUNTS,
337
+ mountId: 'qe',
338
+ path: 'raw',
339
+ entries: ENTRIES,
340
+ totalCount: 4,
341
+ },
342
+ global: { stubs: { teleport: true } },
343
+ })
344
+
345
+ expect(wrapper.text()).not.toContain('shown')
346
+ })
347
+
348
+ it('should report a capped listing', () => {
349
+ const wrapper = mount(FileBrowserModal, {
350
+ props: {
351
+ modelValue: true,
352
+ mounts: MOUNTS,
353
+ mountId: 'qe',
354
+ path: 'raw',
355
+ entries: ENTRIES,
356
+ totalCount: 4,
357
+ truncated: true,
358
+ },
359
+ global: { stubs: { teleport: true } },
360
+ })
361
+
362
+ expect(wrapper.text()).toContain('listing capped')
363
+ })
364
+
365
+ it('should label a folder count as items rather than files', () => {
366
+ const wrapper = mount(FileBrowserModal, {
367
+ props: { modelValue: true, mounts: MOUNTS, mountId: 'qe', path: 'raw', entries: ENTRIES },
368
+ global: { stubs: { teleport: true } },
369
+ })
370
+
371
+ expect(wrapper.text()).toContain('2 items')
372
+ })
373
+ })
374
+
375
+ describe('FileBrowserModal polish', () => {
376
+ it.each([{ loading: true }, { error: 'Offline' }, { mountId: '' }])(
377
+ 'disables current-folder selection without a ready listing: %o',
378
+ (props) => {
379
+ const wrapper = mountBrowser(props)
380
+ const button = wrapper.get('.mint-file-browser__select-folder')
381
+ expect(button.attributes('disabled')).toBeDefined()
382
+ },
383
+ )
384
+
385
+ it('distinguishes no configured mounts from an empty folder', () => {
386
+ const wrapper = mountBrowser({ mounts: [], mountId: '', entries: [] })
387
+ expect(wrapper.text()).toContain('No server mounts configured')
388
+ })
389
+
390
+ it('offers a retry after a browse failure', async () => {
391
+ const wrapper = mountBrowser({ error: 'NAS offline' })
392
+ await wrapper.get('button[aria-label="Retry folder listing"]').trigger('click')
393
+ expect(wrapper.emitted('refresh')).toHaveLength(1)
394
+ })
395
+
396
+ it('keeps selections with separator characters in their mount IDs distinct', async () => {
397
+ const wrapper = mountBrowser({
398
+ mountId: 'a',
399
+ entries: [{ name: 'x', path: 'b::c', kind: 'file' }],
400
+ selected: [{ mount_id: 'a::b', path: 'c', name: 'c', kind: 'file' }],
401
+ })
402
+ await wrapper.get('input[type="checkbox"]').setValue(true)
403
+ expect(wrapper.emitted('update:selected')?.[0]?.[0]).toHaveLength(2)
404
+ })
405
+ })
406
+
407
+ describe('FileBrowserModal dialog workspace', () => {
408
+ it('uses SDK actions and an accessible independently labelled dialog', () => {
409
+ const wrapper = mountBrowser({ title: 'Choose instrument files' })
410
+ expect(wrapper.get('[role="dialog"]').attributes('aria-label')).toBe('Choose instrument files')
411
+ expect(wrapper.get('.mint-file-browser__confirm').classes()).toContain('mint-button--primary')
412
+ expect(wrapper.get('.mint-file-browser__cancel').classes()).toContain('mint-button--secondary')
413
+ expect(wrapper.get('[aria-label="Go to parent folder"]').classes()).toContain(
414
+ 'mint-icon-button',
415
+ )
416
+ expect(wrapper.get('[aria-label="Search this folder"]').classes()).toContain('mint-input')
417
+ })
418
+
419
+ it('focuses an initially open dialog, traps Tab and restores focus and scroll on close', async () => {
420
+ const launcher = document.createElement('button')
421
+ document.body.append(launcher)
422
+ launcher.focus()
423
+ document.body.style.overflow = 'scroll'
424
+ const wrapper = mount(FileBrowserModal, {
425
+ attachTo: document.body,
426
+ props: { modelValue: true, showLocalPicker: false },
427
+ global: { stubs: { teleport: true } },
428
+ })
429
+ try {
430
+ await nextTick()
431
+ const close = wrapper.get('[aria-label="Close modal"]')
432
+ expect(document.activeElement).toBe(close.element)
433
+ expect(document.body.style.overflow).toBe('hidden')
434
+ await close.trigger('keydown', { key: 'Tab', shiftKey: true })
435
+ expect(document.activeElement).toBe(wrapper.get('.mint-file-browser__cancel').element)
436
+ await wrapper.get('.mint-file-browser__cancel').trigger('keydown', { key: 'Tab' })
437
+ expect(document.activeElement).toBe(close.element)
438
+ await close.trigger('keydown', { key: 'Escape' })
439
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
440
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
441
+ await wrapper.setProps({ modelValue: false })
442
+ expect(document.activeElement).toBe(launcher)
443
+ expect(document.body.style.overflow).toBe('scroll')
444
+ } finally {
445
+ wrapper.unmount()
446
+ launcher.remove()
447
+ document.body.style.overflow = ''
448
+ }
449
+ })
450
+
451
+ it('restores the existing scroll setting when unmounted while open', async () => {
452
+ document.body.style.overflow = 'auto'
453
+ const wrapper = mountBrowser()
454
+ await nextTick()
455
+ expect(document.body.style.overflow).toBe('hidden')
456
+ wrapper.unmount()
457
+ expect(document.body.style.overflow).toBe('auto')
458
+ document.body.style.overflow = ''
459
+ })
460
+ })