An agent's suggestion in live mode is a proposal: Propose makes the change on a copy and lists the cells it sets and what else it does; Apply makes it on the workbook as one step of the current author, the cells picked or, for a change beyond cells, the whole change run again. Adopt gives a person the steps made before the history was shared. TestRandomSharedEdits has an agent among the authors, its proposals accepted whole, in part or not at all.
- commit
- 6f104fba135597a813c12aa7213e878c21227410
- parent
- e34a85f0dda96ae15f0397b53f13abee49669c16
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.3 of 3, Jev's estimate
| M | internal/ | 16 | ++ |
| A | internal/ | 279 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 165 | ++++++++++++++++++ |
| M | internal/ | 60 | ++++++- |
@@ -64,6 +64,22 @@ func (w *Workbook) Author() int { return w.author }
6464 // steps, from now on.6565 func (w *Workbook) ShareHistory() { w.hist.shared = true }6666 67+// Adopt makes the steps of nobody in particular (author 0), made while68+// one person edited alone, the author's: a session sharing its workbook69+// from then on keeps its person's earlier steps theirs to undo.70+func (w *Workbook) Adopt(author int) {71+ for _, st := range w.hist.undo {72+ if st.author == 0 {73+ st.author = author74+ }75+ }76+ for _, st := range w.hist.redo {77+ if st.author == 0 {78+ st.author = author79+ }80+ }81+}82+6783 // Version changes whenever the workbook changes: a step made, undone or6884 // redone, a recalculation, rows arriving at a region.6985 func (w *Workbook) Version() uint64 { return w.gen + w.hist.seq }@@ -0,0 +1,279 @@
1+package sheet2+3+import (4+ "bytes"5+ "cmp"6+ "errors"7+ "slices"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+)11+12+// Proposals: a change made on a copy of a workbook, to be made on the13+// workbook itself later, whole or cell by cell, or not at all. An14+// agent's suggestion in live mode is one (docs/agents/live.md): the15+// person sees the cells it would change and accepts some or all of16+// them, and what's accepted is one step, its author the workbook's17+// author when it's applied, so undo and the others' marks follow as18+// for any step.19+//20+// A change that does more than set cells (inserting rows, adding a21+// sheet or a chart, a filter) is made whole only, by running it again22+// on the workbook; a change of cells alone is applied as the cells it23+// made, so what's accepted is what was shown.24+25+// Proposal is a change made on a copy of a workbook.26+type Proposal struct {27+ Label string28+ // Cells are the cells whose contents, format or note the change29+ // sets, by sheet in the copy's order, then row and column.30+ Cells []Proposed31+ // Other says what the change does besides setting cells, one32+ // phrase each ("inserts or deletes rows or columns on Sheet1"); a33+ // proposal with any is made whole only.34+ Other []string35+ // Sheets are the sheets, by name, whose lines, charts, rules,36+ // filters or pivot the change changes; Book is set when it changes37+ // the sheet list, the settings, the names or the macros.38+ Sheets []string39+ Book bool40+ // Remote is set when a formula it enters asks a hosted model (JEV),41+ // which reaches the network once the change is made.42+ Remote bool43+44+ copy *Workbook45+ fn func(*Workbook) error46+}47+48+// Proposed is a cell a proposal sets: its sheet, where, its input49+// before and after ("" for blank), and whether its format, style or50+// note change too.51+type Proposed struct {52+ Sheet string53+ At Addr54+ Was, Now string55+ Look bool56+}57+58+// ErrWhole is returned when part of a proposal that must be made whole59+// is picked.60+var ErrWhole = errors.New("this change does more than set cells: it can only be accepted whole")61+62+// Propose makes fn, labelled label, on a copy of w and says what it63+// changed. w isn't changed; fn may be run again on it when the proposal64+// is applied whole.65+func Propose(w *Workbook, label string, fn func(*Workbook) error) (*Proposal, error) {66+ var data bytes.Buffer67+ if err := w.Write(&data); err != nil {68+ return nil, err69+ }70+ s, err := Read(&data)71+ if err != nil {72+ return nil, err73+ }74+ c := s.Book()75+ before := c.StateID()76+ if err := c.Batch(Change{Label: label, Sheet: c.Sheet(c.Active())}, func() error { return fn(c) }); err != nil {77+ return nil, err78+ }79+ p := &Proposal{Label: label, copy: c, fn: fn}80+ if st := c.hist.top(); st != nil && st.id != before {81+ p.collect(w, st)82+ }83+ return p, nil84+}85+86+// Empty reports whether the proposal changes nothing.87+func (p *Proposal) Empty() bool { return len(p.Cells) == 0 && len(p.Other) == 0 }88+89+// Whole reports whether the proposal can only be made whole.90+func (p *Proposal) Whole() bool { return len(p.Other) > 0 }91+92+// Result is the copy with the change made, to read what it would look93+// like. It must not be changed.94+func (p *Proposal) Result() *Workbook { return p.copy }95+96+// collect notes what step st made on the copy, against w.97+func (p *Proposal) collect(w *Workbook, st *step) {98+ order := map[string]int{}99+ for i, s := range p.copy.sheets {100+ order[s.name] = i101+ }102+ for s, img := range st.cells {103+ orig := w.Lookup(s.name)104+ img.addrs(func(a Addr) bool {105+ p.cell(orig, s, a)106+ return true107+ })108+ }109+ slices.SortFunc(p.Cells, func(x, y Proposed) int {110+ return cmp.Or(cmp.Compare(order[x.Sheet], order[y.Sheet]), cmp.Compare(x.At.Row, y.At.Row), cmp.Compare(x.At.Col, y.At.Col))111+ })112+ p.others(st)113+}114+115+// cell notes a's change on the copy's sheet s, when it changed from116+// what the workbook's sheet orig holds (nil: the sheet is new).117+func (p *Proposal) cell(orig, s *Sheet, a Addr) {118+ now := s.cells.get(a)119+ if now.Spilled() || now != nil && now.derived {120+ return // an array's or a pivot's: its formula is the change121+ }122+ var was *Cell123+ if orig != nil {124+ was = orig.cells.get(a)125+ }126+ in := func(c *Cell) string {127+ if c == nil {128+ return ""129+ }130+ return c.Input131+ }132+ look := func(c *Cell) (Format, Style, string) {133+ if c == nil {134+ return Format{}, Style{}, ""135+ }136+ return c.Format, c.Style, c.Note137+ }138+ wf, ws, wn := look(was)139+ nf, ns, nn := look(now)140+ lookChanged := wf != nf || ws != ns || wn != nn141+ if in(was) == in(now) && !lookChanged {142+ return143+ }144+ p.Cells = append(p.Cells, Proposed{Sheet: s.name, At: a, Was: in(was), Now: in(now), Look: lookChanged})145+ if now != nil && now.expr != nil && callsRemote(now.expr) {146+ p.Remote = true147+ }148+}149+150+// others notes what the step changed besides cells.151+func (p *Proposal) others(st *step) {152+ sheets := map[*Sheet]bool{}153+ add := func(s *Sheet) {154+ if !sheets[s] {155+ sheets[s] = true156+ p.Sheets = append(p.Sheets, s.name)157+ }158+ }159+ for s := range st.shifts {160+ add(s)161+ p.Other = append(p.Other, "inserts or deletes rows or columns on "+s.name)162+ }163+ for _, m := range []struct {164+ what string165+ on map[*Sheet]bool166+ }{{"charts", keysOf(st.charts)}, {"a pivot table", keysOf(st.pivots)}, {"rules", keysOf(st.rules)}, {"regions", keysOf(st.regions)}, {"the view", keysOf(st.views)}} {167+ for s := range m.on {168+ add(s)169+ p.Other = append(p.Other, "changes "+m.what+" on "+s.name)170+ }171+ }172+ lines := map[*Sheet]bool{}173+ for k := range st.widths {174+ lines[k.s] = true175+ }176+ for k := range st.heights {177+ lines[k.s] = true178+ }179+ for k := range st.lines {180+ lines[k.s] = true181+ }182+ for s := range lines {183+ add(s)184+ p.Other = append(p.Other, "changes columns' or rows' sizes or formats on "+s.name)185+ }186+ for _, b := range []struct {187+ what string188+ on bool189+ }{{"adds, deletes, renames or moves sheets", st.sheets != nil}, {"changes named ranges", len(st.names) > 0},190+ {"changes the settings", st.settings != nil}, {"changes the macros", st.macros != nil}} {191+ if b.on {192+ p.Book = true193+ p.Other = append(p.Other, b.what)194+ }195+ }196+ slices.Sort(p.Other)197+}198+199+// callsRemote reports whether a formula calls a function a hosted200+// model answers.201+func callsRemote(n Node) bool {202+ found := false203+ var walk func(Node)204+ walk = func(n Node) {205+ if call, ok := n.(formula.Call); ok && !found {206+ if f := funcOf(call); f != nil && f.Remote() {207+ found = true208+ }209+ }210+ formula.EachChild(n, walk)211+ }212+ walk(n)213+ return found214+}215+216+// Apply makes the proposal on w as one step, in the name of w's author:217+// the cells pick keeps (nil keeps them all), or the whole change run218+// again on w when it does more than set cells.219+func (p *Proposal) Apply(w *Workbook, pick func(i int) bool) error {220+ if p.Whole() {221+ for i := range p.Cells {222+ if pick != nil && !pick(i) {223+ return ErrWhole224+ }225+ }226+ return w.Batch(Change{Label: p.Label, Sheet: w.Sheet(w.Active())}, func() error { return p.fn(w) })227+ }228+ var picked []Proposed229+ for i, c := range p.Cells {230+ if pick == nil || pick(i) {231+ picked = append(picked, c)232+ }233+ }234+ if len(picked) == 0 {235+ return nil236+ }237+ first := w.Lookup(picked[0].Sheet)238+ if first == nil {239+ return errors.New(picked[0].Sheet + " is gone")240+ }241+ focus := Rect{From: picked[0].At, To: picked[0].At}242+ for _, c := range picked[1:] {243+ if c.Sheet == picked[0].Sheet {244+ focus = union(focus, Rect{From: c.At, To: c.At})245+ }246+ }247+ return w.Batch(Change{Label: p.Label, Sheet: first, Focus: focus}, func() error {248+ for _, c := range picked {249+ if err := p.place(w, c); err != nil {250+ return err251+ }252+ }253+ return nil254+ })255+}256+257+// place sets c's cell on w as the copy holds it.258+func (p *Proposal) place(w *Workbook, c Proposed) error {259+ s := w.Lookup(c.Sheet)260+ if s == nil {261+ return errors.New(c.Sheet + " is gone")262+ }263+ r := Rect{From: c.At, To: c.At}264+ if s.InPivot(r) {265+ return ErrPivotEdit266+ }267+ if _, ok := s.SpillAnchor(c.At); ok && c.Now != "" {268+ return ErrSpillEdit269+ }270+ src := p.copy.Lookup(c.Sheet).cells.get(c.At)271+ if src == nil {272+ if s.cells.has(c.At) {273+ s.place(c.At, nil)274+ }275+ return nil276+ }277+ s.place(c.At, src.clone())278+ return nil279+}@@ -0,0 +1,165 @@
1+package sheet2+3+import (4+ "errors"5+ "testing"6+)7+8+// proposalBook is a workbook with 1, 2, 3 in A1:A3 and their sum in A4,9+// its history shared by two authors, 1 the person and 2 the agent.10+func proposalBook(t *testing.T) *Workbook {11+ t.Helper()12+ w := New().Book()13+ s := w.Sheet(0)14+ for i, in := range []string{"1", "2", "3", "=SUM(A1:A3)"} {15+ if err := s.Set(Addr{Row: i}, in); err != nil {16+ t.Fatal(err)17+ }18+ }19+ w.ShareHistory()20+ return w21+}22+23+func setCells(entries map[Addr]string) func(*Workbook) error {24+ return func(w *Workbook) error {25+ for a, in := range entries {26+ if err := w.Sheet(0).Set(a, in); err != nil {27+ return err28+ }29+ }30+ return nil31+ }32+}33+34+func TestProposeLeavesTheWorkbook(t *testing.T) {35+ w := proposalBook(t)36+ state := w.StateID()37+ p, err := Propose(w, "set", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1, Col: 1}: "x"}))38+ if err != nil {39+ t.Fatal(err)40+ }41+ if w.StateID() != state || w.Sheet(0).Value(Addr{Row: 3}).Num != 6 {42+ t.Fatal("proposing changed the workbook")43+ }44+ if len(p.Cells) != 2 || p.Whole() || p.Remote {45+ t.Fatalf("cells %+v, other %v", p.Cells, p.Other)46+ }47+ if c := p.Cells[0]; c.At != (Addr{Row: 0}) || c.Was != "1" || c.Now != "10" {48+ t.Fatalf("first cell %+v", c)49+ }50+ if got := p.Result().Sheet(0).Value(Addr{Row: 3}).Num; got != 15 {51+ t.Fatalf("the copy's sum is %v", got)52+ }53+}54+55+func TestProposalAcceptedWholeIsOneStepOfItsAuthor(t *testing.T) {56+ w := proposalBook(t)57+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1}: "20"}))58+ w.SetAuthor(1)59+ w.Sheet(0).Set(Addr{Col: 2}, "mine")60+ w.SetAuthor(2)61+ if err := p.Apply(w, nil); err != nil {62+ t.Fatal(err)63+ }64+ if got := w.Sheet(0).Value(Addr{Row: 3}).Num; got != 33 {65+ t.Fatalf("sum %v after accepting", got)66+ }67+ w.SetAuthor(2)68+ if w.UndoLabel() != "fix" {69+ t.Fatalf("the agent's undo is %q", w.UndoLabel())70+ }71+ w.Undo()72+ if got := w.Sheet(0).Value(Addr{Row: 3}).Num; got != 6 || w.Sheet(0).Cell(Addr{Col: 2}).Input != "mine" {73+ t.Fatal("the agent's undo took back more than its step")74+ }75+}76+77+func TestProposalAcceptedByCell(t *testing.T) {78+ w := proposalBook(t)79+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10", {Row: 1}: "20", {Row: 2}: ""}))80+ if len(p.Cells) != 3 {81+ t.Fatalf("cells %+v", p.Cells)82+ }83+ if err := p.Apply(w, func(i int) bool { return i != 1 }); err != nil {84+ t.Fatal(err)85+ }86+ s := w.Sheet(0)87+ if inputOf(s, Addr{Row: 0}) != "10" || inputOf(s, Addr{Row: 1}) != "2" || inputOf(s, Addr{Row: 2}) != "" {88+ t.Fatal("the picked cells weren't the ones set")89+ }90+ if got := s.Value(Addr{Row: 3}).Num; got != 12 {91+ t.Fatalf("sum %v", got)92+ }93+}94+95+func TestProposalRejectedChangesNothing(t *testing.T) {96+ w := proposalBook(t)97+ state := w.StateID()98+ p, _ := Propose(w, "fix", setCells(map[Addr]string{{Row: 0}: "10"}))99+ if err := p.Apply(w, func(int) bool { return false }); err != nil || w.StateID() != state {100+ t.Fatal("picking no cell made a step")101+ }102+}103+104+func TestProposalKeepsFormats(t *testing.T) {105+ w := proposalBook(t)106+ p, _ := Propose(w, "bold", func(w *Workbook) error {107+ w.Sheet(0).SetStyle(Rect{From: Addr{Row: 0}, To: Addr{Row: 0}}, func(st *Style) { st.Bold = true })108+ return nil109+ })110+ if len(p.Cells) != 1 || !p.Cells[0].Look || p.Cells[0].Was != p.Cells[0].Now {111+ t.Fatalf("cells %+v", p.Cells)112+ }113+ p.Apply(w, nil)114+ if !w.Sheet(0).Cell(Addr{Row: 0}).Style.Bold {115+ t.Fatal("the style wasn't applied")116+ }117+}118+119+func TestProposalOfRowsIsWhole(t *testing.T) {120+ w := proposalBook(t)121+ p, _ := Propose(w, "insert", func(w *Workbook) error { return w.Sheet(0).InsertRows(0, 1) })122+ if !p.Whole() || len(p.Sheets) != 1 || p.Sheets[0] != "Sheet1" {123+ t.Fatalf("other %v, sheets %v", p.Other, p.Sheets)124+ }125+ if err := p.Apply(w, func(int) bool { return false }); !errors.Is(err, ErrWhole) && len(p.Cells) > 0 {126+ t.Fatalf("part of a whole change: %v", err)127+ }128+ if err := p.Apply(w, nil); err != nil {129+ t.Fatal(err)130+ }131+ if w.Sheet(0).Cell(Addr{Row: 1}).Input != "1" {132+ t.Fatal("the row wasn't inserted")133+ }134+}135+136+func TestProposalNotesRemoteFormulas(t *testing.T) {137+ w := proposalBook(t)138+ p, _ := Propose(w, "ask", setCells(map[Addr]string{{Col: 1}: `=JEV.CLASSIFY(A1, "parity", "odd, even")`}))139+ if !p.Remote {140+ t.Fatalf("a JEV formula isn't remote: %+v", p.Cells)141+ }142+}143+144+func TestAdoptKeepsEarlierStepsTheirs(t *testing.T) {145+ w := New().Book()146+ w.Sheet(0).Set(Addr{}, "1")147+ w.ShareHistory()148+ w.Adopt(1)149+ w.SetAuthor(1)150+ if !w.CanUndo() {151+ t.Fatal("the adopted step isn't the author's")152+ }153+ w.SetAuthor(2)154+ if w.CanUndo() {155+ t.Fatal("another author can undo it")156+ }157+}158+159+// inputOf is the input at a, "" for a blank cell.160+func inputOf(s *Sheet, a Addr) string {161+ if c := s.Cell(a); c != nil {162+ return c.Input163+ }164+ return ""165+}@@ -11,7 +11,9 @@ import (
1111 // Random edits by several participants: the edits of TestRandomEdits,1212 // each made by one of three authors drawn from the seed, interleaved in1313 // one workbook whose history is shared, as a room of 012 serve orders14-// them. Then the authors undo, in turns drawn from the seed, each only14+// them, and among them an agent's (live mode): its edits are proposals15+// (Propose), accepted whole, cell by cell or not at all, as drawn from16+// the seed. Then the authors, the agent among them, undo, in turns drawn from the seed, each only1517 // their own steps, from under the others' where nothing later overlaps1618 // them. What must hold:1719 //@@ -41,8 +43,46 @@ func TestRandomSharedEdits(t *testing.T) {
4143 }4244 }4345 44-// randAuthors is how many participants edit.45-const randAuthors = 346+// randAuthors is how many people edit; the agent is one more.47+const (48+ randAuthors = 349+ randAgent = randAuthors + 150+)51+52+// suggest has the agent propose an edit, made on a copy, and accepts it53+// whole, some of its cells or none, as rng draws.54+func (r *randBook) suggest(e *edits, rng *rand.Rand) string {55+ from := *e56+ var label string57+ edit := func(w *Workbook) error {58+ b := r59+ if w != r.wb {60+ b = &randBook{wb: w, nb: w.Lookup(r.nb.Name()), feeds: maps.Clone(r.feeds)}61+ }62+ d := from63+ label = b.edit(&d)64+ *e = d65+ return nil66+ }67+ p, err := Propose(r.wb, "suggestion", edit)68+ if err != nil {69+ return fmt.Sprintf("%d suggests %q: %v", randAgent, label, err)70+ }71+ var pick func(int) bool72+ what := "accepted"73+ switch rng.IntN(3) {74+ case 0:75+ return fmt.Sprintf("%d suggests %q: rejected", randAgent, label)76+ case 1:77+ if !p.Whole() {78+ pick, what = func(int) bool { return rng.IntN(2) == 0 }, "accepted in part"79+ }80+ }81+ if err := p.Apply(r.wb, pick); err != nil {82+ what = err.Error()83+ }84+ return fmt.Sprintf("%d suggests %q: %s", randAgent, label, what)85+}4686 4787 func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string) {4888 e := &edits{data: data}@@ -54,9 +94,13 @@ func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string
5494 if e.done() {5595 break5696 }57- a := 1 + e.n(randAuthors)97+ a := 1 + e.n(randAuthors+1)5898 r.wb.SetAuthor(a)59- log = append(log, fmt.Sprintf("%d: %s", a, r.edit(e)))99+ if a == randAgent {100+ log = append(log, r.suggest(e, rng))101+ } else {102+ log = append(log, fmt.Sprintf("%d: %s", a, r.edit(e)))103+ }60104 feedStale(r.wb, r.feeds)61105 }62106 for undone := 0; ; undone++ {@@ -100,10 +144,10 @@ func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string
100144 // from rng, or reports false when nobody has a step left. It fails the101145 // run when steps are left but nobody can undo one.102146 func (r *randBook) nextUndo(rng *rand.Rand) (int, bool) {103- first := rng.IntN(randAuthors)147+ first := rng.IntN(randAgent)104148 left := false105- for i := range randAuthors {106- a := 1 + (first+i)%randAuthors149+ for i := range randAgent {150+ a := 1 + (first+i)%randAgent107151 r.wb.SetAuthor(a)108152 if !r.wb.CanUndo() {109153 continue