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 != `71°F` { 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 != `22°C` { t.Errorf("Temp C: %q", got) } // Unset unit behaves as Fahrenheit (Python's ContextVar default None). if got := Temp("", fp(58.0)); got != `58°F` { 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 != `15°` { 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 != `0.10 in` { 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 != `3 mm` { 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 != `25 mph` { t.Errorf("Wind F: %q", got) } // 24.7 mph * 1.609344 = 39.75... km/h -> 40. if got := Wind("C", fp(24.7)); got != `40 km/h` { 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 != `98°F` { t.Errorf("Fmt tmax: %q", got) } if got := Fmt("F", "precip", fp(1.0)); got != `1.00 in` { 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 != `38 mph` { 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) } }