Skip to content

Commit 5e0061d

Browse files
authored
Merge pull request #40 from pheuberger/claude/increase-code-coverage-UPtW4
Add comprehensive test suite for custom hooks
2 parents 4407447 + dffa83c commit 5e0061d

17 files changed

Lines changed: 2839 additions & 2 deletions
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* useBookmarkFilters Tests
3+
*/
4+
5+
import { describe, it, expect, vi, afterEach } from 'vitest'
6+
import { renderHook, act } from '@testing-library/react'
7+
import { useBookmarkFilters } from './useBookmarkFilters.js'
8+
9+
const bookmarks = [
10+
{ _id: 'b1', title: 'Zebra', url: 'https://z.com', description: '', tags: ['animals'], readLater: true, inbox: false, createdAt: 100, updatedAt: 200 },
11+
{ _id: 'b2', title: 'Apple', url: 'https://a.com', description: '', tags: ['fruit'], readLater: false, inbox: true, createdAt: 200, updatedAt: 100 },
12+
{ _id: 'b3', title: 'Mango', url: 'https://m.com', description: '', tags: ['fruit'], readLater: false, inbox: false, createdAt: 300, updatedAt: 300 },
13+
]
14+
15+
describe('useBookmarkFilters', () => {
16+
afterEach(() => {
17+
vi.useRealTimers()
18+
})
19+
20+
it('returns all bookmarks by default', () => {
21+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
22+
expect(result.current.filterView).toBe('all')
23+
expect(result.current.filteredBookmarks).toHaveLength(3)
24+
})
25+
26+
it('sorts by recent (default)', () => {
27+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
28+
expect(result.current.sortBy).toBe('recent')
29+
expect(result.current.filteredBookmarks[0]._id).toBe('b3') // createdAt 300
30+
expect(result.current.filteredBookmarks[2]._id).toBe('b1') // createdAt 100
31+
})
32+
33+
it('sorts by oldest', () => {
34+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
35+
36+
act(() => result.current.setSortBy('oldest'))
37+
expect(result.current.filteredBookmarks[0]._id).toBe('b1')
38+
expect(result.current.filteredBookmarks[2]._id).toBe('b3')
39+
})
40+
41+
it('sorts by title', () => {
42+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
43+
44+
act(() => result.current.setSortBy('title'))
45+
expect(result.current.filteredBookmarks[0].title).toBe('Apple')
46+
expect(result.current.filteredBookmarks[2].title).toBe('Zebra')
47+
})
48+
49+
it('sorts by updated', () => {
50+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
51+
52+
act(() => result.current.setSortBy('updated'))
53+
expect(result.current.filteredBookmarks[0]._id).toBe('b3') // updatedAt 300
54+
})
55+
56+
it('goToReadLater filters for readLater only', () => {
57+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
58+
59+
act(() => result.current.goToReadLater())
60+
expect(result.current.filterView).toBe('read-later')
61+
expect(result.current.filteredBookmarks).toHaveLength(1)
62+
expect(result.current.filteredBookmarks[0]._id).toBe('b1')
63+
})
64+
65+
it('goToInbox filters for inbox only', () => {
66+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
67+
68+
act(() => result.current.goToInbox())
69+
expect(result.current.filterView).toBe('inbox')
70+
expect(result.current.filteredBookmarks).toHaveLength(1)
71+
expect(result.current.filteredBookmarks[0]._id).toBe('b2')
72+
})
73+
74+
it('handleTagSelect filters by tag', () => {
75+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
76+
77+
act(() => result.current.handleTagSelect('fruit'))
78+
expect(result.current.filterView).toBe('tag')
79+
expect(result.current.selectedTag).toBe('fruit')
80+
expect(result.current.filteredBookmarks).toHaveLength(2)
81+
expect(result.current.filteredBookmarks.every(b => b.tags.includes('fruit'))).toBe(true)
82+
})
83+
84+
it('handleTagClick filters by tag', () => {
85+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
86+
87+
act(() => result.current.handleTagClick('animals'))
88+
expect(result.current.filterView).toBe('tag')
89+
expect(result.current.selectedTag).toBe('animals')
90+
expect(result.current.filteredBookmarks).toHaveLength(1)
91+
})
92+
93+
it('handleFilterChange sets filter view and clears tag', () => {
94+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
95+
96+
// First set a tag filter
97+
act(() => result.current.handleTagSelect('fruit'))
98+
expect(result.current.selectedTag).toBe('fruit')
99+
100+
// Then change filter - should clear tag
101+
act(() => result.current.handleFilterChange('all'))
102+
expect(result.current.filterView).toBe('all')
103+
expect(result.current.selectedTag).toBeNull()
104+
})
105+
106+
it('goToAllBookmarks resets everything', () => {
107+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
108+
109+
act(() => result.current.goToReadLater())
110+
act(() => result.current.setSearchQuery('test'))
111+
112+
act(() => result.current.goToAllBookmarks())
113+
expect(result.current.filterView).toBe('all')
114+
expect(result.current.selectedTag).toBeNull()
115+
expect(result.current.searchQuery).toBe('')
116+
})
117+
118+
it('setSearchQuery updates search query state', () => {
119+
const { result } = renderHook(() => useBookmarkFilters(bookmarks))
120+
121+
act(() => result.current.setSearchQuery('hello'))
122+
expect(result.current.searchQuery).toBe('hello')
123+
})
124+
125+
it('handles bookmarks with missing tags array', () => {
126+
const messyBookmarks = [
127+
{ _id: 'b1', title: 'No Tags', url: 'https://x.com', tags: null, readLater: false, inbox: false, createdAt: 100, updatedAt: 100 },
128+
]
129+
const { result } = renderHook(() => useBookmarkFilters(messyBookmarks))
130+
131+
// Should not crash when filtering by tag
132+
act(() => result.current.handleTagSelect('test'))
133+
expect(result.current.filteredBookmarks).toHaveLength(0)
134+
})
135+
})
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* useBookmarkKeyboardNav Tests
3+
*/
4+
5+
import { describe, it, expect, vi, beforeEach } from 'vitest'
6+
import { renderHook, act } from '@testing-library/react'
7+
import { useBookmarkKeyboardNav } from './useBookmarkKeyboardNav.js'
8+
9+
const bookmarks = [
10+
{ _id: 'b1', title: 'A' },
11+
{ _id: 'b2', title: 'B' },
12+
{ _id: 'b3', title: 'C' },
13+
]
14+
15+
function setup(bm = bookmarks, opts = {}) {
16+
const defaultOpts = {
17+
filterView: 'all',
18+
inboxViewRef: { current: null },
19+
selectedTag: null,
20+
debouncedSearchQuery: '',
21+
...opts,
22+
}
23+
return renderHook(() => useBookmarkKeyboardNav(bm, defaultOpts))
24+
}
25+
26+
describe('useBookmarkKeyboardNav', () => {
27+
it('starts with no selection', () => {
28+
const { result } = setup()
29+
expect(result.current.selectedIndex).toBe(-1)
30+
expect(result.current.hoveredIndex).toBe(-1)
31+
expect(result.current.keyboardNavActive).toBe(false)
32+
})
33+
34+
it('selectNext moves to first item from no selection', () => {
35+
const { result } = setup()
36+
37+
act(() => result.current.selectNext())
38+
expect(result.current.selectedIndex).toBe(0)
39+
expect(result.current.keyboardNavActive).toBe(true)
40+
})
41+
42+
it('selectNext advances through items', () => {
43+
const { result } = setup()
44+
45+
act(() => result.current.selectNext())
46+
expect(result.current.selectedIndex).toBe(0)
47+
48+
act(() => result.current.selectNext())
49+
expect(result.current.selectedIndex).toBe(1)
50+
51+
act(() => result.current.selectNext())
52+
expect(result.current.selectedIndex).toBe(2)
53+
})
54+
55+
it('selectNext does not go past last item', () => {
56+
const { result } = setup()
57+
58+
act(() => result.current.selectNext())
59+
act(() => result.current.selectNext())
60+
act(() => result.current.selectNext())
61+
act(() => result.current.selectNext()) // attempt to go past end
62+
63+
expect(result.current.selectedIndex).toBe(2) // stays at last
64+
})
65+
66+
it('selectPrev moves to last item from no selection', () => {
67+
const { result } = setup()
68+
69+
act(() => result.current.selectPrev())
70+
expect(result.current.selectedIndex).toBe(2) // last
71+
expect(result.current.keyboardNavActive).toBe(true)
72+
})
73+
74+
it('selectPrev moves backward through items', () => {
75+
const { result } = setup()
76+
77+
act(() => result.current.selectPrev())
78+
expect(result.current.selectedIndex).toBe(2)
79+
80+
act(() => result.current.selectPrev())
81+
expect(result.current.selectedIndex).toBe(1)
82+
83+
act(() => result.current.selectPrev())
84+
expect(result.current.selectedIndex).toBe(0)
85+
})
86+
87+
it('selectPrev does not go below 0', () => {
88+
const { result } = setup()
89+
90+
act(() => result.current.selectPrev())
91+
act(() => result.current.selectPrev())
92+
act(() => result.current.selectPrev())
93+
act(() => result.current.selectPrev()) // attempt to go below 0
94+
95+
expect(result.current.selectedIndex).toBe(0)
96+
})
97+
98+
it('goToTop selects first item', () => {
99+
const { result } = setup()
100+
101+
act(() => result.current.goToTop())
102+
expect(result.current.selectedIndex).toBe(0)
103+
})
104+
105+
it('goToBottom selects last item', () => {
106+
const { result } = setup()
107+
108+
act(() => result.current.goToBottom())
109+
expect(result.current.selectedIndex).toBe(2)
110+
})
111+
112+
it('getSelectedBookmark returns null when no selection', () => {
113+
const { result } = setup()
114+
expect(result.current.getSelectedBookmark()).toBeNull()
115+
})
116+
117+
it('getSelectedBookmark returns the selected bookmark', () => {
118+
const { result } = setup()
119+
120+
act(() => result.current.selectNext())
121+
expect(result.current.getSelectedBookmark()).toEqual(bookmarks[0])
122+
})
123+
124+
it('handleBookmarkHover sets hovered index', () => {
125+
const { result } = setup()
126+
127+
act(() => result.current.handleBookmarkHover(1))
128+
expect(result.current.hoveredIndex).toBe(1)
129+
})
130+
131+
it('handleBookmarkHover deactivates keyboard nav', () => {
132+
const { result } = setup()
133+
134+
// First activate keyboard nav
135+
act(() => result.current.selectNext())
136+
expect(result.current.keyboardNavActive).toBe(true)
137+
138+
// Hover should deactivate keyboard nav
139+
act(() => result.current.handleBookmarkHover(2))
140+
expect(result.current.keyboardNavActive).toBe(false)
141+
expect(result.current.selectedIndex).toBe(-1)
142+
})
143+
144+
it('selectNext starts at hovered index if available', () => {
145+
const { result } = setup()
146+
147+
act(() => result.current.handleBookmarkHover(1))
148+
act(() => result.current.selectNext())
149+
expect(result.current.selectedIndex).toBe(1)
150+
})
151+
152+
it('delegates to inboxViewRef when filterView is inbox', () => {
153+
const inboxMock = {
154+
selectNext: vi.fn(),
155+
selectPrev: vi.fn(),
156+
goToTop: vi.fn(),
157+
goToBottom: vi.fn(),
158+
}
159+
const { result } = setup(bookmarks, {
160+
filterView: 'inbox',
161+
inboxViewRef: { current: inboxMock },
162+
})
163+
164+
act(() => result.current.selectNext())
165+
expect(inboxMock.selectNext).toHaveBeenCalled()
166+
167+
act(() => result.current.selectPrev())
168+
expect(inboxMock.selectPrev).toHaveBeenCalled()
169+
170+
act(() => result.current.goToTop())
171+
expect(inboxMock.goToTop).toHaveBeenCalled()
172+
173+
act(() => result.current.goToBottom())
174+
expect(inboxMock.goToBottom).toHaveBeenCalled()
175+
})
176+
177+
it('selectNext with empty bookmarks stays at -1', () => {
178+
const { result } = setup([])
179+
180+
act(() => result.current.selectNext())
181+
expect(result.current.selectedIndex).toBe(-1)
182+
})
183+
184+
it('selectPrev with empty bookmarks stays at -1', () => {
185+
const { result } = setup([])
186+
187+
act(() => result.current.selectPrev())
188+
expect(result.current.selectedIndex).toBe(-1)
189+
})
190+
191+
it('goToTop with empty bookmarks does nothing', () => {
192+
const { result } = setup([])
193+
194+
act(() => result.current.goToTop())
195+
expect(result.current.selectedIndex).toBe(-1)
196+
})
197+
198+
it('goToBottom with empty bookmarks does nothing', () => {
199+
const { result } = setup([])
200+
201+
act(() => result.current.goToBottom())
202+
expect(result.current.selectedIndex).toBe(-1)
203+
})
204+
205+
it('suppressHoverBriefly ignores hover temporarily', () => {
206+
vi.useFakeTimers()
207+
const { result } = setup()
208+
209+
act(() => result.current.suppressHoverBriefly())
210+
act(() => result.current.handleBookmarkHover(1))
211+
// Should be ignored
212+
expect(result.current.hoveredIndex).toBe(-1)
213+
214+
// After timeout, should work again
215+
act(() => vi.advanceTimersByTime(200))
216+
act(() => result.current.handleBookmarkHover(2))
217+
expect(result.current.hoveredIndex).toBe(2)
218+
219+
vi.useRealTimers()
220+
})
221+
})

0 commit comments

Comments
 (0)