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 Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth here -- every route fetches from the backend's /content/* API, so this is I/O-bound glue with no hard-porting wall; the risk was always in reproducing the rendering exactly, not the language. 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 (frontend/tests/fixtures/) and every one of the 11 routes compared byte-for-byte. The only surviving differences after that process are insignificant inter-tag whitespace and one attribute where Go's stricter escaper HTML-encodes an apostrophe Jinja left literal (functionally identical in every browser) -- confirmed programmatically by normalizing whitespace and unescaping before diffing, not by eyeballing. That process caught defects unit tests alone would have missed, because map[string]any has no compile-time field check: - Render-context keys were snake_case throughout (content.py's Jinja convention, ported verbatim) while the templates -- written independently -- read PascalCase fields. A missing map key doesn't error in html/template, it silently renders empty, so this was invisible in every status code and every "it built" signal: title, meta description, canonical URL, OpenGraph tags, the homepage's entire ranked list, and the brand-tag/nav-active state were all blank across every page. Fixed by renaming every key to match each template's own header comment (the authoritative per-page field contract) and, where an API struct's exported fields already matched what a template needed (contentapi.CityInfo, Crumb, HomeRanked, HubCountry, ...), passing the struct straight through instead of hand-rewrapping it in a map -- removes a whole layer of future drift risk, not just this instance of it. - Three pages 500'd outright: `.ToolHref` needed a fully-composed href string, not the bare "lat,lon" fragment the handlers were building; the all-time-records table needed the raw contentapi.AllTimeRecords struct, not a re-wrapped map. - JSON-LD was being double-encoded: `<script type="application/ld+json">` is JAVASCRIPT context to html/template's contextual escaper regardless of the script's `type` attribute, so a template.HTML-typed value placed there gets re-escaped as a quoted JS string instead of emitted raw -- the entire structured-data payload shipped as a JSON string containing JSON, which no crawler would parse as the intended object. Needed template.JS instead, the type that actually means "trusted JS source." The glossary term page's JSON-LD was simply never built at all (the Jinja original assembled it inline in the template rather than through content.py's context dict, and that got lost in translation) -- added. - html/template silently strips literal HTML comments AND JavaScript comments from the parsed output (verified in isolation, zero template actions involved) -- confirmed as real engine behavior, not a bug in either port, so both need a FuncMap function returning template.HTML / template.JS respectively to survive parsing rather than a literal `<!-- -->` or `//` in the template source. Packaging: multi-stage Go build, final image alpine (not distroless -- the Swarm stack's env-entrypoint.sh shim needs bash), 187MB -> 22.6MB. Two defects caught before they reached a host: - The Swarm stack overrides `entrypoint:` with no `command:`, which drops the image's own CMD entirely (Docker/Swarm semantics, not merged) -- env-entrypoint.sh then fell through to its hardcoded `exec uvicorn app:app` fallback, which doesn't exist in this image. Every deploy would have exited 127. Fixed with an explicit `command:` on the stack's frontend service, and corrected the shim's stale comment claiming CMD passes through automatically. - `COPY --chown=thermograph` resolves the group by NAME at copy time; Alpine's `adduser -S` with no `-G` doesn't create a same-named group, so the classic (non-BuildKit) Docker builder -- which this CI runner falls back to, since it installs plain `docker.io` with no buildx plugin -- failed outright. Fixed with an explicit group and numeric --chown. Verification: go build/vet/test -race clean across all packages; the Docker image builds and passes its embedded go test step under both BuildKit and the classic builder; shellcheck 0 findings on the one script touched; rebased onto current main (the ERA5 lake stack landed on both main and dev during this work -- confirmed additive, no overlap with frontend/daemon).
2026-07-24 00:49:49 +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)
}
}