A frame is View plus what Bubble Tea does with it: parse the string into an ultraviolet cell buffer and diff it onto a terminal (io.Discard). Benchmarks cover View and whole frames at 80x24, 200x60 and 400x120 on empty, dense, long-text, many-names and many-charts sheets; key presses through to their frame (arrows, page down, extending a selection over the whole sheet, typing into a fan-in sheet); and draining thousands of JEV answers from a fake client through Update.
- commit
- 484093772d17b89d1dd7402805f1ecd886468054
- parent
- 66fabf0336eefd39d11fc6cbfaa2fd5ba7c06856
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 232 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,232 @@
1+//go:build stress2+3+// 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/limits.md).8+package ui9+10+import (11+ "context"12+ "fmt"13+ "io"14+ "testing"15+ "time"16+17+ tea "charm.land/bubbletea/v2"18+ uv "github.com/charmbracelet/ultraviolet"19+ typesafe "github.com/FelineStateMachine/typesafe-go"20+21+ "012/internal/jev"22+ "012/internal/sheet"23+ "012/internal/stress"24+)25+26+var termSizes = []struct{ w, h int }{{80, 24}, {200, 60}, {400, 120}}27+28+type uiShape struct {29+ name string30+ build func() *sheet.Sheet31+}32+33+func uiShapes() []uiShape {34+ return []uiShape{35+ {"empty", sheet.New},36+ {"dense-8192x26", func() *sheet.Sheet { return stress.Dense(sheet.MaxRows, 26) }},37+ {"dense-8192x256", func() *sheet.Sheet { return stress.Dense(sheet.MaxRows, sheet.MaxCols) }},38+ {"longtext-8192x500", func() *sheet.Sheet { return stress.LongText(sheet.MaxRows, 500) }},39+ {"names-1000", func() *sheet.Sheet { return stress.Names(1000) }},40+ {"charts-20", func() *sheet.Sheet {41+ s := stress.Table(200, 3)42+ stress.Charts(s, 20)43+ return s44+ }},45+ }46+}47+48+// fakeTerm stands in for Bubble Tea's renderer: a cell buffer the frame49+// is drawn into and a terminal renderer diffing it to io.Discard.50+type fakeTerm struct {51+ buf uv.ScreenBuffer52+ scr *uv.TerminalRenderer53+}54+55+func newFakeTerm(w, h int) *fakeTerm {56+ t := &fakeTerm{buf: uv.NewScreenBuffer(w, h), scr: uv.NewTerminalRenderer(io.Discard, nil)}57+ t.scr.Resize(w, h)58+ return t59+}60+61+func (t *fakeTerm) frame(m *Model) {62+ v := m.View()63+ t.buf.Clear()64+ uv.NewStyledString(v.Content).Draw(t.buf, t.buf.Bounds())65+ t.scr.Render(t.buf.RenderBuffer)66+ t.scr.Flush()67+}68+69+func sized(s *sheet.Sheet, w, h int) *Model {70+ m := New(s, "")71+ m.Update(tea.WindowSizeMsg{Width: w, Height: h})72+ return m73+}74+75+// BenchmarkView is View alone: 012's share of a frame.76+func BenchmarkView(b *testing.B) {77+ for _, sh := range uiShapes() {78+ s := sh.build()79+ for _, sz := range termSizes {80+ b.Run(fmt.Sprintf("%s/%dx%d", sh.name, sz.w, sz.h), func(b *testing.B) {81+ m := sized(s, sz.w, sz.h)82+ for b.Loop() {83+ m.View()84+ }85+ })86+ }87+ }88+}89+90+// BenchmarkFrame is a whole frame: View, parse and diff.91+func BenchmarkFrame(b *testing.B) {92+ for _, sh := range uiShapes() {93+ s := sh.build()94+ for _, sz := range termSizes {95+ b.Run(fmt.Sprintf("%s/%dx%d", sh.name, sz.w, sz.h), func(b *testing.B) {96+ m := sized(s, sz.w, sz.h)97+ t := newFakeTerm(sz.w, sz.h)98+ for b.Loop() {99+ t.frame(m)100+ }101+ })102+ }103+ }104+}105+106+func key(k string) tea.KeyPressMsg {107+ switch k {108+ case "down":109+ return tea.KeyPressMsg{Code: tea.KeyDown}110+ case "up":111+ return tea.KeyPressMsg{Code: tea.KeyUp}112+ case "right":113+ return tea.KeyPressMsg{Code: tea.KeyRight}114+ case "left":115+ return tea.KeyPressMsg{Code: tea.KeyLeft}116+ case "pgdown":117+ return tea.KeyPressMsg{Code: tea.KeyPgDown}118+ case "pgup":119+ return tea.KeyPressMsg{Code: tea.KeyPgUp}120+ case "shift+down":121+ return tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModShift}122+ case "shift+up":123+ return tea.KeyPressMsg{Code: tea.KeyUp, Mod: tea.ModShift}124+ case "ctrl+a":125+ return tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}126+ case "enter":127+ return tea.KeyPressMsg{Code: tea.KeyEnter}128+ case "esc":129+ return tea.KeyPressMsg{Code: tea.KeyEscape}130+ }131+ r := []rune(k)[0]132+ return tea.KeyPressMsg{Code: r, Text: k}133+}134+135+// BenchmarkKeystroke is a key press through to its frame, alternating136+// keys that undo each other so the state stays put.137+func BenchmarkKeystroke(b *testing.B) {138+ cases := []struct {139+ name string140+ shape uiShape141+ setup []string142+ keys []string143+ }{144+ {"arrow/dense-8192x26", uiShapes()[1], nil, []string{"down", "up"}},145+ {"arrow/dense-8192x256", uiShapes()[2], nil, []string{"right", "left"}},146+ {"pgdown/dense-8192x256", uiShapes()[2], nil, []string{"pgdown", "pgup"}},147+ {"arrow/longtext", uiShapes()[3], nil, []string{"down", "up"}},148+ {"arrow/names-1000", uiShapes()[4], nil, []string{"down", "up"}},149+ {"arrow/charts-20", uiShapes()[5], nil, []string{"down", "up"}},150+ {"select-all/dense-8192x256", uiShapes()[2], []string{"ctrl+a", "ctrl+a"}, []string{"shift+down", "shift+up"}},151+ {"extend/dense-8192x26", uiShapes()[1], []string{"shift+down"}, []string{"shift+down", "shift+up"}},152+ {"type/fanin-1000", uiShape{"fanin", func() *sheet.Sheet { return stress.FanIn(sheet.MaxRows, 1000) }},153+ nil, []string{"7", "enter", "up"}},154+ {"type/dense-8192x26", uiShapes()[1], nil, []string{"7", "enter", "up"}},155+ }156+ for _, c := range cases {157+ s := c.shape.build()158+ for _, sz := range termSizes[:2] {159+ b.Run(fmt.Sprintf("%s/%dx%d", c.name, sz.w, sz.h), func(b *testing.B) {160+ m := sized(s, sz.w, sz.h)161+ t := newFakeTerm(sz.w, sz.h)162+ for _, k := range c.setup {163+ m.Update(key(k))164+ }165+ i := 0166+ for b.Loop() {167+ m.Update(key(c.keys[i%len(c.keys)]))168+ t.frame(m)169+ i++170+ }171+ })172+ }173+ }174+}175+176+// fakeJEV answers every question at once, never touching the network.177+type stressJEV struct{}178+179+func (stressJEV) SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {180+ return &typesafe.SystemOneResponse{Answers: map[string]typesafe.Answer{"answer": typesafe.NoulAnswer{Noul: 0.7}}}, nil181+}182+183+// BenchmarkJEV loads n JEV.TEST formulas with distinct questions and184+// drains every answer through Update, as the running program would,185+// counting frames drawn along the way at 60 per second.186+func BenchmarkJEV(b *testing.B) {187+ for _, n := range []int{100, 1000, 4000} {188+ b.Run(fmt.Sprint(n), func(b *testing.B) {189+ for b.Loop() {190+ cache := jev.NewCache()191+ prev := sheet.Remote192+ sheet.Remote = cache193+ s := sheet.New()194+ for i := range n {195+ s.Load(sheet.Addr{Row: i}, fmt.Sprintf("row %d", i), sheet.Format{}, sheet.Style{})196+ 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{})197+ }198+ s.RecalcAll()199+ m := sized(s, 200, 60)200+ m.EnableJEV(stressJEV{}, cache)201+ start := time.Now()202+ drain(m, m.sendJEV())203+ if in, q := cache.Busy(); in+q > 0 {204+ b.Fatalf("%d in flight, %d queued", in, q)205+ }206+ b.ReportMetric(float64(time.Since(start).Microseconds())/float64(n), "us/answer")207+ sheet.Remote = prev208+ }209+ })210+ }211+}212+213+// drain runs cmd and every command its messages lead to, breadth first,214+// like Bubble Tea's event loop with instant commands.215+func drain(m *Model, cmd tea.Cmd) {216+ queue := []tea.Cmd{cmd}217+ for len(queue) > 0 {218+ c := queue[0]219+ queue = queue[1:]220+ if c == nil {221+ continue222+ }223+ switch msg := c().(type) {224+ case nil:225+ case tea.BatchMsg:226+ queue = append(queue, msg...)227+ default:228+ _, next := m.Update(msg)229+ queue = append(queue, next)230+ }231+ }232+}