1package ui23import (4 "strings"5 "testing"67 tea "charm.land/bubbletea/v2"8 "github.com/charmbracelet/x/ansi"910 "src.rest/cat/012/internal/sheet"11 "src.rest/cat/012/internal/ui/tabstrip"12)1314// status is the status line, where the tabs are.15func status(m *Model) string { return line(m, m.height-1) }1617// tabX is the screen x of the middle of a sheet's tab, or of the + for18// name "+".19func tabX(t *testing.T, m *Model, name string) int {20 t.Helper()21 _, spans := m.statusLayout()22 for _, sp := range spans {23 if name == "+" && sp.Kind == tabstrip.Add || sp.Kind == tabstrip.Tab && m.tabSheet(sp.Index).Name() == name {24 return sp.X + sp.W/225 }26 }27 t.Fatalf("no tab %q in %q", name, status(m))28 return 029}3031// sheetNames lists the workbook's sheets in tab order.32func sheetNames(m *Model) string {33 var names []string34 for _, s := range m.book().Sheets() {35 names = append(names, s.Name())36 }37 return strings.Join(names, ",")38}3940func TestTabKeys(t *testing.T) {41 m := newModel()42 if s := status(m); !strings.HasPrefix(s, " Sheet1 + │ untitled") {43 t.Fatalf("status %q", s)44 }45 press(t, m, "<down>", "<down>", "<f11>") // plain F11 does nothing46 press(t, m, "<shift+f11>")47 if m.sheet.Name() != "Sheet2" || m.cur != addr("A1") || !strings.HasPrefix(status(m), " Sheet1 Sheet2 +") {48 t.Fatalf("new sheet: %s at %v, status %q", m.sheet.Name(), m.cur, status(m))49 }50 press(t, m, "x", "<enter>", "<right>")51 // Each sheet remembers where its cursor was.52 press(t, m, "<ctrl+pgup>")53 if m.sheet.Name() != "Sheet1" || m.cur != addr("A3") || input(m, "A1") != "" {54 t.Errorf("previous: %s at %v", m.sheet.Name(), m.cur)55 }56 press(t, m, "<ctrl+pgup>") // no wrapping57 if m.sheet.Name() != "Sheet1" {58 t.Errorf("wrapped to %s", m.sheet.Name())59 }60 press(t, m, "<alt+right>")61 if m.sheet.Name() != "Sheet2" || m.cur != addr("B2") || input(m, "A1") != "x" {62 t.Errorf("next: %s at %v", m.sheet.Name(), m.cur)63 }64 press(t, m, "<alt+left>", "<ctrl+pgdown>")65 if m.sheet.Name() != "Sheet2" {66 t.Errorf("ctrl+pgdown: %s", m.sheet.Name())67 }68 // Undo is workbook-wide and shows the sheet it changes.69 press(t, m, "<ctrl+pgup>", "<ctrl+z>")70 if m.sheet.Name() != "Sheet2" || input(m, "A1") != "" || !strings.Contains(line(m, contextLine), "Undid: edit A1") {71 t.Errorf("undo: %s, %q", m.sheet.Name(), line(m, contextLine))72 }73 press(t, m, "<ctrl+z>")74 if m.book().Len() != 1 || m.sheet.Name() != "Sheet1" || m.cur != addr("A3") {75 t.Errorf("undo the new sheet: %d sheets, on %s at %v", m.book().Len(), m.sheet.Name(), m.cur)76 }77 press(t, m, "<ctrl+y>")78 if m.book().Len() != 2 || m.sheet.Name() != "Sheet2" {79 t.Errorf("redo: %d sheets, on %s", m.book().Len(), m.sheet.Name())80 }81}8283func TestTabMouse(t *testing.T) {84 m := newModel()85 leftClick(m, tabX(t, m, "+"), m.height-1)86 leftClick(m, tabX(t, m, "+"), m.height-1)87 if sheetNames(m) != "Sheet1,Sheet2,Sheet3" || m.sheet.Name() != "Sheet3" {88 t.Fatalf("+ twice: %s, on %s", sheetNames(m), m.sheet.Name())89 }90 leftClick(m, tabX(t, m, "Sheet1"), m.height-1)91 if m.sheet.Name() != "Sheet1" {92 t.Errorf("click: on %s", m.sheet.Name())93 }94 // Double-click renames, on the context line.95 x := tabX(t, m, "Sheet2")96 leftClick(m, x, m.height-1)97 leftClick(m, x, m.height-1)98 if m.mode != modePrompt || !strings.HasPrefix(line(m, contextLine), "Rename sheet: Sheet2") {99 t.Fatalf("double-click: mode %v, %q", m.mode, line(m, contextLine))100 }101 press(t, m, "Q3 plan", "<enter>")102 if sheetNames(m) != "Sheet1,Q3 plan,Sheet3" || !strings.Contains(line(m, contextLine), "Renamed Sheet2 to Q3 plan") {103 t.Errorf("renamed: %s, %q", sheetNames(m), line(m, contextLine))104 }105 // Dragging a tab onto another moves it there.106 x = tabX(t, m, "Sheet3")107 send(m, tea.MouseClickMsg{X: x, Y: m.height - 1, Button: tea.MouseLeft})108 to := tabX(t, m, "Sheet1")109 send(m, tea.MouseMotionMsg{X: to, Y: m.height - 1, Button: tea.MouseLeft})110 send(m, tea.MouseReleaseMsg{X: to, Y: m.height - 1, Button: tea.MouseLeft})111 if sheetNames(m) != "Sheet3,Sheet1,Q3 plan" || m.sheet.Name() != "Sheet3" {112 t.Errorf("drag: %s, on %s", sheetNames(m), m.sheet.Name())113 }114 // Right-click opens the tab's menu over the status line.115 send(m, tea.MouseClickMsg{X: tabX(t, m, "Q3 plan"), Y: m.height - 1, Button: tea.MouseRight})116 o := openMenu(t, m)117 if m.sheet.Name() != "Q3 plan" || o.levels[0].items[0].label() != "Rename" {118 t.Fatalf("right-click: on %s, menu %v", m.sheet.Name(), o.levels[0].items)119 }120 press(t, m, "<down>", "<enter>") // Duplicate121 if sheetNames(m) != "Sheet3,Sheet1,Q3 plan,Copy of Q3 plan" || m.sheet.Name() != "Copy of Q3 plan" {122 t.Errorf("duplicate: %s, on %s", sheetNames(m), m.sheet.Name())123 }124}125126func TestRenameErrors(t *testing.T) {127 m := newModel()128 press(t, m, "<shift+f11>")129 m.runCommand("sheet.rename")130 press(t, m, "sheet1", "<enter>")131 if m.mode != modeError || !strings.Contains(status(m), "There's already a sheet named Sheet1") {132 t.Errorf("duplicate name: mode %v, %q", m.mode, status(m))133 }134 press(t, m, "x")135 m.runCommand("sheet.rename")136 press(t, m, "<esc>")137 if m.mode != modeReady || m.sheet.Name() != "Sheet2" {138 t.Errorf("esc: mode %v, %s", m.mode, m.sheet.Name())139 }140}141142func TestDeleteSheetAsks(t *testing.T) {143 m := newModel()144 press(t, m, "<shift+f11>", "1", "<enter>", "2", "<enter>")145 if !commands["sheet.delete"].available(m) {146 t.Fatal("delete unavailable with two sheets")147 }148 m.runCommand("sheet.delete")149 if l := line(m, contextLine); !strings.Contains(l, "Delete Sheet2 and its 2 cells?") {150 t.Fatalf("question %q", l)151 }152 press(t, m, "<enter>")153 if sheetNames(m) != "Sheet1" || m.sheet.Name() != "Sheet1" || commands["sheet.delete"].available(m) {154 t.Errorf("deleted: %s, on %s", sheetNames(m), m.sheet.Name())155 }156 press(t, m, "<ctrl+z>")157 if sheetNames(m) != "Sheet1,Sheet2" || m.sheet.Name() != "Sheet2" || input(m, "A2") != "2" {158 t.Errorf("undo: %s, on %s", sheetNames(m), m.sheet.Name())159 }160 // An empty sheet goes without asking.161 press(t, m, "<shift+f11>")162 m.runCommand("sheet.delete")163 if m.mode != modeReady || sheetNames(m) != "Sheet1,Sheet2" {164 t.Errorf("empty sheet: mode %v, %s", m.mode, sheetNames(m))165 }166}167168// Typing a formula and switching sheets points into the other sheet, as169// clicking a tab while editing does in Sheets.170func TestPointIntoAnotherSheet(t *testing.T) {171 m := newModel()172 press(t, m, "<shift+f11>", "<down>", "5", "<enter>", "<ctrl+pgup>")173 press(t, m, "<right>", "=")174 press(t, m, "<ctrl+pgdown>")175 if m.mode != modePoint || m.sheet.Name() != "Sheet2" || bar(m) != "=Sheet2!A3" {176 t.Fatalf("pointing: mode %v on %s, bar %q", m.mode, m.sheet.Name(), bar(m))177 }178 if name := strings.TrimSpace(ansi.Cut(line(m, formulaLine), 0, nameBoxW)); name != "Sheet1!B1" {179 t.Errorf("name box %q", name)180 }181 press(t, m, "<up>", "*2")182 if m.mode != modeEnter || bar(m) != "=Sheet2!A2*2" || m.sheet.Name() != "Sheet2" {183 t.Fatalf("typing on: mode %v on %s, bar %q", m.mode, m.sheet.Name(), bar(m))184 }185 // A click on the other sheet points too.186 press(t, m, "+")187 click(m, cellX(0), gridTop+1, 0)188 if bar(m) != "=Sheet2!A2*2+Sheet2!A2" {189 t.Errorf("click: bar %q", bar(m))190 }191 // Back on the entry's sheet, references have no sheet.192 press(t, m, "<ctrl+pgup>")193 if m.sheet.Name() != "Sheet1" || bar(m) != "=Sheet2!A2*2+B1" {194 t.Errorf("back home: on %s, bar %q", m.sheet.Name(), bar(m))195 }196 press(t, m, "<left>", "<enter>")197 if m.sheet.Name() != "Sheet1" || m.cur != addr("B2") || input(m, "B1") != "=Sheet2!A2*2+A1" || m.sheet.Value(addr("B1")).Num != 10 {198 t.Errorf("stored: on %s at %v, B1 %q = %v", m.sheet.Name(), m.cur, input(m, "B1"), m.sheet.Value(addr("B1")))199 }200 // Enter while pointing on another sheet stores the formula and201 // returns to its cell's sheet.202 press(t, m, "=", "<alt+right>", "<enter>")203 if m.sheet.Name() != "Sheet1" || input(m, "B2") != "=Sheet2!A2" || m.cur != addr("B3") {204 t.Errorf("enter away: on %s, B2 %q, at %v", m.sheet.Name(), input(m, "B2"), m.cur)205 }206 // Esc while typing on another sheet cancels and returns.207 press(t, m, "=", "<alt+right>", "<esc>", "<esc>")208 if m.sheet.Name() != "Sheet1" || m.mode != modeReady || input(m, "B3") != "" {209 t.Errorf("esc away: on %s, mode %v", m.sheet.Name(), m.mode)210 }211 // Text isn't a formula: switching stores it first.212 press(t, m, "hello", "<alt+right>")213 if m.sheet.Name() != "Sheet2" || m.mode != modeReady || m.book().Sheet(0).Cell(addr("B3")).Input != "hello" {214 t.Errorf("text: on %s, mode %v", m.sheet.Name(), m.mode)215 }216}217218func TestGotoOtherSheet(t *testing.T) {219 m := newModel()220 press(t, m, "<shift+f11>")221 m.runCommand("sheet.rename")222 press(t, m, "Q3 plan", "<enter>", "<ctrl+pgup>")223 m.runCommand("goto")224 press(t, m, "'Q3 plan'!C4", "<enter>")225 if m.sheet.Name() != "Q3 plan" || m.cur != addr("C4") {226 t.Errorf("goto: %s %v", m.sheet.Name(), m.cur)227 }228 m.runCommand("goto")229 press(t, m, "Sheet1!B2:C3", "<enter>")230 if m.sheet.Name() != "Sheet1" || m.selection() != sheet.NewRect(addr("B2"), addr("C3")) {231 t.Errorf("goto range: %s %v", m.sheet.Name(), m.selection())232 }233 m.runCommand("goto")234 press(t, m, "Nope!A1", "<enter>")235 if m.mode != modeError || !strings.Contains(status(m), "There's no sheet named Nope") {236 t.Errorf("unknown sheet: %q", status(m))237 }238}239240func TestSheetPicker(t *testing.T) {241 m := newModel()242 for range 3 {243 press(t, m, "<shift+f11>")244 }245 press(t, m, "<alt+shift+k>")246 p := openPicker(t, m)247 if len(p.Shown()) != 4 || p.Sel != 3 {248 t.Fatalf("picker: %d shown, %d selected", len(p.Shown()), p.Sel)249 }250 press(t, m, "sheet2", "<enter>")251 if m.overlay != nil || m.sheet.Name() != "Sheet2" {252 t.Errorf("picked %s", m.sheet.Name())253 }254}255256func TestManyTabsScroll(t *testing.T) {257 m := newModel()258 m.Update(tea.WindowSizeMsg{Width: 60, Height: 16})259 for range 11 {260 press(t, m, "<shift+f11>")261 }262 s := status(m)263 if !strings.HasPrefix(s, "‹ Sheet") || !strings.Contains(s, " Sheet12 ") || strings.Contains(s, " Sheet1 ") {264 t.Errorf("last sheet shown: %q", s)265 }266 for range 11 {267 press(t, m, "<ctrl+pgup>")268 }269 s = status(m)270 if !strings.HasPrefix(s, " Sheet1 ") || !strings.Contains(s, "›") {271 t.Errorf("first sheet shown: %q", s)272 }273 // The arrows step through the sheets.274 leftClick(m, strings.Index(s, "›"), m.height-1)275 if m.sheet.Name() != "Sheet2" {276 t.Errorf("› went to %s", m.sheet.Name())277 }278}279280func TestCopyAndCutBetweenSheets(t *testing.T) {281 m := newModel()282 press(t, m, "5", "<tab>", "=A1*2", "<enter>")283 press(t, m, "<up>", "<shift+right>", "<ctrl+c>", "<shift+f11>", "<ctrl+v>")284 if input(m, "B1") != "=A1*2" || m.sheet.Value(addr("B1")).Num != 10 {285 t.Errorf("paste: B1 %q = %v", input(m, "B1"), m.sheet.Value(addr("B1")))286 }287 press(t, m, "<ctrl+pgup>", "<ctrl+home>", "<ctrl+x>", "<ctrl+pgdown>", "<down>", "<down>")288 if !strings.Contains(line(m, contextLine), "Cut Sheet1!A1") {289 t.Errorf("cut on another sheet: %q", line(m, contextLine))290 }291 press(t, m, "<ctrl+v>")292 one := m.book().Sheet(0)293 if input(m, "A3") != "5" || one.Cell(addr("B1")).Input != "=Sheet2!A3*2" || one.Value(addr("B1")).Num != 10 {294 t.Errorf("move: A3 %q, Sheet1!B1 %q", input(m, "A3"), one.Cell(addr("B1")).Input)295 }296}297298func TestFindAllSheets(t *testing.T) {299 m := newModel()300 press(t, m, "rent", "<enter>", "<shift+f11>", "<down>", "rental", "<enter>", "<ctrl+pgup>")301 press(t, m, "<ctrl+f>", "rent")302 f := findBarOf(t, m)303 if len(f.Matches()) != 1 || !strings.Contains(line(m, contextLine), "in Sheet1") {304 t.Fatalf("this sheet: %d matches, %q", len(f.Matches()), line(m, contextLine))305 }306 press(t, m, "<alt+s>")307 if len(f.Matches()) != 2 || !strings.Contains(line(m, contextLine), "in all sheets") {308 t.Fatalf("all sheets: %d matches, %q", len(f.Matches()), line(m, contextLine))309 }310 press(t, m, "<enter>")311 if m.sheet.Name() != "Sheet2" || m.cur != addr("A2") || !strings.Contains(line(m, contextLine), "2 of 2 on Sheet2") {312 t.Errorf("next: on %s at %v, %q", m.sheet.Name(), m.cur, line(m, contextLine))313 }314 if !m.found(addr("A2")) || m.found(addr("A1")) {315 t.Error("highlights not per sheet")316 }317 press(t, m, "<ctrl+h>", "lease", "<ctrl+enter>")318 if input(m, "A2") != "leaseal" || m.book().Sheet(0).Cell(addr("A1")).Input != "lease" {319 t.Errorf("replace all: %q", input(m, "A2"))320 }321 press(t, m, "<esc>", "<ctrl+z>")322 if input(m, "A2") != "rental" || m.book().Sheet(0).Cell(addr("A1")).Input != "rent" {323 t.Errorf("one undo: %q", input(m, "A2"))324 }325}326327func TestNamedRangeOnAnotherSheet(t *testing.T) {328 m := newModel()329 press(t, m, "<shift+f11>", "3", "<enter>", "4", "<enter>")330 m.sheet.DefineName("Costs", sheet.NewRect(addr("A1"), addr("A2")))331 press(t, m, "<ctrl+pgup>", "=SUM(Costs)", "<enter>")332 if v := m.sheet.Value(addr("A1")); v.Num != 7 {333 t.Errorf("SUM(Costs) = %v", v)334 }335 m.runCommand("data.named_ranges")336 if !strings.Contains(screen(m), "Sheet2!A1:A2") {337 t.Errorf("names list doesn't say the sheet:\n%s", screen(m))338 }339 press(t, m, "cos", "<enter>")340 if m.sheet.Name() != "Sheet2" || m.selection() != sheet.NewRect(addr("A1"), addr("A2")) {341 t.Errorf("go to name: on %s, %v", m.sheet.Name(), m.selection())342 }343 if name := strings.TrimSpace(ansi.Cut(line(m, formulaLine), 0, nameBoxW)); name != "Costs" {344 t.Errorf("name box %q", name)345 }346 press(t, m, "<ctrl+pgup>", "<ctrl+home>", "<shift+down>")347 if name := strings.TrimSpace(ansi.Cut(line(m, formulaLine), 0, nameBoxW)); name != "A1:A2" {348 t.Errorf("name box on Sheet1 %q", name)349 }350}351352func TestTraceIntoOtherSheet(t *testing.T) {353 m := newModel()354 press(t, m, "<shift+f11>", "7", "<enter>", "<ctrl+pgup>", "=Sheet2!A1*2", "<enter>", "<up>")355 press(t, m, "<alt+,>")356 if m.sheet.Name() != "Sheet2" || m.cur != addr("A1") || !strings.Contains(line(m, contextLine), "1 precedent of Sheet1!A1: Sheet2!A1") {357 t.Errorf("trace: on %s at %v, %q", m.sheet.Name(), m.cur, line(m, contextLine))358 }359 press(t, m, "<esc>")360 if m.sheet.Name() != "Sheet1" || m.cur != addr("A1") {361 t.Errorf("esc: on %s at %v", m.sheet.Name(), m.cur)362 }363}