Bars, lines, areas, pies, donuts, radar, histogram, scatter, bubbles, heatmaps, gauge, bullet, funnel, waterfall, treemap, candles, box plots, steps, rings, legend, axis, crosshair, polar area, pyramid, spectrum bars and win-loss strips, each drawn dot by dot. Ticks follow 1-2-5 steps and print as precisely as their step needs; a label row uses micro letters or the pixel font as a whole. Descriptions carry the data in numbers. Bad data panics.
218 lines
9.2 KiB
Go
218 lines
9.2 KiB
Go
package chart_test
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/egoist/mygo/ui"
|
||
|
||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart"
|
||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||
)
|
||
|
||
var zhDays = []string{"周一", "周二", "周三"}
|
||
|
||
func TestFontLabelsClearTheAxis(t *testing.T) {
|
||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||
chart.BarChart(c, "金币", zhDays, []float64{3, 9, 4}, chart.BarChartOptions{})
|
||
})
|
||
img := tt.Image()
|
||
// With 15-row font labels the axis is row 63.
|
||
if n := count(t, img, 10, 63, 149, 1, pixel.Edge); n != 149 {
|
||
t.Fatalf("the axis row holds %d Edge dots of 149", n)
|
||
}
|
||
if count(t, img, 0, 64, 160, 16, pixel.Faint) == 0 || count(t, img, 11, 3, 148, 60, pixel.Faint) != 0 {
|
||
t.Fatal("labels are not all below the axis")
|
||
}
|
||
}
|
||
|
||
func TestHeatmapAndCrosshairGrowForFontLabels(t *testing.T) {
|
||
tt := view(400, 300, newClock(), func(c *ui.Context) {
|
||
chart.Heatmap(c, "热", []string{"周一", "周二"}, []string{"早", "晚上"}, [][]float64{{1, 2}, {3, 4}}, chart.HeatmapOptions{})
|
||
chart.Crosshair(c, "血", zhDays, []chart.Series{{Name: "A", Values: []float64{1, 2, 3}}, {Name: "B", Values: []float64{3, 2, 1}}}, chart.CrosshairOptions{})
|
||
})
|
||
// Rows are 14 dots under a 15-dot label head.
|
||
if r := find(t, tt, "热"); r.H != 2*(15+2*14) {
|
||
t.Fatalf("heatmap is %v px tall", r.H)
|
||
}
|
||
r := find(t, tt, "血")
|
||
tt.Move(r.X+2*80, r.Y+2*30)
|
||
// The readout holds a 14-row label and two 7-row values.
|
||
x, last := int(r.X)/2+95, -1
|
||
top, _ := column(t, tt.Image(), x, 150, pixel.Lit)
|
||
for y := range 150 {
|
||
if at(t, tt.Image(), x, y) == pixel.Lit {
|
||
last = y
|
||
}
|
||
}
|
||
if top < 0 || last-top+1 < 3+14+2*7-2 {
|
||
t.Fatalf("readout spans rows %d–%d", top, last)
|
||
}
|
||
}
|
||
|
||
func TestTreemapSkipsEmptySlices(t *testing.T) {
|
||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||
chart.Treemap(c, "Bag", []chart.Slice{{Name: "a", Value: 1, Slot: pixel.Info}, {Name: "none", Slot: pixel.Magic},
|
||
{Name: "b", Value: 3, Slot: pixel.Warn}, {Name: "zero", Slot: pixel.Magic}}, chart.TreemapOptions{})
|
||
})
|
||
img := tt.Image()
|
||
a, none, b := count(t, img, 0, 0, 160, 80, pixel.Info), count(t, img, 0, 0, 160, 80, pixel.Magic), count(t, img, 0, 0, 160, 80, pixel.Warn)
|
||
if none != 0 || a == 0 || b < 3*a-200 || b > 3*a+200 {
|
||
t.Fatalf("dots: a %d, empty %d, b %d", a, none, b)
|
||
}
|
||
}
|
||
|
||
func TestChartsRejectBadData(t *testing.T) {
|
||
nan := math.NaN()
|
||
for name, build := range map[string]func(c *ui.Context){
|
||
"a widening funnel": func(c *ui.Context) {
|
||
chart.Funnel(c, "x", []chart.Slice{{Name: "A", Value: 9}, {Name: "B", Value: 3}, {Name: "C", Value: 5}}, chart.FunnelOptions{})
|
||
},
|
||
"a NaN funnel stage": func(c *ui.Context) {
|
||
chart.Funnel(c, "x", []chart.Slice{{Name: "A", Value: nan}}, chart.FunnelOptions{})
|
||
},
|
||
"no stacked labels": func(c *ui.Context) { chart.StackedBar(c, "x", nil, nil, chart.StackedBarOptions{}) },
|
||
"no box groups": func(c *ui.Context) { chart.BoxPlot(c, "x", nil, chart.BoxPlotOptions{}) },
|
||
"an axis of nothing": func(c *ui.Context) { chart.Axis(c, chart.AxisOptions{Min: 5, Max: 5}) },
|
||
"a NaN bullet": func(c *ui.Context) { chart.BulletChart(c, "x", nan, 1, []float64{2}, chart.BulletChartOptions{}) },
|
||
"a NaN bubble": func(c *ui.Context) {
|
||
chart.BubbleChart(c, "x", []chart.Point{{X: 1, Y: 1, Size: nan}}, chart.BubbleChartOptions{})
|
||
},
|
||
"a NaN candle": func(c *ui.Context) {
|
||
chart.Candlestick(c, "x", []chart.Candle{{Label: "A", Open: nan, High: 2, Low: 0, Close: 1}}, chart.CandlestickOptions{})
|
||
},
|
||
"a negative radar": func(c *ui.Context) {
|
||
chart.RadarChart(c, "x", []string{"A", "B", "C"}, []float64{1, -2, 3}, chart.RadarChartOptions{})
|
||
},
|
||
"a NaN radar Max": func(c *ui.Context) {
|
||
chart.RadarChart(c, "x", []string{"A", "B", "C"}, []float64{1, 2, 3}, chart.RadarChartOptions{Max: nan})
|
||
},
|
||
"a negative radar Max": func(c *ui.Context) {
|
||
chart.RadarChart(c, "x", []string{"A", "B", "C"}, []float64{1, 2, 3}, chart.RadarChartOptions{Max: -1})
|
||
},
|
||
"bars with no room": func(c *ui.Context) {
|
||
chart.BarChart(c, "x", []string{"A"}, []float64{1}, chart.BarChartOptions{Width: 10})
|
||
},
|
||
"bubbles with no room": func(c *ui.Context) {
|
||
chart.BubbleChart(c, "x", []chart.Point{{Size: 1}}, chart.BubbleChartOptions{Height: 20})
|
||
},
|
||
"an endless gauge": func(c *ui.Context) {
|
||
chart.Gauge(c, "x", math.Inf(1), chart.GaugeOptions{Max: math.Inf(1)})
|
||
},
|
||
} {
|
||
panics(t, name, func() { view(400, 200, newClock(), build) })
|
||
}
|
||
}
|
||
|
||
func TestLabelsStayInsideTheChart(t *testing.T) {
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: newClock().now, Locale: pixel.Zh})
|
||
ui.Column(c).Children(func() {
|
||
chart.ScatterPlot(c, "Hits", []chart.Point{{X: 100, Y: 1}, {X: 900, Y: 9}}, chart.ScatterPlotOptions{})
|
||
chart.CalendarHeatmap(c, "Days", 2026, func(time.Time) int { return 0 }, chart.CalendarHeatmapOptions{})
|
||
chart.BarChart(c, "Days", []string{"A", "WEDNESDAY"}, []float64{1, 2}, chart.BarChartOptions{Width: 60})
|
||
})
|
||
}, 800, 600)
|
||
img := tt.Image()
|
||
if n := count(t, img, 160, 0, 240, 80, pixel.Faint); n != 0 {
|
||
t.Fatalf("%d scatter label dots past the right edge", n)
|
||
}
|
||
cal := find(t, tt, "Days 2026")
|
||
// The year spans 53 weeks of 5 dots.
|
||
if n := count(t, img, 265, int(cal.Y)/2, 135, 20, pixel.Faint); n != 0 {
|
||
t.Fatalf("%d month label dots past the right edge", n)
|
||
}
|
||
bars := find(t, tt, "Days")
|
||
if n := count(t, img, 60, int(bars.Y)/2, 340, 80, pixel.Faint); n != 0 {
|
||
t.Fatalf("%d bar label dots past the right edge", n)
|
||
}
|
||
}
|
||
|
||
func TestBulletTargetAtTheEndStaysInside(t *testing.T) {
|
||
tt := view(400, 100, newClock(), func(c *ui.Context) {
|
||
ui.Row(c).Children(func() {
|
||
chart.BulletChart(c, "Goal", 50, 100, []float64{100}, chart.BulletChartOptions{Width: 100})
|
||
})
|
||
})
|
||
if n := count(t, tt.Image(), 100, 0, 100, 14, pixel.Accent); n != 0 {
|
||
t.Fatalf("%d target dots past the right edge", n)
|
||
}
|
||
if n := count(t, tt.Image(), 98, 2, 2, 10, pixel.Accent); n != 20 {
|
||
t.Fatalf("the end target holds %d dots of 20", n)
|
||
}
|
||
}
|
||
|
||
func TestTinyRangesKeepTheirShape(t *testing.T) {
|
||
for _, scale := range []float64{1, 1e-5} {
|
||
tt := view(400, 400, newClock(), func(c *ui.Context) {
|
||
chart.ScatterPlot(c, "Hits", []chart.Point{{X: 1 * scale, Y: 1 * scale}, {X: 9 * scale, Y: 9 * scale}}, chart.ScatterPlotOptions{Slot: pixel.Magic})
|
||
chart.Histogram(c, "Rolls", []float64{1 * scale, 1 * scale, 1 * scale, 2 * scale, 2 * scale, 9 * scale}, chart.HistogramOptions{Bins: 2, Slot: pixel.Info})
|
||
})
|
||
img := tt.Image()
|
||
if n := count(t, img, 0, 0, 160, 80, pixel.Magic); n != 8 {
|
||
t.Fatalf("scale %v: two points drew %d dots", scale, n)
|
||
}
|
||
_, low := column(t, img, 40, 160, pixel.Info)
|
||
_, high := column(t, img, 120, 160, pixel.Info)
|
||
if high == 0 || low < 4*high-6 {
|
||
t.Fatalf("scale %v: bins of 5 and 1 stand %d and %d dots", scale, low, high)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestZeroRadarKeepsItsWeb(t *testing.T) {
|
||
tt := view(200, 200, newClock(), func(c *ui.Context) {
|
||
chart.RadarChart(c, "Newbie", []string{"STR", "DEX", "INT", "VIT", "AGI", "LUK"}, make([]float64, 6), chart.RadarChartOptions{})
|
||
})
|
||
if n := count(t, tt.Image(), 0, 0, 80, 80, pixel.Shade); n < 200 {
|
||
t.Fatalf("the web holds %d dots", n)
|
||
}
|
||
}
|
||
|
||
func TestBubblesStayInsideAShortChart(t *testing.T) {
|
||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||
ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() {
|
||
chart.BubbleChart(c, "Towns", []chart.Point{{Size: 1}, {X: 10, Y: 10, Size: 1}}, chart.BubbleChartOptions{Height: 40})
|
||
})
|
||
})
|
||
img := tt.Image()
|
||
if n := 200*20 - count(t, img, 0, 0, 200, 20, pixel.Back); n != 0 {
|
||
t.Fatalf("%d bubble dots above the chart", n)
|
||
}
|
||
if n := 20*40 - count(t, img, 180, 20, 20, 40, pixel.Back); n != 0 {
|
||
t.Fatalf("%d bubble dots right of the chart", n)
|
||
}
|
||
}
|
||
|
||
func TestTinyMagnitudesKeepTheirProportions(t *testing.T) {
|
||
bubbles := map[float64]int{}
|
||
for _, scale := range []float64{1, 1e-12} {
|
||
tt := view(800, 400, newClock(), func(c *ui.Context) {
|
||
ui.Row(c).Children(func() {
|
||
chart.BubbleChart(c, "Towns", []chart.Point{{X: 0, Y: 0, Size: 1 * scale}, {X: 9, Y: 9, Size: 4 * scale}}, chart.BubbleChartOptions{Slot: pixel.Info})
|
||
chart.HorizontalBar(c, "Kills", []string{"A", "B"}, []float64{1 * scale, 2 * scale}, chart.HorizontalBarOptions{Slot: pixel.Warn})
|
||
})
|
||
chart.Pyramid(c, "Ages", []string{"A"}, chart.Series{Name: "L", Values: []float64{1 * scale}, Slot: pixel.Magic},
|
||
chart.Series{Name: "R", Values: []float64{2 * scale}, Slot: pixel.Good}, chart.PyramidOptions{})
|
||
chart.Funnel(c, "Shop", []chart.Slice{{Name: "IN", Value: 4 * scale, Slot: pixel.Bad}, {Name: "OUT", Value: 1 * scale, Slot: pixel.Bad}}, chart.FunnelOptions{})
|
||
})
|
||
img, kills := tt.Image(), find(t, tt, "Kills")
|
||
kx, ky := int(kills.X)/2, int(kills.Y)/2
|
||
bubbles[scale] = count(t, img, 0, 0, 160, 80, pixel.Info)
|
||
// One dot row through each bar gives its length.
|
||
for _, bars := range [][2]int{
|
||
{count(t, img, kx, ky+4, 160, 1, pixel.Warn), count(t, img, kx, ky+20, 160, 1, pixel.Warn)},
|
||
{count(t, img, 0, 100, 200, 1, pixel.Magic), count(t, img, 0, 100, 200, 1, pixel.Good)},
|
||
{count(t, img, 0, 131, 160, 1, pixel.Bad) * 2, count(t, img, 0, 115, 160, 1, pixel.Bad)},
|
||
} {
|
||
if bars[0] < 10 || bars[1]-2*bars[0] > 4 || 2*bars[0]-bars[1] > 4 {
|
||
t.Errorf("scale %v: bars %v are not 1:2", scale, bars)
|
||
}
|
||
}
|
||
}
|
||
if bubbles[1] != bubbles[1e-12] || bubbles[1] == 0 {
|
||
t.Fatalf("bubble dots %v", bubbles)
|
||
}
|
||
}
|