forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { beforeEach, describe, expect, it, vi } from 'vitest'
2import { mountSuspended } from '@nuxt/test-utils/runtime'
3import { ref } from 'vue'
4import type { ComparisonFacet } from '#shared/types/comparison'
5import {
6 DEFAULT_FACETS,
7 FACET_INFO,
8 FACETS_BY_CATEGORY,
9 hasComingSoonFacets,
10} from '#shared/types/comparison'
11import type { FacetInfoWithLabels } from '~/composables/useFacetSelection'
12
13// Mock useRouteQuery - needs to be outside of the helper to persist across calls
14const mockRouteQuery = ref('')
15vi.mock('@vueuse/router', () => ({
16 useRouteQuery: () => mockRouteQuery,
17}))
18
19/**
20 * Helper to test useFacetSelection by wrapping it in a component.
21 * This is required because the composable uses useI18n which must be
22 * called inside a Vue component's setup function.
23 */
24async function useFacetSelectionInComponent() {
25 // Create refs to capture the composable's return values
26 const capturedSelectedFacets = shallowRef<FacetInfoWithLabels[]>([])
27 const capturedIsAllSelected = ref(false)
28 const capturedIsNoneSelected = ref(false)
29 let capturedIsFacetSelected: (facet: ComparisonFacet) => boolean
30 let capturedToggleFacet: (facet: ComparisonFacet) => void
31 let capturedSelectCategory: (category: string) => void
32 let capturedDeselectCategory: (category: string) => void
33 let capturedSelectAll: () => void
34 let capturedDeselectAll: () => void
35 let capturedAllFacets: ComparisonFacet[]
36
37 const WrapperComponent = defineComponent({
38 setup() {
39 const {
40 selectedFacets,
41 isFacetSelected,
42 toggleFacet,
43 selectCategory,
44 deselectCategory,
45 selectAll,
46 deselectAll,
47 isAllSelected,
48 isNoneSelected,
49 allFacets,
50 } = useFacetSelection()
51
52 // Sync values to captured refs
53 watchEffect(() => {
54 capturedSelectedFacets.value = [...selectedFacets.value]
55 capturedIsAllSelected.value = isAllSelected.value
56 capturedIsNoneSelected.value = isNoneSelected.value
57 })
58
59 capturedIsFacetSelected = isFacetSelected
60 capturedToggleFacet = toggleFacet
61 capturedSelectCategory = selectCategory
62 capturedDeselectCategory = deselectCategory
63 capturedSelectAll = selectAll
64 capturedDeselectAll = deselectAll
65 capturedAllFacets = allFacets
66
67 return () => h('div')
68 },
69 })
70
71 await mountSuspended(WrapperComponent)
72
73 return {
74 selectedFacets: capturedSelectedFacets,
75 isFacetSelected: (facet: ComparisonFacet) => capturedIsFacetSelected(facet),
76 toggleFacet: (facet: ComparisonFacet) => capturedToggleFacet(facet),
77 selectCategory: (category: string) => capturedSelectCategory(category),
78 deselectCategory: (category: string) => capturedDeselectCategory(category),
79 selectAll: () => capturedSelectAll(),
80 deselectAll: () => capturedDeselectAll(),
81 isAllSelected: capturedIsAllSelected,
82 isNoneSelected: capturedIsNoneSelected,
83 allFacets: capturedAllFacets!,
84 }
85}
86
87describe('useFacetSelection', () => {
88 beforeEach(() => {
89 mockRouteQuery.value = ''
90 })
91
92 it('returns DEFAULT_FACETS when no query param', async () => {
93 const { isFacetSelected } = await useFacetSelectionInComponent()
94
95 // All default facets should be selected
96 for (const facet of DEFAULT_FACETS) {
97 expect(isFacetSelected(facet)).toBe(true)
98 }
99 })
100
101 it('parses facets from query param', async () => {
102 mockRouteQuery.value = 'downloads,types,license'
103
104 const { isFacetSelected } = await useFacetSelectionInComponent()
105
106 expect(isFacetSelected('downloads')).toBe(true)
107 expect(isFacetSelected('types')).toBe(true)
108 expect(isFacetSelected('license')).toBe(true)
109 expect(isFacetSelected('packageSize')).toBe(false)
110 })
111
112 it('filters out invalid facets from query', async () => {
113 mockRouteQuery.value = 'downloads,invalidFacet,types'
114
115 const { isFacetSelected } = await useFacetSelectionInComponent()
116
117 expect(isFacetSelected('downloads')).toBe(true)
118 expect(isFacetSelected('types')).toBe(true)
119 })
120
121 it.runIf(hasComingSoonFacets)('filters out comingSoon facets from query', async () => {
122 mockRouteQuery.value = 'downloads,totalDependencies,types'
123
124 const { isFacetSelected } = await useFacetSelectionInComponent()
125
126 expect(isFacetSelected('downloads')).toBe(true)
127 expect(isFacetSelected('types')).toBe(true)
128 expect(isFacetSelected('totalDependencies')).toBe(false)
129 })
130
131 it('falls back to DEFAULT_FACETS if all parsed facets are invalid', async () => {
132 mockRouteQuery.value = 'invalidFacet1,invalidFacet2'
133
134 const { isFacetSelected } = await useFacetSelectionInComponent()
135
136 // All default facets should be selected when query is invalid
137 for (const facet of DEFAULT_FACETS) {
138 expect(isFacetSelected(facet)).toBe(true)
139 }
140 })
141
142 describe('selectedFacets enriched data', () => {
143 it('includes label and description for each facet', async () => {
144 mockRouteQuery.value = 'downloads,types'
145
146 const { selectedFacets } = await useFacetSelectionInComponent()
147
148 for (const facet of selectedFacets.value) {
149 expect(facet.id).toBeDefined()
150 expect(facet.label).toBeDefined()
151 expect(facet.description).toBeDefined()
152 expect(facet.category).toBeDefined()
153 }
154 })
155
156 it('includes category info for each facet', async () => {
157 mockRouteQuery.value = 'downloads,packageSize'
158
159 const { selectedFacets } = await useFacetSelectionInComponent()
160
161 const downloadsFacet = selectedFacets.value.find(f => f.id === 'downloads')
162 const packageSizeFacet = selectedFacets.value.find(f => f.id === 'packageSize')
163
164 expect(downloadsFacet?.category).toBe('health')
165 expect(packageSizeFacet?.category).toBe('performance')
166 })
167 })
168
169 describe('isFacetSelected', () => {
170 it('returns true for selected facets', async () => {
171 mockRouteQuery.value = 'downloads,types'
172
173 const { isFacetSelected } = await useFacetSelectionInComponent()
174
175 expect(isFacetSelected('downloads')).toBe(true)
176 expect(isFacetSelected('types')).toBe(true)
177 })
178
179 it('returns false for unselected facets', async () => {
180 mockRouteQuery.value = 'downloads,types'
181
182 const { isFacetSelected } = await useFacetSelectionInComponent()
183
184 expect(isFacetSelected('license')).toBe(false)
185 expect(isFacetSelected('engines')).toBe(false)
186 })
187 })
188
189 describe('toggleFacet', () => {
190 it('adds facet when not selected', async () => {
191 mockRouteQuery.value = 'downloads'
192
193 const { isFacetSelected, toggleFacet } = await useFacetSelectionInComponent()
194
195 toggleFacet('types')
196
197 expect(isFacetSelected('downloads')).toBe(true)
198 expect(isFacetSelected('types')).toBe(true)
199 })
200
201 it('removes facet when selected', async () => {
202 mockRouteQuery.value = 'downloads,types'
203
204 const { isFacetSelected, toggleFacet } = await useFacetSelectionInComponent()
205
206 toggleFacet('types')
207
208 expect(isFacetSelected('downloads')).toBe(true)
209 expect(isFacetSelected('types')).toBe(false)
210 })
211
212 it('does not remove last facet', async () => {
213 mockRouteQuery.value = 'downloads'
214
215 const { isFacetSelected, toggleFacet } = await useFacetSelectionInComponent()
216
217 toggleFacet('downloads')
218
219 // Should still be selected (can't remove the last one)
220 expect(isFacetSelected('downloads')).toBe(true)
221 })
222 })
223
224 describe('selectCategory', () => {
225 it('selects all facets in a category', async () => {
226 mockRouteQuery.value = 'downloads'
227
228 const { isFacetSelected, selectCategory } = await useFacetSelectionInComponent()
229
230 selectCategory('performance')
231
232 const performanceFacets = FACETS_BY_CATEGORY.performance.filter(
233 f => !FACET_INFO[f].comingSoon, // comingSoon facet
234 )
235 for (const facet of performanceFacets) {
236 expect(isFacetSelected(facet)).toBe(true)
237 }
238 })
239
240 it('preserves existing selections from other categories', async () => {
241 mockRouteQuery.value = 'downloads,license'
242
243 const { isFacetSelected, selectCategory } = await useFacetSelectionInComponent()
244
245 selectCategory('compatibility')
246
247 expect(isFacetSelected('downloads')).toBe(true)
248 expect(isFacetSelected('license')).toBe(true)
249 })
250 })
251
252 describe('deselectCategory', () => {
253 it('deselects all facets in a category', async () => {
254 mockRouteQuery.value = ''
255 const { isFacetSelected, deselectCategory } = await useFacetSelectionInComponent()
256
257 deselectCategory('performance')
258
259 const nonComingSoonPerformanceFacets = FACETS_BY_CATEGORY.performance.filter(
260 f => !FACET_INFO[f].comingSoon,
261 )
262 for (const facet of nonComingSoonPerformanceFacets) {
263 expect(isFacetSelected(facet)).toBe(false)
264 }
265 })
266
267 it('does not deselect if it would leave no facets', async () => {
268 mockRouteQuery.value = 'packageSize,installSize'
269
270 const { isFacetSelected, deselectCategory } = await useFacetSelectionInComponent()
271
272 deselectCategory('performance')
273
274 // Should still have at least one facet selected - since we can't
275 // deselect all, the original selection should remain
276 expect(isFacetSelected('packageSize') || isFacetSelected('installSize')).toBe(true)
277 })
278 })
279
280 describe('selectAll', () => {
281 it('selects all default facets', async () => {
282 mockRouteQuery.value = 'downloads'
283
284 const { isFacetSelected, selectAll } = await useFacetSelectionInComponent()
285
286 selectAll()
287
288 for (const facet of DEFAULT_FACETS) {
289 expect(isFacetSelected(facet)).toBe(true)
290 }
291 })
292 })
293
294 describe('deselectAll', () => {
295 it('keeps only the first default facet', async () => {
296 mockRouteQuery.value = ''
297
298 const { isFacetSelected, deselectAll } = await useFacetSelectionInComponent()
299
300 deselectAll()
301
302 // Only the first default facet should be selected
303 expect(isFacetSelected(DEFAULT_FACETS[0]!)).toBe(true)
304 // Second one should not be selected
305 expect(isFacetSelected(DEFAULT_FACETS[1]!)).toBe(false)
306 })
307 })
308
309 describe('isAllSelected', () => {
310 it('returns true when all facets selected', async () => {
311 mockRouteQuery.value = ''
312
313 const { isAllSelected } = await useFacetSelectionInComponent()
314
315 expect(isAllSelected.value).toBe(true)
316 })
317
318 it('returns false when not all facets selected', async () => {
319 mockRouteQuery.value = 'downloads,types'
320
321 const { isAllSelected } = await useFacetSelectionInComponent()
322
323 expect(isAllSelected.value).toBe(false)
324 })
325 })
326
327 describe('isNoneSelected', () => {
328 it('returns true when only one facet selected', async () => {
329 mockRouteQuery.value = 'downloads'
330
331 const { isNoneSelected } = await useFacetSelectionInComponent()
332
333 expect(isNoneSelected.value).toBe(true)
334 })
335
336 it('returns false when multiple facets selected', async () => {
337 mockRouteQuery.value = 'downloads,types'
338
339 const { isNoneSelected } = await useFacetSelectionInComponent()
340
341 expect(isNoneSelected.value).toBe(false)
342 })
343 })
344
345 describe('URL param behavior', () => {
346 it('clears URL param when selecting all defaults', async () => {
347 mockRouteQuery.value = 'downloads,types'
348
349 const { selectAll } = await useFacetSelectionInComponent()
350
351 selectAll()
352
353 // Should clear to empty string when matching defaults
354 expect(mockRouteQuery.value).toBe('')
355 })
356
357 it('sets URL param when selecting subset of facets via toggleFacet', async () => {
358 mockRouteQuery.value = ''
359
360 const { toggleFacet, deselectAll } = await useFacetSelectionInComponent()
361
362 // Start with one facet, then add another
363 deselectAll()
364 toggleFacet('types')
365
366 expect(mockRouteQuery.value).toContain('types')
367 })
368 })
369
370 describe('allFacets export', () => {
371 it('exports allFacets array', async () => {
372 const { allFacets } = await useFacetSelectionInComponent()
373
374 expect(Array.isArray(allFacets)).toBe(true)
375 expect(allFacets.length).toBeGreaterThan(0)
376 })
377 })
378
379 describe('whitespace handling', () => {
380 it('trims whitespace from facet names in query', async () => {
381 mockRouteQuery.value = ' downloads , types , license '
382
383 const { isFacetSelected } = await useFacetSelectionInComponent()
384
385 expect(isFacetSelected('downloads')).toBe(true)
386 expect(isFacetSelected('types')).toBe(true)
387 expect(isFacetSelected('license')).toBe(true)
388 })
389 })
390
391 describe('duplicate handling', () => {
392 it('handles duplicate facets in query by deduplication via Set', async () => {
393 // When adding facets, the code uses Set for deduplication
394 mockRouteQuery.value = 'downloads'
395
396 const { isFacetSelected, selectCategory } = await useFacetSelectionInComponent()
397
398 // downloads is in health category, selecting health should dedupe
399 selectCategory('health')
400
401 // downloads should be selected exactly once (verified by isFacetSelected working)
402 expect(isFacetSelected('downloads')).toBe(true)
403 })
404 })
405
406 describe('multiple category operations', () => {
407 it('can select multiple categories', async () => {
408 mockRouteQuery.value = 'downloads'
409
410 const { isFacetSelected, selectCategory } = await useFacetSelectionInComponent()
411
412 selectCategory('performance')
413 selectCategory('security')
414
415 // Should have facets from both categories plus original
416 expect(isFacetSelected('packageSize')).toBe(true)
417 expect(isFacetSelected('license')).toBe(true)
418 expect(isFacetSelected('downloads')).toBe(true)
419 })
420
421 it('can deselect multiple categories', async () => {
422 mockRouteQuery.value = ''
423
424 const { isFacetSelected, deselectCategory } = await useFacetSelectionInComponent()
425
426 deselectCategory('performance')
427 deselectCategory('health')
428
429 // Should not have performance or health facets
430 expect(isFacetSelected('packageSize')).toBe(false)
431 expect(isFacetSelected('downloads')).toBe(false)
432 })
433 })
434})