Files
tajniak81andClaude Opus 5 2b4f4f034d A task holds the whole night, not one end of it
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>
2026-09-03 23:32:07 +02:00

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")
}
}