forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { describe, expect, it } from 'vitest'
2import { mountSuspended } from '@nuxt/test-utils/runtime'
3import type { NpmSearchResult } from '#shared/types'
4
5// Helper to create mock package results
6function createPackage(overrides: {
7 name: string
8 description?: string
9 keywords?: string[]
10 downloads?: number
11 updated?: string
12 insecure?: number
13}): NpmSearchResult {
14 return {
15 package: {
16 name: overrides.name,
17 version: '1.0.0',
18 description: overrides.description ?? '',
19 keywords: overrides.keywords ?? [],
20 date: overrides.updated ?? '2024-01-01T00:00:00.000Z',
21 links: { npm: '' },
22 publisher: { username: 'test', email: '' },
23 maintainers: [],
24 },
25 downloads: { weekly: overrides.downloads ?? 0 },
26 updated: overrides.updated ?? '2024-01-01T00:00:00.000Z',
27 flags: { insecure: overrides.insecure ?? 0 },
28 score: { final: 0.5, detail: { quality: 0.5, popularity: 0.5, maintenance: 0.5 } },
29 searchScore: 1000,
30 }
31}
32
33/**
34 * Helper to test useStructuredFilters by wrapping it in a component.
35 * This is required because the composable uses useI18n which must be
36 * called inside a Vue component's setup function.
37 */
38async function useStructuredFiltersInComponent(packages: Ref<NpmSearchResult[]>) {
39 let capturedResult: ReturnType<typeof useStructuredFilters>
40
41 const WrapperComponent = defineComponent({
42 setup() {
43 capturedResult = useStructuredFilters({ packages })
44 return () => h('div')
45 },
46 })
47
48 await mountSuspended(WrapperComponent)
49
50 return capturedResult!
51}
52
53describe('useStructuredFilters', () => {
54 describe('keyword filtering (AND logic)', () => {
55 it('returns all packages when no keywords selected', async () => {
56 const packages = ref([
57 createPackage({ name: 'pkg-a', keywords: ['react'] }),
58 createPackage({ name: 'pkg-b', keywords: ['vue'] }),
59 ])
60
61 const { sortedPackages } = await useStructuredFiltersInComponent(packages)
62
63 expect(sortedPackages.value).toHaveLength(2)
64 })
65
66 it('filters to packages with single keyword', async () => {
67 const packages = ref([
68 createPackage({ name: 'pkg-a', keywords: ['react', 'hooks'] }),
69 createPackage({ name: 'pkg-b', keywords: ['vue'] }),
70 ])
71
72 const { sortedPackages, addKeyword } = await useStructuredFiltersInComponent(packages)
73 addKeyword('react')
74
75 expect(sortedPackages.value).toHaveLength(1)
76 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a')
77 })
78
79 it('uses AND logic for multiple keywords', async () => {
80 const packages = ref([
81 createPackage({ name: 'pkg-a', keywords: ['react', 'hooks'] }),
82 createPackage({ name: 'pkg-b', keywords: ['react', 'state'] }),
83 createPackage({ name: 'pkg-c', keywords: ['react', 'hooks', 'state'] }),
84 ])
85
86 const { sortedPackages, addKeyword } = await useStructuredFiltersInComponent(packages)
87 addKeyword('react')
88 addKeyword('hooks')
89
90 // Only packages with BOTH 'react' AND 'hooks'
91 expect(sortedPackages.value).toHaveLength(2)
92 expect(sortedPackages.value.map(p => p.package.name)).toContain('pkg-a')
93 expect(sortedPackages.value.map(p => p.package.name)).toContain('pkg-c')
94 })
95
96 it('returns empty when no package has all keywords', async () => {
97 const packages = ref([
98 createPackage({ name: 'pkg-a', keywords: ['react'] }),
99 createPackage({ name: 'pkg-b', keywords: ['hooks'] }),
100 ])
101
102 const { sortedPackages, addKeyword } = await useStructuredFiltersInComponent(packages)
103 addKeyword('react')
104 addKeyword('hooks')
105
106 expect(sortedPackages.value).toHaveLength(0)
107 })
108 })
109
110 describe('text filtering', () => {
111 it('filters by name when scope is name', async () => {
112 const packages = ref([
113 createPackage({ name: 'react-query', description: 'Data fetching' }),
114 createPackage({ name: 'vue-query', description: 'Data fetching for Vue' }),
115 ])
116
117 const { sortedPackages, setTextFilter, setSearchScope } =
118 await useStructuredFiltersInComponent(packages)
119 setSearchScope('name')
120 setTextFilter('react')
121
122 expect(sortedPackages.value).toHaveLength(1)
123 expect(sortedPackages.value[0]!.package.name).toBe('react-query')
124 })
125
126 it('filters by description when scope is description', async () => {
127 const packages = ref([
128 createPackage({ name: 'pkg-a', description: 'A React component library' }),
129 createPackage({ name: 'pkg-b', description: 'A Vue component library' }),
130 ])
131
132 const { sortedPackages, setTextFilter, setSearchScope } =
133 await useStructuredFiltersInComponent(packages)
134 setSearchScope('description')
135 setTextFilter('React')
136
137 expect(sortedPackages.value).toHaveLength(1)
138 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a')
139 })
140
141 it('filters by keywords when scope is keywords', async () => {
142 const packages = ref([
143 createPackage({ name: 'pkg-a', keywords: ['typescript', 'react'] }),
144 createPackage({ name: 'pkg-b', keywords: ['javascript', 'vue'] }),
145 ])
146
147 const { sortedPackages, setTextFilter, setSearchScope } =
148 await useStructuredFiltersInComponent(packages)
149 setSearchScope('keywords')
150 setTextFilter('type')
151
152 expect(sortedPackages.value).toHaveLength(1)
153 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a')
154 })
155
156 it('filters by all fields when scope is all', async () => {
157 const packages = ref([
158 createPackage({ name: 'pkg-a', description: 'foo', keywords: ['bar'] }),
159 createPackage({ name: 'pkg-b', description: 'baz', keywords: ['qux'] }),
160 createPackage({ name: 'search-term', description: 'other', keywords: [] }),
161 ])
162
163 const { sortedPackages, setTextFilter, setSearchScope } =
164 await useStructuredFiltersInComponent(packages)
165 setSearchScope('all')
166 setTextFilter('search-term')
167
168 expect(sortedPackages.value).toHaveLength(1)
169 expect(sortedPackages.value[0]!.package.name).toBe('search-term')
170 })
171 })
172
173 describe('text filtering with operators', () => {
174 it('parses name: operator in all scope', async () => {
175 const packages = ref([
176 createPackage({ name: 'react-query', description: 'vue stuff' }),
177 createPackage({ name: 'vue-query', description: 'react stuff' }),
178 ])
179
180 const { sortedPackages, setTextFilter, setSearchScope } =
181 await useStructuredFiltersInComponent(packages)
182 setSearchScope('all')
183 setTextFilter('name:react')
184
185 expect(sortedPackages.value).toHaveLength(1)
186 expect(sortedPackages.value[0]!.package.name).toBe('react-query')
187 })
188
189 it('parses desc: operator in all scope', async () => {
190 const packages = ref([
191 createPackage({ name: 'pkg-a', description: 'A fantastic library' }),
192 createPackage({ name: 'pkg-b', description: 'A mediocre library' }),
193 ])
194
195 const { sortedPackages, setTextFilter, setSearchScope } =
196 await useStructuredFiltersInComponent(packages)
197 setSearchScope('all')
198 setTextFilter('desc:fantastic')
199
200 expect(sortedPackages.value).toHaveLength(1)
201 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a')
202 })
203
204 it('parses kw: operator in all scope', async () => {
205 const packages = ref([
206 createPackage({ name: 'pkg-a', keywords: ['typescript'] }),
207 createPackage({ name: 'pkg-b', keywords: ['javascript'] }),
208 ])
209
210 const { sortedPackages, setTextFilter, setSearchScope } =
211 await useStructuredFiltersInComponent(packages)
212 setSearchScope('all')
213 setTextFilter('kw:typescript')
214
215 expect(sortedPackages.value).toHaveLength(1)
216 expect(sortedPackages.value[0]!.package.name).toBe('pkg-a')
217 })
218
219 it('combines multiple operators with AND logic', async () => {
220 const packages = ref([
221 createPackage({ name: 'react-lib', description: 'hooks library', keywords: ['react'] }),
222 createPackage({ name: 'react-other', description: 'other stuff', keywords: ['react'] }),
223 createPackage({ name: 'vue-lib', description: 'hooks library', keywords: ['vue'] }),
224 ])
225
226 const { sortedPackages, setTextFilter, setSearchScope } =
227 await useStructuredFiltersInComponent(packages)
228 setSearchScope('all')
229 setTextFilter('name:react desc:hooks')
230
231 expect(sortedPackages.value).toHaveLength(1)
232 expect(sortedPackages.value[0]!.package.name).toBe('react-lib')
233 })
234
235 it('handles comma-separated keyword values with OR logic', async () => {
236 const packages = ref([
237 createPackage({ name: 'pkg-a', keywords: ['react'] }),
238 createPackage({ name: 'pkg-b', keywords: ['vue'] }),
239 createPackage({ name: 'pkg-c', keywords: ['angular'] }),
240 ])
241
242 const { sortedPackages, setTextFilter, setSearchScope } =
243 await useStructuredFiltersInComponent(packages)
244 setSearchScope('all')
245 setTextFilter('kw:react,vue')
246
247 expect(sortedPackages.value).toHaveLength(2)
248 expect(sortedPackages.value.map(p => p.package.name)).toContain('pkg-a')
249 expect(sortedPackages.value.map(p => p.package.name)).toContain('pkg-b')
250 })
251
252 it('combines operators with remaining text', async () => {
253 const packages = ref([
254 createPackage({
255 name: 'react-query',
256 description: 'Data fetching library',
257 keywords: ['react'],
258 }),
259 createPackage({ name: 'react-form', description: 'Form library', keywords: ['react'] }),
260 createPackage({
261 name: 'vue-query',
262 description: 'Data fetching library',
263 keywords: ['vue'],
264 }),
265 ])
266
267 const { sortedPackages, setTextFilter, setSearchScope } =
268 await useStructuredFiltersInComponent(packages)
269 setSearchScope('all')
270 setTextFilter('name:react fetching')
271
272 expect(sortedPackages.value).toHaveLength(1)
273 expect(sortedPackages.value[0]!.package.name).toBe('react-query')
274 })
275 })
276
277 describe('download range filtering', () => {
278 it('filters packages below threshold', async () => {
279 const packages = ref([
280 createPackage({ name: 'popular', downloads: 50000 }),
281 createPackage({ name: 'unpopular', downloads: 50 }),
282 ])
283
284 const { sortedPackages, setDownloadRange } = await useStructuredFiltersInComponent(packages)
285 setDownloadRange('gt100k')
286
287 expect(sortedPackages.value).toHaveLength(0)
288 })
289
290 it('filters packages within range', async () => {
291 const packages = ref([
292 createPackage({ name: 'pkg-a', downloads: 500 }),
293 createPackage({ name: 'pkg-b', downloads: 5000 }),
294 createPackage({ name: 'pkg-c', downloads: 50000 }),
295 ])
296
297 const { sortedPackages, setDownloadRange } = await useStructuredFiltersInComponent(packages)
298 setDownloadRange('1k-10k')
299
300 expect(sortedPackages.value).toHaveLength(1)
301 expect(sortedPackages.value[0]!.package.name).toBe('pkg-b')
302 })
303 })
304
305 describe('sorting', () => {
306 it('sorts by downloads descending by default with downloads sort', async () => {
307 const packages = ref([
308 createPackage({ name: 'pkg-a', downloads: 100 }),
309 createPackage({ name: 'pkg-b', downloads: 1000 }),
310 createPackage({ name: 'pkg-c', downloads: 500 }),
311 ])
312
313 const { sortedPackages, setSort } = await useStructuredFiltersInComponent(packages)
314 setSort('downloads-week-desc')
315
316 expect(sortedPackages.value[0]!.package.name).toBe('pkg-b')
317 expect(sortedPackages.value[1]!.package.name).toBe('pkg-c')
318 expect(sortedPackages.value[2]!.package.name).toBe('pkg-a')
319 })
320
321 it('sorts by name ascending', async () => {
322 const packages = ref([
323 createPackage({ name: 'zlib' }),
324 createPackage({ name: 'axios' }),
325 createPackage({ name: 'lodash' }),
326 ])
327
328 const { sortedPackages, setSort } = await useStructuredFiltersInComponent(packages)
329 setSort('name-asc')
330
331 expect(sortedPackages.value[0]!.package.name).toBe('axios')
332 expect(sortedPackages.value[1]!.package.name).toBe('lodash')
333 expect(sortedPackages.value[2]!.package.name).toBe('zlib')
334 })
335
336 it('sorts by updated date descending', async () => {
337 const packages = ref([
338 createPackage({ name: 'old', updated: '2023-01-01T00:00:00.000Z' }),
339 createPackage({ name: 'new', updated: '2024-06-01T00:00:00.000Z' }),
340 createPackage({ name: 'mid', updated: '2024-01-01T00:00:00.000Z' }),
341 ])
342
343 const { sortedPackages, setSort } = await useStructuredFiltersInComponent(packages)
344 setSort('updated-desc')
345
346 expect(sortedPackages.value[0]!.package.name).toBe('new')
347 expect(sortedPackages.value[1]!.package.name).toBe('mid')
348 expect(sortedPackages.value[2]!.package.name).toBe('old')
349 })
350
351 it('relevance sort preserves original order', async () => {
352 const packages = ref([
353 createPackage({ name: 'first', downloads: 100 }),
354 createPackage({ name: 'second', downloads: 1000 }),
355 createPackage({ name: 'third', downloads: 500 }),
356 ])
357
358 const { sortedPackages, setSort } = await useStructuredFiltersInComponent(packages)
359 setSort('relevance-desc')
360
361 // Relevance should preserve the original order from the server
362 expect(sortedPackages.value[0]!.package.name).toBe('first')
363 expect(sortedPackages.value[1]!.package.name).toBe('second')
364 expect(sortedPackages.value[2]!.package.name).toBe('third')
365 })
366 })
367
368 describe('clearing filters', () => {
369 it('clearAllFilters resets all filters', async () => {
370 const packages = ref([
371 createPackage({ name: 'pkg-a', keywords: ['react'], downloads: 1000 }),
372 createPackage({ name: 'pkg-b', keywords: ['vue'], downloads: 500 }),
373 ])
374
375 const { sortedPackages, setTextFilter, addKeyword, setDownloadRange, clearAllFilters } =
376 await useStructuredFiltersInComponent(packages)
377
378 setTextFilter('pkg-a')
379 addKeyword('react')
380 setDownloadRange('1k-10k')
381
382 expect(sortedPackages.value).toHaveLength(1)
383
384 clearAllFilters()
385
386 expect(sortedPackages.value).toHaveLength(2)
387 })
388
389 it('removeKeyword removes specific keyword', async () => {
390 const packages = ref([
391 createPackage({ name: 'pkg-a', keywords: ['react', 'hooks'] }),
392 createPackage({ name: 'pkg-b', keywords: ['react'] }),
393 ])
394
395 const { sortedPackages, addKeyword, removeKeyword } =
396 await useStructuredFiltersInComponent(packages)
397
398 addKeyword('react')
399 addKeyword('hooks')
400 expect(sortedPackages.value).toHaveLength(1)
401
402 removeKeyword('hooks')
403 expect(sortedPackages.value).toHaveLength(2)
404 })
405 })
406})