// 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" "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, "", "/climate/" + testSlug + "/" + testMonth + "", "/climate/" + testSlug + "/records", } { 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("http://testserver/thermograph/%s"+ "daily1.0", 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 != `7°C` { 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) != `26°C` { 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 != `18°C to 27°C` { 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) } } 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) != `14°C` { 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 `

a

b

` renders as // `

a

b

`) -- 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 != "" { t.Errorf("comment(%q) = %q", "hello", got) } tmpl, err := template.New("t").Funcs(fm).Parse(`

a

{{comment "x"}}

b

`) 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 != `

a

b

` { t.Errorf("comment through a real template = %q", got) } } // html/template ALSO strips JavaScript `//` line comments from ") if err != nil { t.Fatal(err) } var buf strings.Builder if err := tmpl.Execute(&buf, nil); err != nil { t.Fatal(err) } want := "" 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 := "\n " + "" if string(got) != want { t.Errorf("head_verify: %q", got) } // Attribute escaping matches markupsafe. if !strings.Contains(string(headVerifyHTML(`a"b`, "")), "a"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&'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 != `-23°C` { 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") } }