185 lines
5.4 KiB
Go
185 lines
5.4 KiB
Go
// 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, "<") != 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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|