detached 478ef93

test(autocomplete): add tests and fix code duplication

commit
478ef939795d72668216deb2935d2d7deac49125
author
brice · · 2026-01-24 03:47 UTC
parent
18f9d2e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +553 -6
A internal/application/usecase/autocomplete_test.go +283 -0
@@ -0,0 +1,283 @@
1+package usecase_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/domain/autocomplete"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestAutocompleteUseCase_GetSuggestion_EmptyInput(t *testing.T) {
16+ ctx := testContext()
17+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
18+
19+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: ""})
20+
21+ require.NotNil(t, output)
22+ assert.False(t, output.Found)
23+ assert.Nil(t, output.Suggestion)
24+}
25+
26+func TestAutocompleteUseCase_GetSuggestion_NilDependencies(t *testing.T) {
27+ ctx := testContext()
28+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
29+
30+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
31+
32+ require.NotNil(t, output)
33+ assert.False(t, output.Found)
34+}
35+
36+func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
37+ ctx := testContext()
38+
39+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
40+ folderRepo := repomocks.NewMockFolderRepository(t)
41+ tagRepo := repomocks.NewMockTagRepository(t)
42+
43+ favorites := []*entity.Favorite{
44+ {ID: 1, URL: "https://github.com/user/repo", Title: "My Repo"},
45+ {ID: 2, URL: "https://google.com", Title: "Google"},
46+ }
47+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
48+
49+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
50+ uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
51+
52+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
53+
54+ require.NotNil(t, output)
55+ assert.True(t, output.Found)
56+ assert.NotNil(t, output.Suggestion)
57+ assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
58+ assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText)
59+ assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix)
60+ assert.Equal(t, "My Repo", output.Suggestion.Title)
61+}
62+
63+func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) {
64+ ctx := testContext()
65+
66+ // Favorites returns empty
67+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
68+ folderRepo := repomocks.NewMockFolderRepository(t)
69+ tagRepo := repomocks.NewMockTagRepository(t)
70+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
71+
72+ // History returns a match
73+ historyRepo := repomocks.NewMockHistoryRepository(t)
74+ historyMatches := []entity.HistoryMatch{
75+ {Entry: &entity.HistoryEntry{URL: "https://news.ycombinator.com", Title: "Hacker News"}},
76+ }
77+ historyRepo.EXPECT().Search(mock.Anything, "news", 10).Return(historyMatches, nil)
78+
79+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
80+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
81+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
82+
83+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
84+
85+ require.NotNil(t, output)
86+ assert.True(t, output.Found)
87+ assert.NotNil(t, output.Suggestion)
88+ assert.Equal(t, autocomplete.SourceHistory, output.Suggestion.Source)
89+ assert.Equal(t, "news.ycombinator.com", output.Suggestion.FullText)
90+ assert.Equal(t, ".ycombinator.com", output.Suggestion.Suffix)
91+}
92+
93+func TestAutocompleteUseCase_GetSuggestion_BangShortcut(t *testing.T) {
94+ ctx := testContext()
95+
96+ shortcuts := map[string]usecase.SearchShortcut{
97+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
98+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
99+ }
100+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
101+ uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
102+
103+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "!g"})
104+
105+ require.NotNil(t, output)
106+ assert.True(t, output.Found)
107+ assert.NotNil(t, output.Suggestion)
108+ assert.Equal(t, autocomplete.SourceBangShortcut, output.Suggestion.Source)
109+ assert.Equal(t, "!gh", output.Suggestion.FullText)
110+ assert.Equal(t, "h", output.Suggestion.Suffix)
111+ assert.Equal(t, "GitHub search", output.Suggestion.Title)
112+}
113+
114+func TestAutocompleteUseCase_GetSuggestion_BangWithoutPrefix(t *testing.T) {
115+ ctx := testContext()
116+
117+ shortcuts := map[string]usecase.SearchShortcut{
118+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
119+ }
120+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
121+ uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
122+
123+ // Input without ! should not match bang shortcuts
124+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "gh"})
125+
126+ require.NotNil(t, output)
127+ assert.False(t, output.Found)
128+}
129+
130+func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
131+ ctx := testContext()
132+
133+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
134+ folderRepo := repomocks.NewMockFolderRepository(t)
135+ tagRepo := repomocks.NewMockTagRepository(t)
136+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
137+
138+ historyRepo := repomocks.NewMockHistoryRepository(t)
139+ historyRepo.EXPECT().Search(mock.Anything, "xyz123", 10).Return([]entity.HistoryMatch{}, nil)
140+
141+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
142+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
143+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
144+
145+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "xyz123"})
146+
147+ require.NotNil(t, output)
148+ assert.False(t, output.Found)
149+}
150+
151+func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {
152+ ctx := testContext()
153+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
154+
155+ tests := []struct {
156+ name string
157+ input string
158+ targetURL string
159+ }{
160+ {name: "empty input", input: "", targetURL: "https://example.com"},
161+ {name: "empty URL", input: "exa", targetURL: ""},
162+ {name: "both empty", input: "", targetURL: ""},
163+ }
164+
165+ for _, tt := range tests {
166+ t.Run(tt.name, func(t *testing.T) {
167+ output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
168+ require.NotNil(t, output)
169+ assert.False(t, output.Found)
170+ })
171+ }
172+}
173+
174+func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) {
175+ ctx := testContext()
176+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
177+
178+ tests := []struct {
179+ name string
180+ input string
181+ targetURL string
182+ wantSuffix string
183+ wantMatchedURL string
184+ }{
185+ {
186+ name: "prefix match without protocol",
187+ input: "github",
188+ targetURL: "https://github.com/user/repo",
189+ wantSuffix: ".com/user/repo",
190+ wantMatchedURL: "github.com/user/repo",
191+ },
192+ {
193+ name: "prefix match with protocol",
194+ input: "https://github",
195+ targetURL: "https://github.com/user/repo",
196+ wantSuffix: ".com/user/repo",
197+ wantMatchedURL: "https://github.com/user/repo",
198+ },
199+ {
200+ name: "case insensitive match",
201+ input: "GITHUB",
202+ targetURL: "https://github.com",
203+ wantSuffix: ".com",
204+ wantMatchedURL: "github.com",
205+ },
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
211+ require.NotNil(t, output)
212+ assert.True(t, output.Found)
213+ require.NotNil(t, output.Suggestion)
214+ assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix)
215+ assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText)
216+ })
217+ }
218+}
219+
220+func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) {
221+ ctx := testContext()
222+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
223+
224+ output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com")
225+
226+ require.NotNil(t, output)
227+ assert.False(t, output.Found)
228+}
229+
230+func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) {
231+ ctx := testContext()
232+
233+ // Both favorites and history have matches - favorites should win
234+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
235+ folderRepo := repomocks.NewMockFolderRepository(t)
236+ tagRepo := repomocks.NewMockTagRepository(t)
237+ favorites := []*entity.Favorite{
238+ {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"},
239+ }
240+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
241+
242+ // History would also match, but should not be called because favorites matched
243+ historyRepo := repomocks.NewMockHistoryRepository(t)
244+ // Note: no EXPECT on historyRepo - it shouldn't be called
245+
246+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
247+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
248+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
249+
250+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
251+
252+ require.NotNil(t, output)
253+ assert.True(t, output.Found)
254+ assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
255+ assert.Equal(t, "Favorite Repo", output.Suggestion.Title)
256+}
257+
258+func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) {
259+ ctx := testContext()
260+
261+ // Favorites returns empty
262+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
263+ folderRepo := repomocks.NewMockFolderRepository(t)
264+ tagRepo := repomocks.NewMockTagRepository(t)
265+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
266+
267+ // History returns results but none are prefix matches
268+ historyRepo := repomocks.NewMockHistoryRepository(t)
269+ historyMatches := []entity.HistoryMatch{
270+ // URL doesn't start with "git", so no prefix match
271+ {Entry: &entity.HistoryEntry{URL: "https://example.com/git", Title: "Example"}},
272+ }
273+ historyRepo.EXPECT().Search(mock.Anything, "git", 10).Return(historyMatches, nil)
274+
275+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
276+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
277+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
278+
279+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"})
280+
281+ require.NotNil(t, output)
282+ assert.False(t, output.Found)
283+}
@@ -0,0 +1,283 @@
1+package usecase_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/domain/autocomplete"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestAutocompleteUseCase_GetSuggestion_EmptyInput(t *testing.T) {
16+ ctx := testContext()
17+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
18+
19+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: ""})
20+
21+ require.NotNil(t, output)
22+ assert.False(t, output.Found)
23+ assert.Nil(t, output.Suggestion)
24+}
25+
26+func TestAutocompleteUseCase_GetSuggestion_NilDependencies(t *testing.T) {
27+ ctx := testContext()
28+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
29+
30+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
31+
32+ require.NotNil(t, output)
33+ assert.False(t, output.Found)
34+}
35+
36+func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
37+ ctx := testContext()
38+
39+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
40+ folderRepo := repomocks.NewMockFolderRepository(t)
41+ tagRepo := repomocks.NewMockTagRepository(t)
42+
43+ favorites := []*entity.Favorite{
44+ {ID: 1, URL: "https://github.com/user/repo", Title: "My Repo"},
45+ {ID: 2, URL: "https://google.com", Title: "Google"},
46+ }
47+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
48+
49+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
50+ uc := usecase.NewAutocompleteUseCase(nil, favoritesUC, nil)
51+
52+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
53+
54+ require.NotNil(t, output)
55+ assert.True(t, output.Found)
56+ assert.NotNil(t, output.Suggestion)
57+ assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
58+ assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText)
59+ assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix)
60+ assert.Equal(t, "My Repo", output.Suggestion.Title)
61+}
62+
63+func TestAutocompleteUseCase_GetSuggestion_HistoryFallback(t *testing.T) {
64+ ctx := testContext()
65+
66+ // Favorites returns empty
67+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
68+ folderRepo := repomocks.NewMockFolderRepository(t)
69+ tagRepo := repomocks.NewMockTagRepository(t)
70+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
71+
72+ // History returns a match
73+ historyRepo := repomocks.NewMockHistoryRepository(t)
74+ historyMatches := []entity.HistoryMatch{
75+ {Entry: &entity.HistoryEntry{URL: "https://news.ycombinator.com", Title: "Hacker News"}},
76+ }
77+ historyRepo.EXPECT().Search(mock.Anything, "news", 10).Return(historyMatches, nil)
78+
79+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
80+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
81+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
82+
83+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "news"})
84+
85+ require.NotNil(t, output)
86+ assert.True(t, output.Found)
87+ assert.NotNil(t, output.Suggestion)
88+ assert.Equal(t, autocomplete.SourceHistory, output.Suggestion.Source)
89+ assert.Equal(t, "news.ycombinator.com", output.Suggestion.FullText)
90+ assert.Equal(t, ".ycombinator.com", output.Suggestion.Suffix)
91+}
92+
93+func TestAutocompleteUseCase_GetSuggestion_BangShortcut(t *testing.T) {
94+ ctx := testContext()
95+
96+ shortcuts := map[string]usecase.SearchShortcut{
97+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
98+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
99+ }
100+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
101+ uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
102+
103+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "!g"})
104+
105+ require.NotNil(t, output)
106+ assert.True(t, output.Found)
107+ assert.NotNil(t, output.Suggestion)
108+ assert.Equal(t, autocomplete.SourceBangShortcut, output.Suggestion.Source)
109+ assert.Equal(t, "!gh", output.Suggestion.FullText)
110+ assert.Equal(t, "h", output.Suggestion.Suffix)
111+ assert.Equal(t, "GitHub search", output.Suggestion.Title)
112+}
113+
114+func TestAutocompleteUseCase_GetSuggestion_BangWithoutPrefix(t *testing.T) {
115+ ctx := testContext()
116+
117+ shortcuts := map[string]usecase.SearchShortcut{
118+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
119+ }
120+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
121+ uc := usecase.NewAutocompleteUseCase(nil, nil, shortcutsUC)
122+
123+ // Input without ! should not match bang shortcuts
124+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "gh"})
125+
126+ require.NotNil(t, output)
127+ assert.False(t, output.Found)
128+}
129+
130+func TestAutocompleteUseCase_GetSuggestion_NoMatch(t *testing.T) {
131+ ctx := testContext()
132+
133+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
134+ folderRepo := repomocks.NewMockFolderRepository(t)
135+ tagRepo := repomocks.NewMockTagRepository(t)
136+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
137+
138+ historyRepo := repomocks.NewMockHistoryRepository(t)
139+ historyRepo.EXPECT().Search(mock.Anything, "xyz123", 10).Return([]entity.HistoryMatch{}, nil)
140+
141+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
142+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
143+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
144+
145+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "xyz123"})
146+
147+ require.NotNil(t, output)
148+ assert.False(t, output.Found)
149+}
150+
151+func TestAutocompleteUseCase_GetSuggestionForURL_EmptyInput(t *testing.T) {
152+ ctx := testContext()
153+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
154+
155+ tests := []struct {
156+ name string
157+ input string
158+ targetURL string
159+ }{
160+ {name: "empty input", input: "", targetURL: "https://example.com"},
161+ {name: "empty URL", input: "exa", targetURL: ""},
162+ {name: "both empty", input: "", targetURL: ""},
163+ }
164+
165+ for _, tt := range tests {
166+ t.Run(tt.name, func(t *testing.T) {
167+ output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
168+ require.NotNil(t, output)
169+ assert.False(t, output.Found)
170+ })
171+ }
172+}
173+
174+func TestAutocompleteUseCase_GetSuggestionForURL_Match(t *testing.T) {
175+ ctx := testContext()
176+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
177+
178+ tests := []struct {
179+ name string
180+ input string
181+ targetURL string
182+ wantSuffix string
183+ wantMatchedURL string
184+ }{
185+ {
186+ name: "prefix match without protocol",
187+ input: "github",
188+ targetURL: "https://github.com/user/repo",
189+ wantSuffix: ".com/user/repo",
190+ wantMatchedURL: "github.com/user/repo",
191+ },
192+ {
193+ name: "prefix match with protocol",
194+ input: "https://github",
195+ targetURL: "https://github.com/user/repo",
196+ wantSuffix: ".com/user/repo",
197+ wantMatchedURL: "https://github.com/user/repo",
198+ },
199+ {
200+ name: "case insensitive match",
201+ input: "GITHUB",
202+ targetURL: "https://github.com",
203+ wantSuffix: ".com",
204+ wantMatchedURL: "github.com",
205+ },
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ output := uc.GetSuggestionForURL(ctx, tt.input, tt.targetURL)
211+ require.NotNil(t, output)
212+ assert.True(t, output.Found)
213+ require.NotNil(t, output.Suggestion)
214+ assert.Equal(t, tt.wantSuffix, output.Suggestion.Suffix)
215+ assert.Equal(t, tt.wantMatchedURL, output.Suggestion.FullText)
216+ })
217+ }
218+}
219+
220+func TestAutocompleteUseCase_GetSuggestionForURL_NoMatch(t *testing.T) {
221+ ctx := testContext()
222+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
223+
224+ output := uc.GetSuggestionForURL(ctx, "xyz", "https://github.com")
225+
226+ require.NotNil(t, output)
227+ assert.False(t, output.Found)
228+}
229+
230+func TestAutocompleteUseCase_GetSuggestion_FavoritesOverHistory(t *testing.T) {
231+ ctx := testContext()
232+
233+ // Both favorites and history have matches - favorites should win
234+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
235+ folderRepo := repomocks.NewMockFolderRepository(t)
236+ tagRepo := repomocks.NewMockTagRepository(t)
237+ favorites := []*entity.Favorite{
238+ {ID: 1, URL: "https://github.com/favorite", Title: "Favorite Repo"},
239+ }
240+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
241+
242+ // History would also match, but should not be called because favorites matched
243+ historyRepo := repomocks.NewMockHistoryRepository(t)
244+ // Note: no EXPECT on historyRepo - it shouldn't be called
245+
246+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
247+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
248+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
249+
250+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "github"})
251+
252+ require.NotNil(t, output)
253+ assert.True(t, output.Found)
254+ assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
255+ assert.Equal(t, "Favorite Repo", output.Suggestion.Title)
256+}
257+
258+func TestAutocompleteUseCase_GetSuggestion_HistoryNotPrefixMatch(t *testing.T) {
259+ ctx := testContext()
260+
261+ // Favorites returns empty
262+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
263+ folderRepo := repomocks.NewMockFolderRepository(t)
264+ tagRepo := repomocks.NewMockTagRepository(t)
265+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
266+
267+ // History returns results but none are prefix matches
268+ historyRepo := repomocks.NewMockHistoryRepository(t)
269+ historyMatches := []entity.HistoryMatch{
270+ // URL doesn't start with "git", so no prefix match
271+ {Entry: &entity.HistoryEntry{URL: "https://example.com/git", Title: "Example"}},
272+ }
273+ historyRepo.EXPECT().Search(mock.Anything, "git", 10).Return(historyMatches, nil)
274+
275+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
276+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
277+ uc := usecase.NewAutocompleteUseCase(historyUC, favoritesUC, nil)
278+
279+ output := uc.GetSuggestion(ctx, usecase.GetSuggestionInput{Input: "git"})
280+
281+ require.NotNil(t, output)
282+ assert.False(t, output.Found)
283+}
A internal/domain/autocomplete/autocomplete_test.go +268 -0
@@ -0,0 +1,268 @@
1+package autocomplete
2+
3+import "testing"
4+
5+func TestComputeCompletionSuffix(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ input string
9+ fullText string
10+ wantSuffix string
11+ wantOK bool
12+ }{
13+ {
14+ name: "empty input",
15+ input: "",
16+ fullText: "example.com",
17+ wantSuffix: "",
18+ wantOK: false,
19+ },
20+ {
21+ name: "empty fullText",
22+ input: "exa",
23+ fullText: "",
24+ wantSuffix: "",
25+ wantOK: false,
26+ },
27+ {
28+ name: "both empty",
29+ input: "",
30+ fullText: "",
31+ wantSuffix: "",
32+ wantOK: false,
33+ },
34+ {
35+ name: "exact match returns false",
36+ input: "example.com",
37+ fullText: "example.com",
38+ wantSuffix: "",
39+ wantOK: false,
40+ },
41+ {
42+ name: "prefix match",
43+ input: "exa",
44+ fullText: "example.com",
45+ wantSuffix: "mple.com",
46+ wantOK: true,
47+ },
48+ {
49+ name: "case insensitive match",
50+ input: "EXA",
51+ fullText: "example.com",
52+ wantSuffix: "mple.com",
53+ wantOK: true,
54+ },
55+ {
56+ name: "case insensitive preserves original case in suffix",
57+ input: "git",
58+ fullText: "GitHub.com",
59+ wantSuffix: "Hub.com",
60+ wantOK: true,
61+ },
62+ {
63+ name: "no prefix match",
64+ input: "xyz",
65+ fullText: "example.com",
66+ wantSuffix: "",
67+ wantOK: false,
68+ },
69+ {
70+ name: "input longer than fullText",
71+ input: "example.com/path",
72+ fullText: "example.com",
73+ wantSuffix: "",
74+ wantOK: false,
75+ },
76+ {
77+ name: "single character prefix",
78+ input: "e",
79+ fullText: "example.com",
80+ wantSuffix: "xample.com",
81+ wantOK: true,
82+ },
83+ {
84+ name: "bang shortcut prefix",
85+ input: "!g",
86+ fullText: "!github",
87+ wantSuffix: "ithub",
88+ wantOK: true,
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ suffix, ok := ComputeCompletionSuffix(tt.input, tt.fullText)
95+ if suffix != tt.wantSuffix {
96+ t.Errorf("ComputeCompletionSuffix(%q, %q) suffix = %q, want %q",
97+ tt.input, tt.fullText, suffix, tt.wantSuffix)
98+ }
99+ if ok != tt.wantOK {
100+ t.Errorf("ComputeCompletionSuffix(%q, %q) ok = %v, want %v",
101+ tt.input, tt.fullText, ok, tt.wantOK)
102+ }
103+ })
104+ }
105+}
106+
107+func TestStripProtocol(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ url string
111+ want string
112+ }{
113+ {
114+ name: "https protocol",
115+ url: "https://example.com",
116+ want: "example.com",
117+ },
118+ {
119+ name: "http protocol",
120+ url: "http://example.com",
121+ want: "example.com",
122+ },
123+ {
124+ name: "no protocol",
125+ url: "example.com",
126+ want: "example.com",
127+ },
128+ {
129+ name: "https with path",
130+ url: "https://example.com/path/to/page",
131+ want: "example.com/path/to/page",
132+ },
133+ {
134+ name: "http with query string",
135+ url: "http://example.com?query=value",
136+ want: "example.com?query=value",
137+ },
138+ {
139+ name: "file protocol unchanged",
140+ url: "file:///path/to/file",
141+ want: "file:///path/to/file",
142+ },
143+ {
144+ name: "empty string",
145+ url: "",
146+ want: "",
147+ },
148+ }
149+
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ got := StripProtocol(tt.url)
153+ if got != tt.want {
154+ t.Errorf("StripProtocol(%q) = %q, want %q", tt.url, got, tt.want)
155+ }
156+ })
157+ }
158+}
159+
160+func TestComputeURLCompletionSuffix(t *testing.T) {
161+ tests := []struct {
162+ name string
163+ input string
164+ fullURL string
165+ wantSuffix string
166+ wantMatchedURL string
167+ wantOK bool
168+ }{
169+ {
170+ name: "empty input",
171+ input: "",
172+ fullURL: "https://example.com",
173+ wantSuffix: "",
174+ wantMatchedURL: "",
175+ wantOK: false,
176+ },
177+ {
178+ name: "direct match with protocol",
179+ input: "https://exa",
180+ fullURL: "https://example.com",
181+ wantSuffix: "mple.com",
182+ wantMatchedURL: "https://example.com",
183+ wantOK: true,
184+ },
185+ {
186+ name: "match without protocol in input",
187+ input: "exa",
188+ fullURL: "https://example.com",
189+ wantSuffix: "mple.com",
190+ wantMatchedURL: "example.com",
191+ wantOK: true,
192+ },
193+ {
194+ name: "match with www prefix in URL strips www",
195+ input: "exa",
196+ fullURL: "https://www.example.com",
197+ wantSuffix: "mple.com",
198+ wantMatchedURL: "example.com",
199+ wantOK: true,
200+ },
201+ {
202+ name: "match with www in input but not URL",
203+ input: "www.exa",
204+ fullURL: "https://example.com",
205+ wantSuffix: "mple.com",
206+ wantMatchedURL: "example.com",
207+ wantOK: true,
208+ },
209+ {
210+ name: "no match",
211+ input: "xyz",
212+ fullURL: "https://example.com",
213+ wantSuffix: "",
214+ wantMatchedURL: "",
215+ wantOK: false,
216+ },
217+ {
218+ name: "case insensitive URL match",
219+ input: "GITHUB",
220+ fullURL: "https://github.com/user/repo",
221+ wantSuffix: ".com/user/repo",
222+ wantMatchedURL: "github.com/user/repo",
223+ wantOK: true,
224+ },
225+ {
226+ name: "http protocol stripped",
227+ input: "news",
228+ fullURL: "http://news.ycombinator.com",
229+ wantSuffix: ".ycombinator.com",
230+ wantMatchedURL: "news.ycombinator.com",
231+ wantOK: true,
232+ },
233+ {
234+ name: "full URL match returns false",
235+ input: "example.com",
236+ fullURL: "https://example.com",
237+ wantSuffix: "",
238+ wantMatchedURL: "",
239+ wantOK: false,
240+ },
241+ {
242+ name: "URL with path",
243+ input: "github.com/user",
244+ fullURL: "https://github.com/user/repo",
245+ wantSuffix: "/repo",
246+ wantMatchedURL: "github.com/user/repo",
247+ wantOK: true,
248+ },
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ suffix, matchedURL, ok := ComputeURLCompletionSuffix(tt.input, tt.fullURL)
254+ if suffix != tt.wantSuffix {
255+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) suffix = %q, want %q",
256+ tt.input, tt.fullURL, suffix, tt.wantSuffix)
257+ }
258+ if matchedURL != tt.wantMatchedURL {
259+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) matchedURL = %q, want %q",
260+ tt.input, tt.fullURL, matchedURL, tt.wantMatchedURL)
261+ }
262+ if ok != tt.wantOK {
263+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) ok = %v, want %v",
264+ tt.input, tt.fullURL, ok, tt.wantOK)
265+ }
266+ })
267+ }
268+}
@@ -0,0 +1,268 @@
1+package autocomplete
2+
3+import "testing"
4+
5+func TestComputeCompletionSuffix(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ input string
9+ fullText string
10+ wantSuffix string
11+ wantOK bool
12+ }{
13+ {
14+ name: "empty input",
15+ input: "",
16+ fullText: "example.com",
17+ wantSuffix: "",
18+ wantOK: false,
19+ },
20+ {
21+ name: "empty fullText",
22+ input: "exa",
23+ fullText: "",
24+ wantSuffix: "",
25+ wantOK: false,
26+ },
27+ {
28+ name: "both empty",
29+ input: "",
30+ fullText: "",
31+ wantSuffix: "",
32+ wantOK: false,
33+ },
34+ {
35+ name: "exact match returns false",
36+ input: "example.com",
37+ fullText: "example.com",
38+ wantSuffix: "",
39+ wantOK: false,
40+ },
41+ {
42+ name: "prefix match",
43+ input: "exa",
44+ fullText: "example.com",
45+ wantSuffix: "mple.com",
46+ wantOK: true,
47+ },
48+ {
49+ name: "case insensitive match",
50+ input: "EXA",
51+ fullText: "example.com",
52+ wantSuffix: "mple.com",
53+ wantOK: true,
54+ },
55+ {
56+ name: "case insensitive preserves original case in suffix",
57+ input: "git",
58+ fullText: "GitHub.com",
59+ wantSuffix: "Hub.com",
60+ wantOK: true,
61+ },
62+ {
63+ name: "no prefix match",
64+ input: "xyz",
65+ fullText: "example.com",
66+ wantSuffix: "",
67+ wantOK: false,
68+ },
69+ {
70+ name: "input longer than fullText",
71+ input: "example.com/path",
72+ fullText: "example.com",
73+ wantSuffix: "",
74+ wantOK: false,
75+ },
76+ {
77+ name: "single character prefix",
78+ input: "e",
79+ fullText: "example.com",
80+ wantSuffix: "xample.com",
81+ wantOK: true,
82+ },
83+ {
84+ name: "bang shortcut prefix",
85+ input: "!g",
86+ fullText: "!github",
87+ wantSuffix: "ithub",
88+ wantOK: true,
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ suffix, ok := ComputeCompletionSuffix(tt.input, tt.fullText)
95+ if suffix != tt.wantSuffix {
96+ t.Errorf("ComputeCompletionSuffix(%q, %q) suffix = %q, want %q",
97+ tt.input, tt.fullText, suffix, tt.wantSuffix)
98+ }
99+ if ok != tt.wantOK {
100+ t.Errorf("ComputeCompletionSuffix(%q, %q) ok = %v, want %v",
101+ tt.input, tt.fullText, ok, tt.wantOK)
102+ }
103+ })
104+ }
105+}
106+
107+func TestStripProtocol(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ url string
111+ want string
112+ }{
113+ {
114+ name: "https protocol",
115+ url: "https://example.com",
116+ want: "example.com",
117+ },
118+ {
119+ name: "http protocol",
120+ url: "http://example.com",
121+ want: "example.com",
122+ },
123+ {
124+ name: "no protocol",
125+ url: "example.com",
126+ want: "example.com",
127+ },
128+ {
129+ name: "https with path",
130+ url: "https://example.com/path/to/page",
131+ want: "example.com/path/to/page",
132+ },
133+ {
134+ name: "http with query string",
135+ url: "http://example.com?query=value",
136+ want: "example.com?query=value",
137+ },
138+ {
139+ name: "file protocol unchanged",
140+ url: "file:///path/to/file",
141+ want: "file:///path/to/file",
142+ },
143+ {
144+ name: "empty string",
145+ url: "",
146+ want: "",
147+ },
148+ }
149+
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ got := StripProtocol(tt.url)
153+ if got != tt.want {
154+ t.Errorf("StripProtocol(%q) = %q, want %q", tt.url, got, tt.want)
155+ }
156+ })
157+ }
158+}
159+
160+func TestComputeURLCompletionSuffix(t *testing.T) {
161+ tests := []struct {
162+ name string
163+ input string
164+ fullURL string
165+ wantSuffix string
166+ wantMatchedURL string
167+ wantOK bool
168+ }{
169+ {
170+ name: "empty input",
171+ input: "",
172+ fullURL: "https://example.com",
173+ wantSuffix: "",
174+ wantMatchedURL: "",
175+ wantOK: false,
176+ },
177+ {
178+ name: "direct match with protocol",
179+ input: "https://exa",
180+ fullURL: "https://example.com",
181+ wantSuffix: "mple.com",
182+ wantMatchedURL: "https://example.com",
183+ wantOK: true,
184+ },
185+ {
186+ name: "match without protocol in input",
187+ input: "exa",
188+ fullURL: "https://example.com",
189+ wantSuffix: "mple.com",
190+ wantMatchedURL: "example.com",
191+ wantOK: true,
192+ },
193+ {
194+ name: "match with www prefix in URL strips www",
195+ input: "exa",
196+ fullURL: "https://www.example.com",
197+ wantSuffix: "mple.com",
198+ wantMatchedURL: "example.com",
199+ wantOK: true,
200+ },
201+ {
202+ name: "match with www in input but not URL",
203+ input: "www.exa",
204+ fullURL: "https://example.com",
205+ wantSuffix: "mple.com",
206+ wantMatchedURL: "example.com",
207+ wantOK: true,
208+ },
209+ {
210+ name: "no match",
211+ input: "xyz",
212+ fullURL: "https://example.com",
213+ wantSuffix: "",
214+ wantMatchedURL: "",
215+ wantOK: false,
216+ },
217+ {
218+ name: "case insensitive URL match",
219+ input: "GITHUB",
220+ fullURL: "https://github.com/user/repo",
221+ wantSuffix: ".com/user/repo",
222+ wantMatchedURL: "github.com/user/repo",
223+ wantOK: true,
224+ },
225+ {
226+ name: "http protocol stripped",
227+ input: "news",
228+ fullURL: "http://news.ycombinator.com",
229+ wantSuffix: ".ycombinator.com",
230+ wantMatchedURL: "news.ycombinator.com",
231+ wantOK: true,
232+ },
233+ {
234+ name: "full URL match returns false",
235+ input: "example.com",
236+ fullURL: "https://example.com",
237+ wantSuffix: "",
238+ wantMatchedURL: "",
239+ wantOK: false,
240+ },
241+ {
242+ name: "URL with path",
243+ input: "github.com/user",
244+ fullURL: "https://github.com/user/repo",
245+ wantSuffix: "/repo",
246+ wantMatchedURL: "github.com/user/repo",
247+ wantOK: true,
248+ },
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ suffix, matchedURL, ok := ComputeURLCompletionSuffix(tt.input, tt.fullURL)
254+ if suffix != tt.wantSuffix {
255+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) suffix = %q, want %q",
256+ tt.input, tt.fullURL, suffix, tt.wantSuffix)
257+ }
258+ if matchedURL != tt.wantMatchedURL {
259+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) matchedURL = %q, want %q",
260+ tt.input, tt.fullURL, matchedURL, tt.wantMatchedURL)
261+ }
262+ if ok != tt.wantOK {
263+ t.Errorf("ComputeURLCompletionSuffix(%q, %q) ok = %v, want %v",
264+ tt.input, tt.fullURL, ok, tt.wantOK)
265+ }
266+ })
267+ }
268+}
M internal/ui/component/omnibox.go +2 -6
@@ -10,6 +10,7 @@ import (
1010 "unicode/utf8"
1111
1212 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/autocomplete"
1314 "github.com/bnema/dumber/internal/domain/entity"
1415 "github.com/bnema/dumber/internal/domain/url"
1516 "github.com/bnema/dumber/internal/logging"
@@ -916,12 +917,7 @@ func (o *Omnibox) updateGhostFromURL(targetURL string) {
916917 o.mu.RUnlock()
917918
918919 // Strip protocol from URL for cleaner display
919- displayURL := targetURL
920- if strings.HasPrefix(displayURL, "https://") {
921- displayURL = displayURL[8:]
922- } else if strings.HasPrefix(displayURL, "http://") {
923- displayURL = displayURL[7:]
924- }
920+ displayURL := autocomplete.StripProtocol(targetURL)
925921
926922 // Try to compute proper suffix if input is a prefix of the URL
927923 if o.autocompleteUC != nil && userInput != "" {
@@ -10,6 +10,7 @@ import (
10 "unicode/utf8"10 "unicode/utf8"
11 11
12 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/autocomplete"
13 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/domain/url"15 "github.com/bnema/dumber/internal/domain/url"
15 "github.com/bnema/dumber/internal/logging"16 "github.com/bnema/dumber/internal/logging"
@@ -916,12 +917,7 @@ func (o *Omnibox) updateGhostFromURL(targetURL string) {
916 o.mu.RUnlock()917 o.mu.RUnlock()
917 918
918 // Strip protocol from URL for cleaner display919 // Strip protocol from URL for cleaner display
919- displayURL := targetURL920+ displayURL := autocomplete.StripProtocol(targetURL)
920- if strings.HasPrefix(displayURL, "https://") {
921- displayURL = displayURL[8:]
922- } else if strings.HasPrefix(displayURL, "http://") {
923- displayURL = displayURL[7:]
924- }
925 921
926 // Try to compute proper suffix if input is a prefix of the URL922 // Try to compute proper suffix if input is a prefix of the URL
927 if o.autocompleteUC != nil && userInput != "" {923 if o.autocompleteUC != nil && userInput != "" {

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