1package ui23import (4 "fmt"5 "strings"6 "testing"78 tea "charm.land/bubbletea/v2"910 "src.rest/cat/012/internal/sheet"11)1213func init() {14 named["f4"] = tea.Key{Code: tea.KeyF4}15 named["end"] = tea.Key{Code: tea.KeyEnd}16}1718// A formula nested past the parser's cap stays in EDIT, saying why on19// the context line.20func TestTooDeepFormulaExplained(t *testing.T) {21 m := newModel()22 press(t, m, "="+strings.Repeat("(", 1100)+"1"+strings.Repeat(")", 1100), "<enter>")23 if m.mode != modeEdit || m.sheet.Cell(addr("A1")) != nil {24 t.Fatalf("mode %v, A1 %+v", m.mode, m.sheet.Cell(addr("A1")))25 }26 if got := line(m, contextLine); !strings.Contains(got, "Formula is nested too deeply (more than 1024 levels)") {27 t.Errorf("context line %q", got)28 }29}3031func TestUndoRedoKeys(t *testing.T) {32 t.Chdir(t.TempDir())33 m := newModel()34 press(t, m, "1", "<enter>", "=A1*2", "<enter>", "<ctrl+s>", "undo", "<enter>")35 if m.changed {36 t.Fatal("changed after save")37 }38 press(t, m, "<up>", "<delete>")39 if !m.changed || input(m, "A2") != "" {40 t.Fatalf("clear: changed %v A2 %q", m.changed, input(m, "A2"))41 }42 press(t, m, "<ctrl+home>", "<ctrl+z>")43 if input(m, "A2") != "=A1*2" || m.cur != addr("A2") || m.changed {44 t.Errorf("undo: A2 %q cur %v changed %v", input(m, "A2"), m.cur, m.changed)45 }46 if got := line(m, 2); got != "Undid: clear A2" {47 t.Errorf("context line %q", got)48 }49 press(t, m, "<ctrl+z>", "<ctrl+z>")50 if m.sheet.Len() != 0 || line(m, 2) != "Undid: edit A1" || !m.changed {51 t.Errorf("undo all: %d cells, line %q, changed %v", m.sheet.Len(), line(m, 2), m.changed)52 }53 press(t, m, "<ctrl+z>")54 if line(m, 2) != "Nothing to undo" {55 t.Errorf("context line %q", line(m, 2))56 }57 press(t, m, "<ctrl+y>", "<ctrl+shift+z>")58 if m.sheet.Value(addr("A2")).Num != 2 || line(m, 2) != "Redid: edit A2" {59 t.Errorf("redo: A2 %+v line %q", m.sheet.Value(addr("A2")), line(m, 2))60 }61 // The note goes away with the next key.62 press(t, m, "<down>")63 if line(m, 2) != "" {64 t.Errorf("note stayed: %q", line(m, 2))65 }66}6768func TestUndoColumnWidthPrompt(t *testing.T) {69 m := newModel()70 run(m, m.runCommand("column.width"))71 press(t, m, "<right>", "<right>", "<enter>")72 if m.sheet.ColWidth(0) != 12 {73 t.Fatalf("width %d", m.sheet.ColWidth(0))74 }75 press(t, m, "<ctrl+z>")76 if m.sheet.ColWidth(0) != 10 || m.sheet.CanUndo() {77 t.Errorf("width preview and result should be one step: width %d", m.sheet.ColWidth(0))78 }79 run(m, m.runCommand("column.width"))80 press(t, m, "<right>", "<esc>")81 if m.sheet.CanUndo() || m.changed {82 t.Errorf("cancelled width prompt left an undo step: %v %v %d %v", m.sheet.CanUndo(), m.changed, m.sheet.ColWidth(0), m.mode)83 }84}8586// clipboardText runs the copy command and returns what it sends to the87// system clipboard.88func clipboardText(t *testing.T, m *Model, id string) string {89 t.Helper()90 cmd := m.runCommand(id)91 if cmd == nil {92 t.Fatal("no clipboard command")93 }94 return fmt.Sprint(cmd())95}9697func TestCopyPaste(t *testing.T) {98 m := newModel()99 press(t, m, "1", "<tab>", "=A1*2", "<enter>", "2", "<enter>", "<ctrl+home>", "<shift+right>")100 if got := clipboardText(t, m, "edit.copy"); got != "1\t2" {101 t.Errorf("system clipboard %q", got)102 }103 if l := line(m, 2); !m.copied.marks(m.sheet, addr("B1")) || !strings.HasPrefix(l, "Copied A1:B1") || !strings.Contains(l, "Ctrl+V") {104 t.Fatalf("marker %v, line %q", m.copied.marks(m.sheet, addr("B1")), line(m, 2))105 }106 press(t, m, "<down>", "<ctrl+v>")107 if input(m, "B2") != "=A2*2" || m.sheet.Value(addr("B2")).Num != 2 || m.selection().String() != "A2:B2" {108 t.Errorf("paste: B2 %q = %+v, selection %v", input(m, "B2"), m.sheet.Value(addr("B2")), m.selection())109 }110 if line(m, 2) != "Pasted 2 cells at A2:B2" || !m.copied.marked {111 t.Errorf("after paste: line %q marked %v", line(m, 2), m.copied.marked)112 }113 // Pasting into a larger multiple tiles.114 press(t, m, "<down>", "<shift+down>", "<shift+down>", "<shift+right>", "<ctrl+v>")115 if input(m, "B5") != "=A5*2" || m.selection().String() != "A3:B5" {116 t.Errorf("tiled paste: B5 %q selection %v", input(m, "B5"), m.selection())117 }118 press(t, m, "<esc>")119 if m.copied.marked || line(m, 2) != "" {120 t.Errorf("Esc kept the marker: line %q", line(m, 2))121 }122 // Ctrl+V still pastes after the marker is gone.123 press(t, m, "<right>", "<right>", "<ctrl+shift+v>")124 if input(m, "C3") != "1" || input(m, "D3") != "2" {125 t.Errorf("paste values: %q %q", input(m, "C3"), input(m, "D3"))126 }127}128129func TestEditClearsCopyMarker(t *testing.T) {130 m := newModel()131 press(t, m, "1", "<enter>", "<up>", "<ctrl+c>", "<down>", "x", "<enter>")132 if m.copied.marked {133 t.Error("an edit kept the copy marker")134 }135 press(t, m, "<ctrl+v>")136 if input(m, "A3") != "1" {137 t.Errorf("paste after edit: %q", input(m, "A3"))138 }139}140141func TestCutPasteMoves(t *testing.T) {142 m := newModel()143 press(t, m, "5", "<enter>", "=A1+1", "<enter>", "<ctrl+home>", "<ctrl+x>")144 if l := line(m, 2); !strings.HasPrefix(l, "Cut A1") || !strings.Contains(l, "move here") {145 t.Errorf("context line %q", line(m, 2))146 }147 press(t, m, "<right>", "<right>", "<ctrl+v>")148 if input(m, "A1") != "" || input(m, "C1") != "5" || input(m, "A2") != "=C1+1" {149 t.Errorf("move: A1 %q C1 %q A2 %q", input(m, "A1"), input(m, "C1"), input(m, "A2"))150 }151 if m.copied.clip != nil || line(m, 2) != "Moved A1 to C1" {152 t.Errorf("cut should paste once: line %q", line(m, 2))153 }154 press(t, m, "<ctrl+z>")155 if input(m, "A1") != "5" || input(m, "A2") != "=A1+1" {156 t.Errorf("undo move: A1 %q A2 %q", input(m, "A1"), input(m, "A2"))157 }158}159160func TestCopyMarkerDrawn(t *testing.T) {161 m := newModel()162 press(t, m, "1", "<enter>")163 before := m.gridRow(0)164 press(t, m, "<up>", "<ctrl+c>", "<down>")165 if m.gridRow(0) == before || !strings.Contains(m.gridRow(0), "\x1b[") {166 t.Errorf("copy marker not drawn: %q", m.gridRow(0))167 }168}169170func TestPasteTSVFromTerminal(t *testing.T) {171 m := newModel()172 press(t, m, "<right>")173 send(m, tea.PasteMsg{Content: "Qty\tPrice\r\n2\t3\r\n\t=B2*C2\r\n"})174 want := map[string]string{"B1": "Qty", "C1": "Price", "B2": "2", "C2": "3", "C3": "=B2*C2"}175 for a, in := range want {176 if input(m, a) != in {177 t.Errorf("%s = %q, want %q", a, input(m, a), in)178 }179 }180 if m.sheet.Value(addr("C3")).Num != 6 || m.selection().String() != "B1:C3" || line(m, 2) != "Pasted 6 cells at B1:C3" {181 t.Errorf("C3 %+v selection %v line %q", m.sheet.Value(addr("C3")), m.selection(), line(m, 2))182 }183 press(t, m, "<ctrl+z>")184 if m.sheet.Len() != 0 {185 t.Errorf("paste should undo in one step, %d cells left", m.sheet.Len())186 }187 // A block of more than max-cells cells is refused.188 sheet.SetMaxCells(5)189 defer sheet.SetMaxCells(0)190 send(m, tea.PasteMsg{Content: "Qty\tPrice\r\n2\t3\r\n\t=B2*C2\r\n"})191 if m.sheet.Len() != 0 || m.mode != modeError || !strings.Contains(m.errMsg, "max-cells") {192 t.Errorf("a paste past max-cells wrote %d cells, error %q", m.sheet.Len(), m.errMsg)193 }194 press(t, m, "<esc>")195 // A single line still starts an entry.196 send(m, tea.PasteMsg{Content: "hello\n"})197 if m.mode != modeEnter || m.line.Text() != "hello" {198 t.Errorf("mode %v buf %q", m.mode, m.line.Text())199 }200}201202func TestTSVRoundTrip(t *testing.T) {203 rows := [][]string{{"a", "tab\there", ""}, {`"quoted"`, "line\nbreak", `5" pipe`}, {"", "", ""}}204 text := formatTSV(rows)205 got := parseTSV(text)206 if fmt.Sprint(got) != fmt.Sprint(rows) {207 t.Errorf("round trip:\n%q\n%q", got, rows)208 }209 if got := parseTSV("a\t\tb\n\nc"); fmt.Sprint(got) != fmt.Sprint([][]string{{"a", "", "b"}, {""}, {"c"}}) {210 t.Errorf("blank fields and lines: %q", got)211 }212}213214func TestFillKeys(t *testing.T) {215 m := newModel()216 press(t, m, "1", "<enter>", "2", "<enter>", "<ctrl+home>", "<right>", "=A1*10", "<enter>")217 press(t, m, "<up>", "<shift+down>", "<ctrl+d>")218 if input(m, "B2") != "=A2*10" || m.sheet.Value(addr("B2")).Num != 20 {219 t.Errorf("fill down: B2 %q", input(m, "B2"))220 }221 press(t, m, "<shift+right>", "<ctrl+r>")222 if input(m, "C2") != "=B2*10" {223 t.Errorf("fill right: C2 %q", input(m, "C2"))224 }225 // Ctrl+Enter fills the entry into the selection, keeping it selected.226 press(t, m, "<esc>", "<down>", "<down>", "<shift+down>", "<shift+right>", "=A1+$A$1", "<ctrl+enter>")227 if input(m, "C4") != "=B2+$A$1" || m.mode != modeReady || m.selection().String() != "B3:C4" {228 t.Errorf("ctrl+enter: C4 %q mode %v selection %v", input(m, "C4"), m.mode, m.selection())229 }230 press(t, m, "<ctrl+z>")231 if input(m, "B3") != "" || input(m, "C4") != "" {232 t.Error("ctrl+enter fill is not one undo step")233 }234}235236func TestInsertDeleteRowsAndColumns(t *testing.T) {237 m := newModel()238 press(t, m, "1", "<enter>", "2", "<enter>", "=SUM(A1:A2)", "<enter>")239 press(t, m, "<up>", "<up>")240 m.runCommand("insert.row_above")241 if input(m, "A4") != "=SUM(A1:A3)" || input(m, "A2") != "" {242 t.Fatalf("insert above: A4 %q", input(m, "A4"))243 }244 m.runCommand("insert.row_below")245 if input(m, "A5") != "=SUM(A1:A4)" {246 t.Errorf("insert below: A5 %q", input(m, "A5"))247 }248 press(t, m, "<ctrl+home>")249 m.runCommand("delete.row")250 if input(m, "A4") != "=SUM(A1:A3)" || m.sheet.Value(addr("A4")).Num != 2 {251 t.Errorf("delete: A4 %q = %+v", input(m, "A4"), m.sheet.Value(addr("A4")))252 }253 m.runCommand("insert.col_left")254 if input(m, "B4") != "=SUM(B1:B3)" {255 t.Errorf("insert col: B4 %q", input(m, "B4"))256 }257 m.runCommand("insert.col_right")258 press(t, m, "<right>")259 m.runCommand("delete.col")260 if input(m, "B4") != "=SUM(B1:B3)" {261 t.Errorf("delete col: B4 %q", input(m, "B4"))262 }263 press(t, m, "<ctrl+z>")264 if line(m, 2) != "Undid: delete 1 column" || m.whole != wholeCols {265 t.Errorf("undo: line %q whole %v", line(m, 2), m.whole)266 }267 // Inserting can't push data off the sheet.268 press(t, m, "<esc>", "<ctrl+end>", "<ctrl+space>")269 m.runCommand("insert.row_above")270 if m.mode != modeError {271 t.Errorf("mode %v", m.mode)272 }273}274275func TestF4CyclesReferences(t *testing.T) {276 m := newModel()277 press(t, m, "=A1+B2")278 for _, want := range []string{"=A1+$B$2", "=A1+B$2", "=A1+$B2", "=A1+B2"} {279 press(t, m, "<f4>")280 if m.line.Text() != want {281 t.Errorf("F4 gave %q, want %q", m.line.Text(), want)282 }283 }284 // F4 while pointing puts the reference in first.285 press(t, m, "*", "<up>", "<f4>")286 if m.line.Text() != "=A1+B2*$A$1" || m.mode != modeEnter {287 t.Errorf("F4 in POINT: %q mode %v", m.line.Text(), m.mode)288 }289}290291func TestInsertDeleteKeysFollowSelection(t *testing.T) {292 m := newModel()293 press(t, m, "1", "<tab>", "2", "<enter>", "=A1+B1", "<enter>", "<ctrl+home>")294 press(t, m, "<ctrl+alt+=>")295 if input(m, "A3") != "=A2+B2" {296 t.Errorf("Ctrl+Alt+= on a cell inserts a row: A3 %q", input(m, "A3"))297 }298 press(t, m, "<ctrl+alt+->")299 press(t, m, "<ctrl+space>", "<ctrl+alt+=>")300 if input(m, "B2") != "=B1+C1" {301 t.Errorf("Ctrl+Alt+= on columns inserts a column: B2 %q", input(m, "B2"))302 }303 press(t, m, "<right>", "<right>", "<ctrl+space>", "<ctrl+alt+->")304 if input(m, "B2") != "=B1+#REF!" {305 t.Errorf("Ctrl+Alt+- on columns deletes them: B2 %q", input(m, "B2"))306 }307}