Skip to content

Commit b5a77e3

Browse files
committed
test(files): cover the directory listing selector
The list operation's entry selection had no tests. These pin the parts that are easy to get subtly wrong: a file counts one level below its folder, so the deepest folder a depth admits arrives without its contents; search filters the result rather than the traversal, so a deep match still reports even when its parent folders do not match; and a cut listing reports truncated rather than looking complete.
1 parent ef927b1 commit b5a77e3

1 file changed

Lines changed: 187 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
type DirectoryFile,
7+
type DirectoryFolder,
8+
selectDirectoryEntries,
9+
} from '@/lib/workspace-files/directory-listing'
10+
11+
function folder(id: string, parentId: string | null, path: string): DirectoryFolder {
12+
const name = path.split('/').pop() ?? path
13+
return {
14+
id,
15+
parentId,
16+
name,
17+
path: `/${path}`,
18+
parentPath: parentId ? `/${path.split('/').slice(0, -1).join('/')}` : '/',
19+
createdAt: '2026-01-01T00:00:00.000Z',
20+
updatedAt: '2026-01-01T00:00:00.000Z',
21+
}
22+
}
23+
24+
function file(id: string, name: string, folderId: string | null): DirectoryFile {
25+
return { id, name, folderId, size: 10, type: 'text/plain', updatedAt: '2026-01-01T00:00:00.000Z' }
26+
}
27+
28+
const folders = [
29+
folder('reports', null, 'Reports'),
30+
folder('q3', 'reports', 'Reports/Q3'),
31+
folder('week1', 'q3', 'Reports/Q3/Week1'),
32+
folder('archive', null, 'Archive'),
33+
]
34+
35+
const files = [
36+
file('f-root', 'root.txt', null),
37+
file('f-reports', 'summary.txt', 'reports'),
38+
file('f-q3', 'q3.csv', 'q3'),
39+
file('f-week1', 'week1.md', 'week1'),
40+
]
41+
42+
const ROOT = { rootId: null, rootPath: '/', maxDepth: 1, limit: 200 }
43+
44+
describe('selectDirectoryEntries', () => {
45+
/*
46+
* "What is in here" is one question, so folders and files come back together.
47+
* A file sits one level below the folder holding it, which is what makes a
48+
* non-recursive listing the direct subfolders plus the direct files.
49+
*/
50+
it('lists direct children only by default', () => {
51+
const { entries } = selectDirectoryEntries(folders, files, ROOT)
52+
53+
expect(entries.map((entry) => `${entry.kind}:${entry.name}`)).toEqual([
54+
'folder:Archive',
55+
'folder:Reports',
56+
'file:root.txt',
57+
])
58+
})
59+
60+
it('descends when the depth allows it', () => {
61+
const { entries } = selectDirectoryEntries(folders, files, {
62+
...ROOT,
63+
maxDepth: Number.POSITIVE_INFINITY,
64+
})
65+
66+
expect(entries.filter((entry) => entry.kind === 'file').map((entry) => entry.name)).toEqual([
67+
'root.txt',
68+
'summary.txt',
69+
'q3.csv',
70+
'week1.md',
71+
])
72+
})
73+
74+
/*
75+
* A file counts as one level below its folder, so the deepest folder a depth
76+
* admits arrives without its contents: at depth 2 the Q3 folder is listed but
77+
* q3.csv, which sits inside it, is depth 3. That boundary is the whole reason
78+
* depth is counted this way rather than over folders alone.
79+
*/
80+
it('stops at the requested depth, listing the edge folder without its files', () => {
81+
const { entries } = selectDirectoryEntries(folders, files, { ...ROOT, maxDepth: 2 })
82+
const names = entries.map((entry) => entry.name)
83+
84+
expect(names).toContain('Q3')
85+
expect(names).toContain('summary.txt')
86+
expect(names).not.toContain('q3.csv')
87+
expect(names).not.toContain('week1.md')
88+
})
89+
90+
it('lists from a nested folder, counting depth from there', () => {
91+
const { entries } = selectDirectoryEntries(folders, files, {
92+
rootId: 'reports',
93+
rootPath: '/Reports',
94+
maxDepth: 1,
95+
limit: 200,
96+
})
97+
98+
expect(entries.map((entry) => `${entry.kind}:${entry.name}:${entry.depth}`)).toEqual([
99+
'folder:Q3:1',
100+
'file:summary.txt:1',
101+
])
102+
})
103+
104+
it('shows folders before files at the same level, then by name', () => {
105+
const { entries } = selectDirectoryEntries(folders, files, {
106+
...ROOT,
107+
maxDepth: Number.POSITIVE_INFINITY,
108+
})
109+
const level1 = entries.filter((entry) => entry.depth === 1)
110+
111+
expect(level1.map((entry) => entry.kind)).toEqual(['folder', 'folder', 'file'])
112+
})
113+
114+
/*
115+
* Search filters the result, not the traversal: a deep match still reports at
116+
* its real depth even though its parent folders do not match. Filtering the
117+
* walk instead would hide everything under an unmatched folder.
118+
*/
119+
it('reports a deep match whose ancestors do not match', () => {
120+
const { entries } = selectDirectoryEntries(folders, files, {
121+
...ROOT,
122+
maxDepth: Number.POSITIVE_INFINITY,
123+
search: 'week1',
124+
})
125+
126+
expect(entries.map((entry) => `${entry.kind}:${entry.name}`)).toEqual([
127+
'folder:Week1',
128+
'file:week1.md',
129+
])
130+
})
131+
132+
it('matches case-insensitively', () => {
133+
const { entries } = selectDirectoryEntries(folders, files, { ...ROOT, search: 'ARCH' })
134+
135+
expect(entries.map((entry) => entry.name)).toEqual(['Archive'])
136+
})
137+
138+
it('tells a file its containing folder path', () => {
139+
const { entries } = selectDirectoryEntries(folders, files, {
140+
...ROOT,
141+
maxDepth: Number.POSITIVE_INFINITY,
142+
})
143+
const nested = entries.find((entry) => entry.kind === 'file' && entry.name === 'q3.csv')
144+
145+
expect(nested?.kind === 'file' && nested.folderPath).toBe('/Reports/Q3')
146+
})
147+
148+
it('gives a root file the root path', () => {
149+
const { entries } = selectDirectoryEntries(folders, files, ROOT)
150+
const rootFile = entries.find((entry) => entry.kind === 'file')
151+
152+
expect(rootFile?.kind === 'file' && rootFile.folderPath).toBe('/')
153+
})
154+
155+
/*
156+
* A cut listing has to say so. Reporting the first N and looking complete is
157+
* the failure mode the flag exists to prevent.
158+
*/
159+
it('reports truncation rather than looking complete', () => {
160+
const { entries, truncated } = selectDirectoryEntries(folders, files, { ...ROOT, limit: 2 })
161+
162+
expect(entries).toHaveLength(2)
163+
expect(truncated).toBe(true)
164+
})
165+
166+
it('is not truncated when everything fits', () => {
167+
expect(selectDirectoryEntries(folders, files, ROOT).truncated).toBe(false)
168+
})
169+
170+
it('returns nothing for an empty workspace', () => {
171+
const { entries, truncated } = selectDirectoryEntries([], [], ROOT)
172+
173+
expect(entries).toEqual([])
174+
expect(truncated).toBe(false)
175+
})
176+
177+
it('leaves out a file whose folder is outside the listed subtree', () => {
178+
const { entries } = selectDirectoryEntries(folders, files, {
179+
rootId: 'archive',
180+
rootPath: '/Archive',
181+
maxDepth: Number.POSITIVE_INFINITY,
182+
limit: 200,
183+
})
184+
185+
expect(entries).toEqual([])
186+
})
187+
})

0 commit comments

Comments
 (0)