cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / ui / stress_test.go
stress_test.go 11.0K
1//go:build stress23// Stress benchmarks of the front end: drawing a frame, and a keystroke4// through to the next frame, on big sheets at typical and large terminal5// sizes. A frame is View plus what Bubble Tea does with it: parse the6// string into a cell buffer and diff it onto the terminal. Only built7// with -tags stress (see `make stress` and docs/contributing/limits.md).8package ui910import (11 "context"12 "fmt"13 "log/slog"14 "path/filepath"15 "strings"16 "testing"17 "time"1819 tea "charm.land/bubbletea/v2"20 typesafe "github.com/FelineStateMachine/typesafe-go"2122 "src.rest/cat/012/internal/jev"23 "src.rest/cat/012/internal/sheet"24 "src.rest/cat/012/internal/stress"25 "src.rest/cat/012/internal/telemetry"26)2728var termSizes = []struct{ w, h int }{{80, 24}, {200, 60}, {400, 120}}2930type uiShape struct {31 name string32 build func() *sheet.Sheet33}3435func uiShapes() []uiShape {36 return []uiShape{37 {"empty", sheet.New},38 {"dense-8192x26", func() *sheet.Sheet { return stress.Dense(stress.Rows, 26) }},39 {"dense-8192x256", func() *sheet.Sheet { return stress.Dense(stress.Rows, stress.Cols) }},40 {"longtext-8192x500", func() *sheet.Sheet { return stress.LongText(stress.Rows, 500) }},41 {"names-1000", func() *sheet.Sheet { return stress.Names(1000) }},42 {"charts-20", func() *sheet.Sheet {43 s := stress.Table(200, 3)44 stress.Charts(s, 20)45 return s46 }},47 {"sparse-1M", func() *sheet.Sheet { return stress.Sparse(10000, 100, 1000) }},48 {"scale-8192x26", scaled},49 {"laidout-8192x26", func() *sheet.Sheet { return stress.Laidout(stress.Rows, 26) }},50 {"bars-8192x26", barred},51 }52}5354// barred is dense-8192x26 with data bars over half its columns and55// arrows over the rest, every visible cell drawn with one: what bars56// and icons cost a frame.57func barred() *sheet.Sheet {58 s := stress.Dense(stress.Rows, 26)59 var fs []sheet.CondFormat60 for _, line := range []string{61 `{"ranges":"A1:M8192","dataBar":{"color":"blue","min":{"type":"min"},"max":{"type":"max"}}}`,62 `{"ranges":"N1:Z8192","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}]}}`,63 } {64 f, err := sheet.ParseCondFormat(line)65 if err != nil {66 panic(err)67 }68 fs = append(fs, f)69 }70 if s.LoadCondFormats(fs) > 0 {71 panic("rules left out")72 }73 return s74}7576// BenchmarkView is View alone: 012's share of a frame.77func BenchmarkView(b *testing.B) {78 for _, sh := range uiShapes() {79 s := lazy(sh.build)80 for _, sz := range termSizes {81 b.Run(fmt.Sprintf("%s/%dx%d", sh.name, sz.w, sz.h), func(b *testing.B) {82 m := sized(s(), sz.w, sz.h)83 for b.Loop() {84 m.View()85 }86 })87 }88 }89}9091// BenchmarkFrame is a whole frame: View, parse and diff.92func BenchmarkFrame(b *testing.B) {93 for _, sh := range uiShapes() {94 s := lazy(sh.build)95 for _, sz := range termSizes {96 b.Run(fmt.Sprintf("%s/%dx%d", sh.name, sz.w, sz.h), func(b *testing.B) {97 m := sized(s(), sz.w, sz.h)98 t := newFakeTerm(sz.w, sz.h)99 for b.Loop() {100 t.frame(m)101 }102 })103 }104 }105}106107func key(k string) tea.KeyPressMsg {108 switch k {109 case "down":110 return tea.KeyPressMsg{Code: tea.KeyDown}111 case "up":112 return tea.KeyPressMsg{Code: tea.KeyUp}113 case "right":114 return tea.KeyPressMsg{Code: tea.KeyRight}115 case "left":116 return tea.KeyPressMsg{Code: tea.KeyLeft}117 case "pgdown":118 return tea.KeyPressMsg{Code: tea.KeyPgDown}119 case "pgup":120 return tea.KeyPressMsg{Code: tea.KeyPgUp}121 case "shift+down":122 return tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModShift}123 case "shift+up":124 return tea.KeyPressMsg{Code: tea.KeyUp, Mod: tea.ModShift}125 case "ctrl+shift+down":126 return tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl | tea.ModShift}127 case "ctrl+shift+up":128 return tea.KeyPressMsg{Code: tea.KeyUp, Mod: tea.ModCtrl | tea.ModShift}129 case "ctrl+shift+right":130 return tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModCtrl | tea.ModShift}131 case "ctrl+a":132 return tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}133 case "ctrl+down":134 return tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl}135 case "ctrl+up":136 return tea.KeyPressMsg{Code: tea.KeyUp, Mod: tea.ModCtrl}137 case "ctrl+space":138 return tea.KeyPressMsg{Code: tea.KeySpace, Mod: tea.ModCtrl}139 case "shift+right":140 return tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModShift}141 case "shift+left":142 return tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModShift}143 case "enter":144 return tea.KeyPressMsg{Code: tea.KeyEnter}145 case "esc":146 return tea.KeyPressMsg{Code: tea.KeyEscape}147 }148 r := []rune(k)[0]149 return tea.KeyPressMsg{Code: r, Text: k}150}151152// BenchmarkKeystroke is a key press through to its frame, alternating153// keys that undo each other so the state stays put. Cases without keys154// draw frames over a standing selection (whose statistics the status155// line shows).156func BenchmarkKeystroke(b *testing.B) {157 cases := []struct {158 name string159 shape uiShape160 setup []string161 keys []string162 }{163 {"arrow/dense-8192x26", uiShapes()[1], nil, []string{"down", "up"}},164 {"arrow/dense-8192x256", uiShapes()[2], nil, []string{"right", "left"}},165 {"pgdown/dense-8192x256", uiShapes()[2], nil, []string{"pgdown", "pgup"}},166 {"arrow/longtext", uiShapes()[3], nil, []string{"down", "up"}},167 {"arrow/names-1000", uiShapes()[4], nil, []string{"down", "up"}},168 {"arrow/charts-20", uiShapes()[5], nil, []string{"down", "up"}},169 {"select-data/dense-8192x256", uiShapes()[2], []string{"ctrl+a"}, nil},170 {"select-sheet/dense-8192x256", uiShapes()[2], []string{"ctrl+a", "ctrl+a"}, nil},171 {"extend-data/dense-8192x256", uiShapes()[2], []string{"ctrl+a"}, []string{"shift+up", "shift+down"}},172 {"extend-edge/dense-8192x256", uiShapes()[2], []string{"ctrl+shift+right"}, []string{"ctrl+shift+down", "ctrl+shift+up"}},173 {"extend/dense-8192x26", uiShapes()[1], []string{"shift+down"}, []string{"shift+down", "shift+up"}},174 {"type/fanin-1000", uiShape{"fanin", func() *sheet.Sheet { return stress.FanIn(stress.Rows, 1000) }},175 nil, []string{"7", "enter", "up"}},176 {"type/dense-8192x26", uiShapes()[1], nil, []string{"7", "enter", "up"}},177 {"arrow/scale-8192x26", uiShapes()[7], nil, []string{"down", "up"}},178 {"arrow/laidout-8192x26", uiShapes()[8], nil, []string{"down", "up"}},179 {"pgdown/laidout-8192x26", uiShapes()[8], nil, []string{"pgdown", "pgup"}},180 {"type/laidout-8192x26", uiShapes()[8], nil, []string{"7", "enter", "up"}},181 {"type/scale-8192x26", uiShapes()[7], nil, []string{"7", "enter", "up"}},182 {"jump/sparse-1M", uiShapes()[6], nil, []string{"ctrl+down", "ctrl+down", "ctrl+up", "ctrl+up"}},183 {"select-sheet/sparse-1M", uiShapes()[6], []string{"ctrl+a", "ctrl+a"}, nil},184 {"extend-column/sparse-1M", uiShapes()[6], []string{"ctrl+space"}, []string{"shift+right", "shift+left"}},185 {"type/sparse-1M", uiShapes()[6], nil, []string{"7", "enter", "up"}},186 {"arrow/corner", uiShape{"corner", sheet.New}, []string{"@XFD1048576"}, []string{"up", "left", "down", "right"}},187 }188 for _, c := range cases {189 s := lazy(c.shape.build)190 for _, sz := range termSizes[:2] {191 b.Run(fmt.Sprintf("%s/%dx%d", c.name, sz.w, sz.h), func(b *testing.B) {192 m := sized(s(), sz.w, sz.h)193 t := newFakeTerm(sz.w, sz.h)194 for _, k := range c.setup {195 if a, ok := strings.CutPrefix(k, "@"); ok { // go to a cell196 cell, _ := sheet.ParseAddr(a)197 m.cur = cell198 m.scrollTo(cell)199 continue200 }201 m.Update(key(k))202 }203 i := 0204 for b.Loop() {205 if len(c.keys) > 0 {206 m.Update(key(c.keys[i%len(c.keys)]))207 }208 t.frame(m)209 i++210 }211 })212 }213 }214}215216// BenchmarkTelemetry is an arrow key through to its frame and an edit217// that recalculates, with telemetry off and on (a JSON log in a temp218// file): what the instrumentation costs.219func BenchmarkTelemetry(b *testing.B) {220 s := lazy(func() *sheet.Sheet { return stress.Dense(stress.Rows, 26) })221 for _, on := range []bool{false, true} {222 name := "off"223 if on {224 name = "on"225 }226 b.Run("arrow/"+name, func(b *testing.B) {227 stop := telemetryFor(b, on)228 defer stop()229 m := sized(s(), 200, 60)230 t := newFakeTerm(200, 60)231 keys := []string{"down", "up"}232 i := 0233 for b.Loop() {234 m.Update(key(keys[i%2]))235 t.frame(m)236 i++237 }238 })239 b.Run("edit/"+name, func(b *testing.B) {240 stop := telemetryFor(b, on)241 defer stop()242 m := sized(s(), 200, 60)243 keys := []string{"7", "enter", "up"}244 i := 0245 for b.Loop() {246 m.Update(key(keys[i%3]))247 i++248 }249 })250 }251}252253// telemetryFor turns telemetry on, logging to a temp file, when on is254// set, the way cmd/012 does.255func telemetryFor(b *testing.B, on bool) func() error {256 if !on {257 return func() error { return nil }258 }259 stop, err := telemetry.Setup(telemetry.Config{LogPath: filepath.Join(b.TempDir(), "log.jsonl")})260 if err != nil {261 b.Fatal(err)262 }263 // As cmd/012 does: recalculations nest in the model's trace.264 sheet.OnBegin = func(trace any, op string) {265 t, _ := trace.(*telemetry.Trace)266 t.Begin(op)267 }268 sheet.OnRecalc = func(trace any, i sheet.RecalcInfo) {269 t, _ := trace.(*telemetry.Trace)270 t.End(slog.Int("evaluated", i.Evaluated), slog.Int("cells", i.Cells))271 }272 return func() error {273 sheet.OnBegin, sheet.OnRecalc = nil, nil274 return stop()275 }276}277278// fakeJEV answers every question at once, never touching the network.279type stressJEV struct{}280281func (stressJEV) SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {282 return &typesafe.SystemOneResponse{Answers: map[string]typesafe.Answer{"answer": typesafe.NoulAnswer{Noul: 0.7}}}, nil283}284285// BenchmarkJEV loads n JEV.TEST formulas with distinct questions and286// drains every answer through Update, as the running program would.287// Answers that are queued together (up to jevParallel) recalculate once.288func BenchmarkJEV(b *testing.B) {289 for _, n := range []int{100, 1000, 4000} {290 b.Run(fmt.Sprint(n), func(b *testing.B) {291 for b.Loop() {292 cache := jev.NewCache()293 s := sheet.New()294 for i := range n {295 s.Load(sheet.Addr{Row: i}, fmt.Sprintf("row %d", i), sheet.Format{}, sheet.Style{})296 s.Load(sheet.Addr{Col: 1, Row: i}, fmt.Sprintf(`=JEV.TEST(A%d, "Is this row %d?")`, i+1, i), sheet.Format{}, sheet.Style{})297 }298 s.RecalcAll()299 m := sized(s, 200, 60)300 m.EnableJEV(stressJEV{}, cache)301 m.jev.gather = 0 // answers already queued still gather; don't wait a frame302 start := time.Now()303 drain(m, m.jev.send(m.spans.Parent()))304 if in, q := cache.Busy(); in+q > 0 {305 b.Fatalf("%d in flight, %d queued", in, q)306 }307 b.ReportMetric(float64(time.Since(start).Microseconds())/float64(n), "us/answer")308 }309 })310 }311}312313// lazy builds a sheet on first use, so running one sub-benchmark doesn't314// build every shape.315func lazy(build func() *sheet.Sheet) func() *sheet.Sheet {316 var s *sheet.Sheet317 return func() *sheet.Sheet {318 if s == nil {319 s = build()320 }321 return s322 }323}324325// drain runs cmd and every command its messages lead to, breadth first,326// like Bubble Tea's event loop with instant commands.327func drain(m *Model, cmd tea.Cmd) {328 queue := []tea.Cmd{cmd}329 for len(queue) > 0 {330 c := queue[0]331 queue = queue[1:]332 if c == nil {333 continue334 }335 switch msg := c().(type) {336 case nil:337 case tea.BatchMsg:338 queue = append(queue, msg...)339 default:340 _, next := m.Update(msg)341 queue = append(queue, next)342 }343 }344}