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
308 lines
10 KiB
Go
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)
|
|
}
|
|
}
|