oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package core_test
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "testing"
9
10 "amadan.net/oficina/slides/internal/slides/core"
11)
12
13func newRuntime(t *testing.T) (context.Context, *core.Runtime) {
14 t.Helper()
15 ctx := context.Background()
16 rt, err := core.New(ctx)
17 if err != nil {
18 t.Fatalf("core.New: %v", err)
19 }
20 t.Cleanup(func() { rt.Close(ctx) })
21 return ctx, rt
22}
23
24func newDoc(t *testing.T, ctx context.Context, rt *core.Runtime, guid string, client uint64) *core.Handle {
25 t.Helper()
26 h, err := rt.NewDoc(ctx, guid, client)
27 if err != nil {
28 t.Fatalf("NewDoc(%q): %v", guid, err)
29 }
30 t.Cleanup(func() { h.Close(ctx) })
31 return h
32}
33
34const addSlide = `[
35 {"op":"set_meta","title":"Host test"},
36 {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title"},
37 {"op":"add_block","slide":"aaaaaaaaaaaa","id":"bbbbbbbbbbbb","type":"heading",
38 "props":{"level":1},"region":"heading","markdown":"Hello **world**"}
39]`
40
41// TestOpsProduceUpdateAndView: an op batch yields a non-empty update, and
42// the view afterwards carries what the batch wrote.
43func TestOpsProduceUpdateAndView(t *testing.T) {
44 ctx, rt := newRuntime(t)
45 h := newDoc(t, ctx, rt, "ops-test", 1)
46
47 update, err := h.Ops(ctx, []byte(addSlide))
48 if err != nil {
49 t.Fatalf("Ops: %v", err)
50 }
51 if len(update) == 0 {
52 t.Fatal("Ops returned an empty update")
53 }
54
55 view, err := h.JSON(ctx)
56 if err != nil {
57 t.Fatalf("JSON: %v", err)
58 }
59 if !strings.Contains(string(view), `"id":"aaaaaaaaaaaa"`) {
60 t.Errorf("view lacks the slide id: %s", view)
61 }
62 var parsed struct {
63 Meta struct {
64 Title string `json:"title"`
65 } `json:"meta"`
66 Slides []struct {
67 ID string `json:"id"`
68 Blocks []struct {
69 Text [][]struct {
70 T string `json:"t"`
71 B bool `json:"b"`
72 } `json:"text"`
73 } `json:"blocks"`
74 } `json:"slides"`
75 }
76 if err := json.Unmarshal(view, &parsed); err != nil {
77 t.Fatalf("view is not JSON: %v\n%s", err, view)
78 }
79 if parsed.Meta.Title != "Host test" || len(parsed.Slides) != 1 || parsed.Slides[0].ID != "aaaaaaaaaaaa" {
80 t.Errorf("unexpected view: %s", view)
81 }
82 runs := parsed.Slides[0].Blocks[0].Text[0]
83 if len(runs) != 2 || runs[0].T != "Hello " || runs[1].T != "world" || !runs[1].B {
84 t.Errorf("unexpected runs: %+v", runs)
85 }
86
87 // The update the batch produced is a real Yjs update: a fresh
88 // document fed only that reads the same.
89 other := newDoc(t, ctx, rt, "ops-test-other", 2)
90 if err := other.Apply(ctx, update); err != nil {
91 t.Fatalf("Apply(update from Ops): %v", err)
92 }
93 otherView, err := other.JSON(ctx)
94 if err != nil {
95 t.Fatalf("JSON other: %v", err)
96 }
97 if string(otherView) != string(view) {
98 t.Errorf("views differ after applying the ops update:\n%s\n%s", view, otherView)
99 }
100}
101
102// TestApplyStateRoundtrip: State encodes the whole document as one
103// update; applying it to an empty document reproduces the view.
104func TestApplyStateRoundtrip(t *testing.T) {
105 ctx, rt := newRuntime(t)
106 a := newDoc(t, ctx, rt, "state-a", 1)
107 if _, err := a.Ops(ctx, []byte(addSlide)); err != nil {
108 t.Fatalf("Ops: %v", err)
109 }
110 state, err := a.State(ctx)
111 if err != nil {
112 t.Fatalf("State: %v", err)
113 }
114 if len(state) == 0 {
115 t.Fatal("State returned no bytes")
116 }
117
118 b := newDoc(t, ctx, rt, "state-b", 2)
119 if err := b.Apply(ctx, state); err != nil {
120 t.Fatalf("Apply(state): %v", err)
121 }
122 av, err := a.JSON(ctx)
123 if err != nil {
124 t.Fatalf("JSON a: %v", err)
125 }
126 bv, err := b.JSON(ctx)
127 if err != nil {
128 t.Fatalf("JSON b: %v", err)
129 }
130 if string(av) != string(bv) {
131 t.Errorf("views differ:\n%s\n%s", av, bv)
132 }
133}
134
135// TestRejectedOpsSurfaceMessage: a refused batch comes back as a
136// *RejectedError carrying the core's message, applies nothing, and
137// leaves the instance usable.
138func TestRejectedOpsSurfaceMessage(t *testing.T) {
139 ctx, rt := newRuntime(t)
140 h := newDoc(t, ctx, rt, "reject-test", 1)
141 before, err := h.JSON(ctx)
142 if err != nil {
143 t.Fatalf("JSON: %v", err)
144 }
145
146 _, err = h.Ops(ctx, []byte(`[{"op":"add_slide","id":"x","layout":"flow"},{"op":"frobnicate"}]`))
147 if err == nil {
148 t.Fatal("expected an error for an unknown op")
149 }
150 var rejected *core.RejectedError
151 if !errors.As(err, &rejected) {
152 t.Fatalf("error is not a *RejectedError: %T %v", err, err)
153 }
154 if !core.Rejected(err) {
155 t.Error("Rejected(err) is false")
156 }
157 if !strings.Contains(err.Error(), "frobnicate") {
158 t.Errorf("message does not name the op: %v", err)
159 }
160 if core.Recovered(err) {
161 t.Error("a clean refusal must not read as a recovery")
162 }
163
164 after, err := h.JSON(ctx)
165 if err != nil {
166 t.Fatalf("JSON after: %v", err)
167 }
168 if string(after) != string(before) {
169 t.Errorf("a refused batch changed the document:\n%s\n%s", before, after)
170 }
171
172 // Malformed JSON and garbage bytes are refusals too, not traps.
173 for _, bad := range []string{`not json`, `{"op":"set_meta"}`, "\xff\xfe"} {
174 if _, err := h.Ops(ctx, []byte(bad)); !core.Rejected(err) {
175 t.Errorf("Ops(%q): want a RejectedError, got %v", bad, err)
176 }
177 }
178
179 // A garbage update is a clean rejection as well.
180 if err := h.Apply(ctx, []byte{0xff, 0xff, 0xff, 0xff}); !core.Rejected(err) {
181 t.Errorf("Apply(garbage): want a RejectedError, got %v", err)
182 }
183
184 // Still usable.
185 if _, err := h.Ops(ctx, []byte(addSlide)); err != nil {
186 t.Fatalf("instance unusable after refusals: %v", err)
187 }
188}
189
190// TestAnOpPastTheMemoryLimitIsAnErrorNotACrash: the runtime caps one
191// document's linear memory (defaultMemoryPages), and a document that
192// asks for more than the cap fails the way every other guest failure
193// does — an error out of the call, the process untouched.
194//
195// The cap is dropped to 2 MiB here rather than writing a quarter of a
196// gibibyte of text to reach the real one: what is under test is that
197// the limit is armed at all and that hitting it is an error, and both
198// are the same fact at any ceiling. The same op at the default limit is
199// run afterwards, so this cannot pass because the op itself is bad.
200func TestAnOpPastTheMemoryLimitIsAnErrorNotACrash(t *testing.T) {
201 ctx := context.Background()
202 big := `[{"op":"set_meta","title":"` + strings.Repeat("x", 200_000) + `"}]`
203
204 tight, err := core.New(ctx, core.WithMemoryLimitPages(32)) // 2 MiB
205 if err != nil {
206 t.Fatalf("core.New with a 32-page limit: %v", err)
207 }
208 defer tight.Close(ctx)
209 h, err := tight.NewDoc(ctx, "tight", 1)
210 if err != nil {
211 t.Fatalf("NewDoc under a 32-page limit: %v", err)
212 }
213 defer h.Close(ctx)
214 if _, err := h.Ops(ctx, []byte(big)); err == nil {
215 t.Fatal("an op past the memory limit succeeded; the limit is not armed")
216 } else if core.Rejected(err) {
217 t.Errorf("running out of memory reads as a refusal of the ops: %v", err)
218 } else {
219 t.Logf("over the limit: %v", err)
220 }
221
222 // The same batch, at the real ceiling, is ordinary work.
223 _, rt := newRuntime(t)
224 roomy := newDoc(t, ctx, rt, "roomy", 1)
225 if _, err := roomy.Ops(ctx, []byte(big)); err != nil {
226 t.Fatalf("the same op at the default limit: %v", err)
227 }
228}
229
230// TestABIVersion: NewDoc instantiates the embedded module and checks its
231// reported ABI version — this is the end-to-end proof that the module in
232// testdata and this host agree (core.New alone never reaches the check).
233func TestABIVersion(t *testing.T) {
234 ctx, rt := newRuntime(t)
235 if core.ABIVersion != 2 {
236 t.Errorf("ABIVersion = %d, want 2", core.ABIVersion)
237 }
238 newDoc(t, ctx, rt, "abi-test", 1)
239}
240