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 / charts_test.go
charts_test.go 8.6K
1package ui23import (4 "strings"5 "testing"67 tea "charm.land/bubbletea/v2"8 uv "github.com/charmbracelet/ultraviolet"9 "github.com/charmbracelet/x/ansi/kitty"1011 "src.rest/cat/012/internal/sheet"12)1314// chartTable types a small table and leaves A1 active.15func chartTable(t *testing.T, m *Model) {16 t.Helper()17 press(t, m, "Month", "<tab>", "Rent", "<tab>", "Food", "<enter>")18 press(t, m, "Jan", "<tab>", "1450", "<tab>", "600", "<enter>")19 press(t, m, "Feb", "<tab>", "1450", "<tab>", "640", "<enter>")20 press(t, m, "Mar", "<tab>", "1500", "<tab>", "612", "<enter>")21 press(t, m, "<ctrl+home>")22}2324func wideModel() *Model {25 m := New(sheet.New(), "")26 m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})27 return m28}2930func TestInsertChart(t *testing.T) {31 m := wideModel()32 chartTable(t, m)33 send(m, nil)34 m.runCommand("insert.chart")35 charts := m.sheet.Charts()36 if len(charts) != 1 {37 t.Fatalf("%d charts", len(charts))38 }39 c := charts[0]40 if c.Data.String() != "A1:C4" || !c.Header || !c.Labels || c.At.String() != "D1" || c.Title != "Rent and Food" {41 t.Errorf("chart %+v", c)42 }43 if m.indicator() != "CHART" || !strings.Contains(line(m, contextLine), "Column") {44 t.Errorf("editor not open: %q / %q", m.indicator(), line(m, contextLine))45 }46 s := screen(m)47 for _, want := range []string{"Rent and Food", "1,500 ┤", "■ Rent", "A1:C4"} {48 if !strings.Contains(s, want) {49 t.Errorf("screen lacks %q:\n%s", want, s)50 }51 }52 // The editor changes the chart live; Enter keeps it selected.53 press(t, m, "<right>", "s")54 if c := m.sheet.Charts()[0]; c.Type != sheet.ChartBar || !c.ByRow {55 t.Errorf("after editing: %+v", c)56 }57 press(t, m, "<enter>")58 if _, ok := m.overlay.(*chartSel); !ok || !strings.Contains(line(m, contextLine), "Bar chart of A1:C4") {59 t.Errorf("not selected after Enter: %q", line(m, contextLine))60 }61 press(t, m, "<esc>")62 if m.mode != modeReady {63 t.Errorf("mode %v", m.mode)64 }65}6667func TestInsertChartEscCancels(t *testing.T) {68 m := wideModel()69 chartTable(t, m)70 m.runCommand("insert.chart")71 press(t, m, "<right>", "<esc>")72 if n := len(m.sheet.Charts()); n != 0 || m.changed != true {73 // The table itself is unsaved; the chart is gone.74 t.Errorf("%d charts after Esc", n)75 }76 // From one blank cell far from data there's nothing to chart.77 press(t, m, "<f5>", "H20", "<enter>")78 m.runCommand("insert.chart")79 if len(m.sheet.Charts()) != 0 || !strings.Contains(line(m, contextLine), "Select the data") {80 t.Errorf("charted nothing: %q", line(m, contextLine))81 }82}8384func TestChartEditorPrompts(t *testing.T) {85 m := wideModel()86 chartTable(t, m)87 m.runCommand("insert.chart")88 press(t, m, "t", "<ctrl+a>")89 if m.mode != modePrompt {90 t.Fatalf("title prompt not open, mode %v", m.mode)91 }92 m.line.Buf, m.line.Pos = []rune("Spend"), 593 press(t, m, "<enter>")94 if c := m.sheet.Charts()[0]; c.Title != "Spend" || m.indicator() != "CHART" {95 t.Errorf("title %q, indicator %q", c.Title, m.indicator())96 }97 press(t, m, "r")98 if !m.pointing() {99 t.Fatal("range prompt not pointing")100 }101 press(t, m, "<shift+up>", "<enter>")102 if c := m.sheet.Charts()[0]; c.Data.String() != "A1:C3" {103 t.Errorf("data %s", c.Data)104 }105 press(t, m, "r", "<esc>", "<esc>")106 if _, ok := m.overlay.(*chartEditor); !ok {107 t.Errorf("Esc in the range prompt left the editor: %T", m.overlay)108 }109}110111func TestChartMoveResizeDelete(t *testing.T) {112 m := wideModel()113 chartTable(t, m)114 m.runCommand("insert.chart")115 press(t, m, "<enter>")116 press(t, m, "<down>", "<right>", "<shift+right>", "<shift+down>")117 c := m.sheet.Charts()[0]118 if c.At.String() != "E2" || c.W != newChartW+2 || c.H != newChartH+1 {119 t.Errorf("after keys: %s %dx%d", c.At, c.W, c.H)120 }121 // Mouse: drag the body one column left, then the corner.122 x, y := m.chartScreen(c)123 send(m, tea.MouseClickMsg{X: x + 5, Y: y + 2, Button: tea.MouseLeft})124 send(m, tea.MouseMotionMsg{X: x + 5 - 10, Y: y + 3, Button: tea.MouseLeft})125 if got := m.sheet.Charts()[0].At; got.String() != "E2" {126 t.Errorf("moved before release: %s", got)127 }128 send(m, tea.MouseReleaseMsg{X: x - 5, Y: y + 3, Button: tea.MouseLeft})129 c = m.sheet.Charts()[0]130 if c.At.String() != "D3" {131 t.Errorf("after drag: %s", c.At)132 }133 x, y = m.chartScreen(c)134 send(m, tea.MouseClickMsg{X: x + c.W - 1, Y: y + c.H - 1, Button: tea.MouseLeft})135 send(m, tea.MouseMotionMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})136 send(m, tea.MouseReleaseMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})137 if c = m.sheet.Charts()[0]; c.W != 30 || c.H != 12 {138 t.Errorf("after resize: %dx%d", c.W, c.H)139 }140 press(t, m, "<ctrl+z>")141 if c = m.sheet.Charts()[0]; c.W == 30 {142 t.Error("undo didn't restore the size")143 }144 // Click the chart to select it, Del deletes it; undo brings it back.145 press(t, m, "<esc>")146 x, y = m.chartScreen(c)147 send(m, tea.MouseClickMsg{X: x + 3, Y: y + 3, Button: tea.MouseLeft})148 send(m, tea.MouseReleaseMsg{X: x + 3, Y: y + 3, Button: tea.MouseLeft})149 press(t, m, "<delete>")150 if len(m.sheet.Charts()) != 0 || m.mode != modeReady {151 t.Fatalf("not deleted: %d, mode %v", len(m.sheet.Charts()), m.mode)152 }153 press(t, m, "<ctrl+z>")154 if len(m.sheet.Charts()) != 1 {155 t.Error("undo didn't restore the chart")156 }157 // Clicking a cell outside a selected chart deselects it and lands there.158 m.runCommand("chart.select")159 send(m, tea.MouseClickMsg{X: cellX(0), Y: gridTop + 8, Button: tea.MouseLeft})160 if m.mode != modeReady || m.cur.String() != "A9" {161 t.Errorf("mode %v at %s", m.mode, m.cur)162 }163}164165func TestChartFollowsData(t *testing.T) {166 m := wideModel()167 chartTable(t, m)168 m.runCommand("insert.chart")169 press(t, m, "<enter>", "<esc>")170 if !strings.Contains(screen(m), "1,500 ┤") {171 t.Fatalf("no axis:\n%s", screen(m))172 }173 press(t, m, "<f5>", "B4", "<enter>", "3000", "<enter>")174 if !strings.Contains(screen(m), "3,000 ┤") {175 t.Errorf("chart didn't follow the edit:\n%s", screen(m))176 }177}178179func TestChartImages(t *testing.T) {180 m := wideModel()181 chartTable(t, m)182 var raw []string183 collect := func(cmd tea.Cmd) {184 for _, msg := range flatten(cmd) {185 if r, ok := msg.(tea.RawMsg); ok {186 raw = append(raw, r.Msg.(string))187 }188 }189 }190 // No kitty reply: text, and nothing sent.191 m.runCommand("insert.chart")192 press(t, m, "<enter>", "<esc>")193 if cmd := m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans); cmd != nil {194 t.Error("images sent without kitty graphics")195 }196 // The terminal answers the query: images, and placeholders in the view.197 _, cmd := m.Update(uv.KittyGraphicsEvent{Options: kitty.Options{ID: 31}, Payload: []byte("OK")})198 collect(cmd)199 if !m.term.kitty || len(raw) == 0 || !strings.Contains(strings.Join(raw, ""), "\x1b_Ga=T,q=2,f=32,o=z,") {200 t.Fatalf("no image sent: %q", raw)201 }202 if !strings.Contains(m.View().Content, "\x1b[38;5;16m\U0010EEEE\u0305\u0305") {203 t.Error("no placeholders for image 16")204 }205 // Unchanged charts aren't sent again; edits are.206 raw = nil207 _, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyRight})208 collect(cmd)209 if len(raw) != 0 {210 t.Errorf("unchanged chart sent again")211 }212 press(t, m, "<f5>", "B2", "<enter>", "99", "<enter>")213 m.Update(nil)214 raw = nil215 m.term.sent[firstImageID] = "stale"216 collect(m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans))217 if len(raw) != 1 {218 t.Errorf("changed chart not sent")219 }220 // The old image and its placement are freed before the new one is221 // sent, so the terminal sizes it by the new placement, not the first.222 del, send := strings.Index(raw[0], "a=d,d=I,i=16"), strings.Index(raw[0], "a=T,")223 if del < 0 || send < del {224 t.Errorf("resent image doesn't free the old placement first: %q", raw[0][:min(len(raw[0]), 80)])225 }226 // Deleting the chart frees its image.227 raw = nil228 m.sheet.DeleteChart(0)229 collect(m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans))230 if len(raw) != 1 || !strings.Contains(raw[0], "a=d,d=I,i=16") {231 t.Errorf("image not freed: %q", raw)232 }233}234235// flatten runs a command and returns the messages of it and any batch.236func flatten(cmd tea.Cmd) []tea.Msg {237 if cmd == nil {238 return nil239 }240 msg := cmd()241 if batch, ok := msg.(tea.BatchMsg); ok {242 var out []tea.Msg243 for _, c := range batch {244 out = append(out, flatten(c)...)245 }246 return out247 }248 return []tea.Msg{msg}249}250251func TestPaletteReply(t *testing.T) {252 tests := []struct {253 in string254 i int255 ok bool256 }{257 {"\x1b]4;6;rgb:0000/cdcd/cdcd\x07", 6, true},258 {"\x1b]4;12;rgb:5c5c/5c5c/ffff\x1b\\", 12, true},259 {"\x1b]10;rgb:0/0/0\x07", 0, false},260 {"\x1b]4;x;rgb:0/0/0\x07", 0, false},261 }262 for _, tt := range tests {263 i, c, ok := parsePaletteReply(tt.in)264 if ok != tt.ok || ok && i != tt.i {265 t.Errorf("%q: %d %v %v", tt.in, i, c, ok)266 }267 }268 m := newModel()269 send(m, uv.UnknownOscEvent("\x1b]4;6;rgb:0000/cdcd/cdcd\x07"))270 if c := m.term.color(6); c.G != 0xcd || c.R != 0 {271 t.Errorf("palette color %v", c)272 }273}