v0.32.0 0617278

history_model_test.go RawHistoryFolder

1package component
2
3import (
4	"fmt"
5	"testing"
6	"time"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11)
12
13func TestGroupHistoryByDay_Empty(t *testing.T) {
14	assert.Nil(t, groupHistoryByDay(nil))
15	assert.Nil(t, groupHistoryByDay([]*entity.HistoryEntry{}))
16}
17
18func TestGroupHistoryByDay_SingleEntry(t *testing.T) {
19	now := time.Now()
20	entries := []*entity.HistoryEntry{
21		{ID: 1, URL: "https://example.com", Title: "Example", LastVisited: now},
22	}
23	groups := groupHistoryByDay(entries)
24	require.Len(t, groups, 1)
25	assert.Equal(t, "Today", groups[0].Label)
26	assert.Len(t, groups[0].Entries, 1)
27}
28
29func TestGroupHistoryByDay_TodayYesterdayOlder(t *testing.T) {
30	now := time.Now()
31	today := now
32	yesterday := now.AddDate(0, 0, -1)
33	older := now.AddDate(0, 0, -5)
34
35	entries := []*entity.HistoryEntry{
36		{ID: 1, URL: "https://older.com", Title: "Older", LastVisited: older},
37		{ID: 2, URL: "https://yesterday.com", Title: "Yesterday", LastVisited: yesterday},
38		{ID: 3, URL: "https://today.com", Title: "Today", LastVisited: today},
39	}
40	groups := groupHistoryByDay(entries)
41	require.Len(t, groups, 3)
42	assert.Equal(t, "Today", groups[0].Label)
43	assert.Equal(t, "Yesterday", groups[1].Label)
44	assert.NotEmpty(t, groups[2].Label, "older entry should have a formatted date label")
45	assert.Len(t, groups[2].Entries, 1)
46}
47
48func TestGroupHistoryByDay_SameDayEntriesGrouped(t *testing.T) {
49	now := time.Date(2026, time.June, 24, 12, 0, 0, 0, time.Local)
50	entries := []*entity.HistoryEntry{
51		{ID: 1, URL: "https://a.com", Title: "A", LastVisited: now},
52		{ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-time.Hour)},
53	}
54	groups := groupHistoryByDayAt(entries, now)
55	require.Len(t, groups, 1)
56	assert.Equal(t, "Today", groups[0].Label)
57	assert.Len(t, groups[0].Entries, 2)
58}
59
60func TestReadableURL_StripsProtocol(t *testing.T) {
61	assert.Equal(t, "example.com", readableURL("https://example.com"))
62	assert.Equal(t, "example.com/page", readableURL("http://example.com/page"))
63	assert.Equal(t, "example.com", readableURL("https://www.example.com"))
64}
65
66func TestReadableURL_KeepsPath(t *testing.T) {
67	assert.Equal(t, "example.com/path/to/page", readableURL("https://example.com/path/to/page"))
68}
69
70func TestRelativeTime_Now(t *testing.T) {
71	assert.Equal(t, "now", relativeTime(time.Now()))
72}
73
74func TestRelativeTime_Minutes(t *testing.T) {
75	assert.Equal(t, "1m ago", relativeTime(time.Now().Add(-1*time.Minute)))
76	assert.Equal(t, "5m ago", relativeTime(time.Now().Add(-5*time.Minute)))
77}
78
79func TestRelativeTime_Hours(t *testing.T) {
80	assert.Equal(t, "1h ago", relativeTime(time.Now().Add(-1*time.Hour)))
81	assert.Equal(t, "3h ago", relativeTime(time.Now().Add(-3*time.Hour)))
82}
83
84func TestRelativeTime_Days(t *testing.T) {
85	assert.Equal(t, "1d ago", relativeTime(time.Now().Add(-25*time.Hour)))
86	assert.Equal(t, "3d ago", relativeTime(time.Now().Add(-72*time.Hour)))
87}
88
89func TestGroupHistoryByDay_CrossYearDifferentLabels(t *testing.T) {
90	now := time.Now()
91	thisYear := now
92	lastYear := now.AddDate(-1, 0, 0)
93	twoYearsAgo := now.AddDate(-2, 0, 0)
94
95	entries := []*entity.HistoryEntry{
96		{ID: 1, URL: "https://two-years-ago.com", Title: "Two Years Ago", LastVisited: twoYearsAgo},
97		{ID: 2, URL: "https://last-year.com", Title: "Last Year", LastVisited: lastYear},
98		{ID: 3, URL: "https://this-year.com", Title: "This Year", LastVisited: thisYear},
99	}
100	groups := groupHistoryByDay(entries)
101	require.Len(t, groups, 3)
102	assert.Equal(t, "Today", groups[0].Label)
103	assert.Equal(t, lastYear.Format(dayLabelOtherYearFormat), groups[1].Label)
104	assert.Equal(t, twoYearsAgo.Format(dayLabelOtherYearFormat), groups[2].Label)
105	assert.Len(t, groups[2].Entries, 1)
106}
107
108func TestGroupHistoryByDay_MaintainsInputOrderWithinDay(t *testing.T) {
109	now := time.Date(2026, time.June, 24, 12, 0, 0, 0, time.Local)
110	entries := []*entity.HistoryEntry{
111		{ID: 1, URL: "https://first.com", Title: "First", LastVisited: now},
112		{ID: 2, URL: "https://second.com", Title: "Second", LastVisited: now.Add(-30 * time.Minute)},
113		{ID: 3, URL: "https://third.com", Title: "Third", LastVisited: now.Add(-2 * time.Hour)},
114	}
115	groups := groupHistoryByDayAt(entries, now)
116	require.Len(t, groups, 1)
117	require.Len(t, groups[0].Entries, 3)
118	// Entries within the same day maintain input order
119	assert.Equal(t, "https://first.com", groups[0].Entries[0].URL)
120	assert.Equal(t, "https://second.com", groups[0].Entries[1].URL)
121	assert.Equal(t, "https://third.com", groups[0].Entries[2].URL)
122}
123
124func TestGroupHistoryByDay_LeapYearBoundary(t *testing.T) {
125	// Two consecutive days in a leap year should produce separate groups.
126	// Use local noon on a known leap year to avoid UTC-to-local date
127	// rollover, and check that labels include the year when not the
128	// current year.
129	loc := time.Now().Location()
130	leapYear := 2024
131	feb28 := time.Date(leapYear, time.February, 28, 12, 0, 0, 0, loc)
132	feb29 := time.Date(leapYear, time.February, 29, 12, 0, 0, 0, loc)
133
134	entries := []*entity.HistoryEntry{
135		{ID: 1, URL: "https://feb29.com", Title: "Feb 29", LastVisited: feb29},
136		{ID: 2, URL: "https://feb28.com", Title: "Feb 28", LastVisited: feb28},
137	}
138	groups := groupHistoryByDay(entries)
139	require.Len(t, groups, 2, "Feb 28 and Feb 29 should be in separate groups")
140	// Verify order: newest first
141	// Since 2024 is likely not the current year, labels include year
142	assert.Equal(t, feb29.Format("January 2, 2006"), groups[0].Label)
143	assert.Equal(t, feb28.Format("January 2, 2006"), groups[1].Label)
144}
145
146func TestGroupHistoryByDay_SameLocalDay(t *testing.T) {
147	// Two entries on the same calendar day in local timezone should be grouped.
148	loc := time.Now().Location()
149	sameDay := time.Date(2026, time.June, 10, 12, 0, 0, 0, loc)
150	earlier := time.Date(2026, time.June, 10, 8, 0, 0, 0, loc)
151
152	entries := []*entity.HistoryEntry{
153		{ID: 1, URL: "https://later.com", Title: "Later", LastVisited: sameDay},
154		{ID: 2, URL: "https://earlier.com", Title: "Earlier", LastVisited: earlier},
155	}
156	groups := groupHistoryByDay(entries)
157	require.Len(t, groups, 1, "entries on same calendar day should be in one group")
158	assert.Contains(t, groups[0].Label, "June")
159}
160
161func TestReadableURL_StripsFTPAndWWW(t *testing.T) {
162	assert.Equal(t, "example.com", readableURL("ftp://example.com"))
163	assert.Equal(t, "example.com", readableURL("http://www.example.com"))
164	assert.Equal(t, "example.com", readableURL("https://www.example.com/"))
165}
166
167func TestReadableURL_NoProtocol(t *testing.T) {
168	assert.Equal(t, "example.com", readableURL("example.com"))
169}
170
171func TestReadableURL_TrailingSlash(t *testing.T) {
172	assert.Equal(t, "example.com", readableURL("https://example.com/"))
173	assert.Equal(t, "example.com/path/", readableURL("https://example.com/path/"))
174}
175
176func TestReadableURL_EmptyOrRoot(t *testing.T) {
177	assert.Empty(t, readableURL(""))
178	assert.Empty(t, readableURL("/"))
179}
180
181func TestReadableURL_PreservesPort(t *testing.T) {
182	assert.Equal(t, "example.com:8080", readableURL("https://example.com:8080"))
183	assert.Equal(t, "example.com:3000/path", readableURL("https://example.com:3000/path"))
184}
185
186func TestRelativeTime_BoundaryEdgeCases(t *testing.T) {
187	// Just under 1 minute → "now"
188	assert.Equal(t, "now", relativeTime(time.Now().Add(-30*time.Second)))
189	// 59 seconds → "now"
190	assert.Equal(t, "now", relativeTime(time.Now().Add(-59*time.Second)))
191	// 59 minutes 59 seconds → "59m ago"
192	assert.Equal(t, "59m ago", relativeTime(time.Now().Add(-59*time.Minute).Add(-59*time.Second)))
193	// 23 hours 59 minutes → "23h ago"
194	assert.Equal(t, "23h ago", relativeTime(time.Now().Add(-23*time.Hour).Add(-59*time.Minute)))
195	// 6 days 23 hours → "6d ago"
196	assert.Equal(t, "6d ago", relativeTime(time.Now().Add(-6*24*time.Hour).Add(-23*time.Hour)))
197	// 7 days → "Jul 8" format (changes by date, just verify not in "Xd ago")
198	result := relativeTime(time.Now().Add(-7 * 24 * time.Hour))
199	assert.NotContains(t, result, "d ago", "7+ days should not use day format")
200}
201
202func TestRelativeTime_Future(t *testing.T) {
203	future := time.Now().Add(time.Hour)
204	result := relativeTime(future)
205	// Current implementation returns "now" for negative durations.
206	// This is a known limitation; the test documents the behavior.
207	assert.Equal(t, "now", result, "future times currently reported as 'now' (known limitation)")
208}
209
210func TestRelativeTime_DifferentYear(t *testing.T) {
211	// An entry from a previous year should show a date with month abbreviation
212	// and year when not current year.
213	lastYear := time.Now().AddDate(-1, -1, 0)
214	result := relativeTime(lastYear)
215	_, err := time.Parse("Jan 2, 2006", result)
216	assert.NoError(t, err, "old entry should use the Jan 2, 2006 layout, got %q", result)
217}
218
219func TestDayLabelForKey_MultiYearAgo(t *testing.T) {
220	// Produce a label for a dayKey that is multiple years ago
221	now := time.Now()
222	todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
223	// Two years ago, same month/day
224	past := now.AddDate(-2, 0, -1)
225	key := dayKey{past.Year(), past.Month(), past.Day()}
226	label := dayLabelForKey(key, todayStart, now)
227	assert.Contains(t, label, past.Format("2006"), "multi-year-old day label should include year")
228}
229
230func TestDayLabelForKey_WithinCurrentYear(t *testing.T) {
231	now := time.Now()
232	todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
233	// Earlier this month
234	past := now.AddDate(0, 0, -10)
235	key := dayKey{past.Year(), past.Month(), past.Day()}
236	label := dayLabelForKey(key, todayStart, now)
237	// Should be weekday format without year
238	assert.NotContains(t, label, "2006", "within-year label should not include year")
239	assert.Contains(t, label, past.Weekday().String(), "within-year label should include weekday")
240}
241
242// =============================================================================
243// keyboardNavModel tests
244// =============================================================================
245
246func makeGroups(dayCounts ...int) []historyGroup {
247	now := time.Now()
248	groups := make([]historyGroup, len(dayCounts))
249	for i, count := range dayCounts {
250		entries := make([]*entity.HistoryEntry, count)
251		for j := range count {
252			entries[j] = &entity.HistoryEntry{
253				ID:          int64(i*100 + j),
254				URL:         fmt.Sprintf("https://entry-%d-%d.com", i, j),
255				Title:       fmt.Sprintf("Entry %d-%d", i, j),
256				LastVisited: now.Add(-time.Duration(i) * 24 * time.Hour).Add(-time.Duration(j) * time.Minute),
257			}
258		}
259		groups[i] = historyGroup{
260			Label:   fmt.Sprintf("Day %d", i),
261			Entries: entries,
262		}
263	}
264	return groups
265}
266
267func TestKeyboardNavModel_EmptyGroups(t *testing.T) {
268	m := newKeyboardNavModel(nil)
269	assert.Equal(t, 0, m.totalRows())
270	assert.Equal(t, -1, m.firstSelectableIndex())
271	assert.Equal(t, -1, m.lastSelectableIndex())
272	assert.Nil(t, m.entryAt(0))
273	assert.Empty(t, m.entryURLAt(0))
274
275	m = newKeyboardNavModel([]historyGroup{})
276	assert.Equal(t, 0, m.totalRows())
277}
278
279func TestKeyboardNavModel_SingleGroup(t *testing.T) {
280	groups := makeGroups(3) // 1 header + 3 entries = 4 rows
281	m := newKeyboardNavModel(groups)
282
283	assert.Equal(t, 4, m.totalRows())
284
285	// Row 0: header (not selectable)
286	assert.False(t, m.isSelectable(0))
287	assert.Nil(t, m.entryAt(0))
288	assert.Empty(t, m.entryURLAt(0))
289
290	// Rows 1-3: entries (selectable)
291	for i := 1; i <= 3; i++ {
292		assert.True(t, m.isSelectable(i), "row %d should be selectable", i)
293		assert.NotNil(t, m.entryAt(i))
294		assert.Contains(t, m.entryURLAt(i), "entry-0")
295	}
296
297	// first / last
298	assert.Equal(t, 1, m.firstSelectableIndex())
299	assert.Equal(t, 3, m.lastSelectableIndex())
300}
301
302func TestKeyboardNavModel_MultipleGroups(t *testing.T) {
303	groups := makeGroups(2, 3) // group0: header+e0+e1, group1: header+e0+e1+e2
304	m := newKeyboardNavModel(groups)
305
306	// Layout: [H0, E0-0, E0-1, H1, E1-0, E1-1, E1-2] = 7 rows
307	assert.Equal(t, 7, m.totalRows())
308
309	// Selectable: rows 1,2,4,5,6
310	assert.False(t, m.isSelectable(0)) // H0
311	assert.True(t, m.isSelectable(1))  // E0-0
312	assert.True(t, m.isSelectable(2))  // E0-1
313	assert.False(t, m.isSelectable(3)) // H1
314	assert.True(t, m.isSelectable(4))  // E1-0
315	assert.True(t, m.isSelectable(5))  // E1-1
316	assert.True(t, m.isSelectable(6))  // E1-2
317
318	assert.Equal(t, 1, m.firstSelectableIndex())
319	assert.Equal(t, 6, m.lastSelectableIndex())
320}
321
322func TestKeyboardNavModel_NextPreviousSelectable(t *testing.T) {
323	groups := makeGroups(2, 2) // rows: H0(0), E0-0(1), E0-1(2), H1(3), E1-0(4), E1-1(5)
324	m := newKeyboardNavModel(groups)
325
326	// Next from 0 (header) → 1
327	assert.Equal(t, 1, m.nextSelectableIndex(0, +1))
328	// Next from 1 → 2
329	assert.Equal(t, 2, m.nextSelectableIndex(1, +1))
330	// Next from 2 → 4 (skip header H1 at 3)
331	assert.Equal(t, 4, m.nextSelectableIndex(2, +1))
332	// Next from 5 → -1 (end)
333	assert.Equal(t, -1, m.nextSelectableIndex(5, +1))
334
335	// Previous from 5 (E1-1) → 4
336	assert.Equal(t, 4, m.nextSelectableIndex(5, -1))
337	// Previous from 4 → 2 (skip header H1 at 3)
338	assert.Equal(t, 2, m.nextSelectableIndex(4, -1))
339	// Previous from 1 → -1 (beginning, only header before)
340	assert.Equal(t, -1, m.nextSelectableIndex(1, -1))
341	// Previous from 0 (header) → -1
342	assert.Equal(t, -1, m.nextSelectableIndex(0, -1))
343}
344
345func TestKeyboardNavModel_InvalidDirection(t *testing.T) {
346	groups := makeGroups(2)
347	m := newKeyboardNavModel(groups)
348	assert.Equal(t, -1, m.nextSelectableIndex(0, 0))
349	assert.Equal(t, -1, m.nextSelectableIndex(0, 2))
350	assert.Equal(t, -1, m.nextSelectableIndex(0, -2))
351}
352
353func TestKeyboardNavModel_DayBoundaries(t *testing.T) {
354	groups := makeGroups(2, 1, 2)
355	// Layout: H0(0), E0-0(1), E0-1(2), H1(3), E1-0(4), H2(5), E2-0(6), E2-1(7)
356	m := newKeyboardNavModel(groups)
357
358	// Previous day from E0-1(2) → E0-1 is in group 0, no previous day.
359	assert.Equal(t, -1, m.previousDayBoundary(2))
360
361	// Previous day from E1-0(4) → first entry in group 0 = row 1
362	assert.Equal(t, 1, m.previousDayBoundary(4))
363
364	// Previous day from first group's entry (E0-0 at 1) → no previous day
365	assert.Equal(t, -1, m.previousDayBoundary(1))
366
367	// Next day from E0-0(1) → first entry in group 1 = row 4
368	assert.Equal(t, 4, m.nextDayBoundary(1))
369
370	// Next day from E0-1(2) → row 4
371	assert.Equal(t, 4, m.nextDayBoundary(2))
372
373	// Next day from E1-0(4) → first entry in group 2 = row 6
374	assert.Equal(t, 6, m.nextDayBoundary(4))
375
376	// Next day from E2-0(6, group 2) → last group, no next day
377	assert.Equal(t, -1, m.nextDayBoundary(6))
378
379	// Next day from E2-1(7, last entry) → no next day
380	assert.Equal(t, -1, m.nextDayBoundary(7))
381}
382
383func TestKeyboardNavModel_EntryAtURL(t *testing.T) {
384	groups := makeGroups(1, 1)
385	m := newKeyboardNavModel(groups)
386
387	e0 := m.entryAt(1)
388	require.NotNil(t, e0)
389	assert.Equal(t, "https://entry-0-0.com", e0.URL)
390	assert.Equal(t, "https://entry-0-0.com", m.entryURLAt(1))
391
392	e1 := m.entryAt(3)
393	require.NotNil(t, e1)
394	assert.Equal(t, "https://entry-1-0.com", e1.URL)
395
396	// Header rows return nil / ""
397	assert.Nil(t, m.entryAt(0)) // H0
398	assert.Empty(t, m.entryURLAt(0))
399	assert.Nil(t, m.entryAt(2)) // H1
400	assert.Empty(t, m.entryURLAt(2))
401
402	// Out of range
403	assert.Nil(t, m.entryAt(99))
404	assert.Empty(t, m.entryURLAt(99))
405	assert.Nil(t, m.entryAt(-1))
406}
407
408func TestKeyboardNavModel_EntryCount(t *testing.T) {
409	assert.Equal(t, 0, newKeyboardNavModel(nil).entryCount())
410	assert.Equal(t, 0, newKeyboardNavModel([]historyGroup{}).entryCount())
411	assert.Equal(t, 5, newKeyboardNavModel(makeGroups(2, 3)).entryCount())
412	assert.Equal(t, 10, newKeyboardNavModel(makeGroups(3, 3, 4)).entryCount())
413}
414
415func TestBuildHistoryDisplayRows_IncludesHeadersAndEntries(t *testing.T) {
416	now := time.Now()
417	entryA := &entity.HistoryEntry{ID: 1, URL: "https://a.com", Title: "A", LastVisited: now}
418	entryB := &entity.HistoryEntry{ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-time.Hour)}
419	groups := []historyGroup{
420		{Label: "Today", Entries: []*entity.HistoryEntry{entryA, entryB}},
421		{Label: "Yesterday", Entries: nil},
422	}
423
424	rows := buildHistoryDisplayRows(groups)
425	require.Len(t, rows, 4)
426	assert.Equal(t, historyDisplayRowHeader, rows[0].Kind)
427	assert.Equal(t, "Today", rows[0].Label)
428	assert.Equal(t, 0, rows[0].GroupIndex)
429	assert.Equal(t, historyDisplayRowEntry, rows[1].Kind)
430	assert.Same(t, entryA, rows[1].Entry)
431	assert.Equal(t, 0, rows[1].GroupIndex)
432	assert.Equal(t, historyDisplayRowEntry, rows[2].Kind)
433	assert.Same(t, entryB, rows[2].Entry)
434	assert.Equal(t, historyDisplayRowHeader, rows[3].Kind)
435	assert.Equal(t, "Yesterday", rows[3].Label)
436	assert.Equal(t, 1, rows[3].GroupIndex)
437}
438
439func TestKeyboardNavModelFromRows_UsesExplicitDisplayRows(t *testing.T) {
440	now := time.Now()
441	entryA := &entity.HistoryEntry{ID: 1, URL: "https://a.com", Title: "A", LastVisited: now}
442	entryB := &entity.HistoryEntry{ID: 2, URL: "https://b.com", Title: "B", LastVisited: now.Add(-24 * time.Hour)}
443	rows := buildHistoryDisplayRows([]historyGroup{
444		{Label: "Today", Entries: []*entity.HistoryEntry{entryA}},
445		{Label: "Yesterday", Entries: []*entity.HistoryEntry{entryB}},
446	})
447
448	m := newKeyboardNavModelFromRows(rows)
449	assert.Equal(t, 4, m.totalRows())
450	assert.False(t, m.isSelectable(0))
451	assert.True(t, m.isSelectable(1))
452	assert.False(t, m.isSelectable(2))
453	assert.True(t, m.isSelectable(3))
454	assert.Same(t, entryA, m.entryAt(1))
455	assert.Equal(t, entryB.URL, m.entryURLAt(3))
456	assert.Equal(t, 3, m.nextDayBoundary(1))
457	assert.Equal(t, 1, m.previousDayBoundary(3))
458}
459
460// =============================================================================
461// Search state transition tests
462// =============================================================================
463
464func TestTransitionSearchState_EmptyQuery(t *testing.T) {
465	next := transitionSearchState("", 0)
466	assert.Empty(t, next.Query)
467	assert.False(t, next.HasSearchDone)
468	assert.False(t, next.HasResults)
469	assert.Equal(t, 0, next.ResultCount)
470}
471
472func TestTransitionSearchState_NewQuery(t *testing.T) {
473	next := transitionSearchState("example", 5)
474	assert.Equal(t, "example", next.Query)
475	assert.True(t, next.HasSearchDone)
476	assert.True(t, next.HasResults)
477	assert.Equal(t, 5, next.ResultCount)
478}
479
480func TestTransitionSearchState_QueryWithNoResults(t *testing.T) {
481	next := transitionSearchState("nonexistent", 0)
482	assert.Equal(t, "nonexistent", next.Query)
483	assert.True(t, next.HasSearchDone)
484	assert.False(t, next.HasResults)
485	assert.Equal(t, 0, next.ResultCount)
486}
487
488func TestTransitionSearchState_QueryToQuery(t *testing.T) {
489	next := transitionSearchState("new", 7)
490	assert.Equal(t, "new", next.Query)
491	assert.True(t, next.HasSearchDone)
492	assert.True(t, next.HasResults)
493	assert.Equal(t, 7, next.ResultCount)
494}
495
496func TestTransitionSearchState_QueryToEmpty(t *testing.T) {
497	next := transitionSearchState("", 0)
498	assert.Empty(t, next.Query)
499	assert.False(t, next.HasSearchDone)
500	assert.False(t, next.HasResults)
501	assert.Equal(t, 0, next.ResultCount)
502}
503
504// =============================================================================
505// Reload preservation tests
506// =============================================================================
507
508func TestApplyReloadState_WithoutQuery(t *testing.T) {
509	s := applyReloadState("")
510	assert.Empty(t, s.PreservedQuery)
511	assert.True(t, s.ResetBrowse)
512	assert.False(t, s.ClearSearch)
513}
514
515func TestApplyReloadState_WithQuery(t *testing.T) {
516	s := applyReloadState("search-term")
517	assert.Equal(t, "search-term", s.PreservedQuery)
518	assert.False(t, s.ResetBrowse)
519	assert.True(t, s.ClearSearch)
520}
521
522// =============================================================================
523// keyboardNavModel edge cases
524// =============================================================================
525
526func TestKeyboardNavModel_SingleGroupZeroEntries(t *testing.T) {
527	// A group with a header but zero entries.
528	groups := []historyGroup{
529		{Label: "Today", Entries: []*entity.HistoryEntry{}},
530	}
531	m := newKeyboardNavModel(groups)
532
533	assert.Equal(t, 1, m.totalRows()) // header only
534	assert.False(t, m.isSelectable(0))
535	assert.Equal(t, -1, m.firstSelectableIndex())
536	assert.Equal(t, -1, m.lastSelectableIndex())
537	assert.Nil(t, m.entryAt(0))
538	assert.Equal(t, 0, m.entryCount())
539}
540
541func TestKeyboardNavModel_MixedEmptyAndNonEmptyGroups(t *testing.T) {
542	// Groups: [A(0 entries), B(2 entries), C(0 entries)]
543	// Layout: H0(0), H1(1), E1-0(2), E1-1(3), H2(4)
544	groups := []historyGroup{
545		{Label: "EmptyA", Entries: []*entity.HistoryEntry{}},
546		{Label: "HasTwo", Entries: []*entity.HistoryEntry{
547			{ID: 1, URL: "https://b1.com", Title: "B1", LastVisited: time.Now()},
548			{ID: 2, URL: "https://b2.com", Title: "B2", LastVisited: time.Now()},
549		}},
550		{Label: "EmptyC", Entries: []*entity.HistoryEntry{}},
551	}
552	m := newKeyboardNavModel(groups)
553
554	assert.Equal(t, 5, m.totalRows())
555	// All headers: 0, 1, 4 are not selectable
556	assert.False(t, m.isSelectable(0))
557	assert.False(t, m.isSelectable(1))
558	assert.False(t, m.isSelectable(4))
559	// Entries: 2, 3 are selectable
560	assert.True(t, m.isSelectable(2))
561	assert.True(t, m.isSelectable(3))
562
563	assert.Equal(t, 2, m.firstSelectableIndex())
564	assert.Equal(t, 3, m.lastSelectableIndex())
565	assert.Equal(t, 2, m.entryCount())
566
567	// next/previous day boundary from group B (index 2) should cross
568	// empty group A header to group C header
569	// previousDayBoundary from entry in B (row 2) → group before B is A (no entries) → -1
570	assert.Equal(t, -1, m.previousDayBoundary(2))
571	// nextDayBoundary from entry in B (row 2) → group after B is C (no entries) → -1
572	assert.Equal(t, -1, m.nextDayBoundary(2))
573}
574
575func TestKeyboardNavModel_GroupIndexAt(t *testing.T) {
576	groups := makeGroups(2, 0, 3) // A:2 entries, B:0 entries, C:3 entries
577	// Layout: H0(0), E0-0(1), E0-1(2), H1(3), H2(4), E2-0(5), E2-1(6), E2-2(7)
578	// Groups: A at rows 0-2 (header+2 entries), B at row 3 (header only), C at rows 4-7 (header+3 entries)
579	m := newKeyboardNavModel(groups)
580
581	assert.Equal(t, 0, m.groupIndexAt(0), "row 0 should be in group 0 (A header)")
582	assert.Equal(t, 0, m.groupIndexAt(1), "row 1 should be in group 0 (A entry)")
583	assert.Equal(t, 1, m.groupIndexAt(3), "row 3 should be in group 1 (B header)")
584	assert.Equal(t, 2, m.groupIndexAt(4), "row 4 should be in group 2 (C header)")
585	assert.Equal(t, 2, m.groupIndexAt(7), "row 7 should be in group 2 (last C entry)")
586	assert.Equal(t, -1, m.groupIndexAt(99), "out of range should return -1")
587}
588
589func TestKeyboardNavModel_CumulativeOffsetAtGroup(t *testing.T) {
590	groups := makeGroups(2, 0, 3)
591	m := newKeyboardNavModel(groups)
592
593	assert.Equal(t, 0, m.cumulativeOffsetAtGroup(0), "group 0 offset should be 0")
594	// Group 0: 1 header + 2 entries = 3 rows
595	assert.Equal(t, 3, m.cumulativeOffsetAtGroup(1), "group 1 offset should skip group 0")
596	// Group 1: 1 header + 0 entries = 1 row, so group 2 offset = 3 + 1 = 4
597	assert.Equal(t, 4, m.cumulativeOffsetAtGroup(2), "group 2 offset should skip groups 0 and 1")
598	assert.Equal(t, -1, m.cumulativeOffsetAtGroup(99), "out of range should return -1")
599}
600
601func TestKeyboardNavModel_FirstEntryOfGroup(t *testing.T) {
602	groups := makeGroups(2, 0, 3)
603	m := newKeyboardNavModel(groups)
604
605	// Group 0: offset=0, first entry = 1
606	assert.Equal(t, 1, m.firstEntryOfGroup(0))
607	// Group 1: offset=3, first entry = 4 (but group has 0 entries -> -1)
608	assert.Equal(t, -1, m.firstEntryOfGroup(1))
609	// Group 2: offset=4, first entry = 5
610	assert.Equal(t, 5, m.firstEntryOfGroup(2))
611}
612
613func TestKeyboardNavModel_NextPreviousSelectable_EmptyGroups(t *testing.T) {
614	// All groups have zero entries.
615	groups := []historyGroup{
616		{Label: "A", Entries: []*entity.HistoryEntry{}},
617		{Label: "B", Entries: []*entity.HistoryEntry{}},
618	}
619	m := newKeyboardNavModel(groups)
620
621	assert.Equal(t, -1, m.nextSelectableIndex(0, 1))
622	assert.Equal(t, -1, m.nextSelectableIndex(0, -1))
623}
624
625func TestKeyboardNavModel_EntryCountWithEmptyGroups(t *testing.T) {
626	groups := []historyGroup{
627		{Label: "A", Entries: []*entity.HistoryEntry{{
628			ID: 1, URL: "https://a.com", Title: "A", LastVisited: time.Now(),
629		}}},
630		{Label: "B", Entries: []*entity.HistoryEntry{}},
631		{Label: "C", Entries: []*entity.HistoryEntry{{
632			ID: 2, URL: "https://c.com", Title: "C", LastVisited: time.Now(),
633		}}},
634	}
635	m := newKeyboardNavModel(groups)
636	assert.Equal(t, 2, m.entryCount(), "only non-empty groups' entries should count")
637}
638
639// =============================================================================
640// Search generation / stale-result suppression (pure transition model)
641// =============================================================================
642
643// TestTransitionSearchState_StaleGenBehavior documents that the search
644// generation counter (searchGen) is a production-side concern for stale
645// result suppression. The transitionSearchState pure function only models
646// query/result transitions; the actual generation guard is enforced by
647// the GTK idle callback comparing gen vs hs.searchGen.
648//
649// We test the callback pattern here by simulating two sequential searches
650// where a later result arrives after the gen has moved on.
651func TestTransitionSearchState_SequentialSearches(t *testing.T) {
652	// Search 1: "foo" -> 5 results
653	next1 := transitionSearchState("foo", 5)
654	assert.Equal(t, "foo", next1.Query)
655	assert.True(t, next1.HasSearchDone)
656	assert.True(t, next1.HasResults)
657	assert.Equal(t, 5, next1.ResultCount)
658
659	// Search 2: "foobar" -> 3 results (supersedes search 1)
660	next2 := transitionSearchState("foobar", 3)
661	assert.Equal(t, "foobar", next2.Query)
662	assert.True(t, next2.HasSearchDone)
663	assert.True(t, next2.HasResults)
664	assert.Equal(t, 3, next2.ResultCount)
665}
666
667// TestTransitionSearchState_SearchThenClearThenReSearch verifies the
668// transition from search -> empty -> new search produces correct state.
669func TestTransitionSearchState_SearchThenClearThenReSearch(t *testing.T) {
670	// Search "term" -> 2 results
671	s := transitionSearchState("term", 2)
672	assert.Equal(t, "term", s.Query)
673	assert.True(t, s.HasSearchDone)
674	assert.True(t, s.HasResults)
675	assert.Equal(t, 2, s.ResultCount)
676
677	// Clear: query becomes ""
678	s = transitionSearchState("", 0)
679	assert.Empty(t, s.Query)
680	assert.False(t, s.HasSearchDone)
681	assert.False(t, s.HasResults)
682	assert.Equal(t, 0, s.ResultCount)
683
684	// Re-search: new term
685	s = transitionSearchState("other", 7)
686	assert.Equal(t, "other", s.Query)
687	assert.True(t, s.HasSearchDone)
688	assert.True(t, s.HasResults)
689	assert.Equal(t, 7, s.ResultCount)
690}
691
692// TestApplyReloadState_EmptyAndNonEmpty verifies all reload preservation
693// states are correctly modeled by the pure function.
694func TestApplyReloadState_EmptyAndNonEmpty(t *testing.T) {
695	tt := []struct {
696		name      string
697		query     string
698		wantQuery string
699		wantReset bool
700		wantClear bool
701	}{
702		{name: "empty query", query: "", wantQuery: "", wantReset: true, wantClear: false},
703		{name: "non-empty", query: "search", wantQuery: "search", wantReset: false, wantClear: true},
704		{name: "whitespace only", query: "   ", wantQuery: "   ", wantReset: false, wantClear: true},
705		{name: "long query", query: "very long search query with many terms", wantQuery: "very long search query with many terms", wantReset: false, wantClear: true},
706	}
707
708	for _, tc := range tt {
709		t.Run(tc.name, func(t *testing.T) {
710			s := applyReloadState(tc.query)
711			assert.Equal(t, tc.wantQuery, s.PreservedQuery)
712			assert.Equal(t, tc.wantReset, s.ResetBrowse)
713			assert.Equal(t, tc.wantClear, s.ClearSearch)
714		})
715	}
716}
717
718// TestDayLabelForKey_DifferentYears verifies multi-year scenarios produce
719// the expected label format.
720func TestDayLabelForKey_DifferentYears(t *testing.T) {
721	now := time.Now()
722	todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
723
724	// Same year, not today/yesterday
725	future := now.AddDate(0, 0, -3)
726	key := dayKey{future.Year(), future.Month(), future.Day()}
727	label := dayLabelForKey(key, todayStart, now)
728	assert.NotContains(t, label, "2006", "within-current-year label should not contain year")
729
730	// Previous year (now.Year()-1)
731	lastYear := now.AddDate(-1, 0, 0)
732	key = dayKey{lastYear.Year(), lastYear.Month(), lastYear.Day()}
733	label = dayLabelForKey(key, todayStart, now)
734	assert.Equal(t, lastYear.Format(dayLabelOtherYearFormat), label)
735
736	// Multiple years ago
737	twoYearsAgo := now.AddDate(-2, 0, 0)
738	key = dayKey{twoYearsAgo.Year(), twoYearsAgo.Month(), twoYearsAgo.Day()}
739	label = dayLabelForKey(key, todayStart, now)
740	assert.Contains(t, label, twoYearsAgo.Format("2006"), "multi-year-old label should include year")
741}

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