1package sheet23import (4 "bytes"5 "flag"6 "fmt"7 "maps"8 "math"9 "math/rand/v2"10 "slices"11 "strings"12 "testing"1314 "src.rest/cat/012/internal/notebook"15)1617// Random edits check invariants that hold between features rather than18// within one: a sequence of edits, pastes, fills, sorts, inserts and19// deletes, formats, merges, moves, notes, rules, names, sheets and20// region runs, drawn from a seed, must leave a workbook that21//22// - undoes, step by step, to exactly where it started (the file it23// saves and every value), and redoes to where it ended;24// - saves and reopens to the same file and the same values (reopening25// recalculates everything from scratch, so this is also a full26// recalculation agreeing with the incremental one);27// - recalculates in place, everything at once, to the same values.28//29// go test runs a fixed set of seeds in a few seconds; -randedit.seeds30// runs more, and FuzzRandomEdits lets the fuzzer choose the edits.31var (32 randSeeds = flag.Int("randedit.seeds", 3000, "seeds TestRandomEdits runs")33 randSteps = flag.Int("randedit.steps", 40, "edits per seed")34)3536func TestRandomEdits(t *testing.T) {37 seeds := *randSeeds38 if testing.Short() {39 seeds = min(seeds, 50)40 }41 for seed := range seeds {42 rng := rand.New(rand.NewPCG(uint64(seed), 12))43 data := make([]byte, 64+(*randSteps)*8)44 for i := range data {45 data[i] = byte(rng.Uint32())46 }47 t.Run(fmt.Sprint(seed), func(t *testing.T) { checkRandomEdits(t, data, *randSteps) })48 }49}5051// FuzzRandomEdits reads the edits from the fuzzer's bytes: go test52// -run '^$' -fuzz FuzzRandomEdits ./internal/sheet.53func FuzzRandomEdits(f *testing.F) {54 f.Add([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20})55 f.Fuzz(func(t *testing.T, data []byte) { checkRandomEdits(t, data, 60) })56}5758// edits draws choices from bytes; past their end every choice is 0.59type edits struct {60 data []byte61 i int62}6364func (e *edits) n(k int) int {65 if k <= 1 || e.i >= len(e.data) {66 return 067 }68 b := int(e.data[e.i])69 e.i++70 return b % k71}7273func (e *edits) done() bool { return e.i >= len(e.data) }7475// Edits stay in a small corner, so they meet: columns A to H, rows 1 to76// 20.77const randCols, randRows = 8, 207879func (e *edits) addr() Addr { return Addr{Col: e.n(randCols), Row: e.n(randRows)} }8081func (e *edits) rect() Rect {82 a := e.addr()83 return NewRect(a, Addr{Col: min(a.Col+e.n(3), randCols-1), Row: min(a.Row+e.n(5), randRows-1)})84}8586// checkRandomEdits applies up to steps edits from data and checks the87// invariants. When one fails, it reports the fewest of the edits that88// still fail, with the log of them.89func checkRandomEdits(t *testing.T, data []byte, steps int) {90 t.Helper()91 msg, log := randomEdits(data, steps)92 if msg == "" {93 return94 }95 for n := 1; n < len(log); n++ {96 if m, l := randomEdits(data, n); m != "" {97 msg, log = m, l98 break99 }100 }101 t.Fatalf("%s\nafter %d edits:\n %s", msg, len(log), strings.Join(log, "\n "))102}103104// randomEdits builds a workbook, applies up to steps edits from data,105// and says which invariant fails, if one does, and the edits made.106//107// A region's rows come from outside the undo history, as a notebook108// cell's output or a linked file does: the edits send a region its109// rows (feed), and after undoing, redoing or reopening, each region is110// sent the rows its source gave at that point, as the UI would.111func randomEdits(data []byte, steps int) (string, []string) {112 e := &edits{data: data}113 r := newRandBook(e)114 start, startVals, startFeeds := r.save(), bookValues(r.wb), maps.Clone(r.feeds)115 var log []string116 for range steps {117 if e.done() {118 break119 }120 log = append(log, r.edit(e))121 feedStale(r.wb, r.feeds)122 }123 end, endVals := r.save(), bookValues(r.wb)124 if d := r.reopenDiff(end, endVals); d != "" {125 return "reopening differs: " + d, log126 }127 undone := 0128 for r.wb.CanUndo() {129 r.wb.Undo()130 undone++131 }132 feedAll(r.wb, startFeeds)133 feedStale(r.wb, startFeeds)134 if got := r.save(); !bytes.Equal(got, start) {135 return fmt.Sprintf("undoing %d steps saves differently:\n%s", undone, lineDiff(string(start), string(got))), log136 }137 if d := diffValues(startVals, bookValues(r.wb)); d != "" {138 return fmt.Sprintf("undoing %d steps: %s", undone, d), log139 }140 for r.wb.CanRedo() {141 r.wb.Redo()142 }143 feedAll(r.wb, r.feeds)144 feedStale(r.wb, r.feeds)145 if got := r.save(); !bytes.Equal(got, end) {146 return "redoing saves differently:\n" + lineDiff(string(end), string(got)), log147 }148 if d := diffValues(endVals, bookValues(r.wb)); d != "" {149 return "redoing: " + d, log150 }151 r.wb.RecalcAll()152 feedStale(r.wb, r.feeds)153 if d := diffValues(endVals, bookValues(r.wb)); d != "" {154 return "recalculating everything: " + d, log155 }156 return "", log157}158159// randBook is the workbook edited, its notebook tab, and the rows each160// region's source gives now, by region.161type randBook struct {162 wb *Workbook163 nb *Sheet164 feeds map[string]LiveOp165}166167// newRandBook makes two sheets of values and formulas drawn from e, a168// named range, a notebook tab of a note and a code cell, the code169// cell's output sent to S2, then forgets the history, so undoing170// everything comes back here.171func newRandBook(e *edits) *randBook {172 s := New()173 wb := s.Book()174 s2, _ := wb.AddSheet("S2", 1)175 nb, _ := wb.AddNotebook("N", s2)176 r := &randBook{wb: wb, nb: nb, feeds: map[string]LiveOp{}}177 for _, sh := range []*Sheet{s, s2} {178 for range 14 {179 sh.Set(e.addr(), r.input(e))180 }181 }182 wb.DefineName("Total", s, NewRect(Addr{}, Addr{Col: 1, Row: 9}))183 nb.SetNotebookCells("add cells", []notebook.Cell{{Kind: notebook.Note, Source: "# Files"}, {Source: "r1 = ls"}})184 r.addOutput(s2, "r1", e)185 feedStale(wb, r.feeds)186 wb.ClearHistory()187 return r188}189190// addOutput sends the output of the notebook cell name to s at an empty191// cell, and gives it rows.192func (r *randBook) addOutput(s *Sheet, name string, e *edits) error {193 a := e.addr()194 for tries := 0; s.Filled(a) && tries < 20; tries++ {195 a = e.addr()196 }197 if err := s.AddRegion(Region{Name: name, At: a, Output: true}); err != nil {198 return err199 }200 r.feed(name, e)201 return nil202}203204// feed has a region's source give it a random table of numbers under a205// header, as running its cell would.206func (r *randBook) feed(name string, e *edits) {207 rows, cols := e.n(5), 1+e.n(3)208 op := LiveOp{Region: name, Reset: true}209 for c := range cols {210 op.Header = append(op.Header, LiveCell{V: Value{Kind: Text, Str: fmt.Sprintf("c%d", c)}})211 }212 for range rows {213 row := make(LiveRow, cols)214 for c := range row {215 row[c] = LiveCell{V: Value{Kind: Number, Num: float64(e.n(20))}}216 }217 op.Rows = append(op.Rows, row)218 }219 r.feeds[name] = op220 sendRows(r.wb, op)221}222223// feedStale sends the regions to be sent again (moved, put back by224// undo, no longer blocked) their source's rows, as the UI does after225// every change, until none is: a region sent may free cells another226// was blocked by, or take cells another gives way with.227func feedStale(wb *Workbook, feeds map[string]LiveOp) {228 for range 8 {229 stale := wb.StaleOutputs()230 if len(stale) == 0 {231 return232 }233 for _, name := range stale {234 if op, ok := feeds[name]; ok {235 sendRows(wb, op)236 }237 }238 }239}240241// feedAll sends each region wb holds its source's rows.242func feedAll(wb *Workbook, feeds map[string]LiveOp) {243 for _, name := range slices.Sorted(maps.Keys(feeds)) {244 sendRows(wb, feeds[name])245 }246}247248// sendRows sends a region its rows. A table widens its columns to its249// text the first time it shows, outside the undo history as its rows250// are, so undoing the region leaves them wide, as intended251// (docs/files/following.md); the edits change widths themselves252// instead.253func sendRows(wb *Workbook, op LiveOp) {254 if s, r, ok := wb.Region(op.Region); ok {255 s.meta(nameKey(r.Name)).fitted = true256 }257 wb.ApplyLive(op)258}259260func (r *randBook) save() []byte { return saveBook(r.wb) }261262// saveBook is the file wb saves. Saving into memory fails only on a263// bug, which the test then panics with.264func saveBook(wb *Workbook) []byte {265 var b bytes.Buffer266 if err := wb.Write(&b); err != nil {267 panic(err)268 }269 return b.Bytes()270}271272// reopenDiff reads the saved file, sends its regions their rows again,273// and says how it differs from the workbook saved.274func (r *randBook) reopenDiff(file []byte, vals map[string]cellValue) string {275 wb, err := ReadBook(bytes.NewReader(file))276 if err != nil {277 return "reopening: " + err.Error()278 }279 if again := saveBook(wb); !bytes.Equal(again, file) {280 return "saves differently:\n" + lineDiff(string(file), string(again))281 }282 feedAll(wb, r.feeds)283 feedStale(wb, r.feeds)284 return diffValues(vals, bookValues(wb))285}286287// cellValue is a cell's value, and what was typed in it to say which.288type cellValue struct {289 v Value290 input string291}292293// bookValues are every filled cell's value, by sheet and address.294func bookValues(wb *Workbook) map[string]cellValue {295 out := map[string]cellValue{}296 for _, s := range wb.sheets {297 for _, a := range s.Addrs() {298 out[s.name+"!"+a.String()] = cellValue{s.Value(a), s.Cell(a).Input}299 }300 }301 return out302}303304func diffValues(want, got map[string]cellValue) string {305 var b strings.Builder306 for k, w := range want {307 if g, ok := got[k]; !ok || !sameValue(w.v, g.v) {308 fmt.Fprintf(&b, "\n %s: want %v (%q), got %v (%q)", k, w.v, w.input, g.v, g.input)309 }310 }311 for k, g := range got {312 if _, ok := want[k]; !ok {313 fmt.Fprintf(&b, "\n %s: want nothing, got %v (%q)", k, g.v, g.input)314 }315 }316 return b.String()317}318319func sameValue(a, b Value) bool {320 return a.Kind == b.Kind && a.Str == b.Str && (a.Num == b.Num || math.IsNaN(a.Num) && math.IsNaN(b.Num))321}