import { afterEach, expect, it, vi } from 'vitest' import { effectScope, type EffectScope } from 'vue' import { usePickerNavigation } from '../../composables/filePicker/usePickerNavigation' import type { PickerAdapter, PickerNode, PickerSort } from '../../types/filePicker' const scopes: EffectScope[] = [] afterEach(() => { scopes.splice(0).forEach((scope) => scope.stop()) vi.useRealTimers() }) const folder = (path: string): PickerNode => ({ kind: 'folder', path, name: path }) function setup(roots: PickerNode[], allowed = (_node: PickerNode) => true) { vi.useFakeTimers() const adapter: PickerAdapter = { listRoot: vi.fn(async () => roots), listChildren: vi.fn(async (path) => [folder(`${path}/child`)]), search: vi.fn(async () => roots), } const scope = effectScope() scopes.push(scope) const nav = scope.run(() => usePickerNavigation(() => adapter, () => ({}), allowed))! return { nav, adapter, scope } } it('warms only the next level and reuses an in-flight prefetch when entering', async () => { const { nav, adapter } = setup([folder('a')]) let finish!: (nodes: PickerNode[]) => void vi.mocked(adapter.listChildren).mockReturnValueOnce(new Promise((resolve) => { finish = resolve })) await nav.load() expect(nav.loading.value).toBe(false) expect(adapter.listChildren).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(100) expect(adapter.listChildren).toHaveBeenCalledTimes(1) nav.enter(folder('a')) const opened = nav.load() finish([folder('a/child')]) await opened expect(adapter.listChildren).toHaveBeenCalledTimes(1) expect(nav.entries.value).toEqual([folder('a/child')]) await vi.advanceTimersByTimeAsync(100) expect(vi.mocked(adapter.listChildren).mock.calls.map(([path]) => path)).toEqual(['a', 'a/child']) await vi.advanceTimersByTimeAsync(1000) expect(adapter.listChildren).toHaveBeenCalledTimes(2) await nav.loadChildren('a') expect(adapter.listChildren).toHaveBeenCalledTimes(2) }) it('bounds background work to two concurrent requests and twenty folders per view', async () => { const { nav, adapter } = setup(Array.from({ length: 30 }, (_, i) => folder(`${i}`))) const finish: (() => void)[] = [] vi.mocked(adapter.listChildren).mockImplementation(() => new Promise((resolve) => { finish.push(() => resolve([])) })) await nav.load() await vi.advanceTimersByTimeAsync(100) expect(adapter.listChildren).toHaveBeenCalledTimes(2) vi.mocked(adapter.listChildren).mockResolvedValue([]) finish.forEach((resolve) => resolve()) await vi.advanceTimersByTimeAsync(100) expect(adapter.listChildren).toHaveBeenCalledTimes(20) }) it('does not prefetch forbidden, offline, empty folders or search results', async () => { const { nav, adapter } = setup([ folder('denied'), { ...folder('offline'), unavailableReason: 'Offline' }, { kind: 'folder', path: 'empty', name: 'empty', hasChildren: false }, folder('ok'), ], (node) => node.path !== 'denied') await nav.load() await vi.advanceTimersByTimeAsync(100) expect(vi.mocked(adapter.listChildren).mock.calls.map(([path]) => path)).toEqual(['ok']) nav.invalidate() await nav.load('query') await vi.advanceTimersByTimeAsync(100) expect(adapter.listChildren).toHaveBeenCalledTimes(1) }) it('cancels queued prefetches and discards late responses when closed', async () => { const { nav, adapter, scope } = setup([folder('a'), folder('b'), folder('c')]) let finish!: (nodes: PickerNode[]) => void vi.mocked(adapter.listChildren).mockImplementation(() => new Promise((resolve) => { finish = resolve })) await nav.load() await vi.advanceTimersByTimeAsync(100) scope.stop() expect(vi.mocked(adapter.listChildren).mock.calls[0]![1]!.signal!.aborted).toBe(true) finish([folder('late')]) await vi.advanceTimersByTimeAsync(1000) expect(nav.nodes.value.size).toBe(0) expect(nav.children.value.size).toBe(0) expect(adapter.listChildren).toHaveBeenCalledTimes(2) }) it('retries a failed prefetch on demand and refresh bypasses warmed results', async () => { const { nav, adapter } = setup([folder('a')]) vi.mocked(adapter.listChildren).mockRejectedValueOnce(new Error('Offline')) await nav.load() await vi.advanceTimersByTimeAsync(100) expect(nav.error.value).toBe('') nav.enter(folder('a')) await nav.load() expect(nav.entries.value).toEqual([folder('a/child')]) nav.invalidate() await nav.load('', false, true) expect(adapter.listChildren).toHaveBeenCalledTimes(3) expect(vi.mocked(adapter.listChildren).mock.calls.at(-1)![1]).toMatchObject({ refresh: true }) }) it('warms expanded children and rechecks their registered ancestors before prefetching', async () => { let denied = '' const { nav, adapter } = setup([folder('a'), folder('b')], (node) => node.path !== denied) await nav.load() await nav.expand(folder('a')) await vi.advanceTimersByTimeAsync(100) expect(vi.mocked(adapter.listChildren).mock.calls.map(([path]) => path)).toEqual(['a', 'a/child']) await vi.advanceTimersByTimeAsync(1000) expect(adapter.listChildren).toHaveBeenCalledTimes(2) await nav.expand(folder('b')) denied = 'b' await vi.advanceTimersByTimeAsync(100) expect(nav.isAllowed(nav.nodes.value.get('b/child')!)).toBe(false) expect(vi.mocked(adapter.listChildren).mock.calls.map(([path]) => path)).toEqual(['a', 'a/child', 'b']) }) it('restarts prefetch after reopening even if old requests ignore cancellation', async () => { const { nav, adapter } = setup([folder('a'), folder('b'), folder('c')]) const finish: (() => void)[] = [] vi.mocked(adapter.listChildren).mockImplementation(() => new Promise((resolve) => { finish.push(() => resolve([])) })) await nav.load() await vi.advanceTimersByTimeAsync(100) expect(adapter.listChildren).toHaveBeenCalledTimes(2) nav.clear() await nav.load() await vi.advanceTimersByTimeAsync(100) expect(adapter.listChildren).toHaveBeenCalledTimes(4) finish[0]!() finish[1]!() await vi.advanceTimersByTimeAsync(100) expect(adapter.listChildren).toHaveBeenCalledTimes(4) expect(nav.children.value.size).toBe(0) finish[2]!() await vi.advanceTimersByTimeAsync(100) expect(adapter.listChildren).toHaveBeenCalledTimes(5) finish[3]!() finish[4]!() await vi.advanceTimersByTimeAsync(100) }) const dated = (kind: 'folder' | 'file', name: string, modifiedAt?: string): PickerNode => kind === 'folder' ? { kind, path: name, name, modifiedAt } : { kind, path: name, name, modifiedAt, format: 'raw' } const mixed = () => [ dated('file', 'b.raw', '2026-09-01T00:00:00Z'), dated('folder', 'old', '2026-01-01T00:00:00Z'), dated('file', 'a.raw', '2026-09-29T00:00:00Z'), dated('folder', 'new', '2026-09-28T00:00:00Z'), dated('file', 'undated.raw'), ] async function sortedNames(sort: PickerSort | undefined) { vi.useFakeTimers() const adapter: PickerAdapter = { listRoot: vi.fn(async () => mixed()), listChildren: vi.fn(async () => []), search: vi.fn(async () => []), } const scope = effectScope() scopes.push(scope) const nav = scope.run(() => usePickerNavigation(() => adapter, () => ({}), () => true, () => sort))! await nav.load() return nav.rows.value.map((row) => row.node.name) } it('should list folders first, newest first, when sorted by newest', async () => { expect(await sortedNames('newest')).toEqual(['new', 'old', 'a.raw', 'b.raw', 'undated.raw']) }) it('should list folders first, oldest first, when sorted by oldest', async () => { expect(await sortedNames('oldest')).toEqual(['old', 'new', 'b.raw', 'a.raw', 'undated.raw']) }) it('should list folders first by name when sorted by name', async () => { expect(await sortedNames('name')).toEqual(['new', 'old', 'a.raw', 'b.raw', 'undated.raw']) }) it('should list folders first by reverse name when sorted by name-desc', async () => { expect(await sortedNames('name-desc')).toEqual(['old', 'new', 'undated.raw', 'b.raw', 'a.raw']) }) it('should keep the adapter order when no sort is given', async () => { expect(await sortedNames(undefined)).toEqual(['b.raw', 'old', 'a.raw', 'new', 'undated.raw']) }) it('should show partial results and flag them when the adapter search is truncated', async () => { const { nav, adapter } = setup([folder('a')]) vi.mocked(adapter.search).mockResolvedValueOnce({ nodes: [folder('a')], truncated: true }) await nav.load('a', true) expect([nav.entries.value.length, nav.truncated.value]).toEqual([1, true]) }) it('should clear the truncated flag on the next plain listing', async () => { const { nav, adapter } = setup([folder('a')]) vi.mocked(adapter.search).mockResolvedValueOnce({ nodes: [], truncated: true }) await nav.load('a', true) await nav.load() expect(nav.truncated.value).toBe(false) })