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:
co-authored by
Claude Opus 5
parent
0f48093d1a
commit
2b4f4f034d
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user