One command per task was the wrong unit. A charging window is two commands and reads as one intention, so it was two rows that had to be named twice, switched off twice, and kept in step by hand — and there was nowhere to put the third thing, the ease down to 10 A once the house is asleep. So a task holds a flow. Steps are rows in the editor: an action, a time, and the ceiling under the one action that takes one. The chargers and the days belong to the task, because they are the same for every step of a night, and the switch governs all of it. The steps keep the order they were written rather than being sorted by the clock. A night crosses midnight, and clock order files "start at 23:00" last, behind the stop that closes it — which is not the flow anybody described. Nothing about firing depends on the order: every step is timed on its own, and the sweep asks each one whether its minute has come. Run now moved onto the step. A flow is not a thing that can happen at once — firing a start and the stop that closes it back to back would leave the charger where it began and prove nothing — so the button fires the one line it sits on, and the outcome names the step by its time. The stored shape changes with it: action/amps/time give way to a steps list. The collection was a day old and empty, so this replaces them outright rather than carrying a compatibility path for a schema nothing has run on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
12 KiB
Go
333 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/models"
|
|
)
|
|
|
|
// 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 step 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 step(action, at string, amps float64) models.ChargingStep {
|
|
return models.ChargingStep{Action: action, Time: at, Amps: amps}
|
|
}
|
|
|
|
func TestDueSteps(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)
|
|
|
|
// A whole night in one task, which is the point of a flow: it opens, it
|
|
// eases off, it closes.
|
|
base := chargingTaskRecord{
|
|
Zone: "Europe/Warsaw",
|
|
Enabled: true,
|
|
Steps: []models.ChargingStep{
|
|
step("start", "23:00", 0),
|
|
step("limit", "01:00", 10),
|
|
step("stop", "06:30", 0),
|
|
},
|
|
}
|
|
|
|
due := dueSteps(base, at2300)
|
|
if len(due) != 1 || due[0].Action != "start" {
|
|
t.Fatalf("at 23:00 got %+v, want just the start step", due)
|
|
}
|
|
// The same instant handed over as UTC. The zone on the task is what the
|
|
// times are read in, so where the server thinks it is must not matter.
|
|
if got := dueSteps(base, at2300.UTC()); len(got) != 1 || got[0].Action != "start" {
|
|
t.Errorf("the step stopped being due when the same instant arrived as UTC: %+v", got)
|
|
}
|
|
// The other two steps, each in its own minute and no other.
|
|
if got := dueSteps(base, time.Date(2026, 9, 3, 1, 0, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "limit" {
|
|
t.Errorf("at 01:00 got %+v, want the limit step", got)
|
|
}
|
|
if got := dueSteps(base, time.Date(2026, 9, 3, 6, 30, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "stop" {
|
|
t.Errorf("at 06:30 got %+v, want the stop step", got)
|
|
}
|
|
// A minute the flow says nothing about fires nothing — the point being that
|
|
// a task with a step at 23:00 is not "on" from 23:00 onwards.
|
|
for _, at := range []time.Time{at2300.Add(time.Minute), at2300.Add(-time.Minute),
|
|
time.Date(2026, 9, 3, 3, 0, 0, 0, warsaw)} {
|
|
if got := dueSteps(base, at); len(got) != 0 {
|
|
t.Errorf("at %s got %+v, want nothing due", at.Format("15:04"), got)
|
|
}
|
|
}
|
|
|
|
// Weekdays gate the whole task. 2026-09-02 is a Wednesday (3).
|
|
weeknights := base
|
|
weeknights.Days = []int{1, 2, 3, 4, 5}
|
|
if len(dueSteps(weeknights, at2300)) != 1 {
|
|
t.Error("a weeknight task did not fire on a Wednesday")
|
|
}
|
|
weekends := base
|
|
weekends.Days = []int{0, 6}
|
|
if len(dueSteps(weekends, at2300)) != 0 {
|
|
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 len(dueSteps(fired, at2300.Add(20*time.Second))) != 0 {
|
|
t.Error("a step 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 len(dueSteps(yesterday, at2300)) != 1 {
|
|
t.Error("yesterday's run stopped today's from firing")
|
|
}
|
|
|
|
// A step whose time is not a time of day fires nothing rather than firing at
|
|
// midnight, which is what a zero hour and minute would have meant — and it
|
|
// does not take the rest of the flow down with it.
|
|
broken := base
|
|
broken.Steps = []models.ChargingStep{step("start", "later", 0), step("stop", "23:00", 0)}
|
|
got := dueSteps(broken, at2300)
|
|
if len(got) != 1 || got[0].Action != "stop" {
|
|
t.Errorf("got %+v, want only the readable step", got)
|
|
}
|
|
|
|
// Two steps timed to the same minute contradict each other, but nothing
|
|
// stops somebody writing them, so both are returned rather than one
|
|
// silently winning.
|
|
clash := base
|
|
clash.Steps = []models.ChargingStep{step("start", "23:00", 0), step("boost", "23:00", 0)}
|
|
if got := dueSteps(clash, at2300); len(got) != 2 {
|
|
t.Errorf("got %+v, want both steps sharing the minute", got)
|
|
}
|
|
}
|
|
|
|
// 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 TestDueStepsUnknownZone(t *testing.T) {
|
|
now := time.Now()
|
|
rec := chargingTaskRecord{
|
|
Zone: "Mars/Olympus_Mons",
|
|
Steps: []models.ChargingStep{step("start", now.Format("15:04"), 0)},
|
|
}
|
|
if len(dueSteps(rec, now)) != 1 {
|
|
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 TestNormalizeSteps(t *testing.T) {
|
|
// A night, written the way it is meant: it opens, it eases off, it closes.
|
|
// The half-written times are the client's business to send and this
|
|
// function's business to pad.
|
|
got, err := normalizeSteps([]models.ChargingStep{
|
|
step("start", "23:00", 0),
|
|
step("limit", "1:00", 10),
|
|
step("stop", "6:30", 0),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("normalizeSteps: %v", err)
|
|
}
|
|
// Kept in the order it was written. Sorting by the clock would file the
|
|
// 23:00 start last, behind the stop that closes it, which is not the flow
|
|
// anybody described — and firing does not depend on the order at all.
|
|
wantTimes := []string{"23:00", "01:00", "06:30"}
|
|
for i, want := range wantTimes {
|
|
if got[i].Time != want {
|
|
t.Errorf("step %d time = %q, want %q (whole flow: %+v)", i, got[i].Time, want, got)
|
|
}
|
|
}
|
|
|
|
for name, steps := range map[string][]models.ChargingStep{
|
|
"no steps at all": {},
|
|
"an unknown action": {step("melt", "23:00", 0)},
|
|
"a time that is not one": {step("start", "half past", 0)},
|
|
"a limit with no amps": {step("limit", "23:00", 0)},
|
|
"amps below the floor": {step("limit", "23:00", 3)},
|
|
"amps above the rating": {step("limit", "23:00", 40)},
|
|
} {
|
|
if _, err := normalizeSteps(steps); err == nil {
|
|
t.Errorf("normalizeSteps accepted %s", name)
|
|
}
|
|
}
|
|
|
|
// The cap bounds what the sweep re-reads every thirty seconds.
|
|
tooMany := make([]models.ChargingStep, maxTaskSteps+1)
|
|
for i := range tooMany {
|
|
tooMany[i] = step("start", "23:00", 0)
|
|
}
|
|
if _, err := normalizeSteps(tooMany); err == nil {
|
|
t.Errorf("normalizeSteps accepted %d steps, past the cap of %d", len(tooMany), maxTaskSteps)
|
|
}
|
|
}
|
|
|
|
func TestTaskPayloadValidation(t *testing.T) {
|
|
str := func(s string) *string { return &s }
|
|
steps := func(s ...models.ChargingStep) *[]models.ChargingStep { return &s }
|
|
|
|
full := chargingTaskBody{
|
|
Name: str(" Night rate "),
|
|
Steps: steps(step("start", "23:00", 0), step("stop", "6:30", 0)),
|
|
}
|
|
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["enabled"] != true {
|
|
t.Error("a new task was written switched off; nobody fills in a schedule to leave it off")
|
|
}
|
|
if flow, _ := payload["steps"].([]models.ChargingStep); len(flow) != 2 || flow[0].Time != "23:00" {
|
|
t.Errorf("steps = %+v, want both, as written", payload["steps"])
|
|
}
|
|
|
|
// The two things a task cannot exist without.
|
|
for _, missing := range []chargingTaskBody{
|
|
{Steps: steps(step("start", "23:00", 0))},
|
|
{Name: str("x")},
|
|
{Name: str(" "), Steps: steps(step("start", "23:00", 0))},
|
|
} {
|
|
if _, err := taskPayload(missing, true); err == nil {
|
|
t.Errorf("taskPayload accepted an incomplete task: %+v", missing)
|
|
}
|
|
}
|
|
|
|
// A partial write says only what it names, so a task switched off from the
|
|
// list keeps its flow, its chargers and its days.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The list is ordered by the time a task begins, which lives inside its flow
|
|
// rather than in a column PocketBase could sort on.
|
|
func TestFirstStepTime(t *testing.T) {
|
|
rec := chargingTaskRecord{Steps: []models.ChargingStep{step("start", "23:00", 0), step("stop", "06:30", 0)}}
|
|
if got := rec.firstStepTime(); got != "23:00" {
|
|
t.Errorf("firstStepTime = %q, want the first step's 23:00", got)
|
|
}
|
|
// A task with no steps cannot be written, but sorting must not depend on
|
|
// that — it sorts last rather than first.
|
|
if got := (chargingTaskRecord{}).firstStepTime(); got < "23:59" {
|
|
t.Errorf("an empty task sorted to %q, want it last", got)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|