The charger's own cloud schedule is one window inside one box: charge between these hours, every day, and that is the whole vocabulary. A third tab on Charging holds a list instead — each line an action, a time, the days it repeats on and the chargers it acts on — and one list covers the whole account rather than each charger hiding its own. The clock is the server's. A schedule that only fires while a tab is open is a reminder, so a ticker sweeps every enabled task and fires whichever minute has come. It sends by handing a synthesised request to the same control endpoint the page's buttons use, so a scheduled command goes through the same cascade, ownership gate, rate limit and audit trail — what the owner cannot press by hand, the scheduler cannot send for them. A task names its chargers, or names none, which means all of them and keeps meaning that for a charger imported next year. Times are stored as a wall clock plus the zone they were written in, so 23:00 stays 23:00 wherever the server sits. One action per task: a charging window is the two tasks that open and close it, which is how it is read back, edited and switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
9.1 KiB
Go
273 lines
9.1 KiB
Go
package api
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The scheduler's clock is the part worth pinning down: it decides on its own,
|
|
// with nobody watching, whether to send a car a command. A task that fires in
|
|
// the wrong hour is worse than one that does not fire at all, so the zone, the
|
|
// weekday and the not-twice-in-one-minute guard are each checked here.
|
|
|
|
func TestTaskIsDue(t *testing.T) {
|
|
warsaw, err := time.LoadLocation("Europe/Warsaw")
|
|
if err != nil {
|
|
t.Fatalf("Europe/Warsaw: %v", err)
|
|
}
|
|
// A Wednesday, 23:00 in Warsaw — 22:00 UTC.
|
|
at2300 := time.Date(2026, 9, 2, 23, 0, 30, 0, warsaw)
|
|
|
|
base := chargingTaskRecord{Time: "23:00", Zone: "Europe/Warsaw", Enabled: true}
|
|
|
|
if !taskIsDue(base, at2300) {
|
|
t.Error("a task set for 23:00 is not due at 23:00 in its own zone")
|
|
}
|
|
// The same instant, handed over as UTC. The zone on the task is what the
|
|
// time is read in, so where the server thinks it is must not matter.
|
|
if !taskIsDue(base, at2300.UTC()) {
|
|
t.Error("the task stopped being due when the same instant arrived as UTC")
|
|
}
|
|
if taskIsDue(base, at2300.Add(time.Minute)) {
|
|
t.Error("a task fired a minute after its time")
|
|
}
|
|
if taskIsDue(base, at2300.Add(-time.Minute)) {
|
|
t.Error("a task fired a minute before its time")
|
|
}
|
|
|
|
// Weekdays. 2026-09-02 is a Wednesday (3).
|
|
weeknights := base
|
|
weeknights.Days = []int{1, 2, 3, 4, 5}
|
|
if !taskIsDue(weeknights, at2300) {
|
|
t.Error("a weeknight task is not due on a Wednesday")
|
|
}
|
|
weekends := base
|
|
weekends.Days = []int{0, 6}
|
|
if taskIsDue(weekends, at2300) {
|
|
t.Error("a weekend task fired on a Wednesday")
|
|
}
|
|
|
|
// Fired already inside this minute: the sweep runs more than once a minute,
|
|
// and the second sweep must not send the command again.
|
|
fired := base
|
|
fired.LastRun = at2300.UTC().Format(time.RFC3339)
|
|
if taskIsDue(fired, at2300.Add(20*time.Second)) {
|
|
t.Error("a task fired twice inside its own minute")
|
|
}
|
|
// Yesterday's firing is not this minute's.
|
|
yesterday := base
|
|
yesterday.LastRun = at2300.Add(-24 * time.Hour).UTC().Format(time.RFC3339)
|
|
if !taskIsDue(yesterday, at2300) {
|
|
t.Error("yesterday's run stopped today's from firing")
|
|
}
|
|
|
|
// A time that is not a time of day fires nothing rather than firing at
|
|
// midnight, which is what a zero hour and minute would have meant.
|
|
broken := base
|
|
broken.Time = "later"
|
|
if taskIsDue(broken, at2300) {
|
|
t.Error("a task with an unreadable time fired")
|
|
}
|
|
}
|
|
|
|
// A zone the host has no database for falls back to the server's own clock
|
|
// rather than silently shifting the schedule to UTC.
|
|
func TestTaskIsDueUnknownZone(t *testing.T) {
|
|
now := time.Now()
|
|
rec := chargingTaskRecord{
|
|
Time: now.Format("15:04"),
|
|
Zone: "Mars/Olympus_Mons",
|
|
}
|
|
if !taskIsDue(rec, now) {
|
|
t.Error("a task in an unknown zone did not fall back to the server's clock")
|
|
}
|
|
}
|
|
|
|
func TestParseHHMM(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in string
|
|
h, m int
|
|
wantOK bool
|
|
}{
|
|
{in: "23:00", h: 23, m: 0, wantOK: true},
|
|
{in: "7:05", h: 7, m: 5, wantOK: true},
|
|
{in: " 00:00 ", h: 0, m: 0, wantOK: true},
|
|
{in: "24:00"},
|
|
{in: "12:60"},
|
|
{in: "12:5"}, // a half-typed minute is not a minute
|
|
{in: "123:00"}, // nor is a three-digit hour
|
|
{in: "12"},
|
|
{in: ""},
|
|
{in: "ab:cd"},
|
|
} {
|
|
h, m, ok := parseHHMM(tc.in)
|
|
if ok != tc.wantOK {
|
|
t.Errorf("parseHHMM(%q) ok = %v, want %v", tc.in, ok, tc.wantOK)
|
|
continue
|
|
}
|
|
if ok && (h != tc.h || m != tc.m) {
|
|
t.Errorf("parseHHMM(%q) = %d:%d, want %d:%d", tc.in, h, m, tc.h, tc.m)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeDays(t *testing.T) {
|
|
got := normalizeDays([]int{5, 1, 5, 9, -1, 0})
|
|
want := []int{0, 1, 5}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("normalizeDays = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("normalizeDays = %v, want %v", got, want)
|
|
}
|
|
}
|
|
// Empty means every day and stays empty rather than becoming all seven —
|
|
// the two read the same today, but only one keeps meaning "every day".
|
|
if len(normalizeDays(nil)) != 0 {
|
|
t.Error("normalizeDays(nil) invented days")
|
|
}
|
|
}
|
|
|
|
func TestTaskPayloadValidation(t *testing.T) {
|
|
str := func(s string) *string { return &s }
|
|
num := func(f float64) *float64 { return &f }
|
|
|
|
// A whole task, as a create sends it.
|
|
full := chargingTaskBody{
|
|
Name: str(" Night rate "),
|
|
Action: str("limit"),
|
|
Time: str("7:05"),
|
|
Amps: num(10),
|
|
}
|
|
payload, err := taskPayload(full, true)
|
|
if err != nil {
|
|
t.Fatalf("taskPayload: %v", err)
|
|
}
|
|
if payload["name"] != "Night rate" {
|
|
t.Errorf("name = %v, want it trimmed", payload["name"])
|
|
}
|
|
if payload["time"] != "07:05" {
|
|
t.Errorf("time = %v, want the padded 07:05", payload["time"])
|
|
}
|
|
if payload["enabled"] != true {
|
|
t.Error("a new task was written switched off; nobody fills in a schedule to leave it off")
|
|
}
|
|
|
|
// The fields a task cannot exist without.
|
|
for _, missing := range []chargingTaskBody{
|
|
{Action: str("start"), Time: str("23:00")},
|
|
{Name: str("x"), Time: str("23:00")},
|
|
{Name: str("x"), Action: str("start")},
|
|
{Name: str(" "), Action: str("start"), Time: str("23:00")},
|
|
{Name: str("x"), Action: str("melt"), Time: str("23:00")},
|
|
{Name: str("x"), Action: str("start"), Time: str("half past")},
|
|
} {
|
|
if _, err := taskPayload(missing, true); err == nil {
|
|
t.Errorf("taskPayload accepted an incomplete task: %+v", missing)
|
|
}
|
|
}
|
|
|
|
// The charger's own floor and ceiling.
|
|
for _, amps := range []float64{1, 5, 33} {
|
|
if _, err := taskPayload(chargingTaskBody{Amps: num(amps)}, false); err == nil {
|
|
t.Errorf("taskPayload accepted %g A, outside what the charger will take", amps)
|
|
}
|
|
}
|
|
|
|
// A partial write says only what it names, so a task switched off from the
|
|
// list keeps its days and its time.
|
|
on := true
|
|
partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false)
|
|
if err != nil {
|
|
t.Fatalf("taskPayload(partial): %v", err)
|
|
}
|
|
if len(partial) != 1 {
|
|
t.Errorf("a switch-only write touched %v, want only enabled", partial)
|
|
}
|
|
}
|
|
|
|
// A "limit" with no ceiling would fire and ask the charger for 0 A. Both halves
|
|
// are read from whatever the write leaves behind, so a PATCH naming one of them
|
|
// is checked against the stored other.
|
|
func TestCheckLimitAmps(t *testing.T) {
|
|
stored := chargingTaskRecord{Action: "start", Amps: 0}
|
|
if err := checkLimitAmps(map[string]any{"action": "limit"}, stored); err == nil {
|
|
t.Error("a task switched to limit with no amps was accepted")
|
|
}
|
|
if err := checkLimitAmps(map[string]any{"action": "limit", "amps": 16.0}, stored); err != nil {
|
|
t.Errorf("a complete limit task was refused: %v", err)
|
|
}
|
|
// The amps are already on the record; the PATCH only changes the action.
|
|
hasAmps := chargingTaskRecord{Action: "start", Amps: 16}
|
|
if err := checkLimitAmps(map[string]any{"action": "limit"}, hasAmps); err != nil {
|
|
t.Errorf("a limit task with stored amps was refused: %v", err)
|
|
}
|
|
// And the other way round: clearing the amps on a task that limits.
|
|
limits := chargingTaskRecord{Action: "limit", Amps: 16}
|
|
if err := checkLimitAmps(map[string]any{"amps": 0.0}, limits); err == nil {
|
|
t.Error("the amps were cleared off a limit task")
|
|
}
|
|
}
|
|
|
|
func TestSummarizeTaskRun(t *testing.T) {
|
|
all := summarizeTaskRun([]taskRunResult{
|
|
{Name: "Home-DK", Status: "Accepted"},
|
|
{Name: "Home-PL", Status: "Accepted"},
|
|
})
|
|
if all != "2 of 2 sent" {
|
|
t.Errorf("summary = %q, want \"2 of 2 sent\"", all)
|
|
}
|
|
|
|
// One charger failing is the case the list has to be able to show: the
|
|
// summary names which, because "1 of 2 sent" alone is not actionable.
|
|
some := summarizeTaskRun([]taskRunResult{
|
|
{Name: "Home-DK", Status: "Accepted"},
|
|
{Name: "Home-PL", Error: "charger is not connected"},
|
|
})
|
|
if !strings.Contains(some, "Home-PL") || !strings.Contains(some, "not connected") {
|
|
t.Errorf("summary = %q, want it to name the charger that failed and why", some)
|
|
}
|
|
|
|
// A single failure is its own reason — no count to read past.
|
|
only := summarizeTaskRun([]taskRunResult{{Name: "Home-PL", Error: "control mode is off"}})
|
|
if only != "Home-PL: control mode is off" {
|
|
t.Errorf("summary = %q, want the bare reason", only)
|
|
}
|
|
}
|
|
|
|
// The runner reads a control response the same way whichever transport answered
|
|
// it: a status when the charger took the command, the endpoint's own words when
|
|
// it did not.
|
|
func TestCaptureWriterControlOutcome(t *testing.T) {
|
|
ok := &captureWriter{header: nil}
|
|
ok.WriteHeader(200)
|
|
_, _ = ok.Write([]byte(`{"status":"Accepted"}`))
|
|
if got, err := ok.controlOutcome(); err != nil || got != "Accepted" {
|
|
t.Errorf("controlOutcome = %q, %v; want Accepted", got, err)
|
|
}
|
|
|
|
// A 2xx with nothing to read still means the command went.
|
|
bare := &captureWriter{}
|
|
bare.WriteHeader(204)
|
|
if got, err := bare.controlOutcome(); err != nil || got != "sent" {
|
|
t.Errorf("controlOutcome = %q, %v; want sent", got, err)
|
|
}
|
|
|
|
bad := &captureWriter{}
|
|
bad.WriteHeader(409)
|
|
_, _ = bad.Write([]byte(`{"error":"charger is not connected to the control backend"}`))
|
|
_, err := bad.controlOutcome()
|
|
if err == nil || !strings.Contains(err.Error(), "not connected") {
|
|
t.Errorf("controlOutcome err = %v, want the endpoint's own reason", err)
|
|
}
|
|
|
|
// An error status with no envelope to read still has to say something.
|
|
mute := &captureWriter{}
|
|
mute.WriteHeader(502)
|
|
if _, err := mute.controlOutcome(); err == nil {
|
|
t.Error("a 502 with an empty body was read as a success")
|
|
}
|
|
}
|