import { describe, expect, it, vi, beforeEach } from 'vitest' const mockGet = vi.fn() vi.mock('../../composables/useApi', () => ({ useApi: () => ({ get: mockGet }), })) import { effectScope, nextTick, ref } from 'vue' import { useFileBrowser } from '../../composables/useFileBrowser' const MOUNTS = { mounts: [ { id: 'seq', label: '/mnt/sequencer-01', available: false }, { id: 'qe', label: '/mnt/instrument-qe', available: true }, ], } const LISTING = { mount_id: 'qe', path: 'raw', parent: '', breadcrumbs: [{ label: '/mnt/instrument-qe', path: '' }, { label: 'raw', path: 'raw' }], entries: [ { name: 'blanks', path: 'raw/blanks', kind: 'directory', child_count: 6 }, { name: 'a.raw', path: 'raw/a.raw', kind: 'file', size_bytes: 10, extension: '.raw' }, ], total_count: 2, match_count: 1, } beforeEach(() => { mockGet.mockReset() }) describe('useFileBrowser', () => { it('should load the configured mounts', async () => { mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING) const browser = useFileBrowser() await browser.init() expect(browser.mounts.value.map(m => m.id)).toEqual(['seq', 'qe']) }) // An offline mount must not be the one we open on. it('should open the first available mount', async () => { mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING) const browser = useFileBrowser() await browser.init() expect(mockGet.mock.calls[1][0]).toContain('mount_id=qe') }) // 'nas' is listed after 'qe', so choosing it proves the preference is honoured // rather than the first-available fallback happening to win. it('should honour an explicit initial mount when it is reachable', async () => { mockGet .mockResolvedValueOnce({ mounts: [...MOUNTS.mounts, { id: 'nas', label: '/mnt/nas', available: true }] }) .mockResolvedValueOnce(LISTING) const browser = useFileBrowser({ initialMountId: 'nas' }) await browser.init() expect(mockGet.mock.calls[1][0]).toContain('mount_id=nas') }) it('should expose the listing entries', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.entries.value.map(e => e.name)).toEqual(['blanks', 'a.raw']) }) it('should expose the breadcrumb trail', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.breadcrumbs.value.map(c => c.label)).toEqual(['/mnt/instrument-qe', 'raw']) }) it('should send the configured type rules', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser({ typeRules: ['.raw', '.mzML'] }) await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(mockGet.mock.calls[0][0]).toContain('type_rules=.raw&type_rules=.mzML') }) it('should surface a listing failure', async () => { mockGet.mockRejectedValue(new Error('Mount is offline')) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.error.value).toContain('Mount is offline') }) it('should empty the listing when a request fails', async () => { mockGet.mockRejectedValue(new Error('nope')) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.entries.value).toEqual([]) }) // A slow response for the folder you left must not overwrite the one you are in. it('should ignore a superseded listing response', async () => { let resolveSlow: (value: unknown) => void = () => {} const slow = new Promise(resolve => { resolveSlow = resolve }) mockGet.mockReturnValueOnce(slow).mockResolvedValueOnce(LISTING) const browser = useFileBrowser() const first = browser.navigate({ mount_id: 'qe', path: 'stale' }) await browser.navigate({ mount_id: 'qe', path: 'raw' }) resolveSlow({ ...LISTING, path: 'stale', entries: [] }) await first expect(browser.path.value).toBe('raw') }) it('should take the current folder into the selection', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) browser.selectCurrentFolder() expect(browser.selected.value).toMatchObject([{ mount_id: 'qe', path: 'raw', kind: 'directory' }]) }) it('should not take the same folder twice', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) browser.selectCurrentFolder() browser.selectCurrentFolder() expect(browser.selected.value).toHaveLength(1) }) it('should clear the selection', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) browser.selectCurrentFolder() browser.clear() expect(browser.selected.value).toEqual([]) }) it('should report the unfiltered total count', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.totalCount.value).toBe(2) }) it('should not be loading once a request settles', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.isLoading.value).toBe(false) }) }) describe('useFileBrowser review fixes', () => { it('should skip an offline mount even when named as the initial one', async () => { mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING) const browser = useFileBrowser({ initialMountId: 'seq' }) await browser.init() expect(mockGet.mock.calls[1][0]).toContain('mount_id=qe') }) // No configured mounts means the routes are absent, not that browsing failed. it('should treat a 404 mount list as no mounts configured', async () => { mockGet.mockRejectedValue({ response: { status: 404 } }) const browser = useFileBrowser() await browser.init() expect(browser.error.value).toBeNull() }) it('should leave the mount list empty when none are configured', async () => { mockGet.mockRejectedValue({ response: { status: 404 } }) const browser = useFileBrowser() await browser.init() expect(browser.mounts.value).toEqual([]) }) it('should surface the API error detail rather than the status code', async () => { mockGet.mockRejectedValue({ response: { status: 404, data: { detail: "Mount 'nas' is not available" } }, message: 'Request failed with status code 404', }) const browser = useFileBrowser() await browser.navigate({ mount_id: 'nas', path: '' }) expect(browser.error.value).toBe("Mount 'nas' is not available") }) it('should refetch when the sort changes', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) const before = mockGet.mock.calls.length browser.sort.value = 'size' await new Promise(resolve => setTimeout(resolve, 0)) expect(mockGet.mock.calls.length).toBeGreaterThan(before) expect(mockGet.mock.calls.at(-1)?.[0]).not.toContain('refresh=') }) it('should refetch when the search changes', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) const before = mockGet.mock.calls.length browser.search.value = 'hela' await new Promise(resolve => setTimeout(resolve, 400)) expect(mockGet.mock.calls.length).toBeGreaterThan(before) expect(mockGet.mock.calls.at(-1)?.[0]).not.toContain('refresh=') }) it('should expose how many rows the listing carries', async () => { mockGet.mockResolvedValue({ ...LISTING, shown_count: 2 }) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.shownCount.value).toBe(2) }) it('should expose a truncated listing', async () => { mockGet.mockResolvedValue({ ...LISTING, truncated: true }) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.truncated.value).toBe(true) }) }) describe('useFileBrowser selection and request lifecycle', () => { it('bypasses the server directory cache only for an explicit refresh', async () => { mockGet.mockResolvedValue(LISTING) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(mockGet.mock.calls.at(-1)?.[0]).not.toContain('refresh=') await browser.refresh() expect(mockGet.mock.calls.at(-1)?.[0]).toContain('&refresh=true') }) it('never selects an uninitialized or failed directory', async () => { const browser = useFileBrowser() browser.selectCurrentFolder() expect(browser.selected.value).toEqual([]) mockGet.mockRejectedValueOnce(new Error('offline')) await browser.navigate({ mount_id: 'nas', path: 'runs' }) browser.selectCurrentFolder() expect(browser.selected.value).toEqual([]) }) it('retries the failed destination rather than returning to the previous folder', async () => { mockGet.mockResolvedValueOnce(LISTING).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ ...LISTING, path: 'new' }) const browser = useFileBrowser() await browser.navigate({ mount_id: 'qe', path: 'raw' }) await browser.navigate({ mount_id: 'qe', path: 'new' }) await browser.refresh() expect(mockGet.mock.calls.at(-1)?.[0]).toContain('path=new') }) it('does not let obsolete requests keep the current directory loading', async () => { let finish!: (value: unknown) => void mockGet.mockReturnValueOnce(new Promise(resolve => { finish = resolve })).mockResolvedValueOnce(LISTING) const browser = useFileBrowser() const old = browser.navigate({ mount_id: 'qe', path: 'old' }) await browser.navigate({ mount_id: 'qe', path: 'raw' }) expect(browser.isLoading.value).toBe(false) finish(LISTING) await old }) it('refreshes when reactive type rules change', async () => { mockGet.mockResolvedValue(LISTING) const typeRules = ref(['.raw']) const scope = effectScope() const browser = scope.run(() => useFileBrowser({ typeRules }))! await browser.navigate({ mount_id: 'qe', path: 'raw' }) typeRules.value = ['.mzML'] await nextTick() expect(mockGet.mock.calls.at(-1)?.[0]).toContain('type_rules=.mzML') expect(mockGet.mock.calls.at(-1)?.[0]).not.toContain('refresh=') scope.stop() }) it('disposes a pending search without issuing another request', async () => { vi.useFakeTimers() const scope = effectScope() try { mockGet.mockResolvedValue(LISTING) const browser = scope.run(() => useFileBrowser())! await browser.navigate({ mount_id: 'qe', path: 'raw' }) browser.search.value = 'abc' await nextTick() const calls = mockGet.mock.calls.length scope.stop() await vi.advanceTimersByTimeAsync(300) expect(mockGet).toHaveBeenCalledTimes(calls) } finally { scope.stop() vi.useRealTimers() } }) })