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 / format_test.go
format_test.go 7.6K
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/shortcuts"12)1314// cells returns the text of the first n columns of grid row r (1-based),15// each trimmed.16func rowCells(m *Model, r, n int) []string {17 l := line(m, gridTop+r-1)18 out := make([]string, n)19 x := minRowHdrW20 for i := range n {21 w := m.sheet.ColWidth(m.left + i)22 // Cut by display columns: cells may hold multi-byte text.23 out[i] = strings.TrimSpace(ansi.Cut(l, x, x+w))24 x += w25 }26 return out27}2829// raw returns the columns of grid row r untrimmed, joined by |.30func raw(m *Model, r, n int) string {31 l := line(m, gridTop+r-1)32 l += strings.Repeat(" ", max(0, 400-len(l)))33 var parts []string34 x := minRowHdrW35 for i := range n {36 w := m.sheet.ColWidth(m.left + i)37 parts = append(parts, l[x:x+w])38 x += w39 }40 return strings.Join(parts, "|")41}4243func TestFormatShortcutsApplyToSelection(t *testing.T) {44 tests := []struct {45 keys []string46 a, b string47 }{48 {[]string{"<ctrl+shift+1>"}, "1,234.50", "0.25"},49 {[]string{"<ctrl+!>"}, "1,234.50", "0.25"}, // shifted form of Ctrl+Shift+150 {[]string{"<ctrl+shift+4>"}, "$1,234.50", "$0.25"},51 {[]string{"<ctrl+$>"}, "$1,234.50", "$0.25"},52 {[]string{"<ctrl+shift+5>"}, "123450.00%", "25.00%"},53 {[]string{"<ctrl+shift+6>"}, "1.23E+03", "2.50E-01"},54 {[]string{"<ctrl+shift+3>"}, "5/18/1903", "12/30/1899"},55 {[]string{"<ctrl+shift+2>"}, "12:00:00 PM", "6:00:00 AM"},56 }57 for _, tt := range tests {58 m := newModel()59 m.sheet.SetColWidth(0, 12)60 m.sheet.SetColWidth(1, 12)61 press(t, m, "1234.5", "<tab>", "0.25", "<enter>", "<up>", "<shift+right>")62 press(t, m, tt.keys...)63 if got := rowCells(m, 1, 2); got[0] != tt.a || got[1] != tt.b {64 t.Errorf("%v: row 1 = %q, want %q %q", tt.keys, got, tt.a, tt.b)65 }66 }67}6869func TestFormatCommandsCoverSheetsMenu(t *testing.T) {70 m := newModel()71 press(t, m, "-1450", "<enter>", "<up>")72 m.sheet.SetColWidth(0, 12)73 tests := []struct{ id, want string }{74 {"format.accounting", " $(1,450.00)"},75 {"format.financial", " (1,450.00) "},76 {"format.currency_rounded", " -$1,450 "},77 {"format.duration", "-34800:00:00"}, // uses the padding: nothing to its right78 {"format.size", " -1.4 kB "}, // cut, not rounded, as nushell shows it79 {"format.plain_text", " -1450 "},80 {"format.automatic", " -1450 "},81 }82 for _, tt := range tests {83 m.runCommand(tt.id)84 if got := raw(m, 1, 1); got != tt.want {85 t.Errorf("%s: A1 shows %q, want %q", tt.id, got, tt.want)86 }87 }88}8990func TestDecimalPlaces(t *testing.T) {91 m := newModel()92 press(t, m, "1.5", "<enter>", "<up>")93 m.runCommand("format.decimals_more")94 m.runCommand("format.decimals_more")95 if got := rowCells(m, 1, 1)[0]; got != "1.500" {96 t.Errorf("after two more: %q", got)97 }98 m.runCommand("format.decimals_less")99 m.runCommand("format.decimals_less")100 m.runCommand("format.decimals_less")101 if got := rowCells(m, 1, 1)[0]; got != "2" {102 t.Errorf("after three less: %q", got)103 }104}105106func TestTextStylesToggle(t *testing.T) {107 m := newModel()108 press(t, m, "Rent", "<enter>", "Food", "<enter>", "<up>", "<up>", "<shift+down>")109 style := func(a string) sheet.Style {110 if c := m.sheet.Cell(addr(a)); c != nil {111 return c.Style112 }113 return sheet.Style{}114 }115 press(t, m, "<ctrl+b>")116 if !style("A1").Bold || !style("A2").Bold {117 t.Fatal("Ctrl+B didn't bold the selection")118 }119 if got := line(m, 2); got != "Bold on for A1:A2" {120 t.Errorf("line 3 = %q", got)121 }122 press(t, m, "<ctrl+b>")123 if style("A1").Bold || style("A2").Bold {124 t.Error("second Ctrl+B didn't turn bold off")125 }126 if line(m, 2) != "Bold off for A1:A2" {127 t.Errorf("line 3 = %q", line(m, 2))128 }129 press(t, m, "<ctrl+i>", "<ctrl+u>", "<alt+shift+5>")130 if st := style("A2"); !st.Italic || !st.Underline || !st.Strikethrough {131 t.Errorf("A2 style %+v", st)132 }133 press(t, m, "<ctrl+\\>")134 if m.sheet.Cell(addr("A1")).Style != (sheet.Style{}) || input(m, "A1") != "Rent" {135 t.Error("Ctrl+\\ didn't clear formatting, or cleared contents")136 }137 // The note goes away on the next key.138 press(t, m, "<down>")139 if line(m, 2) != "" {140 t.Errorf("line 3 after moving = %q", line(m, 2))141 }142}143144func TestStylesRender(t *testing.T) {145 m := newModel()146 press(t, m, "Total", "<enter>", "<up>", "<ctrl+b>", "<ctrl+u>", "<down>")147 grid := strings.Split(m.View().Content, "\n")[gridTop]148 // Bold and underline wrap just the text, not the padding.149 if !strings.Contains(grid, "m \x1b[1;4") || !strings.Contains(grid, "l\x1b[m ") {150 t.Errorf("row 1 = %q", grid)151 }152}153154func TestAlignment(t *testing.T) {155 m := newModel()156 press(t, m, "<right>", "abc", "<tab>", "42", "<enter>")157 press(t, m, "<up>", "<ctrl+shift+r>", "<right>", "<ctrl+shift+l>")158 if got := raw(m, 1, 3); got != " | abc | 42 " {159 t.Errorf("right/left = %q", got)160 }161 press(t, m, "<left>", "<ctrl+shift+e>", "<right>", "<ctrl+shift+e>")162 if got := raw(m, 1, 3); got != " | abc | 42 " {163 t.Errorf("centered = %q", got)164 }165}166167func TestOverflowDirections(t *testing.T) {168 m := newModel()169 // Right-aligned text runs left into blank cells; centered text runs170 // both ways; a formatted blank still lets text through.171 press(t, m, "<right>", "<right>", "Right-aligned heading", "<enter>", "<up>", "<ctrl+shift+r>")172 if got := raw(m, 1, 4); got != " Ri|ght-aligne|d heading | " {173 t.Errorf("right overflow = %q", got)174 }175 m.sheet.SetFormat(sheet.Rect{From: addr("B1"), To: addr("B1")}, sheet.Preset(sheet.FmtCurrency))176 press(t, m, "<ctrl+shift+e>")177 if got := raw(m, 1, 4); got != " | Right|-aligned h|eading " {178 t.Errorf("centered overflow = %q", got)179 }180 // A filled neighbor stops it.181 press(t, m, "<left>", "x", "<enter>")182 if got := raw(m, 1, 4); got != " | x |-aligned h|eading " {183 t.Errorf("blocked overflow = %q", got)184 }185}186187func TestNumbersTooWideShowHashes(t *testing.T) {188 m := newModel()189 press(t, m, "1234567.891", "<enter>", "<up>", "<ctrl+shift+1>")190 if got := raw(m, 1, 1); got != "######### " {191 t.Errorf("A1 = %q", got)192 }193 m.sheet.SetColWidth(0, 14)194 if got := raw(m, 1, 1); got != " 1,234,567.89 " {195 t.Errorf("wider A1 = %q", got)196 }197}198199func TestLongDatesUseThePadding(t *testing.T) {200 m := newModel()201 press(t, m, "12/31/2026", "<enter>")202 if got := raw(m, 1, 2); got != "12/31/2026| " {203 t.Errorf("A1 = %q", got)204 }205 // With a neighbor there's no room, as in Sheets.206 press(t, m, "<up>", "<right>", "x", "<enter>")207 if got := raw(m, 1, 2); got != "######### | x " {208 t.Errorf("A1 next to B1 = %q", got)209 }210}211212func TestTypedDatesAndCurrencyShowFormatted(t *testing.T) {213 m := newModel()214 press(t, m, "9/26/2026", "<enter>", "$1,200", "<enter>", "12.5%", "<enter>", "14:30", "<enter>")215 for i, want := range []string{"9/26/2026", "$1,200", "12.50%", "14:30:00"} {216 if got := rowCells(m, i+1, 1)[0]; got != want {217 t.Errorf("row %d = %q, want %q", i+1, got, want)218 }219 }220 // The formula bar keeps what was typed.221 press(t, m, "<ctrl+home>")222 if l := line(m, formulaLine); !strings.HasPrefix(l, " A1") || !strings.HasSuffix(l, " 9/26/2026") {223 t.Errorf("formula bar %q", l)224 }225}226227func TestFormatHelpListsShortcuts(t *testing.T) {228 m := newModel()229 m.Update(teaSize(120, 60))230 press(t, m, "<f1>")231 // Check every row of the shortcuts overlay, not just the visible ones.232 rows, _ := m.overlay.(*shortcuts.View).Lines()233 s := ansi.Strip(strings.Join(rows, "\n"))234 for _, want := range []string{"Ctrl+Shift+4", "Ctrl+B", "Alt+Shift+5", "Ctrl+\\"} {235 if !strings.Contains(s, want) {236 t.Errorf("help lacks %s", want)237 }238 }239 if strings.Contains(s, "Ctrl+$") {240 t.Error("help lists key aliases")241 }242}243244func teaSize(w, h int) tea.WindowSizeMsg { return tea.WindowSizeMsg{Width: w, Height: h} }