@morscherlab/mint-sdk 1.2.0-beta.12 → 1.2.0-beta.14

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 (69) hide show
  1. package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
  2. package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
  3. package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
  4. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  5. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  6. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  7. package/dist/components/FilePicker.vue.d.ts +32 -0
  8. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  9. package/dist/components/index.d.ts +3 -0
  10. package/dist/components/index.js +2 -2
  11. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  12. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  13. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  14. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  15. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  16. package/dist/{components-DnZzoTN4.js → components-6IIxCSI8.js} +3725 -1519
  17. package/dist/components-6IIxCSI8.js.map +1 -0
  18. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  19. package/dist/composables/filePicker/usePickerNavigation.d.ts +95 -0
  20. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  21. package/dist/composables/filePicker/validation.d.ts +5 -0
  22. package/dist/composables/index.d.ts +2 -0
  23. package/dist/composables/index.js +3 -3
  24. package/dist/composables/useFileBrowser.d.ts +55 -0
  25. package/dist/composables/useFilePicker.d.ts +190 -0
  26. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  27. package/dist/{composables-B_pia3hO.js → composables-hY2Q2xPI.js} +289 -3
  28. package/dist/composables-hY2Q2xPI.js.map +1 -0
  29. package/dist/index.js +4 -4
  30. package/dist/install.js +1 -1
  31. package/dist/styles.css +2232 -1259
  32. package/dist/types/fileBrowserTypes.d.ts +77 -0
  33. package/dist/types/filePicker.d.ts +69 -0
  34. package/dist/types/index.d.ts +2 -0
  35. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-nBbSn1TI.js} +46 -2
  36. package/dist/validation-nBbSn1TI.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  39. package/src/__tests__/components/FilePicker.state.test.ts +160 -0
  40. package/src/__tests__/components/FilePicker.test.ts +328 -0
  41. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  42. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  43. package/src/components/FileBrowserModal.story.vue +203 -0
  44. package/src/components/FileBrowserModal.vue +384 -0
  45. package/src/components/FilePicker.vue +301 -0
  46. package/src/components/fileBrowserPresentation.ts +39 -0
  47. package/src/components/index.ts +4 -0
  48. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  49. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  50. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  51. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  52. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  53. package/src/composables/filePicker/localFiles.ts +55 -0
  54. package/src/composables/filePicker/usePickerNavigation.ts +199 -0
  55. package/src/composables/filePicker/usePickerSelection.ts +244 -0
  56. package/src/composables/filePicker/validation.ts +40 -0
  57. package/src/composables/index.ts +7 -0
  58. package/src/composables/useFileBrowser.ts +314 -0
  59. package/src/composables/useFilePicker.ts +347 -0
  60. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  61. package/src/styles/components/file-browser-modal.css +489 -0
  62. package/src/styles/components/file-picker.css +501 -0
  63. package/src/styles/index.css +3 -0
  64. package/src/types/fileBrowserTypes.ts +85 -0
  65. package/src/types/filePicker.ts +65 -0
  66. package/src/types/index.ts +14 -0
  67. package/dist/components-DnZzoTN4.js.map +0 -1
  68. package/dist/composables-B_pia3hO.js.map +0 -1
  69. package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
@@ -0,0 +1,328 @@
1
+ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import FilePicker from '../../components/FilePicker.vue'
4
+ import type { PickerAdapter, PickerNode, PickerSelection } from '../../types/filePicker'
5
+ import { localFilesAdapter } from '../../composables/filePicker/localFiles'
6
+ import { pickerExclusion } from '../../composables/filePicker/validation'
7
+
8
+ enableAutoUnmount(afterEach)
9
+ afterEach(() => vi.useRealTimers())
10
+ const folder: PickerNode = {
11
+ kind: 'folder',
12
+ path: 'opaque:a',
13
+ name: 'Tissue liver',
14
+ fileCount: 3,
15
+ bytes: 60,
16
+ formats: { raw: 2, txt: 1 },
17
+ }
18
+ const other: PickerNode = { kind: 'folder', path: 'opaque:b', name: 'Tissue kidney' }
19
+ const one: PickerNode = {
20
+ kind: 'file',
21
+ path: 'file:one',
22
+ parentPath: folder.path,
23
+ name: 'sample-01.raw',
24
+ bytes: 20,
25
+ format: 'raw',
26
+ }
27
+ const two: PickerNode = {
28
+ kind: 'file',
29
+ path: 'file:two',
30
+ parentPath: folder.path,
31
+ name: 'sample-02.raw',
32
+ bytes: 20,
33
+ format: 'raw',
34
+ }
35
+ const unsupported: PickerNode = {
36
+ kind: 'file',
37
+ path: 'file:notes',
38
+ parentPath: folder.path,
39
+ name: 'notes.txt',
40
+ bytes: 20,
41
+ format: 'txt',
42
+ }
43
+ function adapter(): PickerAdapter {
44
+ return {
45
+ listRoot: vi.fn(async () => [folder, other]),
46
+ listChildren: vi.fn(async (path) => (path === folder.path ? [one, two, unsupported] : [])),
47
+ search: vi.fn(async (query) =>
48
+ [folder, one, two, unsupported].filter((node) =>
49
+ node.name.toLowerCase().includes(query.toLowerCase()),
50
+ ),
51
+ ),
52
+ }
53
+ }
54
+ async function setup(props: Record<string, unknown> = {}) {
55
+ const wrapper = mount(FilePicker, {
56
+ props: {
57
+ open: true,
58
+ adapter: adapter(),
59
+ capabilities: { server: { formats: ['raw'] } },
60
+ ...props,
61
+ },
62
+ global: { stubs: { teleport: true } },
63
+ })
64
+ await flushPromises()
65
+ return wrapper
66
+ }
67
+ function row(wrapper: Awaited<ReturnType<typeof setup>>, path: string) {
68
+ return wrapper.findAll('[role="treeitem"]').find((item) => item.attributes('data-path') === path)!
69
+ }
70
+ function confirm(wrapper: Awaited<ReturnType<typeof setup>>) {
71
+ return wrapper.findAll('.mint-picker__actions button').at(-1)!
72
+ }
73
+ async function expand(wrapper: Awaited<ReturnType<typeof setup>>) {
74
+ await wrapper.get('[aria-label="Expand Tissue liver"]').trigger('click')
75
+ await flushPromises()
76
+ }
77
+
78
+ describe('FilePicker', () => {
79
+ it('selects a folder without navigating, resolves metadata and excludes unsupported files', async () => {
80
+ const wrapper = await setup()
81
+ await row(wrapper, folder.path).trigger('click')
82
+ await flushPromises()
83
+ expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true')
84
+ expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(2)
85
+ await confirm(wrapper).trigger('click')
86
+ const result = wrapper.emitted('select')?.[0]?.[0] as Extract<
87
+ PickerSelection,
88
+ { source: 'server' }
89
+ >
90
+ expect(result.folders).toEqual([folder.path])
91
+ expect(result.files.map((file) => file.path)).toEqual([one.path, two.path])
92
+ expect(result.excluded).toEqual([
93
+ { path: unsupported.path, name: unsupported.name, reason: 'unsupported' },
94
+ ])
95
+ })
96
+
97
+ it('discloses without selecting and double-clicks to navigate', async () => {
98
+ const wrapper = await setup()
99
+ await expand(wrapper)
100
+ expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('false')
101
+ expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(5)
102
+ await row(wrapper, folder.path).trigger('dblclick')
103
+ await flushPromises()
104
+ expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(3)
105
+ expect(wrapper.get('[aria-label="Breadcrumb"]').text()).toContain(folder.name)
106
+ })
107
+
108
+ it('drops an inherited file with Ctrl and restores it with another Ctrl click', async () => {
109
+ const wrapper = await setup()
110
+ await row(wrapper, folder.path).trigger('click')
111
+ await expand(wrapper)
112
+ await row(wrapper, one.path).trigger('click', { ctrlKey: true })
113
+ expect(row(wrapper, one.path).attributes('aria-selected')).toBe('false')
114
+ expect(row(wrapper, folder.path).text()).toContain('1 / 3')
115
+ await confirm(wrapper).trigger('click')
116
+ const result = wrapper.emitted('select')?.[0]?.[0] as Extract<
117
+ PickerSelection,
118
+ { source: 'server' }
119
+ >
120
+ expect(result.excluded).toContainEqual({ path: one.path, name: one.name, reason: 'deselected' })
121
+ await row(wrapper, one.path).trigger('click', { metaKey: true })
122
+ expect(row(wrapper, one.path).attributes('aria-selected')).toBe('true')
123
+ })
124
+
125
+ it('keeps unsupported files inert for click, double-click and keyboard', async () => {
126
+ const wrapper = await setup({ selectionMode: 'single-file' })
127
+ await expand(wrapper)
128
+ const unavailable = row(wrapper, unsupported.path)
129
+ expect(unavailable.attributes('aria-disabled')).toBe('true')
130
+ expect(unavailable.attributes('title')).toContain('not supported')
131
+ await unavailable.trigger('click')
132
+ await unavailable.trigger('dblclick')
133
+ await unavailable.trigger('keydown', { key: 'Enter' })
134
+ expect(wrapper.emitted('select')).toBeUndefined()
135
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
136
+ })
137
+
138
+ it('single-file mode replaces selection and folder mode leaves files inert', async () => {
139
+ const wrapper = await setup({ selectionMode: 'single-file' })
140
+ await expand(wrapper)
141
+ await row(wrapper, one.path).trigger('click')
142
+ await row(wrapper, two.path).trigger('click', { metaKey: true })
143
+ await flushPromises()
144
+ await confirm(wrapper).trigger('click')
145
+ expect(
146
+ (wrapper.emitted('select')?.[0]?.[0] as { files: PickerNode[] }).files.map(
147
+ (file) => file.path,
148
+ ),
149
+ ).toEqual([two.path])
150
+ const folders = await setup({ selectionMode: 'folder' })
151
+ await expand(folders)
152
+ await row(folders, one.path).trigger('click')
153
+ expect(row(folders, one.path).attributes('aria-selected')).toBe('false')
154
+ })
155
+
156
+ it('uses Shift to select the file range, excluding unsupported entries', async () => {
157
+ const wrapper = await setup({ selectionMode: 'multi-file' })
158
+ await expand(wrapper)
159
+ await row(wrapper, one.path).trigger('click')
160
+ await row(wrapper, two.path).trigger('click', { shiftKey: true })
161
+ await flushPromises()
162
+ await confirm(wrapper).trigger('click')
163
+ expect((wrapper.emitted('select')?.[0]?.[0] as { files: PickerNode[] }).files).toHaveLength(2)
164
+ })
165
+
166
+ it('preserves selected paths while searching and passes the recursive scope', async () => {
167
+ vi.useFakeTimers()
168
+ const data = adapter()
169
+ const wrapper = await setup({ adapter: data })
170
+ await row(wrapper, folder.path).trigger('click')
171
+ await flushPromises()
172
+ await wrapper.get('[aria-label="Search folders and files"]').setValue('sample')
173
+ await wrapper.get('.mint-picker__scope').trigger('click')
174
+ await vi.advanceTimersByTimeAsync(250)
175
+ expect(data.search).toHaveBeenCalledWith(
176
+ 'sample',
177
+ { path: '', recursive: true },
178
+ expect.anything(),
179
+ )
180
+ expect(confirm(wrapper).attributes('disabled')).toBeUndefined()
181
+ await confirm(wrapper).trigger('click')
182
+ expect((wrapper.emitted('select')?.[0]?.[0] as { folders: string[] }).folders).toEqual([
183
+ folder.path,
184
+ ])
185
+ })
186
+
187
+ it('cancel emits no selection and reopening restores the initial confirmed selection', async () => {
188
+ const initial: PickerSelection = {
189
+ source: 'server',
190
+ folders: [folder.path],
191
+ files: [],
192
+ excluded: [],
193
+ }
194
+ const wrapper = await setup({ initialSelection: initial })
195
+ await row(wrapper, other.path).trigger('click')
196
+ await wrapper.get('[aria-label="Close modal"]').trigger('click')
197
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
198
+ expect(wrapper.emitted('select')).toBeUndefined()
199
+ await wrapper.setProps({ open: false })
200
+ await wrapper.setProps({ open: true })
201
+ await flushPromises()
202
+ expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true')
203
+ expect(row(wrapper, other.path).attributes('aria-selected')).toBe('false')
204
+ })
205
+
206
+ it('toggles a selected folder off with a second plain click', async () => {
207
+ const wrapper = await setup()
208
+ await row(wrapper, folder.path).trigger('click')
209
+ await flushPromises()
210
+ await row(wrapper, folder.path).trigger('click')
211
+ await flushPromises()
212
+ expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('false')
213
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
214
+ })
215
+
216
+ it('toggles inherited files off and on without a modifier or losing the folder', async () => {
217
+ const wrapper = await setup()
218
+ await row(wrapper, folder.path).trigger('click')
219
+ await expand(wrapper)
220
+ await row(wrapper, one.path).trigger('click')
221
+ expect(row(wrapper, one.path).attributes('aria-selected')).toBe('false')
222
+ expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true')
223
+ await row(wrapper, one.path).trigger('click')
224
+ expect(row(wrapper, one.path).attributes('aria-selected')).toBe('true')
225
+ expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true')
226
+ })
227
+
228
+ it('toggles a single file off but Enter still confirms a selected file', async () => {
229
+ const wrapper = await setup({ selectionMode: 'single-file' })
230
+ await expand(wrapper)
231
+ await row(wrapper, one.path).trigger('click')
232
+ await row(wrapper, one.path).trigger('click')
233
+ await flushPromises()
234
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
235
+ await row(wrapper, one.path).trigger('click')
236
+ await row(wrapper, one.path).trigger('keydown', { key: 'Enter' })
237
+ await flushPromises()
238
+ expect(wrapper.emitted('select')).toHaveLength(1)
239
+ })
240
+
241
+ it('deselects one explicit file without losing the other selected files', async () => {
242
+ const wrapper = await setup({ selectionMode: 'multi-file' })
243
+ await expand(wrapper)
244
+ await row(wrapper, one.path).trigger('click')
245
+ await row(wrapper, two.path).trigger('click', { ctrlKey: true })
246
+ await row(wrapper, one.path).trigger('click')
247
+ expect(row(wrapper, one.path).attributes('aria-selected')).toBe('false')
248
+ expect(row(wrapper, two.path).attributes('aria-selected')).toBe('true')
249
+ })
250
+
251
+ it('does not undo the first click of a double-click or toggle on keyboard navigation', async () => {
252
+ const wrapper = await setup({ selectionMode: 'single-file' })
253
+ await expand(wrapper)
254
+ await row(wrapper, one.path).trigger('click', { detail: 1 })
255
+ await row(wrapper, one.path).trigger('click', { detail: 2 })
256
+ expect(row(wrapper, one.path).attributes('aria-selected')).toBe('true')
257
+ await row(wrapper, one.path).trigger('keydown', { key: 'ArrowDown' })
258
+ await row(wrapper, two.path).trigger('keydown', { key: 'End' })
259
+ expect(row(wrapper, two.path).attributes('aria-selected')).toBe('true')
260
+ })
261
+
262
+ it('offers only server and local files and has a text-only confirm action', async () => {
263
+ const wrapper = await setup({ sources: ['server', 'localFile'] })
264
+ expect(
265
+ wrapper
266
+ .findAll('[aria-label="Data source"] button')
267
+ .map((button) => button.attributes('aria-label')),
268
+ ).toEqual(['Server', 'Local files'])
269
+ expect(confirm(wrapper).text()).toBe('Use selection')
270
+ expect(wrapper.get('.mint-picker__receipt > span').text()).toBe('No selection')
271
+ await wrapper.get('[aria-label="Local files"]').trigger('click')
272
+ await flushPromises()
273
+ expect(wrapper.get('[aria-label="Local files"]').attributes('aria-pressed')).toBe('true')
274
+ expect(wrapper.find('[aria-label="Absolute path"]').exists()).toBe(false)
275
+ expect(wrapper.text()).toContain('Choose files…')
276
+ })
277
+
278
+ it('does not render sources the host did not supply', async () => {
279
+ const wrapper = await setup({ sources: ['server'] })
280
+ expect(wrapper.find('[aria-label="Local files"]').exists()).toBe(false)
281
+ expect(wrapper.find('[aria-label="This machine"]').exists()).toBe(false)
282
+ })
283
+
284
+ it('retries a failed expanded level and blocks confirmation when validation fails', async () => {
285
+ const data = adapter()
286
+ vi.mocked(data.listChildren).mockRejectedValueOnce(new Error('Share unavailable'))
287
+ const wrapper = await setup({ adapter: data })
288
+ await row(wrapper, folder.path).trigger('click')
289
+ await flushPromises()
290
+ expect(wrapper.text()).toContain('Share unavailable')
291
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
292
+ await wrapper.get('.mint-picker__preview-error button').trigger('click')
293
+ await flushPromises()
294
+ expect(confirm(wrapper).attributes('disabled')).toBeUndefined()
295
+ })
296
+
297
+ it('keyboard disclosure never selects and Escape cancels', async () => {
298
+ const wrapper = await setup()
299
+ await row(wrapper, folder.path).trigger('keydown', { key: 'ArrowRight' })
300
+ await flushPromises()
301
+ expect(row(wrapper, folder.path).attributes('aria-expanded')).toBe('true')
302
+ expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('false')
303
+ await row(wrapper, folder.path).trigger('keydown', { key: 'Escape' })
304
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
305
+ })
306
+ })
307
+
308
+ describe('local metadata and capability validation', () => {
309
+ it('keeps the original File objects without reading bytes', async () => {
310
+ const file = new File(['data'], 'run.mzML.gz')
311
+ const read = vi.spyOn(file, 'arrayBuffer')
312
+ const data = localFilesAdapter([file])
313
+ const nodes = await data.listRoot()
314
+ expect(nodes[0]).toMatchObject({ kind: 'file', format: 'mzML.gz', file })
315
+ expect(read).not.toHaveBeenCalled()
316
+ expect(pickerExclusion(nodes[0]!, { formats: ['.MZML.GZ'] })).toBeNull()
317
+ expect(pickerExclusion(nodes[0]!, { formats: ['raw'] })?.reason).toBe('unsupported')
318
+ expect(pickerExclusion(nodes[0]!, { formats: ['mzML.gz'], maxBytes: 1 })?.reason).toBe(
319
+ 'tooLarge',
320
+ )
321
+ })
322
+ it('preserves duplicate plain file names as distinct browser selections', async () => {
323
+ const files = [new File(['a'], 'run.raw'), new File(['b'], 'run.raw')]
324
+ const nodes = await localFilesAdapter(files).listRoot()
325
+ expect(new Set(nodes.map((node) => node.path)).size).toBe(2)
326
+ expect(nodes.map((node) => node.kind === 'file' && node.file)).toEqual(files)
327
+ })
328
+ })
@@ -0,0 +1,305 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
2
+
3
+ const mockGet = vi.fn()
4
+ vi.mock('../../composables/useApi', () => ({
5
+ useApi: () => ({ get: mockGet }),
6
+ }))
7
+
8
+ import { effectScope, nextTick, ref } from 'vue'
9
+
10
+ import { useFileBrowser } from '../../composables/useFileBrowser'
11
+
12
+ const MOUNTS = {
13
+ mounts: [
14
+ { id: 'seq', label: '/mnt/sequencer-01', available: false },
15
+ { id: 'qe', label: '/mnt/instrument-qe', available: true },
16
+ ],
17
+ }
18
+
19
+ const LISTING = {
20
+ mount_id: 'qe',
21
+ path: 'raw',
22
+ parent: '',
23
+ breadcrumbs: [{ label: '/mnt/instrument-qe', path: '' }, { label: 'raw', path: 'raw' }],
24
+ entries: [
25
+ { name: 'blanks', path: 'raw/blanks', kind: 'directory', child_count: 6 },
26
+ { name: 'a.raw', path: 'raw/a.raw', kind: 'file', size_bytes: 10, extension: '.raw' },
27
+ ],
28
+ total_count: 2,
29
+ match_count: 1,
30
+ }
31
+
32
+ beforeEach(() => {
33
+ mockGet.mockReset()
34
+ })
35
+
36
+ describe('useFileBrowser', () => {
37
+ it('should load the configured mounts', async () => {
38
+ mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING)
39
+ const browser = useFileBrowser()
40
+ await browser.init()
41
+
42
+ expect(browser.mounts.value.map(m => m.id)).toEqual(['seq', 'qe'])
43
+ })
44
+
45
+ // An offline mount must not be the one we open on.
46
+ it('should open the first available mount', async () => {
47
+ mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING)
48
+ const browser = useFileBrowser()
49
+ await browser.init()
50
+
51
+ expect(mockGet.mock.calls[1][0]).toContain('mount_id=qe')
52
+ })
53
+
54
+ // 'nas' is listed after 'qe', so choosing it proves the preference is honoured
55
+ // rather than the first-available fallback happening to win.
56
+ it('should honour an explicit initial mount when it is reachable', async () => {
57
+ mockGet
58
+ .mockResolvedValueOnce({ mounts: [...MOUNTS.mounts, { id: 'nas', label: '/mnt/nas', available: true }] })
59
+ .mockResolvedValueOnce(LISTING)
60
+ const browser = useFileBrowser({ initialMountId: 'nas' })
61
+ await browser.init()
62
+
63
+ expect(mockGet.mock.calls[1][0]).toContain('mount_id=nas')
64
+ })
65
+
66
+ it('should expose the listing entries', async () => {
67
+ mockGet.mockResolvedValue(LISTING)
68
+ const browser = useFileBrowser()
69
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
70
+
71
+ expect(browser.entries.value.map(e => e.name)).toEqual(['blanks', 'a.raw'])
72
+ })
73
+
74
+ it('should expose the breadcrumb trail', async () => {
75
+ mockGet.mockResolvedValue(LISTING)
76
+ const browser = useFileBrowser()
77
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
78
+
79
+ expect(browser.breadcrumbs.value.map(c => c.label)).toEqual(['/mnt/instrument-qe', 'raw'])
80
+ })
81
+
82
+ it('should send the configured type rules', async () => {
83
+ mockGet.mockResolvedValue(LISTING)
84
+ const browser = useFileBrowser({ typeRules: ['.raw', '.mzML'] })
85
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
86
+
87
+ expect(mockGet.mock.calls[0][0]).toContain('type_rules=.raw&type_rules=.mzML')
88
+ })
89
+
90
+ it('should surface a listing failure', async () => {
91
+ mockGet.mockRejectedValue(new Error('Mount is offline'))
92
+ const browser = useFileBrowser()
93
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
94
+
95
+ expect(browser.error.value).toContain('Mount is offline')
96
+ })
97
+
98
+ it('should empty the listing when a request fails', async () => {
99
+ mockGet.mockRejectedValue(new Error('nope'))
100
+ const browser = useFileBrowser()
101
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
102
+
103
+ expect(browser.entries.value).toEqual([])
104
+ })
105
+
106
+ // A slow response for the folder you left must not overwrite the one you are in.
107
+ it('should ignore a superseded listing response', async () => {
108
+ let resolveSlow: (value: unknown) => void = () => {}
109
+ const slow = new Promise(resolve => { resolveSlow = resolve })
110
+ mockGet.mockReturnValueOnce(slow).mockResolvedValueOnce(LISTING)
111
+
112
+ const browser = useFileBrowser()
113
+ const first = browser.navigate({ mount_id: 'qe', path: 'stale' })
114
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
115
+ resolveSlow({ ...LISTING, path: 'stale', entries: [] })
116
+ await first
117
+
118
+ expect(browser.path.value).toBe('raw')
119
+ })
120
+
121
+ it('should take the current folder into the selection', async () => {
122
+ mockGet.mockResolvedValue(LISTING)
123
+ const browser = useFileBrowser()
124
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
125
+ browser.selectCurrentFolder()
126
+
127
+ expect(browser.selected.value).toMatchObject([{ mount_id: 'qe', path: 'raw', kind: 'directory' }])
128
+ })
129
+
130
+ it('should not take the same folder twice', async () => {
131
+ mockGet.mockResolvedValue(LISTING)
132
+ const browser = useFileBrowser()
133
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
134
+ browser.selectCurrentFolder()
135
+ browser.selectCurrentFolder()
136
+
137
+ expect(browser.selected.value).toHaveLength(1)
138
+ })
139
+
140
+ it('should clear the selection', async () => {
141
+ mockGet.mockResolvedValue(LISTING)
142
+ const browser = useFileBrowser()
143
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
144
+ browser.selectCurrentFolder()
145
+ browser.clear()
146
+
147
+ expect(browser.selected.value).toEqual([])
148
+ })
149
+
150
+ it('should report the unfiltered total count', async () => {
151
+ mockGet.mockResolvedValue(LISTING)
152
+ const browser = useFileBrowser()
153
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
154
+
155
+ expect(browser.totalCount.value).toBe(2)
156
+ })
157
+
158
+ it('should not be loading once a request settles', async () => {
159
+ mockGet.mockResolvedValue(LISTING)
160
+ const browser = useFileBrowser()
161
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
162
+
163
+ expect(browser.isLoading.value).toBe(false)
164
+ })
165
+ })
166
+
167
+ describe('useFileBrowser review fixes', () => {
168
+ it('should skip an offline mount even when named as the initial one', async () => {
169
+ mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING)
170
+ const browser = useFileBrowser({ initialMountId: 'seq' })
171
+ await browser.init()
172
+
173
+ expect(mockGet.mock.calls[1][0]).toContain('mount_id=qe')
174
+ })
175
+
176
+ // No configured mounts means the routes are absent, not that browsing failed.
177
+ it('should treat a 404 mount list as no mounts configured', async () => {
178
+ mockGet.mockRejectedValue({ response: { status: 404 } })
179
+ const browser = useFileBrowser()
180
+ await browser.init()
181
+
182
+ expect(browser.error.value).toBeNull()
183
+ })
184
+
185
+ it('should leave the mount list empty when none are configured', async () => {
186
+ mockGet.mockRejectedValue({ response: { status: 404 } })
187
+ const browser = useFileBrowser()
188
+ await browser.init()
189
+
190
+ expect(browser.mounts.value).toEqual([])
191
+ })
192
+
193
+ it('should surface the API error detail rather than the status code', async () => {
194
+ mockGet.mockRejectedValue({
195
+ response: { status: 404, data: { detail: "Mount 'nas' is not available" } },
196
+ message: 'Request failed with status code 404',
197
+ })
198
+ const browser = useFileBrowser()
199
+ await browser.navigate({ mount_id: 'nas', path: '' })
200
+
201
+ expect(browser.error.value).toBe("Mount 'nas' is not available")
202
+ })
203
+
204
+ it('should refetch when the sort changes', async () => {
205
+ mockGet.mockResolvedValue(LISTING)
206
+ const browser = useFileBrowser()
207
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
208
+ const before = mockGet.mock.calls.length
209
+ browser.sort.value = 'size'
210
+ await new Promise(resolve => setTimeout(resolve, 0))
211
+
212
+ expect(mockGet.mock.calls.length).toBeGreaterThan(before)
213
+ })
214
+
215
+ it('should refetch when the search changes', async () => {
216
+ mockGet.mockResolvedValue(LISTING)
217
+ const browser = useFileBrowser()
218
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
219
+ const before = mockGet.mock.calls.length
220
+ browser.search.value = 'hela'
221
+ await new Promise(resolve => setTimeout(resolve, 400))
222
+
223
+ expect(mockGet.mock.calls.length).toBeGreaterThan(before)
224
+ })
225
+
226
+ it('should expose how many rows the listing carries', async () => {
227
+ mockGet.mockResolvedValue({ ...LISTING, shown_count: 2 })
228
+ const browser = useFileBrowser()
229
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
230
+
231
+ expect(browser.shownCount.value).toBe(2)
232
+ })
233
+
234
+ it('should expose a truncated listing', async () => {
235
+ mockGet.mockResolvedValue({ ...LISTING, truncated: true })
236
+ const browser = useFileBrowser()
237
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
238
+
239
+ expect(browser.truncated.value).toBe(true)
240
+ })
241
+ })
242
+
243
+
244
+ describe('useFileBrowser selection and request lifecycle', () => {
245
+ it('never selects an uninitialized or failed directory', async () => {
246
+ const browser = useFileBrowser()
247
+ browser.selectCurrentFolder()
248
+ expect(browser.selected.value).toEqual([])
249
+ mockGet.mockRejectedValueOnce(new Error('offline'))
250
+ await browser.navigate({ mount_id: 'nas', path: 'runs' })
251
+ browser.selectCurrentFolder()
252
+ expect(browser.selected.value).toEqual([])
253
+ })
254
+
255
+ it('retries the failed destination rather than returning to the previous folder', async () => {
256
+ mockGet.mockResolvedValueOnce(LISTING).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ ...LISTING, path: 'new' })
257
+ const browser = useFileBrowser()
258
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
259
+ await browser.navigate({ mount_id: 'qe', path: 'new' })
260
+ await browser.refresh()
261
+ expect(mockGet.mock.calls.at(-1)?.[0]).toContain('path=new')
262
+ })
263
+
264
+ it('does not let obsolete requests keep the current directory loading', async () => {
265
+ let finish!: (value: unknown) => void
266
+ mockGet.mockReturnValueOnce(new Promise(resolve => { finish = resolve })).mockResolvedValueOnce(LISTING)
267
+ const browser = useFileBrowser()
268
+ const old = browser.navigate({ mount_id: 'qe', path: 'old' })
269
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
270
+ expect(browser.isLoading.value).toBe(false)
271
+ finish(LISTING)
272
+ await old
273
+ })
274
+
275
+ it('refreshes when reactive type rules change', async () => {
276
+ mockGet.mockResolvedValue(LISTING)
277
+ const typeRules = ref(['.raw'])
278
+ const scope = effectScope()
279
+ const browser = scope.run(() => useFileBrowser({ typeRules }))!
280
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
281
+ typeRules.value = ['.mzML']
282
+ await nextTick()
283
+ expect(mockGet.mock.calls.at(-1)?.[0]).toContain('type_rules=.mzML')
284
+ scope.stop()
285
+ })
286
+
287
+ it('disposes a pending search without issuing another request', async () => {
288
+ vi.useFakeTimers()
289
+ const scope = effectScope()
290
+ try {
291
+ mockGet.mockResolvedValue(LISTING)
292
+ const browser = scope.run(() => useFileBrowser())!
293
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
294
+ browser.search.value = 'abc'
295
+ await nextTick()
296
+ const calls = mockGet.mock.calls.length
297
+ scope.stop()
298
+ await vi.advanceTimersByTimeAsync(300)
299
+ expect(mockGet).toHaveBeenCalledTimes(calls)
300
+ } finally {
301
+ scope.stop()
302
+ vi.useRealTimers()
303
+ }
304
+ })
305
+ })