TestRandomEdits runs a thousand seeds of up to 40 edits across two sheets and a notebook's regions, and FuzzRandomEdits (make fuzz) lets the fuzzer choose them.
- commit
- 161167b1a53a5d2aafc7c154f4406f656b6da350
- parent
- d3cfa075f1c29680cafeea75a1847e9cd6986509
- author
- Dami <dami.etoile@gmail.com>
- kind
- test risk score 0.1 of 3, Jev's estimate
| M | Makefile | 1 | + |
| M | docs/ | 20 | ++- |
| A | internal/ | 298 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 252 | +++++++++++++++++++++++++ |
@@ -43,6 +43,7 @@ lint:
4343 fuzz:4444 go test ./internal/sheet -run '^$$' -fuzz FuzzParse -fuzztime 60s4545 go test ./internal/sheet -run '^$$' -fuzz FuzzRead -fuzztime 60s46+ go test ./internal/sheet -run '^$$' -fuzz FuzzRandomEdits -fuzztime 60s4647 go test ./internal/fileio -run '^$$' -fuzz FuzzReadDelimited -fuzztime 60s4748 go test ./internal/fileio -run '^$$' -fuzz FuzzReadWK1 -fuzztime 60s4849 go test ./internal/fileio -run '^$$' -fuzz 'FuzzReadXLSX$$' -fuzztime 60s@@ -9,7 +9,7 @@ sidebar_position: 5
99 make check # all of the below that must pass before a push: gofmt, vet, lint, test, oracle, e2e1010 make lint # go vet, staticcheck, cognitive complexity at most 25, Go files at most 500 lines, doclint and doccheck (see Docs below)1111 make test # engine, file formats and UI unit tests12-make fuzz # fuzz the formula parser and the .012, CSV, .wk1, XLSX and NUON readers12+make fuzz # fuzz the formula parser, random edits, and the .012, CSV, .wk1, XLSX and NUON readers1313 make oracle # compare formulas and number formats with excelize1414 make e2e # run the real binary in a terminal emulator (needs Zig and pkg-config)1515 make screens # rewrite the golden screens and build the review gallery@@ -25,7 +25,23 @@ streaming `.012` reader to encoding/json decoding the whole file: both
2525 refuse a file or both read it into workbooks that save the same bytes.2626 `TestFixturesOpenAndSaveUnchanged` opens the workbooks every release2727 saved and saves them again, byte for byte28-([File fixtures](releasing.md#file-fixtures)). The UI is tested by sending28+([File fixtures](releasing.md#file-fixtures)).29+30+`TestRandomEdits` (`internal/sheet/randedit_test.go`) checks what holds31+between features: from each of a thousand seeds it builds a workbook32+of two sheets and a notebook, makes up to 40 edits drawn from the seed33+(entries, pastes, fills, series, sorts, inserted and deleted rows and34+columns, formats, borders, merges, moves, notes, rules, names, sheets,35+frozen panes, filters, and region runs and sorts, with tables standing36+in for nushell's output), and checks that undoing every step gives back37+the file and the values it started with, redoing gives back the end,38+saving and reopening gives the same file and values (a recalculation39+from scratch), and recalculating everything in place changes no value.40+A failure names the fewest of the seed's edits that still fail. It takes41+about a second; `-randedit.seeds=20000` runs more, and `FuzzRandomEdits`42+(in `make fuzz`) lets the fuzzer choose the edits.43+44+The UI is tested by sending2945 Bubble Tea messages (keys, mouse, paste, window size) to the model and3046 reading what `View` renders, without a terminal.3147 @@ -0,0 +1,298 @@
1+package sheet2+3+import (4+ "fmt"5+ "strings"6+)7+8+// The edits TestRandomEdits draws from, each one undo step (or none,9+// when it's refused), described for the log.10+var randEdits = []func(r *randBook, e *edits) string{11+ (*randBook).editSet, (*randBook).editSet, (*randBook).editSet,12+ (*randBook).editErase, (*randBook).editPaste, (*randBook).editFill,13+ (*randBook).editSeries, (*randBook).editSort, (*randBook).editInsert,14+ (*randBook).editDelete, (*randBook).editFormat, (*randBook).editStyle,15+ (*randBook).editMerge, (*randBook).editMove, (*randBook).editLayout,16+ (*randBook).editNote, (*randBook).editRule, (*randBook).editRegion,17+ (*randBook).editSheets, (*randBook).editView,18+}19+20+func (r *randBook) edit(e *edits) string {21+ return randEdits[e.n(len(randEdits))](r, e)22+}23+24+// sheet picks one of the grid sheets (not the notebook) to edit.25+func (r *randBook) sheet(e *edits) *Sheet {26+ var grids []*Sheet27+ for _, s := range r.wb.sheets {28+ if !s.Notebook() {29+ grids = append(grids, s)30+ }31+ }32+ return grids[e.n(len(grids))]33+}34+35+// input is something to type into a cell: numbers, text, dates and36+// formulas that reach across ranges, sheets, names, regions and spills.37+func (r *randBook) input(e *edits) string {38+ ref := func() string {39+ a := e.addr()40+ if e.n(4) == 0 {41+ return "$" + ColName(a.Col) + "$" + fmt.Sprint(a.Row+1)42+ }43+ return a.String()44+ }45+ rng := func() string { return e.rect().String() }46+ other := []string{"Sheet1", "S2"}[e.n(2)]47+ forms := []func() string{48+ func() string { return fmt.Sprint(e.n(20) - 5) },49+ func() string { return fmt.Sprintf("%d.5", e.n(10)) },50+ func() string { return []string{"a", "b", "x y", "TRUE", "2026-01-05", "10%"}[e.n(6)] },51+ func() string { return "=" + ref() + "+" + ref() },52+ func() string { return "=SUM(" + rng() + ")" },53+ func() string { return "=" + other + "!" + ref() + "*2" },54+ func() string { return "=SUM(" + other + "!" + rng() + ")" },55+ func() string { return "=IFERROR(" + ref() + "/" + ref() + ",0)" },56+ func() string { return `=COUNTIF(` + rng() + `,">2")` },57+ func() string { return "=SEQUENCE(" + fmt.Sprint(1+e.n(3)) + ")" },58+ func() string { return "=SORT(" + rng() + ")" },59+ func() string { return "=VLOOKUP(" + ref() + "," + rng() + ",1,FALSE)" },60+ func() string { return "=SUM(nu.r1)" },61+ func() string { return "=SUM(Total)*2" },62+ func() string { return "=INDEX(" + rng() + ",1,1)" },63+ func() string { return "=MAX(" + ColName(e.n(randCols)) + ":" + ColName(e.n(randCols)) + ")" },64+ func() string { return "=CONCAT(" + ref() + "," + ref() + ")" },65+ func() string { return "=ABS(" + ref() + ")+1" },66+ func() string { return "=" + ref() + "&\"\"" },67+ func() string { return "" },68+ }69+ return forms[e.n(len(forms))]()70+}71+72+func (r *randBook) editSet(e *edits) string {73+ s, a, in := r.sheet(e), e.addr(), r.input(e)74+ err := s.Set(a, in)75+ return fmt.Sprintf("%s!%s = %q (%v)", s.name, a, in, err)76+}77+78+func (r *randBook) editErase(e *edits) string {79+ s, rg := r.sheet(e), e.rect()80+ s.EraseRange(rg)81+ return fmt.Sprintf("erase %s!%s", s.name, rg)82+}83+84+func (r *randBook) editPaste(e *edits) string {85+ src, dst := r.sheet(e), r.sheet(e)86+ from, to := e.rect(), e.rect()87+ values := e.n(3) == 088+ _, err := dst.Paste(src.Copy(from), to, values)89+ return fmt.Sprintf("copy %s!%s, paste (values %v) %s!%s (%v)", src.name, from, values, dst.name, to, err)90+}91+92+func (r *randBook) editFill(e *edits) string {93+ s, rg := r.sheet(e), e.rect()94+ var err error95+ if e.n(2) == 0 {96+ _, err = s.FillDown(rg)97+ return fmt.Sprintf("fill down %s!%s (%v)", s.name, rg, err)98+ }99+ _, err = s.FillRight(rg)100+ return fmt.Sprintf("fill right %s!%s (%v)", s.name, rg, err)101+}102+103+func (r *randBook) editSeries(e *edits) string {104+ s, src := r.sheet(e), e.rect()105+ dst := src106+ if e.n(2) == 0 {107+ dst.To.Row = min(dst.To.Row+1+e.n(6), randRows-1)108+ } else {109+ dst.To.Col = min(dst.To.Col+1+e.n(3), randCols-1)110+ }111+ _, err := s.FillSeries(src, dst)112+ return fmt.Sprintf("series %s!%s to %s (%v)", s.name, src, dst, err)113+}114+115+func (r *randBook) editSort(e *edits) string {116+ s, rg := r.sheet(e), e.rect()117+ keys := []SortKey{{Col: rg.From.Col + e.n(rg.To.Col-rg.From.Col+1), Desc: e.n(2) == 0}}118+ if e.n(2) == 0 {119+ keys = append(keys, SortKey{Col: rg.From.Col + e.n(rg.To.Col-rg.From.Col+1)})120+ }121+ s.SortRange(rg, keys)122+ return fmt.Sprintf("sort %s!%s by %v", s.name, rg, keys)123+}124+125+func (r *randBook) editInsert(e *edits) string {126+ s, at, n := r.sheet(e), e.n(randRows), 1+e.n(3)127+ if e.n(2) == 0 {128+ err := s.InsertRows(at, n)129+ return fmt.Sprintf("insert %d rows at %s!%d (%v)", n, s.name, at+1, err)130+ }131+ at %= randCols132+ err := s.InsertCols(at, n)133+ return fmt.Sprintf("insert %d cols at %s!%s (%v)", n, s.name, ColName(at), err)134+}135+136+func (r *randBook) editDelete(e *edits) string {137+ s, at, n := r.sheet(e), e.n(randRows), 1+e.n(3)138+ if e.n(2) == 0 {139+ s.DeleteRows(at, n)140+ return fmt.Sprintf("delete %d rows at %s!%d", n, s.name, at+1)141+ }142+ at %= randCols143+ s.DeleteCols(at, n)144+ return fmt.Sprintf("delete %d cols at %s!%s", n, s.name, ColName(at))145+}146+147+func (r *randBook) editFormat(e *edits) string {148+ s, rg := r.sheet(e), e.rect()149+ f := Format{Kind: FormatKind(e.n(int(FmtSize) + 1))}150+ if f.Kind.HasDecimals() {151+ f.Decimals = e.n(4)152+ }153+ switch e.n(3) {154+ case 0:155+ rg = colRect(rg.From.Col, rg.To.Col)156+ case 1:157+ rg = rowRect(rg.From.Row, rg.To.Row)158+ }159+ s.SetFormat(rg, f)160+ return fmt.Sprintf("format %s!%s %s", s.name, rg, f.Kind)161+}162+163+func (r *randBook) editStyle(e *edits) string {164+ s, rg := r.sheet(e), e.rect()165+ switch e.n(4) {166+ case 0:167+ s.SetStyle(rg, func(st *Style) { st.Bold = !st.Bold })168+ return fmt.Sprintf("bold %s!%s", s.name, rg)169+ case 1:170+ k, l := BorderKind(e.n(int(BorderNone)+1)), Line(1+e.n(3))171+ s.SetBorders(rg, k, l)172+ return fmt.Sprintf("borders %d %d %s!%s", k, l, s.name, rg)173+ case 2:174+ s.SetStyle(rg, func(st *Style) { st.Wrap = WrapOn })175+ return fmt.Sprintf("wrap %s!%s", s.name, rg)176+ }177+ s.ClearFormatting(rg)178+ return fmt.Sprintf("clear formatting %s!%s", s.name, rg)179+}180+181+func (r *randBook) editMerge(e *edits) string {182+ s, rg := r.sheet(e), e.rect()183+ if e.n(3) == 0 {184+ n := s.Unmerge(rg)185+ return fmt.Sprintf("unmerge %s!%s (%d)", s.name, rg, n)186+ }187+ k := MergeKind(e.n(3))188+ err := s.Merge(rg, k)189+ return fmt.Sprintf("merge %d %s!%s (%v)", k, s.name, rg, err)190+}191+192+func (r *randBook) editMove(e *edits) string {193+ src, dst := r.sheet(e), r.sheet(e)194+ rg, to := e.rect(), e.addr()195+ _, err := src.MoveTo(dst, rg, to)196+ return fmt.Sprintf("move %s!%s to %s!%s (%v)", src.name, rg, dst.name, to, err)197+}198+199+func (r *randBook) editLayout(e *edits) string {200+ s := r.sheet(e)201+ if e.n(2) == 0 {202+ c, w := e.n(randCols), 3+e.n(20)203+ s.SetColWidth(c, w)204+ return fmt.Sprintf("width %s!%s %d", s.name, ColName(c), w)205+ }206+ row, h := e.n(randRows), e.n(4)207+ s.SetRowHeight(row, row, h)208+ return fmt.Sprintf("height %s!%d %d", s.name, row+1, h)209+}210+211+func (r *randBook) editNote(e *edits) string {212+ s, a := r.sheet(e), e.addr()213+ text := []string{"", "note", "another"}[e.n(3)]214+ err := s.SetNote(a, text)215+ return fmt.Sprintf("note %s!%s %q (%v)", s.name, a, text, err)216+}217+218+func (r *randBook) editRule(e *edits) string {219+ s, rg := r.sheet(e), e.rect()220+ switch e.n(3) {221+ case 0:222+ err := s.AddCondFormat(CondFormat{Ranges: []Rect{rg}, Op: RuleGreater, Args: [2]string{fmt.Sprint(e.n(9))}, Style: RuleStyle{Bold: true}})223+ return fmt.Sprintf("conditional format %s!%s (%v)", s.name, rg, err)224+ case 1:225+ err := s.AddValidation(Validation{Ranges: []Rect{rg}, Kind: ValidList, Items: []string{"a", "b"}})226+ return fmt.Sprintf("validation %s!%s (%v)", s.name, rg, err)227+ }228+ s.ClearCondFormats(rg)229+ s.ClearValidations(rg)230+ return fmt.Sprintf("clear rules %s!%s", s.name, rg)231+}232+233+func (r *randBook) editRegion(e *edits) string {234+ switch e.n(4) {235+ case 0:236+ keys := []SortKey{{Col: e.n(3), Desc: e.n(2) == 0}}237+ err := r.nb.SortRegion("r1", keys)238+ return fmt.Sprintf("sort r1 by %v (%v)", keys, err)239+ case 1:240+ if _, _, ok := r.wb.Region("r2"); ok {241+ err := r.nb.DeleteRegion("r2")242+ return fmt.Sprintf("delete r2 (%v)", err)243+ }244+ err := r.nb.AddRegion(Region{Name: "r2", Command: "$r1 | first 2", Deps: []string{"r1"}})245+ return fmt.Sprintf("add r2 (%v)", err)246+ }247+ name := "r1"248+ if _, _, ok := r.wb.Region("r2"); ok && e.n(2) == 0 {249+ name = "r2"250+ }251+ r.run(name, e)252+ return "run " + name253+}254+255+func (r *randBook) editSheets(e *edits) string {256+ s := r.sheet(e)257+ switch e.n(4) {258+ case 0:259+ n, err := r.wb.AddSheet("", e.n(len(r.wb.sheets)+1))260+ if err != nil {261+ return fmt.Sprintf("add sheet (%v)", err)262+ }263+ return "add sheet " + n.name264+ case 1:265+ if s.name == "Sheet1" || s.name == "S2" {266+ return "keep " + s.name267+ }268+ err := r.wb.DeleteSheet(s)269+ return fmt.Sprintf("delete sheet %s (%v)", s.name, err)270+ case 2:271+ name := s.name + "x"272+ if strings.HasPrefix(s.name, "Sheet1") || s.name == "S2" {273+ return "keep the name " + s.name274+ }275+ err := r.wb.RenameSheet(s, name)276+ return fmt.Sprintf("rename %s (%v)", s.name, err)277+ }278+ rg := e.rect()279+ r.wb.DeleteName("Total")280+ err := r.wb.DefineName("Total", s, rg)281+ return fmt.Sprintf("Total = %s!%s (%v)", s.name, rg, err)282+}283+284+func (r *randBook) editView(e *edits) string {285+ s := r.sheet(e)286+ switch e.n(3) {287+ case 0:288+ rows, cols := e.n(3), e.n(3)289+ s.SetFrozen(rows, cols)290+ return fmt.Sprintf("freeze %s %d %d", s.name, rows, cols)291+ case 1:292+ rg := e.rect()293+ s.CreateFilter(rg)294+ return fmt.Sprintf("filter %s!%s", s.name, rg)295+ }296+ s.RemoveFilter()297+ return "remove filter " + s.name298+}@@ -0,0 +1,252 @@
1+package sheet2+3+import (4+ "bytes"5+ "flag"6+ "fmt"7+ "math"8+ "math/rand/v2"9+ "strings"10+ "testing"11+)12+13+// Random edits check invariants that hold between features rather than14+// within one: a sequence of edits, pastes, fills, sorts, inserts and15+// deletes, formats, merges, moves, notes, rules, names, sheets and16+// region runs, drawn from a seed, must leave a workbook that17+//18+// - undoes, step by step, to exactly where it started (the file it19+// saves and every value), and redoes to where it ended;20+// - saves and reopens to the same file and the same values (reopening21+// recalculates everything from scratch, so this is also a full22+// recalculation agreeing with the incremental one);23+// - recalculates in place, everything at once, to the same values.24+//25+// go test runs a fixed set of seeds in a second or two; -randedit.seeds26+// runs more, and FuzzRandomEdits lets the fuzzer choose the edits.27+var (28+ randSeeds = flag.Int("randedit.seeds", 1000, "seeds TestRandomEdits runs")29+ randSteps = flag.Int("randedit.steps", 40, "edits per seed")30+)31+32+func TestRandomEdits(t *testing.T) {33+ seeds := *randSeeds34+ if testing.Short() {35+ seeds = min(seeds, 50)36+ }37+ for seed := range seeds {38+ rng := rand.New(rand.NewPCG(uint64(seed), 12))39+ data := make([]byte, 64+(*randSteps)*8)40+ for i := range data {41+ data[i] = byte(rng.Uint32())42+ }43+ t.Run(fmt.Sprint(seed), func(t *testing.T) { checkRandomEdits(t, data, *randSteps) })44+ }45+}46+47+// FuzzRandomEdits reads the edits from the fuzzer's bytes: go test48+// -run '^$' -fuzz FuzzRandomEdits ./internal/sheet.49+func FuzzRandomEdits(f *testing.F) {50+ f.Add([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20})51+ f.Fuzz(func(t *testing.T, data []byte) { checkRandomEdits(t, data, 60) })52+}53+54+// edits draws choices from bytes; past their end every choice is 0.55+type edits struct {56+ data []byte57+ i int58+}59+60+func (e *edits) n(k int) int {61+ if k <= 1 || e.i >= len(e.data) {62+ return 063+ }64+ b := int(e.data[e.i])65+ e.i++66+ return b % k67+}68+69+func (e *edits) done() bool { return e.i >= len(e.data) }70+71+// Edits stay in a small corner, so they meet: columns A to H, rows 1 to72+// 20.73+const randCols, randRows = 8, 2074+75+func (e *edits) addr() Addr { return Addr{Col: e.n(randCols), Row: e.n(randRows)} }76+77+func (e *edits) rect() Rect {78+ a := e.addr()79+ return NewRect(a, Addr{Col: min(a.Col+e.n(3), randCols-1), Row: min(a.Row+e.n(5), randRows-1)})80+}81+82+// checkRandomEdits applies up to steps edits from data and checks the83+// invariants. When one fails, it reports the fewest of the edits that84+// still fail, with the log of them.85+func checkRandomEdits(t *testing.T, data []byte, steps int) {86+ t.Helper()87+ msg, log := randomEdits(data, steps)88+ if msg == "" {89+ return90+ }91+ for n := 1; n < len(log); n++ {92+ if m, l := randomEdits(data, n); m != "" {93+ msg, log = m, l94+ break95+ }96+ }97+ t.Fatalf("%s\nafter %d edits:\n %s", msg, len(log), strings.Join(log, "\n "))98+}99+100+// randomEdits builds a workbook, applies up to steps edits from data,101+// and says which invariant fails, if one does, and the edits made.102+func randomEdits(data []byte, steps int) (string, []string) {103+ e := &edits{data: data}104+ r := newRandBook(e)105+ start, startVals := r.save(), bookValues(r.wb)106+ var log []string107+ for range steps {108+ if e.done() {109+ break110+ }111+ log = append(log, r.edit(e))112+ }113+ end, endVals := r.save(), bookValues(r.wb)114+ if d := r.reopenDiff(end, endVals); d != "" {115+ return "reopening differs: " + d, log116+ }117+ undone := 0118+ for r.wb.CanUndo() {119+ r.wb.Undo()120+ undone++121+ }122+ if got := r.save(); !bytes.Equal(got, start) {123+ return fmt.Sprintf("undoing %d steps saves differently:\n%s", undone, lineDiff(string(start), string(got))), log124+ }125+ if d := diffValues(startVals, bookValues(r.wb)); d != "" {126+ return fmt.Sprintf("undoing %d steps: %s", undone, d), log127+ }128+ for r.wb.CanRedo() {129+ r.wb.Redo()130+ }131+ if got := r.save(); !bytes.Equal(got, end) {132+ return "redoing saves differently:\n" + lineDiff(string(end), string(got)), log133+ }134+ if d := diffValues(endVals, bookValues(r.wb)); d != "" {135+ return "redoing: " + d, log136+ }137+ r.wb.RecalcAll()138+ if d := diffValues(endVals, bookValues(r.wb)); d != "" {139+ return "recalculating everything: " + d, log140+ }141+ return "", log142+}143+144+// randBook is the workbook edited, and the tables its regions show,145+// which a file doesn't keep.146+type randBook struct {147+ wb *Workbook148+ nb *Sheet149+}150+151+// newRandBook makes two sheets of values and formulas drawn from e, a152+// named range, and a notebook with a region shown, then forgets the153+// history, so undoing everything comes back here.154+func newRandBook(e *edits) *randBook {155+ s := New()156+ wb := s.Book()157+ s2, _ := wb.AddSheet("S2", 1)158+ nb, _ := wb.AddNotebook("N", s2)159+ r := &randBook{wb: wb, nb: nb}160+ for _, sh := range []*Sheet{s, s2} {161+ for range 14 {162+ sh.Set(e.addr(), r.input(e))163+ }164+ }165+ wb.DefineName("Total", s, NewRect(Addr{}, Addr{Col: 1, Row: 9}))166+ nb.AddRegion(Region{Name: "r1", Command: "ls"})167+ r.run("r1", e)168+ wb.ClearHistory()169+ return r170+}171+172+// run shows a random table of numbers in a region, as running it would.173+func (r *randBook) run(name string, e *edits) {174+ d := &RegionData{Rows: 1 + e.n(5), Cols: 1 + e.n(3)}175+ d.Values = make([]Value, d.Rows*d.Cols)176+ for i := range d.Values {177+ if i < d.Cols {178+ d.Values[i] = Value{Kind: Text, Str: fmt.Sprintf("c%d", i)}179+ } else {180+ d.Values[i] = Value{Kind: Number, Num: float64(e.n(20))}181+ }182+ }183+ r.nb.ShowRegion(name, d)184+}185+186+func (r *randBook) save() []byte { return saveBook(r.wb) }187+188+// saveBook is the file wb saves. Saving into memory fails only on a189+// bug, which the test then panics with.190+func saveBook(wb *Workbook) []byte {191+ var b bytes.Buffer192+ if err := wb.Write(&b); err != nil {193+ panic(err)194+ }195+ return b.Bytes()196+}197+198+// reopenDiff reads the saved file, runs its regions again with the199+// tables they showed, and says how it differs from the workbook saved.200+func (r *randBook) reopenDiff(file []byte, vals map[string]shown) string {201+ wb, err := ReadBook(bytes.NewReader(file))202+ if err != nil {203+ return "reopening: " + err.Error()204+ }205+ if again := saveBook(wb); !bytes.Equal(again, file) {206+ return "saves differently:\n" + lineDiff(string(file), string(again))207+ }208+ for _, s := range wb.sheets {209+ for _, rg := range s.regions.list {210+ if d := r.nb.regions.data[nameKey(rg.Name)]; d != nil && !rg.Linked() {211+ s.ShowRegion(rg.Name, d)212+ }213+ }214+ }215+ return diffValues(vals, bookValues(wb))216+}217+218+// shown is a cell's value, and what was typed in it to say which.219+type shown struct {220+ v Value221+ input string222+}223+224+// bookValues are every filled cell's value, by sheet and address.225+func bookValues(wb *Workbook) map[string]shown {226+ out := map[string]shown{}227+ for _, s := range wb.sheets {228+ for _, a := range s.Addrs() {229+ out[s.name+"!"+a.String()] = shown{s.Value(a), s.Cell(a).Input}230+ }231+ }232+ return out233+}234+235+func diffValues(want, got map[string]shown) string {236+ var b strings.Builder237+ for k, w := range want {238+ if g, ok := got[k]; !ok || !sameValue(w.v, g.v) {239+ fmt.Fprintf(&b, "\n %s: want %v (%q), got %v (%q)", k, w.v, w.input, g.v, g.input)240+ }241+ }242+ for k, g := range got {243+ if _, ok := want[k]; !ok {244+ fmt.Fprintf(&b, "\n %s: want nothing, got %v (%q)", k, g.v, g.input)245+ }246+ }247+ return b.String()248+}249+250+func sameValue(a, b Value) bool {251+ return a.Kind == b.Kind && a.Str == b.Str && (a.Num == b.Num || math.IsNaN(a.Num) && math.IsNaN(b.Num))252+}