eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "strings"
5 "testing"
6 "time"
7)
8
9func TestScheduleSpecValidate(t *testing.T) {
10 t.Setenv("LCHAT_BOT_MIN_EVERY", "1s") // tests watch bots fire in seconds
11 ok := []ScheduleSpec{
12 {At: "08:00"},
13 {At: "23:59", TZ: "Europe/Dublin"},
14 {Every: "30m"},
15 {Every: "1s"},
16 }
17 for _, s := range ok {
18 s := s
19 if err := s.validate(); err != nil {
20 t.Errorf("%+v rejected: %v", s, err)
21 }
22 }
23 bad := []struct {
24 spec ScheduleSpec
25 want string
26 }{
27 {ScheduleSpec{}, "exactly one"},
28 {ScheduleSpec{At: "08:00", Every: "1h"}, "exactly one"},
29 {ScheduleSpec{At: "25:00"}, "bad at"},
30 {ScheduleSpec{At: "8am"}, "bad at"},
31 {ScheduleSpec{At: "08:00", TZ: "Mars/Olympus"}, "bad tz"},
32 {ScheduleSpec{Every: "0s"}, "bad every"},
33 {ScheduleSpec{Every: "soon"}, "bad every"},
34 {ScheduleSpec{Every: "500ms"}, "bad every"},
35 {ScheduleSpec{Every: "1h", TZ: "Europe/Dublin"}, "tz belongs"},
36 }
37 for _, tc := range bad {
38 tc := tc
39 err := tc.spec.validate()
40 if err == nil || !strings.Contains(err.Error(), tc.want) {
41 t.Errorf("%+v: want error containing %q, got %v", tc.spec, tc.want, err)
42 }
43 }
44}
45
46// TestMinEveryDefault pins the production cost valve: with no override, an
47// interval below 15 minutes must not validate — every interval is a standing
48// scheduled model run.
49func TestMinEveryDefault(t *testing.T) {
50 s := ScheduleSpec{Every: "5m"}
51 if err := s.validate(); err == nil {
52 t.Fatal("5m should be under the 15m production floor")
53 }
54 ok := ScheduleSpec{Every: "15m"}
55 if err := ok.validate(); err != nil {
56 t.Fatalf("15m rejected: %v", err)
57 }
58}
59
60func TestScheduleNext(t *testing.T) {
61 // Interval: strictly after now.
62 s := ScheduleSpec{Every: "30m"}
63 if err := s.validate(); err != nil {
64 t.Fatal(err)
65 }
66 now := time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC)
67 if got := s.next(now); got != now.Add(30*time.Minute) {
68 t.Fatalf("every: got %v", got)
69 }
70
71 // Daily, before today's slot: fires today.
72 s = ScheduleSpec{At: "18:30", TZ: "Europe/Dublin"}
73 if err := s.validate(); err != nil {
74 t.Fatal(err)
75 }
76 dublin, _ := time.LoadLocation("Europe/Dublin")
77 now = time.Date(2026, 7, 9, 10, 0, 0, 0, dublin)
78 got := s.next(now)
79 want := time.Date(2026, 7, 9, 18, 30, 0, 0, dublin)
80 if !got.Equal(want) {
81 t.Fatalf("today's slot: got %v want %v", got, want)
82 }
83
84 // Daily, after today's slot (and exactly on it): tomorrow.
85 now = time.Date(2026, 7, 9, 18, 30, 0, 0, dublin)
86 got = s.next(now)
87 want = time.Date(2026, 7, 10, 18, 30, 0, 0, dublin)
88 if !got.Equal(want) {
89 t.Fatalf("tomorrow's slot: got %v want %v", got, want)
90 }
91}
92
93// earliestCharterDue: the wake-time the host reports to the instance — the
94// soonest next occurrence across live charters, 0 with none.
95func TestEarliestCharterDue(t *testing.T) {
96 mk := func(spec ScheduleSpec) *Charter {
97 if err := spec.validate(); err != nil {
98 t.Fatal(err)
99 }
100 return &Charter{Schedule: spec}
101 }
102 now := time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC)
103
104 if got := earliestCharterDue(map[string]*Charter{}, now); got != 0 {
105 t.Fatalf("no charters: got %d, want 0", got)
106 }
107
108 live := map[string]*Charter{
109 "bot-a": mk(ScheduleSpec{Every: "6h"}), // 16:00
110 "bot-b": mk(ScheduleSpec{At: "12:30"}), // 12:30 UTC today — the soonest
111 "bot-c": mk(ScheduleSpec{Every: "180m"}), // 13:00
112 }
113 want := time.Date(2026, 7, 9, 12, 30, 0, 0, time.UTC).Unix()
114 if got := earliestCharterDue(live, now); got != want {
115 t.Fatalf("earliest = %d, want %d (12:30 UTC)", got, want)
116 }
117
118 // The result is always strictly in the future (Schedule.next's contract) —
119 // the report can never be born stale.
120 if got := earliestCharterDue(live, now); got <= now.Unix() {
121 t.Fatalf("earliest %d not after now %d", got, now.Unix())
122 }
123}
124
125func TestValidateCharter(t *testing.T) {
126 good := func() *Charter {
127 return &Charter{
128 V: 1, Name: "morning-news", Title: "Morning News", Glyph: "📰",
129 Schedule: ScheduleSpec{At: "08:00"},
130 Task: TaskSpec{Prompt: "fetch and summarise", Tools: []string{"fetch"},
131 Fetch: &FetchSpec{Allow: []string{"https://rss.example.com/"}}},
132 Owner: "u1",
133 }
134 }
135 if err := validateCharter(good()); err != nil {
136 t.Fatalf("valid charter rejected: %v", err)
137 }
138 mut := []struct {
139 mutate func(*Charter)
140 want string
141 }{
142 {func(c *Charter) { c.V = 2 }, "unknown charter version"},
143 {func(c *Charter) { c.Name = "Bad Name" }, "bad bot name"},
144 {func(c *Charter) { c.Title = "" }, "title"},
145 {func(c *Charter) { c.Glyph = "" }, "glyph"},
146 {func(c *Charter) { c.Owner = "" }, "no owner"},
147 {func(c *Charter) { c.Schedule = ScheduleSpec{} }, "exactly one"},
148 {func(c *Charter) { c.Task.Prompt = "" }, "needs a prompt"},
149 }
150 for _, tc := range mut {
151 c := good()
152 tc.mutate(c)
153 err := validateCharter(c)
154 if err == nil || !strings.Contains(err.Error(), tc.want) {
155 t.Errorf("want error containing %q, got %v", tc.want, err)
156 }
157 }
158}
159
160func TestParseBotBuildReq(t *testing.T) {
161 input := map[string]any{
162 "name": "morning-news", "title": "Morning News", "glyph": "📰",
163 "at": "08:00", "tz": "Europe/Dublin",
164 "sources": []any{"rte.ie", "nytimes.com"},
165 "prompt": "Fetch today's headlines and write a short digest.",
166 }
167 req, err := parseBotBuildReq(input)
168 if err != nil {
169 t.Fatalf("valid input rejected: %v", err)
170 }
171 if req.Name != "morning-news" || req.Schedule.At != "08:00" || len(req.Sources) != 2 {
172 t.Fatalf("bad parse: %+v", req)
173 }
174
175 bad := []struct {
176 mutate func(map[string]any)
177 want string
178 }{
179 {func(m map[string]any) { m["name"] = "Morning News" }, "bot name"},
180 {func(m map[string]any) { delete(m, "prompt") }, "prompt"},
181 {func(m map[string]any) { m["every"] = "1h" }, "exactly one"},
182 {func(m map[string]any) { delete(m, "at"); delete(m, "tz") }, "exactly one"},
183 {func(m map[string]any) { m["bot_id"] = "NOT-AN-ID!" }, "bad bot_id"},
184 {func(m map[string]any) {
185 m["sources"] = []any{"a", "b", "c", "d", "e", "f", "g"}
186 }, "at most 6"},
187 }
188 for _, tc := range bad {
189 m := map[string]any{}
190 for k, v := range input {
191 m[k] = v
192 }
193 tc.mutate(m)
194 if _, err := parseBotBuildReq(m); err == nil || !strings.Contains(err.Error(), tc.want) {
195 t.Errorf("want error containing %q, got %v", tc.want, err)
196 }
197 }
198}
199
200func TestScheduleSummary(t *testing.T) {
201 s := ScheduleSpec{At: "08:00", TZ: "Europe/Dublin"}
202 if got := s.Summary(); got != "daily at 08:00 (Europe/Dublin)" {
203 t.Fatalf("got %q", got)
204 }
205 s = ScheduleSpec{Every: "4h"}
206 if got := s.Summary(); got != "every 4h" {
207 t.Fatalf("got %q", got)
208 }
209}
210