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