omnibox_initial_behavior_test.go RawHistoryFolder

1package component
2
3import (
4	"context"
5	"errors"
6	"testing"
7	"time"
8
9	appusecase "github.com/bnema/dumber/internal/application/usecase"
10	"github.com/bnema/dumber/internal/domain/entity"
11	repository "github.com/bnema/dumber/internal/domain/repository"
12	repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15)
16
17func TestOmniboxInitialBehaviorBadgeState(t *testing.T) {
18	tests := []struct {
19		name        string
20		behavior    entity.OmniboxInitialBehavior
21		wantVisible bool
22		wantLabel   string
23		wantTooltip string
24		wantNext    entity.OmniboxInitialBehavior
25	}{
26		{
27			name:        "recent",
28			behavior:    entity.OmniboxInitialBehaviorRecent,
29			wantVisible: true,
30			wantLabel:   "Recent",
31			wantTooltip: "Default history order · Ctrl+R to switch",
32			wantNext:    entity.OmniboxInitialBehaviorMostVisited,
33		},
34		{
35			name:        "most visited",
36			behavior:    entity.OmniboxInitialBehaviorMostVisited,
37			wantVisible: true,
38			wantLabel:   "Most used",
39			wantTooltip: "Default history order · Ctrl+R to switch",
40			wantNext:    entity.OmniboxInitialBehaviorRecent,
41		},
42		{
43			name:     "none",
44			behavior: entity.OmniboxInitialBehaviorNone,
45		},
46		{
47			name:     "invalid",
48			behavior: entity.OmniboxInitialBehavior("unsupported"),
49		},
50	}
51
52	for _, tt := range tests {
53		t.Run(tt.name, func(t *testing.T) {
54			got := initialBehaviorBadgeState(tt.behavior)
55			assert.Equal(t, tt.wantVisible, got.visible)
56			assert.Equal(t, tt.wantLabel, got.label)
57			assert.Equal(t, tt.wantTooltip, got.tooltip)
58			assert.Equal(t, tt.wantNext, got.nextBehavior)
59		})
60	}
61}
62
63func TestShouldRefreshInitialHistoryAfterToggle(t *testing.T) {
64	tests := []struct {
65		name        string
66		viewMode    ViewMode
67		query       string
68		behavior    entity.OmniboxInitialBehavior
69		wantRefresh bool
70	}{
71		{
72			name:        "empty history mode",
73			viewMode:    ViewModeHistory,
74			query:       "",
75			behavior:    entity.OmniboxInitialBehaviorRecent,
76			wantRefresh: true,
77		},
78		{
79			name:        "typed query",
80			viewMode:    ViewModeHistory,
81			query:       "go",
82			behavior:    entity.OmniboxInitialBehaviorRecent,
83			wantRefresh: false,
84		},
85		{
86			name:        "favorites mode",
87			viewMode:    ViewModeFavorites,
88			query:       "",
89			behavior:    entity.OmniboxInitialBehaviorRecent,
90			wantRefresh: false,
91		},
92		{
93			name:        "unsupported behavior",
94			viewMode:    ViewModeHistory,
95			query:       "",
96			behavior:    entity.OmniboxInitialBehaviorNone,
97			wantRefresh: false,
98		},
99	}
100
101	for _, tt := range tests {
102		t.Run(tt.name, func(t *testing.T) {
103			assert.Equal(t, tt.wantRefresh, shouldRefreshInitialHistoryAfterToggle(tt.viewMode, tt.query, tt.behavior))
104		})
105	}
106}
107
108func TestOmniboxToggleInitialBehaviorPreference_SaveFailureKeepsBehaviorAndShowsToast(t *testing.T) {
109	o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, ctx: context.Background()}
110	o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error {
111		return errors.New("boom")
112	}
113
114	var gotMessage string
115	var gotLevel ToastLevel
116	o.onToast = func(_ context.Context, message string, level ToastLevel) {
117		gotMessage = message
118		gotLevel = level
119	}
120
121	refreshCalls := 0
122	origLoadInitialHistoryFn := loadInitialHistoryFn
123	loadInitialHistoryFn = func(*Omnibox, uint64) {
124		refreshCalls++
125	}
126	t.Cleanup(func() {
127		loadInitialHistoryFn = origLoadInitialHistoryFn
128	})
129
130	assert.False(t, o.toggleInitialBehaviorPreference())
131	assert.Equal(t, entity.OmniboxInitialBehaviorRecent, o.initialBehavior)
132	assert.Equal(t, "Failed to save default history order", gotMessage)
133	assert.Equal(t, ToastError, gotLevel)
134	assert.Equal(t, 0, refreshCalls)
135}
136
137func TestOmniboxToggleInitialBehaviorPreference_RefreshesEmptyHistory(t *testing.T) {
138	o := &Omnibox{initialBehavior: entity.OmniboxInitialBehaviorRecent, viewMode: ViewModeHistory, ctx: context.Background()}
139	o.saveInitialBehaviorFn = func(context.Context, entity.OmniboxInitialBehavior) error {
140		return nil
141	}
142
143	refreshCalls := 0
144	origLoadInitialHistoryFn := loadInitialHistoryFn
145	loadInitialHistoryFn = func(*Omnibox, uint64) {
146		refreshCalls++
147	}
148	t.Cleanup(func() {
149		loadInitialHistoryFn = origLoadInitialHistoryFn
150	})
151
152	assert.True(t, o.toggleInitialBehaviorPreference())
153	assert.Equal(t, entity.OmniboxInitialBehaviorMostVisited, o.initialBehavior)
154	assert.Equal(t, 1, refreshCalls)
155}
156
157// omniboxSuggestionUpdatesInline makes suggestion refreshes run synchronously
158// on the calling goroutine instead of through the GTK main loop.
159func omniboxSuggestionUpdatesInline(o *Omnibox) {
160	o.scheduleSuggestionUpdate = func(fn func()) { fn() }
161}
162
163// scopeCutoffWithin matches a history scope whose cutoff is approximately the
164// given age, tolerating the small delay between test setup and query execution.
165func scopeCutoffWithin(want, tolerance time.Duration) any {
166	return mock.MatchedBy(func(scope repository.HistoryScope) bool {
167		if scope.Cutoff.IsZero() {
168			return false
169		}
170		diff := time.Since(scope.Cutoff) - want
171		if diff < 0 {
172			diff = -diff
173		}
174		return diff <= tolerance
175	})
176}
177
178// scopeUnbounded matches a history scope with no age restriction.
179func scopeUnbounded() any {
180	return mock.MatchedBy(func(scope repository.HistoryScope) bool {
181		return scope.Cutoff.IsZero()
182	})
183}
184
185func TestOmniboxLoadInitialHistory_UsesCapturedInitialBehavior(t *testing.T) {
186	repo := repomocks.NewMockHistoryRepository(t)
187	done := make(chan struct{})
188	repo.EXPECT().
189		GetRecent(mock.Anything, mock.Anything, 0, mock.Anything).
190		Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }).
191		Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
192
193	o := &Omnibox{
194		historyUC:       appusecase.NewSearchHistoryUseCase(repo),
195		initialBehavior: entity.OmniboxInitialBehaviorRecent,
196		maxHistoryDays:  30,
197		ctx:             context.Background(),
198	}
199	omniboxSuggestionUpdatesInline(o)
200
201	o.loadInitialHistory(1)
202	o.initialBehavior = entity.OmniboxInitialBehaviorMostVisited
203
204	select {
205	case <-done:
206	case <-time.After(2 * time.Second):
207		t.Fatal("timed out waiting for recent-history load")
208	}
209}
210
211func TestOmniboxLoadInitialHistory_RecentUsesConfiguredWindow(t *testing.T) {
212	repo := repomocks.NewMockHistoryRepository(t)
213	done := make(chan struct{})
214	repo.EXPECT().
215		GetRecent(mock.Anything, mock.Anything, 0, scopeCutoffWithin(7*24*time.Hour, time.Minute)).
216		Run(func(context.Context, int, int, repository.HistoryScope) { close(done) }).
217		Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
218
219	o := &Omnibox{
220		historyUC:       appusecase.NewSearchHistoryUseCase(repo),
221		initialBehavior: entity.OmniboxInitialBehaviorRecent,
222		maxHistoryDays:  7,
223		ctx:             context.Background(),
224	}
225	omniboxSuggestionUpdatesInline(o)
226
227	o.loadInitialHistory(1)
228
229	select {
230	case <-done:
231	case <-time.After(2 * time.Second):
232		t.Fatal("timed out waiting for recent-history load")
233	}
234}
235
236func TestOmniboxLoadInitialHistory_MostVisitedUsesConfiguredWindow(t *testing.T) {
237	repo := repomocks.NewMockHistoryRepository(t)
238	done := make(chan struct{})
239	repo.EXPECT().
240		GetMostVisitedWithin(mock.Anything, mock.Anything, scopeCutoffWithin(7*24*time.Hour, time.Minute)).
241		Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
242		Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
243
244	o := &Omnibox{
245		historyUC:       appusecase.NewSearchHistoryUseCase(repo),
246		initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
247		maxHistoryDays:  7,
248		ctx:             context.Background(),
249	}
250	omniboxSuggestionUpdatesInline(o)
251
252	o.loadInitialHistory(1)
253
254	select {
255	case <-done:
256	case <-time.After(2 * time.Second):
257		t.Fatal("timed out waiting for configured most-visited load")
258	}
259}
260
261func TestOmniboxLoadInitialHistory_MostVisitedZeroWindowUsesAllHistory(t *testing.T) {
262	repo := repomocks.NewMockHistoryRepository(t)
263	done := make(chan struct{})
264	repo.EXPECT().
265		GetMostVisitedWithin(mock.Anything, mock.Anything, scopeUnbounded()).
266		Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
267		Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
268
269	o := &Omnibox{
270		historyUC:       appusecase.NewSearchHistoryUseCase(repo),
271		initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
272		maxHistoryDays:  0,
273		ctx:             context.Background(),
274	}
275	omniboxSuggestionUpdatesInline(o)
276
277	o.loadInitialHistory(1)
278
279	select {
280	case <-done:
281	case <-time.After(2 * time.Second):
282		t.Fatal("timed out waiting for all-history most-visited load")
283	}
284}
285
286func TestOmniboxLoadInitialHistory_MostVisitedBoundsSQL(t *testing.T) {
287	repo := repomocks.NewMockHistoryRepository(t)
288	done := make(chan struct{})
289	repo.EXPECT().
290		GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxResults, mock.Anything).
291		Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
292		Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
293
294	o := &Omnibox{
295		historyUC:       appusecase.NewSearchHistoryUseCase(repo),
296		initialBehavior: entity.OmniboxInitialBehaviorMostVisited,
297		maxHistoryDays:  30,
298		ctx:             context.Background(),
299	}
300	omniboxSuggestionUpdatesInline(o)
301
302	o.loadInitialHistory(1)
303
304	select {
305	case <-done:
306	case <-time.After(2 * time.Second):
307		t.Fatal("timed out waiting for bounded most-visited load")
308	}
309}
310
311func TestOmniboxSearchHistory_PassesConfiguredAgeScope(t *testing.T) {
312	repo := repomocks.NewMockHistoryRepository(t)
313	done := make(chan struct{})
314	repo.EXPECT().
315		Search(mock.Anything, "example", 5, scopeCutoffWithin(30*24*time.Hour, time.Minute)).
316		Run(func(context.Context, string, int, repository.HistoryScope) { close(done) }).
317		Return([]entity.HistoryMatch{}, nil)
318
319	o := &Omnibox{
320		historyUC:      appusecase.NewSearchHistoryUseCase(repo),
321		maxHistoryDays: 30,
322		ctx:            context.Background(),
323	}
324	omniboxSuggestionUpdatesInline(o)
325
326	o.searchHistory("example", 5, 1)
327
328	select {
329	case <-done:
330	case <-time.After(2 * time.Second):
331		t.Fatal("timed out waiting for typed history search")
332	}
333}

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