thermograph/frontend/server/internal/format/bands_test.go
emi a4ecb51401
Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
web/worker: add a process-level liveness heartbeat (#80)
2026-07-25 04:13:47 +00:00

138 lines
4.4 KiB
Go

package format
import (
"os"
"path/filepath"
"regexp"
"strconv"
"testing"
)
// Boundary tests derived from the BACKEND's grading.py (the source of truth
// for tier names/thresholds — see the cross-repo contract in CLAUDE.md).
// Each threshold is asserted AT the boundary, not just around it, because the
// side each comparison is inclusive on is exactly what an off-by-one breaks:
// the top tier is strict (pct > 99), every other bound is lower-INCLUSIVE.
//
// Note: the Python frontend itself carried no band table (labels/classes
// arrive pre-computed from the content API); these are ported for the Go
// side's display parity per the same contract the backend enforces.
func TestTempBandBoundaries(t *testing.T) {
cases := []struct {
pct float64
wantLabel string
wantClass string
}{
{99.01, "Near Record", "rec-hot"}, // strictly above 99 only
{99, "Very High", "very-hot"}, // AT 99: NOT Near Record (top tier is strict)
{90, "Very High", "very-hot"}, // lower-inclusive
{89.99, "High", "hot"},
{75, "High", "hot"},
{74.99, "Above Normal", "warm"},
{60, "Above Normal", "warm"},
{59.99, "Normal", "normal"},
{40, "Normal", "normal"},
{39.99, "Below Normal", "cool"},
{25, "Below Normal", "cool"},
{24.99, "Low", "cold"},
{10, "Low", "cold"},
{9.99, "Very Low", "very-cold"},
{1, "Very Low", "very-cold"},
{0.99, "Near Record", "rec-cold"}, // strictly below 1
{0, "Near Record", "rec-cold"},
{-1, "Near Record", "rec-cold"}, // out-of-range falls to the bottom tier
{100, "Near Record", "rec-hot"},
}
for _, c := range cases {
label, class := BandFor(c.pct, TempBands)
if label != c.wantLabel || class != c.wantClass {
t.Errorf("BandFor(%v, TempBands) = (%q, %q), want (%q, %q)",
c.pct, label, class, c.wantLabel, c.wantClass)
}
}
}
func TestRainBandBoundaries(t *testing.T) {
cases := []struct {
pct float64
wantLabel string
wantClass string
}{
{99.01, "Extreme", "wet-9"}, // strictly above 99 only
{99, "Severe", "wet-8"}, // AT 99: NOT Extreme
{95, "Severe", "wet-8"},
{94.99, "Very Heavy", "wet-7"},
{90, "Very Heavy", "wet-7"},
{89.99, "Heavy", "wet-6"},
{60, "Heavy", "wet-6"},
{59.99, "Typical", "wet-5"},
{40, "Typical", "wet-5"},
{39.99, "Brisk", "wet-4"},
{25, "Brisk", "wet-4"},
{24.99, "Light", "wet-3"},
{10, "Light", "wet-3"},
{9.99, "Trace", "wet-2"},
{0, "Trace", "wet-2"},
{100, "Extreme", "wet-9"},
}
for _, c := range cases {
label, class := BandFor(c.pct, RainBands)
if label != c.wantLabel || class != c.wantClass {
t.Errorf("BandFor(%v, RainBands) = (%q, %q), want (%q, %q)",
c.pct, label, class, c.wantLabel, c.wantClass)
}
}
}
// parseBackendBands extracts a band table from backend/data/grading.py by
// name, so the Go copies cannot silently drift from the source of truth.
func parseBackendBands(t *testing.T, src []byte, name string) []Band {
t.Helper()
block := regexp.MustCompile(`(?s)` + name + `\s*=\s*\[(.*?)\]`).FindSubmatch(src)
if block == nil {
t.Fatalf("could not find %s in grading.py", name)
}
rows := regexp.MustCompile(`\(\s*(\d+)\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)`).FindAllSubmatch(block[1], -1)
var out []Band
for _, r := range rows {
thr, err := strconv.ParseFloat(string(r[1]), 64)
if err != nil {
t.Fatalf("bad threshold in %s: %v", name, err)
}
out = append(out, Band{Threshold: thr, Label: string(r[2]), Class: string(r[3])})
}
if len(out) == 0 {
t.Fatalf("no bands parsed for %s", name)
}
return out
}
// TestBandsMatchBackend re-parses grading.py and asserts thresholds, labels,
// classes AND ordering are identical. Skips when the monorepo backend
// checkout isn't present (the tables above remain the committed contract).
func TestBandsMatchBackend(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "backend", "data", "grading.py")
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("backend checkout not present (%v) — parity asserted only against the committed copy", err)
}
for _, tc := range []struct {
name string
ours []Band
}{
{"TEMP_BANDS", TempBands},
{"RAIN_BANDS", RainBands},
} {
theirs := parseBackendBands(t, raw, tc.name)
if len(theirs) != len(tc.ours) {
t.Errorf("%s: backend has %d tiers, ours has %d", tc.name, len(theirs), len(tc.ours))
continue
}
for i := range theirs {
if theirs[i] != tc.ours[i] {
t.Errorf("%s[%d]: backend %+v != ours %+v", tc.name, i, theirs[i], tc.ours[i])
}
}
}
}