1package rules23import (4 "image/color"5 "strings"6 "testing"78 tea "charm.land/bubbletea/v2"9 "github.com/charmbracelet/x/ansi"1011 "src.rest/cat/012/internal/sheet"12 "src.rest/cat/012/internal/ui/lineedit"13 "src.rest/cat/012/internal/ui/overlay"14 "src.rest/cat/012/internal/ui/theme"15)1617// fakeHost is a sheet and an edit line, recording what the panel asks.18type fakeHost struct {19 th theme.Theme20 line lineedit.Line21 s *sheet.Sheet22 sel sheet.Rect23 closed bool24 edits int25 reworked []string26 saves []string27}2829func newHost() *fakeHost {30 s := sheet.New()31 for i, v := range []string{"5", "150", "20"} {32 s.Set(sheet.Addr{Row: i}, v)33 }34 return &fakeHost{th: theme.New(true), s: s, sel: sheet.Rect{To: sheet.Addr{Row: 2}}}35}3637func (h *fakeHost) Theme() *theme.Theme { return &h.th }38func (h *fakeHost) Size() (int, int) { return 100, 30 }39func (h *fakeHost) Line() *lineedit.Line { return &h.line }40func (h *fakeHost) Close() { h.closed = true }41func (h *fakeHost) Sheet() *sheet.Sheet { return h.s }42func (h *fakeHost) Selection() sheet.Rect { return h.sel }43func (h *fakeHost) Reworked(id, answer string) {44 h.edits++45 h.reworked = append(h.reworked, id+" "+answer)46}47func (h *fakeHost) Slot(i int) color.Color { return color.Gray{Y: uint8(i * 16)} }48func (h *fakeHost) SaveFormat(i int, f sheet.CondFormat) error {49 h.saves = append(h.saves, f.JSON())50 if i < 0 {51 return h.s.AddCondFormat(f)52 }53 return h.s.SetCondFormat(i, f)54}55func (h *fakeHost) SaveValidation(i int, v sheet.Validation) error {56 h.saves = append(h.saves, v.JSON())57 if i < 0 {58 return h.s.AddValidation(v)59 }60 return h.s.SetValidation(i, v)61}6263var keys = map[string]tea.Key{"enter": {Code: tea.KeyEnter}, "esc": {Code: tea.KeyEscape},64 "up": {Code: tea.KeyUp}, "down": {Code: tea.KeyDown}, "left": {Code: tea.KeyLeft},65 "right": {Code: tea.KeyRight}, "space": {Code: tea.KeySpace}, "delete": {Code: tea.KeyDelete},66 "backspace": {Code: tea.KeyBackspace}}6768// press sends named keys in angle brackets, and types other text.69func press(e *Editor, ks ...string) {70 for _, k := range ks {71 if name, ok := strings.CutPrefix(k, "<"); ok {72 name = strings.TrimSuffix(name, ">")73 key := keys[strings.TrimPrefix(name, "shift+")]74 if strings.HasPrefix(name, "shift+") {75 key.Mod = tea.ModShift76 }77 e.Key(tea.KeyPressMsg(key))78 continue79 }80 for _, r := range k {81 e.Key(tea.KeyPressMsg{Code: r, Text: string(r)})82 }83 }84}8586func text(e *Editor) string {87 var b strings.Builder88 for _, box := range e.Layout() {89 for _, l := range box.Lines {90 b.WriteString(ansi.Strip(l) + "\n")91 }92 }93 return b.String()94}9596func TestAddAndEditFormat(t *testing.T) {97 h := newHost()98 e := Formats(h)99 if out := text(e); !strings.Contains(out, "Conditional format rules") || !strings.Contains(out, "+ Add rule") || !strings.Contains(out, "0 rules") {100 t.Fatalf("list:\n%s", out)101 }102 press(e, "<enter>") // add103 out := text(e)104 for _, want := range []string{"Add conditional format", "Apply to A1:A3", "‹ Single color ›", "‹ Is not empty ›", "‹ Green ›", "[ ] Bold"} {105 if !strings.Contains(out, want) {106 t.Errorf("form lacks %q:\n%s", want, out)107 }108 }109 // Condition: Greater than is ten steps on; a value row appears.110 press(e, "<down>", "<down>")111 for range 9 {112 press(e, "<right>")113 }114 if !strings.Contains(text(e), "‹ Greater than ›") {115 t.Fatalf("condition:\n%s", text(e))116 }117 press(e, "<down>", "100", "<down>", "<right>") // text color red118 press(e, "<down>", "<down>", "<space>") // bold119 press(e, "<enter>")120 fs := h.s.CondFormats()121 if len(fs) != 1 || fs[0].Op != sheet.RuleGreater || fs[0].Args[0] != "100" || fs[0].Style.Text != sheet.ColorRed || !fs[0].Style.Bold {122 t.Fatalf("saved %+v", fs)123 }124 if !h.s.Look(sheet.Addr{Row: 1}).Styled || h.s.Look(sheet.Addr{}).Styled {125 t.Error("the rule doesn't apply")126 }127 if out := text(e); !strings.Contains(out, "A1:A3 Greater than 100") || !strings.Contains(out, "1 rule") {128 t.Errorf("list after save:\n%s", out)129 }130 // Editing it: a bad value keeps the form open with why.131 press(e, "<down>", "<enter>", "<down>", "<down>", "<down>")132 h.line.Set("")133 e.Changed()134 press(e, "<enter>")135 if e.msg != "Enter a value" || e.f == nil {136 t.Errorf("msg %q", e.msg)137 }138 desc, _ := e.Status()139 if !strings.Contains(desc, "Enter a value") {140 t.Errorf("status %q", desc)141 }142 press(e, "<esc>") // back to the list, unchanged143 if e.f != nil || h.s.CondFormats()[0].Args[0] != "100" {144 t.Error("Esc didn't discard the form")145 }146 press(e, "<esc>")147 if !h.closed {148 t.Error("Esc on the list didn't close")149 }150}151152func TestColorScaleForm(t *testing.T) {153 h := newHost()154 e := Formats(h)155 press(e, "<enter>", "<down>", "<right>") // Color scale156 out := text(e)157 for _, want := range []string{"Minpoint ‹ Min value ›", "Midpoint ‹ None ›", "Maxpoint ‹ Max value ›", "‹ Red ›", "‹ Green ›", "Preview"} {158 if !strings.Contains(out, want) {159 t.Errorf("scale form lacks %q:\n%s", want, out)160 }161 }162 // A percentile midpoint in yellow.163 press(e, "<down>", "<down>", "<down>", "<left>")164 if !strings.Contains(text(e), "‹ Percentile ›") {165 t.Fatalf("midpoint:\n%s", text(e))166 }167 press(e, "<enter>")168 f := h.s.CondFormats()[0]169 if len(f.Scale) != 3 || f.Scale[1].Kind != sheet.PointPercentile || f.Scale[1].Value != "50" || f.Scale[1].Color != sheet.ColorYellow {170 t.Fatalf("scale %+v", f.Scale)171 }172 if l := h.s.Look(sheet.Addr{Row: 2}); !l.Scaled || l.To != sheet.ColorYellow || l.Pos != 1 {173 t.Errorf("look %+v", l)174 }175}176177func TestRemoveAndReorder(t *testing.T) {178 h := newHost()179 for _, v := range []string{"1", "2"} {180 h.s.AddCondFormat(sheet.CondFormat{Ranges: []sheet.Rect{h.sel}, Op: sheet.RuleEqual, Args: [2]string{v}, Style: sheet.RuleStyle{Bold: true}})181 }182 e := Formats(h)183 press(e, "<down>", "<shift+down>")184 if fs := h.s.CondFormats(); fs[1].Args[0] != "1" || e.list.Sel != 2 || h.edits != 1 {185 t.Errorf("reorder: %+v sel %d", fs, e.list.Sel)186 }187 press(e, "<delete>")188 if fs := h.s.CondFormats(); len(fs) != 1 || fs[0].Args[0] != "2" || e.list.Sel != 1 {189 t.Errorf("remove: %+v sel %d", fs, e.list.Sel)190 }191 want := `format.conditional_move {"rule":1,"to":2}` + "\n" + "format.conditional_remove 2"192 if got := strings.Join(h.reworked, "\n"); got != want {193 t.Errorf("as a macro records them:\n%s\nwant:\n%s", got, want)194 }195}196197func TestValidationForm(t *testing.T) {198 h := newHost()199 e := Validations(h).Add(true)200 out := text(e)201 for _, want := range []string{"Add data validation", "‹ Dropdown ›", "Items", "‹ Show a warning ›", "Help text"} {202 if !strings.Contains(out, want) {203 t.Errorf("form lacks %q:\n%s", want, out)204 }205 }206 press(e, "<down>", "<down>", "Yes, No,, Maybe ", "<down>", "<down>", "<right>", "<enter>")207 vs := h.s.Validations()208 if len(vs) != 1 || strings.Join(vs[0].Items, "|") != "Yes|No|Maybe" || !vs[0].Reject || vs[0].Display != sheet.DropChip {209 t.Fatalf("saved %+v", vs)210 }211 // A number between, rejecting.212 press(e, "<down>", "<enter>", "<down>")213 for range 3 {214 press(e, "<right>")215 }216 press(e, "<down>", "<down>", "1", "<down>", "10", "<enter>")217 v := h.s.Validations()[0]218 if v.Kind != sheet.ValidNumber || v.Op != sheet.RuleBetween || v.Args != [2]string{"1", "10"} {219 t.Fatalf("number rule %+v", v)220 }221 if out := text(e); !strings.Contains(out, "Number between 1 and 10") || !strings.Contains(out, "rejects") {222 t.Errorf("list:\n%s", out)223 }224 // Dates offer any date first.225 press(e, "<enter>", "<down>", "<right>")226 if !strings.Contains(text(e), "‹ Is a valid date ›") {227 t.Errorf("date:\n%s", text(e))228 }229}230231func TestMouseAndCursor(t *testing.T) {232 h := newHost()233 e := Formats(h)234 x, y, _ := e.box()235 // Clicking the highlighted "Add rule" opens the form.236 e.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft, Box: ID, Row: 1})237 if e.f == nil {238 t.Fatal("no form")239 }240 cx, cy := e.Cursor()241 if cx != x+1+textX()+len("A1:A3") || cy != y+1 {242 t.Errorf("cursor %d,%d box %d,%d", cx, cy, x, y)243 }244 // Clicking a choice row picks it; clicking again changes it.245 e.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft, Box: ID, Row: 2})246 e.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft, Box: ID, Row: 2})247 if !strings.Contains(text(e), "‹ Color scale ›") {248 t.Errorf("click:\n%s", text(e))249 }250 if cx, _ := e.Cursor(); cx >= 0 {251 t.Error("a caret on a choice row")252 }253 e.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft})254 if !h.closed {255 t.Error("a click outside didn't close")256 }257}258259// The form makes data bars and icon sets: the Format row's third and260// fourth choices, their rows, and a preview drawn as the cells are.261func TestBarAndIconForms(t *testing.T) {262 h := newHost()263 e := Formats(h)264 press(e, "<enter>", "<down>", "<right>", "<right>")265 out := text(e)266 for _, want := range []string{"‹ Data bar ›", "Shortest", "‹ Automatic ›", "Longest", "Bar color", "Show bar only", "Preview"} {267 if !strings.Contains(out, want) {268 t.Errorf("bar form lacks %q:\n%s", want, out)269 }270 }271 press(e, "<enter>")272 fs := h.s.CondFormats()273 if len(fs) != 1 || !fs[0].IsBar() || fs[0].Bar != sheet.ColorBlue || fs[0].Scale[0].Kind != sheet.PointMin {274 t.Fatalf("saved %+v", fs)275 }276 press(e, "<down>", "<enter>", "<down>", "<right>")277 out = text(e)278 for _, want := range []string{"‹ Icon set ›", "‹ Arrows ›", "‹ 3 icons ›", "→ from", "↑ from", "Reverse icons"} {279 if !strings.Contains(out, want) {280 t.Errorf("icon form lacks %q:\n%s", want, out)281 }282 }283 // Four icons, evenly apart.284 press(e, "<down>", "<down>", "<right>", "<enter>")285 f := h.s.CondFormats()[0]286 if !f.IsIcons() || len(f.Scale) != 3 || f.Scale[0].Value != "25" || f.Scale[2].Value != "75" {287 t.Errorf("icons %+v", f)288 }289 if !strings.Contains(text(e), "↓↘↗↑") {290 t.Errorf("list sample:\n%s", text(e))291 }292}