v0.29.0 21b3150

navigate_test.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"path/filepath"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/bnema/dumber/internal/domain/entity"
11	repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12	"github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15)
16
17func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {
18	got := canonicalizeURLForHistory(
19		"https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",
20		historyCanonicalizationOptions{StripTrackingParams: true},
21	)
22
23	require.Equal(t, "https://example.com/path?a=2&b=1", got)
24}
25
26func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {
27	got := canonicalizeURLForHistory(
28		"https://example.com/path/?utm_source=newsletter&b=1#section-1",
29		historyCanonicalizationOptions{StripTrackingParams: false},
30	)
31
32	require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
33}
34
35func TestRecordHistory_IgnoresHashOnlyTransitions(t *testing.T) {
36	ctx := context.Background()
37	repo := repomocks.NewMockHistoryRepository(t)
38
39	repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
40	repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
41		return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
42	})).Return(nil).Once()
43
44	uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
45	uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
46	uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
47	uc.Close()
48}
49
50func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) {
51	ctx := context.Background()
52	repo := repomocks.NewMockHistoryRepository(t)
53
54	repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once()
55	repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
56		return entry.URL == "https://example.com/article" && entry.VisitCount == 2
57	})).Return(nil).Once()
58
59	uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
60	uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
61	uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
62	uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
63	uc.Close()
64}
65
66func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testing.T) {
67	ctx := context.Background()
68	dbPath := filepath.Join(t.TempDir(), "dumber.db")
69	db, err := sqlite.NewConnection(ctx, dbPath)
70	require.NoError(t, err)
71	t.Cleanup(func() { _ = db.Close() })
72
73	repo := sqlite.NewHistoryRepository(db)
74	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
75		URL:   "https://example.com/article",
76		Title: "Old",
77	}))
78
79	before, err := repo.FindByURL(ctx, "https://example.com/article")
80	require.NoError(t, err)
81	require.NotNil(t, before)
82
83	uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
84	uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New")
85	uc.Close()
86
87	after, err := repo.FindByURL(ctx, "https://example.com/article")
88	require.NoError(t, err)
89	require.NotNil(t, after)
90	require.Equal(t, "New", after.Title)
91	require.Equal(t, before.VisitCount, after.VisitCount)
92}
93
94func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {
95	ctx := context.Background()
96
97	// Stateful store — Save writes here, FindByURL reads from here.
98	var mu sync.Mutex
99	store := make(map[string]*entity.HistoryEntry)
100
101	// Fires once during the first Save to re-enqueue a title update while
102	// the flush is in progress. This is the exact race that caused the
103	// concurrent map crash (fixed by swapping maps in flushPending).
104	var saveOnce sync.Once
105	var uc *NavigateUseCase
106
107	repo := repomocks.NewMockHistoryRepository(t)
108
109	repo.EXPECT().FindByURL(mock.Anything, mock.Anything).RunAndReturn(
110		func(_ context.Context, url string) (*entity.HistoryEntry, error) {
111			mu.Lock()
112			defer mu.Unlock()
113			e := store[url]
114			if e == nil {
115				return nil, nil
116			}
117			clone := *e
118			return &clone, nil
119		},
120	).Maybe()
121
122	repo.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(
123		func(_ context.Context, entry *entity.HistoryEntry) error {
124			clone := *entry
125			mu.Lock()
126			store[entry.URL] = &clone
127			mu.Unlock()
128
129			// Re-enqueue a title update during the first Save.
130			saveOnce.Do(func() {
131				if entry.URL == "https://example.com/article" {
132					uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
133				}
134			})
135			return nil
136		},
137	).Maybe()
138
139	uc = NewNavigateUseCase(repo, nil, entity.ZoomDefault)
140	uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
141
142	// The history worker flushes on a timer; there is no exported sync
143	// mechanism, so we sleep before shutting down. Using 5× the flush
144	// interval for headroom — this is a known flaky pattern, but
145	// restructuring the worker for deterministic sync is out of scope.
146	// Close() performs a final flush, so items re-enqueued during the
147	// timed flush are still captured on shutdown.
148	time.Sleep(historyWorkerFlushInterval * 5)
149	uc.Close()
150
151	mu.Lock()
152	entry := store["https://example.com/article"]
153	mu.Unlock()
154	require.NotNil(t, entry)
155	require.Equal(t, int64(1), entry.VisitCount)
156	require.Equal(t, "Queued title", entry.Title)
157}

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