import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import FilePicker from '../../components/FilePicker.vue' import FileUploader from '../../components/FileUploader.vue' import BaseModal from '../../components/BaseModal.vue' import { formatModified } from '../../components/internal/filePickerPresentation' import type { PickerAdapter, PickerNode, PickerSelection } from '../../types/filePicker' import { localFilesAdapter } from '../../composables/filePicker/localFiles' import { pickerExclusion } from '../../composables/filePicker/validation' enableAutoUnmount(afterEach) afterEach(() => vi.useRealTimers()) const folder: PickerNode = { kind: 'folder', path: 'opaque:a', name: 'Tissue liver', fileCount: 3, bytes: 60, formats: { raw: 2, txt: 1 }, } const other: PickerNode = { kind: 'folder', path: 'opaque:b', name: 'Tissue kidney' } const one: PickerNode = { kind: 'file', path: 'file:one', parentPath: folder.path, name: 'sample-01.raw', bytes: 20, format: 'raw', } const two: PickerNode = { kind: 'file', path: 'file:two', parentPath: folder.path, name: 'sample-02.raw', bytes: 20, format: 'raw', } const unsupported: PickerNode = { kind: 'file', path: 'file:notes', parentPath: folder.path, name: 'notes.txt', bytes: 20, format: 'txt', } function adapter(): PickerAdapter { return { listRoot: vi.fn(async () => [folder, other]), listChildren: vi.fn(async (path) => (path === folder.path ? [one, two, unsupported] : [])), search: vi.fn(async (query) => [folder, one, two, unsupported].filter((node) => node.name.toLowerCase().includes(query.toLowerCase()), ), ), } } async function setup(props: Record = {}) { const wrapper = mount(FilePicker, { props: { open: true, adapter: adapter(), capabilities: { server: { formats: ['raw'] } }, view: 'tree', ...props, }, global: { stubs: { teleport: true } }, }) await flushPromises() return wrapper } function row(wrapper: Awaited>, path: string) { return wrapper .findAll('[role="treeitem"],[role="option"]') .find((item) => item.attributes('data-path') === path)! } function confirm(wrapper: Awaited>) { return wrapper.findAll('.mint-picker__actions button').at(-1)! } function sources(wrapper: Awaited>) { return wrapper.findAll('[aria-label="Data source"] button') } async function expand(wrapper: Awaited>) { await wrapper.get('[aria-label="Expand Tissue liver"]').trigger('click') await flushPromises() } describe('FilePicker', () => { it('should focus the search input when / is pressed in the source panel', async () => { const wrapper = await setup() wrapper.element.ownerDocument.body.appendChild(wrapper.element) await wrapper.get('.mint-picker__source-panel').trigger('keydown', { key: '/' }) expect(document.activeElement).toBe(wrapper.get('.mint-picker__search input').element) }) it('should focus the search input when / is pressed on the source switcher', async () => { const wrapper = await setup({ sources: ['server', 'localFile'] }) wrapper.element.ownerDocument.body.appendChild(wrapper.element) await sources(wrapper)[0]!.trigger('keydown', { key: '/' }) expect(document.activeElement).toBe(wrapper.get('.mint-picker__search input').element) }) it('selects a folder without navigating, resolves metadata and excludes unsupported files', async () => { const wrapper = await setup() await row(wrapper, folder.path).trigger('click') await flushPromises() expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true') expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(2) await confirm(wrapper).trigger('click') const result = wrapper.emitted('select')?.[0]?.[0] as Extract< PickerSelection, { source: 'server' } > expect(result.folders).toEqual([folder.path]) expect(result.files.map((file) => file.path)).toEqual([one.path, two.path]) expect(result.excluded).toEqual([ { path: unsupported.path, name: unsupported.name, reason: 'unsupported' }, ]) }) it('discloses without selecting and double-clicks to navigate', async () => { const wrapper = await setup() await expand(wrapper) expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('false') expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(5) await row(wrapper, folder.path).trigger('dblclick') await flushPromises() expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(3) expect(wrapper.get('[aria-label="Breadcrumb"]').text()).toContain(folder.name) }) it('drops an inherited file with Ctrl and restores it with another Ctrl click', async () => { const wrapper = await setup() await row(wrapper, folder.path).trigger('click') await expand(wrapper) await row(wrapper, one.path).trigger('click', { ctrlKey: true }) expect(row(wrapper, one.path).attributes('aria-selected')).toBe('false') expect(row(wrapper, folder.path).text()).toContain('1 / 3') await confirm(wrapper).trigger('click') const result = wrapper.emitted('select')?.[0]?.[0] as Extract< PickerSelection, { source: 'server' } > expect(result.excluded).toContainEqual({ path: one.path, name: one.name, reason: 'deselected' }) await row(wrapper, one.path).trigger('click', { metaKey: true }) expect(row(wrapper, one.path).attributes('aria-selected')).toBe('true') }) it('keeps unsupported files inert for click, double-click and keyboard', async () => { const wrapper = await setup({ selectionMode: 'single-file' }) await expand(wrapper) const unavailable = row(wrapper, unsupported.path) expect(unavailable.attributes('aria-disabled')).toBe('true') expect(unavailable.attributes('title')).toContain('not supported') await unavailable.trigger('click') await unavailable.trigger('dblclick') await unavailable.trigger('keydown', { key: 'Enter' }) expect(wrapper.emitted('select')).toBeUndefined() expect(confirm(wrapper).attributes('disabled')).toBeDefined() }) it('single-file mode replaces selection and folder mode leaves files inert', async () => { const wrapper = await setup({ selectionMode: 'single-file' }) await expand(wrapper) await row(wrapper, one.path).trigger('click') await row(wrapper, two.path).trigger('click', { metaKey: true }) await flushPromises() await confirm(wrapper).trigger('click') expect( (wrapper.emitted('select')?.[0]?.[0] as { files: PickerNode[] }).files.map( (file) => file.path, ), ).toEqual([two.path]) const folders = await setup({ selectionMode: 'folder' }) await expand(folders) await row(folders, one.path).trigger('click') expect(row(folders, one.path).attributes('aria-selected')).toBe('false') }) it('uses Shift to select the file range, excluding unsupported entries', async () => { const wrapper = await setup({ selectionMode: 'multi-file' }) await expand(wrapper) await row(wrapper, one.path).trigger('click') await row(wrapper, two.path).trigger('click', { shiftKey: true }) await flushPromises() await confirm(wrapper).trigger('click') expect((wrapper.emitted('select')?.[0]?.[0] as { files: PickerNode[] }).files).toHaveLength(2) }) it('preserves selected paths while searching and passes the recursive scope', async () => { vi.useFakeTimers() const data = adapter() const wrapper = await setup({ adapter: data }) await row(wrapper, folder.path).trigger('click') await flushPromises() await wrapper.get('[aria-label="Search folders and files"]').setValue('sample') await wrapper.get('.mint-picker__scope').trigger('click') await vi.advanceTimersByTimeAsync(250) expect(data.search).toHaveBeenCalledWith( 'sample', { path: '', recursive: true }, expect.anything(), ) expect(confirm(wrapper).attributes('disabled')).toBeUndefined() await confirm(wrapper).trigger('click') expect((wrapper.emitted('select')?.[0]?.[0] as { folders: string[] }).folders).toEqual([ folder.path, ]) }) it('cancel emits no selection and reopening restores the initial confirmed selection', async () => { const initial: PickerSelection = { source: 'server', folders: [folder.path], files: [], excluded: [], } const wrapper = await setup({ initialSelection: initial }) await row(wrapper, other.path).trigger('click') await wrapper.get('[aria-label="Close modal"]').trigger('click') expect(wrapper.emitted('cancel')).toHaveLength(1) expect(wrapper.emitted('select')).toBeUndefined() await wrapper.setProps({ open: false }) await wrapper.setProps({ open: true }) await flushPromises() expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true') expect(row(wrapper, other.path).attributes('aria-selected')).toBe('false') }) it('accumulates plain clicks in multi modes and Shift still extends a range', async () => { const wrapper = await setup() await expand(wrapper) await row(wrapper, one.path).trigger('click') await row(wrapper, two.path).trigger('click') expect(row(wrapper, one.path).attributes('aria-selected')).toBe('true') expect(row(wrapper, two.path).attributes('aria-selected')).toBe('true') await row(wrapper, two.path).trigger('click') expect(row(wrapper, two.path).attributes('aria-selected')).toBe('false') await row(wrapper, folder.path).trigger('click', { shiftKey: true }) expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true') expect(row(wrapper, two.path).attributes('aria-selected')).toBe('true') }) it('toggles a selected folder off with a second plain click', async () => { const wrapper = await setup() await row(wrapper, folder.path).trigger('click') await flushPromises() await row(wrapper, folder.path).trigger('click') await flushPromises() expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('false') expect(confirm(wrapper).attributes('disabled')).toBeDefined() }) it('toggles inherited files off and on without a modifier or losing the folder', async () => { const wrapper = await setup() await row(wrapper, folder.path).trigger('click') await expand(wrapper) await row(wrapper, one.path).trigger('click') expect(row(wrapper, one.path).attributes('aria-selected')).toBe('false') expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true') await row(wrapper, one.path).trigger('click') expect(row(wrapper, one.path).attributes('aria-selected')).toBe('true') expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('true') }) it('toggles a single file off but Enter still confirms a selected file', async () => { const wrapper = await setup({ selectionMode: 'single-file' }) await expand(wrapper) await row(wrapper, one.path).trigger('click') await row(wrapper, one.path).trigger('click') await flushPromises() expect(confirm(wrapper).attributes('disabled')).toBeDefined() await row(wrapper, one.path).trigger('click') await row(wrapper, one.path).trigger('keydown', { key: 'Enter' }) await flushPromises() expect(wrapper.emitted('select')).toHaveLength(1) }) it('deselects one explicit file without losing the other selected files', async () => { const wrapper = await setup({ selectionMode: 'multi-file' }) await expand(wrapper) await row(wrapper, one.path).trigger('click') await row(wrapper, two.path).trigger('click', { ctrlKey: true }) await row(wrapper, one.path).trigger('click') expect(row(wrapper, one.path).attributes('aria-selected')).toBe('false') expect(row(wrapper, two.path).attributes('aria-selected')).toBe('true') }) it('does not undo the first click of a double-click or toggle on keyboard navigation', async () => { const wrapper = await setup({ selectionMode: 'single-file' }) await expand(wrapper) await row(wrapper, one.path).trigger('click', { detail: 1 }) await row(wrapper, one.path).trigger('click', { detail: 2 }) expect(row(wrapper, one.path).attributes('aria-selected')).toBe('true') await row(wrapper, one.path).trigger('keydown', { key: 'ArrowDown' }) await row(wrapper, two.path).trigger('keydown', { key: 'End' }) expect(row(wrapper, two.path).attributes('aria-selected')).toBe('true') }) it('offers only server and local files and has a text-only confirm action', async () => { const wrapper = await setup({ sources: ['server', 'localFile'] }) expect(sources(wrapper).map((button) => button.text())).toEqual(['Server', 'Local files']) expect(confirm(wrapper).text()).toBe('Use selection') expect(wrapper.get('.mint-picker__summary').text()).toBe('No selection') await sources(wrapper)[1]!.trigger('click') await flushPromises() expect(sources(wrapper)[1]!.attributes('aria-checked')).toBe('true') expect(wrapper.find('[aria-label="Absolute path"]').exists()).toBe(false) expect(wrapper.text()).toContain('Drop files or a folder') }) it.each(['single-file', 'multi-file', 'folder', 'folder+files'] as const)( 'uses one upload panel without a lower workspace and preserves %s selection limits', async (selectionMode) => { const wrapper = await setup({ sources: ['localFile'], selectionMode }) const uploaders = wrapper.findAllComponents(FileUploader) expect(uploaders.map((uploader) => uploader.props('mode'))).toEqual( selectionMode === 'single-file' ? ['file'] : selectionMode === 'folder' ? ['folder'] : ['file+folder'], ) expect(wrapper.find('.mint-picker__workspace').exists()).toBe(false) expect(wrapper.find('.mint-file-uploader__actions').exists()).toBe(false) expect(wrapper.text()).not.toContain('Files stay in this browser.') expect(wrapper.find('.mint-picker__pathbar').exists()).toBe(false) expect(wrapper.find('[aria-label="Search folders and files"]').exists()).toBe(false) const files = [new File(['a'], 'one.raw'), new File(['b'], 'two.raw')] const input = uploaders[0]!.get('input[type="file"]') if (selectionMode === 'folder') { files.forEach((file) => Object.defineProperty(file, 'webkitRelativePath', { value: `batch/${file.name}` }), ) } Object.defineProperty(input.element, 'files', { value: files }) await input.trigger('change') await flushPromises() expect(wrapper.find('.mint-file-uploader__list').exists()).toBe(false) expect(wrapper.find('.mint-file-uploader__folder-summary').exists()).toBe(false) await confirm(wrapper).trigger('click') const result = wrapper.emitted('select')?.[0]?.[0] as Extract< PickerSelection, { source: 'localFile' } > expect(result.files).toEqual(selectionMode === 'single-file' ? [files[0]] : files) expect(result.files[0]).toBe(files[0]) }, ) it.each(['folder+files', 'multi-file'] as const)('accepts dropped directories in %s mode and validates without a workspace', async (selectionMode) => { const wrapper = await setup({ sources: ['localFile'], selectionMode, capabilities: { localFile: { formats: ['mzML.gz'], maxBytes: 10 } }, }) const valid = new File(['data'], 'run.mzML.gz') const unsupported = new File(['notes'], 'notes.txt') const oversized = new File(['too much data'], 'large.mzML.gz') const directory = (name: string, entries: unknown[]) => ({ name, isDirectory: true, createReader: () => { let pending = entries return { readEntries: (resolve: (entries: unknown[]) => void) => { resolve(pending) pending = [] }, } }, }) const nested = directory('batch', [ directory('nested', [valid, unsupported, oversized].map((file) => ({ name: file.name, isFile: true, file: (resolve: (file: File) => void) => resolve(file), }))), ]) const uploader = wrapper.getComponent(FileUploader) await uploader.get('[role="button"]').trigger('drop', { dataTransfer: { items: [{ webkitGetAsEntry: () => nested }] }, }) await flushPromises() expect(wrapper.get('.mint-picker__receipt').text()).toContain('1 file') if (selectionMode === 'folder+files') expect(wrapper.text()).toContain('2 skipped') expect(wrapper.find('.mint-picker__workspace').exists()).toBe(false) await confirm(wrapper).trigger('click') const result = wrapper.emitted('select')?.[0]?.[0] as Extract< PickerSelection, { source: 'localFile' } > expect(result.files).toEqual([valid]) expect(result.files[0]).toBe(valid) await wrapper.setProps({ open: false }) await wrapper.setProps({ open: true }) await flushPromises() expect(wrapper.get('.mint-picker__receipt').text()).toContain('1 file') expect(confirm(wrapper).attributes('disabled')).toBeUndefined() }) it.each(['entry', 'file'] as const)( 'captures dropped %s handles before the drag data becomes protected', async (secondHandle) => { const wrapper = await setup({ sources: ['localFile'], selectionMode: 'multi-file' }) const files = [new File(['a'], 'one.raw'), new File(['b'], 'two.raw')] let protectedMode = false const secondEntry = { isFile: true, file: (resolve: (file: File) => void) => resolve(files[1]!), } await wrapper.getComponent(FileUploader).get('[role="button"]').trigger('drop', { dataTransfer: { items: [ { webkitGetAsEntry: () => ({ isFile: true, file: (resolve: (file: File) => void) => queueMicrotask(() => { protectedMode = true resolve(files[0]!) }), }) }, { webkitGetAsEntry: () => !protectedMode && secondHandle === 'entry' ? secondEntry : null, getAsFile: () => !protectedMode && secondHandle === 'file' ? files[1] : null, }, ], }, }) await flushPromises() await confirm(wrapper).trigger('click') const result = wrapper.emitted('select')?.[0]?.[0] as Extract< PickerSelection, { source: 'localFile' } > expect(result.files).toHaveLength(2) expect(result.files[0]).toBe(files[0]) expect(result.files[1]).toBe(files[1]) }, ) it('shows folder drop errors without a preview panel', async () => { const wrapper = await setup({ sources: ['localFile'] }) await wrapper.getComponent(FileUploader).get('[role="button"]').trigger('drop', { dataTransfer: { items: [{ webkitGetAsEntry: () => ({ name: 'unreadable', isDirectory: true, createReader: () => ({ readEntries: (_resolve: unknown, reject: (error: Error) => void) => reject(new Error('Folder cannot be read')) }), }) }] }, }) await flushPromises() expect(wrapper.get('[role="alert"]').text()).toBe('Folder cannot be read') expect(confirm(wrapper).attributes('disabled')).toBeDefined() }) it('does not render sources the host did not supply', async () => { const wrapper = await setup({ sources: ['server'] }) expect(wrapper.find('[aria-label="Data source"]').exists()).toBe(false) expect(wrapper.find('[aria-label="This machine"]').exists()).toBe(false) }) it('retries a failed expanded level and blocks confirmation when validation fails', async () => { const data = adapter() vi.mocked(data.listChildren).mockRejectedValueOnce(new Error('Share unavailable')) const wrapper = await setup({ adapter: data }) await row(wrapper, folder.path).trigger('click') await flushPromises() expect(wrapper.text()).toContain('Share unavailable') expect(confirm(wrapper).attributes('disabled')).toBeDefined() await wrapper.get('.mint-picker__preview-error button').trigger('click') await flushPromises() expect(confirm(wrapper).attributes('disabled')).toBeUndefined() }) it('keyboard disclosure never selects', async () => { const wrapper = await setup() await row(wrapper, folder.path).trigger('keydown', { key: 'ArrowRight' }) await flushPromises() expect(row(wrapper, folder.path).attributes('aria-expanded')).toBe('true') expect(row(wrapper, folder.path).attributes('aria-selected')).toBe('false') }) }) describe('local metadata and capability validation', () => { it('keeps the original File objects without reading bytes', async () => { const file = new File(['data'], 'run.mzML.gz') const read = vi.spyOn(file, 'arrayBuffer') const data = localFilesAdapter([file]) const nodes = await data.listRoot() expect(nodes[0]).toMatchObject({ kind: 'file', format: 'mzML.gz', file }) expect(read).not.toHaveBeenCalled() expect(pickerExclusion(nodes[0]!, { formats: ['.MZML.GZ'] })).toBeNull() expect(pickerExclusion(nodes[0]!, { formats: ['raw'] })?.reason).toBe('unsupported') expect(pickerExclusion(nodes[0]!, { formats: ['mzML.gz'], maxBytes: 1 })?.reason).toBe( 'tooLarge', ) }) it('preserves duplicate plain file names as distinct browser selections', async () => { const files = [new File(['a'], 'run.raw'), new File(['b'], 'run.raw')] const nodes = await localFilesAdapter(files).listRoot() expect(new Set(nodes.map((node) => node.path)).size).toBe(2) expect(nodes.map((node) => node.kind === 'file' && node.file)).toEqual(files) }) }) describe('local file modification time', () => { it('should expose the browser file lastModified as an ISO timestamp', async () => { const file = new File(['a'], 'run.raw', { lastModified: Date.UTC(2026, 8, 24, 14, 32) }) const [node] = await localFilesAdapter([file]).listRoot() expect(node!.modifiedAt).toBe('2026-09-24T14:32:00.000Z') }) }) const mountA: PickerNode = { kind: 'folder', path: 'mount:a', name: 'Instrument', parentPath: null } const mountB: PickerNode = { kind: 'folder', path: 'mount:b', name: 'Shared', parentPath: null } const offline: PickerNode = { kind: 'folder', path: 'mount:c', name: 'Archive', parentPath: null, unavailableReason: 'This mount is offline.', } const inA: PickerNode = { kind: 'file', path: 'a/run.raw', name: 'run.raw', format: 'raw', bytes: 20 } const inB: PickerNode = { kind: 'file', path: 'b/qc.raw', name: 'qc.raw', format: 'raw', bytes: 30 } function mountAdapter(): PickerAdapter { return { mounts: true, listRoot: vi.fn(async () => [mountA, mountB, offline]), listChildren: vi.fn(async (path) => (path === mountA.path ? [inA] : path === mountB.path ? [inB] : [])), search: vi.fn(async () => []), } } describe('FilePicker list view', () => { it('should render a flat list without disclosure controls by default', async () => { const wrapper = await setup({ view: undefined }) expect(wrapper.find('[aria-label="Expand Tissue liver"]').exists()).toBe(false) }) it('should expose list rows as listbox options', async () => { const wrapper = await setup({ view: undefined }) expect(wrapper.findAll('[role="listbox"] [role="option"]')).toHaveLength(2) }) it('should enter a folder on double-click', async () => { const wrapper = await setup({ view: undefined }) await row(wrapper, folder.path).trigger('dblclick') await flushPromises() expect(wrapper.findAll('[role="option"]').map((item) => item.attributes('data-path'))).toEqual([ // Undated rows fall back to name order under the default newest-first sort. unsupported.path, one.path, two.path, ]) }) it('should not select a folder opened by double-click', async () => { const wrapper = await setup({ view: undefined }) await row(wrapper, folder.path).trigger('click', { detail: 1 }) await row(wrapper, folder.path).trigger('click', { detail: 2 }) await row(wrapper, folder.path).trigger('dblclick') await flushPromises() expect(wrapper.get('.mint-picker__summary').text()).toBe('No selection') }) it('should keep a folder opened by double-click selected in folder mode', async () => { const wrapper = await setup({ view: undefined, selectionMode: 'folder' }) await row(wrapper, folder.path).trigger('click', { detail: 1 }) await row(wrapper, folder.path).trigger('click', { detail: 2 }) await row(wrapper, folder.path).trigger('dblclick') await flushPromises() expect(wrapper.get('.mint-picker__summary').text()).not.toBe('No selection') }) it('should not confirm a file on ArrowRight', async () => { const wrapper = await setup({ view: undefined, selectionMode: 'single-file' }) await row(wrapper, folder.path).trigger('dblclick') await flushPromises() await row(wrapper, one.path).trigger('keydown', { key: 'ArrowRight' }) await flushPromises() expect(wrapper.emitted('select')).toBeUndefined() }) it('should render a checkbox on every selectable row in multi modes', async () => { const wrapper = await setup({ view: undefined }) expect(wrapper.findAll('[role="option"] .mint-checkbox')).toHaveLength(2) }) it('should reflect the row selection in its checkbox', async () => { const wrapper = await setup({ view: undefined }) await row(wrapper, folder.path).trigger('click') await flushPromises() expect(row(wrapper, folder.path).find('input[type="checkbox"]').element.checked).toBe(true) }) it('should show a lock instead of a checkbox on an unreadable row', async () => { const locked: PickerNode = { ...other, unavailableReason: 'No read permission' } const data = adapter() vi.mocked(data.listRoot).mockResolvedValue([folder, locked]) const wrapper = await setup({ view: undefined, adapter: data }) expect(row(wrapper, locked.path).find('.mint-picker__lock').exists()).toBe(true) }) it('should name the reason on an unreadable row', async () => { const locked: PickerNode = { ...other, unavailableReason: 'No read permission' } const data = adapter() vi.mocked(data.listRoot).mockResolvedValue([folder, locked]) const wrapper = await setup({ view: undefined, adapter: data }) expect(row(wrapper, locked.path).get('.mint-picker__reason').text()).toBe('No read permission') }) it('should show the modification time in its own column', async () => { const stamped: PickerNode = { ...folder, modifiedAt: '2026-09-24T14:32:00' } const data = adapter() vi.mocked(data.listRoot).mockResolvedValue([stamped]) const wrapper = await setup({ view: undefined, adapter: data }) expect(row(wrapper, stamped.path).get('.mint-picker__cell--time').text()).toBe( formatModified(stamped.modifiedAt), ) }) it('should render in an extra-large BaseModal', async () => { const wrapper = await setup() expect(wrapper.getComponent(BaseModal).props('size')).toBe('xl') }) it('should cancel on Escape', async () => { const wrapper = await setup() document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) expect(wrapper.emitted('cancel')).toHaveLength(1) }) }) describe('FilePicker receipt', () => { it('should summarise the selected files and their total size', async () => { const wrapper = await setup({ view: undefined, selectionMode: 'multi-file' }) await row(wrapper, folder.path).trigger('dblclick') await flushPromises() await row(wrapper, one.path).trigger('click') await row(wrapper, two.path).trigger('click') await flushPromises() expect(wrapper.get('.mint-picker__summary').text()).toBe('2 files selected · 40 B') }) it('should render the footer clear as a text link when files are selected', async () => { const wrapper = await setup() await row(wrapper, folder.path).trigger('click') await flushPromises() expect(wrapper.get('.mint-picker__clear').classes()).toContain('mint-picker__link') }) it('should render the scope toggle at path-bar scale when the source is a server', async () => { const wrapper = await setup() expect(wrapper.get('.mint-picker__scope').classes()).not.toContain('mint-button') }) it('should render the refresh action at path-bar scale when the source is a server', async () => { const wrapper = await setup() expect(wrapper.get('[aria-label="Refresh location"]').classes()).not.toContain('mint-icon-button') }) it('should clear the selection from the footer', async () => { const wrapper = await setup() await row(wrapper, folder.path).trigger('click') await flushPromises() await wrapper.get('.mint-picker__clear').trigger('click') await flushPromises() expect(wrapper.get('.mint-picker__summary').text()).toBe('No selection') }) }) describe('FilePicker mounts', () => { it('should offer each mount as a header source', async () => { const wrapper = await setup({ adapter: mountAdapter() }) expect(sources(wrapper).map((button) => button.text())).toEqual([ 'Instrument', 'Shared', 'Archive', ]) }) it('should disable an offline mount', async () => { const wrapper = await setup({ adapter: mountAdapter() }) expect(sources(wrapper)[2]!.attributes('disabled')).toBeDefined() }) it('should open the first reachable mount', async () => { const wrapper = await setup({ adapter: mountAdapter(), view: undefined }) expect(wrapper.findAll('[role="option"]').map((item) => item.attributes('data-path'))).toEqual([ inA.path, ]) }) it('should list another mount when its source is chosen', async () => { const wrapper = await setup({ adapter: mountAdapter(), view: undefined }) await sources(wrapper)[1]!.trigger('click') await flushPromises() expect(wrapper.findAll('[role="option"]').map((item) => item.attributes('data-path'))).toEqual([ inB.path, ]) }) it('should keep the selection when switching mounts', async () => { const wrapper = await setup({ adapter: mountAdapter(), view: undefined }) await row(wrapper, inA.path).trigger('click') await sources(wrapper)[1]!.trigger('click') await flushPromises() await row(wrapper, inB.path).trigger('click') await flushPromises() expect(wrapper.get('.mint-picker__summary').text()).toBe('2 files selected · 50 B') }) it('should name the previewed file’s own mount as its source after switching mounts', async () => { const wrapper = await setup({ adapter: mountAdapter(), view: undefined }) await row(wrapper, inA.path).trigger('click') await sources(wrapper)[1]!.trigger('click') await flushPromises() expect(wrapper.get('.mint-picker__metadata dd:last-of-type').text()).toBe(mountA.name) }) it('should start the breadcrumb at the mount instead of the mount list', async () => { const wrapper = await setup({ adapter: mountAdapter() }) expect(wrapper.get('[aria-label="Breadcrumb"]').text()).toBe('Instrument') }) it('should reach the mount list from an initial folder inside a mount', async () => { const adapter = mountAdapter() vi.mocked(adapter.listChildren).mockImplementation(async (path) => (path === 'a/sub' ? [inA] : [])) const wrapper = await setup({ adapter, view: undefined, initialPath: 'a/sub' }) await row(wrapper, inA.path).trigger('keydown', { key: 'ArrowLeft' }) await flushPromises() expect(wrapper.findAll('[role="option"]').map((item) => item.attributes('data-path'))).toContain( mountA.path, ) }) it('should mark the containing mount selected for an initial folder inside it', async () => { const adapter = { ...mountAdapter(), mountOf: (path: string) => `mount:${path.split('/')[0]}` } vi.mocked(adapter.listChildren).mockImplementation(async (path) => (path === 'a/sub' ? [inA] : [])) const wrapper = await setup({ adapter, view: undefined, initialPath: 'a/sub' }) expect(sources(wrapper)[0]!.attributes('aria-checked')).toBe('true') }) it('should name the containing mount as the source of a file under an initial folder', async () => { const adapter = { ...mountAdapter(), mountOf: (path: string) => `mount:${path.split('/')[0]}` } vi.mocked(adapter.listChildren).mockImplementation(async (path) => path === 'a/sub' ? [{ ...inA, parentPath: 'a/sub' }] : [], ) const wrapper = await setup({ adapter, view: undefined, initialPath: 'a/sub' }) await row(wrapper, inA.path).trigger('click') await flushPromises() expect(wrapper.get('.mint-picker__metadata dd:last-of-type').text()).toBe(mountA.name) }) it('should add local files as a source after the mounts', async () => { const wrapper = await setup({ adapter: mountAdapter(), sources: ['server', 'localFile'] }) expect(sources(wrapper).at(-1)!.text()).toBe('Local files') }) }) describe('formatModified', () => { const now = new Date(2026, 8, 25) it('should show month, day and time within the current year', () => { expect(formatModified('2026-09-24T14:32:00', now)).toBe('09-24 14:32') }) it('should show the full date outside the current year', () => { expect(formatModified('2025-01-02T08:00:00', now)).toBe('2025-01-02') }) it('should list the newest folder first when opened', async () => { const wrapper = await setup({ adapter: { ...adapter(), listRoot: vi.fn(async () => [ { ...folder, modifiedAt: '2026-01-01T00:00:00Z' }, { ...other, modifiedAt: '2026-09-29T00:00:00Z' }, ]), }, }) expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([ other.path, folder.path, ]) }) it('should reorder rows by name when Name A–Z is chosen from the sort menu', async () => { const wrapper = await setup({ defaultSort: 'name-desc' }) await wrapper.get('.mint-picker__sort').trigger('click') await wrapper.findAll('[role="menuitemradio"]').find((item) => item.text() === 'Name A–Z')!.trigger('click') expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([ other.path, folder.path, ]) }) it('should label the sort trigger with the active order', async () => { const wrapper = await setup() expect(wrapper.get('.mint-picker__sort').text()).toContain('Newest first') }) it('should tell the user when search results are partial', async () => { vi.useFakeTimers() const base = adapter() const wrapper = await setup({ adapter: { ...base, search: vi.fn(async () => ({ nodes: [one], truncated: true })) }, }) await wrapper.get('[aria-label="Search folders and files"]').setValue('sample') await vi.advanceTimersByTimeAsync(400) await flushPromises() expect(wrapper.get('.mint-picker__truncated').text()).toContain('first 1') }) it('should still list the partial search results', async () => { vi.useFakeTimers() const wrapper = await setup({ adapter: { ...adapter(), search: vi.fn(async () => ({ nodes: [one], truncated: true })) }, }) await wrapper.get('[aria-label="Search folders and files"]').setValue('sample') await vi.advanceTimersByTimeAsync(400) await flushPromises() expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([one.path]) }) it('should say the search stopped early when a truncated search found nothing', async () => { vi.useFakeTimers() const wrapper = await setup({ adapter: { ...adapter(), search: vi.fn(async () => ({ nodes: [], truncated: true })) }, }) await wrapper.get('[aria-label="Search folders and files"]').setValue('sample') await vi.advanceTimersByTimeAsync(400) await flushPromises() expect(wrapper.get('.mint-picker__empty').text()).toContain('stopped') }) })