main 5041353

history_sidebar_search_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close