thermograph/frontend/server/internal/format/format_test.go

258 lines
8.1 KiB
Go
Raw Normal View History

frontend: rewrite the SSR content service in Go (#28) Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth -- every route fetches from the backend's /content/* API. Verified with a golden-HTML diff, not just unit tests: both the Python original and the Go rewrite were run against the same committed fixtures and every route compared byte-for-byte, confirmed programmatically. That process caught defects unit tests alone missed, since map[string]any has no compile-time field check: - Render-context keys were snake_case throughout while the templates read PascalCase fields. A missing map key doesn't error, it silently renders empty -- title, meta description, canonical URL, OpenGraph tags, and the homepage's entire ranked list were blank on every page despite every route returning 200. Fixed by renaming every key to match each template's own documented field contract, and passing API structs straight through wherever their fields already matched (removes a whole layer of future drift risk). - Three pages 500'd: ToolHref needed a composed href, not a bare "lat,lon" fragment; the records table needed the raw API struct. - JSON-LD was double-encoded: <script type="application/ld+json"> is JAVASCRIPT context to html/template's escaper regardless of the script's type attribute, so template.HTML gets re-escaped as a quoted JS string. Needed template.JS. The glossary term page's JSON-LD was never built at all -- added. - html/template silently strips literal HTML and JS comments from parsed output (verified in isolation) -- both need a FuncMap function returning template.HTML/template.JS to survive. Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the Swarm stack's entrypoint override with no explicit command drops the image's CMD entirely (every deploy would have exited 127), and COPY --chown by name fails under the classic Docker builder on Alpine. Both fixed. go build/vet/test -race clean; docker build passes its embedded test step under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00
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)
}
}