1package chart23import (4 "bytes"5 "compress/zlib"6 "encoding/base64"7 "flag"8 "image"9 "image/color"10 "io"11 "math"12 "math/rand/v2"13 "os"14 "path/filepath"15 "regexp"16 "strings"17 "testing"1819 "src.rest/cat/012/internal/sheet"20)2122var update = flag.Bool("update", false, "rewrite text chart goldens in testdata")2324var nan = math.NaN()2526var budget = sheet.ChartData{27 Categories: []string{"Jan", "Feb", "Mar", "Apr"},28 Series: []sheet.ChartSeries{29 {Name: "Rent", Values: []float64{1450, 1450, 1500, 1500}},30 {Name: "Food", Values: []float64{600, nan, 612, 700}},31 },32 Format: sheet.Format{Kind: sheet.FmtCurrency},33}3435var single = sheet.ChartData{36 Categories: []string{"North", "South", "East", "West"},37 Series: []sheet.ChartSeries{{Name: "Sales", Values: []float64{120, 80, 45, 30}}},38}3940var mixed = sheet.ChartData{41 Categories: []string{"Q1", "Q2", "Q3", "Q4"},42 Series: []sheet.ChartSeries{{Name: "Profit", Values: []float64{0.12, -0.05, 0.08, 0.2}}},43 Format: sheet.Format{Kind: sheet.FmtPercent},44}4546// signs has positive and negative values in the same category.47var signs = sheet.ChartData{48 Categories: []string{"Q1", "Q2", "Q3"},49 Series: []sheet.ChartSeries{50 {Name: "Sales", Values: []float64{30, 40, 20}},51 {Name: "Costs", Values: []float64{-20, -10, -30}},52 {Name: "Other", Values: []float64{10, -5, 15}},53 },54}5556// xy is a scatter: X in the first series, two series of Y against it.57var xy = sheet.ChartData{58 Categories: []string{"1", "2", "3", "4", "5", "6"},59 Series: []sheet.ChartSeries{60 {Name: "Hours", Values: []float64{1, 2, 3, 5, 6, 8}},61 {Name: "Score", Values: []float64{52, 55, 61, 70, 71, 80}},62 {Name: "Retake", Values: []float64{40, nan, 50, 58, 66, 69}},63 },64}6566// growth spans several powers of ten, for log scales.67var growth = sheet.ChartData{68 Categories: []string{"Y1", "Y2", "Y3", "Y4", "Y5"},69 Series: []sheet.ChartSeries{{Name: "Users", Values: []float64{3, 40, 700, 9000, 0}}},70}7172// TestDrawGoldens renders every chart type as text; the goldens in73// testdata are the review surface for how charts look.74func TestDrawGoldens(t *testing.T) {75 cases := []struct {76 name string77 t sheet.ChartType78 d sheet.ChartData79 w, h int80 o sheet.ChartOptions81 }{82 {"column", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{}},83 {"column-single", sheet.ChartColumn, single, 40, 10, sheet.ChartOptions{}},84 {"column-negative", sheet.ChartColumn, mixed, 36, 10, sheet.ChartOptions{}},85 {"column-narrow", sheet.ChartColumn, budget, 22, 8, sheet.ChartOptions{}},86 {"bar", sheet.ChartBar, budget, 44, 12, sheet.ChartOptions{}},87 {"bar-single", sheet.ChartBar, single, 40, 8, sheet.ChartOptions{}},88 {"line", sheet.ChartLine, budget, 44, 12, sheet.ChartOptions{}},89 {"line-single", sheet.ChartLine, single, 40, 10, sheet.ChartOptions{}},90 {"pie", sheet.ChartPie, single, 40, 10, sheet.ChartOptions{}},91 {"empty", sheet.ChartColumn, sheet.ChartData{Categories: []string{"a"}, Series: []sheet.ChartSeries{{Values: []float64{nan}}}}, 30, 8, sheet.ChartOptions{}},92 {"pie-negative", sheet.ChartPie, sheet.ChartData{Categories: []string{"a"}, Series: []sheet.ChartSeries{{Values: []float64{-1}}}}, 30, 8, sheet.ChartOptions{}},93 {"pie-no-legend", sheet.ChartPie, single, 40, 10, sheet.ChartOptions{Legend: sheet.LegendNone}},94 {"area", sheet.ChartArea, budget, 44, 12, sheet.ChartOptions{}},95 {"area-stacked", sheet.ChartArea, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackNormal}},96 {"area-negative", sheet.ChartArea, mixed, 36, 10, sheet.ChartOptions{}},97 {"column-stacked", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackNormal}},98 {"column-percent", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackPercent}},99 {"column-stacked-negative", sheet.ChartColumn, signs, 40, 12, sheet.ChartOptions{Stack: sheet.StackNormal}},100 {"bar-stacked", sheet.ChartBar, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackNormal}},101 {"bar-percent", sheet.ChartBar, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackPercent}},102 {"scatter", sheet.ChartScatter, xy, 44, 12, sheet.ChartOptions{}},103 {"scatter-trend", sheet.ChartScatter, xy, 44, 12, sheet.ChartOptions{Trend: true}},104 {"scatter-single", sheet.ChartScatter, single, 40, 10, sheet.ChartOptions{}},105 {"legend-right", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{Legend: sheet.LegendRight}},106 {"legend-none", sheet.ChartLine, budget, 44, 12, sheet.ChartOptions{Legend: sheet.LegendNone}},107 {"no-gridlines", sheet.ChartColumn, single, 40, 10, sheet.ChartOptions{NoGrid: true}},108 {"axis-min-max", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{Min: 500, HasMin: true, Max: 1600, HasMax: true}},109 {"axis-min", sheet.ChartLine, budget, 44, 12, sheet.ChartOptions{Min: 400, HasMin: true}},110 {"axis-log", sheet.ChartColumn, growth, 40, 12, sheet.ChartOptions{Log: true}},111 {"bar-log", sheet.ChartBar, growth, 40, 10, sheet.ChartOptions{Log: true}},112 }113 for _, tc := range cases {114 t.Run(tc.name, func(t *testing.T) {115 got := Draw(tc.t, tc.d, tc.w, tc.h, Options{Chart: tc.o}).String() + "\n"116 path := filepath.Join("testdata", tc.name+".txt")117 if *update {118 os.MkdirAll("testdata", 0o755)119 os.WriteFile(path, []byte(got), 0o644)120 return121 }122 want, err := os.ReadFile(path)123 if err != nil {124 t.Fatalf("missing golden (go test ./internal/chart -update): %v", err)125 }126 if got != string(want) {127 t.Errorf("chart changed:\n%s\nwant:\n%s", got, want)128 }129 })130 }131}132133func TestScale(t *testing.T) {134 tests := []struct {135 lo, hi float64136 length int137 f sheet.Format138 wantLo, wantHi float64139 labels []string140 }{141 {0, 1500, 9, sheet.Format{Kind: sheet.FmtCurrency}, 0, 1500, []string{"$0", "$500", "$1,000", "$1,500"}},142 {0, 1, 10, sheet.Format{}, 0, 1, []string{"0.0", "0.2", "0.4", "0.6", "0.8", "1.0"}},143 {-0.05, 0.2, 7, sheet.Format{Kind: sheet.FmtPercent}, -0.1, 0.2, []string{"-10%", "0%", "10%", "20%"}},144 {0, 48000, 8, sheet.Format{}, 0, 60000, []string{"0K", "20K", "40K", "60K"}},145 {0, 0.75, 6, sheet.Format{}, 0, 0.75, []string{"0.00", "0.25", "0.50", "0.75"}},146 {5, 5, 4, sheet.Format{}, 0, 5, nil},147 }148 for _, tt := range tests {149 s := newScale(tt.lo, tt.hi, tt.length, 2, 6, tt.f)150 if s.lo != tt.wantLo || s.hi != tt.wantHi {151 t.Errorf("%v..%v: scale %v..%v, want %v..%v", tt.lo, tt.hi, s.lo, s.hi, tt.wantLo, tt.wantHi)152 }153 if s.cells() > tt.length || s.k < 2 {154 t.Errorf("%v..%v: %d intervals of %d cells in %d", tt.lo, tt.hi, s.n, s.k, tt.length)155 }156 if tt.labels == nil {157 continue158 }159 var got []string160 for j := 0; j <= s.n; j++ {161 got = append(got, s.label(s.tick(j)))162 }163 if strings.Join(got, " ") != strings.Join(tt.labels, " ") {164 t.Errorf("%v..%v: labels %q, want %q", tt.lo, tt.hi, got, tt.labels)165 }166 }167}168169func TestLayout(t *testing.T) {170 // Columns: every bar lies inside the plot and bars don't overlap.171 p := newColumnPlan(budget, 44, 12, columnBars, sheet.ChartOptions{})172 prev := 0173 for i := range 4 {174 for j := range 2 {175 x0, x1 := p.bar(i, j)176 if x0 < p.plot.Min.X || x1 > p.plot.Max.X || x0 < prev {177 t.Errorf("bar %d,%d at %d..%d overlaps or leaves %v", i, j, x0, x1, p.plot)178 }179 prev = x1180 }181 }182 // Ticks land on cell boundaries, the top one inside the plot.183 if top := p.sc.cells(); top > p.plot.Dy() || p.sc.pos(p.sc.hi) != float64(top) {184 t.Errorf("value axis %d cells in a %d row plot", top, p.plot.Dy())185 }186 // Too many categories: only what fits is drawn.187 many := sheet.ChartData{Series: []sheet.ChartSeries{{Name: "x"}}}188 for i := range 100 {189 many.Categories = append(many.Categories, "c")190 many.Series[0].Values = append(many.Series[0].Values, float64(i))191 }192 if p := newColumnPlan(many, 30, 10, columnBars, sheet.ChartOptions{}); p.shown != p.plot.Dx() {193 t.Errorf("shown %d categories in %d columns", p.shown, p.plot.Dx())194 }195 // The plot rectangle is what Image fills.196 for _, ty := range sheet.ChartTypes {197 o := Options{CellW: 8, CellH: 16}198 g := Draw(ty, budget, 44, 12, o)199 img := Image(ty, budget, 44, 12, o, testPalette)200 if img == nil || img.Bounds().Dx() != g.Plot.Dx()*8 || img.Bounds().Dy() != g.Plot.Dy()*16 {201 t.Errorf("%v: image for plot %v", ty, g.Plot)202 }203 }204}205206// TestTypes checks every chart type the editor offers has a layout.207func TestTypes(t *testing.T) {208 for _, ty := range sheet.ChartTypes {209 if types[ty].layout == nil {210 t.Errorf("%v has no layout", ty)211 }212 }213 if len(types) != len(sheet.ChartTypes) {214 t.Errorf("%d layouts for %d chart types", len(types), len(sheet.ChartTypes))215 }216}217218func TestImageLeavesPlotToImage(t *testing.T) {219 g := Draw(sheet.ChartColumn, budget, 44, 12, Options{Image: true})220 for y := g.Plot.Min.Y; y < g.Plot.Max.Y; y++ {221 for x := g.Plot.Min.X; x < g.Plot.Max.X; x++ {222 if c := g.At(x, y); c.Text != " " {223 t.Fatalf("plot cell %d,%d = %q with an image", x, y, c.Text)224 }225 }226 }227 if !strings.Contains(g.String(), "$1,500 ┤") || !strings.Contains(g.String(), "■ Rent") {228 t.Errorf("labels missing:\n%s", g)229 }230}231232var testPalette = Palette{233 Series: [Colors]color.RGBA{{255, 0, 0, 255}, {0, 0, 255, 255}, {0, 255, 0, 255}, {9, 9, 9, 255}, {8, 8, 8, 255}, {7, 7, 7, 255}},234 Grid: color.RGBA{128, 128, 128, 80},235}236237func TestImagePixels(t *testing.T) {238 o := Options{CellW: 8, CellH: 16}239 p := newColumnPlan(single, 40, 10, columnBars, sheet.ChartOptions{})240 img := Image(sheet.ChartColumn, single, 40, 10, o, testPalette)241 x0, x1 := p.bar(0, 0)242 mid := ((x0+x1)/2 - p.plot.Min.X) * 8243 bottom := img.Bounds().Dy() - 2244 if c := img.RGBAAt(mid, bottom); c != (color.RGBA{255, 0, 0, 255}) {245 t.Errorf("bar pixel %v", c)246 }247 if c := img.RGBAAt(mid, 1); c.A != 0 {248 t.Errorf("above the bar %v, want transparent", c)249 }250 // Pie: inside the first slice (top right) is its color.251 pie := Image(sheet.ChartPie, single, 40, 10, o, testPalette)252 b := pie.Bounds()253 if c := pie.RGBAAt(b.Dx()/2+b.Dx()/8, b.Dy()/2-b.Dy()/4); c != testPalette.Series[0] {254 t.Errorf("pie slice pixel %v", c)255 }256 if c := pie.RGBAAt(0, 0); c.A != 0 {257 t.Errorf("pie corner %v, want transparent", c)258 }259}260261func TestTransmit(t *testing.T) {262 img := image.NewRGBA(image.Rect(0, 0, 2, 1))263 img.Pix = []uint8{255, 0, 0, 255, 0, 0, 64, 128} // opaque red, half blue (premultiplied)264 got := Transmit(7, img, 3, 2, nil)265 re := regexp.MustCompile("^\x1b_Ga=T,q=2,f=32,o=z,s=2,v=1,i=7,U=1,c=3,r=2,m=0;([A-Za-z0-9+/=]+)\x1b\\\\$")266 m := re.FindStringSubmatch(got)267 if m == nil {268 t.Fatalf("transmit = %q", got)269 }270 z, _ := base64.StdEncoding.DecodeString(m[1])271 zr, err := zlib.NewReader(bytes.NewReader(z))272 if err != nil {273 t.Fatal(err)274 }275 raw, _ := io.ReadAll(zr)276 if want := []byte{255, 0, 0, 255, 0, 0, 127, 128}; !bytes.Equal(raw, want) {277 t.Errorf("pixels %v, want straight alpha %v", raw, want)278 }279280 // Big images go in chunks of at most 4096 bytes, each wrapped.281 noise := image.NewRGBA(image.Rect(0, 0, 200, 200))282 rng := rand.New(rand.NewPCG(1, 2))283 for i := range noise.Pix {284 noise.Pix[i] = uint8(rng.IntN(256))285 }286 wrapped := Transmit(9, noise, 20, 10, func(s string) string { return "<" + s + ">" })287 chunks := strings.Split(strings.TrimSuffix(strings.TrimPrefix(wrapped, "<"), ">"), "><")288 if len(chunks) < 2 {289 t.Fatalf("%d chunks", len(chunks))290 }291 for i, c := range chunks {292 head, body, _ := strings.Cut(strings.TrimPrefix(c, "\x1b_G"), ";")293 last := i == len(chunks)-1294 switch {295 case i == 0 && !strings.HasPrefix(head, "a=T,q=2,") || i > 0 && !strings.HasPrefix(head, "q=2,"):296 t.Errorf("chunk %d options %q", i, head)297 case last != strings.HasSuffix(head, "m=0"):298 t.Errorf("chunk %d of %d: %q", i, len(chunks), head)299 case len(strings.TrimSuffix(body, "\x1b\\")) > 4096:300 t.Errorf("chunk %d is %d bytes", i, len(body))301 }302 }303}304305func TestPlaceholderAndDelete(t *testing.T) {306 if got := Placeholder(0, 2); got != "\U0010EEEE̅̎" {307 t.Errorf("placeholder %q", got)308 }309 if got := Delete(7, nil); got != "\x1b_Ga=d,d=I,i=7,q=2\x1b\\" {310 t.Errorf("delete %q", got)311 }312 if got := Query(); got != "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" {313 t.Errorf("query %q", got)314 }315}316317// Text bars start on the axis line, in the middle of the axis row or318// column, and a value on a tick ends on the tick's line: columns up319// and down from zero, bars to the right.320func TestBarsMeetTheAxis(t *testing.T) {321 ticks := sheet.ChartData{322 Categories: []string{"a", "b"},323 Series: []sheet.ChartSeries{{Name: "v", Values: []float64{100, -100}}},324 }325 col := Draw(sheet.ChartColumn, ticks, 30, 10, Options{})326 p, _ := planFor(sheet.ChartColumn, ticks, 30, 10, Options{})327 cp := p.(*columnPlan)328 up, _ := cp.bar(0, 0)329 down, _ := cp.bar(1, 0)330 zero := cp.axisRow - int(cp.sc.pos(0))/cp.sc.k*cp.sc.k // the zero tick's row331 top := cp.axisRow - cp.sc.cells() // the 100 tick's row332 for _, c := range []struct {333 x, y int334 want string335 }{336 {up, zero, "▀"}, {up, zero - 1, "█"}, {up, top, "▄"},337 {down, zero, "▄"}, {down, cp.axisRow, "▀"},338 } {339 if got := col.At(c.x, c.y).Text; got != c.want {340 t.Errorf("column cell %d,%d = %q, want %q\n%s", c.x, c.y, got, c.want, col)341 }342 }343 bar := Draw(sheet.ChartBar, single, 40, 10, Options{})344 bp, _ := planFor(sheet.ChartBar, single, 40, 10, Options{})345 if got := bar.At(bp.(*barPlan).axisX, 0).Text; got != "▐" {346 t.Errorf("bar at the axis = %q, want ▐\n%s", got, bar)347 }348}