carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package schedule
2
3import (
4 "testing"
5 "time"
6)
7
8func TestParseSpecRoundTrips(t *testing.T) {
9 for _, s := range []string{"every 6h", "every 1h30m", "cron 0 8 * * *", "at 1787004000"} {
10 sp, err := ParseSpec(s)
11 if err != nil {
12 t.Fatalf("%q: %v", s, err)
13 }
14 if got := sp.String(); got != s {
15 t.Errorf("%q → %q", s, got)
16 }
17 }
18 for _, bad := range []string{"", "every", "every 0s", "every 30s", "every 800h", "cron * *", "at x", "sometimes"} {
19 if _, err := ParseSpec(bad); err == nil {
20 t.Errorf("ParseSpec(%q) = ok, want error", bad)
21 }
22 }
23}
24
25// Spec.String is what lands in the timers.spec column and what `carlos
26// schedule ls` shows verbatim (spec §4.3's example is literally "every
27// 6h"). time.Duration.String would render that "6h0m0s" — always spelling
28// out hours, minutes and seconds — so String must render the canonical
29// short form instead: whatever a member typed, or the minimal equivalent
30// for whatever a box already had on disk.
31func TestSpecStringRendersCanonicalShortDuration(t *testing.T) {
32 cases := map[string]string{
33 "every 6h0m0s": "every 6h", // what stdlib Duration.String produces
34 "every 60m": "every 1h", // minutes that add up to whole hours
35 "every 90m": "every 1h30m",
36 "every 45m": "every 45m", // already minimal
37 "every 1m": "every 1m",
38 "every 720h": "every 720h", // the 30-day ceiling
39 }
40 for in, want := range cases {
41 sp, err := ParseSpec(in)
42 if err != nil {
43 t.Fatalf("%q: %v", in, err)
44 }
45 if got := sp.String(); got != want {
46 t.Errorf("ParseSpec(%q).String() = %q, want %q", in, got, want)
47 }
48 }
49}
50
51// Epoch alignment (spec §6.1): "every 6h" fires at 00/06/12/18 UTC on every
52// box, whatever time the row was created, so there is no phase to persist
53// and nothing drifts across restarts.
54func TestEveryIsEpochAligned(t *testing.T) {
55 sp := Spec{Kind: KindSchedule, Every: 6 * time.Hour}
56 cases := map[string]string{
57 "2026-08-23T13:07:00Z": "2026-08-23T18:00:00Z",
58 "2026-08-23T18:00:00Z": "2026-08-24T00:00:00Z", // strictly after
59 "2026-08-23T23:59:59Z": "2026-08-24T00:00:00Z",
60 }
61 for now, want := range cases {
62 if got := sp.Next(at(now)); !got.Equal(at(want)) {
63 t.Errorf("Next(%s) = %s, want %s", now, got.Format(time.RFC3339), want)
64 }
65 }
66}
67
68// Coalescing (spec §6.1): Next is computed from NOW, so a box that was down
69// across several occurrences produces one next fire, not a burst.
70func TestNextNeverReturnsThePast(t *testing.T) {
71 sp := Spec{Kind: KindSchedule, Every: time.Hour}
72 now := at("2026-08-23T13:07:00Z")
73 if got := sp.Next(now); !got.After(now) {
74 t.Fatalf("Next(%s) = %s is not after now", now, got)
75 }
76}
77
78func TestOnceFiresOnceThenNever(t *testing.T) {
79 sp := Spec{Kind: KindOnce, At: at("2026-09-01T08:00:00Z")}
80 if got := sp.Next(at("2026-08-23T00:00:00Z")); !got.Equal(sp.At) {
81 t.Fatalf("before: %s", got)
82 }
83 if got := sp.Next(at("2026-09-01T08:00:00Z")); !got.IsZero() {
84 t.Fatalf("after firing: %s, want zero", got)
85 }
86}
87