thermograph/frontend/server/internal/format/format_test.go
Emi Griffith b2b56bdc1a frontend: fix the three inconsistencies the onboarding guide found
1. CLAUDE.md and README.md described the superseded Python service. Both now
   describe server/ (Go), say plainly that the Python files at that level are
   the original the port was made from, and drop CLAUDE.md's claim that
   `make test-unit` is "the tier CI runs" — CI's only frontend check is the
   Dockerfile builder stage's gofmt + vet + go test.

2. static/units.js's F_REGIONS was guarded by nothing, despite three source
   comments claiming "a test asserts all three stay identical": the only check
   compared the Go set against the backend's Python. TestFCountriesMatchesUnitsJS
   now diffs the browser copy both directions.

   That backend cross-check also skips in CI — the image build context is
   frontend/, so backend/ is unreachable from the builder stage, which is the
   only place CI runs these tests. static/ IS in the context, so the Dockerfile
   copies units.js into the builder and the new assertion runs during the image
   build. Verified by mutating units.js and confirming the build fails.

3. Both docker-compose.test.yml files defaulted to the retired
   emi/thermograph-backend/app path, and the frontend harness pinned the
   split-era v0.0.2-split-ci tag. Path corrected in both. Rather than swap one
   hardcoded pin for another, backend-for-tests.sh now derives the tag from the
   checkout — sha-<12hex of `git log -1 -- backend/`>, the same domain-keyed
   rule build-push.yml and deploy.yml use — and compose requires the variable
   so a stale pin cannot creep back in.

Verified: backend 429 passed/8 skipped; frontend go vet clean and all packages
ok; frontend image builds; `make backend-up` pulls and serves on the derived
tag; shellcheck zero findings across the tree.

Unrelated pre-existing issue noted in the docs, not fixed here:
`make test-integration` fails 7/16 with 503 against a cold throwaway backend
(empty database, nothing warm). Reproduced identically on the old image, so it
predates this change.

Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 11:43:22 -07:00

308 lines
10 KiB
Go

package format
import (
"encoding/json"
"math"
"os"
"path/filepath"
"regexp"
"testing"
)
func fp(v float64) *float64 { return &v }
func TestRoundHalfUp(t *testing.T) {
// floor(x+0.5): JS Math.round parity, incl. the half-to-even divergence
// (16.5 -> 17, where Python's round() says 16) and negative halves
// rounding UP (-0.5 -> 0), both asserted so a future "simplification" to
// math.Round (halves away from zero: -0.5 -> -1) fails here.
cases := []struct {
in float64
want int
}{
{16.5, 17}, {16.4, 16}, {16.6, 17}, {-0.5, 0}, {-0.6, -1},
{-1.5, -1}, {0, 0}, {0.5, 1}, {2.5, 3}, {99.5, 100},
}
for _, c := range cases {
if got := RoundHalfUp(c.in); got != c.want {
t.Errorf("RoundHalfUp(%v) = %d, want %d", c.in, got, c.want)
}
}
}
func TestUnitForCountry(t *testing.T) {
cases := []struct {
code string
want Unit
}{
{"US", "F"}, {"us", "F"}, {"LR", "F"}, {"BS", "F"},
{"GB", "C"}, {"", "C"}, {"XX", "C"}, {"USA", "C"},
}
for _, c := range cases {
if got := UnitForCountry(c.code); got != c.want {
t.Errorf("UnitForCountry(%q) = %q, want %q", c.code, got, c.want)
}
}
}
// parseCountryCodes pulls a set of ISO-3166 alpha-2 codes out of `path` by
// matching `outer` (which must capture the literal's body in group 1) and then
// scanning that body for quoted codes. Returns nil when the file isn't present,
// so a caller can skip rather than fail in a checkout/build context that does
// not carry it.
func parseCountryCodes(t *testing.T, path string, outer *regexp.Regexp) map[string]bool {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
m := outer.FindSubmatch(raw)
if m == nil {
t.Fatalf("could not find the country-set literal in %s", path)
}
codes := regexp.MustCompile(`"([A-Z]{2})"`).FindAllSubmatch(m[1], -1)
if len(codes) == 0 {
t.Fatalf("no codes parsed from %s", path)
}
set := map[string]bool{}
for _, c := range codes {
set[string(c[1])] = true
}
return set
}
// assertMatchesFCountries diffs a parsed copy against ours, both directions.
func assertMatchesFCountries(t *testing.T, name string, other map[string]bool) {
t.Helper()
if len(other) != len(FCountries) {
t.Errorf("size mismatch: %s %d vs ours %d", name, len(other), len(FCountries))
}
for code := range other {
if !FCountries[code] {
t.Errorf("%s has %q, ours does not", name, code)
}
}
for code := range FCountries {
if !other[code] {
t.Errorf("ours has %q, %s does not", code, name)
}
}
}
// TestFCountriesMatchesBackend re-parses the backend's F_COUNTRIES (the
// source of truth per both CLAUDE.md files) and asserts our set is identical.
// Skips when the monorepo backend checkout isn't present — notably inside
// frontend/Dockerfile's builder stage, whose build context is frontend/ only,
// so backend/ is structurally unreachable there. This check is therefore a
// local/CI-checkout guard, not an image-build one.
func TestFCountriesMatchesBackend(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "backend", "api", "content_payloads.py")
backend := parseCountryCodes(t, path, regexp.MustCompile(`(?s)F_COUNTRIES\s*=\s*frozenset\(\{(.*?)\}\)`))
if backend == nil {
t.Skip("backend checkout not present — parity asserted only against the committed copy")
}
assertMatchesFCountries(t, "backend", backend)
}
// TestFCountriesMatchesUnitsJS asserts the BROWSER copy — static/units.js's
// F_REGIONS — matches too.
//
// This copy was previously guarded by nothing at all, despite comments in
// backend/api/content_payloads.py, frontend/format.py and format.go each
// claiming "a test asserts all three stay identical": the only check compared
// Go against the backend's Python, and nothing read units.js. The sets happened
// to agree, held in step by convention alone. Client-side unit selection
// disagreeing with server-rendered unit selection means the same page shows °C
// in SSR and °F after hydration — a silent, per-country split.
//
// Unlike the backend check above, static/ IS inside the frontend build context,
// and frontend/Dockerfile copies units.js into the builder stage precisely so
// this runs during the image build — the only place CI executes these tests.
func TestFCountriesMatchesUnitsJS(t *testing.T) {
for _, path := range []string{
filepath.Join("..", "..", "..", "static", "units.js"), // repo checkout
filepath.Join("/", "static", "units.js"), // Dockerfile builder stage
} {
js := parseCountryCodes(t, path, regexp.MustCompile(`(?s)F_REGIONS\s*=\s*new Set\(\[(.*?)\]\)`))
if js != nil {
assertMatchesFCountries(t, "static/units.js", js)
return
}
}
t.Skip("static/units.js not reachable from this working directory")
}
func TestTempSpans(t *testing.T) {
// Exact rendered bytes — the golden-diff contract.
if got := Temp("F", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">71°F</span>` {
t.Errorf("Temp F: %q", got)
}
// Celsius display converts the visible number but data-temp-f keeps °F.
if got := Temp("C", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">22°C</span>` {
t.Errorf("Temp C: %q", got)
}
// Unset unit behaves as Fahrenheit (Python's ContextVar default None).
if got := Temp("", fp(58.0)); got != `<span class="temp" data-temp-f="58.0">58°F</span>` {
t.Errorf("Temp unset: %q", got)
}
if got := Temp("C", nil); got != "—" {
t.Errorf("Temp nil: %q", got)
}
if got := TempBare("C", fp(58.4)); got != `<span class="temp" data-temp-f="58.4" data-bare>15°</span>` {
t.Errorf("TempBare: %q", got)
}
if got := TempBare("F", nil); got != "—" {
t.Errorf("TempBare nil: %q", got)
}
if got := TempText("C", fp(32.0)); got != "0°C" {
t.Errorf("TempText: %q", got)
}
// Negative + conversion: -40 is where the scales meet.
if got := TempText("C", fp(-40.0)); got != "-40°C" {
t.Errorf("TempText -40: %q", got)
}
// Round-half-up of the converted value: 31.1°F -> -0.5°C -> 0 (JS parity).
if got := TempText("C", fp(31.1)); got != "0°C" {
t.Errorf("TempText 31.1F: %q", got)
}
}
func TestPrecipAndWind(t *testing.T) {
if got := Precip("F", fp(0.1)); got != `<span class="precip" data-precip-in="0.100">0.10 in</span>` {
t.Errorf("Precip F: %q", got)
}
// 0.1 in * 25.4 = 2.54 mm -> rounds half-up to 3.
if got := Precip("C", fp(0.1)); got != `<span class="precip" data-precip-in="0.100">3 mm</span>` {
t.Errorf("Precip C: %q", got)
}
if got := Precip("C", nil); got != "—" {
t.Errorf("Precip nil: %q", got)
}
if got := PrecipText("F", fp(0.005)); got != "0.01 in" {
t.Errorf("PrecipText: %q", got)
}
if got := Wind("F", fp(24.7)); got != `<span class="wind" data-wind-mph="24.7">25 mph</span>` {
t.Errorf("Wind F: %q", got)
}
// 24.7 mph * 1.609344 = 39.75... km/h -> 40.
if got := Wind("C", fp(24.7)); got != `<span class="wind" data-wind-mph="24.7">40 km/h</span>` {
t.Errorf("Wind C: %q", got)
}
if got := Wind("F", nil); got != "—" {
t.Errorf("Wind nil: %q", got)
}
}
func TestFmtDispatch(t *testing.T) {
if got := Fmt("F", "tmax", fp(97.5)); got != `<span class="temp" data-temp-f="97.5">98°F</span>` {
t.Errorf("Fmt tmax: %q", got)
}
if got := Fmt("F", "precip", fp(1.0)); got != `<span class="precip" data-precip-in="1.000">1.00 in</span>` {
t.Errorf("Fmt precip: %q", got)
}
// Humidity is unit-independent: absolute g/m³ either way.
if got := Fmt("C", "humid", fp(16.0)); got != "16.0 g/m³" {
t.Errorf("Fmt humid: %q", got)
}
if got := Fmt("F", "gust", fp(38.0)); got != `<span class="wind" data-wind-mph="38.0">38 mph</span>` {
t.Errorf("Fmt gust: %q", got)
}
if got := Fmt("F", "wind", nil); got != "—" {
t.Errorf("Fmt nil: %q", got)
}
}
func TestTempClassBoundaries(t *testing.T) {
// Upper bounds are EXCLUSIVE (f < upper): the value AT each boundary
// belongs to the tier above it.
cases := []struct {
f float64
want string
}{
{-100, "rec-cold"}, {19.9, "rec-cold"},
{20, "very-cold"}, {31.9, "very-cold"},
{32, "cold"}, {44.9, "cold"},
{45, "cool"}, {57.9, "cool"},
{58, "normal"}, {69.9, "normal"},
{70, "warm"}, {79.9, "warm"},
{80, "hot"}, {89.9, "hot"},
{90, "very-hot"}, {99.9, "very-hot"},
{100, "rec-hot"}, {130, "rec-hot"},
}
for _, c := range cases {
if got := TempClass(&c.f); got != c.want {
t.Errorf("TempClass(%v) = %q, want %q", c.f, got, c.want)
}
}
if got := TempClass(nil); got != "none" {
t.Errorf("TempClass(nil) = %q, want none", got)
}
}
func TestPctOrdinal(t *testing.T) {
cases := []struct {
in any
want string
}{
{66.4, "66th"},
{99.6, "99th"}, // rounds to 100, clamps to 99 — "100th percentile" is never shown
{100.0, "99th"},
{0.0, "1st"}, // rounds to 0, clamps to 1
{0.2, "1st"},
{1.0, "1st"},
{2.0, "2nd"},
{3.0, "3rd"},
{4.0, "4th"},
{11.0, "11th"}, // teens are always "th"
{12.0, "12th"},
{13.0, "13th"},
{21.0, "21st"},
{22.0, "22nd"},
{23.0, "23rd"},
{16.5, "17th"}, // floor(x+0.5): JS parity, NOT Python round-half-even (16)
{50.5, "51st"},
{fp(42.0), "42nd"}, // nullable payload pointer
{json.Number("88"), "88th"},
{"60", "60th"}, // Python float("60") accepted strings
{nil, "—"},
{(*float64)(nil), "—"},
{"garbage", "—"},
{math.NaN(), "—"}, // Python: math.floor(nan) raised ValueError -> "—"
}
for _, c := range cases {
if got := PctOrdinal(c.in); got != c.want {
t.Errorf("PctOrdinal(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMonthRangeBar(t *testing.T) {
if MonthRangeBar(nil, fp(70)) != nil || MonthRangeBar(fp(50), nil) != nil {
t.Fatal("missing endpoint must yield nil bar")
}
// 51.3..61.1 on the -10..115 axis: left (51.3+10)/125*100 = 49.04 -> "49.0",
// width (61.1-51.3)/125*100 = 7.84 -> "7.8".
b := MonthRangeBar(fp(51.3), fp(61.1))
if b.Left != "49.0" || b.Width != "7.8" {
t.Errorf("bar geometry: left %q width %q", b.Left, b.Width)
}
if b.C1 != "cool" || b.C2 != "normal" {
t.Errorf("bar colours: %q %q", b.C1, b.C2)
}
// Width floor: a degenerate range still paints a 2.0%% sliver, and the
// formatted floor keeps Python's "2.0" (str of a rounded float), not "2".
b = MonthRangeBar(fp(60), fp(60.5))
if b.Width != "2.0" {
t.Errorf("min width: %q", b.Width)
}
// Endpoints clamp into the axis for geometry, but the tier colours come
// from the raw values (a -40 low is still rec-cold even though the bar
// starts at the axis edge).
b = MonthRangeBar(fp(-40), fp(120))
if b.Left != "0.0" || b.Width != "100.0" {
t.Errorf("clamped geometry: left %q width %q", b.Left, b.Width)
}
if b.C1 != "rec-cold" || b.C2 != "rec-hot" {
t.Errorf("clamped colours: %q %q", b.C1, b.C2)
}
}