1package core
2
3import (
4 "fmt"
5 "github.com/bnema/neferafk/internal/ports"
6 "slices"
7 "testing"
8 "time"
9)
10
11func handle(t *testing.T, p *Policy, now time.Time, e ports.PolicyEvent) []ports.PolicyEffect {
12 t.Helper()
13 out, err := p.Handle(now, e)
14 if err != nil {
15 t.Fatal(err)
16 }
17 return out
18}
19func actions(effects []ports.PolicyEffect) []ports.Action {
20 var a []ports.Action
21 for _, e := range effects {
22 switch e.(type) {
23 case ports.AcquireLock:
24 a = append(a, ports.Lock)
25 case ports.SetFade:
26 a = append(a, ports.Fade)
27 case ports.SetOutputPower:
28 a = append(a, ports.OutputOff)
29 case ports.Suspend:
30 a = append(a, ports.Sleep)
31 }
32 }
33 return a
34}
35func config(ds [4]time.Duration) ports.Config {
36 c := ports.Defaults()
37 c.LockAfter, c.FadeAfter, c.OffAfter, c.SleepAfter = ds[0], ds[1], ds[2], ds[3]
38 return c
39}
40
41func TestAllSixteenSubsetsTies(t *testing.T) {
42 for bits := 0; bits < 16; bits++ {
43 t.Run(fmt.Sprint(bits), func(t *testing.T) {
44 var ds [4]time.Duration
45 var want []ports.Action
46 for i := range 4 {
47 if bits&(1<<i) != 0 {
48 ds[i] = time.Second
49 want = append(want, ports.Action(i))
50 }
51 }
52 now := time.Unix(100, 0)
53 p, armed, err := New(config(ds), now)
54 if err != nil {
55 t.Fatal(err)
56 }
57 arm := armed[0].(ports.ArmIdle)
58 if len(arm.Notifications) != min(bits, 1) {
59 t.Fatal(arm)
60 }
61 got := handle(t, p, now.Add(time.Second), ports.IdleDue{Generation: arm.Generation, After: time.Second})
62 if bits&1 != 0 {
63 got = append(got, handle(t, p, now.Add(time.Second), ports.LockConfirmed{Generation: arm.Generation})...)
64 }
65 if !slices.Equal(actions(got), want) {
66 t.Fatalf("got %v want %v", actions(got), want)
67 }
68 })
69 }
70}
71
72func TestAllTwentyFourDeadlineOrders(t *testing.T) {
73 var visit func([]int, []int)
74 visit = func(prefix, remaining []int) {
75 if len(remaining) > 0 {
76 for i, v := range remaining {
77 visit(append(slices.Clone(prefix), v), append(slices.Clone(remaining[:i]), remaining[i+1:]...))
78 }
79 return
80 }
81 t.Run(fmt.Sprint(prefix), func(t *testing.T) {
82 var ds [4]time.Duration
83 for rank, a := range prefix {
84 ds[a] = time.Duration(rank+1) * time.Second
85 }
86 now := time.Unix(100, 0)
87 p, _, _ := New(config(ds), now)
88 var got []ports.Action
89 for rank := range 4 {
90 d := time.Duration(rank+1) * time.Second
91 out := handle(t, p, now.Add(d), ports.IdleDue{Generation: 1, After: d})
92 got = append(got, actions(out)...)
93 if p.acquiring {
94 got = append(got, actions(handle(t, p, now.Add(d), ports.LockConfirmed{Generation: p.lockGeneration}))...)
95 }
96 }
97 var want []ports.Action
98 for _, a := range prefix {
99 want = append(want, ports.Action(a))
100 }
101 if !slices.Equal(got, want) {
102 t.Fatalf("got %v want %v", got, want)
103 }
104 })
105 }
106 visit(nil, []int{0, 1, 2, 3})
107}
108
109func TestAcquiringHoldsOnlyDueOffSleepActivityNeverUnlocks(t *testing.T) {
110 now := time.Unix(100, 0)
111 p, _, _ := New(config([4]time.Duration{time.Second, 2 * time.Second, 3 * time.Second, 4 * time.Second}), now)
112 handle(t, p, now, ports.IdleDue{Generation: 1, After: time.Second})
113 if len(handle(t, p, now, ports.IdleDue{Generation: 1, After: 3 * time.Second})) != 0 {
114 t.Fatal("off before lock confirmation")
115 }
116 if got := actions(handle(t, p, now, ports.IdleDue{Generation: 1, After: 2 * time.Second})); !slices.Equal(got, []ports.Action{ports.Fade}) {
117 t.Fatal(got)
118 }
119 handle(t, p, now, ports.IdleDue{Generation: 1, After: 4 * time.Second})
120 if got := actions(handle(t, p, now, ports.LockConfirmed{Generation: 1})); !slices.Equal(got, []ports.Action{ports.OutputOff, ports.Sleep}) {
121 t.Fatal(got)
122 }
123 out := handle(t, p, now, ports.Activity{})
124 if !p.protected || p.off || p.faded {
125 t.Fatal(p.State())
126 }
127 if len(out) != 3 {
128 t.Fatal(out)
129 }
130 if len(handle(t, p, now, ports.IdleDue{Generation: 1, After: time.Second})) != 0 {
131 t.Fatal("old proxy event ran")
132 }
133 handle(t, p, now, ports.LockReleased{Generation: 99})
134 if !p.protected {
135 t.Fatal("stale unlock")
136 }
137 handle(t, p, now, ports.LockReleased{Generation: 1})
138 if p.protected {
139 t.Fatal("valid visual release ignored")
140 }
141}
142
143func TestReloadRebasesOnlyChangedPendingAndKeepsProtection(t *testing.T) {
144 now := time.Unix(100, 0)
145 cfg := ports.Defaults()
146 p, _, _ := New(cfg, now)
147 next := cfg
148 next.FadeAfter = 8 * time.Minute
149 out := handle(t, p, now.Add(time.Minute), ports.Reload{Config: next})
150 arm := out[0].(ports.ArmIdle)
151 want := []ports.IdleNotification{{ID: 1, After: 5 * time.Minute}, {ID: 3, After: 7 * time.Minute}, {ID: 5, After: 8 * time.Minute}, {ID: 4, After: 20 * time.Minute}}
152 if !slices.Equal(arm.Notifications, want) {
153 t.Fatalf("arm %+v", arm)
154 }
155 handle(t, p, now, ports.IdleDue{Generation: 1, Notification: 1, After: 5 * time.Minute})
156 next.LockAfter = 0
157 handle(t, p, now, ports.Reload{Config: next})
158 if !p.acquiring {
159 t.Fatal("reload cancelled acquisition")
160 }
161 handle(t, p, now, ports.LockConfirmed{Generation: 2})
162 if !p.protected {
163 t.Fatal("reload lost lock generation")
164 }
165 bad := next
166 bad.SleepAfter = -1
167 if _, err := p.Handle(now, ports.Reload{Config: bad}); err == nil {
168 t.Fatal("bad config committed")
169 }
170 if p.cfg != next {
171 t.Fatal("partial reload")
172 }
173 handle(t, p, now, ports.Activity{})
174 if !p.protected {
175 t.Fatal("activity unlock")
176 }
177}
178
179func TestBeforeSleepHonorsLockDisabled(t *testing.T) {
180 now := time.Unix(100, 0)
181 for _, enabled := range []bool{false, true} {
182 cfg := ports.Defaults()
183 if !enabled {
184 cfg.LockAfter = 0
185 }
186 p, _, _ := New(cfg, now)
187 out := handle(t, p, now, ports.BeforeSleep{})
188 if !enabled {
189 if _, ok := out[0].(ports.SleepReady); !ok {
190 t.Fatal(out)
191 }
192 if p.acquiring {
193 t.Fatal("forced disabled lock")
194 }
195 } else {
196 if _, ok := out[0].(ports.AcquireLock); !ok {
197 t.Fatal(out)
198 }
199 out = handle(t, p, now, ports.LockConfirmed{Generation: 1})
200 if _, ok := out[len(out)-1].(ports.SleepReady); !ok {
201 t.Fatal(out)
202 }
203 }
204 }
205}
206
207func TestReloadRetainsUnchangedProxyRejectsReplacedProxyAndDoneFlags(t *testing.T) {
208 now := time.Unix(100, 0)
209 cfg := ports.Defaults()
210 p, initial, _ := New(cfg, now)
211 old := initial[0].(ports.ArmIdle)
212 fadeID := old.Notifications[1].ID
213 next := cfg
214 next.FadeAfter = 8 * time.Minute
215 out := handle(t, p, now.Add(time.Minute), ports.Reload{Config: next})
216 if len(handle(t, p, now, ports.IdleDue{Generation: 1, Notification: fadeID, After: 6 * time.Minute})) != 0 {
217 t.Fatal("replaced proxy delivered")
218 }
219 var newFade ports.IdleNotification
220 for _, n := range out[0].(ports.ArmIdle).Notifications {
221 if n.After == 8*time.Minute {
222 newFade = n
223 }
224 }
225 if got := actions(handle(t, p, now, ports.IdleDue{Generation: 2, Notification: newFade.ID, After: newFade.After})); !slices.Equal(got, []ports.Action{ports.Fade}) {
226 t.Fatal(got)
227 }
228 next.FadeAfter = time.Minute
229 handle(t, p, now, ports.Reload{Config: next})
230 if !p.deadlines[ports.Fade].done {
231 t.Fatal("reload reset completed fade")
232 }
233 if len(handle(t, p, now, ports.IdleDue{Generation: 2, Notification: newFade.ID, After: newFade.After})) != 0 {
234 t.Fatal("done fade replayed")
235 }
236}
237
238func TestCompletedFadeDisableReenableRetiresOldNotification(t *testing.T) {
239 now := time.Unix(100, 0)
240 cfg := config([4]time.Duration{0, time.Second, 0, 0})
241 p, initial, _ := New(cfg, now)
242 proxy := initial[0].(ports.ArmIdle).Notifications[0]
243 handle(t, p, now, ports.IdleDue{Generation: 1, Notification: proxy.ID, After: proxy.After})
244 cfg.FadeAfter = 0
245 handle(t, p, now, ports.Reload{Config: cfg})
246 d := p.deadlines[ports.Fade]
247 if d.after != 0 || !d.done || d.due || d.notification != 0 {
248 t.Fatalf("disable metadata %+v", d)
249 }
250 cfg.FadeAfter = 2 * time.Second
251 out := handle(t, p, now, ports.Reload{Config: cfg})
252 if len(out[0].(ports.ArmIdle).Notifications) != 0 {
253 t.Fatal("completed action rearmed")
254 }
255 if len(handle(t, p, now, ports.IdleDue{Generation: 1, Notification: proxy.ID, After: proxy.After})) != 0 {
256 t.Fatal("old event replayed")
257 }
258 handle(t, p, now, ports.Activity{})
259 if p.deadlines[ports.Fade].after != 2*time.Second || p.deadlines[ports.Fade].done {
260 t.Fatal("next cycle ignored latest enablement")
261 }
262}
263
264func TestEqualAfterReloadKeepsOriginalProxySeparateFromRebasedDeadline(t *testing.T) {
265 now := time.Unix(100, 0)
266 cfg := config([4]time.Duration{time.Minute, 2 * time.Minute, 0, 0})
267 p, initial, _ := New(cfg, now)
268 old := initial[0].(ports.ArmIdle).Notifications[0]
269 cfg.FadeAfter = time.Minute
270 arm := handle(t, p, now.Add(30*time.Second), ports.Reload{Config: cfg})[0].(ports.ArmIdle)
271 if len(arm.Notifications) != 2 || arm.Notifications[0].ID == arm.Notifications[1].ID {
272 t.Fatal("unrelated clock origins incorrectly coalesced")
273 }
274 // Equal timeout values do not imply equal due instants: fade was rearmed
275 // at reload while lock's existing notification stayed registered.
276 if got := actions(handle(t, p, now, ports.IdleDue{Generation: 1, Notification: old.ID, After: old.After})); !slices.Equal(got, []ports.Action{ports.Lock}) {
277 t.Fatal(got)
278 }
279 if got := actions(handle(t, p, now, ports.IdleDue{Generation: 2, Notification: arm.Notifications[1].ID, After: time.Minute})); !slices.Equal(got, []ports.Action{ports.Fade}) {
280 t.Fatal(got)
281 }
282}
283
284func TestLockRequestedAcquiresOnceAndNeverUnlocks(t *testing.T) {
285 now := time.Unix(100, 0)
286 p, _, _ := New(config([4]time.Duration{0, 0, time.Minute, 0}), now)
287 out := handle(t, p, now, ports.LockRequested{})
288 if len(out) != 1 || out[0] != (ports.AcquireLock{Generation: 1}) {
289 t.Fatalf("lock request %v", out)
290 }
291 if got := handle(t, p, now, ports.LockRequested{}); len(got) != 0 {
292 t.Fatalf("duplicate acquisition %v", got)
293 }
294 handle(t, p, now, ports.LockConfirmed{Generation: 1})
295 if got := handle(t, p, now, ports.LockRequested{}); len(got) != 0 || !p.State().Protected {
296 t.Fatalf("protected lock re-requested %v", got)
297 }
298 handle(t, p, now, ports.Activity{})
299 if !p.State().Protected {
300 t.Fatal("activity released the lock")
301 }
302}