cat / 012
clone

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

Guard every command against grid-sized work

Dami committed 3953e11
A test runs every available command with the whole sheet, a whole
column and a whole row selected on a sheet of a million rows by 16,384
columns, presses Enter to accept what a dialog offers, draws a frame,
and fails past 250 ms or 8 MB.

It found: resetting and setting column widths made an undo step per
column of the selection (now one step, and a reset touches only the
columns that have a width), and copying put every blank between values
on the system clipboard (now refused past max-cells cells, with a note;
Ctrl+V still pastes).
commit
3953e111662c62d394e9ae8f7a64bde7bee9d708
parent
460847ca0ffc9481d94b393d3ab2b8f4f31609e0
author
Dami <dami.etoile@gmail.com>
4 files changed, +126 -8
Minternal/ui/clipboard.go7++-
Minternal/ui/columns.go11++-
Minternal/ui/commands.go11++-
Ainternal/ui/vast_test.go105++++++++++++++++++++++++++++++
M internal/ui/clipboard.go +6 -1
M internal/ui/columns.go +7 -4
M internal/ui/commands.go +8 -3
A internal/ui/vast_test.go +105 -0
@@ -0,0 +1,105 @@
1+package ui2+3+import (4+ "fmt"5+ "maps"6+ "os"7+ "runtime"8+ "slices"9+ "strings"10+ "testing"11+ "time"12+13+ tea "charm.land/bubbletea/v2"14+15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// The guardrail against work proportional to the grid: with the whole19+// sheet (or a whole column, or a whole row) selected on a nearly empty20+// sheet of a million rows by 16,384 columns, every command that can run21+// does, and the frame after it draws, in a small bound of time and22+// memory. A command that walked every address of its selection would23+// visit 17 billion cells here, and one that walked a whole column a24+// million: either fails.25+func TestCommandsCostTheDataNotTheGrid(t *testing.T) {26+ if testing.Short() {27+ t.Skip("runs every command three times")28+ }29+ skip := map[string]bool{30+ "quit": true, // the session, not the sheet31+ "settings.config_edit": true, "settings.config_reload": true, "settings.jev_key": true,32+ }33+ const maxTime, maxAlloc = 250 * time.Millisecond, 8 << 20 // a whole column of 8-byte entries is 8 MB34+ selections := []struct {35+ name string36+ keys []string37+ }{38+ {"sheet", []string{"<ctrl+a>", "<ctrl+a>"}},39+ {"column", []string{"<ctrl+space>"}},40+ {"row", []string{"<shift+space>"}},41+ }42+ var report strings.Builder43+ for _, id := range slices.Sorted(maps.Keys(commands)) {44+ if skip[id] || strings.HasPrefix(id, "file.") {45+ continue46+ }47+ for _, sel := range selections {48+ m := vastModel(t)49+ press(t, m, sel.keys...)50+ if !commands[id].available(m) {51+ continue52+ }53+ if r := m.selection(); sel.name != "row" && !r.AllRows() || sel.name != "column" && !r.AllCols() {54+ t.Fatalf("%s selected %v", sel.name, r)55+ }56+ // Then Enter, which accepts what a dialog the command opened57+ // offers (a sort, a filter, a chart), and a frame.58+ d, alloc := measure(func() {59+ run(m, commands[id].run(m))60+ m.View()61+ press(t, m, "<enter>")62+ m.View()63+ })64+ fmt.Fprintf(&report, "%-28s %-7s %8.2f ms %9d B\n", id, sel.name, float64(d.Microseconds())/1000, alloc)65+ if d > maxTime || alloc > maxAlloc {66+ t.Errorf("%s over the whole %s took %v and allocated %d bytes", id, sel.name, d, alloc)67+ }68+ }69+ }70+ if testing.Verbose() {71+ t.Log("\n" + report.String())72+ }73+}74+75+// vastModel is a model on a sheet with the full grid and a few cells,76+// clear of the top row and the first column (so filling down or right77+// from them has nothing to copy), in a temporary directory.78+func vastModel(t *testing.T) *Model {79+ t.Helper()80+ dir := t.TempDir()81+ wd, _ := os.Getwd()82+ os.Chdir(dir)83+ t.Cleanup(func() { os.Chdir(wd) })84+ s := sheet.New()85+ for _, in := range []struct{ a, v string }{{"B2", "Name"}, {"C2", "Qty"}, {"B3", "pen"}, {"C3", "3"}, {"B4", "ink"}, {"C4", "=C3*2"}, {"D5", "=SUM(C:C)"}} {86+ s.Set(addr(in.a), in.v)87+ }88+ s.ClearHistory()89+ m := New(s, "")90+ m.Update(tea.WindowSizeMsg{Width: 200, Height: 60})91+ return m92+}93+94+// measure runs fn and returns how long it took and how much it95+// allocated.96+func measure(fn func()) (time.Duration, uint64) {97+ var before, after runtime.MemStats98+ runtime.GC()99+ runtime.ReadMemStats(&before)100+ start := time.Now()101+ fn()102+ d := time.Since(start)103+ runtime.ReadMemStats(&after)104+ return d, after.TotalAlloc - before.TotalAlloc105+}