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
257 lines
8.1 KiB
Go
257 lines
8.1 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func TestFCountriesMatchesBackend(t *testing.T) {
|
|
path := filepath.Join("..", "..", "..", "..", "backend", "api", "content_payloads.py")
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Skipf("backend checkout not present (%v) — parity asserted only against the committed copy", err)
|
|
}
|
|
m := regexp.MustCompile(`(?s)F_COUNTRIES\s*=\s*frozenset\(\{(.*?)\}\)`).FindSubmatch(raw)
|
|
if m == nil {
|
|
t.Fatalf("could not find F_COUNTRIES 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)
|
|
}
|
|
backend := map[string]bool{}
|
|
for _, c := range codes {
|
|
backend[string(c[1])] = true
|
|
}
|
|
if len(backend) != len(FCountries) {
|
|
t.Errorf("F_COUNTRIES size mismatch: backend %d vs ours %d", len(backend), len(FCountries))
|
|
}
|
|
for code := range backend {
|
|
if !FCountries[code] {
|
|
t.Errorf("backend has %q, ours does not", code)
|
|
}
|
|
}
|
|
for code := range FCountries {
|
|
if !backend[code] {
|
|
t.Errorf("ours has %q, backend does not", code)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|