package render import ( "encoding/xml" "math" "regexp" "strconv" "strings" "testing" "time" "github.com/lukaszkasprzak/prognosis/internal/openmeteo" ) // svgView builds hours starting at 12:00, so a 24-hour view crosses midnight. func svgView(hours int) View { v := View{Label: "Krakow, PL", Sun: map[string][2]string{}} start := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) for i := 0; i < hours; i++ { when := start.Add(time.Duration(i) * time.Hour) v.Rows = append(v.Rows, openmeteo.Row{ When: when, Code: 3, Vals: map[string]float64{ "temperature_2m": 15 + 6*math.Sin(float64(i)/4), "apparent_temperature": 14 + 6*math.Sin(float64(i)/4), "precipitation": float64(i%7) * 0.1, "precipitation_probability": float64(i % 100), "relative_humidity_2m": 50 + float64(i%40), }, }) v.Sun[when.Format("2006-01-02")] = [2]string{"05:47", "19:35"} } return v } func svgOf(t *testing.T, hours int) string { t.Helper() out := SVG(svgView(hours), testConfig()) if out == "" { t.Fatal("SVG produced nothing") } return out } func TestSVGIsWellFormedXML(t *testing.T) { out := svgOf(t, 24) if err := xml.Unmarshal([]byte(out), new(any)); err != nil { t.Fatalf("not well-formed XML: %v", err) } if !strings.HasPrefix(out, "` out := SVG(v, testConfig()) if strings.Contains(out, "") || strings.Contains(out, "& ") { t.Errorf("label was not escaped:\n%s", out) } if err := xml.Unmarshal([]byte(out), new(any)); err != nil { t.Fatalf("unescaped label broke the XML: %v", err) } } func TestSVGEmptyViewProducesNothing(t *testing.T) { if got := SVG(View{}, testConfig()); got != "" { t.Fatalf("expected empty output for no rows, got %d bytes", len(got)) } } // One point per hour, on every series. func TestSVGSeriesHaveAPointPerHour(t *testing.T) { const hours = 18 out := svgOf(t, hours) polylines := regexp.MustCompile(`temp<") == 0 || strings.Count(out, ">feels<") == 0 { t.Errorf("the dashed series is unexplained without a legend:\n%s", out) } } func TestSVGDrawsDayBoundaries(t *testing.T) { crossing := svgOf(t, 30) if !strings.Contains(crossing, `stroke="#9aa3ad"`) { t.Error("no midnight rule drawn on a view that crosses midnight") } if strings.Contains(svgOf(t, 6), `stroke="#9aa3ad"`) { t.Error("midnight rule drawn on a six-hour view that never reaches midnight") } } // Axis labels must land on round numbers, and the count must follow the step. func TestNiceTicks(t *testing.T) { for _, c := range []struct{ lo, hi float64 }{{9.4, 22.6}, {0, 1}, {-4.2, 3.1}, {990, 1030}} { lo, hi, lines := niceTicks(c.lo, c.hi, 6) if lo > c.lo || hi < c.hi { t.Errorf("niceTicks(%v,%v) = %v,%v — must contain the range", c.lo, c.hi, lo, hi) } if lines < 2 || lines > 8 { t.Errorf("niceTicks(%v,%v) wants %d lines", c.lo, c.hi, lines) } step := (hi - lo) / float64(lines-1) for i := 0; i < lines; i++ { v := lo + step*float64(i) if math.Abs(v-math.Round(v*100)/100) > 1e-9 { t.Errorf("tick %v is not a round value", v) } } } } // The meteogram needs its own fields whatever the table shows. func TestSVGFieldsCoverEveryPanel(t *testing.T) { for _, f := range []string{ "temperature_2m", "apparent_temperature", "precipitation", "precipitation_probability", "relative_humidity_2m", } { found := false for _, have := range SVGFields { if have == f { found = true } } if !found { t.Errorf("SVGFields is missing %q, so that panel would be empty", f) } } }