Files
tensor/plot/plot_test.go
T

218 lines
6.6 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package plot
import (
"math"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
func sampleChart() Chart {
pts1 := make([]Point, 0, 50)
pts2 := make([]Point, 0, 50)
for i := range 50 {
x := float64(i) / 49
pts1 = append(pts1, Point{X: x, Y: x * x})
pts2 = append(pts2, Point{X: x, Y: math.Sqrt(x)})
}
return Chart{
Title: "Test <chart>",
XLabel: "x", YLabel: "y",
Series: []Series{{Name: "quadratic", Points: pts1}, {Name: "sqrt", Points: pts2}},
}
}
func TestWriteSVG(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "out.svg")
if err := sampleChart().WriteSVG(path); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
s := string(body)
if !strings.HasPrefix(s, "<?xml") {
t.Fatal("the SVG lacks the XML declaration")
}
if got := strings.Count(s, "<polyline"); got != 2 {
t.Fatalf("%d polylines, want 2", got)
}
if strings.Count(s, "&lt;") != 1 {
t.Fatal("the title was not escaped")
}
// Determinism: the same chart renders byte for byte the same file.
path2 := filepath.Join(dir, "out2.svg")
if err := sampleChart().WriteSVG(path2); err != nil {
t.Fatal(err)
}
again, err := os.ReadFile(path2)
if err != nil {
t.Fatal(err)
}
if string(body) != string(again) {
t.Fatal("the rendering is not deterministic")
}
}
func TestWriteSVGErrors(t *testing.T) {
dir := t.TempDir()
one := Chart{Series: []Series{{Name: "only", Points: []Point{{X: 0, Y: 0}, {X: 1, Y: 1}}}}}
one.Series[0].Points = one.Series[0].Points[:1]
if err := one.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
t.Fatal("a single point must fail")
}
empty := Chart{Series: []Series{{Name: "none"}}}
if err := empty.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
t.Fatal("an empty series must fail")
}
// A series built by hand is the caller's own: the constructor's
// non-finite refusal still has to hold at the rendering door, or
// the deterministic contract would publish a polyline carrying the
// literal NaN no renderer draws.
for _, pt := range []Point{{X: math.NaN(), Y: 0}, {X: 0, Y: math.Inf(1)}, {X: math.Inf(-1), Y: 1}} {
nan := Chart{Series: []Series{{Name: "broken", Points: []Point{{X: 0, Y: 0}, pt, {X: 2, Y: 1}}}}}
if err := nan.WriteSVG(filepath.Join(dir, "bad.svg")); err == nil {
t.Fatalf("the non-finite point %+v was rendered", pt)
}
}
// An axis range with an infinite or unordered span falls back to
// the data's own bounds instead of mapping every point through a
// division the format cannot evaluate.
for _, r := range [][2]float64{{0, math.Inf(1)}, {math.Inf(-1), 0}, {2, 1}, {math.NaN(), 1}} {
c := sampleChart()
c.YRange = r
path := filepath.Join(dir, "range.svg")
if err := c.WriteSVG(path); err != nil {
t.Fatalf("the range %v was refused instead of falling back: %v", r, err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if s := string(body); strings.Contains(s, "NaN") || strings.Contains(s, "Inf") {
t.Fatalf("the range %v leaked a non-finite coordinate into the rendering", r)
}
}
}
// TestWriteSVGExtremeFiniteValuesStayFinite pins the renderer against
// the extreme-but-finite corner: data, or an explicit axis range, whose
// span overflows float64 must not leak NaN or Inf coordinates into the
// drawing, because no renderer displays them and the non-finite
// refusal at the door guarantees every input point is finite.
func TestWriteSVGExtremeFiniteValuesStayFinite(t *testing.T) {
dir := t.TempDir()
padded := sampleChart()
padded.XRange = [2]float64{-1e308, 1e308}
padded.YRange = [2]float64{-1e308, 1e308}
charts := []struct {
name string
chart Chart
}{
{"wide-data.svg", Chart{Series: []Series{{Name: "wide",
Points: []Point{{X: 0, Y: -1e308}, {X: 1, Y: 1e308}}}}}},
{"wide-range.svg", padded},
}
for _, tc := range charts {
path := filepath.Join(dir, tc.name)
if err := tc.chart.WriteSVG(path); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if s := string(body); strings.Contains(s, "NaN") || strings.Contains(s, "Inf") {
t.Fatalf("%s: the rendering leaked a non-finite coordinate: %s", tc.name, s)
}
}
}
2026-09-03 10:00:00 +02:00
func mustFromFloats(t *testing.T, values []float64, shape ...int) *core.Array {
t.Helper()
a, err := core.FromFloats(values, shape...)
if err != nil {
t.Fatal(err)
}
return a
}
func TestLine(t *testing.T) {
xs := mustFromFloats(t, []float64{0, 1, 2, 3}, 4)
ys := mustFromFloats(t, []float64{0, 1, 4, 9}, 4)
s, err := Line("quadratic", xs, ys)
if err != nil {
t.Fatal(err)
}
if s.Name != "quadratic" || len(s.Points) != 4 || s.Points[3] != (Point{X: 3, Y: 9}) {
t.Fatalf("the series is wrong: %+v", s)
}
}
func TestLineValidation(t *testing.T) {
xs := mustFromFloats(t, []float64{0, 1, 2}, 3)
ys := mustFromFloats(t, []float64{0, 1, 4}, 3)
if _, err := Line("mismatch", xs, mustFromFloats(t, []float64{0, 1}, 2)); err == nil {
t.Fatal("a length mismatch must fail")
}
if _, err := Line("matrix", mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2), ys); err == nil {
t.Fatal("a rank-2 array must fail")
}
if _, err := Line("empty", mustFromFloats(t, nil, 0), mustFromFloats(t, nil, 0)); err == nil {
t.Fatal("an empty array must fail")
}
if _, err := Line("nil", nil, ys); err == nil {
t.Fatal("a nil array must fail")
}
if _, err := Line("nan", xs, mustFromFloats(t, []float64{0, 1, math.NaN()}, 3)); err == nil {
t.Fatal("a non-finite point must fail")
}
}
func TestTick(t *testing.T) {
if tick(2) != "2" {
t.Fatalf("tick(2) = %q", tick(2))
}
if tick(0.25) != "0.25" {
t.Fatalf("tick(0.25) = %q", tick(0.25))
}
if tick(1e-7) != "1e-07" {
t.Fatalf("tick(1e-7) = %q", tick(1e-7))
}
}
// TestSeriesColourCycle pins the colour cycle element for element: the
// seven Viridis samples are part of the deterministic output, so an
// accidental edit must fall over here, and the cycle must wrap at the
// palette's own length.
func TestSeriesColourCycle(t *testing.T) {
want := [7]string{
"#440154",
"#482a79",
"#3d4d8a",
"#2f6c8e",
"#23888e",
"#20a486",
"#43bf71",
}
if seriesColours != want {
t.Fatalf("series colours = %v, want %v", seriesColours, want)
}
for i := range len(seriesColours) {
if colour(i) != seriesColours[i] {
t.Fatalf("colour(%d) = %s, want %s", i, colour(i), seriesColours[i])
}
if colour(i+len(seriesColours)) != seriesColours[i] {
t.Fatalf("colour(%d) does not wrap onto colour(%d)", i+len(seriesColours), i)
}
}
}