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 / follow_test.go
follow_test.go 8.8K
1package ui23import (4 "os"5 "path/filepath"6 "strings"7 "testing"89 "github.com/charmbracelet/x/ansi"1011 "src.rest/cat/012/internal/confine"12 "src.rest/cat/012/internal/sheet"13)1415// pump polls every region's source once, as the ticks would, and16// applies what the polls found.17func pump(t *testing.T, m *Model) {18 t.Helper()19 followInterval = 020 run(m, m.syncFollowers())21 for range 3 { // a stale region reads, then the tick after22 for _, f := range m.follow.by {23 if f.waiting {24 send(m, followTickMsg{f})25 }26 }27 }28}2930func appendFile(t *testing.T, name, text string) {31 t.Helper()32 f, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0)33 if err != nil {34 t.Fatal(err)35 }36 defer f.Close()37 f.WriteString(text)38}3940// linkLog links log.csv at the active cell, keeping every row.41func linkLog(t *testing.T, m *Model) {42 t.Helper()43 m.runCommand("data.link")44 press(t, m, "log.csv", "<enter>")45 if !strings.Contains(line(m, contextLine), "Follow log.csv") {46 t.Fatalf("no question: %q", line(m, contextLine))47 }48 press(t, m, "<enter>")49 pump(t, m)50}5152func TestLinkFollowsAppends(t *testing.T) {53 t.Chdir(t.TempDir())54 writeFile(t, "log.csv", "level,n\ninfo,1\nwarn,2\n")55 m := newModel()56 press(t, m, "<right>", "<down>") // B257 linkLog(t, m)58 if got := m.sheet.Value(addr("C4")).Num; got != 2 || m.sheet.Value(addr("B2")).Str != "level" {59 t.Fatalf("C4 %v, B2 %v; %q", got, m.sheet.Value(addr("B2")), m.errMsg)60 }61 press(t, m, "<left>", "<up>", "=SUM(C:C)", "<enter>")62 m.changed = false63 appendFile(t, "log.csv", "error,4\npartial,")64 pump(t, m)65 if m.sheet.Value(addr("A1")).Num != 7 || m.sheet.Filled(addr("B6")) {66 t.Fatalf("after appending: SUM %v, B6 %v", m.sheet.Value(addr("A1")), m.sheet.Value(addr("B6")))67 }68 if m.changed {69 t.Error("rows arriving marked the file changed")70 }71 // The tab and the context line say it's following.72 m.cur = addr("B3")73 if l := line(m, contextLine); !strings.Contains(l, "● Following log.csv 3 rows") {74 t.Errorf("context line %q", l)75 }76 if st := status(m); !strings.Contains(st, "Sheet1 ●") {77 t.Errorf("status %q", st)78 }79 // Typing into the region is refused.80 press(t, m, "x", "<enter>")81 if m.sheet.Value(addr("B3")).Str != "info" {82 t.Errorf("typed over a linked cell: %v", m.sheet.Value(addr("B3")))83 }84 m.mode, m.errMsg = modeReady, ""85 m.cur = addr("B3")86 m.runCommand("clear")87 if !strings.Contains(m.note, "linked file") || m.sheet.Value(addr("B3")).Str != "info" {88 t.Errorf("clear: note %q, B3 %v", m.note, m.sheet.Value(addr("B3")))89 }9091 // Paused, it reads nothing; resumed, it catches up.92 m.runCommand("data.link_follow")93 appendFile(t, "log.csv", "5\n")94 pump(t, m)95 if m.sheet.Filled(addr("B6")) || !strings.Contains(ansi.Strip(m.linkedLine()), "‖ Paused log.csv") {96 t.Fatalf("paused, yet read: %q", m.linkedLine())97 }98 m.runCommand("data.link_follow")99 pump(t, m)100 if m.sheet.Value(addr("B6")).Str != "partial" || m.sheet.Value(addr("A1")).Num != 12 {101 t.Fatalf("resumed: B6 %v, SUM %v", m.sheet.Value(addr("B6")), m.sheet.Value(addr("A1")))102 }103104 // Keeping the last two rows.105 m.runCommand("data.link_rows")106 press(t, m, "l", "2", "<enter>")107 pump(t, m)108 if m.sheet.Value(addr("B3")).Str != "error" || m.sheet.Filled(addr("B5")) || m.sheet.Value(addr("B2")).Str != "level" {109 t.Fatalf("window: B3 %v B5 %v", m.sheet.Value(addr("B3")), m.sheet.Value(addr("B5")))110 }111 if l := line(m, contextLine); !strings.Contains(l, "2 rows (the last 2)") {112 t.Errorf("context line %q", l)113 }114115 // Unlinking keeps the values as ordinary cells.116 m.runCommand("data.unlink")117 run(m, m.syncFollowers())118 if m.sheet.HasLinked() || len(m.follow.by) != 0 || m.sheet.Cell(addr("B3")).Spilled() || m.sheet.Value(addr("B3")).Str != "error" {119 t.Fatalf("unlinked: %d followers, B3 %+v", len(m.follow.by), m.sheet.Cell(addr("B3")))120 }121 // Undo brings the region back, reading the file again.122 press(t, m, "<ctrl+z>")123 pump(t, m)124 if !m.sheet.HasLinked() || m.sheet.Value(addr("B3")).Str != "error" || !m.sheet.Cell(addr("B3")).Spilled() {125 t.Fatalf("undo: B3 %+v", m.sheet.Cell(addr("B3")))126 }127}128129func TestFollowInNewSheetAndReopen(t *testing.T) {130 t.Chdir(t.TempDir())131 writeFile(t, "log.csv", "a\n1\n")132 m := newModel()133 m.SetMachine("here")134 press(t, m, "x", "<enter>")135 m.runCommand("file.import")136 press(t, m, "log.csv", "<enter>", "follow", "<enter>", "<enter>")137 pump(t, m)138 if m.sheet.Name() != "log" || m.sheet.Value(addr("A2")).Num != 1 {139 t.Fatalf("sheet %q, A2 %v (%q)", m.sheet.Name(), m.sheet.Value(addr("A2")), m.errMsg)140 }141 // Saved in a folder of its own, the path is kept relative to it.142 os.Mkdir("books", 0o755)143 press(t, m, "<ctrl+s>", filepath.Join("books", "b"), "<enter>")144 data, _ := os.ReadFile(filepath.Join("books", "b.012"))145 if !strings.Contains(string(data), `"path":"../log.csv"`) {146 t.Fatalf("saved:\n%s", data)147 }148 if rs := m.book().LinkedRegions(); rs[0].Source.Path != filepath.Join("..", "log.csv") {149 t.Fatalf("path after saving: %q", rs[0].Source.Path)150 }151 appendFile(t, "log.csv", "2\n")152 pump(t, m)153 if m.sheet.Value(addr("A3")).Num != 2 {154 t.Fatalf("after saving elsewhere, A3 %v", m.sheet.Value(addr("A3")))155 }156157 // Reopened, it follows again; a missing file shows in the region.158 m2 := newModel()159 m2.SetMachine("here")160 press(t, m2, "<ctrl+o>", filepath.Join("books", "b"), "<enter>")161 m2.showSheet(m2.book().Lookup("log"))162 pump(t, m2)163 if m2.sheet.Value(addr("A3")).Num != 2 {164 t.Fatalf("reopened: A3 %v", m2.sheet.Value(addr("A3")))165 }166 os.Remove("log.csv")167 pump(t, m2)168 m2.cur = addr("A1")169 if l := line(m2, contextLine); !strings.Contains(l, "! log.csv: the file isn't there") {170 t.Errorf("context line %q", l)171 }172 if st := status(m2); !strings.Contains(st, "log !") {173 t.Errorf("status %q", st)174 }175}176177func TestLinksFromElsewhereAskOnce(t *testing.T) {178 dir := t.TempDir()179 t.Chdir(dir)180 outside := filepath.Join(t.TempDir(), "far.csv")181 os.WriteFile(outside, []byte("a\n1\n"), 0o644)182 book := `{"version": 2, "macroOrigin": "another", "cells": {}, "regions": [{"name": "far", "at": "A1", "path": ` + jsonQuote(outside) + `}]}`183 writeFile(t, "b.012", book)184 for _, answer := range []string{"<esc>", "<enter>"} {185 m := newModel()186 m.SetMachine("this-one")187 press(t, m, "<ctrl+o>", "b", "<enter>")188 if !strings.Contains(line(m, contextLine), "made on another computer") {189 t.Fatalf("no question: %q", line(m, contextLine))190 }191 press(t, m, answer)192 pump(t, m)193 followed := m.sheet.Value(addr("A2")).Num == 1194 if followed != (answer == "<enter>") {195 t.Errorf("%s: followed %v", answer, followed)196 }197 if answer == "<enter>" && m.book().MacroOrigin() != "this-one" {198 t.Errorf("trusting didn't record this computer: %q", m.book().MacroOrigin())199 }200 }201 // A file in the workbook's own folder needs no question.202 writeFile(t, "near.csv", "a\n1\n")203 writeFile(t, "c.012", `{"version": 2, "macroOrigin": "another", "cells": {}, "regions": [{"name": "near", "at": "A1", "path": "near.csv"}]}`)204 m := newModel()205 m.SetMachine("this-one")206 press(t, m, "<ctrl+o>", "c", "<enter>")207 pump(t, m)208 if m.sheet.Value(addr("A2")).Num != 1 || m.overlay != nil {209 t.Errorf("a file beside the workbook: A2 %v, overlay %T", m.sheet.Value(addr("A2")), m.overlay)210 }211}212213// Served, a linked file resolves inside the served folder: one outside214// shows why in its region, one inside is followed.215func TestLinkServedConfined(t *testing.T) {216 dir := t.TempDir()217 writeFile(t, filepath.Join(dir, "in.csv"), "a\n1\n")218 m := newModel()219 m.root, _ = confine.New(dir)220 m.SetMachine("here")221 m.book().SetMacroOrigin("here")222 out, _ := m.sheet.AddLinked(addr("A1"), sheet.LinkSource{Path: "../out.csv"})223 m.sheet.AddLinked(addr("C1"), sheet.LinkSource{Path: "in.csv"})224 pump(t, m)225 if r, _ := m.book().LinkedRegion(out); !strings.Contains(r.Err, "outside") || m.sheet.Value(addr("A1")).Str != "#REF!" {226 t.Errorf("outside the served folder: %+v, A1 %v", r, m.sheet.Value(addr("A1")))227 }228 if m.sheet.Value(addr("C2")).Num != 1 {229 t.Errorf("inside it: C2 %v", m.sheet.Value(addr("C2")))230 }231}232233func TestLinkPath(t *testing.T) {234 for _, c := range []struct{ name, book, want string }{235 {"log.csv", "", "log.csv"},236 {"logs/a.csv", "books/b.012", "../logs/a.csv"},237 {"books/a.csv", "books/b.012", "a.csv"},238 } {239 if got := linkPath(c.name, c.book); got != filepath.FromSlash(c.want) {240 t.Errorf("linkPath(%q, %q) = %q, want %q", c.name, c.book, got, c.want)241 }242 }243 abs := filepath.Join(t.TempDir(), "x.csv")244 if got := linkPath(abs, "b.012"); got != abs {245 t.Errorf("an absolute path outside: %q", got)246 }247 if got := linkNameIn("../a.csv", "books/b.012"); got != "a.csv" {248 t.Errorf("linkNameIn: %q", got)249 }250}251252func TestFreeSheetName(t *testing.T) {253 w := sheet.NewBook()254 w.RenameSheet(w.Sheet(0), "log")255 if got := freeSheetName(w, "log"); got != "log 2" {256 t.Errorf("%q", got)257 }258 if got := freeSheetName(w, "a:b"); got != "a_b" {259 t.Errorf("%q", got)260 }261}262263func jsonQuote(s string) string { return `"` + strings.ReplaceAll(s, `\`, `\\`) + `"` }