summaryrefslogtreecommitdiff
path: root/internal/render/svg_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/render/svg_test.go')
-rw-r--r--internal/render/svg_test.go215
1 files changed, 215 insertions, 0 deletions
diff --git a/internal/render/svg_test.go b/internal/render/svg_test.go
new file mode 100644
index 0000000..075cb72
--- /dev/null
+++ b/internal/render/svg_test.go
@@ -0,0 +1,215 @@
+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, "<svg xmlns=") {
+ t.Error("missing the SVG namespace, so viewers will refuse it")
+ }
+}
+
+// A place name is user input and can carry characters XML reserves.
+func TestSVGEscapesTheLabel(t *testing.T) {
+ v := svgView(6)
+ v.Label = `Foo & <Bar>`
+ out := SVG(v, testConfig())
+ if strings.Contains(out, "<Bar>") || 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(`<polyline points="([^"]+)"`).FindAllStringSubmatch(out, -1)
+ if len(polylines) < 4 {
+ t.Fatalf("expected at least four series, found %d", len(polylines))
+ }
+ for i, m := range polylines {
+ if n := len(strings.Fields(m[1])); n != hours {
+ t.Errorf("series %d has %d points, want %d", i, n, hours)
+ }
+ }
+}
+
+// Night shading is what makes a meteogram readable; without sun data there must
+// be none rather than a guess.
+func TestSVGNightBandsFollowSunData(t *testing.T) {
+ withSun := svgOf(t, 24)
+ if !strings.Contains(withSun, `fill="#eef1f6"`) {
+ t.Error("no night bands drawn despite sunrise/sunset being known")
+ }
+ v := svgView(24)
+ v.Sun = map[string][2]string{}
+ if strings.Contains(SVG(v, testConfig()), `fill="#eef1f6"`) {
+ t.Error("night bands drawn without sun data; that is a guess")
+ }
+}
+
+func TestIsNight(t *testing.T) {
+ sun := map[string][2]string{"2026-08-27": {"05:47", "19:35"}}
+ at := func(h int) time.Time { return time.Date(2026, 8, 27, h, 0, 0, 0, time.UTC) }
+ for _, c := range []struct {
+ hour int
+ want bool
+ }{{0, true}, {5, true}, {6, false}, {12, false}, {19, false}, {20, true}, {23, true}} {
+ if got := isNight(at(c.hour), sun); got != c.want {
+ t.Errorf("%02d:00 night=%v, want %v", c.hour, got, c.want)
+ }
+ }
+ // An unknown date must not be shaded on a guess.
+ if isNight(time.Date(2030, 1, 1, 3, 0, 0, 0, time.UTC), sun) {
+ t.Error("shaded a date with no sun data")
+ }
+}
+
+// The rain panel carries millimetres and percent. Without the right-hand axis
+// the dashed probability line is read against the millimetre scale.
+func TestSVGRainPanelHasBothAxes(t *testing.T) {
+ out := svgOf(t, 24)
+ if !strings.Contains(out, "100%") || !strings.Contains(out, "50%") {
+ t.Errorf("no percentage axis for the probability line:\n%s", out)
+ }
+}
+
+func TestSVGDatesAreBoldAndHoursDoNotCollide(t *testing.T) {
+ out := svgOf(t, 30) // crosses midnight
+ if !strings.Contains(out, `font-weight="bold"`) {
+ t.Error("date labels are not bold")
+ }
+
+ // Collect the x of every axis label, and check none sits on top of a date.
+ type label struct {
+ x float64
+ bold bool
+ }
+ var labels []label
+ re := regexp.MustCompile(`<text x="([0-9.]+)" y="\d+" text-anchor="middle"( font-weight="bold")?`)
+ for _, m := range re.FindAllStringSubmatch(out, -1) {
+ x, _ := strconv.ParseFloat(m[1], 64)
+ labels = append(labels, label{x: x, bold: m[2] != ""})
+ }
+ if len(labels) < 4 {
+ t.Fatalf("expected several axis labels, found %d", len(labels))
+ }
+ for _, a := range labels {
+ if !a.bold {
+ continue
+ }
+ for _, b := range labels {
+ if !b.bold && math.Abs(a.x-b.x) < 20 {
+ t.Errorf("an hour label at x=%.0f collides with the date at x=%.0f", b.x, a.x)
+ }
+ }
+ }
+}
+
+func TestSVGHasALegendForTheTwoTemperatureSeries(t *testing.T) {
+ out := svgOf(t, 12)
+ if strings.Count(out, ">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)
+ }
+ }
+}