history_sidebar_reactivity_test.go RawHistoryFolder
1package component
2
3import (
4 "sync"
5 "testing"
6 "time"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/puregotk/v4/glib"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12)
13
14type sidebarReactivityHarness struct {
15 hs *HistorySidebar
16 idle []glib.SourceFunc
17 timers map[uint]glib.SourceFunc
18 removed []uint
19 nextID uint
20 idleMu sync.Mutex
21 timerMu sync.Mutex
22}
23
24func newSidebarReactivityHarness() *sidebarReactivityHarness {
25 h := &sidebarReactivityHarness{timers: make(map[uint]glib.SourceFunc), nextID: 10}
26 hs := newTestSidebarSearchHarness()
27 hs.now = func() time.Time { return time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) }
28 hs.idleScheduler = func(cb glib.SourceFunc) {
29 h.idleMu.Lock()
30 defer h.idleMu.Unlock()
31 h.idle = append(h.idle, cb)
32 }
33 hs.timeoutAdd = func(_ uint, cb glib.SourceFunc) uint {
34 h.timerMu.Lock()
35 defer h.timerMu.Unlock()
36 h.nextID++
37 h.timers[h.nextID] = cb
38 return h.nextID
39 }
40 hs.sourceRemove = func(id uint) {
41 h.timerMu.Lock()
42 defer h.timerMu.Unlock()
43 h.removed = append(h.removed, id)
44 delete(h.timers, id)
45 }
46 h.hs = hs
47 return h
48}
49
50func (h *sidebarReactivityHarness) runIdle() int {
51 h.idleMu.Lock()
52 callbacks := append([]glib.SourceFunc(nil), h.idle...)
53 h.idle = nil
54 h.idleMu.Unlock()
55 for _, cb := range callbacks {
56 cb(0)
57 }
58 return len(callbacks)
59}
60
61func (h *sidebarReactivityHarness) singleTimer(t *testing.T) (uint, glib.SourceFunc) {
62 t.Helper()
63 h.timerMu.Lock()
64 defer h.timerMu.Unlock()
65 require.Len(t, h.timers, 1)
66 for id, cb := range h.timers {
67 return id, cb
68 }
69 return 0, nil
70}
71
72func TestHistorySidebar_RequestReloadIfVisible_HiddenNoop(t *testing.T) {
73 h := newSidebarReactivityHarness()
74 h.hs.RequestReloadIfVisible("hidden")
75 require.Zero(t, h.runIdle())
76 assert.Empty(t, h.timers)
77 assert.Zero(t, h.hs.reloadDebounceTimer)
78}
79
80func TestHistorySidebar_RequestReloadIfVisible_CoalescesVisible(t *testing.T) {
81 h := newSidebarReactivityHarness()
82 h.hs.visible = true
83 h.hs.RequestReloadIfVisible("a")
84 h.hs.RequestReloadIfVisible("b")
85 require.Equal(t, 2, h.runIdle())
86 assert.Len(t, h.timers, 1)
87 assert.Len(t, h.removed, 1, "first debounce source should be removed")
88 assert.NotZero(t, h.hs.reloadDebounceTimer)
89}
90
91func TestHistorySidebar_ReloadDebounceCallbackReloadsOnceAndClearsTimer(t *testing.T) {
92 h := newSidebarReactivityHarness()
93 h.hs.visible = true
94 h.hs.RequestReloadIfVisible("history-change")
95 require.Equal(t, 1, h.runIdle())
96 timerID, cb := h.singleTimer(t)
97 require.Equal(t, timerID, h.hs.reloadDebounceTimer)
98
99 keep := cb(0)
100
101 assert.False(t, keep, "debounce timeout should be one-shot")
102 assert.Zero(t, h.hs.reloadDebounceTimer)
103 assert.True(t, h.hs.loadStarted, "debounce should call Reload and start a history load")
104}
105
106func TestHistorySidebar_ReloadDebounceCallbackSkipsHiddenSidebar(t *testing.T) {
107 h := newSidebarReactivityHarness()
108 h.hs.visible = true
109 h.hs.RequestReloadIfVisible("history-change")
110 require.Equal(t, 1, h.runIdle())
111 _, cb := h.singleTimer(t)
112 h.hs.visible = false
113
114 keep := cb(0)
115
116 assert.False(t, keep, "debounce timeout should be one-shot")
117 assert.Zero(t, h.hs.reloadDebounceTimer)
118 assert.False(t, h.hs.loadStarted, "hidden sidebar should not reload when debounce fires")
119}
120
121func TestHistorySidebar_ReloadDebounceCallbackIgnoresSupersededTimer(t *testing.T) {
122 h := newSidebarReactivityHarness()
123 h.hs.visible = true
124 h.hs.RequestReloadIfVisible("first")
125 require.Equal(t, 1, h.runIdle())
126 firstID, staleCB := h.singleTimer(t)
127
128 h.hs.RequestReloadIfVisible("second")
129 require.Equal(t, 1, h.runIdle())
130 currentID, currentCB := h.singleTimer(t)
131 require.NotEqual(t, firstID, currentID)
132 require.Equal(t, currentID, h.hs.reloadDebounceTimer)
133
134 keep := staleCB(0)
135
136 assert.False(t, keep, "stale debounce timeout should be one-shot")
137 assert.Equal(t, currentID, h.hs.reloadDebounceTimer)
138 assert.False(t, h.hs.loadStarted, "superseded debounce should not reload")
139
140 keep = currentCB(0)
141 assert.False(t, keep)
142 assert.Zero(t, h.hs.reloadDebounceTimer)
143 assert.True(t, h.hs.loadStarted, "current debounce should still reload")
144}
145
146func TestHistorySidebar_DestroyCancelsReloadTickerAndClearsBindings(t *testing.T) {
147 h := newSidebarReactivityHarness()
148 h.hs.visible = true
149 h.hs.reloadDebounceTimer = 51
150 h.hs.relativeTimeTicker = 52
151 h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime()}}
152
153 h.hs.Destroy()
154
155 h.hs.mu.RLock()
156 destroyed := h.hs.destroyed
157 reloadTimer := h.hs.reloadDebounceTimer
158 ticker := h.hs.relativeTimeTicker
159 bindings := h.hs.relativeTimeLabelBinds
160 h.hs.mu.RUnlock()
161 assert.True(t, destroyed)
162 assert.Zero(t, reloadTimer)
163 assert.Zero(t, ticker)
164 assert.Empty(t, bindings)
165 assert.Contains(t, h.removed, uint(51))
166 assert.Contains(t, h.removed, uint(52))
167}
168
169func TestHistorySidebar_HideCancelsReloadTickerAndClearsBindingsWithoutDestroying(t *testing.T) {
170 h := newSidebarReactivityHarness()
171 h.hs.visible = true
172 h.hs.reloadDebounceTimer = 61
173 h.hs.relativeTimeTicker = 62
174 h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime()}}
175
176 h.hs.Hide()
177
178 h.hs.mu.RLock()
179 destroyed := h.hs.destroyed
180 visible := h.hs.visible
181 reloadTimer := h.hs.reloadDebounceTimer
182 ticker := h.hs.relativeTimeTicker
183 bindings := h.hs.relativeTimeLabelBinds
184 h.hs.mu.RUnlock()
185 assert.False(t, destroyed)
186 assert.False(t, visible)
187 assert.Zero(t, reloadTimer)
188 assert.Zero(t, ticker)
189 assert.Empty(t, bindings)
190 assert.Contains(t, h.removed, uint(61))
191 assert.Contains(t, h.removed, uint(62))
192}
193
194func TestHistorySidebar_RelativeTimeBindingsLifecycleAndTicker(t *testing.T) {
195 h := newSidebarReactivityHarness()
196 h.hs.visible = true
197 h.hs.bindRelativeTimeLabel(nil, h.hs.currentTime())
198 assert.Empty(t, h.hs.relativeTimeLabelBinds, "nil labels are skipped")
199 h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime().Add(-time.Minute)}}
200 h.hs.mu.Lock()
201 h.hs.clearRelativeTimeBindingsLocked()
202 h.hs.mu.Unlock()
203 assert.Empty(t, h.hs.relativeTimeLabelBinds)
204
205 h.hs.startRelativeTimeTicker()
206 assert.NotZero(t, h.hs.relativeTimeTicker)
207 assert.Len(t, h.timers, 1)
208 assert.Equal(t, 1, h.runIdle(), "initial label refresh is scheduled through idle path")
209
210 ticker := h.hs.relativeTimeTicker
211 h.hs.visible = false
212 h.hs.relativeTimeTicker = 0
213 h.hs.removeSource(ticker)
214 assert.Contains(t, h.removed, ticker)
215}
216
217func TestHistorySidebar_RelativeTimeTickerIgnoresSupersededTimer(t *testing.T) {
218 h := newSidebarReactivityHarness()
219 h.hs.visible = true
220
221 h.hs.startRelativeTimeTicker()
222 firstID, staleCB := h.singleTimer(t)
223 require.Equal(t, 1, h.runIdle(), "clear initial refresh")
224
225 h.hs.startRelativeTimeTicker()
226 currentID, currentCB := h.singleTimer(t)
227 require.NotEqual(t, firstID, currentID)
228 require.Equal(t, currentID, h.hs.relativeTimeTicker)
229 require.Equal(t, 1, h.runIdle(), "clear replacement initial refresh")
230
231 keep := staleCB(0)
232
233 assert.False(t, keep, "stale ticker should stop")
234 assert.Equal(t, currentID, h.hs.relativeTimeTicker)
235 assert.Zero(t, h.runIdle(), "stale ticker should not schedule refresh")
236
237 keep = currentCB(0)
238 assert.True(t, keep, "current ticker should keep running while visible")
239 assert.Equal(t, 1, h.runIdle(), "current ticker schedules refresh")
240}
241
242func TestHistorySidebar_RelativeTimeDayBoundaryRequestsReloadNoRepoCall(t *testing.T) {
243 h := newSidebarReactivityHarness()
244 day1 := time.Date(2026, 6, 24, 23, 59, 0, 0, time.UTC)
245 day2 := day1.Add(2 * time.Minute)
246 now := day1
247 h.hs.now = func() time.Time { return now }
248 h.hs.visible = true
249 h.hs.relativeTimeDayKey = h.hs.currentDayKey()
250 h.hs.relativeTimeDayKeySet = true
251 h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: day1.Add(-time.Hour)}}
252 now = day2
253 h.hs.updateRelativeTimeLabelsOnMain()
254 assert.False(t, h.hs.loadStarted, "day boundary should not reload synchronously")
255 assert.Equal(t, 1, h.runIdle(), "day boundary schedules debounced reload via RequestReloadIfVisible")
256 assert.False(t, h.hs.loadStarted, "day boundary should wait for debounce before loading")
257 assert.Equal(t, h.hs.currentDayKey(), h.hs.relativeTimeDayKey)
258 assert.Len(t, h.timers, 1)
259}
260
261func TestRelativeTimeAtDeterministic(t *testing.T) {
262 now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
263 assert.Equal(t, "1m ago", relativeTimeAt(now.Add(-time.Minute), now))
264 assert.Equal(t, "2h ago", relativeTimeAt(now.Add(-2*time.Hour), now))
265}
266
267func TestHistorySidebar_RelativeRefreshDoesNotTouchHistoryRepo(t *testing.T) {
268 h := newSidebarReactivityHarness()
269 h.hs.historyUC = nil
270 h.hs.visible = true
271 h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime().Add(-time.Minute)}}
272 h.hs.updateRelativeTimeLabelsOnMain()
273 assert.Empty(t, h.timers, "label-only refresh does not schedule reload/repo work without day boundary")
274}
275
276func TestHistorySidebar_ReloadStillPreservesDisplayRowsSemantics(t *testing.T) {
277 hs := newTestSidebarSearchHarness()
278 entry := &entity.HistoryEntry{URL: "https://example.com", LastVisited: time.Now()}
279 hs.setDisplayGroupsLocked(groupHistoryByDay([]*entity.HistoryEntry{entry}))
280 require.Len(t, hs.displayRows, 2)
281 assert.Equal(t, entry.URL, hs.entryURLAtIndex(1))
282}