thermograph/frontend/server/internal/content/content_test.go
emi a4ecb51401
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
web/worker: add a process-level liveness heartbeat (#80)
2026-07-25 04:13:47 +00:00

892 lines
32 KiB
Go

// Behaviour tests for the content.py port, driven by the same committed
// backend fixtures the Python suite used (frontend/tests/fixtures/*.json,
// captured by scripts/capture-fixtures.sh) and mirroring the assertions of
// frontend/tests/unit/test_rendering.py that belong to this layer. Full
// rendered-page assertions (200 + HTML structure) live with the template
// port / golden-diff phase — here the templates are placeholders, so these
// tests target the routes' status behavior and the exact render contexts the
// handlers build.
package content
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"thermograph/frontend/internal/config"
"thermograph/frontend/internal/contentapi"
"thermograph/frontend/internal/contentdata"
"thermograph/frontend/internal/format"
"thermograph/frontend/internal/render"
)
const (
testSlug = "london-england-gb" // captured into tests/fixtures/ (GB -> Celsius)
testMonth = "july"
fakeKey = "test0000indexnow0000key000000000"
)
// fixturesDir / contentDir reach the repo's committed test fixtures and SSR
// copy from this package directory.
var (
fixturesDir = filepath.Join("..", "..", "..", "tests", "fixtures")
contentDir = filepath.Join("..", "..", "..", "content")
)
func loadFixture[T any](t *testing.T, name string) *T {
t.Helper()
raw, err := os.ReadFile(filepath.Join(fixturesDir, name+".json"))
if err != nil {
t.Fatalf("fixture %s: %v", name, err)
}
out := new(T)
if err := json.Unmarshal(raw, out); err != nil {
t.Fatalf("fixture %s: %v", name, err)
}
return out
}
// fakeAPI is the Go analogue of conftest.py's monkeypatched api_client.
type fakeAPI struct {
hub func() (*contentapi.HubPayload, error)
sitemap func() ([]contentapi.SitemapEntry, error)
indexNowKey func() (string, error)
home func() (*contentapi.HomePayload, error)
city func(slug, origin string) (*contentapi.CityPayload, error)
cityMonth func(slug, month string) (*contentapi.MonthPayload, error)
cityRecords func(slug, origin string) (*contentapi.RecordsPayload, error)
}
func (f *fakeAPI) Hub() (*contentapi.HubPayload, error) { return f.hub() }
func (f *fakeAPI) Sitemap() ([]contentapi.SitemapEntry, error) { return f.sitemap() }
func (f *fakeAPI) IndexNowKey() (string, error) { return f.indexNowKey() }
func (f *fakeAPI) Home() (*contentapi.HomePayload, error) { return f.home() }
func (f *fakeAPI) City(slug, origin string) (*contentapi.CityPayload, error) {
return f.city(slug, origin)
}
func (f *fakeAPI) CityMonth(slug, month string) (*contentapi.MonthPayload, error) {
return f.cityMonth(slug, month)
}
func (f *fakeAPI) CityRecords(slug, origin string) (*contentapi.RecordsPayload, error) {
return f.cityRecords(slug, origin)
}
func notFound(detail string) error {
return &contentapi.StatusError{
StatusCode: 404,
Body: fmt.Sprintf(`{"detail":%q}`, detail),
URL: "http://backend.invalid/x",
}
}
var errUnreachable = errors.New("connect: connection refused")
// fixtureAPI wires every method to the committed fixtures, exactly like the
// Python `client` fixture's monkeypatching (unknown slug/month -> a backend
// 404 StatusError).
func fixtureAPI(t *testing.T) *fakeAPI {
t.Helper()
city := loadFixture[contentapi.CityPayload](t, "city")
month := loadFixture[contentapi.MonthPayload](t, "month")
records := loadFixture[contentapi.RecordsPayload](t, "records")
home := loadFixture[contentapi.HomePayload](t, "home")
hub := loadFixture[contentapi.HubPayload](t, "hub")
sitemap := loadFixture[[]contentapi.SitemapEntry](t, "sitemap")
return &fakeAPI{
hub: func() (*contentapi.HubPayload, error) { return hub, nil },
sitemap: func() ([]contentapi.SitemapEntry, error) { return *sitemap, nil },
indexNowKey: func() (string, error) { return fakeKey, nil },
home: func() (*contentapi.HomePayload, error) { return home, nil },
city: func(slug, origin string) (*contentapi.CityPayload, error) {
if slug != testSlug {
return nil, notFound("Unknown city.")
}
return city, nil
},
cityMonth: func(slug, m string) (*contentapi.MonthPayload, error) {
if slug != testSlug || m != testMonth {
return nil, notFound("Unknown month.")
}
return month, nil
},
cityRecords: func(slug, origin string) (*contentapi.RecordsPayload, error) {
if slug != testSlug {
return nil, notFound("Unknown city.")
}
return records, nil
},
}
}
func testConfig() config.Config {
return config.Config{
APIBaseInternal: "http://backend.invalid",
APIVersion: "v2",
Base: "/thermograph", // matches THERMOGRAPH_BASE in the Python conftest
AssetBase: "/thermograph",
GoogleVerify: "",
BingVerify: "",
}
}
func newHandlers(t *testing.T, api API) *Handlers {
t.Helper()
cfg := testConfig()
engine, err := render.New(FuncMap(cfg))
if err != nil {
t.Fatalf("render.New: %v", err)
}
glossary, err := contentdata.LoadGlossary(contentDir)
if err != nil {
t.Fatalf("LoadGlossary: %v", err)
}
pages, err := contentdata.LoadPages(contentDir)
if err != nil {
t.Fatalf("LoadPages: %v", err)
}
h, err := New(cfg, api, engine, glossary, pages, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
return h
}
func newMux(t *testing.T, api API) *http.ServeMux {
t.Helper()
mux := http.NewServeMux()
static := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("STATIC")) // stands in for main.go's file server
})
h := newHandlers(t, api)
h.Register(mux, static)
// main.go ALSO registers a subtree static catch-all ("GET {base}/", via
// handlers.Server.Register) on the same mux — Register's own doc comment
// notes this. Only the LAZY IndexNow fallback claims the single-segment
// slot itself and forwards non-.txt requests to `static`; the EAGER path
// (key fetched successfully at boot) registers just the exact key-file
// route and relies on that separate subtree registration for everything
// else. Mirror both here, or the eager-route test exercises a mux with no
// handler for e.g. app.js at all — a gap in this test double, not in
// production routing.
mux.Handle("GET "+h.cfg.Base+"/", static)
return mux
}
func get(mux *http.ServeMux, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "http://testserver"+path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
// --- robots / sitemap --------------------------------------------------------
func TestRobotsTxt(t *testing.T) {
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/robots.txt")
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
want := "User-agent: *\n" +
"Allow: /\n" +
"Disallow: /thermograph/api/\n" +
"Disallow: /thermograph/alerts\n" +
"Sitemap: http://testserver/thermograph/sitemap.xml\n"
if rec.Body.String() != want {
t.Errorf("body:\n%s", rec.Body.String())
}
}
func TestSitemapListsCityURLs(t *testing.T) {
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/sitemap.xml")
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "<urlset") {
t.Error("missing <urlset")
}
for _, frag := range []string{
"/climate/" + testSlug + "</loc>",
"/climate/" + testSlug + "/" + testMonth + "</loc>",
"/climate/" + testSlug + "/records</loc>",
} {
if !strings.Contains(body, frag) {
t.Errorf("missing %q", frag)
}
}
if ct := rec.Header().Get("Content-Type"); ct != "application/xml" {
t.Errorf("Content-Type %q", ct)
}
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=300" {
t.Errorf("Cache-Control %q", cc)
}
// One full entry, byte-exact: loc + boot-date lastmod + changefreq + priority.
h := newHandlers(t, fixtureAPI(t))
wantURL := fmt.Sprintf("<url><loc>http://testserver/thermograph/</loc><lastmod>%s</lastmod>"+
"<changefreq>daily</changefreq><priority>1.0</priority></url>", h.bootDate)
if !strings.Contains(body, wantURL) {
t.Errorf("missing exact entry %q", wantURL)
}
}
// --- error mapping -----------------------------------------------------------
func TestUnknownCityAndMonthAre404(t *testing.T) {
mux := newMux(t, fixtureAPI(t))
for _, path := range []string{
"/thermograph/climate/nope-not-a-city",
"/thermograph/climate/" + testSlug + "/nonemonth",
"/thermograph/climate/nope-not-a-city/records",
} {
if rec := get(mux, path); rec.Code != 404 {
t.Errorf("%s -> %d, want 404", path, rec.Code)
}
}
}
func TestGlossaryUnknownTermIs404(t *testing.T) {
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/glossary/not-a-term")
if rec.Code != 404 {
t.Fatalf("status %d", rec.Code)
}
if got := rec.Body.String(); got != `{"detail":"Unknown term."}` {
t.Errorf("body %q", got)
}
}
// TestBackendUnreachableMapsTo503 is the port of the Python suite's
// backend-down resilience cases: a transport-level failure (no response at
// all) must become a clean 503, not a raw 500.
func TestBackendUnreachableMapsTo503(t *testing.T) {
api := fixtureAPI(t)
api.city = func(_, _ string) (*contentapi.CityPayload, error) { return nil, errUnreachable }
api.cityMonth = func(_, _ string) (*contentapi.MonthPayload, error) { return nil, errUnreachable }
api.cityRecords = func(_, _ string) (*contentapi.RecordsPayload, error) { return nil, errUnreachable }
api.hub = func() (*contentapi.HubPayload, error) { return nil, errUnreachable }
api.home = func() (*contentapi.HomePayload, error) { return nil, errUnreachable }
api.sitemap = func() ([]contentapi.SitemapEntry, error) { return nil, errUnreachable }
mux := newMux(t, api)
for _, path := range []string{
"/thermograph/climate/" + testSlug,
"/thermograph/climate/" + testSlug + "/july",
"/thermograph/climate/" + testSlug + "/records",
"/thermograph/climate",
"/thermograph/",
"/thermograph/sitemap.xml",
} {
rec := get(mux, path)
if rec.Code != 503 {
t.Errorf("%s -> %d, want 503", path, rec.Code)
}
if body := rec.Body.String(); body != `{"detail":"backend unavailable"}` {
t.Errorf("%s body %q", path, body)
}
}
}
// A backend 404/503 status passes straight through with its own detail text.
func TestBackendStatusPassesThrough(t *testing.T) {
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/climate/nope-not-a-city")
if rec.Code != 404 {
t.Fatalf("status %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Unknown city.") {
t.Errorf("detail not forwarded: %q", rec.Body.String())
}
}
// --- IndexNow ----------------------------------------------------------------
func TestIndexNowEagerRoute(t *testing.T) {
mux := newMux(t, fixtureAPI(t))
rec := get(mux, "/thermograph/"+fakeKey+".txt")
if rec.Code != 200 || rec.Body.String() != fakeKey+"\n" {
t.Errorf("eager key file: %d %q", rec.Code, rec.Body.String())
}
// With the eager route registered there is no {token} catch-all: other
// single-segment paths stay with the static file server.
if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" {
t.Errorf("static delegation broken: %q", rec.Body.String())
}
}
func TestIndexNowLazyFallback(t *testing.T) {
api := fixtureAPI(t)
down := true
api.indexNowKey = func() (string, error) {
if down {
return "", errUnreachable
}
return fakeKey, nil
}
mux := newMux(t, api) // Register's eager fetch fails -> lazy route
// Backend still down: the key file answers 503 (never a wrong/empty key).
if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 503 {
t.Errorf("while down: %d", rec.Code)
}
// Backend back up: the correct key serves with no frontend restart.
down = false
if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 200 || rec.Body.String() != fakeKey+"\n" {
t.Errorf("after recovery: %d %q", rec.Code, rec.Body.String())
}
// A wrong token is 404, and non-.txt single-segment paths fall through to
// the static handler the lazy route displaced.
if rec := get(mux, "/thermograph/wrongkey.txt"); rec.Code != 404 {
t.Errorf("wrong token: %d", rec.Code)
}
if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" {
t.Errorf("static delegation broken: %q", rec.Body.String())
}
// Explicit page routes still beat the {token} pattern on specificity.
if rec := get(mux, "/thermograph/robots.txt"); !strings.HasPrefix(rec.Body.String(), "User-agent:") {
t.Errorf("robots displaced by token route: %q", rec.Body.String())
}
}
// --- ctx builders ------------------------------------------------------------
func TestCityCtx(t *testing.T) {
h := newHandlers(t, fixtureAPI(t))
j := loadFixture[contentapi.CityPayload](t, "city")
ctx, unit, err := h.cityCtx(j)
if err != nil {
t.Fatal(err)
}
if unit != "C" { // GB city -> Celsius (test_city_page_renders_structure)
t.Errorf("unit %q", unit)
}
months := ctx["Months"].([]map[string]any)
if len(months) != 12 {
t.Fatalf("months: %d", len(months))
}
// January high 44.5°F -> 7°C, formatted as the converter span.
if got := months[0]["High"].(template.HTML); got != `<span class="temp" data-temp-f="44.5">7°C</span>` {
t.Errorf("january high: %q", got)
}
if bar := months[0]["Bar"]; bar == nil {
t.Error("january bar missing")
}
warmest := ctx["Warmest"].(map[string]any)
if warmest["Slug"] != "july" {
t.Errorf("warmest %v", warmest["Slug"])
}
if ctx["Coldest"].(map[string]any)["Slug"] != "february" ||
ctx["Wettest"].(map[string]any)["Slug"] != "january" {
t.Error("coldest/wettest lookup broken")
}
// Full href, base + "#lat,lon" — not just the bare "lat,lon" fragment, so
// the template never has to concatenate it (html/template's URL-context
// escaper would %-escape the comma if it did).
if ctx["ToolHref"] != "/thermograph/#51.50853,-0.12574" {
t.Errorf("ToolHref %v", ctx["ToolHref"])
}
if ctx["CompareURL"] != "/thermograph/compare#loc=51.5085,-0.1257" {
t.Errorf("CompareURL %v", ctx["CompareURL"])
}
// JSON-LD: compacted raw bytes, key order preserved (starts with @context).
jsonld := string(ctx["JSONLDStr"].(template.JS))
if !strings.HasPrefix(jsonld, `{"@context":"https://schema.org"`) {
t.Errorf("JSONLDStr prefix: %.60s", jsonld)
}
if !strings.Contains(jsonld, `"@type":"Dataset"`) {
t.Error("JSONLDStr lost the Dataset block") // test_city_page_renders_structure
}
if strings.Contains(jsonld, ": ") {
t.Error("JSONLDStr not compact")
}
// Today cards: pre-formatted value; percentile stays raw for |ordinal.
today := ctx["Today"].(map[string]any)
card := today["Cards"].([]map[string]any)[0]
if card["Value"].(template.HTML) != `<span class="temp" data-temp-f="79.4">26°C</span>` {
t.Errorf("today card value: %q", card["Value"])
}
if format.PctOrdinal(card["Percentile"]) != "89th" {
t.Errorf("card percentile ordinal: %v", card["Percentile"])
}
if ctx["Display"] != j.Display || ctx["Name"] != "London" {
t.Error("display/name broken")
}
// Breadcrumb: the raw []contentapi.Crumb straight off the payload (its own
// Name/Href fields already match what the "breadcrumb" template reads).
// Terminal crumb has nil Href ({{if $c.Href}} -> false).
bc := ctx["Breadcrumb"].([]contentapi.Crumb)
if len(bc) == 0 || bc[len(bc)-1].Href != nil {
t.Errorf("breadcrumb terminal href: %v", bc)
}
}
func TestMonthCtx(t *testing.T) {
h := newHandlers(t, fixtureAPI(t))
j := loadFixture[contentapi.MonthPayload](t, "month")
city := loadFixture[contentapi.CityPayload](t, "city").City
ctx, unit, err := h.monthCtx(j, city)
if err != nil {
t.Fatal(err)
}
if unit != "C" {
t.Errorf("unit %q", unit)
}
stats := ctx["Stats"].([]map[string]any)
labels := make([]string, len(stats))
for i, s := range stats {
labels[i] = s["Label"].(string)
}
want := []string{"Average high", "Typical high range", "Average low", "Typical low range", "Average daily precipitation"}
if strings.Join(labels, "|") != strings.Join(want, "|") {
t.Errorf("stat labels: %v", labels)
}
// "X to Y" concatenation: temp(lo) + " to " + temp(hi).
rng := string(stats[1]["Value"].(template.HTML))
if rng != `<span class="temp" data-temp-f="64.3">18°C</span> to <span class="temp" data-temp-f="79.8">27°C</span>` {
t.Errorf("typical high range: %q", rng)
}
// display falls back to the city name when the payload has none
// (j.get("display", city["name"])).
if j.Display == nil && ctx["Display"] != "London" {
t.Errorf("display fallback: %v", ctx["Display"])
}
// Record label -> metric dispatch: Humidity is a bare g/m³ figure.
recs := ctx["Records"].([]map[string]any)
var humid map[string]any
for _, r := range recs {
if r["Label"] == "Humidity" {
humid = r
}
}
if humid == nil || humid["High"].(template.HTML) != "16.0 g/m³" {
t.Errorf("humidity record: %v", humid)
}
if ctx["AvgHighCls"] != "warm" { // 71.1°F -> [70, 80)
t.Errorf("AvgHighCls %v", ctx["AvgHighCls"])
}
// Prev/Next: the raw contentapi.MonthLink (dereferenced), not a map — its
// own Name/Slug fields already match what the template reads.
if prev := ctx["Prev"].(contentapi.MonthLink); prev.Slug != "june" {
t.Errorf("prev %v", prev)
}
}
// --- fetchWithCity -----------------------------------------------------------
func TestFetchWithCityHappyPath(t *testing.T) {
api := &fakeAPI{
city: func(slug, _ string) (*contentapi.CityPayload, error) {
return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug, Name: "Testville"}}, nil
},
}
primary, city, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil })
if err != nil {
t.Fatal(err)
}
if primary != "primary-value" {
t.Errorf("primary = %q", primary)
}
if city.Slug != "testville" || city.Name != "Testville" {
t.Errorf("city = %+v", city)
}
}
// When only primary fails, its error must win even though City() also ran
// (and, here, succeeded) -- matching the old sequential code's behavior,
// where City() was never even called once primary had already failed.
func TestFetchWithCityPrimaryErrorWins(t *testing.T) {
primaryErr := notFound("no such month")
api := &fakeAPI{
city: func(slug, _ string) (*contentapi.CityPayload, error) {
return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug}}, nil
},
}
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr })
if err != primaryErr {
t.Errorf("err = %v, want the primary error", err)
}
}
// When primary succeeds but City() fails, City's error must surface -- same
// as the old sequential code (it called City() second and returned whatever
// that call did).
func TestFetchWithCityCityErrorSurfaces(t *testing.T) {
cityErr := notFound("no such city")
api := &fakeAPI{
city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr },
}
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil })
if err != cityErr {
t.Errorf("err = %v, want the city error", err)
}
}
// When BOTH fail, primary's error still wins -- the same priority as the
// single-failure case above, just with both goroutines erroring.
func TestFetchWithCityBothErrorPrimaryWins(t *testing.T) {
primaryErr, cityErr := notFound("primary"), notFound("city")
api := &fakeAPI{
city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr },
}
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr })
if err != primaryErr {
t.Errorf("err = %v, want the primary error (city error must not win)", err)
}
}
// Proves the two calls actually run CONCURRENTLY rather than sequentially --
// deterministically, via a rendezvous, not by timing (which would be flaky
// under CI load). Each fake blocks until BOTH have started; if fetchWithCity
// ran them sequentially, the second would never start until the first
// returned, and this would deadlock and fail on the test's own timeout.
func TestFetchWithCityRunsConcurrently(t *testing.T) {
started := make(chan struct{}, 2)
release := make(chan struct{})
rendezvous := func() {
started <- struct{}{}
<-release
}
api := &fakeAPI{
city: func(_, _ string) (*contentapi.CityPayload, error) {
rendezvous()
return &contentapi.CityPayload{}, nil
},
}
done := make(chan struct{})
go func() {
fetchWithCity(api, "testville", func() (string, error) {
rendezvous()
return "", nil
})
close(done)
}()
// Both goroutines must reach the rendezvous before either can proceed --
// only possible if they were launched concurrently.
for range 2 {
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for both calls to start -- they are not running concurrently")
}
}
close(release)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("fetchWithCity did not return after release")
}
}
func TestMonthCtxUnknownLabelIsError(t *testing.T) {
h := newHandlers(t, fixtureAPI(t))
j := loadFixture[contentapi.MonthPayload](t, "month")
city := loadFixture[contentapi.CityPayload](t, "city").City
j.Records[0].Label = "Bogus"
if _, _, err := h.monthCtx(j, city); err == nil {
t.Error("unknown record label must error (Python KeyError -> 500)")
}
}
func TestRecordsCtx(t *testing.T) {
h := newHandlers(t, fixtureAPI(t))
j := loadFixture[contentapi.RecordsPayload](t, "records")
city := loadFixture[contentapi.CityPayload](t, "city").City
ctx, unit, err := h.recordsCtx(j, city)
if err != nil {
t.Fatal(err)
}
if unit != "C" {
t.Errorf("unit %q", unit)
}
rows := ctx["Rows"].([]map[string]any)
var precip, high map[string]any
for _, r := range rows {
switch r["Label"] {
case "Precip":
precip = r
case "High":
high = r
}
}
// The precip low is the dry streak, not a formatted value.
if precip["Low"] != "33-day dry spell" {
t.Errorf("dry spell: %v", precip["Low"])
}
if precip["LowDate"] != "1995-07-31" {
t.Errorf("dry spell date: %v", precip["LowDate"])
}
// HighF/LowF only ride along for temperature metrics.
if precip["HighF"] != nil {
t.Errorf("precip HighF should be nil: %v", precip["HighF"])
}
if high["HighF"] == nil {
t.Error("temp HighF missing")
}
// Monthly extremes carry the {Txt, Cls, Date} shape the extremes() macro
// reads; 57.9°F sits in the [45, 58) "cool" tier — boundary-adjacent.
warm := ctx["Monthly"].([]map[string]any)[0]["High"].(map[string]any)["Warm"].(map[string]any)
if warm["Cls"] != "cool" || warm["Date"] != "2022-01-01" {
t.Errorf("monthly warm extreme: %v", warm)
}
if warm["Txt"].(template.HTML) != `<span class="temp" data-temp-f="57.9">14°C</span>` {
t.Errorf("monthly warm txt: %q", warm["Txt"])
}
if ctx["Hemisphere"] != j.Hemisphere {
t.Error("hemisphere lost")
}
// ToolHref: the full href, same composition as city/month.
if ctx["ToolHref"] != fmt.Sprintf("/thermograph/#%.5f,%.5f", city.Lat, city.Lon) {
t.Errorf("ToolHref %v", ctx["ToolHref"])
}
}
func TestHubCtx(t *testing.T) {
h := newHandlers(t, fixtureAPI(t))
hub := loadFixture[contentapi.HubPayload](t, "hub")
ctx := h.hubCtx(hub)
if ctx["NCities"] != 1000 || ctx["NCountries"] != 124 {
t.Errorf("counts: %v %v", ctx["NCities"], ctx["NCountries"])
}
// Groups is the raw []contentapi.HubCountry slice, preserving the
// backend's country order (the Python dict was insertion-ordered; a Go
// map would shuffle every render) — HubCountry/HubCity's own fields
// already match what the template reads, so no rebuilding at all.
groups := ctx["Groups"].([]contentapi.HubCountry)
if groups[0].Country != "Afghanistan" {
t.Errorf("group order lost: %v", groups[0].Country)
}
first := groups[0].Cities[0]
if first.Slug != "kabul-af" || first.Name != "Kabul" {
t.Errorf("city entry: %v", first)
}
if ctx["PageTitle"] == "" || ctx["CanonicalPath"] != "/climate" {
t.Error("hub meta broken")
}
}
func TestHomeCtxJSONLD(t *testing.T) {
h := newHandlers(t, fixtureAPI(t))
hp := loadFixture[contentapi.HomePayload](t, "home")
ctx, err := h.homeCtx(hp, "http://example.test")
if err != nil {
t.Fatal(err)
}
// Byte-exact against Python's json.dumps({**_HOME_JSONLD, "url": ...})
// (default separators — spaces preserved — and insertion order).
want := `{"@context": "https://schema.org", "@type": "WebApplication", "name": "Thermograph", ` +
`"applicationCategory": "WeatherApplication", "operatingSystem": "Web, iOS, Android", ` +
`"isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, ` +
`"description": "How unusual is your weather? Any day, anywhere on Earth, graded against 45 years ` +
`of that place's own history.", "url": "http://example.test/thermograph/"}`
if got := string(ctx["JSONLDStr"].(template.JS)); got != want {
t.Errorf("home jsonld:\n got %s\nwant %s", got, want)
}
if ctx["BrandTag"] != "p" || ctx["MainClass"] != "" || ctx["Section"] != "home" {
t.Error("home ctx flags broken")
}
// The captured fixture has no hero pick; nil must stay nil (template's
// {{if .Unusual}} falsiness), not a zero-valued struct.
if ctx["Unusual"] != nil {
t.Errorf("unusual: %v", ctx["Unusual"])
}
// Cities is the raw []contentapi.HomeCity slice (Slug/Name already match).
if len(ctx["Cities"].([]contentapi.HomeCity)) != 12 {
t.Error("city chips lost")
}
}
func TestHomeRankedPreservesLatLonText(t *testing.T) {
h := newHandlers(t, fixtureAPI(t))
v := 100.4
hp := &contentapi.HomePayload{Ranked: []contentapi.HomeRanked{{
Cls: "rec-hot", Display: "Testville", Lat: "51.5074", Lon: "-0.1000",
Value: &v, GradeLabel: "Near Record", MetricLabel: "High", PctLabel: "99",
}}}
ctx, err := h.homeCtx(hp, "http://x")
if err != nil {
t.Fatal(err)
}
// Ranked is the raw []contentapi.HomeRanked slice (Cls/Display/Lat/Lon/
// Value/... already match what the template reads).
r := ctx["Ranked"].([]contentapi.HomeRanked)[0]
// json.Number prints its raw text — "-0.1000" must not become "-0.1".
if fmt.Sprint(r.Lat) != "51.5074" || fmt.Sprint(r.Lon) != "-0.1000" {
t.Errorf("lat/lon text: %v %v", r.Lat, r.Lon)
}
if r.DateLabel != nil {
t.Errorf("DateLabel should be nil: %v", r.DateLabel)
}
}
func TestGlossaryCtx(t *testing.T) {
h := newHandlers(t, fixtureAPI(t))
first := h.glossary.Terms[0]
ctx, err := h.glossaryTermCtx(first.Slug, first, "http://testserver/thermograph")
if err != nil {
t.Fatal(err)
}
// {base} substitution happens per-request in the body, like the Python's
// _glossary_body.
body := string(ctx["Body"].(template.HTML))
if strings.Contains(body, "{base}") {
t.Error("body {base} placeholder not substituted")
}
if strings.Contains(first.Body, "{base}") && !strings.Contains(body, "/thermograph") {
t.Error("body {base} not replaced with the configured base")
}
if ctx["PageTitle"] != first.Term+": what it means | Thermograph" {
t.Errorf("PageTitle: %v", ctx["PageTitle"])
}
// Others is the raw []contentdata.GlossaryTerm slice (Slug/Term already
// match what the template reads).
others := ctx["Others"].([]contentdata.GlossaryTerm)
if len(others) != len(h.glossary.Terms)-1 {
t.Errorf("others: %d", len(others))
}
for _, o := range others {
if o.Slug == first.Slug {
t.Error("others must exclude the current term")
}
}
// JSONLDStr: a raw DefinedTerm object, not a JSON-encoded STRING
// containing one (the template.HTML-vs-template.JS bug this guards
// against would wrap the whole thing in quotes with escaped internals).
jsonld := ctx["JSONLDStr"]
js, ok := jsonld.(template.JS)
if !ok {
t.Fatalf("JSONLDStr type = %T, want template.JS", jsonld)
}
want := `{"@context":"https://schema.org","@type":"DefinedTerm","name":"` + first.Term +
`","description":"` + first.Short + `","inDefinedTermSet":"http://testserver/thermograph/glossary"}`
if string(js) != want {
t.Errorf("JSONLDStr:\n got %s\nwant %s", js, want)
}
}
// --- FuncMap helpers ---------------------------------------------------------
// html/template strips literal <!-- --> comments while parsing (verified
// directly: a template of only `<p>a</p><!-- x --><p>b</p>` renders as
// `<p>a</p><p>b</p>`) -- comment must wrap its output template.HTML to insert
// it as trusted content rather than markup, so a visible comment written in a
// .tmpl file actually survives into what ships. Checked both as a bare
// function call and end to end through a real html/template parse+execute,
// since the function alone proves nothing about whether html/template's
// parser then strips it back out.
func TestCommentSurvivesParsing(t *testing.T) {
fm := FuncMap(testConfig())
comment := fm["comment"].(func(string) template.HTML)
if got := comment("hello"); got != "<!-- hello -->" {
t.Errorf("comment(%q) = %q", "hello", got)
}
tmpl, err := template.New("t").Funcs(fm).Parse(`<p>a</p>{{comment "x"}}<p>b</p>`)
if err != nil {
t.Fatal(err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != `<p>a</p><!-- x --><p>b</p>` {
t.Errorf("comment through a real template = %q", got)
}
}
// html/template ALSO strips JavaScript `//` line comments from <script>
// bodies, independent of and in addition to the HTML-comment stripping
// TestCommentSurvivesParsing guards -- verified with zero {{ }} actions
// anywhere near the comment, so it isn't specific to how a value gets
// interpolated. jscomment needs template.JS, not template.HTML: inside
// <script>, html/template treats template.HTML as an untrusted value and
// re-escapes it as a quoted JS string (the same failure mode the JSONLDStr
// sites hit before being fixed to template.JS).
func TestJSCommentSurvivesParsing(t *testing.T) {
fm := FuncMap(testConfig())
jscomment := fm["jscomment"].(func(string) template.JS)
if got := jscomment("hello"); got != "// hello" {
t.Errorf("jscomment(%q) = %q", "hello", got)
}
tmpl, err := template.New("t").Funcs(fm).Parse("<script>\nvar x = 1;\n\n{{jscomment \"x\"}}\nvar y = 2;\n</script>")
if err != nil {
t.Fatal(err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatal(err)
}
want := "<script>\nvar x = 1;\n\n// x\nvar y = 2;\n</script>"
if got := buf.String(); got != want {
t.Errorf("jscomment through a real template:\n got %q\nwant %q", got, want)
}
}
func TestHeadVerifyHTML(t *testing.T) {
if headVerifyHTML("", "") != "" {
t.Error("no tokens -> empty")
}
got := headVerifyHTML("gtok", "btok")
want := "<meta name=\"google-site-verification\" content=\"gtok\">\n " +
"<meta name=\"msvalidate.01\" content=\"btok\">"
if string(got) != want {
t.Errorf("head_verify: %q", got)
}
// Attribute escaping matches markupsafe.
if !strings.Contains(string(headVerifyHTML(`a"b`, "")), "a&#34;b") {
t.Error("token not attribute-escaped")
}
}
func TestToJSON(t *testing.T) {
// u builds a backslash-u escape the way json.dumps/htmlsafe_json_dumps
// emit them (lowercase hex, four digits).
u := func(r rune) string { return fmt.Sprintf(`\u%04x`, r) }
cases := []struct {
in, want string
}{
{"Feels-like temperature", `"Feels-like temperature"`},
// Jinja's htmlsafe |tojson escapes the HTML-unsafe <, >, &, '.
{`a<b>&'c`, `"a` + u('<') + `b` + u('>') + u('&') + u('\'') + `c"`},
// ...and ensure_ascii turns non-ASCII into escapes.
{"50°F", `"50` + u('°') + `F"`},
{"±7", `"` + u('±') + `7"`},
}
for _, c := range cases {
got, err := toJSON(c.in)
if err != nil {
t.Fatal(err)
}
if string(got) != c.want {
t.Errorf("toJSON(%q) = %s, want %s", c.in, got, c.want)
}
}
}
func TestFuncMapHelpers(t *testing.T) {
fm := FuncMap(testConfig())
temp := fm["temp"].(func(any, any) template.HTML)
// Jinja's {{ temp(-10) }} literal (int) and a nullable payload pointer.
if got := temp("C", -10); got != `<span class="temp" data-temp-f="-10.0">-23°C</span>` {
t.Errorf("temp int literal: %q", got)
}
if got := temp("", (*float64)(nil)); got != "—" {
t.Errorf("temp nil pointer: %q", got)
}
tc := fm["temp_class"].(func(any) string)
if tc(nil) != "none" {
t.Error("temp_class nil")
}
ord := fm["ordinal"].(func(any) string)
if ord(89.1) != "89th" {
t.Error("ordinal")
}
}