1package ui
2
3import (
4 "testing"
5 "time"
6
7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/stretchr/testify/require"
9)
10
11func TestResidencyTimeoutFromMillis(t *testing.T) {
12 require.Equal(t, time.Duration(0), ResidencyTimeoutFromMillis(0))
13 require.Equal(t, time.Duration(0), ResidencyTimeoutFromMillis(-5))
14 require.Equal(t, 60*time.Second, ResidencyTimeoutFromMillis(60000))
15 require.Equal(t, 300000*time.Millisecond, ResidencyTimeoutFromMillis(300000))
16 require.Equal(t, 300000*time.Millisecond, ResidencyTimeoutFromMillis(999999))
17}
18
19func TestResidencyTimeoutLatchAndRestartRequired(t *testing.T) {
20 app := &App{}
21 require.Equal(t, time.Duration(0), app.LatchedResidencyTimeout())
22
23 app.SetResidencyTimeout(60 * time.Second)
24 require.Equal(t, 60*time.Second, app.LatchedResidencyTimeout())
25 require.False(t, app.ResidencyTimeoutRestartRequired(60000))
26 require.True(t, app.ResidencyTimeoutRestartRequired(0))
27 require.True(t, app.ResidencyTimeoutRestartRequired(120000))
28
29 // A nil app never panics and reports no change.
30 var nilApp *App
31 nilApp.SetResidencyTimeout(time.Minute)
32 require.Equal(t, time.Duration(0), nilApp.LatchedResidencyTimeout())
33 require.False(t, nilApp.ResidencyTimeoutRestartRequired(60000))
34}
35
36type residencyFakeApp struct {
37 holds int
38 releases int
39 quits int
40}
41
42func (f *residencyFakeApp) Hold() { f.holds++ }
43func (f *residencyFakeApp) Release() { f.releases++ }
44func (f *residencyFakeApp) Quit() { f.quits++ }
45
46type residencyFakeScheduler struct {
47 scheduled []time.Duration
48 generations []uint64
49 cancels int
50}
51
52func (f *residencyFakeScheduler) schedule(d time.Duration, generation uint64) {
53 f.scheduled = append(f.scheduled, d)
54 f.generations = append(f.generations, generation)
55}
56
57func (f *residencyFakeScheduler) cancel() { f.cancels++ }
58
59func newResidencyTestController(timeout time.Duration) (*ResidencyController, *residencyFakeApp, *residencyFakeScheduler, *int) {
60 now := time.Now()
61 clock := func() time.Time { return now }
62 app := &residencyFakeApp{}
63 sched := &residencyFakeScheduler{}
64 quits := 0
65 ctl := NewResidencyController(timeout, clock, sched.schedule, sched.cancel, func() { quits++ })
66 return ctl, app, sched, &quits
67}
68
69func TestResidencyControllerDisabledPreservesQuit(t *testing.T) {
70 ctl, app, sched, quits := newResidencyTestController(0)
71 require.False(t, ctl.Enabled())
72 ctl.AcquireHold(app)
73 require.Equal(t, 0, app.holds, "disabled residency takes no hold")
74 ctl.ReleaseHold(app)
75 require.Equal(t, 0, app.releases)
76
77 ctl.SetWindowCount(1)
78 decision := ctl.SetWindowCount(0)
79 require.Equal(t, usecase.ResidencyQuit, decision.Action)
80 require.Equal(t, 1, *quits)
81 require.Empty(t, sched.scheduled)
82}
83
84func TestResidencyControllerHoldMatchesReleaseOnce(t *testing.T) {
85 ctl, app, _, quits := newResidencyTestController(time.Minute)
86 require.True(t, ctl.Enabled())
87 ctl.AcquireHold(app)
88 ctl.AcquireHold(app)
89 require.Equal(t, 1, app.holds, "exactly one hold")
90 ctl.ReleaseHold(app)
91 ctl.ReleaseHold(app)
92 require.Equal(t, 1, app.releases, "exactly one release")
93 require.Equal(t, 0, *quits, "hold cycling never quits")
94}
95
96func TestResidencyControllerLastWindowArmsAndExpiryQuits(t *testing.T) {
97 ctl, _, sched, quits := newResidencyTestController(time.Minute)
98 ctl.SetWindowCount(1)
99 decision := ctl.SetWindowCount(0)
100 require.Equal(t, usecase.ResidencyArm, decision.Action)
101 require.NotEmpty(t, sched.scheduled)
102 armed := sched.generations[len(sched.generations)-1]
103 ctl.OnTimerFired(armed)
104 require.Equal(t, 1, *quits)
105}
106
107func TestResidencyControllerReopenCancelsTimer(t *testing.T) {
108 ctl, _, sched, _ := newResidencyTestController(time.Minute)
109 ctl.SetWindowCount(1)
110 ctl.SetWindowCount(0)
111 require.NotEmpty(t, sched.scheduled)
112 armed := sched.generations[len(sched.generations)-1]
113 cancels := sched.cancels
114 ctl.SetWindowCount(1)
115 require.Greater(t, sched.cancels, cancels, "reopen cancels the armed deadline")
116 ctl.OnTimerFired(armed)
117}
118
119func TestResidencyControllerExplicitQuitBypassesDeadline(t *testing.T) {
120 ctl, _, sched, quits := newResidencyTestController(time.Minute)
121 ctl.SetWindowCount(1)
122 ctl.SetWindowCount(0)
123 require.NotEmpty(t, sched.scheduled)
124 armed := sched.generations[len(sched.generations)-1]
125 ctl.NoteExplicitQuit()
126 require.Equal(t, 0, *quits, "noting quit never invokes quit itself")
127 ctl.OnTimerFired(armed)
128 require.Equal(t, 0, *quits, "deadline never fires after explicit quit")
129}
130
131func TestResidencyControllerNilSafe(t *testing.T) {
132 var ctl *ResidencyController
133 require.False(t, ctl.Enabled())
134 require.Equal(t, usecase.ResidencyQuit, ctl.SetWindowCount(0).Action)
135 require.Equal(t, usecase.ResidencyQuit, ctl.Evaluate().Action)
136 ctl.NoteExplicitQuit()
137 ctl.OnTimerFired(7)
138 ctl.AcquireHold(nil)
139 ctl.ReleaseHold(nil)
140 ctl.SetQuiescentFunc(nil)
141 ctl.SetDispatchToGTK(nil)
142 ctl.DispatchToGTK(nil)
143 ctl.MaybeQuit()
144 require.Equal(t, usecase.ResidencyNone, ctl.SetNativeBusy("x", true).Action)
145}
146
147func TestResidencyControllerDefersQuitUntilSettled(t *testing.T) {
148 ctl, _, sched, quits := newResidencyTestController(time.Minute)
149 quiescent := true
150 ctl.SetQuiescentFunc(func() bool { return quiescent })
151 ctl.SetWindowCount(1)
152 decision := ctl.SetWindowCount(0)
153 require.Equal(t, usecase.ResidencyArm, decision.Action)
154 armed := sched.generations[len(sched.generations)-1]
155
156 quiescent = false
157 ctl.OnTimerFired(armed)
158 require.Equal(t, 0, *quits, "expiry defers while native work is outstanding")
159
160 quiescent = true
161 ctl.MaybeQuit()
162 require.Equal(t, 1, *quits, "settle recheck commits the deferred quit")
163 ctl.MaybeQuit()
164 require.Equal(t, 1, *quits, "quit commits exactly once")
165}
166
167func TestResidencyControllerNativeBusyDrivesPolicy(t *testing.T) {
168 ctl, _, sched, quits := newResidencyTestController(time.Minute)
169 ctl.SetWindowCount(1)
170
171 decision := ctl.SetNativeBusy("cef-activity", true)
172 require.Equal(t, usecase.ResidencyNone, decision.Action)
173 decision = ctl.SetWindowCount(0)
174 require.Equal(t, usecase.ResidencyNone, decision.Action, "close with native work outstanding arms nothing")
175 require.Empty(t, sched.scheduled)
176
177 decision = ctl.SetNativeBusy("cef-activity", false)
178 require.Equal(t, usecase.ResidencyArm, decision.Action, "settle re-arms idle")
179 require.NotEmpty(t, sched.scheduled)
180 require.Equal(t, 0, *quits)
181}
182
183func TestResidencyControllerDuplicateBusyIsSilent(t *testing.T) {
184 ctl, _, sched, _ := newResidencyTestController(time.Minute)
185 ctl.SetNativeBusy("downloads", true)
186 decision := ctl.SetNativeBusy("downloads", true)
187 require.Equal(t, usecase.ResidencyNone, decision.Action)
188 require.Empty(t, sched.scheduled)
189}
190
191func TestResidencyControllerDispatchRoutesCallbacks(t *testing.T) {
192 ctl, _, _, quits := newResidencyTestController(time.Minute)
193 var routed []string
194 ctl.SetDispatchToGTK(func(fn func()) {
195 routed = append(routed, "gtk")
196 fn()
197 })
198 ctl.DispatchToGTK(func() { routed = append(routed, "cb") })
199 require.Equal(t, []string{"gtk", "cb"}, routed)
200 require.Equal(t, 0, *quits)
201}
202
203func TestResidencyControllerSealsBeforeCommit(t *testing.T) {
204 ctl, _, sched, quits := newResidencyTestController(time.Minute)
205 sealed := 0
206 unsealed := 0
207 ctl.SetSealFunc(func() { sealed++ })
208 ctl.SetUnsealFunc(func() { unsealed++ })
209 ctl.SetQuiescentFunc(func() bool { return true })
210 ctl.SetWindowCount(1)
211 ctl.SetWindowCount(0)
212 armed := sched.generations[len(sched.generations)-1]
213 // Quiescent expiry seals admission, then commits the quit.
214 ctl.OnTimerFired(armed)
215 require.Equal(t, 1, sealed)
216 require.Equal(t, 0, unsealed)
217 require.Equal(t, 1, *quits)
218}
219
220func TestResidencyControllerReopenInvalidatesDeferredQuit(t *testing.T) {
221 ctl, _, sched, quits := newResidencyTestController(time.Minute)
222 quiescent := true
223 unsealed := 0
224 ctl.SetUnsealFunc(func() { unsealed++ })
225 ctl.SetQuiescentFunc(func() bool { return quiescent })
226 ctl.SetWindowCount(1)
227 ctl.SetWindowCount(0)
228 armed := sched.generations[len(sched.generations)-1]
229 quiescent = false
230 ctl.OnTimerFired(armed)
231 require.Equal(t, 0, *quits, "quit defers while unsettled")
232 // A reopened window invalidates the deferral.
233 ctl.SetWindowCount(1)
234 quiescent = true
235 ctl.MaybeQuit()
236 require.Equal(t, 0, *quits, "obsolete quit must not run after reopen")
237 require.Equal(t, 1, unsealed, "invalidation reopens admission")
238}
239
240func TestResidencyControllerDuplicateEmptyStaysArmed(t *testing.T) {
241 ctl, _, sched, quits := newResidencyTestController(time.Minute)
242 ctl.SetWindowCount(1)
243 first := ctl.SetWindowCount(0)
244 require.Equal(t, usecase.ResidencyArm, first.Action)
245 require.Len(t, sched.scheduled, 1)
246 // A repeated empty update (e.g. tab-empty after window removal)
247 // stays on the armed deadline instead of re-arming.
248 again := ctl.SetWindowCount(0)
249 require.Equal(t, usecase.ResidencyNone, again.Action)
250 require.Len(t, sched.scheduled, 1, "no duplicate timer")
251 require.Equal(t, 0, *quits)
252}