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>
This commit is contained in:
tajniak81
2026-09-03 23:32:07 +02:00
co-authored by Claude Opus 5
parent 0f48093d1a
commit 2b4f4f034d
13 changed files with 553 additions and 357 deletions
+136 -76
View File
@@ -4,14 +4,20 @@ 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 task that fires in
// 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 TestTaskIsDue(t *testing.T) {
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)
@@ -19,32 +25,52 @@ func TestTaskIsDue(t *testing.T) {
// 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")
// 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),
},
}
// Weekdays. 2026-09-02 is a Wednesday (3).
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 !taskIsDue(weeknights, at2300) {
t.Error("a weeknight task is not due on a Wednesday")
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 taskIsDue(weekends, at2300) {
if len(dueSteps(weekends, at2300)) != 0 {
t.Error("a weekend task fired on a Wednesday")
}
@@ -52,34 +78,45 @@ func TestTaskIsDue(t *testing.T) {
// 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")
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 !taskIsDue(yesterday, at2300) {
if len(dueSteps(yesterday, at2300)) != 1 {
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.
// 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.Time = "later"
if taskIsDue(broken, at2300) {
t.Error("a task with an unreadable time fired")
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 TestTaskIsDueUnknownZone(t *testing.T) {
func TestDueStepsUnknownZone(t *testing.T) {
now := time.Now()
rec := chargingTaskRecord{
Time: now.Format("15:04"),
Zone: "Mars/Olympus_Mons",
Zone: "Mars/Olympus_Mons",
Steps: []models.ChargingStep{step("start", now.Format("15:04"), 0)},
}
if !taskIsDue(rec, now) {
if len(dueSteps(rec, now)) != 1 {
t.Error("a task in an unknown zone did not fall back to the server's clock")
}
}
@@ -130,16 +167,58 @@ func TestNormalizeDays(t *testing.T) {
}
}
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 }
num := func(f float64) *float64 { return &f }
steps := func(s ...models.ChargingStep) *[]models.ChargingStep { return &s }
// A whole task, as a create sends it.
full := chargingTaskBody{
Name: str(" Night rate "),
Action: str("limit"),
Time: str("7:05"),
Amps: num(10),
Name: str(" Night rate "),
Steps: steps(step("start", "23:00", 0), step("stop", "6:30", 0)),
}
payload, err := taskPayload(full, true)
if err != nil {
@@ -148,36 +227,26 @@ func TestTaskPayloadValidation(t *testing.T) {
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")
}
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 fields a task cannot exist without.
// The two things 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")},
{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)
}
}
// 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.
// list keeps its flow, its chargers and its days.
on := true
partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false)
if err != nil {
@@ -188,26 +257,17 @@ func TestTaskPayloadValidation(t *testing.T) {
}
}
// 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")
// 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)
}
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")
// 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)
}
}