cat / 012
clone

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

cat/module-path
012 / internal / ui / data_test.go
data_test.go 7.5K
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/filterpick"12)1314// handleX is the screen x of the fill handle in column c (scrolled to A).15func handleX(c int) int { return minRowHdrW + (c+1)*sheet.DefaultWidth - 1 }1617func TestFillHandleDrag(t *testing.T) {18 tests := []struct {19 name string20 cells []string // A1 down21 to sheet.Addr22 want map[string]string23 sel string24 }{25 {"down continues a series", []string{"1", "2"}, addr("A5"), map[string]string{"A3": "3", "A5": "5"}, "A1:A5"},26 {"right, further out than down", []string{"Mon"}, addr("C2"), map[string]string{"B1": "Tue", "C1": "Wed", "A2": ""}, "A1:C1"},27 {"back inside fills nothing", []string{"x"}, addr("A1"), map[string]string{"A2": ""}, "A1"},28 }29 for _, tt := range tests {30 t.Run(tt.name, func(t *testing.T) {31 m := fillModel(tt.cells)32 y := gridTop + len(tt.cells) - 133 hoverFillHandle(t, m, y)34 send(m, tea.MouseClickMsg(leftAt(handleX(0), y)))35 to := leftAt(cellX(tt.to.Col), gridTop+tt.to.Row)36 send(m, tea.MouseMotionMsg(to))37 if tt.sel != "A1" && !strings.Contains(line(m, contextLine), "Fill "+tt.sel) {38 t.Errorf("context line %q", line(m, contextLine))39 }40 send(m, tea.MouseReleaseMsg(to))41 for a, in := range tt.want {42 if got := input(m, a); got != in {43 t.Errorf("%s = %q, want %q", a, got, in)44 }45 }46 if got := m.selection().String(); got != tt.sel {47 t.Errorf("selection %s, want %s", got, tt.sel)48 }49 if tt.sel != "A1" {50 press(t, m, "<ctrl+z>")51 if m.sheet.CanUndo() || m.sheet.Len() != len(tt.cells) {52 t.Error("fill isn't one undo step")53 }54 }55 })56 }57}5859// fillModel has cells entered from A1 down and selected.60func fillModel(cells []string) *Model {61 m := newModel()62 for i, in := range cells {63 m.sheet.Set(sheet.Addr{Row: i}, in)64 }65 m.sheet.ClearHistory()66 m.cur, m.ext = addr("A1"), sheet.Addr{Row: len(cells) - 1}67 m.selecting = len(cells) > 168 return m69}7071// hoverFillHandle hovers the corner cell on line y and checks that the72// fill handle shows there.73func hoverFillHandle(t *testing.T, m *Model, y int) {74 t.Helper()75 send(m, tea.MouseMotionMsg{X: cellX(0), Y: y})76 if !strings.Contains(line(m, y), "▟") {77 t.Fatalf("no handle: %q", line(m, y))78 }79 if h := m.hitTest(handleX(0), y); h.kind != hitFillHandle {80 t.Fatalf("hit %+v", h)81 }82}8384func TestFillHandleEscCancels(t *testing.T) {85 m := newModel()86 press(t, m, "1", "<enter>", "<up>")87 send(m, tea.MouseClickMsg(leftAt(handleX(0), gridTop)))88 send(m, tea.MouseMotionMsg(leftAt(cellX(0), gridTop+4)))89 press(t, m, "<esc>")90 send(m, tea.MouseReleaseMsg(leftAt(cellX(0), gridTop+4)))91 if input(m, "A2") != "" || m.mouse.drag != dragNone {92 t.Errorf("A2 %q drag %v", input(m, "A2"), m.mouse.drag)93 }94}9596func TestFillDownKeyContinuesSeries(t *testing.T) {97 m := newModel()98 press(t, m, "Item 1", "<enter>", "Item 2", "<enter>", "<up>", "<up>")99 press(t, m, "<shift+down>", "<shift+down>", "<shift+down>", "<ctrl+d>")100 if input(m, "A4") != "Item 4" {101 t.Errorf("A4 = %q", input(m, "A4"))102 }103}104105func TestSortBar(t *testing.T) {106 m := newModel()107 table(t, m, 6) // Qty: 1 2 3 0 1 2108 press(t, m, "<ctrl+home>", "<right>")109 m.runCommand("data.sort_range")110 if m.mode != modeMenu || line(m, menuLine)[len(line(m, menuLine))-4:] != "SORT" {111 t.Fatalf("mode %v, %q", m.mode, line(m, menuLine))112 }113 // The header row is found, and the range excludes it.114 if l := line(m, contextLine); !strings.HasPrefix(l, "Sort A2:B7 by B Qty A→Z") || !strings.Contains(l, "Header row") {115 t.Errorf("bar %q", l)116 }117 press(t, m, "<space>", "<alt+a>")118 if l := line(m, contextLine); !strings.Contains(l, "B Qty Z→A then A Item A→Z") {119 t.Errorf("bar %q", l)120 }121 press(t, m, "<left>") // stays in the range122 press(t, m, "<enter>")123 want := []string{"3", "2", "2", "1", "1", "0"}124 wantA := []string{"Row 3", "Row 2", "Row 6", "Row 1", "Row 5", "Row 4"}125 for i := range want {126 a, b := sheet.Addr{Row: i + 1}, sheet.Addr{Col: 1, Row: i + 1}127 if input(m, b.String()) != want[i] || input(m, a.String()) != wantA[i] {128 t.Errorf("row %d: %q %q", i+2, input(m, a.String()), input(m, b.String()))129 }130 }131 if m.note != "Sorted A2:B7 by B Z→A, then A A→Z" || m.mode != modeReady {132 t.Errorf("note %q mode %v", m.note, m.mode)133 }134 // Esc cancels without sorting; Alt+H drops the header row.135 m.runCommand("data.sort_range")136 press(t, m, "<alt+h>")137 if !strings.HasPrefix(line(m, contextLine), "Sort A1:B7 by B ") {138 t.Errorf("without header %q", line(m, contextLine))139 }140 press(t, m, "<esc>")141 if m.mode != modeReady || input(m, "A1") != "Item" {142 t.Error("Esc didn't cancel")143 }144}145146func TestSortSheetKeepsFrozenRows(t *testing.T) {147 m := newModel()148 table(t, m, 4) // Qty: 1 2 3 0149 m.sheet.SetFrozen(1, 0)150 press(t, m, "<right>", "<down>")151 m.runCommand("data.sort_sheet_za")152 if input(m, "B1") != "Qty" || input(m, "B2") != "3" || input(m, "B5") != "0" {153 t.Errorf("B = %q %q %q", input(m, "B1"), input(m, "B2"), input(m, "B5"))154 }155}156157func TestFilterPicker(t *testing.T) {158 m := newModel()159 table(t, m, 8) // Qty: 1 2 3 0 1 2 3 0160 press(t, m, "<right>") // B1, inside the data161 if commands["data.filter_column"].available(m) {162 t.Error("filter by column available without a filter")163 }164 m.runCommand("data.filter")165 if r, ok := m.sheet.FilterRange(); !ok || r.String() != "A1:B9" {166 t.Fatalf("filter %v %v", r, ok)167 }168 press(t, m, "<alt+down>")169 if _, ok := m.overlay.(*filterpick.Picker); !ok {170 t.Fatalf("overlay %T", m.overlay)171 }172 out := screen(m)173 for _, want := range []string{"Filter B Qty", "If ‹ None ›", "[x] Select all", "[x] 0", "4 of 4"} {174 if !strings.Contains(out, want) {175 t.Errorf("picker lacks %q:\n%s", want, out)176 }177 }178 // Uncheck 0 and 1; the search narrows the list.179 press(t, m, "<down>", "<space>", "<down>", "<space>", "3")180 if !strings.Contains(screen(m), "1 of 4") {181 t.Errorf("search shows:\n%s", screen(m))182 }183 press(t, m, "<enter>")184 if m.sheet.HiddenRows() != 4 || m.note != "Filtered column B: 4 rows hidden" {185 t.Errorf("hidden %d note %q", m.sheet.HiddenRows(), m.note)186 }187 // A condition on another column combines with it.188 press(t, m, "<left>", "<alt+down>", "<tab>", "<down>", "<down>", "<down>")189 if !strings.Contains(screen(m), "‹ Text contains ›") {190 t.Fatalf("condition:\n%s", screen(m))191 }192 press(t, m, "Row 2", "<enter>")193 var shown []int194 for r := range 10 {195 if !m.sheet.RowHidden(r) {196 shown = append(shown, r+1)197 }198 }199 if len(shown) != 3 || shown[1] != 3 || shown[2] != 10 {200 t.Errorf("shown rows %v", shown)201 }202 // Esc leaves the criteria alone; removing the filter shows all.203 press(t, m, "<alt+down>", "<space>", "<esc>")204 if m.sheet.HiddenRows() != 7 || m.mode != modeReady {205 t.Errorf("after Esc: %d hidden", m.sheet.HiddenRows())206 }207 m.runCommand("data.filter_remove")208 if m.sheet.HiddenRows() != 0 {209 t.Error("remove kept rows hidden")210 }211}212213func TestFilterButtonClick(t *testing.T) {214 m := newModel()215 table(t, m, 4)216 m.runCommand("data.filter")217 x := m.filterButtonX(1)218 if ansi.Cut(line(m, headerLine), x, x+1) != "▾" {219 t.Fatalf("button at %d in %q", x, line(m, headerLine))220 }221 click(m, x, headerLine, 0)222 if _, ok := m.overlay.(*filterpick.Picker); !ok || !strings.Contains(screen(m), "Filter B") {223 t.Fatalf("overlay %T", m.overlay)224 }225 // Clicking a value toggles it; clicking outside cancels.226 click(m, x+4, gridTop+5+1, 0) // the list starts five rows into the box227 if !strings.Contains(screen(m), "[ ] 0") {228 t.Error("click didn't uncheck 0")229 }230 click(m, 70, 18, 0)231 if m.overlay != nil || m.sheet.HiddenRows() != 0 {232 t.Error("click outside applied or stayed open")233 }234}