Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions go/internal/control/sitesim_field_api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//go:build !race

package control_test

import (
"encoding/json"
"net/http"
"net/http/httptest"

"github.com/srcfl/ftw/go/internal/api"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// The field matrix reads the EV verdict as the app does, from GET
// /api/loadpoints. The api package imports control, so only this external
// test package can build the server.
func init() {
control.FieldVerdict = func(tel *telemetry.Store, lps *loadpoint.Manager, ctrl *loadpoint.Controller) func() (string, string) {
srv := api.New(&api.Deps{Tel: tel, Loadpoints: lps, LoadpointCtrl: ctrl})
return func() (string, string) {
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil))
var body struct {
Loadpoints []struct {
Feedback *struct{ Status, Reason string } `json:"control_feedback"`
} `json:"loadpoints"`
}
if json.Unmarshal(rr.Body.Bytes(), &body) != nil || len(body.Loadpoints) == 0 || body.Loadpoints[0].Feedback == nil {
return "none", "no control_feedback"
}
return body.Loadpoints[0].Feedback.Status, body.Loadpoints[0].Feedback.Reason
}
}
}
145 changes: 145 additions & 0 deletions go/internal/control/sitesim_field_matrix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//go:build !race

// The race detector makes the matrix 15 times slower (826 s of CPU) and finds
// nothing in it: each case runs on one goroutine. TestSiteSimScenarios still
// runs under -race.

package control

import (
"fmt"
"maps"
"os"
"slices"
"sort"
"strings"
"sync"
"testing"
"testing/synctest"
)

// fieldKnownFile lists the rows that fail on master: case, kind and the fix
// that turns it green. A listed kind that passes fails the test until its
// line goes, and so does a failure the file does not list.
// FTW_FIELD_KNOWN=write rewrites it from a run and keeps each line's label.
const fieldKnownFile = "testdata/sitesim_field_known.txt"

type fieldKnown struct {
header []string
fixes map[string]map[string]string // case, kind: fix
}

func readFieldKnown(t *testing.T) fieldKnown {
t.Helper()
data, err := os.ReadFile(fieldKnownFile)
if err != nil {
t.Fatal(err)
}
k := fieldKnown{fixes: map[string]map[string]string{}}
for i, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
if line == "" || strings.HasPrefix(line, "#") {
k.header = append(k.header, line)
continue
}
f := strings.Fields(line)
if len(f) != 3 {
t.Fatalf("%s:%d: want case, kind and fix: %q", fieldKnownFile, i+1, line)
}
if k.fixes[f[0]] == nil {
k.fixes[f[0]] = map[string]string{}
}
k.fixes[f[0]][f[1]] = f[2]
}
return k
}

func (k fieldKnown) write(t *testing.T, results []fieldResult) {
t.Helper()
lines := slices.Clone(k.header)
for _, r := range results {
for _, kind := range r.failed {
fix := k.fixes[r.name][kind]
if fix == "" {
fix = "new"
}
lines = append(lines, r.name+" "+kind+" "+fix)
}
}
if err := os.WriteFile(fieldKnownFile, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}

// fieldResult is one case's broken invariants.
type fieldResult struct {
name string
failed []string
}

func TestSiteSimFieldMatrix(t *testing.T) {
cases, known := fieldCases(), readFieldKnown(t)
writeKnown := os.Getenv("FTW_FIELD_KNOWN") == "write"
names := map[string]bool{}
for _, c := range cases {
names[c.name()] = true
}
for name := range known.fixes {
if !names[name] {
t.Errorf("%s names %s, which the matrix no longer has", fieldKnownFile, name)
}
}
var mu sync.Mutex
var results []fieldResult
t.Cleanup(func() {
sort.Slice(results, func(i, j int) bool { return results[i].name < results[j].name })
byKind, failing := map[string]int{}, 0
for _, r := range results {
if len(r.failed) > 0 {
failing++
}
for _, k := range r.failed {
byKind[k]++
}
}
t.Logf("field matrix: %d cases, %d fail an invariant; by kind %v", len(results), failing, byKind)
if writeKnown && len(results) == len(cases) {
known.write(t, results)
}
})
for _, c := range cases {
t.Run(c.name(), func(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
var trace func(string, ...any)
if os.Getenv("FTW_FIELD_TRACE") == c.name() {
trace = t.Logf
}
s, f := runFieldCase(c, trace)
failed, fixes := slices.Sorted(maps.Keys(f.failures)), known.fixes[c.name()]
var problems []string
for _, k := range failed {
if fix, ok := fixes[k]; ok {
t.Logf("fails as known (%s): %s", fix, f.failures[k])
} else {
problems = append(problems, f.failures[k])
}
}
for _, k := range slices.Sorted(maps.Keys(fixes)) {
if _, ok := f.failures[k]; !ok {
problems = append(problems, fmt.Sprintf("%s passes now: delete its known line (%s)", k, fixes[k]))
}
}
mu.Lock()
results = append(results, fieldResult{name: c.name(), failed: failed})
mu.Unlock()
t.Logf("peak phase %.1f A, delivered %.1f kWh, car idle with room %v", s.peakA, f.deliveredWh/1000, s.idleWithRoom)
switch {
case len(problems) > 0 && writeKnown:
t.Log(strings.Join(problems, "\n"))
case len(problems) > 0:
t.Fatal(strings.Join(problems, "\n"))
}
})
})
}
}
Loading
Loading