cat / 012
clone

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

main
012 / internal / ui / model_test.go
model_test.go 12.7K
1package ui23import (4 "os"5 "reflect"6 "strings"7 "testing"8 "time"910 tea "charm.land/bubbletea/v2"11 "github.com/charmbracelet/x/ansi"1213 "src.rest/cat/012/internal/sheet"14)1516var named = map[string]tea.Key{17 "enter": {Code: tea.KeyEnter}, "esc": {Code: tea.KeyEscape}, "backspace": {Code: tea.KeyBackspace},18 "tab": {Code: tea.KeyTab}, "delete": {Code: tea.KeyDelete}, "home": {Code: tea.KeyHome}, "end": {Code: tea.KeyEnd},19 "up": {Code: tea.KeyUp}, "down": {Code: tea.KeyDown}, "left": {Code: tea.KeyLeft}, "right": {Code: tea.KeyRight},20 "pgdown": {Code: tea.KeyPgDown}, "pgup": {Code: tea.KeyPgUp}, "f1": {Code: tea.KeyF1}, "f2": {Code: tea.KeyF2}, "f3": {Code: tea.KeyF3}, "f4": {Code: tea.KeyF4}, "f5": {Code: tea.KeyF5},21 "f9": {Code: tea.KeyF9}, "f10": {Code: tea.KeyF10}, "f11": {Code: tea.KeyF11}, "space": {Code: tea.KeySpace},22}2324// press sends keys to m. Named keys go in angle brackets with optional25// modifiers, e.g. "<shift+down>" or "<ctrl+s>"; other text is typed a26// character at a time. Commands run and their messages are fed back,27// except tea.Quit, which is returned.28func press(t *testing.T, m *Model, keys ...string) tea.Msg {29 t.Helper()30 return pressTo(t, func(msg tea.Msg) tea.Msg { return send(m, msg) }, keys...)31}3233// pressTo sends keys, as press does, through send.34func pressTo(t *testing.T, send func(tea.Msg) tea.Msg, keys ...string) tea.Msg {35 t.Helper()36 var last tea.Msg37 for _, k := range keys {38 name, ok := strings.CutPrefix(k, "<")39 if !ok {40 for _, r := range k {41 if msg := send(tea.KeyPressMsg{Code: r, Text: string(r)}); msg != nil {42 last = msg43 }44 }45 continue46 }47 parts := strings.Split(strings.TrimSuffix(name, ">"), "+")48 var mod tea.KeyMod49 for _, p := range parts[:len(parts)-1] {50 switch p {51 case "shift":52 mod |= tea.ModShift53 case "ctrl":54 mod |= tea.ModCtrl55 case "alt":56 mod |= tea.ModAlt57 }58 }59 base := parts[len(parts)-1]60 key, ok := named[base]61 if !ok && len(base) == 1 {62 key, ok = tea.Key{Code: rune(base[0])}, true63 }64 if !ok {65 t.Fatalf("unknown key %q", k)66 }67 key.Mod = mod68 if msg := send(tea.KeyPressMsg(key)); msg != nil {69 last = msg70 }71 }72 return last73}7475// send delivers msg and runs resulting commands (including batches and76// sequences), returning a QuitMsg if one is produced. Timer commands such77// as autoscroll ticks are not run; tests send those messages directly.78func send(m *Model, msg tea.Msg) tea.Msg {79 _, cmd := m.Update(msg)80 return run(m, cmd)81}8283func run(m *Model, cmd tea.Cmd) tea.Msg {84 return runVia(func(msg tea.Msg) tea.Cmd { _, next := m.Update(msg); return next }, cmd)85}8687// runVia runs cmd as run does, handing its messages to update.88func runVia(update func(tea.Msg) tea.Cmd, cmd tea.Cmd) tea.Msg {89 if cmd == nil {90 return nil91 }92 out := cmd()93 if _, ok := out.(tea.QuitMsg); ok {94 return out95 }96 if _, ok := out.(followTickMsg); ok {97 return nil // tests poll with pump98 }99 if _, ok := out.(sourceTickMsg); ok {100 return nil // tests look at sources' files with pollSources101 }102 if _, ok := out.(autoscrollMsg); ok {103 return nil104 }105 if msg, ok := out.(pausedMsg); ok {106 pauses = append(pauses, msg.msg) // tests end them with endPauses107 return nil108 }109 if msg, ok := out.(nbStreamMsg); ok {110 streamPolls = append(streamPolls, msg) // tests take them with pumpStreams111 return nil112 }113 // Batches and sequences are slices of commands.114 cmdType := reflect.TypeFor[tea.Cmd]()115 if v := reflect.ValueOf(out); v.Kind() == reflect.Slice && v.Type().Elem() == cmdType {116 for i := range v.Len() {117 if c, ok := v.Index(i).Interface().(tea.Cmd); ok {118 if q := runVia(update, c); q != nil {119 return q120 }121 }122 }123 return nil124 }125 return runVia(update, update(out))126}127128func newModel() *Model {129 m := New(sheet.New(), "")130 m.Update(tea.WindowSizeMsg{Width: 80, Height: 20})131 return m132}133134func screen(m *Model) string {135 return ansi.Strip(m.View().Content)136}137138func line(m *Model, i int) string {139 return strings.TrimRight(strings.Split(screen(m), "\n")[i], " ")140}141142// bar is the formula bar's text after the name box.143func bar(m *Model) string {144 return strings.TrimSpace(ansi.Cut(line(m, formulaLine), formulaBarTextX(), m.width))145}146147func addr(s string) sheet.Addr {148 a, _ := sheet.ParseAddr(s)149 return a150}151152func input(m *Model, a string) string {153 if c := m.sheet.Cell(addr(a)); c != nil {154 return c.Input155 }156 return ""157}158159func TestEnterCommitsAndMovesDown(t *testing.T) {160 m := newModel()161 press(t, m, "12", "<enter>", "30", "<enter>", "=SUM(A1:A2)", "<enter>")162 if m.mode != modeReady || m.cur != addr("A4") {163 t.Fatalf("mode %v at %v", m.mode, m.cur)164 }165 if got := m.sheet.Value(addr("A3")).Num; got != 42 {166 t.Errorf("A3 = %v", got)167 }168 press(t, m, "<up>")169 if l := line(m, formulaLine); !strings.HasPrefix(l, " A3 ") || bar(m) != "=SUM(A1:A2)" {170 t.Errorf("formula bar %q", l)171 }172}173174func TestTabThenEnterReturnsToStartColumn(t *testing.T) {175 m := newModel()176 press(t, m, "<right>", "Name", "<tab>", "Qty", "<tab>", "Price", "<enter>")177 if m.cur != addr("B2") {178 t.Errorf("cur %v, want B2", m.cur)179 }180 if input(m, "D1") != "Price" {181 t.Errorf("D1 = %q", input(m, "D1"))182 }183}184185func TestArrowsAcceptAndMove(t *testing.T) {186 m := newModel()187 press(t, m, "hello", "<right>")188 if input(m, "A1") != "hello" || m.cur != addr("B1") {189 t.Errorf("A1 %q cur %v", input(m, "A1"), m.cur)190 }191 press(t, m, "x", "<esc>")192 if input(m, "B1") != "" || m.mode != modeReady {193 t.Errorf("Esc kept %q", input(m, "B1"))194 }195}196197func TestTextOverflow(t *testing.T) {198 m := newModel()199 press(t, m, "Quarterly revenue report", "<enter>")200 if row := line(m, gridTop); !strings.Contains(row, " Quarterly revenue report") {201 t.Errorf("text did not overflow: %q", row)202 }203 press(t, m, "<up>", "<right>", "5", "<enter>")204 if row := line(m, gridTop); !strings.Contains(row, " Quarterly 5") {205 t.Errorf("overflow not cut off: %q", row)206 }207}208209func TestPointMode(t *testing.T) {210 m := newModel()211 press(t, m, "1", "<enter>", "2", "<enter>")212 press(t, m, "=SUM(", "<up>", "<up>")213 if m.mode != modePoint || bar(m) != "=SUM(A1" {214 t.Fatalf("mode %v, edit line %q", m.mode, bar(m))215 }216 press(t, m, "<shift+down>")217 if bar(m) != "=SUM(A1:A2" {218 t.Fatalf("edit line %q", bar(m))219 }220 press(t, m, ")", "<enter>")221 if input(m, "A3") != "=SUM(A1:A2)" || m.sheet.Value(addr("A3")).Num != 3 || m.cur != addr("A4") {222 t.Errorf("A3 %q = %v, cur %v", input(m, "A3"), m.sheet.Value(addr("A3")), m.cur)223 }224}225226func TestInvalidFormulaStaysInEdit(t *testing.T) {227 m := newModel()228 press(t, m, "=B1+", "<enter>")229 if m.mode != modeEdit || m.entry.hint != "Formula is incomplete" || m.sheet.Cell(addr("A1")) != nil {230 t.Fatalf("mode %v hint %q", m.mode, m.entry.hint)231 }232 press(t, m, "1", "<enter>")233 if m.mode != modeReady || m.sheet.Value(addr("A1")).Num != 1 || m.cur != addr("A2") {234 t.Errorf("mode %v A1 %+v cur %v", m.mode, m.sheet.Value(addr("A1")), m.cur)235 }236}237238func TestEditExisting(t *testing.T) {239 m := newModel()240 press(t, m, "=2*3", "<enter>", "<up>", "<f2>", "<left>", "<backspace>", "4", "<enter>")241 // Caret lands before "3", Backspace removes "*", then "4" is inserted.242 if got := input(m, "A1"); got != "=243" {243 t.Errorf("input %q, want =243", got)244 }245 press(t, m, "<up>", "<enter>")246 if m.mode != modeEdit || m.line.Text() != "=243" {247 t.Errorf("Enter should edit: mode %v buf %q", m.mode, m.line.Text())248 }249}250251func TestShiftSelectStatsAndClear(t *testing.T) {252 m := newModel()253 press(t, m, "1", "<enter>", "2", "<enter>", "3", "<enter>", "<ctrl+home>")254 press(t, m, "<shift+down>", "<shift+down>")255 if got := m.selection().String(); got != "A1:A3" || m.cur != addr("A1") {256 t.Fatalf("selection %s cur %v", got, m.cur)257 }258 status := line(m, m.height-1)259 for _, want := range []string{"A1:A3", "Sum 6", "Avg 2", "Count 3"} {260 if !strings.Contains(status, want) {261 t.Errorf("status %q missing %q", status, want)262 }263 }264 press(t, m, "<delete>")265 if m.sheet.Len() != 0 {266 t.Errorf("%d cells left after clear", m.sheet.Len())267 }268 press(t, m, "<down>")269 if m.hasRange() {270 t.Error("arrow did not collapse the selection")271 }272}273274func TestCtrlArrowJumps(t *testing.T) {275 m := newModel()276 press(t, m, "1", "<enter>", "2", "<enter>", "3", "<enter>", "<ctrl+home>")277 press(t, m, "<ctrl+down>")278 if m.cur != addr("A3") {279 t.Errorf("ctrl+down to %v", m.cur)280 }281 press(t, m, "<ctrl+home>", "<ctrl+shift+down>")282 if got := m.selection().String(); got != "A1:A3" {283 t.Errorf("ctrl+shift+down selected %s", got)284 }285}286287func TestSelectAllColumnsRows(t *testing.T) {288 m := newModel()289 press(t, m, "<right>", "<down>", "x", "<enter>", "<ctrl+a>")290 if got := m.selection().String(); got != "A1:B2" {291 t.Errorf("ctrl+a selected %s", got)292 }293 press(t, m, "<ctrl+a>")294 if m.whole != wholeAll {295 t.Error("second ctrl+a did not select everything")296 }297 press(t, m, "<esc>", "<ctrl+space>")298 if got := m.selection(); got.From.Row != 0 || got.To.Row != sheet.MaxRows-1 {299 t.Errorf("ctrl+space selected %s", got)300 }301 press(t, m, "<esc>", "<shift+space>")302 if got := m.selection(); got.From.Col != 0 || got.To.Col != sheet.MaxCols-1 {303 t.Errorf("shift+space selected %s", got)304 }305}306307func click(m *Model, x, y int, mod tea.KeyMod) {308 send(m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft, Mod: mod})309 send(m, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})310}311312// cellX returns a screen x inside column c when scrolled to column A.313func cellX(c int) int { return minRowHdrW + c*sheet.DefaultWidth + 2 }314315func TestMouseSelection(t *testing.T) {316 m := newModel()317 click(m, cellX(1), gridTop+2, 0)318 if m.cur != addr("B3") || m.hasRange() {319 t.Fatalf("click: cur %v", m.cur)320 }321 click(m, cellX(2), gridTop+4, tea.ModShift)322 if got := m.selection().String(); got != "B3:C5" {323 t.Errorf("shift+click selected %s", got)324 }325326 send(m, tea.MouseClickMsg{X: cellX(0), Y: gridTop, Button: tea.MouseLeft})327 send(m, tea.MouseMotionMsg{X: cellX(1), Y: gridTop + 1, Button: tea.MouseLeft})328 send(m, tea.MouseReleaseMsg{X: cellX(1), Y: gridTop + 1, Button: tea.MouseLeft})329 if got := m.selection().String(); got != "A1:B2" || m.cur != addr("A1") {330 t.Errorf("drag selected %s, cur %v", got, m.cur)331 }332333 click(m, cellX(3), headerLine, 0)334 if got := m.selection(); got.From != addr("D1") || got.To != addr("D1048576") {335 t.Errorf("column header selected %s", got)336 }337 click(m, 1, gridTop+3, 0)338 if got := m.selection(); got.From != addr("A4") || got.To.Col != sheet.MaxCols-1 {339 t.Errorf("row header selected %s", got)340 }341}342343func TestDoubleClickEdits(t *testing.T) {344 m := newModel()345 press(t, m, "hi", "<enter>")346 click(m, cellX(0), gridTop, 0)347 click(m, cellX(0), gridTop, 0)348 if m.mode != modeEdit || m.line.Text() != "hi" {349 t.Errorf("mode %v buf %q", m.mode, m.line.Text())350 }351 press(t, m, "<esc>")352 m.mouse.lastClick = time.Time{}353 click(m, cellX(0), gridTop, 0)354 if m.mode != modeReady {355 t.Error("a single click edited")356 }357}358359func TestWheelDoesNotSnapBack(t *testing.T) {360 m := newModel()361 for range 5 {362 send(m, tea.MouseWheelMsg{Button: tea.MouseWheelDown})363 }364 if m.top != 15 || m.cur != addr("A1") {365 t.Fatalf("top %d cur %v", m.top, m.cur)366 }367 press(t, m, "<down>")368 if m.top != 1 {369 t.Errorf("moving the cursor should bring it back into view, top %d", m.top)370 }371}372373func TestGoto(t *testing.T) {374 m := newModel()375 press(t, m, "<ctrl+g>", "Z100", "<enter>")376 if m.cur != addr("Z100") || m.top == 0 || m.left == 0 {377 t.Errorf("cur %v top %d left %d", m.cur, m.top, m.left)378 }379 press(t, m, "<f5>", "nope", "<enter>")380 if m.mode != modeError {381 t.Errorf("mode %v", m.mode)382 }383 press(t, m, "<esc>")384 if m.mode != modeReady {385 t.Errorf("mode %v after dismiss", m.mode)386 }387}388389func TestSaveAndOpen(t *testing.T) {390 t.Chdir(t.TempDir())391 m := newModel()392 press(t, m, "Hello", "<enter>", "42", "<enter>")393 press(t, m, "<ctrl+s>", "budget", "<enter>")394 if m.filename != "budget.012" || m.changed {395 t.Fatalf("filename %q changed %v err %q", m.filename, m.changed, m.errMsg)396 }397 if _, err := os.Stat("budget.012"); err != nil {398 t.Fatal(err)399 }400 // Ctrl+S again saves without asking.401 press(t, m, "7", "<enter>", "<ctrl+s>")402 if m.mode != modeReady || m.changed {403 t.Errorf("second save: mode %v changed %v", m.mode, m.changed)404 }405406 m2 := newModel()407 press(t, m2, "<ctrl+o>")408 if !strings.Contains(line(m2, 2), "budget.012") {409 t.Errorf("file list %q", line(m2, 2))410 }411 press(t, m2, "budget", "<enter>")412 if m2.sheet.Value(addr("A2")).Num != 42 || m2.filename != "budget.012" {413 t.Errorf("opened A2 %+v file %q", m2.sheet.Value(addr("A2")), m2.filename)414 }415 press(t, m2, "<ctrl+o>", "missing", "<enter>")416 if m2.mode != modeError {417 t.Errorf("mode %v", m2.mode)418 }419}420421func TestScrollFollowsCursor(t *testing.T) {422 m := newModel()423 for range 40 {424 press(t, m, "<down>")425 }426 if m.top == 0 || m.cur.Row != 40 || m.cur.Row >= m.top+m.visibleRows() {427 t.Errorf("top %d cur %v rows %d", m.top, m.cur, m.visibleRows())428 }429 press(t, m, "<ctrl+home>")430 if m.top != 0 || m.cur != (sheet.Addr{}) {431 t.Errorf("ctrl+home: top %d cur %v", m.top, m.cur)432 }433}