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 / macrodialog_test.go
macrodialog_test.go 11.7K
1package ui23import (4 "strings"5 "testing"67 tea "charm.land/bubbletea/v2"89 "src.rest/cat/012/internal/sheet"10)1112// Dialogs recorded as the commands that open them, answered with their13// choices, and replayed from those answers.1415// salesModel is a table of sales with a header row, the fixture both a16// recording and its replay start from.17func salesModel() *Model { return withSales(newModel()) }1819// wideSales is salesModel on a screen wide enough for a chart beside the20// table.21func wideSales() *Model { return withSales(wideModel()) }2223func withSales(m *Model) *Model {24 for i, row := range [][]string{25 {"Region", "Units", "Price"},26 {"North", "12", "3.5"},27 {"South", "40", "2"},28 {"East", "7", "9"},29 {"West", "25", "4"},30 } {31 for j, v := range row {32 m.sheet.Set(addr(string(rune('A'+j))+string(rune('1'+i))), v)33 }34 }35 return m36}3738// recordDo records what do does as a macro named name, from wherever m39// is, and returns its script without the header.40func recordDo(t *testing.T, m *Model, name string, do func()) string {41 t.Helper()42 send(m, nil)43 run(m, m.runCommand("macro.record"))44 do()45 send(m, nil)46 run(m, m.runCommand("macro.stop"))47 m.line.Set(name)48 press(t, m, "<enter>")49 m.line.Set("")50 press(t, m, "<enter>")51 mc, ok := m.book().Macro(name)52 if !ok {53 t.Fatalf("no macro %s: %q", name, m.errMsg)54 }55 return body(mc.Source)56}5758// replays runs macro name from the recording model on a fresh fixture59// made by fresh, and checks it leaves the same workbook.60func replays(t *testing.T, recorded *Model, name string, fresh func() *Model) *Model {61 t.Helper()62 r := fresh()63 mc, _ := recorded.book().Macro(name)64 if err := r.book().SaveMacro("", mc, "add"); err != nil {65 t.Fatal(err)66 }67 r.trustHere()68 mc, _ = r.book().Macro(name)69 run(r, r.runMacro(mc))70 if r.note != "Ran "+name {71 t.Fatalf("running %s: note %q warn %q err %q", name, r.note, r.warn, r.errMsg)72 }73 if got, want := fileOf(t, r), fileOf(t, recorded); got != want {74 t.Errorf("%s replayed differently:\n%s\nrecorded:\n%s", name, got, want)75 }76 return r77}7879func TestRecordSortBar(t *testing.T) {80 m := salesModel()81 got := recordDo(t, m, "Sort", func() {82 run(m, m.runCommand("data.sort_range"))83 press(t, m, "<right>", "<space>", "<alt+a>", "<enter>")84 })85 want := `run("data.sort_range", answer={"by": [{"column": "B", "order": "desc"}, {"column": "A"}], "header": True})`86 if got != want {87 t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)88 }89 if input(m, "A2") != "South" {90 t.Fatalf("not sorted: A2 %q", input(m, "A2"))91 }92 replays(t, m, "Sort", salesModel)93}9495func TestScriptAnswersSortBar(t *testing.T) {96 m := salesModel()97 script(t, m, `run("data.sort_range", answer={"by": [{"column": "C"}], "header": False})`)98 if input(m, "A1") != "South" || input(m, "A5") != "Region" {99 t.Fatalf("sorted with the header: A1 %q A5 %q warn %q", input(m, "A1"), input(m, "A5"), m.warn)100 }101 if m.overlay != nil || m.mode != modeReady {102 t.Fatalf("left mode %v, overlay %T", m.mode, m.overlay)103 }104 m = salesModel()105 script(t, m, `run("data.sort_range", answer={"by": [{"column": "Z"}]})`)106 if !strings.Contains(m.warn, `Sort range: column "Z" isn't in A1:C5`) || m.overlay != nil {107 t.Fatalf("warn %q overlay %T", m.warn, m.overlay)108 }109}110111func TestNamesPickerChangesAreNoted(t *testing.T) {112 m := salesModel()113 m.sheet.DefineName("Units", sheet.Rect{From: addr("B2"), To: addr("B5")})114 got := recordDo(t, m, "Names", func() {115 run(m, m.runCommand("data.named_ranges"))116 press(t, m, "<down>", "<ctrl+d>", "<esc>")117 })118 if !strings.HasPrefix(got, "# Not recorded: ") || !strings.Contains(got, "Units") {119 t.Fatalf("recorded:\n%s", got)120 }121}122123func TestRecordFilterPicker(t *testing.T) {124 m := salesModel()125 got := recordDo(t, m, "Filter", func() {126 run(m, m.runCommand("data.filter"))127 press(t, m, "<alt+down>", "<down>", "<down>", "<space>", "<enter>")128 // The button of another column, with a condition.129 press(t, m, "<right>")130 click(m, m.filterButtonX(2), headerLine, 0)131 press(t, m, "<tab>")132 for range int(sheet.CondGreater) {133 press(t, m, "<down>")134 }135 press(t, m, "3", "<enter>")136 })137 want := `run("data.filter")138run("data.filter_column", answer={"column": "A", "hidden": ["North"]})139select("B1")140run("data.filter_column", answer={"column": "C", "condition": "gt", "value": "3"})`141 if got != want {142 t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)143 }144 if m.sheet.HiddenRows() != 2 {145 t.Fatalf("hidden %d", m.sheet.HiddenRows())146 }147 replays(t, m, "Filter", salesModel)148}149150func TestScriptAnswersFilterPicker(t *testing.T) {151 m := salesModel()152 script(t, m, `run("data.filter_column", answer={"column": "B", "hidden": ["40"]})`)153 if !strings.Contains(m.warn, "the sheet has no filter") {154 t.Fatalf("warn %q", m.warn)155 }156 script(t, m, `run("data.filter")157run("data.filter_column", answer={"column": "D", "hidden": ["40"]})`)158 if !strings.Contains(m.warn, "column D isn't in the filter's range A1:C5") || m.overlay != nil {159 t.Fatalf("warn %q overlay %T", m.warn, m.overlay)160 }161 script(t, m, `select("B2")162run("data.filter_column", answer={"hidden": ["40", "7"]})`)163 if m.sheet.HiddenRows() != 2 || m.note != "Ran S" {164 t.Fatalf("hidden %d note %q warn %q", m.sheet.HiddenRows(), m.note, m.warn)165 }166}167168func TestRecordChartEditorAndDrags(t *testing.T) {169 m := wideSales()170 got := recordDo(t, m, "Chart", func() {171 run(m, m.runCommand("insert.chart"))172 press(t, m, "3", "t", "Units", "<enter>", "<enter>")173 // Moved a step at a time, then dragged by the corner: one call.174 press(t, m, "<down>", "<right>", "<right>")175 c := m.sheet.Charts()[0]176 x, y := m.chartScreen(c)177 send(m, tea.MouseClickMsg{X: x + c.W - 1, Y: y + c.H - 1, Button: tea.MouseLeft})178 send(m, tea.MouseMotionMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})179 send(m, tea.MouseReleaseMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})180 // Edited again: the header row off.181 press(t, m, "<enter>", "h", "<enter>", "<esc>")182 })183 want := `run("insert.chart", answer={"type": "line", "data": "A1:C5", "at": "D1", "width": 48, "height": 16, "header": True, "labels": True, "title": "Units"})184run("chart.edit", answer={"chart": 1, "at": "F2", "width": 30, "height": 12})185run("chart.edit", answer={"chart": 1, "header": False})`186 if got != want {187 t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)188 }189 replays(t, m, "Chart", wideSales)190}191192func TestScriptAnswersChartCommands(t *testing.T) {193 m := wideSales()194 script(t, m, `select("A1:B5")195run("insert.chart", answer={"type": "pie", "title": "Units"})196run("chart.edit", answer={"chart": 1, "at": "H2", "legend": "none"})`)197 cs := m.sheet.Charts()198 if len(cs) != 1 || cs[0].Type != sheet.ChartPie || cs[0].Title != "Units" || cs[0].At.String() != "H2" || cs[0].Legend != sheet.LegendNone || cs[0].Data.String() != "A1:B5" {199 t.Fatalf("charts %+v, warn %q", cs, m.warn)200 }201 if m.overlay != nil || m.mode != modeReady {202 t.Fatalf("left mode %v overlay %T", m.mode, m.overlay)203 }204 script(t, m, `run("chart.edit", answer={"chart": 2, "at": "A1"})`)205 if !strings.Contains(m.warn, "there's no chart 2 on Sheet1: it has 1") {206 t.Fatalf("warn %q", m.warn)207 }208 script(t, m, `run("chart.edit", answer={"type": "blimp"})`)209 if !strings.Contains(m.warn, `unknown chart type "blimp"`) || m.overlay != nil {210 t.Fatalf("warn %q overlay %T", m.warn, m.overlay)211 }212}213214func TestRecordFindAndReplace(t *testing.T) {215 m := salesModel()216 got := recordDo(t, m, "Replace", func() {217 press(t, m, "<ctrl+h>", "th", "<tab>", "TH", "<enter>")218 press(t, m, "<alt+c>", "<ctrl+enter>", "<esc>")219 press(t, m, "X", "<enter>")220 })221 want := `select("A2")222run("edit.replace", answer={"find": "th", "replace": "TH", "cell": "A2"})223run("edit.replace", answer={"find": "th", "replace": "TH", "matchCase": True})224enter("X")225select("A4")`226 if got != want {227 t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)228 }229 if input(m, "A2") != "NorTH" || input(m, "A3") != "X" {230 t.Fatalf("A2 %q A3 %q", input(m, "A2"), input(m, "A3"))231 }232 replays(t, m, "Replace", salesModel)233}234235// salesRules is salesModel with two conditional formats on B2:B5 and a236// dropdown on A2:A5.237func salesRules() *Model {238 m := salesModel()239 for _, line := range []string{`{"ranges":"B2:B5","condition":"gt","values":["20"],"fill":"green"}`, `{"ranges":"B2:B5","condition":"lt","values":["10"],"fill":"red"}`} {240 f, _ := sheet.ParseCondFormat(line)241 m.sheet.AddCondFormat(f)242 }243 v, _ := sheet.ParseValidation(`{"ranges":"A2:A5","criteria":"list","items":["North","South","East","West"]}`)244 m.sheet.AddValidation(v)245 return m246}247248func TestRecordRulesPanelEdits(t *testing.T) {249 m := salesRules()250 got := recordDo(t, m, "Rules", func() {251 run(m, m.runCommand("format.conditional"))252 press(t, m, "<down>", "<shift+down>") // green after red253 press(t, m, "<enter>", "<down>", "<down>", "<down>", "<backspace>", "<backspace>", "30", "<enter>", "<esc>")254 run(m, m.runCommand("data.validation"))255 press(t, m, "<down>", "<delete>", "<esc>")256 })257 want := `run("format.conditional_move", answer={"rule": 1, "to": 2})258run("format.conditional_set", answer={"rule": 2, "ranges": "B2:B5", "condition": "gt", "values": ["30"], "fill": "green"})259run("data.validation_remove", answer=1)`260 if got != want {261 t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)262 }263 replays(t, m, "Rules", salesRules)264}265266func TestScriptsChangeRules(t *testing.T) {267 m := salesRules()268 script(t, m, `run("format.conditional_set", answer={"rule": 1, "ranges": "C2:C5", "condition": "empty", "fill": "yellow"})269run("data.validation_set", answer={"rule": 1, "ranges": "A2:A9", "criteria": "list", "items": ["N", "S"]})270run("format.conditional_move", answer={"rule": 2, "to": 1})`)271 fs, vs := m.sheet.CondFormats(), m.sheet.Validations()272 if len(fs) != 2 || fs[1].Ranges[0].String() != "C2:C5" || vs[0].Ranges[0].String() != "A2:A9" || m.note != "Ran S" {273 t.Fatalf("formats %+v validations %+v warn %q", fs, vs, m.warn)274 }275 for src, want := range map[string]string{276 `run("format.conditional_remove", answer=3)`: "there's no rule 3: the sheet has 2",277 `run("format.conditional_move", answer={"rule": 1})`: `give "to", a place from 1 to 2`,278 `run("data.validation_set", answer={"ranges": "A1"})`: "give the rule's number",279 `run("format.conditional_set", answer={"rule": 1, "ranges": "?"})`: `invalid ranges "?"`,280 } {281 script(t, m, src)282 if !strings.Contains(m.warn, want) {283 t.Errorf("%s: warn %q, want %q", src, m.warn, want)284 }285 }286}287288func TestRecordPivotEditor(t *testing.T) {289 m := wideSales()290 got := recordDo(t, m, "Pivot", func() {291 run(m, m.runCommand("data.pivot"))292 press(t, m, "<space>", "reg", "<enter>")293 press(t, m, "<down>", "<down>", "<space>", "units", "<enter>", "<enter>")294 // Edited again: the grand total row off. Unchanged, it records295 // nothing.296 run(m, m.runCommand("data.pivot_edit"))297 press(t, m, "<enter>")298 run(m, m.runCommand("data.pivot_edit"))299 press(t, m, "<end>", "<up>", "<space>", "<enter>")300 })301 want := `run("data.pivot", answer={"source": "Sheet1!A1:C5", "rows": [{"column": "A"}], "values": [{"column": "B", "summarize": "sum"}], "rowTotals": True, "columnTotals": True})302run("data.pivot_edit", answer={"source": "Sheet1!A1:C5", "rows": [{"column": "A"}], "values": [{"column": "B", "summarize": "sum"}], "rowTotals": False, "columnTotals": True})`303 if got != want {304 t.Fatalf("recorded:\n%s\nwant:\n%s", got, want)305 }306 replays(t, m, "Pivot", wideSales)307}308309func TestScriptAnswersPivotCommands(t *testing.T) {310 m := wideSales()311 script(t, m, `run("data.pivot_edit", answer={"source": "Sheet1!A1:C5"})`)312 if !strings.Contains(m.warn, "Sheet1 has no pivot table") {313 t.Fatalf("warn %q", m.warn)314 }315 script(t, m, `run("data.pivot", answer={"source": "Sheet1!A1:C5", "rows": [{"column": "A"}], "values": [{"column": "C", "summarize": "max"}]})`)316 if m.sheet.Name() != "Pivot Table 1" || shows(m, "B1") != "MAX of Price" || m.overlay != nil {317 t.Fatalf("on %s, B1 %q, overlay %T, warn %q", m.sheet.Name(), shows(m, "B1"), m.overlay, m.warn)318 }319}