Files
8bitMyGoUI/chart/shapes_test.go
T
ZacharyZhang-NY 596c62e026 Add chart components 216-245; step-aware ticks, font-aware labels
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.
2026-10-12 00:58:44 -04:00

300 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package chart_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var loot = []chart.Slice{{Name: "Gold", Value: 3, Slot: pixel.Warn}, {Name: "Gems", Value: 1, Slot: pixel.Info}}
func TestPieAndDonutShareBySlice(t *testing.T) {
tt := view(300, 200, newClock(), func(c *ui.Context) {
ui.Row(c).Children(func() {
chart.PieChart(c, "Pie", loot, chart.PieChartOptions{})
chart.DonutChart(c, "Donut", loot, chart.DonutChartOptions{})
})
})
img := tt.Image()
gold, gems := count(t, img, 0, 0, 64, 64, pixel.Warn), count(t, img, 0, 0, 64, 64, pixel.Info)
if share := float64(gold) / float64(gold+gems); share < 0.72 || share > 0.78 {
t.Fatalf("pie dots %d and %d for 3:1", gold, gems)
}
// The donut's middle is hollow.
if at(t, img, 64+32, 26) == pixel.Warn || count(t, img, 64, 0, 64, 64, pixel.Warn) == 0 {
t.Fatal("the donut has no hole, or no ring")
}
keeps(t, img)
panics(t, "slices of nothing", func() {
view(100, 100, newClock(), func(c *ui.Context) { chart.PieChart(c, "x", []chart.Slice{{Name: "a"}}, chart.PieChartOptions{}) })
})
}
func TestPolarAreaReachesByValue(t *testing.T) {
tt := view(200, 200, newClock(), func(c *ui.Context) {
chart.PolarArea(c, "Stats", []chart.Slice{{Name: "A", Value: 4, Slot: pixel.Warn}, {Name: "B", Value: 2, Slot: pixel.Info}}, chart.PolarAreaOptions{})
})
img := tt.Image()
a, b := count(t, img, 0, 0, 64, 64, pixel.Warn), count(t, img, 0, 0, 64, 64, pixel.Info)
// Twice the reach is four times the area.
if a < 3*b || a > 5*b {
t.Fatalf("polar dots %d and %d for reaches 2:1", a, b)
}
tt = view(200, 200, newClock(), func(c *ui.Context) {
chart.PolarArea(c, "Stats", []chart.Slice{{Name: "A", Value: 4, Slot: pixel.Warn}, {Name: "B", Slot: pixel.Info}}, chart.PolarAreaOptions{})
})
if n := count(t, tt.Image(), 0, 0, 64, 64, pixel.Info); n != 0 {
t.Fatalf("a zero slice drew %d dots", n)
}
}
func TestProgressRingFillsClockwise(t *testing.T) {
tt := view(200, 100, newClock(), func(c *ui.Context) {
chart.ProgressRing(c, "Load", 0.25, chart.ProgressRingOptions{Slot: pixel.Good})
})
img := tt.Image()
if right, left := count(t, img, 20, 0, 20, 20, pixel.Good), count(t, img, 0, 0, 20, 40, pixel.Good); right == 0 || left != 0 {
t.Fatalf("a quarter lit %d dots top right, %d on the left", right, left)
}
panics(t, "a value of 2", func() {
view(100, 100, newClock(), func(c *ui.Context) { chart.ProgressRing(c, "x", 2, chart.ProgressRingOptions{}) })
})
}
func TestGaugeNeedlePoints(t *testing.T) {
for _, tc := range []struct {
v float64
x, y int
}{{0, 6, 32}, {100, 58, 32}, {50, 32, 6}} {
tt := view(200, 100, newClock(), func(c *ui.Context) { chart.Gauge(c, "Speed", tc.v, chart.GaugeOptions{}) })
if s := at(t, tt.Image(), tc.x, tc.y); s != pixel.Ink {
t.Fatalf("value %v: no needle at %d,%d (%v)", tc.v, tc.x, tc.y, s)
}
}
}
func TestRadarChartReachesTheRim(t *testing.T) {
for _, tc := range []struct {
axes []string
rim int
}{
// 11-dot labels on the 60° axes leave a 30 rim.
{[]string{"STR", "DEX", "INT", "VIT", "AGI", "LUK"}, 30},
// Pixel-font labels, 24 wide, shrink it to 15.
{[]string{"力量", "敏捷", "智力", "体力", "速度", "运气"}, 15},
} {
tt := view(200, 200, newClock(), func(c *ui.Context) {
chart.RadarChart(c, "Hero", tc.axes, []float64{10, 5, 5, 5, 5, 5}, chart.RadarChartOptions{Slot: pixel.Magic})
})
if top, _ := column(t, tt.Image(), 40, 80, pixel.Magic); top != 40-tc.rim-1 {
t.Fatalf("%s: the largest value's marker tops row %d", tc.axes[0], top)
}
keeps(t, tt.Image())
}
panics(t, "a value above Max", func() {
view(200, 200, newClock(), func(c *ui.Context) {
chart.RadarChart(c, "x", []string{"A", "B", "C"}, []float64{1, 2, 12}, chart.RadarChartOptions{Max: 10})
})
})
panics(t, "labels too wide for the size", func() {
view(200, 200, newClock(), func(c *ui.Context) {
chart.RadarChart(c, "x", []string{"STRENGTH", "B", "C"}, []float64{1, 2, 3}, chart.RadarChartOptions{Size: 30})
})
})
}
func TestHeatmapsShadeByLevel(t *testing.T) {
tt := view(400, 200, newClock(), func(c *ui.Context) {
chart.Heatmap(c, "Heat", []string{"A"}, []string{"X", "Y"}, [][]float64{{0, 10}}, chart.HeatmapOptions{})
chart.CalendarHeatmap(c, "Raids", 2026, func(d time.Time) int {
if d.Month() == time.January {
return 4
}
return 0
}, chart.CalendarHeatmapOptions{})
})
img := tt.Image()
r := find(t, tt, "Heat")
ox, oy := int(r.X)/2, int(r.Y)/2
// Labels take six dots left, eight on top.
low, high := count(t, img, ox+6, oy+8, 9, 9, pixel.Bad), count(t, img, ox+16, oy+8, 9, 9, pixel.Bad)
if low != 0 || high != 81 {
t.Fatalf("cells of 0 and 10 hold %d and %d Bad dots", low, high)
}
cal := find(t, tt, "Raids 2026")
cx, cy := int(cal.X)/2, int(cal.Y)/2
if jan, jun := count(t, img, cx, cy, 16, 28, pixel.Good), count(t, img, cx+100, cy, 16, 28, pixel.Good); jan == 0 || jun != 0 {
t.Fatalf("January lit %d dots, June %d", jan, jun)
}
panics(t, "a level of 5", func() {
view(400, 100, newClock(), func(c *ui.Context) {
chart.CalendarHeatmap(c, "x", 2026, func(time.Time) int { return 5 }, chart.CalendarHeatmapOptions{})
})
})
}
func TestTreemapAreasFollowValues(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: "b", Value: 3, Slot: pixel.Warn}}, chart.TreemapOptions{})
})
img := tt.Image()
a, b := count(t, img, 0, 0, 160, 80, pixel.Info), count(t, img, 0, 0, 160, 80, pixel.Warn)
if b < 3*a-200 || b > 3*a+200 {
t.Fatalf("treemap dots %d and %d for 1:3", a, b)
}
keeps(t, img)
}
func TestBulletFunnelCandlesBoxes(t *testing.T) {
tt := view(400, 400, newClock(), func(c *ui.Context) {
chart.BulletChart(c, "Goal", 60, 80, []float64{50, 100}, chart.BulletChartOptions{Width: 100})
chart.Funnel(c, "Sales", []chart.Slice{{Name: "SEEN", Value: 100, Slot: pixel.Info}, {Name: "BOUGHT", Value: 25, Slot: pixel.Info}}, chart.FunnelOptions{Width: 100})
chart.Candlestick(c, "Gold", []chart.Candle{{Label: "A", Open: 10, High: 20, Low: 5, Close: 15}, {Label: "B", Open: 15, High: 16, Low: 2, Close: 4}}, chart.CandlestickOptions{})
})
img := tt.Image()
if bar := count(t, img, 0, 7, 100, 1, pixel.Ink); bar < 58 || bar > 62 {
t.Fatalf("a 60 of 100 bar is %d dots", bar)
}
if mark := at(t, img, 79, 7); mark != pixel.Accent {
t.Fatalf("no target mark at 80: %v", mark)
}
f := find(t, tt, "Sales")
fy := int(f.Y) / 2
if top, bottom := count(t, img, 0, fy+2, 100, 1, pixel.Info), count(t, img, 0, fy+18, 100, 1, pixel.Info); top < 3*bottom {
t.Fatalf("funnel rows %d and %d dots", top, bottom)
}
cs := find(t, tt, "Gold")
cx, cy := int(cs.X)/2, int(cs.Y)/2
if count(t, img, cx, cy, 80, 80, pixel.Good) == 0 || count(t, img, cx+80, cy, 80, 80, pixel.Bad) == 0 {
t.Fatal("candles are not inked by direction")
}
keeps(t, img)
panics(t, "a candle closing above its high", func() {
view(200, 100, newClock(), func(c *ui.Context) {
chart.Candlestick(c, "x", []chart.Candle{{Open: 1, High: 2, Low: 0, Close: 3}}, chart.CandlestickOptions{})
})
})
}
func TestBoxPlotMedianAndWhiskers(t *testing.T) {
tt := view(400, 200, newClock(), func(c *ui.Context) {
chart.BoxPlot(c, "Rolls", []chart.Series{{Name: "D6", Values: []float64{1, 2, 3, 4, 5, 6, 7}}}, chart.BoxPlotOptions{})
})
img := tt.Image()
if count(t, img, 0, 0, 160, 80, pixel.Accent) == 0 || count(t, img, 0, 0, 160, 80, pixel.Edge) < 40 {
t.Fatal("no box or no whiskers")
}
keeps(t, img)
}
func TestLegendTogglesSeries(t *testing.T) {
hidden, changes := []bool{false, false}, 0
disabled, inert := false, false
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert})
if chart.Legend(c, "Series", hp, hidden, chart.LegendOptions{Disabled: disabled}).Changed {
changes++
}
}, 300, 60)
if err := tt.Click("MP"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeySpace)
if !hidden[0] || !hidden[1] || changes != 2 {
t.Fatalf("click MP, Left, Space left %v", hidden)
}
keeps(t, tt.Image())
for name, set := range map[string]func(bool){"disabled": func(b bool) { disabled = b }, "inert": func(b bool) { inert = b }} {
set(true)
tt.Frame()
tt.Click("HP")
tt.Key(0, ui.KeySpace)
if !hidden[0] || changes != 2 {
t.Fatalf("a %s legend toggled", name)
}
set(false)
}
}
func TestScatterAndBubblesPlacePoints(t *testing.T) {
tt := view(400, 400, newClock(), func(c *ui.Context) {
chart.ScatterPlot(c, "Hits", []chart.Point{{X: 1, Y: 1}, {X: 5, Y: 5}, {X: 9, Y: 9}}, chart.ScatterPlotOptions{Slot: pixel.Magic})
chart.BubbleChart(c, "Towns", []chart.Point{{X: 0, Y: 5, Size: 1}, {X: 10, Y: 5, Size: 4}}, chart.BubbleChartOptions{Slot: pixel.Info})
})
img := tt.Image()
if n := count(t, img, 0, 0, 160, 80, pixel.Magic); n != 12 {
t.Fatalf("three points drew %d dots", n)
}
// Each point sits up and right of the one before.
var xs, ys []int
for y := range 80 {
for x := range 160 {
if at(t, img, x, y) == pixel.Magic && (len(xs) == 0 || abs(xs[len(xs)-1]-x) > 2) {
xs, ys = append(xs, x), append(ys, y)
}
}
}
if len(xs) != 3 || !(xs[0] > xs[1] && xs[1] > xs[2] && ys[0] < ys[1] && ys[1] < ys[2]) {
t.Fatalf("points at x %v, y %v", xs, ys)
}
small, big := count(t, img, 0, 80, 80, 80, pixel.Info), count(t, img, 80, 80, 80, 80, pixel.Info)
// Area follows size: 2–4× the dots for 4× the size.
if small == 0 || big < 2*small || big > 4*small {
t.Fatalf("bubbles of size 1 and 4 hold %d and %d dots", small, big)
}
keeps(t, img)
panics(t, "a negative bubble", func() {
view(200, 100, newClock(), func(c *ui.Context) {
chart.BubbleChart(c, "x", []chart.Point{{Size: -1}}, chart.BubbleChartOptions{})
})
})
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func TestPyramidAndWinLoss(t *testing.T) {
tt := view(400, 200, newClock(), func(c *ui.Context) {
chart.Pyramid(c, "Party", []string{"KIDS"}, chart.Series{Name: "BOYS", Values: []float64{10}, Slot: pixel.Info},
chart.Series{Name: "GIRLS", Values: []float64{5}, Slot: pixel.Warn}, chart.PyramidOptions{Width: 100})
chart.WinLoss(c, "Season", []int{1, -1, 0}, chart.WinLossOptions{})
})
img := tt.Image()
left, right := count(t, img, 0, 20, 50, 4, pixel.Info), count(t, img, 50, 20, 50, 4, pixel.Warn)
if left == 0 || right == 0 || left < 2*right-8 {
t.Fatalf("pyramid bars %d and %d for 10 and 5", left, right)
}
w := find(t, tt, "Season")
wx, wy := int(w.X)/2, int(w.Y)/2
if at(t, img, wx+1, wy+4) != pixel.Good || at(t, img, wx+6, wy+11) != pixel.Bad || at(t, img, wx+11, wy+8) != pixel.Faint {
t.Fatal("win, loss and draw blocks are misplaced")
}
panics(t, "a result of 2", func() {
view(100, 50, newClock(), func(c *ui.Context) { chart.WinLoss(c, "x", []int{2}, chart.WinLossOptions{}) })
})
}
func TestAxisMarksTicksOrLabels(t *testing.T) {
tt := view(400, 200, newClock(), func(c *ui.Context) {
chart.Axis(c, chart.AxisOptions{})
chart.Axis(c, chart.AxisOptions{Labels: []string{"LOW", "HIGH"}, Vertical: true, Length: 40})
chart.Axis(c, chart.AxisOptions{Max: 250})
})
if !tt.HasText("0–100") || !tt.HasText("LOW–HIGH") || !tt.HasText("0–250") {
t.Fatalf("texts %q", tt.Texts())
}
keeps(t, tt.Image())
panics(t, "one mark", func() {
view(100, 50, newClock(), func(c *ui.Context) { chart.Axis(c, chart.AxisOptions{Labels: []string{"A"}}) })
})
}