oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
4 "context"
5 "net/http"
6 "strconv"
7 "strings"
8 "testing"
9
10 "amadan.net/oficina/slides/internal/slides/core"
11)
12
13// newMirrorWithClient is newMirror under a client id of the caller's
14// choosing, for a test that needs two browsers on one deck.
15func newMirrorWithClient(t *testing.T, sqid string, client uint64, log [][]byte) *mirror {
16 t.Helper()
17 ctx := context.Background()
18 rt, err := core.New(ctx)
19 if err != nil {
20 t.Fatalf("core.New: %v", err)
21 }
22 h, err := rt.NewDoc(ctx, sqid, client)
23 if err != nil {
24 rt.Close(ctx)
25 t.Fatalf("core.NewDoc: %v", err)
26 }
27 m := &mirror{rt: rt, h: h}
28 t.Cleanup(func() {
29 h.Close(ctx)
30 rt.Close(ctx)
31 })
32 m.apply(t, log)
33 return m
34}
35
36// TestUpdateWithAnUnknownMarkIsRefused (codex 3): the write gate used
37// to parse the deck's JSON view after applying a posted update, and the
38// view tolerates a formatting attribute it does not know by dropping it
39// from the run — so an update carrying `unknown_mark` parsed perfectly
40// and was stored, and y-prosemirror 1.3.7 then deleted the whole text
41// the moment the editor bound to it. The gate now checks the shared
42// text itself (core.Handle.Check) before storing anything: such an
43// update is a 422, nothing lands in the log, the row's seq does not
44// move, and every surface still opens the deck.
45func TestUpdateWithAnUnknownMarkIsRefused(t *testing.T) {
46 a := newApp(t)
47 cookies := signedIn(t, a, allowedAddress)
48 path := createDeck(t, a, cookies, "Integrity")
49 before, seqBefore := getUpdates(t, a, path, 0, cookies)
50
51 rec := postBody(t, a, path+"/updates", "application/octet-stream", poisonUpdate(t, "unknownmark"), cookies)
52 if rec.Code != http.StatusUnprocessableEntity {
53 t.Fatalf("POST %s/updates with a run under unknown_mark: status %d, want 422\n%s", path, rec.Code, rec.Body)
54 }
55
56 after, seqAfter := getUpdates(t, a, path, 0, cookies)
57 if len(after) != len(before) || seqAfter != seqBefore {
58 t.Errorf("a refused update was stored: %d frames at seq %d, was %d at %d", len(after), seqAfter, len(before), seqBefore)
59 }
60 if got := scalar(t, a, "SELECT seq FROM decks WHERE sqid = ?", sqidOf(path)); got != strconv.FormatInt(seqBefore, 10) {
61 t.Errorf("the row's seq moved to %s on a refused update", got)
62 }
63 stillRenders(t, a, path, "", cookies)
64
65 // The same deck still takes a well-formed update afterwards: the
66 // refusal touched nothing.
67 log, _ := getUpdates(t, a, path, 0, cookies)
68 m := newMirror(t, sqidOf(path), log)
69 update := m.ops(t, `[{"op":"add_slide","id":"integrityokk","layout":"flow","template":"title"},
70 {"op":"add_block","slide":"integrityokk","id":"integrityblk","type":"heading","props":{"level":1},
71 "region":"heading","markdown":"Still **works**"}]`)
72 postUpdate(t, a, path, update, cookies)
73 slide := mustGet(t, a, path+"/slides/integrityokk.html", http.StatusOK, cookies).Body.String()
74 if !strings.Contains(slide, "<strong>works</strong>") {
75 t.Errorf("the well-formed update after the refusal did not render:\n%s", slide)
76 }
77}
78
79// TestMoveBlockOverTheWireKeepsAConcurrentEdit (codex 4): two browsers
80// hold the same deck; one moves a block, the other types into that
81// block; both updates reach the server, in that order. The block's new
82// text is in the deck, in the moved position — the move wrote an order
83// key onto the block map rather than deleting the map and inserting a
84// copy, which is what used to lose the typing.
85func TestMoveBlockOverTheWireKeepsAConcurrentEdit(t *testing.T) {
86 a := newApp(t)
87 cookies := signedIn(t, a, allowedAddress)
88 path := createDeck(t, a, cookies, "Concurrent")
89 log, _ := getUpdates(t, a, path, 0, cookies)
90
91 setup := newMirror(t, sqidOf(path), log)
92 postUpdate(t, a, path, setup.ops(t, `[
93 {"op":"add_slide","id":"concurrentsl","layout":"canvas"},
94 {"op":"add_block","slide":"concurrentsl","id":"concurrentlo","type":"text","markdown":"below",
95 "box":{"x":0.1,"y":0.6,"w":0.3,"h":0.2}},
96 {"op":"add_block","slide":"concurrentsl","id":"concurrenthi","type":"text","markdown":"above",
97 "box":{"x":0.1,"y":0.1,"w":0.3,"h":0.2}}]`), cookies)
98
99 // Two browsers, each caught up to the same point, each unaware of
100 // the other's next edit. A different client id each: newMirror
101 // hands out 2, and two writers sharing one id is the one thing a
102 // CRDT cannot forgive.
103 log, _ = getUpdates(t, a, path, 0, cookies)
104 mover := newMirror(t, sqidOf(path), log)
105 typist := newMirrorWithClient(t, sqidOf(path), 3, log)
106
107 converted := mover.ops(t, `[{"op":"set_slide","id":"concurrentsl","layout":"flow"}]`)
108 typed := typist.ops(t, `[{"op":"set_text","slide":"concurrentsl","id":"concurrentlo","markdown":"below and more"}]`)
109 postUpdate(t, a, path, converted, cookies)
110 postUpdate(t, a, path, typed, cookies)
111
112 slide := mustGet(t, a, path+"/slides/concurrentsl.html", http.StatusOK, cookies).Body.String()
113 if !strings.Contains(slide, "below and more") {
114 t.Errorf("the concurrent edit was lost across the layout switch:\n%s", slide)
115 }
116 hi, lo := strings.Index(slide, `data-id="concurrenthi"`), strings.Index(slide, `data-id="concurrentlo"`)
117 if hi < 0 || lo < 0 || hi > lo {
118 t.Errorf("the conversion did not put the higher block first (hi at %d, lo at %d):\n%s", hi, lo, slide)
119 }
120}
121
122// TestMoveSlideOverTheWireKeepsAConcurrentEdit (codex 4 at slide
123// level): the same story one level up. One browser reorders the deck
124// while another types into the slide being moved; both updates reach
125// the server. The typing is still there and the strip the server
126// renders is in the new order, because move_slide writes an `order`
127// key onto the slide map rather than deleting the map and inserting a
128// copy — which is what the other client's text was addressing.
129func TestMoveSlideOverTheWireKeepsAConcurrentEdit(t *testing.T) {
130 a := newApp(t)
131 cookies := signedIn(t, a, allowedAddress)
132 path := createDeck(t, a, cookies, "Concurrent slides")
133 log, _ := getUpdates(t, a, path, 0, cookies)
134
135 setup := newMirror(t, sqidOf(path), log)
136 postUpdate(t, a, path, setup.ops(t, `[
137 {"op":"add_slide","id":"concurrenta0","layout":"flow"},
138 {"op":"add_slide","id":"concurrentb0","layout":"flow"},
139 {"op":"add_slide","id":"concurrentc0","layout":"flow"},
140 {"op":"add_block","slide":"concurrentc0","id":"concurrenttx","type":"text","markdown":"three"}]`), cookies)
141
142 log, _ = getUpdates(t, a, path, 0, cookies)
143 mover := newMirror(t, sqidOf(path), log)
144 typist := newMirrorWithClient(t, sqidOf(path), 3, log)
145
146 moved := mover.ops(t, `[{"op":"move_slide","id":"concurrentc0","after":null}]`)
147 typed := typist.ops(t, `[{"op":"set_text","slide":"concurrentc0","id":"concurrenttx","markdown":"three, edited"}]`)
148 postUpdate(t, a, path, moved, cookies)
149 postUpdate(t, a, path, typed, cookies)
150
151 slide := mustGet(t, a, path+"/slides/concurrentc0.html", http.StatusOK, cookies).Body.String()
152 if !strings.Contains(slide, "three, edited") {
153 t.Errorf("the concurrent edit was lost across the slide move:\n%s", slide)
154 }
155 strip := mustGet(t, a, path+"/edit", http.StatusOK, cookies).Body.String()
156 var order []int
157 for _, id := range []string{"concurrentc0", "concurrenta0", "concurrentb0"} {
158 i := strings.Index(strip, `data-slide-id="`+id+`"`)
159 if i < 0 {
160 t.Fatalf("slide %s is not in the strip:\n%s", id, strip)
161 }
162 order = append(order, i)
163 }
164 if !(order[0] < order[1] && order[1] < order[2]) {
165 t.Errorf("the strip is not in the moved order (positions %v)", order)
166 }
167}
168