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 / sheet / live_test.go
live_test.go 9.6K
1package sheet23import (4 "bytes"5 "errors"6 "strings"7 "testing"8 "time"910 "src.rest/cat/012/internal/value"11)1213// liveRow makes a row of entries, as a CSV line's fields.14func liveRow(fields ...string) LiveRow {15 row := make(LiveRow, len(fields))16 for i, f := range fields {17 v, fm := EntryValue(f)18 row[i] = LiveCell{V: v, F: fm}19 }20 return row21}2223// linkedSheet is a sheet with a region at A1 showing a header and n rows24// of numbers 1..n in column B.25func linkedSheet(t *testing.T, window int) (*Sheet, string) {26 t.Helper()27 s := New()28 id, err := s.AddLinked(Addr{}, LinkSource{Path: "log.csv", Window: window})29 if err != nil {30 t.Fatal(err)31 }32 return s, id33}3435func apply(t *testing.T, s *Sheet, op LiveOp) LinkedRegion {36 t.Helper()37 if err := s.Book().ApplyLive(op); err != nil {38 t.Fatal(err)39 }40 r, _ := s.Book().LinkedRegion(op.Region)41 return r42}4344func TestLiveResetAndAppend(t *testing.T) {45 s, id := linkedSheet(t, 0)46 s.Set(Addr{Col: 4}, "=SUM(B:B)")47 s.Set(Addr{Col: 5}, "=COUNTA(A2:A100)")48 r := apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("name", "n"), Rows: []LiveRow{liveRow("a", "1"), liveRow("b", "2")}})49 if r.Rows != 2 || r.Area != NewRect(Addr{}, Addr{Col: 1, Row: 2}) || r.Stale {50 t.Fatalf("after reset: %+v", r)51 }52 if got := s.Value(Addr{Col: 4}).Num; got != 3 {53 t.Fatalf("SUM = %v, want 3", got)54 }55 at := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)56 r = apply(t, s, LiveOp{Region: id, At: at, Rows: []LiveRow{liveRow("c", "3", "extra")}})57 if r.Rows != 3 || r.Area.To != (Addr{Col: 2, Row: 3}) || !r.Updated.Equal(at) {58 t.Fatalf("after append: %+v", r)59 }60 if got := s.Value(Addr{Col: 4}).Num; got != 6 {61 t.Fatalf("SUM = %v, want 6", got)62 }63 if got := s.Value(Addr{Col: 5}).Num; got != 3 {64 t.Fatalf("COUNTA = %v, want 3", got)65 }66 if !s.Cell(Addr{Row: 3}).Spilled() || s.Value(Addr{Col: 2, Row: 3}).Str != "extra" {67 t.Fatalf("row 4: %+v", s.Cell(Addr{Row: 3}))68 }69 if s.CanUndo() && s.Book().UndoLabel() != "edit F1" {70 t.Fatalf("rows arriving made an undo step: %q", s.Book().UndoLabel())71 }72 // A reset with fewer rows clears the rest.73 apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("name", "n"), Rows: []LiveRow{liveRow("z", "10")}})74 if s.Filled(Addr{Row: 2}) || s.Filled(Addr{Col: 2, Row: 3}) {75 t.Fatal("a reset left old rows")76 }77 if got := s.Value(Addr{Col: 4}).Num; got != 10 {78 t.Fatalf("SUM = %v, want 10", got)79 }80}8182func TestLiveWindow(t *testing.T) {83 s, id := linkedSheet(t, 3)84 s.Set(Addr{Col: 4}, "=SUM(B2:B10)")85 var rows []LiveRow86 for _, n := range []string{"1", "2", "3", "4", "5"} {87 rows = append(rows, liveRow("r"+n, n))88 }89 r := apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("name", "n"), Rows: rows[:2]})90 if r.Rows != 2 || r.Dropped != 0 {91 t.Fatalf("%+v", r)92 }93 r = apply(t, s, LiveOp{Region: id, Rows: rows[2:]})94 if r.Rows != 3 || r.Dropped != 2 {95 t.Fatalf("after the window filled: %+v", r)96 }97 var got []string98 for row := 1; row <= 3; row++ {99 got = append(got, s.Value(Addr{Row: row}).Str)100 }101 if strings.Join(got, ",") != "r3,r4,r5" || s.Value(Addr{Row: 0}).Str != "name" {102 t.Fatalf("rows %v, header %v", got, s.Value(Addr{}))103 }104 if s.Filled(Addr{Row: 4}) {105 t.Fatal("the window left a row below")106 }107 if v := s.Value(Addr{Col: 4}).Num; v != 12 {108 t.Fatalf("SUM = %v, want 12", v)109 }110 // One more row drops one more.111 r = apply(t, s, LiveOp{Region: id, Rows: []LiveRow{liveRow("r6", "6")}})112 if r.Dropped != 3 || s.Value(Addr{Row: 1}).Str != "r4" || s.Value(Addr{Row: 3}).Str != "r6" {113 t.Fatalf("%+v: %v..%v", r, s.Value(Addr{Row: 1}), s.Value(Addr{Row: 3}))114 }115}116117func TestLiveMaxCells(t *testing.T) {118 SetMaxCells(8) // a header and three rows of two119 defer SetMaxCells(0)120 s, id := linkedSheet(t, 0)121 var rows []LiveRow122 for range 5 {123 rows = append(rows, liveRow("x", "1"))124 }125 r := apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a", "b"), Rows: rows})126 if r.Rows != 3 || !strings.Contains(r.Note, "only the first 3 rows") {127 t.Fatalf("%+v", r)128 }129}130131func TestLiveRefusesEditsAndBlocks(t *testing.T) {132 s, id := linkedSheet(t, 0)133 apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})134 if err := s.Set(Addr{Row: 1}, "5"); !errors.Is(err, ErrLinkedEdit) {135 t.Fatalf("Set in a region: %v", err)136 }137 if _, r, ok := s.InRegion(NewRect(Addr{Row: 1}, Addr{Col: 3, Row: 5})); !ok || r.Name != id {138 t.Fatal("InRegion missed the linked file")139 }140 // Formatting a linked cell keeps its value.141 s.SetStyle(NewRect(Addr{Row: 1}, Addr{Row: 1}), func(st *Style) { st.Bold = true })142 if c := s.Cell(Addr{Row: 1}); c.Value.Num != 1 || !c.Style.Bold || !c.Spilled() {143 t.Fatalf("formatted linked cell: %+v", c)144 }145 // Rows that would overwrite a typed cell aren't shown, and the first146 // cell says why; clearing the cell has the file read again.147 s.Set(Addr{Row: 3}, "mine")148 r := apply(t, s, LiveOp{Region: id, Rows: []LiveRow{liveRow("2"), liveRow("3")}})149 if !strings.Contains(r.Err, "overwrite data in A4") || r.Rows != 0 || s.Value(Addr{Row: 3}).Str != "mine" ||150 s.Value(Addr{}) != value.ErrRef || s.Filled(Addr{Row: 1}) {151 t.Fatalf("%+v", r)152 }153 s.Set(Addr{Row: 3}, "")154 if r, _ := s.Book().LinkedRegion(id); !r.Stale {155 t.Fatalf("clearing the cell in the way: %+v", r)156 }157}158159func TestLiveErrorShowsInAnchor(t *testing.T) {160 s, id := linkedSheet(t, 0)161 r := apply(t, s, LiveOp{Region: id, Err: "no such file"})162 if r.Err != "no such file" || s.Value(Addr{}) != value.ErrRef {163 t.Fatalf("%+v, anchor %v", r, s.Value(Addr{}))164 }165 r = apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})166 if r.Err != "" || s.Value(Addr{}).Str != "a" {167 t.Fatalf("%+v, anchor %v", r, s.Value(Addr{}))168 }169 // An error once rows show keeps them.170 r = apply(t, s, LiveOp{Region: id, Err: "gone"})171 if r.Err != "gone" || s.Value(Addr{Row: 1}).Num != 1 {172 t.Fatalf("%+v", r)173 }174}175176func TestUnlinkAndUndo(t *testing.T) {177 s, id := linkedSheet(t, 0)178 apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})179 if err := s.Book().Unlink(id); err != nil {180 t.Fatal(err)181 }182 if s.HasLinked() || s.Cell(Addr{Row: 1}).Spilled() || s.Value(Addr{Row: 1}).Num != 1 {183 t.Fatalf("unlinked: %+v", s.Cell(Addr{Row: 1}))184 }185 if err := s.Set(Addr{Row: 1}, "7"); err != nil {186 t.Fatal(err)187 }188 s.Undo()189 s.Undo() // the unlink190 r, ok := s.Book().LinkedRegion(id)191 if !ok || !r.Stale || s.Filled(Addr{Row: 1}) {192 t.Fatalf("undo of unlink: %+v %v, A2 %v", r, ok, s.Value(Addr{Row: 1}))193 }194 apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("2")}})195 s.Undo() // making the region196 if s.HasLinked() || s.Filled(Addr{}) || s.Filled(Addr{Row: 1}) {197 t.Fatal("undoing the region left its cells")198 }199 if err := s.Book().ApplyLive(LiveOp{Region: id}); !errors.Is(err, ErrNoRegion) {200 t.Fatalf("op for a region undone: %v", err)201 }202 s.Redo()203 if r, ok := s.Book().LinkedRegion(id); !ok || !r.Stale {204 t.Fatalf("redo: %+v", r)205 }206}207208func TestLinkedShiftsWithRows(t *testing.T) {209 s := New()210 s.Set(Addr{Row: 0}, "above")211 id, err := s.AddLinked(Addr{Row: 2}, LinkSource{Path: "log.csv"})212 if err != nil {213 t.Fatal(err)214 }215 apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})216 s.Set(Addr{Col: 3, Row: 1}, "x")217 if err := s.InsertRows(1, 2); err != nil {218 t.Fatal(err)219 }220 r, _ := s.Book().LinkedRegion(id)221 if r.Anchor != (Addr{Row: 4}) || !r.Stale || s.Filled(Addr{Row: 2}) || s.Filled(Addr{Row: 4}) {222 t.Fatalf("%+v", r)223 }224 if s.Value(Addr{Col: 3, Row: 3}).Str != "x" {225 t.Fatal("a cell moving past the region was lost")226 }227 apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})228 if s.Value(Addr{Row: 5}).Num != 1 {229 t.Fatal("the region wasn't written at its new place")230 }231 // Rows inserted below it leave it be.232 s.InsertRows(20, 1)233 if r, _ := s.Book().LinkedRegion(id); r.Stale {234 t.Fatal("rows below the region made it stale")235 }236 s.DeleteRows(4, 1) // its anchor's row237 if s.HasLinked() {238 t.Fatal("deleting the anchor's row kept the region")239 }240}241242func TestLinkedFileRoundTrip(t *testing.T) {243 s, id := linkedSheet(t, 500)244 apply(t, s, LiveOp{Region: id, Reset: true, Header: liveRow("a"), Rows: []LiveRow{liveRow("1")}})245 var b bytes.Buffer246 if err := s.Write(&b); err != nil {247 t.Fatal(err)248 }249 text := b.String()250 if !strings.Contains(text, `"regions": [`) || !strings.Contains(text, `{"name":"log","at":"A1","path":"log.csv","window":500}`) ||251 strings.Contains(text, `"A2"`) || strings.Contains(text, `"notebook"`) {252 t.Fatalf("written:\n%s", text)253 }254 got, err := Read(strings.NewReader(text))255 if err != nil {256 t.Fatal(err)257 }258 rs := got.LinkedRegions()259 if len(rs) != 1 || rs[0].Name != "log" || rs[0].Source != (LinkSource{Path: "log.csv", Window: 500}) || !rs[0].Stale {260 t.Fatalf("read: %+v", rs)261 }262 for _, bad := range []string{`[{"name": "a", "at": "ZZZZ9", "path": "a"}]`, `[{"name": "a", "at": "A1", "path": "a", "window": -1}]`,263 `[{"name": "a", "at": "A1", "path": "a"}, {"name": "a", "at": "A3", "path": "b"}]`, `[{"name": "a", "at": "A1", "path": "a", "command": "ls"}]`} {264 if _, err := Read(strings.NewReader(`{"version": 2, "cells": {}, "regions": ` + bad + `}`)); err == nil {265 t.Errorf("read links %s", bad)266 }267 }268}269270func TestEntryValue(t *testing.T) {271 for in, want := range map[string]Value{"12": value.Num(12), "$5": value.Num(5), "true": value.Boolean(true),272 "'12": value.Str("12"), "=A1": value.Str("=A1"), "a\x1bb": value.Str("ab"), "": {}} {273 if got, _ := EntryValue(in); got != want {274 t.Errorf("EntryValue(%q) = %v, want %v", in, got, want)275 }276 }277 if _, f := EntryValue("9/26/2026"); f.IsZero() {278 t.Error("a date implied no format")279 }280}