X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/150d707b16a5495272dfb02d690d99bfae50c14e..03ef9525ba0e62037b55606b851ee746967a003f:/schedule_test.go?ds=sidebyside diff --git a/schedule_test.go b/schedule_test.go index 85e11d0..f93427e 100644 --- a/schedule_test.go +++ b/schedule_test.go @@ -2,13 +2,12 @@ package main import ( "context" - "io" - "log" "os" "path/filepath" "strings" "sync" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/require" @@ -24,14 +23,14 @@ func TestSchedulesSet(t *testing.T) { listed string }{ { - name: "a one-time schedule at a local time", + name: "sets a one-time run at its local time", text: "Remind the user to drink water.", at: new("2026-11-09 09:00"), answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.", listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.", }, { - name: "a repeating schedule at the next match", + name: "sets a repeating run at its next match", text: "Tell the user\nthe time.", cron: new("*/5 * * * *"), answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.", @@ -60,43 +59,43 @@ func TestSchedulesSetRefuses(t *testing.T) { message string }{ { - name: "an empty text", + name: "refuses an empty text", text: " \n", at: new("2026-11-09 09:00"), message: "the text must have 1 to 4096 bytes", }, { - name: "a text that is too long", + name: "refuses a text that is too long", text: strings.Repeat("x", scheduleTextBytes+1), at: new("2026-11-09 09:00"), message: "the text must have 1 to 4096 bytes", }, { - name: "neither at nor cron", + name: "refuses neither at nor cron", text: "Tell the user the time.", message: "give either at or cron, and null for the other", }, { - name: "both at and cron", + name: "refuses both at and cron", text: "Tell the user the time.", at: new("2026-11-09 09:00"), cron: new("0 9 * * *"), message: "give either at or cron, and null for the other", }, { - name: "a time in another layout", + name: "refuses a time in another layout", text: "Tell the user the time.", at: new("2026-11-09T09:00:00-03:00"), message: "at must be a local time such as 2026-11-08 20:00", }, { - name: "a time that is past", + name: "refuses a time that is past", text: "Tell the user the time.", at: new("2026-11-08 20:00"), message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00", }, { - name: "runs less than 5 minutes apart", + name: "refuses runs less than 5 minutes apart", text: "Tell the user the time.", cron: new("* * * * *"), message: "at least 5 minutes apart", @@ -130,7 +129,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, log.New(io.Discard, "", 0)) + 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()) @@ -173,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) + for _, schedule := range due { require.NoError(t, schedules.Done(schedule, now)) } @@ -199,7 +199,7 @@ func TestScheduleMessage(t *testing.T) { message string }{ { - name: "a one-time run on time", + name: "labels an on-time run as once", schedule: Schedule{ ID: 3, Text: "Remind the user to drink water.", @@ -209,7 +209,7 @@ func TestScheduleMessage(t *testing.T) { message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply, }, { - name: "a late repeating run", + name: "marks a late run with its repeating expression", schedule: Schedule{ ID: 4, Text: "Tell the user the news.", @@ -232,7 +232,7 @@ 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, log.New(io.Discard, "", 0)) + _, err := openSchedules(path, "OneChat", time.Now, discardLog()) require.ErrorContains(t, err, "schedule 1: the runs of") } @@ -242,7 +242,7 @@ func TestSchedulesRunStartsATurnWhenDue(t *testing.T) { } logs := &SyncBuffer{} - schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, log.New(logs, "", 0)) + schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, daemonTestLogger(logs)) require.NoError(t, err) server := newModelFake(t) @@ -252,6 +252,9 @@ func TestSchedulesRunStartsATurnWhenDue(t *testing.T) { 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()) @@ -259,7 +262,7 @@ func TestSchedulesRunStartsATurnWhenDue(t *testing.T) { 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, "schedule 1 runs\n", logs.String()) + require.Equal(t, "<6>schedule ran id=1\n", logs.String()) } func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) { @@ -271,7 +274,7 @@ func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) { } logs := &SyncBuffer{} - schedules, err := openSchedules(path, clock.Now, log.New(logs, "", 0)) + schedules, err := openSchedules(path, "OneChat", clock.Now, daemonTestLogger(logs)) require.NoError(t, err) server := newModelFake(t) @@ -284,8 +287,9 @@ func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) { "[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, "schedule 1 runs 11h30m0s late, because the daemon was stopped at its time\nschedule 2 runs 10h30m0s late, because the daemon was stopped at its time\n", logs.String()) + 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) { @@ -293,10 +297,9 @@ func TestDaemonRunsADueSchedule(t *testing.T) { now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)), } - schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, log.New(io.Discard, "", 0)) + 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) @@ -306,9 +309,43 @@ func TestDaemonRunsADueSchedule(t *testing.T) { 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) { schedules, _ := scheduleTestOpen(t) box := &Toolbox{ @@ -330,6 +367,36 @@ func TestSubagentHasNoScheduleTools(t *testing.T) { 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 @@ -360,18 +427,17 @@ 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, log.New(io.Discard, "", 0)) + 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. +// the test ends. 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)