1package component
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/puregotk/v4/gdk"
13 "github.com/bnema/puregotk/v4/glib"
14 "github.com/bnema/puregotk/v4/gtk"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17)
18
19type fakeFavoritesSidebarUC struct {
20 favorites []*entity.Favorite
21 tags []*entity.Tag
22
23 addInputs []dto.FavoriteCreateInput
24 updateInputs []dto.FavoriteUpdateInput
25 deletedIDs []entity.FavoriteID
26 shortcuts []struct {
27 id entity.FavoriteID
28 key *int
29 }
30 tagged []struct {
31 favID entity.FavoriteID
32 tagID entity.TagID
33 }
34 untagged []struct {
35 favID entity.FavoriteID
36 tagID entity.TagID
37 }
38 createdTags []struct {
39 name string
40 color string
41 }
42 err error
43 errOnSetShortcut error
44 errOnTag map[entity.TagID]error
45 errOnUntag map[entity.TagID]error
46 failUntagOnCall int
47 getAllCalled chan struct{}
48}
49
50func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) {
51 if f.getAllCalled != nil {
52 select {
53 case f.getAllCalled <- struct{}{}:
54 default:
55 }
56 }
57 return f.favorites, f.err
58}
59func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
60 return f.tags, f.err
61}
62func (f *fakeFavoritesSidebarUC) AddTag(_ context.Context, name, color string) (*entity.Tag, error) {
63 f.createdTags = append(f.createdTags, struct {
64 name string
65 color string
66 }{name: name, color: color})
67 if f.err != nil {
68 return nil, f.err
69 }
70 return &entity.Tag{ID: entity.TagID(len(f.tags) + 1), Name: name, Color: color}, nil
71}
72func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
73 f.addInputs = append(f.addInputs, input)
74 if f.err != nil {
75 return nil, f.err
76 }
77 return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
78}
79func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
80 f.updateInputs = append(f.updateInputs, input)
81 if f.err != nil {
82 return nil, f.err
83 }
84 return &entity.Favorite{ID: input.ID, Title: input.Title}, nil
85}
86func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error {
87 f.deletedIDs = append(f.deletedIDs, id)
88 return f.err
89}
90func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error {
91 f.shortcuts = append(f.shortcuts, struct {
92 id entity.FavoriteID
93 key *int
94 }{id: id, key: key})
95 if f.errOnSetShortcut != nil {
96 return f.errOnSetShortcut
97 }
98 return f.err
99}
100func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
101 f.tagged = append(f.tagged, struct {
102 favID entity.FavoriteID
103 tagID entity.TagID
104 }{favID: favID, tagID: tagID})
105 if err := f.errOnTag[tagID]; err != nil {
106 return err
107 }
108 return f.err
109}
110func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
111 f.untagged = append(f.untagged, struct {
112 favID entity.FavoriteID
113 tagID entity.TagID
114 }{favID: favID, tagID: tagID})
115 if err := f.errOnUntag[tagID]; err != nil {
116 return err
117 }
118 if f.failUntagOnCall > 0 && len(f.untagged) == f.failUntagOnCall {
119 return errors.New("untag failed")
120 }
121 return f.err
122}
123func (f *fakeFavoritesSidebarUC) UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, wanted []entity.TagID) error {
124 wantedSet := make(map[entity.TagID]struct{}, len(wanted))
125 for _, id := range wanted {
126 wantedSet[id] = struct{}{}
127 }
128 var current []entity.Tag
129 for _, favorite := range f.favorites {
130 if favorite != nil && favorite.ID == favID {
131 current = favorite.Tags
132 break
133 }
134 }
135 currentSet := make(map[entity.TagID]struct{}, len(current))
136 for _, tag := range current {
137 currentSet[tag.ID] = struct{}{}
138 if _, keep := wantedSet[tag.ID]; !keep {
139 if err := f.UntagFavorite(ctx, favID, tag.ID); err != nil {
140 return err
141 }
142 }
143 }
144 for _, id := range wanted {
145 if _, exists := currentSet[id]; exists {
146 continue
147 }
148 if err := f.TagFavorite(ctx, favID, id); err != nil {
149 return err
150 }
151 }
152 return nil
153}
154
155func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar {
156 fs := &FavoritesSidebar{
157 favoritesUC: &fakeFavoritesSidebarUC{favorites: favorites, tags: tags},
158 selectedTagIDs: make(map[entity.TagID]struct{}),
159 ctx: context.Background(),
160 idleScheduler: func(cb glib.SourceFunc) {
161 cb(0)
162 },
163 }
164 return fs
165}
166
167func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) {
168 fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2}
169 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}})
170 fs.loadGen = 1
171
172 applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil)
173
174 require.True(t, applied)
175 assert.Len(t, fs.allFavorites, 1)
176 assert.Len(t, fs.allTags, 1)
177 require.Len(t, fs.displayRows, 1)
178 assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
179 assert.Equal(t, "https://go.dev", fs.displayRows[0].URL)
180}
181
182func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) {
183 devTag := entity.Tag{ID: 1, Name: "Dev"}
184 newsTag := entity.Tag{ID: 2, Name: "News"}
185 fs := newFavoritesSidebarHarness([]*entity.Favorite{
186 {ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1},
187 {ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2},
188 }, nil)
189 fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
190
191 fs.currentQuery = "golang"
192 fs.rebuildDisplayRowsLocked()
193 require.Len(t, fs.displayRows, 1)
194 assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
195
196 fs.currentQuery = "example.com"
197 fs.rebuildDisplayRowsLocked()
198 require.Len(t, fs.displayRows, 1)
199 assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID)
200
201 fs.currentQuery = "dev"
202 fs.rebuildDisplayRowsLocked()
203 require.Len(t, fs.displayRows, 1)
204 assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
205}
206
207func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) {
208 fs := newFavoritesSidebarHarness([]*entity.Favorite{
209 {ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}},
210 {ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}},
211 {ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}},
212 }, nil)
213 fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites
214 fs.selectedTagIDs[1] = struct{}{}
215 fs.selectedTagIDs[2] = struct{}{}
216
217 fs.rebuildDisplayRowsLocked()
218
219 require.Len(t, fs.displayRows, 2)
220 assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID)
221 assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID)
222}
223
224func TestFavoritesSidebarActivationCallbacks(t *testing.T) {
225 fs := newFavoritesSidebarHarness(nil, nil)
226 fs.ctx = context.Background()
227 fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
228 var mu sync.Mutex
229 called := []string{}
230 fs.onNavigate = func(_ context.Context, url string) error {
231 mu.Lock()
232 defer mu.Unlock()
233 called = append(called, "nav:"+url)
234 return nil
235 }
236 fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
237 mu.Lock()
238 defer mu.Unlock()
239 called = append(called, "keep:"+url)
240 return nil
241 }
242 fs.onOpenInNewPane = func(_ context.Context, url string) error {
243 mu.Lock()
244 defer mu.Unlock()
245 called = append(called, "pane:"+url)
246 return nil
247 }
248
249 fs.navigateToURL("https://a.test")
250 fs.navigateWithoutClosing("https://b.test")
251 fs.navigateToNewPane("https://c.test")
252
253 assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called)
254}
255
256func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) {
257 called := make(chan struct{}, 1)
258 uc := &fakeFavoritesSidebarUC{
259 favorites: []*entity.Favorite{{ID: 1, URL: "https://go.dev", Title: "Go"}},
260 tags: []*entity.Tag{{ID: 10, Name: "dev"}},
261 getAllCalled: called,
262 }
263 fs := newFavoritesSidebarHarness(nil, nil)
264 fs.favoritesUC = uc
265 fs.visible = true
266
267 fs.RequestReloadIfVisible("test")
268
269 require.Eventually(t, func() bool {
270 select {
271 case <-called:
272 return true
273 default:
274 return false
275 }
276 }, time.Second, 10*time.Millisecond)
277 require.Eventually(t, func() bool {
278 fs.mu.RLock()
279 defer fs.mu.RUnlock()
280 return len(fs.allFavorites) == 1 && len(fs.allTags) == 1
281 }, time.Second, 10*time.Millisecond)
282}
283
284func TestFavoritesSidebarStaleLoadRejected(t *testing.T) {
285 fs := newFavoritesSidebarHarness(nil, nil)
286 fs.loadGen = 2
287
288 applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil)
289
290 assert.False(t, applied)
291 assert.Empty(t, fs.allFavorites)
292 assert.Empty(t, fs.displayRows)
293}
294
295func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) {
296 fs := newFavoritesSidebarHarness(nil, nil)
297 fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
298 called := false
299 fs.onNavigate = func(context.Context, string) error {
300 called = true
301 return nil
302 }
303 fs.destroyed = true
304
305 fs.navigateToURL("https://example.test")
306 fs.toggleTag(1)
307 fs.onSearchChanged()
308
309 assert.False(t, called)
310 assert.Empty(t, fs.selectedTagIDs)
311}
312
313func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) {
314 fs := newFavoritesSidebarHarness(nil, nil)
315 fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
316 fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") }
317
318 fs.navigateToURL("https://example.test")
319
320 assert.Equal(t, "navigation failed", fs.notice)
321}
322
323func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) {
324 fs := newFavoritesSidebarHarness(nil, nil)
325 fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}}
326 fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{
327 {ID: 1, URL: "https://go.dev", Title: "Go"},
328 {ID: 2, URL: "", Title: "Missing URL"},
329 })
330
331 assert.Len(t, fs.allTags, 2)
332 require.Len(t, fs.displayRows, 2)
333 assert.True(t, fs.displayRows[0].Selectable)
334 assert.False(t, fs.displayRows[1].Selectable)
335 assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true))
336 assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false))
337}
338
339func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) {
340 assert.True(t, shouldFocusSearchForSlash(false))
341 assert.False(t, shouldFocusSearchForSlash(true))
342}
343
344func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) {
345 fs := newFavoritesSidebarHarness(nil, nil)
346 fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) }
347 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}}
348 var called []string
349 fs.onNavigate = func(_ context.Context, url string) error {
350 called = append(called, "nav:"+url)
351 return nil
352 }
353 fs.onNavigateKeepOpen = func(_ context.Context, url string) error {
354 called = append(called, "keep:"+url)
355 return nil
356 }
357 fs.onOpenInNewPane = func(_ context.Context, url string) error {
358 called = append(called, "pane:"+url)
359 return nil
360 }
361
362 assert.True(t, fs.handleEnterKey(0))
363 assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue))
364 assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue))
365
366 assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called)
367}
368
369func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) {
370 fs := newFavoritesSidebarHarness(nil, nil)
371 fs.searchEntry = nil
372 fs.tagBox = nil
373 fs.listBox = nil
374 assert.Empty(t, fs.availableFocusZones())
375
376 fs.formBox = nil
377 fs.mode = favoritesSidebarModeAdd
378 fs.confirmDelete = true
379 assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones())
380}
381
382func TestFavoritesSidebarSingleKeyCommandsStartManagementOutsideTextEditContext(t *testing.T) {
383 fs := newFavoritesSidebarHarness(nil, nil)
384 fs.searchEntry = nil
385 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}}
386
387 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), 0))
388 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
389
390 fs.mode = favoritesSidebarModeNone
391 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
392 assert.Equal(t, favoritesSidebarModeEdit, fs.mode)
393}
394
395func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
396 uc := &fakeFavoritesSidebarUC{}
397 fs := newFavoritesSidebarHarness(nil, nil)
398 fs.favoritesUC = uc
399 fs.formURL = "https://add.test"
400 fs.formTitle = "Added"
401 fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}}
402 fs.formShortcut = "3"
403 fs.mode = favoritesSidebarModeAdd
404
405 assert.True(t, fs.submitForm())
406 require.Len(t, uc.addInputs, 1)
407 assert.ElementsMatch(t, []entity.TagID{1, 2}, uc.addInputs[0].Tags)
408 require.Len(t, uc.shortcuts, 1)
409 assert.Equal(t, 3, *uc.shortcuts[0].key)
410
411 fs.mode = favoritesSidebarModeEdit
412 fs.editingID = 7
413 fs.formTitle = "Edited"
414 fs.formShortcut = "4"
415 assert.True(t, fs.submitForm())
416 require.Len(t, uc.updateInputs, 1)
417 assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
418 assert.Equal(t, "Edited", uc.updateInputs[0].Title)
419 require.NotNil(t, uc.updateInputs[0].ShortcutKey)
420 assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey)
421
422 fs.mode = favoritesSidebarModeEdit
423 fs.editingID = 7
424 fs.formTitle = "Edited"
425 fs.formShortcut = ""
426 assert.True(t, fs.submitForm())
427 require.Len(t, uc.updateInputs, 2)
428 assert.True(t, uc.updateInputs[1].ShortcutKeySet)
429 assert.Nil(t, uc.updateInputs[1].ShortcutKey)
430}
431
432func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) {
433 uc := &fakeFavoritesSidebarUC{}
434 fs := newFavoritesSidebarHarness(nil, nil)
435 fs.favoritesUC = uc
436 fs.formURL = "https://add.test"
437 fs.formTitle = "Added"
438 fs.formShortcut = "10"
439 fs.mode = favoritesSidebarModeAdd
440
441 assert.True(t, fs.submitForm())
442 assert.Empty(t, uc.addInputs)
443 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
444 assert.Contains(t, fs.notice, "invalid shortcut")
445 assert.Equal(t, "10", fs.formShortcut)
446}
447
448func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T) {
449 dev := &entity.Tag{ID: 10, Name: "dev"}
450 news := &entity.Tag{ID: 11, Name: "news"}
451 reading := &entity.Tag{ID: 12, Name: "reading"}
452 selected := map[entity.TagID]struct{}{dev.ID: {}}
453
454 assert.Equal(t, []*entity.Tag{dev}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, ""))
455 assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read"))
456}
457
458func TestFavoritesSidebarReloadsAfterPartiallyApplyingTagChanges(t *testing.T) {
459 first := entity.Tag{ID: 10, Name: "first"}
460 second := entity.Tag{ID: 11, Name: "second"}
461 fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{first, second}}
462 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
463 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
464 uc.failUntagOnCall = 2
465 fs.allFavorites = []*entity.Favorite{fav}
466 fs.loadGen = 4
467
468 assert.False(t, fs.updateFavoriteTags(fav.ID, nil))
469 assert.Len(t, uc.untagged, 2)
470 assert.Equal(t, uint64(5), fs.loadGen, "a partial tag mutation must refresh stale favorites")
471 assert.Equal(t, "untag failed", fs.notice)
472}
473
474func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
475 dev := entity.Tag{ID: 10, Name: "dev"}
476 news := entity.Tag{ID: 11, Name: "news"}
477 fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
478 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
479 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
480 fs.allFavorites = []*entity.Favorite{fav}
481 fs.formTagIDs = tagIDSet(fav.Tags)
482
483 fs.toggleFormTag(news.ID)
484 assert.ElementsMatch(t, []entity.TagID{dev.ID, news.ID}, fs.formTagIDsSnapshot())
485 assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
486 require.Len(t, uc.tagged, 1)
487 assert.Equal(t, news.ID, uc.tagged[0].tagID)
488 fav.Tags = []entity.Tag{dev, news}
489
490 fs.toggleFormTag(dev.ID)
491 assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
492 require.Len(t, uc.untagged, 1)
493 assert.Equal(t, dev.ID, uc.untagged[0].tagID)
494 assert.Len(t, uc.tagged, 1, "the synchronized persisted tags must not be added again")
495}
496
497func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) {
498 fav := &entity.Favorite{ID: 1, URL: "https://go.dev"}
499 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
500 if !gtk.InitCheck() {
501 t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
502 }
503 listBox := gtk.NewListBox()
504 require.NotNil(t, listBox)
505 row := gtk.NewListBoxRow()
506 require.NotNil(t, row)
507 row.SetSelectable(true)
508 listBox.Append(&row.Widget)
509 fs.listBox = listBox
510 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
511 fs.focusZone = favoritesSidebarFocusSearch
512
513 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
514 assert.Equal(t, favoritesSidebarFocusList, fs.focusZone)
515 assert.Equal(t, row, fs.listBox.GetSelectedRow())
516 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
517 assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone)
518}
519
520func TestFavoritesSidebarDirectionalFocusConsumesArrowsOnTagControls(t *testing.T) {
521 fs := newFavoritesSidebarHarness(nil, nil)
522 fs.focusZone = favoritesSidebarFocusTags
523
524 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
525 assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
526 assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
527 assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
528}
529
530func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
531 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
532 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
533 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
534 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
535 fs.confirmDelete = true
536 fs.confirmDeleteID = fav.ID
537
538 assert.True(t, fs.confirmDeleteActive())
539 assert.True(t, fs.confirmDeleteFavorite())
540 assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
541}
542
543func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) {
544 uc := &fakeFavoritesSidebarUC{}
545 fs := newFavoritesSidebarHarness(nil, nil)
546 fs.favoritesUC = uc
547 fs.formURL = "https://add.test"
548 fs.formTitle = "Added"
549 fs.mode = favoritesSidebarModeAdd
550 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}}
551 fs.onNavigate = func(context.Context, string) error {
552 t.Fatal("plain Enter in form mode must not activate selected row")
553 return nil
554 }
555
556 assert.False(t, fs.handleReturnKey(0))
557 assert.Empty(t, uc.addInputs)
558 fs.mode = favoritesSidebarModeAdd
559 assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue))
560 require.Len(t, uc.addInputs, 1)
561}
562
563func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
564 uc := &fakeFavoritesSidebarUC{}
565 fs := newFavoritesSidebarHarness(nil, nil)
566 fs.favoritesUC = uc
567 fs.mode = favoritesSidebarModeEdit
568 fs.editingID = 7
569 fs.formTitle = "Edited"
570
571 assert.True(t, fs.submitForm())
572 require.Len(t, uc.updateInputs, 1)
573 assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID)
574 assert.Empty(t, uc.tagged)
575 assert.Empty(t, uc.untagged)
576}
577
578func TestFavoritesSidebarCreateTagTrimsNameAndUsesApplicationBoundary(t *testing.T) {
579 fs := newFavoritesSidebarHarness(nil, nil)
580 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
581
582 assert.True(t, fs.createTag(" reading "))
583 require.Len(t, uc.createdTags, 1)
584 assert.Equal(t, "reading", uc.createdTags[0].name)
585 assert.Empty(t, uc.createdTags[0].color)
586
587 assert.True(t, fs.createTag(" \t "))
588 assert.Len(t, uc.createdTags, 1)
589 assert.Equal(t, "Tag name is required", fs.notice)
590}
591
592func TestFavoritesSidebarTagNameSubmissionCreatesTag(t *testing.T) {
593 fs := newFavoritesSidebarHarness(nil, nil)
594 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
595
596 assert.True(t, fs.submitTagName(" work "))
597 require.Len(t, uc.createdTags, 1)
598 assert.Equal(t, "work", uc.createdTags[0].name)
599}
600
601func TestFavoritesSidebarTagBindingTogglesExistingTag(t *testing.T) {
602 dev := entity.Tag{ID: 10, Name: "dev"}
603 news := entity.Tag{ID: 11, Name: "news"}
604 fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
605 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
606 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
607 fs.allFavorites = []*entity.Favorite{fav}
608
609 fs.toggleFavoriteTag(fav.ID, dev.ID)
610 require.Len(t, uc.untagged, 1)
611 assert.Equal(t, dev.ID, uc.untagged[0].tagID)
612 fs.toggleFavoriteTag(fav.ID, news.ID)
613 require.Len(t, uc.tagged, 1)
614 assert.Equal(t, news.ID, uc.tagged[0].tagID)
615}
616
617func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testing.T) {
618 fs := newFavoritesSidebarHarness(nil, nil)
619 input := >k.Widget{}
620
621 fs.setTextInputFocused(input)
622 assert.True(t, fs.inTextEditContext())
623 assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
624 assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
625 fs.mode = favoritesSidebarModeAdd
626 assert.True(t, fs.cancelManagement())
627 assert.Equal(t, favoritesSidebarModeNone, fs.mode)
628
629 fs.clearTextInputFocus(input)
630 assert.False(t, fs.inTextEditContext())
631 assert.False(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
632}
633
634func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) {
635 for _, key := range []uint{
636 uint(gdk.KEY_a),
637 uint(gdk.KEY_e),
638 uint(gdk.KEY_s),
639 uint(gdk.KEY_r),
640 uint(gdk.KEY_c),
641 uint(gdk.KEY_t),
642 uint(gdk.KEY_plus),
643 uint(gdk.KEY_Up),
644 uint(gdk.KEY_Return),
645 } {
646 assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key)
647 }
648 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab)))
649 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab)))
650 assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Escape)))
651 assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e)))
652}
653
654func TestFavoritesSidebarPlusAndTDoNotHijackTextInput(t *testing.T) {
655 fs := newFavoritesSidebarHarness(nil, nil)
656
657 assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_t), 0))
658 assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_plus), 0))
659 assert.Equal(t, favoritesSidebarModeNone, fs.mode)
660}
661
662func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
663 fs := newFavoritesSidebarHarness(nil, nil)
664 fs.searchEntry = nil
665
666 assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), gdk.ControlMaskValue))
667 assert.Equal(t, favoritesSidebarModeNone, fs.mode)
668}
669
670func TestFavoritesSidebarConfirmDeleteRequiresConfirmationState(t *testing.T) {
671 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
672 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
673 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
674 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
675
676 assert.True(t, fs.confirmDeleteFavorite())
677 assert.Empty(t, uc.deletedIDs)
678 assert.True(t, fs.confirmDelete)
679}
680
681func TestFavoritesSidebarConfirmDeleteRepromptsWhenSelectionChanges(t *testing.T) {
682 first := &entity.Favorite{ID: 5, URL: "https://first.test"}
683 second := &entity.Favorite{ID: 6, URL: "https://second.test"}
684 fs := newFavoritesSidebarHarness([]*entity.Favorite{first, second}, nil)
685 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
686 fs.displayRows = []favoriteSidebarDisplayRow{
687 {FavoriteID: second.ID, Favorite: second, URL: second.URL, Selectable: true},
688 {FavoriteID: first.ID, Favorite: first, URL: first.URL, Selectable: true},
689 }
690 fs.confirmDelete = true
691 fs.confirmDeleteID = first.ID
692
693 assert.True(t, fs.confirmDeleteFavorite())
694 assert.Empty(t, uc.deletedIDs)
695 assert.True(t, fs.confirmDelete)
696 assert.Equal(t, second.ID, fs.confirmDeleteID)
697}
698
699func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
700 uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}
701 fs := newFavoritesSidebarHarness(nil, nil)
702 fs.favoritesUC = uc
703 loaded := make(chan glib.SourceFunc, 1)
704 fs.idleScheduler = func(cb glib.SourceFunc) { loaded <- cb }
705 fs.formURL = "https://add.test"
706 fs.formTitle = "Added"
707 fs.formShortcut = "3"
708 fs.mode = favoritesSidebarModeAdd
709
710 assert.True(t, fs.submitForm())
711 require.Len(t, uc.addInputs, 1)
712 select {
713 case cb := <-loaded:
714 cb(0)
715 case <-time.After(time.Second):
716 t.Fatal("timed out waiting for favorites reload")
717 }
718 assert.Equal(t, favoritesSidebarModeNone, fs.mode)
719 assert.Contains(t, fs.notice, "failed to set shortcut")
720}
721
722func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) {
723 dev := entity.Tag{ID: 10, Name: "dev"}
724 fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
725 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev})
726 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
727 fs.allTags = uc.tags
728 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
729
730 fs.mode = favoritesSidebarModeShortcut
731 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
732 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))
733 require.GreaterOrEqual(t, len(uc.shortcuts), 2)
734 assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key)
735 assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key)
736
737 fs.mode = favoritesSidebarModeNone
738 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
739 assert.True(t, fs.confirmDelete)
740 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0))
741 assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs)
742}
743
744func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) {
745 fs := newFavoritesSidebarHarness(nil, nil)
746 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
747 uc.err = errors.New("add failed")
748 fs.mode = favoritesSidebarModeAdd
749
750 assert.True(t, fs.submitForm())
751 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
752 assert.Equal(t, "add failed", fs.notice)
753}