]> git.giacomolli.uk - onechat/blobdiff - schedule_test.go
Name timeouts for the operations they limit
[onechat] / schedule_test.go
index c13f023ff4ed23b29d25b0081e0b82d9732296c0..cb839d5f95351d810c38a344319da5f3e8424bd6 100644 (file)
@@ -1,10 +1,13 @@
 package main
 
 import (
 package main
 
 import (
+       "context"
        "os"
        "path/filepath"
        "strings"
        "os"
        "path/filepath"
        "strings"
+       "sync"
        "testing"
        "testing"
+       "testing/synctest"
        "time"
 
        "github.com/stretchr/testify/require"
        "time"
 
        "github.com/stretchr/testify/require"
@@ -126,7 +129,7 @@ func TestSchedulesSurviveARestart(t *testing.T) {
        require.NoError(t, err)
        require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
 
        require.NoError(t, err)
        require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
 
-       reopened, err := openSchedules(path, schedules.clock)
+       reopened, err := openSchedules(path, "OneChat", schedules.clock, discardLog())
        require.NoError(t, err)
        require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
 
        require.NoError(t, err)
        require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
 
@@ -169,6 +172,7 @@ func TestSchedulesDone(t *testing.T) {
        now := start.Add(3*time.Hour + 30*time.Minute)
        due := schedules.Due(now)
        require.Len(t, due, 2)
        now := start.Add(3*time.Hour + 30*time.Minute)
        due := schedules.Due(now)
        require.Len(t, due, 2)
+
        for _, schedule := range due {
                require.NoError(t, schedules.Done(schedule, now))
        }
        for _, schedule := range due {
                require.NoError(t, schedules.Done(schedule, now))
        }
@@ -228,22 +232,117 @@ func TestOpenSchedulesRefusesABadCron(t *testing.T) {
        path := filepath.Join(t.TempDir(), scheduleFileName)
        require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
 
        path := filepath.Join(t.TempDir(), scheduleFileName)
        require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
 
-       _, err := openSchedules(path, time.Now)
+       _, err := openSchedules(path, "OneChat", time.Now, discardLog())
        require.ErrorContains(t, err, "schedule 1: the runs of")
 }
 
        require.ErrorContains(t, err, "schedule 1: the runs of")
 }
 
-// scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
-// 2026-11-08 20:00 -03:00, and returns them and the path of the file.
-func scheduleTestOpen(t *testing.T) (*Schedules, string) {
+func TestSchedulesRunStartsATurnWhenDue(t *testing.T) {
+       clock := &ScheduleTestClock{
+               now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
+       }
+
+       logs := &SyncBuffer{}
+       schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, daemonTestLogger(logs))
+       require.NoError(t, err)
+
+       server := newModelFake(t)
+       test := scheduleTestRun(t, schedules, server)
+
+       _, err = schedules.Set("Remind the user to drink water.", new("2026-11-08 20:05"), nil)
+       require.NoError(t, err)
+
+       clock.Set(clock.Now().Add(5 * time.Minute))
+
+       schedules.changed <- struct{}{}
+
+       test.events.WaitFor(t, "idle", 1)
+       require.Equal(t, 1, server.Calls())
+
+       texts := turnInputTexts(t, server.Call(0))
+       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])
+       require.Equal(t, scheduleSource, test.events.LastEvent("work").Source)
+       require.Equal(t, "No schedules.", schedules.List())
+       require.Equal(t, "<6>schedule ran id=1\n", logs.String())
+}
+
+func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) {
        path := filepath.Join(t.TempDir(), scheduleFileName)
        path := filepath.Join(t.TempDir(), scheduleFileName)
-       clock := &TestClock{
+       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))
+
+       clock := &ScheduleTestClock{
+               now: time.Date(2026, time.November, 8, 20, 30, 0, 0, time.FixedZone("", -3*60*60)),
+       }
+
+       logs := &SyncBuffer{}
+       schedules, err := openSchedules(path, "OneChat", clock.Now, daemonTestLogger(logs))
+       require.NoError(t, err)
+
+       server := newModelFake(t)
+       test := scheduleTestRun(t, schedules, server)
+       test.events.WaitFor(t, "idle", 1)
+       require.Equal(t, 1, server.Calls())
+
+       texts := turnInputTexts(t, server.Call(0))
+       require.Equal(t, []string{
+               "[Schedule 1] Sunday 2026-11-08 09:00 -03:00, once\nRemind the user to call.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
+               "[Schedule 2] Sunday 2026-11-08 10:00 -03:00, repeats 0 * * * *\nTell the user the time.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
+       }, texts[len(texts)-2:])
+       require.Equal(t, "2 | Sunday 2026-11-08 21:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
+       require.Equal(t, "<6>schedule ran id=1 late=11h30m0s\n<6>schedule ran id=2 late=10h30m0s\n", logs.String())
+}
+
+func TestDaemonRunsADueSchedule(t *testing.T) {
+       clock := &ScheduleTestClock{
                now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
        }
 
                now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
        }
 
-       schedules, err := openSchedules(path, clock.Now)
+       schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, discardLog())
        require.NoError(t, err)
 
        require.NoError(t, err)
 
-       return schedules, path
+       whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
+       socket, _ := daemonStartTestWithSchedules(t, newModelFake(t), nil, whatsapp, schedules, clock.Now)
+       client := terminalStartTestClient(t, socket)
+       client.WaitFor(t, "[connected to")
+
+       _, err = schedules.Set("Remind the user to drink water.", new("2026-11-08 20:05"), nil)
+       require.NoError(t, err)
+
+       clock.Set(clock.Now().Add(5 * time.Minute))
+
+       schedules.changed <- struct{}{}
+
+       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]")
+}
+
+func TestSchedulesCheckTheClockAfterItsWaitLimit(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
+               loop, events := newTestIdleTurnLoop(t, whatsapp)
+               clock := &ScheduleTestClock{now: time.Now()}
+               schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, discardLog())
+               require.NoError(t, err)
+
+               _, err = schedules.Set("Check the clock.", new(clock.Now().Add(5*time.Minute).Format(scheduleAtLayout)), nil)
+               require.NoError(t, err)
+
+               ctx, cancel := context.WithCancel(t.Context())
+               defer cancel()
+
+               done := make(chan error, 1)
+               go func() {
+                       done <- schedules.Run(ctx, loop)
+               }()
+
+               synctest.Wait()
+               require.Zero(t, loop.memory.Len())
+               clock.Set(clock.Now().Add(5 * time.Minute))
+               time.Sleep(scheduleClockWaitTimeout)
+               synctest.Wait()
+               require.Equal(t, 1, loop.memory.Len())
+               require.Contains(t, events.Last("work"), "Check the clock.")
+               cancel()
+               require.NoError(t, <-done)
+       })
 }
 
 func TestToolboxRunsTheScheduleTools(t *testing.T) {
 }
 
 func TestToolboxRunsTheScheduleTools(t *testing.T) {
@@ -266,3 +365,89 @@ func TestSubagentHasNoScheduleTools(t *testing.T) {
        require.Equal(t, `error: unknown tool "schedule_list"`, box.run(t.Context(), "schedule_list", `{}`))
        require.Equal(t, `error: unknown tool "schedule_remove"`, box.run(t.Context(), "schedule_remove", `{"id":1}`))
 }
        require.Equal(t, `error: unknown tool "schedule_list"`, box.run(t.Context(), "schedule_list", `{}`))
        require.Equal(t, `error: unknown tool "schedule_remove"`, box.run(t.Context(), "schedule_remove", `{"id":1}`))
 }
+
+func TestSchedulesKeepTheirOwnerAcrossRestart(t *testing.T) {
+       path := filepath.Join(t.TempDir(), scheduleFileName)
+       clock := newTestClock()
+       schedules, err := openSchedules(path, "GreenOtter", clock.Now, discardLog())
+       require.NoError(t, err)
+
+       _, err = schedules.Set("Check the plants.", new(clock.Now().Add(time.Minute).Format(scheduleAtLayout)), nil)
+       require.NoError(t, err)
+
+       reopened, err := openSchedules(path, "GreenOtter", clock.Now, discardLog())
+       require.NoError(t, err)
+       require.Equal(t, "GreenOtter", reopened.sorted()[0].Owner)
+
+       _, err = openSchedules(path, "OneChat", clock.Now, discardLog())
+       require.ErrorContains(t, err, "belongs to")
+}
+
+func TestSchedulesAssignTheOwnerToSavedRootSchedules(t *testing.T) {
+       path := filepath.Join(t.TempDir(), scheduleFileName)
+       require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"Check the news.","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
+
+       schedules, err := openSchedules(path, "OneChat", newTestClock().Now, discardLog())
+       require.NoError(t, err)
+       require.Equal(t, "OneChat", schedules.sorted()[0].Owner)
+
+       data, err := os.ReadFile(path)
+       require.NoError(t, err)
+       require.Contains(t, string(data), `"owner":"OneChat"`)
+}
+
+// ScheduleTestClock is a clock that a test sets while Run reads it.
+type ScheduleTestClock struct {
+       mutex sync.Mutex
+       now   time.Time
+}
+
+// Now returns the time of the clock.
+func (c *ScheduleTestClock) Now() time.Time {
+       c.mutex.Lock()
+       defer c.mutex.Unlock()
+
+       return c.now
+}
+
+// Set sets the time of the clock.
+func (c *ScheduleTestClock) Set(now time.Time) {
+       c.mutex.Lock()
+       defer c.mutex.Unlock()
+
+       c.now = now
+}
+
+// scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
+// 2026-11-08 20:00 -03:00, and returns them and the path of the file.
+func scheduleTestOpen(t *testing.T) (*Schedules, string) {
+       path := filepath.Join(t.TempDir(), scheduleFileName)
+       clock := &TestClock{
+               now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
+       }
+
+       schedules, err := openSchedules(path, "OneChat", clock.Now, discardLog())
+       require.NoError(t, err)
+
+       return schedules, path
+}
+
+// scheduleTestRun runs schedules in a new turn loop that calls server, until
+// the test ends.
+func scheduleTestRun(t *testing.T, schedules *Schedules, server *ModelFake) *TurnTestLoop {
+       t.Helper()
+
+       test := newTestTurnLoop(t, server)
+       ctx, cancel := context.WithCancel(t.Context())
+       done := make(chan error)
+       go func() {
+               done <- schedules.Run(ctx, test.loop)
+       }()
+
+       t.Cleanup(func() {
+               cancel()
+               require.NoError(t, <-done)
+       })
+
+       return test
+}