]> git.giacomolli.uk - onechat/blob - schedule_test.go
Explain daemon log levels and journal filtering
[onechat] / schedule_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/require"
13 )
14
15 func TestSchedulesSet(t *testing.T) {
16 testCases := []struct {
17 name string
18 text string
19 at *string
20 cron *string
21 answer string
22 listed string
23 }{
24 {
25 name: "a one-time schedule at a local time",
26 text: "Remind the user to drink water.",
27 at: new("2026-11-09 09:00"),
28 answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
29 listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
30 },
31 {
32 name: "a repeating schedule at the next match",
33 text: "Tell the user\nthe time.",
34 cron: new("*/5 * * * *"),
35 answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
36 listed: "1 | Sunday 2026-11-08 20:05 -03:00 | */5 * * * * | Tell the user the time.",
37 },
38 }
39
40 for _, tc := range testCases {
41 t.Run(tc.name, func(t *testing.T) {
42 schedules, _ := scheduleTestOpen(t)
43
44 answer, err := schedules.Set(tc.text, tc.at, tc.cron)
45 require.NoError(t, err)
46 require.Equal(t, tc.answer, answer)
47 require.Equal(t, tc.listed, schedules.List())
48 })
49 }
50 }
51
52 func TestSchedulesSetRefuses(t *testing.T) {
53 testCases := []struct {
54 name string
55 text string
56 at *string
57 cron *string
58 message string
59 }{
60 {
61 name: "an empty text",
62 text: " \n",
63 at: new("2026-11-09 09:00"),
64 message: "the text must have 1 to 4096 bytes",
65 },
66 {
67 name: "a text that is too long",
68 text: strings.Repeat("x", scheduleTextBytes+1),
69 at: new("2026-11-09 09:00"),
70 message: "the text must have 1 to 4096 bytes",
71 },
72 {
73 name: "neither at nor cron",
74 text: "Tell the user the time.",
75 message: "give either at or cron, and null for the other",
76 },
77 {
78 name: "both at and cron",
79 text: "Tell the user the time.",
80 at: new("2026-11-09 09:00"),
81 cron: new("0 9 * * *"),
82 message: "give either at or cron, and null for the other",
83 },
84 {
85 name: "a time in another layout",
86 text: "Tell the user the time.",
87 at: new("2026-11-09T09:00:00-03:00"),
88 message: "at must be a local time such as 2026-11-08 20:00",
89 },
90 {
91 name: "a time that is past",
92 text: "Tell the user the time.",
93 at: new("2026-11-08 20:00"),
94 message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
95 },
96 {
97 name: "runs less than 5 minutes apart",
98 text: "Tell the user the time.",
99 cron: new("* * * * *"),
100 message: "at least 5 minutes apart",
101 },
102 }
103
104 for _, tc := range testCases {
105 t.Run(tc.name, func(t *testing.T) {
106 schedules, path := scheduleTestOpen(t)
107
108 _, err := schedules.Set(tc.text, tc.at, tc.cron)
109 require.ErrorContains(t, err, tc.message)
110 require.NoFileExists(t, path)
111 })
112 }
113 }
114
115 func TestSchedulesSurviveARestart(t *testing.T) {
116 schedules, path := scheduleTestOpen(t)
117
118 _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
119 require.NoError(t, err)
120
121 _, err = schedules.Set("Tell the user the news.", nil, new("0 8 * * 1-5"))
122 require.NoError(t, err)
123
124 _, err = schedules.Remove(1)
125 require.NoError(t, err)
126
127 info, err := os.Stat(path)
128 require.NoError(t, err)
129 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
130
131 reopened, err := openSchedules(path, schedules.clock, discardLog())
132 require.NoError(t, err)
133 require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
134
135 answer, err := reopened.Set("Remind the user to call.", new("2026-11-08 21:00"), nil)
136 require.NoError(t, err)
137 require.Equal(t, "Schedule 3 set. Next: Sunday 2026-11-08 21:00 -03:00.", answer)
138 }
139
140 func TestSchedulesRemove(t *testing.T) {
141 schedules, _ := scheduleTestOpen(t)
142
143 _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
144 require.NoError(t, err)
145
146 answer, err := schedules.Remove(1)
147 require.NoError(t, err)
148 require.Equal(t, "Schedule 1 removed.", answer)
149 require.Equal(t, "No schedules.", schedules.List())
150
151 _, err = schedules.Remove(1)
152 require.EqualError(t, err, "no schedule 1")
153 }
154
155 func TestSchedulesDone(t *testing.T) {
156 schedules, _ := scheduleTestOpen(t)
157 start := schedules.clock()
158
159 _, err := schedules.Set("Remind the user to drink water.", new("2026-11-08 21:00"), nil)
160 require.NoError(t, err)
161
162 _, err = schedules.Set("Tell the user the time.", nil, new("0 * * * *"))
163 require.NoError(t, err)
164
165 wait, found := schedules.Wait(start)
166 require.True(t, found)
167 require.Equal(t, time.Hour, wait)
168
169 // The daemon was stopped for three hours, so each schedule missed its
170 // times and runs once.
171 now := start.Add(3*time.Hour + 30*time.Minute)
172 due := schedules.Due(now)
173 require.Len(t, due, 2)
174
175 for _, schedule := range due {
176 require.NoError(t, schedules.Done(schedule, now))
177 }
178
179 require.Empty(t, schedules.Due(now))
180 require.Equal(t, "2 | Monday 2026-11-09 00:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
181
182 _, err = schedules.Remove(2)
183 require.NoError(t, err)
184 require.NoError(t, schedules.Done(due[1], now))
185 require.Equal(t, "No schedules.", schedules.List())
186
187 _, found = schedules.Wait(now)
188 require.False(t, found)
189 }
190
191 func TestScheduleMessage(t *testing.T) {
192 zone := time.FixedZone("", -3*60*60)
193 due := time.Date(2026, time.November, 9, 9, 0, 0, 0, zone)
194 testCases := []struct {
195 name string
196 schedule Schedule
197 now time.Time
198 message string
199 }{
200 {
201 name: "a one-time run on time",
202 schedule: Schedule{
203 ID: 3,
204 Text: "Remind the user to drink water.",
205 Due: due,
206 },
207 now: due.Add(time.Second),
208 message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
209 },
210 {
211 name: "a late repeating run",
212 schedule: Schedule{
213 ID: 4,
214 Text: "Tell the user the news.",
215 Cron: "0 9 * * 1-5",
216 Due: due,
217 },
218 now: due.Add(2 * time.Hour),
219 message: "[Schedule 4] Monday 2026-11-09 09:00 -03:00, repeats 0 9 * * 1-5\nTell the user the news.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
220 },
221 }
222
223 for _, tc := range testCases {
224 t.Run(tc.name, func(t *testing.T) {
225 require.Equal(t, tc.message, tc.schedule.Message(tc.now))
226 })
227 }
228 }
229
230 func TestOpenSchedulesRefusesABadCron(t *testing.T) {
231 path := filepath.Join(t.TempDir(), scheduleFileName)
232 require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
233
234 _, err := openSchedules(path, time.Now, discardLog())
235 require.ErrorContains(t, err, "schedule 1: the runs of")
236 }
237
238 func TestSchedulesRunStartsATurnWhenDue(t *testing.T) {
239 clock := &ScheduleTestClock{
240 now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
241 }
242
243 logs := &SyncBuffer{}
244 schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, daemonTestLogger(logs))
245 require.NoError(t, err)
246
247 server := newModelFake(t)
248 test := scheduleTestRun(t, schedules, server)
249
250 _, err = schedules.Set("Remind the user to drink water.", new("2026-11-08 20:05"), nil)
251 require.NoError(t, err)
252
253 clock.Set(clock.Now().Add(5 * time.Minute))
254 test.events.WaitFor(t, "idle", 1)
255 require.Equal(t, 1, server.Calls())
256
257 texts := turnInputTexts(t, server.Call(0))
258 require.Equal(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply, texts[len(texts)-1])
259 require.Equal(t, scheduleSource, test.events.LastEvent("work").Source)
260 require.Equal(t, "No schedules.", schedules.List())
261 require.Equal(t, "<6>schedule ran id=1\n", logs.String())
262 }
263
264 func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) {
265 path := filepath.Join(t.TempDir(), scheduleFileName)
266 require.NoError(t, os.WriteFile(path, []byte(`{"next":2,"schedules":[{"id":1,"text":"Remind the user to call.","cron":"","due":"2026-11-08T09:00:00-03:00"},{"id":2,"text":"Tell the user the time.","cron":"0 * * * *","due":"2026-11-08T10:00:00-03:00"}]}`), 0o600))
267
268 clock := &ScheduleTestClock{
269 now: time.Date(2026, time.November, 8, 20, 30, 0, 0, time.FixedZone("", -3*60*60)),
270 }
271
272 logs := &SyncBuffer{}
273 schedules, err := openSchedules(path, clock.Now, daemonTestLogger(logs))
274 require.NoError(t, err)
275
276 server := newModelFake(t)
277 test := scheduleTestRun(t, schedules, server)
278 test.events.WaitFor(t, "idle", 1)
279 require.Equal(t, 1, server.Calls())
280
281 texts := turnInputTexts(t, server.Call(0))
282 require.Equal(t, []string{
283 "[Schedule 1] Sunday 2026-11-08 09:00 -03:00, once\nRemind the user to call.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
284 "[Schedule 2] Sunday 2026-11-08 10:00 -03:00, repeats 0 * * * *\nTell the user the time.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
285 }, texts[len(texts)-2:])
286 require.Equal(t, "2 | Sunday 2026-11-08 21:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
287 require.Equal(t, "<6>schedule ran id=1 late=11h30m0s\n<6>schedule ran id=2 late=10h30m0s\n", logs.String())
288 }
289
290 func TestDaemonRunsADueSchedule(t *testing.T) {
291 clock := &ScheduleTestClock{
292 now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
293 }
294
295 schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, discardLog())
296 require.NoError(t, err)
297
298 schedules.check = time.Millisecond
299 whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
300 socket, _ := daemonStartTestWithSchedules(t, newModelFake(t), nil, whatsapp, schedules, clock.Now)
301 client := terminalStartTestClient(t, socket)
302 client.WaitFor(t, "[connected to")
303
304 _, err = schedules.Set("Remind the user to drink water.", new("2026-11-08 20:05"), nil)
305 require.NoError(t, err)
306
307 clock.Set(clock.Now().Add(5 * time.Minute))
308 client.WaitFor(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply+"\n[working]\na line\n[done]")
309 }
310
311 func TestToolboxRunsTheScheduleTools(t *testing.T) {
312 schedules, _ := scheduleTestOpen(t)
313 box := &Toolbox{
314 schedules: schedules,
315 }
316
317 require.Equal(t, "Schedule 1 set. Next: Monday 2026-11-09 08:00 -03:00.", box.run(t.Context(), "schedule_set", `{"text":"Tell the user the news.","at":null,"cron":"0 8 * * 1-5"}`))
318 require.Equal(t, "Schedule 2 set. Next: Sunday 2026-11-08 20:02 -03:00.", box.run(t.Context(), "schedule_set", `{"text":"Remind the user to drink water.","at":"2026-11-08 20:02","cron":null}`))
319 require.Equal(t, "2 | Sunday 2026-11-08 20:02 -03:00 | once | Remind the user to drink water.\n1 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", box.run(t.Context(), "schedule_list", `{}`))
320 require.Equal(t, "Schedule 1 removed.", box.run(t.Context(), "schedule_remove", `{"id":1}`))
321 require.Equal(t, "error: no schedule 1", box.run(t.Context(), "schedule_remove", `{"id":1}`))
322 require.Equal(t, "error: give either at or cron, and null for the other", box.run(t.Context(), "schedule_set", `{"text":"x","at":null,"cron":null}`))
323 }
324
325 func TestSubagentHasNoScheduleTools(t *testing.T) {
326 box := &Toolbox{}
327 require.Equal(t, `error: unknown tool "schedule_set"`, box.run(t.Context(), "schedule_set", `{"text":"x","at":"2026-11-09 09:00","cron":null}`))
328 require.Equal(t, `error: unknown tool "schedule_list"`, box.run(t.Context(), "schedule_list", `{}`))
329 require.Equal(t, `error: unknown tool "schedule_remove"`, box.run(t.Context(), "schedule_remove", `{"id":1}`))
330 }
331
332 // ScheduleTestClock is a clock that a test sets while Run reads it.
333 type ScheduleTestClock struct {
334 mutex sync.Mutex
335 now time.Time
336 }
337
338 // Now returns the time of the clock.
339 func (c *ScheduleTestClock) Now() time.Time {
340 c.mutex.Lock()
341 defer c.mutex.Unlock()
342
343 return c.now
344 }
345
346 // Set sets the time of the clock.
347 func (c *ScheduleTestClock) Set(now time.Time) {
348 c.mutex.Lock()
349 defer c.mutex.Unlock()
350
351 c.now = now
352 }
353
354 // scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
355 // 2026-11-08 20:00 -03:00, and returns them and the path of the file.
356 func scheduleTestOpen(t *testing.T) (*Schedules, string) {
357 path := filepath.Join(t.TempDir(), scheduleFileName)
358 clock := &TestClock{
359 now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
360 }
361
362 schedules, err := openSchedules(path, clock.Now, discardLog())
363 require.NoError(t, err)
364
365 return schedules, path
366 }
367
368 // scheduleTestRun runs schedules in a new turn loop that calls server, until
369 // the test ends. Run looks at the times every millisecond.
370 func scheduleTestRun(t *testing.T, schedules *Schedules, server *ModelFake) *TurnTestLoop {
371 t.Helper()
372
373 schedules.check = time.Millisecond
374 test := newTestTurnLoop(t, server)
375 ctx, cancel := context.WithCancel(t.Context())
376 done := make(chan error)
377 go func() {
378 done <- schedules.Run(ctx, test.loop)
379 }()
380
381 t.Cleanup(func() {
382 cancel()
383 require.NoError(t, <-done)
384 })
385
386 return test
387 }