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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 21:11:32 +00:00
|
|
|
// 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()
|
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
|
|
|
raw, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
2026-07-25 21:11:32 +00:00
|
|
|
return nil
|
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
|
|
|
}
|
2026-07-25 21:11:32 +00:00
|
|
|
m := outer.FindSubmatch(raw)
|
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
|
|
|
if m == nil {
|
2026-07-25 21:11:32 +00:00
|
|
|
t.Fatalf("could not find the country-set literal in %s", path)
|
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
|
|
|
}
|
|
|
|
|
codes := regexp.MustCompile(`"([A-Z]{2})"`).FindAllSubmatch(m[1], -1)
|
|
|
|
|
if len(codes) == 0 {
|
|
|
|
|
t.Fatalf("no codes parsed from %s", path)
|
|
|
|
|
}
|
2026-07-25 21:11:32 +00:00
|
|
|
set := map[string]bool{}
|
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
|
|
|
for _, c := range codes {
|
2026-07-25 21:11:32 +00:00
|
|
|
set[string(c[1])] = true
|
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
|
|
|
}
|
2026-07-25 21:11:32 +00:00
|
|
|
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))
|
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
|
|
|
}
|
2026-07-25 21:11:32 +00:00
|
|
|
for code := range other {
|
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
|
|
|
if !FCountries[code] {
|
2026-07-25 21:11:32 +00:00
|
|
|
t.Errorf("%s has %q, ours does not", name, code)
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for code := range FCountries {
|
2026-07-25 21:11:32 +00:00
|
|
|
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
|
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
|
|
|
}
|
|
|
|
|
}
|
2026-07-25 21:11:32 +00:00
|
|
|
t.Skip("static/units.js not reachable from this working directory")
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|