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 / share_test.go
share_test.go 9.0K
1package ui23import (4 "os"5 "path/filepath"6 "strings"7 "testing"89 tea "charm.land/bubbletea/v2"10 "github.com/charmbracelet/x/ansi"1112 "src.rest/cat/012/internal/config"13 "src.rest/cat/012/internal/confine"14 "src.rest/cat/012/internal/room"15 "src.rest/cat/012/internal/sheet"16)1718// seshion is a served session in a room, driven as its program would:19// every message through Shared.20type seshion struct {21 t *testing.T22 sh *Shared23 m *Model24}2526// servedRooms is a served directory and the rooms of its sessions.27type servedRooms struct {28 t *testing.T29 dir string30 root confine.Root31 reg *room.Registry32 all []*seshion33}3435func newRooms(t *testing.T, mode room.Mode) *servedRooms {36 dir := t.TempDir()37 root, err := confine.New(dir)38 if err != nil {39 t.Fatal(err)40 }41 return &servedRooms{t: t, dir: dir, root: root, reg: room.NewRegistry(mode)}42}4344// open starts user's session on name, as ssh -t host name does, and45// catches everyone up.46func (r *servedRooms) open(user, name string) *seshion {47 m := New(sheet.New(), "")48 m.Serve(r.root, []string{"TERM=xterm-256color"})49 m.ShareRooms(r.reg, user)50 m.OpenOnStart(name)51 m.SetMachine("here")52 m.share.still, m.nb.still = true, true53 s := &seshion{t: r.t, sh: InRooms(Guard(m)), m: m}54 s.run(s.sh.Init())55 s.send(tea.WindowSizeMsg{Width: 80, Height: 20})56 r.all = append(r.all, s)57 r.sync()58 s.sh.turn(func() { s.m.nb.still = true }) // entering the room made its notebook state anew59 return s60}6162// sync delivers roomMsg to every session its room told, until none is63// waiting: what Shared.Attach does for a program.64func (r *servedRooms) sync() {65 for range 10 {66 idle := true67 for _, s := range r.all {68 select {69 case <-s.m.share.link.kick:70 idle = false71 s.send(roomMsg{})72 default:73 }74 }75 if idle {76 return77 }78 }79}8081func (s *seshion) update(msg tea.Msg) tea.Cmd {82 _, cmd := s.sh.Update(msg)83 return cmd84}8586func (s *seshion) run(cmd tea.Cmd) tea.Msg { return runVia(s.update, cmd) }8788func (s *seshion) send(msg tea.Msg) tea.Msg { return s.run(s.update(msg)) }8990func (s *seshion) press(keys ...string) tea.Msg {91 s.t.Helper()92 return pressTo(s.t, s.send, keys...)93}9495func (s *seshion) screen() string {96 var out string97 s.sh.turn(func() { out = ansi.Strip(s.m.View().Content) })98 return out99}100101// Two sessions opening one file share its workbook: what one types the102// other sees, where each is shows on the other's screen, and each has103// its own cursor.104func TestSharedSessionsSeeEachOther(t *testing.T) {105 r := newRooms(t, room.Edit)106 writeSheet(t, filepath.Join(r.dir, "budget.012"), "rent")107 ann := r.open("ann", "budget.012")108 bob := r.open("bob", "budget.012")109 if ann.m.book() != bob.m.book() {110 t.Fatal("two workbooks")111 }112 if !strings.Contains(bob.m.note, "Sharing budget.012 with ann") {113 t.Errorf("bob's note %q", bob.m.note)114 }115 ann.press("<down>", "1200", "<enter>")116 r.sync()117 if got := bob.m.sheet.Value(addr("A2")).Num; got != 1200 {118 t.Fatalf("bob sees A2 = %v", got)119 }120 if !strings.Contains(bob.screen(), "1200") {121 t.Errorf("bob's screen:\n%s", bob.screen())122 }123 bob.press("<right>", "<right>")124 r.sync()125 if bob.m.cur != addr("C1") || ann.m.cur != addr("A3") {126 t.Errorf("cursors: ann %v, bob %v", ann.m.cur, bob.m.cur)127 }128 scr := ann.screen()129 if !strings.Contains(scr, "b 1 rent") {130 t.Errorf("bob's initial isn't on row 1's header:\n%s", scr)131 }132 if !strings.Contains(scr, " bob ") {133 t.Errorf("the status line doesn't list bob:\n%s", scr)134 }135 // Ann goes to bob's cell from Who's here.136 run(ann.m, nil)137 ann.run(ann.m.runCommand("share.who"))138 ann.press("<enter>")139 ann.screen() // a frame, which works out where the others are140 if ann.m.cur != addr("C1") || !strings.Contains(ansi.Strip(ann.m.shareLine()), "bob is here") {141 t.Errorf("ann at %v:\n%s", ann.m.cur, ann.screen())142 }143}144145// Undo takes back one's own changes, and says whose later change is in146// the way when it can't.147func TestSharedUndoIsYourOwn(t *testing.T) {148 r := newRooms(t, room.Edit)149 ann := r.open("ann", "@plan")150 bob := r.open("bob", "@plan")151 ann.press("mine", "<enter>")152 r.sync()153 bob.press("<right>", "theirs", "<enter>")154 r.sync()155 ann.press("<ctrl+z>")156 r.sync()157 s := ann.m.sheet158 if s.Filled(addr("A1")) || s.Value(addr("B1")).Str != "theirs" {159 t.Fatalf("after ann's undo: A1 %v, B1 %v", s.Value(addr("A1")), s.Value(addr("B1")))160 }161 if !strings.Contains(bob.screen(), "theirs") || strings.Contains(bob.screen(), "mine") {162 t.Errorf("bob's screen:\n%s", bob.screen())163 }164 ann.press("<ctrl+y>")165 r.sync()166 bob.press("<left>", "<up>", "bob's", "<enter>")167 r.sync()168 ann.press("<ctrl+z>")169 if !strings.Contains(ann.m.warn, "Can't undo") || !strings.Contains(ann.m.warn, "bob") || s.Value(addr("A1")).Str != "bob's" {170 t.Errorf("ann's undo: %q, A1 %v", ann.m.warn, s.Value(addr("A1")))171 }172 if ann.m.displayName() != "@plan" {173 t.Errorf("named %q", ann.m.displayName())174 }175}176177// An entry typed over a cell someone else changed meanwhile goes in, in178// the server's order, and both are told.179func TestSharedConflictingEntries(t *testing.T) {180 r := newRooms(t, room.Edit)181 ann := r.open("ann", "@c")182 bob := r.open("bob", "@c")183 ann.press("a", "nn")184 bob.press("bob", "<enter>")185 r.sync()186 if !strings.Contains(ann.m.warn, "bob changed A1 while you type") {187 t.Errorf("ann wasn't told: %q", ann.m.warn)188 }189 ann.press("<enter>")190 r.sync()191 if ann.m.sheet.Value(addr("A1")).Str != "ann" || !strings.Contains(ann.m.warn, "replaced bob's change to A1") {192 t.Errorf("A1 %v, ann's warning %q", ann.m.sheet.Value(addr("A1")), ann.m.warn)193 }194 bob.press("<up>")195 if !strings.Contains(bob.screen(), "Changed by") {196 t.Errorf("bob's context line:\n%s", bob.screen())197 }198}199200// Saving is the room's: a save by one clears the others' modified mark,201// and quitting while others remain asks nothing.202func TestSharedSaveAndQuit(t *testing.T) {203 r := newRooms(t, room.Edit)204 ann := r.open("ann", "notes.012")205 bob := r.open("bob", "notes.012")206 bob.press("x", "<enter>")207 r.sync()208 if !ann.m.changed || !bob.m.changed {209 t.Fatalf("modified: ann %v, bob %v", ann.m.changed, bob.m.changed)210 }211 ann.press("<ctrl+s>")212 r.sync()213 if ann.m.changed || bob.m.changed {214 t.Errorf("after ann saved, modified: ann %v, bob %v", ann.m.changed, bob.m.changed)215 }216 if _, err := os.Stat(filepath.Join(r.dir, "notes.012")); err != nil {217 t.Fatal(err)218 }219 bob.press("y", "<enter>")220 r.sync()221 if msg := ann.press("<ctrl+q>"); msg == nil {222 t.Errorf("ann asked before quitting, bob still there: %q", ann.screen())223 }224 if ann.m.LeaveRoom() {225 t.Error("ann was the last")226 }227 r.sync()228 if !bob.m.LeaveRoom() || !bob.m.Unsaved() {229 t.Error("bob, last, doesn't keep his unsaved change")230 }231}232233// In a one-writer room the others follow: they can't edit until the234// writer hands writing over.235func TestSharedOneWriter(t *testing.T) {236 r := newRooms(t, room.View)237 ann := r.open("ann", "@talk")238 bob := r.open("bob", "@talk")239 bob.press("hello")240 if bob.m.mode != modeReady || !strings.Contains(bob.m.warn, "ann writes here and you follow") {241 t.Fatalf("bob typed: mode %v, %q", bob.m.mode, bob.m.warn)242 }243 if !strings.Contains(bob.screen(), "ann writes") {244 t.Errorf("bob's status line:\n%s", bob.screen())245 }246 ann.run(ann.m.runCommand("share.hand"))247 ann.press("<enter>")248 r.sync()249 bob.press("hello", "<enter>")250 if bob.m.sheet.Value(addr("A1")).Str != "hello" {251 t.Errorf("bob, writing, couldn't type: %q", bob.m.warn)252 }253 ann.press("<down>", "no", "<enter>")254 if ann.m.sheet.Filled(addr("A2")) {255 t.Error("ann typed after handing over")256 }257}258259// shell lets s's notebook cells run, with a fake nu answering from out.260func (s *seshion) shell(out map[string]string) *fakeNu {261 path := filepath.Join(s.t.TempDir(), "config")262 os.WriteFile(path, []byte("serve-shell = true\nshell = on\n"), 0o600)263 load := func() *config.Config { return config.Load(path, func(string) string { return "" }, nil) }264 nu := &fakeNu{out: out}265 s.sh.turn(func() {266 s.m.Configure(Settings{Config: load(), Reload: load})267 s.m.SetShellRunner(nu)268 })269 return nu270}271272// A shared notebook's runs are the room's: whoever runs a cell, the273// one keeping the room finishes it, everyone sees its output and count,274// and they go on once the one who started them leaves.275func TestSharedNotebookRuns(t *testing.T) {276 r := newRooms(t, room.Edit)277 ann := r.open("ann", "@nb")278 bob := r.open("bob", "@nb")279 out := map[string]string{"ls": lsOut}280 ann.shell(out)281 bob.shell(out)282 ann.run(ann.m.runCommand("nb.open"))283 r.sync()284 ann.press("<esc>", "b", "<enter>")285 ann.send(pasteMsg("files = ls"))286 ann.press("<esc>", "<ctrl+enter>")287 r.sync()288 bob.run(bob.m.runCommand("nb.open"))289 if !strings.Contains(bob.screen(), "a.txt") || !strings.Contains(bob.screen(), "Out[1]:") {290 t.Fatalf("bob's notebook:\n%s", bob.screen())291 }292 bob.press("<down>", "<ctrl+enter>")293 r.sync()294 if !strings.Contains(ann.screen(), "Out[2]:") || !strings.Contains(bob.screen(), "Out[2]:") {295 t.Errorf("bob's run, finished by ann who keeps the room:\n%s", ann.screen())296 }297 if ann.m.LeaveRoom() {298 t.Fatal("ann was the last")299 }300 r.all = r.all[1:]301 r.sync()302 bob.press("<ctrl+enter>")303 r.sync()304 if !strings.Contains(bob.screen(), "Out[3]:") {305 t.Errorf("bob, keeping the room now, didn't finish his run:\n%s", bob.screen())306 }307}