| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // versionFollower mirrors native SheetModel.accept (native/apple/Sources/ |
| 11 | // SheetsKit/SheetModel.swift), the one client that detects missed edits |
| 12 | // from the version: drop anything older than what it holds, refetch on |
| 13 | // resync or when the version moved further than the response accounts |
| 14 | // for, otherwise apply. Change the two together. |
| 15 | type versionFollower struct { |
| 16 | have uint64 |
| 17 | refetches int |
| 18 | } |
| 19 | |
| 20 | func (f *versionFollower) missed(g gridResponse) bool { |
| 21 | allowed := uint64(max(g.Stamps, 1)) |
| 22 | return g.Resync || g.Version > f.have+allowed |
| 23 | } |
| 24 | |
| 25 | // accept returns whether the response made the follower refetch. |
| 26 | func (f *versionFollower) accept(t *testing.T, g gridResponse, refetch func() gridResponse) bool { |
| 27 | t.Helper() |
| 28 | if g.Version < f.have { |
| 29 | return false |
| 30 | } |
| 31 | if f.missed(g) { |
| 32 | f.refetches++ |
| 33 | f.have = refetch().Version |
| 34 | return true |
| 35 | } |
| 36 | f.have = g.Version |
| 37 | return false |
| 38 | } |
| 39 | |
| 40 | // B12 and its review (#35): replay REAL server responses — cell writes, |
| 41 | // batches (with a derived formula cell), a format, a size, snapshots — |
| 42 | // through the native rule. With nothing missed, nothing may refetch: an |
| 43 | // Open used to take a stamp every time, and the handle is evicted between |
| 44 | // requests, so sequential writes jumped by two and every other one |
| 45 | // refetched. With one write missed, the next response of every kind must |
| 46 | // refetch, including a format, whose N cells consume no stamp. |
| 47 | func TestNativeVersionContractAgainstRealResponses(t *testing.T) { |
| 48 | app, cookies := signIn(t) |
| 49 | s, cells := newSheet(t, app, cookies, "Budget") |
| 50 | snapshot := func() gridResponse { |
| 51 | t.Helper() |
| 52 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, cells+"?rows=20&cols=10", nil), cookies)) |
| 53 | if rec.Code != http.StatusOK { |
| 54 | t.Fatalf("GET cells = %d: %s", rec.Code, rec.Body.String()) |
| 55 | } |
| 56 | return decodeGrid(t, rec) |
| 57 | } |
| 58 | ok := func(rec *httptest.ResponseRecorder) gridResponse { |
| 59 | t.Helper() |
| 60 | if rec.Code != http.StatusOK { |
| 61 | t.Fatalf("write = %d: %s", rec.Code, rec.Body.String()) |
| 62 | } |
| 63 | return decodeGrid(t, rec) |
| 64 | } |
| 65 | cell := func(r, c int, raw string) gridResponse { |
| 66 | return ok(postCell(t, app, cookies, cells, fmt.Sprintf(`{"r":%d,"c":%d,"raw":%q}`, r, c, raw))) |
| 67 | } |
| 68 | batch := func(body string) gridResponse { return ok(postCell(t, app, cookies, cells+"/batch", body)) } |
| 69 | format := func(body string) gridResponse { return ok(postFormat(t, app, cookies, s, body)) } |
| 70 | size := func(body string) gridResponse { return ok(postSize(t, app, cookies, s, body)) } |
| 71 | |
| 72 | t.Run("nothing missed, nothing refetched", func(t *testing.T) { |
| 73 | f := &versionFollower{have: snapshot().Version} |
| 74 | steps := []struct { |
| 75 | name string |
| 76 | resp func() gridResponse |
| 77 | }{ |
| 78 | {"cell", func() gridResponse { return cell(0, 0, "1") }}, |
| 79 | {"cell", func() gridResponse { return cell(1, 0, "2") }}, |
| 80 | {"cell", func() gridResponse { return cell(2, 0, "3") }}, |
| 81 | {"batch of 3 with a derived formula", func() gridResponse { |
| 82 | return batch(`{"cells":[{"r":3,"c":0,"raw":"=SUM(A1:A3)"},{"r":0,"c":1,"raw":"b"},{"r":0,"c":0,"raw":"10"}]}`) |
| 83 | }}, |
| 84 | {"format over 6 cells", func() gridResponse { return format(`{"r1":0,"c1":0,"r2":1,"c2":2,"bold":true}`) }}, |
| 85 | {"cell after format", func() gridResponse { return cell(1, 1, "x") }}, |
| 86 | {"size", func() gridResponse { return size(`{"col":2,"size":220}`) }}, |
| 87 | {"batch of 2", func() gridResponse { return batch(`{"cells":[{"r":5,"c":0,"raw":"5"},{"r":6,"c":0,"raw":"6"}]}`) }}, |
| 88 | {"clear", func() gridResponse { return cell(5, 0, "") }}, |
| 89 | {"snapshot", snapshot}, |
| 90 | {"cell after snapshot", func() gridResponse { return cell(7, 0, "7") }}, |
| 91 | {"batch of 4", func() gridResponse { |
| 92 | return batch(`{"cells":[{"r":8,"c":0,"raw":"8"},{"r":8,"c":1,"raw":"8"},{"r":8,"c":2,"raw":"8"},{"r":8,"c":3,"raw":"8"}]}`) |
| 93 | }}, |
| 94 | } |
| 95 | for _, step := range steps { |
| 96 | before := f.have |
| 97 | g := step.resp() |
| 98 | if f.accept(t, g, snapshot) { |
| 99 | t.Errorf("%s: refetched with nothing missed (held %d, got version %d, stamps %d)", |
| 100 | step.name, before, g.Version, g.Stamps) |
| 101 | } |
| 102 | } |
| 103 | }) |
| 104 | |
| 105 | t.Run("one write missed, the next response refetches", func(t *testing.T) { |
| 106 | for _, next := range []struct { |
| 107 | name string |
| 108 | resp func() gridResponse |
| 109 | }{ |
| 110 | {"cell", func() gridResponse { return cell(10, 0, "n") }}, |
| 111 | {"batch", func() gridResponse { return batch(`{"cells":[{"r":10,"c":1,"raw":"n"},{"r":10,"c":2,"raw":"n"}]}`) }}, |
| 112 | {"format over 20 cells", func() gridResponse { return format(`{"r1":11,"c1":0,"r2":14,"c2":4,"italic":true}`) }}, |
| 113 | {"size", func() gridResponse { return size(`{"col":3,"size":140}`) }}, |
| 114 | } { |
| 115 | // Hold the version of a write this follower saw, then miss one. |
| 116 | f := &versionFollower{have: cell(9, 0, "seen").Version} |
| 117 | cell(9, 1, "missed") |
| 118 | g := next.resp() |
| 119 | if !f.missed(g) { |
| 120 | t.Errorf("%s after a missed write was applied, not refetched (held %d, got version %d, stamps %d)", |
| 121 | next.name, f.have, g.Version, g.Stamps) |
| 122 | } |
| 123 | } |
| 124 | }) |
| 125 | } |
| 126 | |