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}