| 1 | package core_test |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "amadan.net/oficina/slides/internal/slides/core" |
| 15 | ) |
| 16 | |
| 17 | // The contract between the browser and the core (spec §2.8): for every |
| 18 | // vector fixtures/generate.mjs wrote, the core's view of the document the |
| 19 | // JS side built must equal the JS side's own view, and the core's own |
| 20 | // Ops applied to the same batch must arrive at that view as well. A |
| 21 | // difference here is a bug in one implementation or the other, never |
| 22 | // something to fix by editing a vector. |
| 23 | |
| 24 | const vectorsDir = "../../../fixtures/vectors" |
| 25 | const dataDir = "../../../fixtures/data" |
| 26 | |
| 27 | func vectorNames(t *testing.T) []string { |
| 28 | t.Helper() |
| 29 | entries, err := filepath.Glob(filepath.Join(vectorsDir, "*.yjs")) |
| 30 | if err != nil { |
| 31 | t.Fatalf("glob: %v", err) |
| 32 | } |
| 33 | if len(entries) == 0 { |
| 34 | t.Fatalf("no vectors under %s: run node generate.mjs in fixtures/", vectorsDir) |
| 35 | } |
| 36 | names := make([]string, 0, len(entries)) |
| 37 | for _, e := range entries { |
| 38 | names = append(names, strings.TrimSuffix(filepath.Base(e), ".yjs")) |
| 39 | } |
| 40 | sort.Strings(names) |
| 41 | return names |
| 42 | } |
| 43 | |
| 44 | // canonical parses JSON and re-encodes it with sorted keys and one entry |
| 45 | // per line, so two views compare as values and a mismatch reads as a diff. |
| 46 | func canonical(t *testing.T, raw []byte) string { |
| 47 | t.Helper() |
| 48 | var v any |
| 49 | if err := json.Unmarshal(raw, &v); err != nil { |
| 50 | t.Fatalf("not JSON: %v\n%s", err, raw) |
| 51 | } |
| 52 | var b strings.Builder |
| 53 | writeCanonical(&b, v, "") |
| 54 | return b.String() |
| 55 | } |
| 56 | |
| 57 | func writeCanonical(b *strings.Builder, v any, indent string) { |
| 58 | switch x := v.(type) { |
| 59 | case map[string]any: |
| 60 | keys := make([]string, 0, len(x)) |
| 61 | for k := range x { |
| 62 | keys = append(keys, k) |
| 63 | } |
| 64 | sort.Strings(keys) |
| 65 | b.WriteString("{\n") |
| 66 | for _, k := range keys { |
| 67 | fmt.Fprintf(b, "%s %q: ", indent, k) |
| 68 | writeCanonical(b, x[k], indent+" ") |
| 69 | b.WriteString("\n") |
| 70 | } |
| 71 | b.WriteString(indent + "}") |
| 72 | case []any: |
| 73 | b.WriteString("[\n") |
| 74 | for _, item := range x { |
| 75 | b.WriteString(indent + " ") |
| 76 | writeCanonical(b, item, indent+" ") |
| 77 | b.WriteString("\n") |
| 78 | } |
| 79 | b.WriteString(indent + "]") |
| 80 | default: |
| 81 | enc, _ := json.Marshal(x) |
| 82 | b.Write(enc) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // diffLines reports the first lines where want and got differ, with a |
| 87 | // little context, so a failing vector says where rather than dumping |
| 88 | // two whole views. |
| 89 | func diffLines(want, got string) string { |
| 90 | w := strings.Split(want, "\n") |
| 91 | g := strings.Split(got, "\n") |
| 92 | i := 0 |
| 93 | for i < len(w) && i < len(g) && w[i] == g[i] { |
| 94 | i++ |
| 95 | } |
| 96 | from := i - 3 |
| 97 | if from < 0 { |
| 98 | from = 0 |
| 99 | } |
| 100 | var b strings.Builder |
| 101 | fmt.Fprintf(&b, "first difference at line %d\n", i+1) |
| 102 | for j := from; j < i; j++ { |
| 103 | fmt.Fprintf(&b, " %s\n", w[j]) |
| 104 | } |
| 105 | for j := i; j < i+6 && j < len(w); j++ { |
| 106 | fmt.Fprintf(&b, "- %s\n", w[j]) |
| 107 | } |
| 108 | for j := i; j < i+6 && j < len(g); j++ { |
| 109 | fmt.Fprintf(&b, "+ %s\n", g[j]) |
| 110 | } |
| 111 | return b.String() |
| 112 | } |
| 113 | |
| 114 | func wantView(t *testing.T, name string) string { |
| 115 | t.Helper() |
| 116 | raw, err := os.ReadFile(filepath.Join(vectorsDir, name+".view.json")) |
| 117 | if err != nil { |
| 118 | t.Fatalf("read view: %v", err) |
| 119 | } |
| 120 | return canonical(t, raw) |
| 121 | } |
| 122 | |
| 123 | func assertView(t *testing.T, ctx context.Context, h *core.Handle, want string) { |
| 124 | t.Helper() |
| 125 | view, err := h.JSON(ctx) |
| 126 | if err != nil { |
| 127 | t.Fatalf("JSON: %v", err) |
| 128 | } |
| 129 | got := canonical(t, view) |
| 130 | if got != want { |
| 131 | t.Errorf("the core's view differs from the JS view (- want, + got):\n%s", diffLines(want, got)) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // TestVectorsViewAsTheJSSideViewsThem: each .yjs update, applied to a |
| 136 | // fresh document, views exactly as the JS side that wrote it says. |
| 137 | func TestVectorsViewAsTheJSSideViewsThem(t *testing.T) { |
| 138 | ctx, rt := newRuntime(t) |
| 139 | for _, name := range vectorNames(t) { |
| 140 | t.Run(name, func(t *testing.T) { |
| 141 | update, err := os.ReadFile(filepath.Join(vectorsDir, name+".yjs")) |
| 142 | if err != nil { |
| 143 | t.Fatalf("read update: %v", err) |
| 144 | } |
| 145 | h := newDoc(t, ctx, rt, name, 1) |
| 146 | if err := h.Apply(ctx, update); err != nil { |
| 147 | t.Fatalf("Apply: %v", err) |
| 148 | } |
| 149 | assertView(t, ctx, h, wantView(t, name)) |
| 150 | }) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // TestVectorsOpsAgreeWithTheJSOps: the same batch through the core's own |
| 155 | // Ops arrives at the same view, so the two implementations of the §7.2 |
| 156 | // vocabulary are one vocabulary. |
| 157 | func TestVectorsOpsAgreeWithTheJSOps(t *testing.T) { |
| 158 | ctx, rt := newRuntime(t) |
| 159 | for _, name := range vectorNames(t) { |
| 160 | t.Run(name, func(t *testing.T) { |
| 161 | raw, err := os.ReadFile(filepath.Join(dataDir, name+".json")) |
| 162 | if err != nil { |
| 163 | t.Fatalf("read data: %v", err) |
| 164 | } |
| 165 | var data struct { |
| 166 | GUID string `json:"guid"` |
| 167 | Ops json.RawMessage `json:"ops"` |
| 168 | } |
| 169 | if err := json.Unmarshal(raw, &data); err != nil { |
| 170 | t.Fatalf("parse data: %v", err) |
| 171 | } |
| 172 | if data.GUID != name { |
| 173 | t.Fatalf("guid %q does not match the file name %q", data.GUID, name) |
| 174 | } |
| 175 | h := newDoc(t, ctx, rt, name, 1) |
| 176 | update, err := h.Ops(ctx, data.Ops) |
| 177 | if err != nil { |
| 178 | t.Fatalf("Ops: %v", err) |
| 179 | } |
| 180 | if len(bytes.TrimSpace(update)) == 0 { |
| 181 | t.Fatal("Ops produced an empty update") |
| 182 | } |
| 183 | assertView(t, ctx, h, wantView(t, name)) |
| 184 | }) |
| 185 | } |
| 186 | } |
| 187 | |