X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/396af2e18eb02d4ec1e35edbc62f01e84bae2b46..3738533260a8eb914e211b8d909e92a14c0c266c:/schedule_test.go?ds=sidebyside diff --git a/schedule_test.go b/schedule_test.go index 5ef174b..6610c26 100644 --- a/schedule_test.go +++ b/schedule_test.go @@ -1,9 +1,11 @@ package main import ( + "context" "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -126,7 +128,7 @@ func TestSchedulesSurviveARestart(t *testing.T) { 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()) @@ -169,6 +171,7 @@ func TestSchedulesDone(t *testing.T) { 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)) } @@ -228,10 +231,156 @@ 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)) - _, err := openSchedules(path, time.Now) + _, err := openSchedules(path, "OneChat", time.Now, discardLog()) require.ErrorContains(t, err, "schedule 1: the runs of") } +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)) + 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) + 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)), + } + + schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, discardLog()) + require.NoError(t, err) + + schedules.check = time.Millisecond + 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)) + 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 TestToolboxRunsTheScheduleTools(t *testing.T) { + schedules, _ := scheduleTestOpen(t) + box := &Toolbox{ + schedules: schedules, + } + + 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"}`)) + 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}`)) + 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", `{}`)) + require.Equal(t, "Schedule 1 removed.", box.run(t.Context(), "schedule_remove", `{"id":1}`)) + require.Equal(t, "error: no schedule 1", box.run(t.Context(), "schedule_remove", `{"id":1}`)) + 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}`)) +} + +func TestSubagentHasNoScheduleTools(t *testing.T) { + box := &Toolbox{} + require.Equal(t, `error: unknown tool "schedule_set"`, box.run(t.Context(), "schedule_set", `{"text":"x","at":"2026-11-09 09:00","cron":null}`)) + 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) { @@ -240,8 +389,29 @@ func scheduleTestOpen(t *testing.T) (*Schedules, string) { 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(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. Run looks at the times every millisecond. +func scheduleTestRun(t *testing.T, schedules *Schedules, server *ModelFake) *TurnTestLoop { + t.Helper() + + schedules.check = time.Millisecond + 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 +}