1package component
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/puregotk/v4/glib"
14 "github.com/rs/zerolog"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18)
19
20// =============================================================================
21// applySearchResults seam tests
22// =============================================================================
23
24// newTestSidebarSearchHarness creates a minimal HistorySidebar with only the
25// fields needed by applySearchResults, avoiding GTK widget construction.
26func newTestSidebarSearchHarness() *HistorySidebar {
27 return &HistorySidebar{
28 mu: sync.RWMutex{},
29 logger: zerolog.Nop(),
30 }
31}
32
33func TestApplySearchResults_NonStaleApplied(t *testing.T) {
34 hs := newTestSidebarSearchHarness()
35 hs.searchGen = 1
36
37 entries := []*entity.HistoryEntry{
38 {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
39 }
40
41 applied := hs.applySearchResults(entries, 1, nil)
42 assert.True(t, applied, "non-stale results must be applied")
43 assert.True(t, hs.searchDone)
44 require.NoError(t, hs.searchErr)
45 require.NotNil(t, hs.searchResults)
46 assert.Len(t, hs.searchResults, 1)
47 assert.Equal(t, "https://example.com", hs.searchResults[0].URL)
48 require.NotNil(t, hs.groups, "groups must be built from results")
49 assert.Equal(t, "Today", hs.groups[0].Label)
50}
51
52func TestApplySearchResults_StaleGenRejected(t *testing.T) {
53 hs := newTestSidebarSearchHarness()
54 hs.searchGen = 2 // Gen has moved on
55
56 entries := []*entity.HistoryEntry{
57 {ID: 1, URL: "https://stale.com", Title: "Stale", LastVisited: time.Now()},
58 }
59
60 applied := hs.applySearchResults(entries, 1, nil) // gen=1 is stale
61 assert.False(t, applied, "stale results must be rejected")
62 assert.False(t, hs.searchDone, "search must not be marked done for stale result")
63 assert.Nil(t, hs.searchResults, "searchResults must remain nil")
64 assert.Nil(t, hs.groups, "groups must remain nil")
65}
66
67func TestApplySearchResults_StaleAfterIncrement(t *testing.T) {
68 hs := newTestSidebarSearchHarness()
69 hs.searchGen = 1
70
71 // First result applied as gen=1
72 firstEntries := []*entity.HistoryEntry{
73 {ID: 1, URL: "https://first.com", Title: "First", LastVisited: time.Now()},
74 }
75 applied := hs.applySearchResults(firstEntries, 1, nil)
76 assert.True(t, applied)
77
78 // Second search starts, gen advances
79 hs.searchGen = 2
80
81 // Stale result from gen=1 arrives
82 staleEntries := []*entity.HistoryEntry{
83 {ID: 2, URL: "https://stale.com", Title: "Stale", LastVisited: time.Now()},
84 }
85 applied = hs.applySearchResults(staleEntries, 1, nil)
86 assert.False(t, applied, "stale result must be rejected")
87
88 // The original results must be preserved
89 require.NotNil(t, hs.searchResults)
90 assert.Len(t, hs.searchResults, 1)
91 assert.Equal(t, "https://first.com", hs.searchResults[0].URL)
92}
93
94func TestApplySearchResults_ErrorStored(t *testing.T) {
95 hs := newTestSidebarSearchHarness()
96 hs.searchGen = 1
97
98 wantErr := errors.New("search failed")
99 entries := []*entity.HistoryEntry{} // empty but not nil
100
101 applied := hs.applySearchResults(entries, 1, wantErr)
102 assert.True(t, applied)
103 assert.True(t, hs.searchDone)
104 require.ErrorIs(t, hs.searchErr, wantErr)
105 require.NotNil(t, hs.searchResults)
106 assert.Empty(t, hs.searchResults)
107}
108
109func TestApplySearchResults_EmptyResultsApplied(t *testing.T) {
110 hs := newTestSidebarSearchHarness()
111 hs.searchGen = 1
112
113 applied := hs.applySearchResults([]*entity.HistoryEntry{}, 1, nil)
114 assert.True(t, applied)
115 assert.True(t, hs.searchDone)
116 require.NoError(t, hs.searchErr)
117 require.NotNil(t, hs.searchResults)
118 assert.Empty(t, hs.searchResults)
119 // Groups from empty entries should be nil or empty
120 assert.Nil(t, hs.groups, "empty entries should produce nil groups")
121}
122
123func TestApplyDeletedEntryLocked_RecomputesBrowseState(t *testing.T) {
124 now := time.Now()
125 keepA := &entity.HistoryEntry{ID: 1, URL: "https://keep-a.com", Title: "Keep A", LastVisited: now}
126 deleteMe := &entity.HistoryEntry{ID: 2, URL: "https://delete-me.com", Title: "Delete Me", LastVisited: now.Add(-time.Minute)}
127 keepB := &entity.HistoryEntry{ID: 3, URL: "https://keep-b.com", Title: "Keep B", LastVisited: now.Add(-2 * time.Minute)}
128
129 hs := newTestSidebarSearchHarness()
130 hs.allEntries = []*entity.HistoryEntry{keepA, deleteMe, keepB}
131 hs.totalLoaded = len(hs.allEntries)
132 hs.loadGen = 7
133 hs.isLoading = true
134 hs.loadStarted = true
135 hs.searchResults = []*entity.HistoryEntry{deleteMe, keepB}
136 hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
137
138 hs.mu.Lock()
139 hs.applyDeletedEntryLocked(deleteMe.ID, keepB.URL)
140 hs.mu.Unlock()
141
142 assert.Equal(t, uint64(8), hs.loadGen, "deletes must invalidate in-flight browse loads")
143 assert.False(t, hs.isLoading, "delete should clear stale loading state")
144 assert.False(t, hs.loadStarted, "delete should clear stale loadStarted state")
145 assert.Equal(t, 2, hs.totalLoaded, "browse pagination offset must track remaining loaded entries")
146 assert.Equal(t, keepB.URL, hs.prevSelectedURL)
147 assert.Len(t, hs.allEntries, 2)
148 assert.Equal(t, []string{keepA.URL, keepB.URL}, []string{hs.allEntries[0].URL, hs.allEntries[1].URL})
149 assert.Len(t, hs.searchResults, 1)
150 assert.Equal(t, keepB.URL, hs.searchResults[0].URL)
151 require.Len(t, hs.groups, 1)
152 require.Len(t, hs.groups[0].Entries, 2)
153 require.Len(t, hs.displayRows, 3, "one header plus two remaining entries")
154 assert.Equal(t, historyDisplayRowHeader, hs.displayRows[0].Kind)
155 assert.Equal(t, historyDisplayRowEntry, hs.displayRows[1].Kind)
156 assert.Equal(t, historyDisplayRowEntry, hs.displayRows[2].Kind)
157}
158
159func TestApplySearchResults_StaleGenerationDropsResultsAfterSearch(t *testing.T) {
160 searchCalled := make(chan struct{}, 1)
161 idleCalled := make(chan glib.SourceFunc, 1)
162
163 history := portmocks.NewMockHistorySidebarHistory(t)
164 history.EXPECT().
165 Search(mock.Anything, mock.Anything).
166 RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
167 searchCalled <- struct{}{}
168 return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
169 {Entry: &entity.HistoryEntry{ID: 1, URL: "https://result.com", Title: "Result", LastVisited: time.Now()}},
170 }}, nil
171 }).
172 Once()
173
174 hs := newTestSidebarSearchHarness()
175 hs.ctx = t.Context()
176 hs.historyUC = history
177 hs.searchGen = 1
178 hs.idleScheduler = func(cb glib.SourceFunc) {
179 idleCalled <- cb
180 }
181
182 // Start search with gen=1.
183 hs.doFTSearch("test", 1)
184 select {
185 case <-searchCalled:
186 case <-time.After(time.Second):
187 t.Fatal("timed out waiting for search use case to be invoked")
188 }
189
190 // Advance gen before the queued UI callback applies results.
191 hs.mu.Lock()
192 hs.searchGen = 2
193 hs.mu.Unlock()
194
195 select {
196 case cb := <-idleCalled:
197 cb(0)
198 case <-time.After(time.Second):
199 t.Fatal("timed out waiting for scheduled idle callback")
200 }
201
202 hs.mu.RLock()
203 defer hs.mu.RUnlock()
204 assert.Nil(t, hs.searchResults)
205 assert.False(t, hs.searchDone)
206 assert.Nil(t, hs.groups)
207}
208
209func TestApplySearchResults_CurrentGenerationAppliedAfterSearch(t *testing.T) {
210 searchCalled := make(chan struct{}, 1)
211 idleCalled := make(chan glib.SourceFunc, 1)
212
213 history := portmocks.NewMockHistorySidebarHistory(t)
214 history.EXPECT().
215 Search(mock.Anything, mock.Anything).
216 RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
217 searchCalled <- struct{}{}
218 return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{
219 {Entry: &entity.HistoryEntry{ID: 1, URL: "https://live.com", Title: "Live", LastVisited: time.Now()}},
220 }}, nil
221 }).
222 Once()
223
224 hs := newTestSidebarSearchHarness()
225 hs.ctx = t.Context()
226 hs.historyUC = history
227 hs.searchGen = 1
228 hs.idleScheduler = func(cb glib.SourceFunc) {
229 idleCalled <- cb
230 }
231
232 // Start the search and wait for the use case to be invoked.
233 hs.doFTSearch("live", 1)
234 select {
235 case <-searchCalled:
236 case <-time.After(time.Second):
237 t.Fatal("timed out waiting for search use case to be invoked")
238 }
239
240 select {
241 case cb := <-idleCalled:
242 cb(0)
243 case <-time.After(time.Second):
244 t.Fatal("timed out waiting for scheduled idle callback")
245 }
246
247 hs.mu.RLock()
248 require.NotNil(t, hs.searchResults)
249 assert.True(t, hs.searchDone)
250 assert.Len(t, hs.searchResults, 1)
251 assert.Equal(t, "https://live.com", hs.searchResults[0].URL)
252 hs.mu.RUnlock()
253}
254
255// =============================================================================
256// Reload with query preservation
257// =============================================================================
258
259// TestHistorySidebar_ReloadPreservesQuery verifies that Reload preserves the
260// active query while resetting browse/search state before the refreshed load.
261func TestHistorySidebar_ReloadPreservesQuery(t *testing.T) {
262 searchCalled := make(chan struct{}, 1)
263 history := portmocks.NewMockHistorySidebarHistory(t)
264 history.EXPECT().
265 Search(mock.Anything, mock.Anything).
266 RunAndReturn(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
267 searchCalled <- struct{}{}
268 return &dto.HistorySearchOutput{Matches: []entity.HistoryMatch{}}, nil
269 }).
270 Once()
271
272 hs := newTestSidebarSearchHarness()
273 hs.currentQuery = "preserved"
274 hs.historyUC = history
275 hs.ctx = t.Context()
276
277 hs.mu.Lock()
278 oldGen := hs.searchGen
279 hs.loadDone = true
280 hs.allEntries = []*entity.HistoryEntry{
281 {ID: 1, URL: "https://old.com", Title: "Old", LastVisited: time.Now()},
282 }
283 hs.groups = groupHistoryByDay(hs.allEntries)
284 hs.searchResults = []*entity.HistoryEntry{{ID: 2, URL: "https://stale.com", Title: "Stale", LastVisited: time.Now()}}
285 hs.searchDone = true
286 hs.mu.Unlock()
287
288 hs.Reload()
289
290 select {
291 case <-searchCalled:
292 case <-time.After(time.Second):
293 t.Fatal("timed out waiting for reload search to be invoked")
294 }
295
296 hs.mu.RLock()
297 assert.Equal(t, "preserved", hs.currentQuery, "query must be preserved after Reload")
298 assert.False(t, hs.loadDone, "loadDone must be reset")
299 assert.False(t, hs.loadStarted, "loadStarted must be reset")
300 assert.Nil(t, hs.allEntries, "entries must be cleared")
301 assert.Nil(t, hs.groups, "groups must be cleared")
302 assert.Nil(t, hs.searchResults, "searchResults must be cleared before refreshed search applies")
303 assert.False(t, hs.searchDone, "searchDone must be reset")
304 assert.Equal(t, oldGen+1, hs.searchGen, "searchGen must be incremented")
305 hs.mu.RUnlock()
306}
307
308// =============================================================================
309// fetchPage generation guard: stale browse results must not clear loading state
310// =============================================================================
311
312func TestFetchPage_StaleGenerationDoesNotMutateLoadingState(t *testing.T) {
313 t.Parallel()
314
315 // Repo blocks GetRecent until test signals proceed, so we can control
316 // when a stale fetch completes relative to generation changes.
317 getRecentCalled := make(chan struct{})
318 proceed := make(chan struct{})
319
320 history := portmocks.NewMockHistorySidebarHistory(t)
321 history.EXPECT().
322 GetRecent(mock.Anything, sidebarPageSize, 0).
323 RunAndReturn(func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
324 close(getRecentCalled)
325 <-proceed
326 return []*entity.HistoryEntry{}, nil
327 }).
328 Once()
329
330 hs := newTestSidebarSearchHarness()
331 hs.ctx = t.Context()
332 hs.historyUC = history
333
334 // Simulate: gen 1 started, then gen 2 started (e.g. by Reload/Show).
335 // The second call incremented loadGen and set isLoading/loadStarted.
336 hs.mu.Lock()
337 hs.loadGen = 2
338 hs.isLoading = true
339 hs.loadStarted = true
340 hs.totalLoaded = 0
341 hs.hasMore = true
342 hs.mu.Unlock()
343
344 // Start stale gen 1 fetch in background
345 fetchDone := make(chan struct{})
346 go func() {
347 hs.fetchPage(0, 1) // gen=1 is stale — loadGen is 2
348 close(fetchDone)
349 }()
350
351 // Wait until GetRecent is entered (fetchPage is blocked inside the repo call)
352 <-getRecentCalled
353
354 // Let GetRecent return — this simulates the gen 1 fetch completing
355 // after gen 2 has already taken over.
356 close(proceed)
357
358 // Wait for fetchPage to finish processing the stale return
359 <-fetchDone
360
361 // Verify: stale completion must NOT clear the new generation's loading state
362 hs.mu.RLock()
363 assert.True(t, hs.isLoading, "isLoading must remain true when stale gen completes")
364 assert.True(t, hs.loadStarted, "loadStarted must remain true when stale gen completes")
365 assert.Equal(t, uint64(2), hs.loadGen, "loadGen must remain unchanged")
366 assert.False(t, hs.loadDone, "loadDone must remain false; gen 2 hasn't completed")
367 assert.Equal(t, 0, hs.totalLoaded, "stale results must not update totalLoaded")
368 hs.mu.RUnlock()
369}
370
371// =============================================================================
372// First-Show single query batch: no constructor load, one batch per Show
373// =============================================================================
374
375// TestHistorySidebar_FirstShowIssuesSingleQueryBatch proves the lazy-load
376// contract behind the on-demand host: a fresh instance issues zero queries
377// (construction performs no load), the first Show-equivalent Reload issues
378// exactly one GetRecent batch, and reopening reloads with one more batch
379// according to existing semantics.
380func TestHistorySidebar_FirstShowIssuesSingleQueryBatch(t *testing.T) {
381 entries := []*entity.HistoryEntry{
382 {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
383 }
384 history := portmocks.NewMockHistorySidebarHistory(t)
385 history.EXPECT().
386 GetRecent(mock.Anything, sidebarPageSize, 0).
387 RunAndReturn(func(context.Context, int, int) ([]*entity.HistoryEntry, error) {
388 return entries, nil
389 }).
390 Times(2)
391
392 hs := newTestSidebarSearchHarness()
393 hs.historyUC = history
394 hs.ctx = t.Context()
395 hs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
396
397 // Fresh instance: construction must not have queried anything (the mock
398 // fails the test on any unexpected call at cleanup).
399 hs.mu.RLock()
400 assert.False(t, hs.loadStarted)
401 assert.False(t, hs.loadDone)
402 hs.mu.RUnlock()
403
404 waitLoaded := func(want int) {
405 t.Helper()
406 require.Eventually(t, func() bool {
407 hs.mu.RLock()
408 defer hs.mu.RUnlock()
409 return hs.loadDone && len(hs.allEntries) == want
410 }, 10*time.Second, 5*time.Millisecond, "Show load must apply fetched entries")
411 }
412
413 // First Show loads exactly one batch.
414 hs.Reload()
415 waitLoaded(1)
416 hs.mu.RLock()
417 assert.Equal(t, "https://example.com", hs.allEntries[0].URL)
418 hs.mu.RUnlock()
419
420 // Reopen reloads with one more batch (Times(2) enforces the exact count).
421 hs.Reload()
422 waitLoaded(1)
423}