package component import ( "context" "errors" "sync" "sync/atomic" "testing" "time" "github.com/bnema/dumber/internal/application/dto" "github.com/bnema/dumber/internal/domain/entity" "github.com/bnema/puregotk/v4/gdk" "github.com/bnema/puregotk/v4/glib" "github.com/bnema/puregotk/v4/gtk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type fakeFavoritesSidebarUC struct { favorites []*entity.Favorite tags []*entity.Tag addInputs []dto.FavoriteCreateInput updateInputs []dto.FavoriteUpdateInput deletedIDs []entity.FavoriteID shortcuts []struct { id entity.FavoriteID key *int } tagged []struct { favID entity.FavoriteID tagID entity.TagID } untagged []struct { favID entity.FavoriteID tagID entity.TagID } createdTags []struct { name string color string } err error errOnSetShortcut error errOnTag map[entity.TagID]error errOnUntag map[entity.TagID]error failUntagOnCall int getAllCalled chan struct{} getAllCalls atomic.Int64 getAllTagsCalls atomic.Int64 } func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) { f.getAllCalls.Add(1) if f.getAllCalled != nil { select { case f.getAllCalled <- struct{}{}: default: } } return f.favorites, f.err } func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { f.getAllTagsCalls.Add(1) return f.tags, f.err } func (f *fakeFavoritesSidebarUC) AddTag(_ context.Context, name, color string) (*entity.Tag, error) { f.createdTags = append(f.createdTags, struct { name string color string }{name: name, color: color}) if f.err != nil { return nil, f.err } return &entity.Tag{ID: entity.TagID(len(f.tags) + 1), Name: name, Color: color}, nil } func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { f.addInputs = append(f.addInputs, input) if f.err != nil { return nil, f.err } return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil } func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { f.updateInputs = append(f.updateInputs, input) if f.err != nil { return nil, f.err } return &entity.Favorite{ID: input.ID, Title: input.Title}, nil } func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error { f.deletedIDs = append(f.deletedIDs, id) return f.err } func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error { f.shortcuts = append(f.shortcuts, struct { id entity.FavoriteID key *int }{id: id, key: key}) if f.errOnSetShortcut != nil { return f.errOnSetShortcut } return f.err } func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error { f.tagged = append(f.tagged, struct { favID entity.FavoriteID tagID entity.TagID }{favID: favID, tagID: tagID}) if err := f.errOnTag[tagID]; err != nil { return err } return f.err } func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error { f.untagged = append(f.untagged, struct { favID entity.FavoriteID tagID entity.TagID }{favID: favID, tagID: tagID}) if err := f.errOnUntag[tagID]; err != nil { return err } if f.failUntagOnCall > 0 && len(f.untagged) == f.failUntagOnCall { return errors.New("untag failed") } return f.err } func (f *fakeFavoritesSidebarUC) UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, wanted []entity.TagID) error { wantedSet := make(map[entity.TagID]struct{}, len(wanted)) for _, id := range wanted { wantedSet[id] = struct{}{} } var current []entity.Tag for _, favorite := range f.favorites { if favorite != nil && favorite.ID == favID { current = favorite.Tags break } } currentSet := make(map[entity.TagID]struct{}, len(current)) for _, tag := range current { currentSet[tag.ID] = struct{}{} if _, keep := wantedSet[tag.ID]; !keep { if err := f.UntagFavorite(ctx, favID, tag.ID); err != nil { return err } } } for _, id := range wanted { if _, exists := currentSet[id]; exists { continue } if err := f.TagFavorite(ctx, favID, id); err != nil { return err } } return nil } func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar { fs := &FavoritesSidebar{ favoritesUC: &fakeFavoritesSidebarUC{favorites: favorites, tags: tags}, selectedTagIDs: make(map[entity.TagID]struct{}), ctx: context.Background(), idleScheduler: func(cb glib.SourceFunc) { cb(0) }, } return fs } func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) { fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}}) fs.loadGen = 1 applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil) require.True(t, applied) assert.Len(t, fs.allFavorites, 1) assert.Len(t, fs.allTags, 1) require.Len(t, fs.displayRows, 1) assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) assert.Equal(t, "https://go.dev", fs.displayRows[0].URL) } func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) { devTag := entity.Tag{ID: 1, Name: "Dev"} newsTag := entity.Tag{ID: 2, Name: "News"} fs := newFavoritesSidebarHarness([]*entity.Favorite{ {ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1}, {ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2}, }, nil) fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites fs.currentQuery = "golang" fs.rebuildDisplayRowsLocked() require.Len(t, fs.displayRows, 1) assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) fs.currentQuery = "example.com" fs.rebuildDisplayRowsLocked() require.Len(t, fs.displayRows, 1) assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID) fs.currentQuery = "dev" fs.rebuildDisplayRowsLocked() require.Len(t, fs.displayRows, 1) assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) } func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) { fs := newFavoritesSidebarHarness([]*entity.Favorite{ {ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}}, {ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}}, {ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}}, }, nil) fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites fs.selectedTagIDs[1] = struct{}{} fs.selectedTagIDs[2] = struct{}{} fs.rebuildDisplayRowsLocked() require.Len(t, fs.displayRows, 2) assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID) } func TestFavoritesSidebarActivationCallbacks(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.ctx = context.Background() fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } var mu sync.Mutex called := []string{} fs.onNavigate = func(_ context.Context, url string) error { mu.Lock() defer mu.Unlock() called = append(called, "nav:"+url) return nil } fs.onNavigateKeepOpen = func(_ context.Context, url string) error { mu.Lock() defer mu.Unlock() called = append(called, "keep:"+url) return nil } fs.onOpenInNewPane = func(_ context.Context, url string) error { mu.Lock() defer mu.Unlock() called = append(called, "pane:"+url) return nil } fs.navigateToURL("https://a.test") fs.navigateWithoutClosing("https://b.test") fs.navigateToNewPane("https://c.test") assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called) } func TestFavoritesSidebarRequestReloadIfVisibleReloadsData(t *testing.T) { called := make(chan struct{}, 1) uc := &fakeFavoritesSidebarUC{ favorites: []*entity.Favorite{{ID: 1, URL: "https://go.dev", Title: "Go"}}, tags: []*entity.Tag{{ID: 10, Name: "dev"}}, getAllCalled: called, } fs := newFavoritesSidebarHarness(nil, nil) fs.favoritesUC = uc fs.visible = true fs.RequestReloadIfVisible("test") require.Eventually(t, func() bool { select { case <-called: return true default: return false } }, time.Second, 10*time.Millisecond) require.Eventually(t, func() bool { fs.mu.RLock() defer fs.mu.RUnlock() return len(fs.allFavorites) == 1 && len(fs.allTags) == 1 }, time.Second, 10*time.Millisecond) } func TestFavoritesSidebarStaleLoadRejected(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.loadGen = 2 applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil) assert.False(t, applied) assert.Empty(t, fs.allFavorites) assert.Empty(t, fs.displayRows) } func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } called := false fs.onNavigate = func(context.Context, string) error { called = true return nil } fs.destroyed = true fs.navigateToURL("https://example.test") fs.toggleTag(1) fs.onSearchChanged() assert.False(t, called) assert.Empty(t, fs.selectedTagIDs) } func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") } fs.navigateToURL("https://example.test") assert.Equal(t, "navigation failed", fs.notice) } func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}} fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{ {ID: 1, URL: "https://go.dev", Title: "Go"}, {ID: 2, URL: "", Title: "Missing URL"}, }) assert.Len(t, fs.allTags, 2) require.Len(t, fs.displayRows, 2) assert.True(t, fs.displayRows[0].Selectable) assert.False(t, fs.displayRows[1].Selectable) assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true)) assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false)) } func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) { assert.True(t, shouldFocusSearchForSlash(false)) assert.False(t, shouldFocusSearchForSlash(true)) } func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}} var called []string fs.onNavigate = func(_ context.Context, url string) error { called = append(called, "nav:"+url) return nil } fs.onNavigateKeepOpen = func(_ context.Context, url string) error { called = append(called, "keep:"+url) return nil } fs.onOpenInNewPane = func(_ context.Context, url string) error { called = append(called, "pane:"+url) return nil } assert.True(t, fs.handleEnterKey(0)) assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue)) assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue)) assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called) } func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.searchEntry = nil fs.tagBox = nil fs.listBox = nil assert.Empty(t, fs.availableFocusZones()) fs.formBox = nil fs.mode = favoritesSidebarModeAdd fs.confirmDelete = true assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones()) } func TestFavoritesSidebarSingleKeyCommandsStartManagementOutsideTextEditContext(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.searchEntry = nil fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}} assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), 0)) assert.Equal(t, favoritesSidebarModeAdd, fs.mode) fs.mode = favoritesSidebarModeNone assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0)) assert.Equal(t, favoritesSidebarModeEdit, fs.mode) } func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { uc := &fakeFavoritesSidebarUC{} fs := newFavoritesSidebarHarness(nil, nil) fs.favoritesUC = uc fs.formURL = "https://add.test" fs.formTitle = "Added" fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}} fs.formShortcut = "3" fs.mode = favoritesSidebarModeAdd assert.True(t, fs.submitForm()) require.Len(t, uc.addInputs, 1) assert.ElementsMatch(t, []entity.TagID{1, 2}, uc.addInputs[0].Tags) require.Len(t, uc.shortcuts, 1) assert.Equal(t, 3, *uc.shortcuts[0].key) fs.mode = favoritesSidebarModeEdit fs.editingID = 7 fs.formTitle = "Edited" fs.formShortcut = "4" assert.True(t, fs.submitForm()) require.Len(t, uc.updateInputs, 1) assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID) assert.Equal(t, "Edited", uc.updateInputs[0].Title) require.NotNil(t, uc.updateInputs[0].ShortcutKey) assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey) fs.mode = favoritesSidebarModeEdit fs.editingID = 7 fs.formTitle = "Edited" fs.formShortcut = "" assert.True(t, fs.submitForm()) require.Len(t, uc.updateInputs, 2) assert.True(t, uc.updateInputs[1].ShortcutKeySet) assert.Nil(t, uc.updateInputs[1].ShortcutKey) } func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) { uc := &fakeFavoritesSidebarUC{} fs := newFavoritesSidebarHarness(nil, nil) fs.favoritesUC = uc fs.formURL = "https://add.test" fs.formTitle = "Added" fs.formShortcut = "10" fs.mode = favoritesSidebarModeAdd assert.True(t, fs.submitForm()) assert.Empty(t, uc.addInputs) assert.Equal(t, favoritesSidebarModeAdd, fs.mode) assert.Contains(t, fs.notice, "invalid shortcut") assert.Equal(t, "10", fs.formShortcut) } func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T) { dev := &entity.Tag{ID: 10, Name: "dev"} news := &entity.Tag{ID: 11, Name: "news"} reading := &entity.Tag{ID: 12, Name: "reading"} selected := map[entity.TagID]struct{}{dev.ID: {}} assert.Equal(t, []*entity.Tag{dev}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "")) assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read")) } func TestFavoritesSidebarReloadsAfterPartiallyApplyingTagChanges(t *testing.T) { first := entity.Tag{ID: 10, Name: "first"} second := entity.Tag{ID: 11, Name: "second"} fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{first, second}} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) uc.failUntagOnCall = 2 fs.allFavorites = []*entity.Favorite{fav} fs.loadGen = 4 assert.False(t, fs.updateFavoriteTags(fav.ID, nil)) assert.Len(t, uc.untagged, 2) assert.Equal(t, uint64(5), fs.loadGen, "a partial tag mutation must refresh stale favorites") assert.Equal(t, "untag failed", fs.notice) } func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) { dev := entity.Tag{ID: 10, Name: "dev"} news := entity.Tag{ID: 11, Name: "news"} fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) fs.allFavorites = []*entity.Favorite{fav} fs.formTagIDs = tagIDSet(fav.Tags) fs.toggleFormTag(news.ID) assert.ElementsMatch(t, []entity.TagID{dev.ID, news.ID}, fs.formTagIDsSnapshot()) assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot())) require.Len(t, uc.tagged, 1) assert.Equal(t, news.ID, uc.tagged[0].tagID) fav.Tags = []entity.Tag{dev, news} fs.toggleFormTag(dev.ID) assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot())) require.Len(t, uc.untagged, 1) assert.Equal(t, dev.ID, uc.untagged[0].tagID) assert.Len(t, uc.tagged, 1, "the synchronized persisted tags must not be added again") } func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) { fav := &entity.Favorite{ID: 1, URL: "https://go.dev"} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) if !gtk.InitCheck() { t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)") } listBox := gtk.NewListBox() require.NotNil(t, listBox) row := gtk.NewListBoxRow() require.NotNil(t, row) row.SetSelectable(true) listBox.Append(&row.Widget) fs.listBox = listBox fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} fs.focusZone = favoritesSidebarFocusSearch assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down))) assert.Equal(t, favoritesSidebarFocusList, fs.focusZone) assert.Equal(t, row, fs.listBox.GetSelectedRow()) assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up))) assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone) } func TestFavoritesSidebarDirectionalFocusConsumesArrowsOnTagControls(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.focusZone = favoritesSidebarFocusTags assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down))) assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone) assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up))) assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone) } func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) { fav := &entity.Favorite{ID: 5, URL: "https://go.dev"} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} fs.confirmDelete = true fs.confirmDeleteID = fav.ID assert.True(t, fs.confirmDeleteActive()) assert.True(t, fs.confirmDeleteFavorite()) assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs) } func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) { uc := &fakeFavoritesSidebarUC{} fs := newFavoritesSidebarHarness(nil, nil) fs.favoritesUC = uc fs.formURL = "https://add.test" fs.formTitle = "Added" fs.mode = favoritesSidebarModeAdd fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}} fs.onNavigate = func(context.Context, string) error { t.Fatal("plain Enter in form mode must not activate selected row") return nil } assert.False(t, fs.handleReturnKey(0)) assert.Empty(t, uc.addInputs) fs.mode = favoritesSidebarModeAdd assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue)) require.Len(t, uc.addInputs, 1) } func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { uc := &fakeFavoritesSidebarUC{} fs := newFavoritesSidebarHarness(nil, nil) fs.favoritesUC = uc fs.mode = favoritesSidebarModeEdit fs.editingID = 7 fs.formTitle = "Edited" assert.True(t, fs.submitForm()) require.Len(t, uc.updateInputs, 1) assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID) assert.Empty(t, uc.tagged) assert.Empty(t, uc.untagged) } func TestFavoritesSidebarCreateTagTrimsNameAndUsesApplicationBoundary(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) assert.True(t, fs.createTag(" reading ")) require.Len(t, uc.createdTags, 1) assert.Equal(t, "reading", uc.createdTags[0].name) assert.Empty(t, uc.createdTags[0].color) assert.True(t, fs.createTag(" \t ")) assert.Len(t, uc.createdTags, 1) assert.Equal(t, "Tag name is required", fs.notice) } func TestFavoritesSidebarTagNameSubmissionCreatesTag(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) assert.True(t, fs.submitTagName(" work ")) require.Len(t, uc.createdTags, 1) assert.Equal(t, "work", uc.createdTags[0].name) } func TestFavoritesSidebarTagBindingTogglesExistingTag(t *testing.T) { dev := entity.Tag{ID: 10, Name: "dev"} news := entity.Tag{ID: 11, Name: "news"} fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) fs.allFavorites = []*entity.Favorite{fav} fs.toggleFavoriteTag(fav.ID, dev.ID) require.Len(t, uc.untagged, 1) assert.Equal(t, dev.ID, uc.untagged[0].tagID) fs.toggleFavoriteTag(fav.ID, news.ID) require.Len(t, uc.tagged, 1) assert.Equal(t, news.ID, uc.tagged[0].tagID) } func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) input := >k.Widget{} fs.setTextInputFocused(input) assert.True(t, fs.inTextEditContext()) assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e))) assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0)) fs.mode = favoritesSidebarModeAdd assert.True(t, fs.cancelManagement()) assert.Equal(t, favoritesSidebarModeNone, fs.mode) fs.clearTextInputFocus(input) assert.False(t, fs.inTextEditContext()) assert.False(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e))) } func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) { for _, key := range []uint{ uint(gdk.KEY_a), uint(gdk.KEY_e), uint(gdk.KEY_s), uint(gdk.KEY_r), uint(gdk.KEY_c), uint(gdk.KEY_t), uint(gdk.KEY_plus), uint(gdk.KEY_Up), uint(gdk.KEY_Return), } { assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key) } assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab))) assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab))) assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Escape))) assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e))) } func TestFavoritesSidebarPlusAndTDoNotHijackTextInput(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_t), 0)) assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_plus), 0)) assert.Equal(t, favoritesSidebarModeNone, fs.mode) } func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) fs.searchEntry = nil assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a), gdk.ControlMaskValue)) assert.Equal(t, favoritesSidebarModeNone, fs.mode) } func TestFavoritesSidebarConfirmDeleteRequiresConfirmationState(t *testing.T) { fav := &entity.Favorite{ID: 5, URL: "https://go.dev"} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} assert.True(t, fs.confirmDeleteFavorite()) assert.Empty(t, uc.deletedIDs) assert.True(t, fs.confirmDelete) } func TestFavoritesSidebarConfirmDeleteRepromptsWhenSelectionChanges(t *testing.T) { first := &entity.Favorite{ID: 5, URL: "https://first.test"} second := &entity.Favorite{ID: 6, URL: "https://second.test"} fs := newFavoritesSidebarHarness([]*entity.Favorite{first, second}, nil) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) fs.displayRows = []favoriteSidebarDisplayRow{ {FavoriteID: second.ID, Favorite: second, URL: second.URL, Selectable: true}, {FavoriteID: first.ID, Favorite: first, URL: first.URL, Selectable: true}, } fs.confirmDelete = true fs.confirmDeleteID = first.ID assert.True(t, fs.confirmDeleteFavorite()) assert.Empty(t, uc.deletedIDs) assert.True(t, fs.confirmDelete) assert.Equal(t, second.ID, fs.confirmDeleteID) } func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) { uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")} fs := newFavoritesSidebarHarness(nil, nil) fs.favoritesUC = uc loaded := make(chan glib.SourceFunc, 1) fs.idleScheduler = func(cb glib.SourceFunc) { loaded <- cb } fs.formURL = "https://add.test" fs.formTitle = "Added" fs.formShortcut = "3" fs.mode = favoritesSidebarModeAdd assert.True(t, fs.submitForm()) require.Len(t, uc.addInputs, 1) select { case cb := <-loaded: cb(0) case <-time.After(time.Second): t.Fatal("timed out waiting for favorites reload") } assert.Equal(t, favoritesSidebarModeNone, fs.mode) assert.Contains(t, fs.notice, "failed to set shortcut") } func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) { dev := entity.Tag{ID: 10, Name: "dev"} fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev}) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) fs.allTags = uc.tags fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} fs.mode = favoritesSidebarModeShortcut assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0)) assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0)) require.GreaterOrEqual(t, len(uc.shortcuts), 2) assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key) assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key) fs.mode = favoritesSidebarModeNone assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0)) assert.True(t, fs.confirmDelete) assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete), 0)) assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs) } func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) { fs := newFavoritesSidebarHarness(nil, nil) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) uc.err = errors.New("add failed") fs.mode = favoritesSidebarModeAdd assert.True(t, fs.submitForm()) assert.Equal(t, favoritesSidebarModeAdd, fs.mode) assert.Equal(t, "add failed", fs.notice) } // TestFavoritesSidebar_FirstShowIssuesSingleQueryBatch proves the lazy-load // contract behind the on-demand host: a fresh instance issues zero queries // (construction performs no load), the first Show-equivalent load issues // exactly one GetAll/GetAllTags batch, and reopening reloads with one more // batch according to existing semantics. func TestFavoritesSidebar_FirstShowIssuesSingleQueryBatch(t *testing.T) { fav := &entity.Favorite{ID: 1, URL: "https://example.com", Title: "Example"} fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) fs.ctx = context.Background() // Fresh instance: construction must not have queried anything. assert.Zero(t, uc.getAllCalls.Load()) assert.Zero(t, uc.getAllTagsCalls.Load()) // First Show issues exactly one batch. fs.startLoad() require.Eventually(t, func() bool { fs.mu.RLock() defer fs.mu.RUnlock() return len(fs.allFavorites) == 1 }, 10*time.Second, 5*time.Millisecond, "first Show load must apply fetched favorites") assert.EqualValues(t, 1, uc.getAllCalls.Load(), "first Show must issue exactly one GetAll batch") assert.EqualValues(t, 1, uc.getAllTagsCalls.Load(), "first Show must issue exactly one GetAllTags batch") assert.Equal(t, "https://example.com", fs.allFavorites[0].URL) // Reopen reloads with one more batch. fs.startLoad() require.Eventually(t, func() bool { return uc.getAllCalls.Load() == 2 && uc.getAllTagsCalls.Load() == 2 }, 10*time.Second, 5*time.Millisecond, "reopen must reload with one more batch") }