oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1//! The operations API end to end: a JSON batch in, a Yjs update out, and
2//! the view JSON as the witness of what changed.
3
4use slides_core::{apply_ops, new_doc, view_json};
5use yrs::Doc;
6
7fn doc(n: u64) -> Doc {
8 new_doc(&format!("test-guid-{n}"), n)
9}
10
11fn view(doc: &Doc) -> serde_json::Value {
12 serde_json::from_str(&view_json(doc)).expect("view is JSON")
13}
14
15#[test]
16fn add_slide_then_view_lists_it() {
17 let d = doc(1);
18 let ops = r#"[
19 {"op":"set_meta","title":"T"},
20 {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title"}
21 ]"#;
22 apply_ops(&d, ops).expect("ops apply");
23 let v = view(&d);
24 assert_eq!(v["meta"]["title"], "T");
25 assert_eq!(v["meta"]["theme"], "paper");
26 assert_eq!(v["meta"]["aspect"], "16:9");
27 let slides = v["slides"].as_array().expect("slides array");
28 assert_eq!(slides.len(), 1);
29 assert_eq!(slides[0]["id"], "aaaaaaaaaaaa");
30 assert_eq!(slides[0]["template"], "title");
31 assert_eq!(slides[0]["layout"], "flow");
32 assert_eq!(slides[0]["blocks"], serde_json::json!([]));
33 assert_eq!(slides[0]["notes"], serde_json::json!([]));
34}
35
36#[test]
37fn add_block_with_markdown_produces_runs() {
38 let d = doc(2);
39 let ops = r#"[
40 {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title"},
41 {"op":"add_block","slide":"aaaaaaaaaaaa","id":"bbbbbbbbbbbb","type":"heading",
42 "props":{"level":1},"region":"heading","markdown":"Hello **world**"}
43 ]"#;
44 apply_ops(&d, ops).expect("ops apply");
45 let v = view(&d);
46 let block = &v["slides"][0]["blocks"][0];
47 assert_eq!(block["id"], "bbbbbbbbbbbb");
48 assert_eq!(block["type"], "heading");
49 assert_eq!(block["props"]["level"], 1);
50 assert_eq!(block["region"], "heading");
51 assert_eq!(block["box"], serde_json::Value::Null);
52 assert_eq!(
53 block["text"],
54 serde_json::json!([[{"t": "Hello "}, {"t": "world", "b": true}]])
55 );
56}
57
58#[test]
59fn ops_are_atomic() {
60 let d = doc(3);
61 let before = view_json(&d);
62 let ops = r#"[
63 {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow"},
64 {"op":"frobnicate","id":"aaaaaaaaaaaa"}
65 ]"#;
66 let err = apply_ops(&d, ops).expect_err("unknown op is an error");
67 assert!(
68 err.to_string().contains("frobnicate"),
69 "message names the op: {err}"
70 );
71 assert_eq!(view_json(&d), before, "a failed batch applies nothing");
72
73 // A missing id is the same kind of failure: nothing applied.
74 let ops = r#"[{"op":"add_slide","layout":"flow"}]"#;
75 let err = apply_ops(&d, ops).expect_err("missing id is an error");
76 assert!(
77 err.to_string().contains("id"),
78 "message names the field: {err}"
79 );
80 assert_eq!(view_json(&d), before);
81
82 // A reference that only becomes wrong mid-batch is caught before
83 // anything is written too: the first op is valid on its own.
84 let ops = r#"[
85 {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow"},
86 {"op":"remove_block","slide":"aaaaaaaaaaaa","id":"nope"}
87 ]"#;
88 apply_ops(&d, ops).expect_err("unknown block is an error");
89 assert_eq!(view_json(&d), before);
90}
91
92#[test]
93fn layout_switch_assigns_boxes() {
94 let d = doc(4);
95 let ops = r#"[
96 {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title-body"},
97 {"op":"add_block","slide":"aaaaaaaaaaaa","id":"bbbbbbbbbbbb","type":"heading",
98 "props":{"level":1},"region":"heading","markdown":"Title"},
99 {"op":"add_block","slide":"aaaaaaaaaaaa","id":"cccccccccccc","type":"text",
100 "props":{"size":"m"},"region":"body","markdown":"Body"}
101 ]"#;
102 apply_ops(&d, ops).expect("ops apply");
103
104 apply_ops(
105 &d,
106 r#"[{"op":"set_slide","id":"aaaaaaaaaaaa","layout":"canvas"}]"#,
107 )
108 .expect("switch to canvas");
109 let v = view(&d);
110 let slide = &v["slides"][0];
111 assert_eq!(slide["layout"], "canvas");
112 let blocks = slide["blocks"].as_array().unwrap();
113 assert_eq!(blocks.len(), 2);
114 for b in blocks {
115 assert!(b["box"].is_object(), "canvas block has a box: {b}");
116 assert!(b["region"].is_null(), "canvas block has no region: {b}");
117 }
118 assert_eq!(
119 blocks[0]["box"],
120 serde_json::json!({"x":0.06,"y":0.08,"w":0.88,"h":0.18})
121 );
122 assert_eq!(
123 blocks[1]["box"],
124 serde_json::json!({"x":0.06,"y":0.30,"w":0.88,"h":0.62})
125 );
126 assert_eq!(blocks[0]["z"], 0);
127 assert_eq!(blocks[1]["z"], 1);
128
129 apply_ops(
130 &d,
131 r#"[{"op":"set_slide","id":"aaaaaaaaaaaa","layout":"flow"}]"#,
132 )
133 .expect("switch back to flow");
134 let v = view(&d);
135 let slide = &v["slides"][0];
136 assert_eq!(slide["layout"], "flow");
137 let blocks = slide["blocks"].as_array().unwrap();
138 assert_eq!(blocks.len(), 2);
139 for b in blocks {
140 assert!(b["box"].is_null(), "flow block has no box: {b}");
141 assert!(b["region"].is_string(), "flow block has a region: {b}");
142 }
143 assert_eq!(blocks[0]["id"], "bbbbbbbbbbbb");
144 assert_eq!(blocks[0]["region"], "heading");
145 assert_eq!(blocks[1]["id"], "cccccccccccc");
146 assert_eq!(blocks[1]["region"], "body");
147}
148
149#[test]
150fn update_roundtrip() {
151 let a = doc(5);
152 let b = doc(6);
153 let ops = r##"[
154 {"op":"set_meta","title":"Roundtrip","theme":"ink","accent":"#ff0000"},
155 {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title-body"},
156 {"op":"add_block","slide":"aaaaaaaaaaaa","id":"bbbbbbbbbbbb","type":"heading",
157 "props":{"level":1},"region":"heading","markdown":"Hello *there* [link](https://x.example)"},
158 {"op":"add_block","slide":"aaaaaaaaaaaa","id":"cccccccccccc","type":"list",
159 "props":{"style":"bullet"},"region":"body","markdown":"- one\n- two ==hi=="},
160 {"op":"add_block","slide":"aaaaaaaaaaaa","id":"dddddddddddd","type":"table",
161 "props":{"rows":2,"cols":2},"region":"body"},
162 {"op":"set_notes","id":"aaaaaaaaaaaa","markdown":"Say `hello`"},
163 {"op":"register_asset","id":"asset1","ref":"blobs/1","name":"pic.png","width":10,"height":20,"alt":"a picture"}
164 ]"##;
165 let out = apply_ops(&a, ops).expect("ops apply");
166 assert!(!out.is_empty(), "an applied batch yields an update");
167 slides_core::apply_update(&b, &out).expect("update applies to B");
168 assert_eq!(view_json(&a), view_json(&b));
169
170 // And the view really carries what was written.
171 let v = view(&a);
172 assert_eq!(v["meta"]["accent"], "#ff0000");
173 assert_eq!(
174 v["slides"][0]["blocks"][0]["text"],
175 serde_json::json!([[
176 {"t": "Hello "},
177 {"t": "there", "i": true},
178 {"t": " "},
179 {"t": "link", "href": "https://x.example"}
180 ]])
181 );
182 assert_eq!(
183 v["slides"][0]["blocks"][1]["text"],
184 serde_json::json!([[{"t": "one"}], [{"t": "two "}, {"t": "hi", "h": true}]])
185 );
186 let cells = v["slides"][0]["blocks"][2]["props"]["cells"]
187 .as_array()
188 .unwrap();
189 assert_eq!(cells.len(), 4);
190 assert_eq!(
191 v["slides"][0]["notes"],
192 serde_json::json!([[{"t": "Say "}, {"t": "hello", "c": true}]])
193 );
194 assert_eq!(v["assets"]["asset1"]["ref"], "blobs/1");
195 assert_eq!(v["assets"]["asset1"]["width"], 10);
196}
197
198/// The ops the tests above do not reach, each checked through the view
199/// and through a second document fed only the returned update.
200#[test]
201fn remaining_ops_roundtrip() {
202 let a = doc(7);
203 let b = doc(8);
204 let mut updates = Vec::new();
205 let mut run = |ops: &str| {
206 let out = apply_ops(&a, ops).unwrap_or_else(|e| panic!("{ops}: {e}"));
207 updates.push(out);
208 };
209 run(r##"[
210 {"op":"add_slide","id":"s1","layout":"flow","template":"title"},
211 {"op":"add_slide","id":"s2","layout":"canvas"},
212 {"op":"add_slide","id":"s0","layout":"flow","after":null},
213 {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"One"},
214 {"op":"add_block","slide":"s1","id":"b2","type":"code","props":{"language":"go"},"markdown":"x := 1\n\ny := **2**"},
215 {"op":"add_block","slide":"s1","id":"b3","type":"columns","props":{"count":2,"children":["b1","b2"]}},
216 {"op":"add_block","slide":"s1","id":"b0","type":"divider","after":null},
217 {"op":"add_block","slide":"s2","id":"c1","type":"text","markdown":"Canvas"},
218 {"op":"add_block","slide":"s2","id":"c2","type":"image","props":{"asset":"x","alt":"y"},"box":{"x":0.5,"y":0.5,"w":0.25,"h":0.25},"z":7}
219 ]"##);
220 let v = view(&a);
221 let ids: Vec<_> = v["slides"]
222 .as_array()
223 .unwrap()
224 .iter()
225 .map(|s| s["id"].clone())
226 .collect();
227 assert_eq!(
228 ids,
229 serde_json::json!(["s0", "s1", "s2"])
230 .as_array()
231 .unwrap()
232 .clone()
233 );
234 let s1 = &v["slides"][1];
235 let bids: Vec<_> = s1["blocks"]
236 .as_array()
237 .unwrap()
238 .iter()
239 .map(|b| b["id"].clone())
240 .collect();
241 assert_eq!(bids, vec!["b0", "b1", "b2", "b3"]);
242 assert_eq!(
243 s1["blocks"][1]["region"], "heading",
244 "heading defaults to its region"
245 );
246 assert_eq!(
247 s1["blocks"][2]["text"],
248 serde_json::json!([[{"t": "x := 1"}], [], [{"t": "y := **2**"}]]),
249 "code is one plain paragraph per line"
250 );
251 assert!(
252 s1["blocks"][3].get("text").is_none(),
253 "columns carry no text"
254 );
255 let s2 = &v["slides"][2];
256 assert_eq!(
257 s2["blocks"][0]["box"],
258 serde_json::json!({"x":0.06,"y":0.30,"w":0.88,"h":0.62})
259 );
260 assert_eq!(s2["blocks"][0]["z"], 0);
261 assert_eq!(
262 s2["blocks"][1]["box"],
263 serde_json::json!({"x":0.5,"y":0.5,"w":0.25,"h":0.25})
264 );
265 assert_eq!(s2["blocks"][1]["z"], 7);
266
267 run(r##"[
268 {"op":"move_slide","id":"s2","after":null},
269 {"op":"move_block","slide":"s1","id":"b0","after":"b3"},
270 {"op":"duplicate_slide","id":"s1","new_id":"s1copy","new_block_ids":["d1","d2","d3","d4"]},
271 {"op":"set_slide","id":"s0","template":"section","background":{"kind":"color","value":"#123456"},"phone":"reflow","transition":"fade"},
272 {"op":"set_notes","id":"s0","markdown":"Notes\n\nMore *notes*"},
273 {"op":"set_meta","fonts":{"heading":"Inter","body":"Georgia"}},
274 {"op":"register_asset","id":"a1","ref":"r","name":"n","width":1,"height":2,"alt":"z"}
275 ]"##);
276 let v = view(&a);
277 let ids: Vec<_> = v["slides"]
278 .as_array()
279 .unwrap()
280 .iter()
281 .map(|s| s["id"].clone())
282 .collect();
283 assert_eq!(ids, vec!["s2", "s0", "s1", "s1copy"]);
284 let s1 = &v["slides"][2];
285 let bids: Vec<_> = s1["blocks"]
286 .as_array()
287 .unwrap()
288 .iter()
289 .map(|b| b["id"].clone())
290 .collect();
291 assert_eq!(bids, vec!["b1", "b2", "b3", "b0"]);
292 let copy = &v["slides"][3];
293 let cids: Vec<_> = copy["blocks"]
294 .as_array()
295 .unwrap()
296 .iter()
297 .map(|b| b["id"].clone())
298 .collect();
299 assert_eq!(cids, vec!["d1", "d2", "d3", "d4"]);
300 assert_eq!(
301 copy["blocks"][0]["text"],
302 serde_json::json!([[{"t": "One"}]]),
303 "the copy keeps its text"
304 );
305 assert_eq!(
306 copy["blocks"][2]["props"]["children"],
307 serde_json::json!(["d1", "d2"]),
308 "columns children follow the new ids"
309 );
310 let s0 = &v["slides"][1];
311 assert_eq!(s0["template"], "section");
312 assert_eq!(
313 s0["background"],
314 serde_json::json!({"kind":"color","value":"#123456"})
315 );
316 assert_eq!(s0["phone"], "reflow");
317 assert_eq!(s0["transition"], "fade");
318 assert_eq!(
319 s0["notes"],
320 serde_json::json!([[{"t":"Notes"}],[{"t":"More "},{"t":"notes","i":true}]])
321 );
322 assert_eq!(
323 v["meta"]["fonts"],
324 serde_json::json!({"heading":"Inter","body":"Georgia"})
325 );
326
327 run(r##"[
328 {"op":"add_block","slide":"s0","id":"t1","type":"table","props":{"rows":2,"cols":3,"header":true}},
329 {"op":"set_block","slide":"s0","id":"t1","props":{"rows":3,"header":null},"region":"aside"},
330 {"op":"set_text","slide":"s1","id":"b1","markdown":"Changed"},
331 {"op":"set_phone","slide":"s1","id":"b1","phone":{"hidden":true,"order":2,"size":"s"}},
332 {"op":"set_phone","slide":"s1","id":"b2","phone":{}},
333 {"op":"remove_block","slide":"s1","id":"b3"},
334 {"op":"remove_slide","id":"s1copy"},
335 {"op":"remove_asset","id":"a1"},
336 {"op":"set_meta","accent":null,"fonts":null}
337 ]"##);
338 let v = view(&a);
339 let t1 = &v["slides"][1]["blocks"][0];
340 assert_eq!(t1["props"]["rows"], 3);
341 assert_eq!(t1["props"]["cols"], 3);
342 assert!(t1["props"].get("header").is_none(), "null removes a prop");
343 assert_eq!(
344 t1["props"]["cells"].as_array().unwrap().len(),
345 9,
346 "cells follow rows*cols"
347 );
348 assert_eq!(t1["region"], "aside");
349 let s1 = &v["slides"][2];
350 assert_eq!(
351 s1["blocks"][0]["text"],
352 serde_json::json!([[{"t": "Changed"}]])
353 );
354 assert_eq!(
355 s1["blocks"][0]["phone"],
356 serde_json::json!({"hidden":true,"order":2,"size":"s"})
357 );
358 assert_eq!(s1["blocks"][1]["phone"], serde_json::json!({}));
359 assert_eq!(s1["blocks"].as_array().unwrap().len(), 3);
360 assert_eq!(v["slides"].as_array().unwrap().len(), 3);
361 assert_eq!(v["assets"], serde_json::json!({}));
362 assert_eq!(v["meta"]["accent"], serde_json::Value::Null);
363 assert_eq!(v["meta"]["fonts"], serde_json::Value::Null);
364
365 // Refusals the model catches.
366 for bad in [
367 r#"[{"op":"add_slide","id":"s0","layout":"flow"}]"#,
368 r#"[{"op":"add_slide","id":"new","layout":"sideways"}]"#,
369 r#"[{"op":"set_slide","id":"s0","template":"nope"}]"#,
370 r#"[{"op":"set_text","slide":"s0","id":"t1","markdown":"x"}]"#,
371 r#"[{"op":"duplicate_slide","id":"s0","new_id":"n","new_block_ids":[]}]"#,
372 r#"[{"op":"set_phone","slide":"s1","id":"b1","phone":{"colour":"red"}}]"#,
373 r#"[{"op":"set_block","slide":"s0","id":"t1","props":{"rows":0}}]"#,
374 r#"[{"op":"move_block","slide":"s1","id":"b1","after":"b1"}]"#,
375 r#"[{"op":"remove_asset","id":"a1"}]"#,
376 r#"{"op":"set_meta"}"#,
377 ] {
378 let before = view_json(&a);
379 apply_ops(&a, bad).expect_err(bad);
380 assert_eq!(view_json(&a), before, "{bad}");
381 }
382
383 for u in &updates {
384 slides_core::apply_update(&b, u).expect("update applies");
385 }
386 assert_eq!(view_json(&a), view_json(&b));
387}
388
389/// Spec §2.4: a `columns` block's `children` names blocks in the same
390/// slide. `add_block` and `set_block` must reject a child that isn't.
391#[test]
392fn columns_children_must_name_blocks_on_the_slide() {
393 let d = doc(9);
394 let setup = r#"[
395 {"op":"add_slide","id":"s1","layout":"flow"},
396 {"op":"add_slide","id":"s2","layout":"flow"},
397 {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"},
398 {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"},
399 {"op":"add_block","slide":"s2","id":"c1","type":"text","markdown":"C"}
400 ]"#;
401 apply_ops(&d, setup).expect("setup applies");
402 let before = view_json(&d);
403
404 // An id that names no block anywhere.
405 let bad = r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns",
406 "props":{"count":2,"children":["b1","nope"]}}]"#;
407 let err = apply_ops(&d, bad).expect_err("unknown child id is rejected");
408 assert!(err.to_string().contains("nope"), "{err}");
409 assert_eq!(view_json(&d), before, "nothing applied");
410
411 // An id that names a block, but on a different slide.
412 let bad = r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns",
413 "props":{"count":2,"children":["b1","c1"]}}]"#;
414 let err = apply_ops(&d, bad).expect_err("a block on another slide is rejected");
415 assert!(err.to_string().contains("c1"), "{err}");
416 assert_eq!(view_json(&d), before, "nothing applied");
417
418 // Valid children apply; then set_block naming the columns block
419 // itself is rejected the same way.
420 apply_ops(
421 &d,
422 r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns",
423 "props":{"count":2,"children":["b1","b2"]}}]"#,
424 )
425 .expect("valid children apply");
426 let before = view_json(&d);
427 let bad = r#"[{"op":"set_block","slide":"s1","id":"b3","props":{"children":["b1","b3"]}}]"#;
428 let err = apply_ops(&d, bad).expect_err("a columns block cannot name itself");
429 assert!(err.to_string().contains("b3"), "{err}");
430 assert_eq!(view_json(&d), before, "nothing applied");
431}
432
433/// Spec §2.4: removing a block that a `columns` block's `children`
434/// names drops it from that list rather than rejecting the removal.
435#[test]
436fn remove_block_drops_it_from_columns_children() {
437 let d = doc(10);
438 apply_ops(
439 &d,
440 r#"[
441 {"op":"add_slide","id":"s1","layout":"flow"},
442 {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"},
443 {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"},
444 {"op":"add_block","slide":"s1","id":"b3","type":"columns",
445 "props":{"count":2,"children":["b1","b2"]}}
446 ]"#,
447 )
448 .expect("setup applies");
449
450 apply_ops(&d, r#"[{"op":"remove_block","slide":"s1","id":"b1"}]"#).expect("remove applies");
451 let v = view(&d);
452 let columns = v["slides"][0]["blocks"]
453 .as_array()
454 .unwrap()
455 .iter()
456 .find(|b| b["id"] == "b3")
457 .expect("columns block still there");
458 assert_eq!(
459 columns["props"]["children"],
460 serde_json::json!(["b2"]),
461 "the removed block is dropped from children, not the whole op rejected"
462 );
463
464 // The model saw the drop too: a later op naming the now-removed
465 // block is still rejected as unknown.
466 let before = view_json(&d);
467 let bad = r#"[{"op":"set_block","slide":"s1","id":"b3","props":{"children":["b1"]}}]"#;
468 apply_ops(&d, bad).expect_err("b1 no longer exists");
469 assert_eq!(view_json(&d), before);
470}
471
472/// Spec §2.4 + duplicate_slide: a copied `columns` block's children
473/// follow the copy's new block ids, and the model tracks the remap so
474/// later ops against the copy validate against its ids, not the
475/// original's.
476#[test]
477fn duplicate_slide_remaps_columns_children() {
478 let d = doc(11);
479 apply_ops(
480 &d,
481 r#"[
482 {"op":"add_slide","id":"s1","layout":"flow"},
483 {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"},
484 {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"},
485 {"op":"add_block","slide":"s1","id":"b3","type":"columns",
486 "props":{"count":2,"children":["b1","b2"]}}
487 ]"#,
488 )
489 .expect("setup applies");
490
491 apply_ops(
492 &d,
493 r#"[{"op":"duplicate_slide","id":"s1","new_id":"s1copy","new_block_ids":["d1","d2","d3"]}]"#,
494 )
495 .expect("duplicate applies");
496 let v = view(&d);
497 let copy = &v["slides"][1];
498 assert_eq!(copy["id"], "s1copy");
499 assert_eq!(
500 copy["blocks"][2]["props"]["children"],
501 serde_json::json!(["d1", "d2"]),
502 "columns children follow the new ids"
503 );
504
505 // The model tracked the remap: a later op naming the copy's new
506 // children validates...
507 apply_ops(
508 &d,
509 r#"[{"op":"set_block","slide":"s1copy","id":"d3","props":{"children":["d2","d1"]}}]"#,
510 )
511 .expect("remapped children still validate");
512
513 // ...and one naming the original slide's old ids against the copy
514 // is rejected: they aren't blocks on s1copy.
515 let before = view_json(&d);
516 let bad = r#"[{"op":"set_block","slide":"s1copy","id":"d3","props":{"children":["b1"]}}]"#;
517 apply_ops(&d, bad).expect_err("the original's id does not exist on the copy");
518 assert_eq!(view_json(&d), before);
519}
520
521/// Codex 7: the children of a slide's columns blocks form a forest. A
522/// block may be named by at most one columns block, once, and a
523/// columns block may not be put under one of its own descendants —
524/// the two-block cycle the renderer used to lose in silence. The
525/// messages are the ones fixtures/ops-model.mjs throws, word for word.
526#[test]
527fn columns_children_have_one_parent_and_no_cycles() {
528 let d = doc(12);
529 apply_ops(
530 &d,
531 r#"[
532 {"op":"add_slide","id":"s1","layout":"flow"},
533 {"op":"add_block","slide":"s1","id":"t1","type":"text","markdown":"T"},
534 {"op":"add_block","slide":"s1","id":"t2","type":"text","markdown":"U"},
535 {"op":"add_block","slide":"s1","id":"a","type":"columns","props":{"children":["t1"]}},
536 {"op":"add_block","slide":"s1","id":"b","type":"columns","props":{"children":["a"]}},
537 {"op":"add_block","slide":"s1","id":"c","type":"columns","props":{"children":[]}}
538 ]"#,
539 )
540 .expect("a forest applies");
541 let before = view_json(&d);
542
543 let refused = |ops: &str, want: &str| {
544 let err = apply_ops(&d, ops).expect_err(want);
545 assert!(err.to_string().contains(want), "want {want:?} in {err}");
546 assert_eq!(view_json(&d), before, "nothing applied");
547 };
548
549 // A → B, B → A: the cycle codex reproduced.
550 refused(
551 r#"[{"op":"set_block","slide":"s1","id":"a","props":{"children":["t1","b"]}}]"#,
552 r#"columns children names "b", which would put columns "a" inside itself"#,
553 );
554 // A shared child: t1 is already under a.
555 refused(
556 r#"[{"op":"set_block","slide":"s1","id":"c","props":{"children":["t1"]}}]"#,
557 r#"columns children names "t1", already a child of columns "a""#,
558 );
559 // The same child twice in one list.
560 refused(
561 r#"[{"op":"set_block","slide":"s1","id":"c","props":{"children":["t2","t2"]}}]"#,
562 r#"columns children names "t2" twice"#,
563 );
564 // A longer cycle: c → b → a, then a → c.
565 apply_ops(
566 &d,
567 r#"[{"op":"set_block","slide":"s1","id":"c","props":{"children":["b"]}}]"#,
568 )
569 .expect("c over b applies");
570 let before = view_json(&d);
571 let err = apply_ops(
572 &d,
573 r#"[{"op":"set_block","slide":"s1","id":"a","props":{"children":["t1","c"]}}]"#,
574 )
575 .expect_err("a three-block cycle is refused");
576 assert!(err.to_string().contains("inside itself"), "{err}");
577 assert_eq!(view_json(&d), before);
578
579 // Moving a child from one columns block to another in one batch
580 // is fine: the model follows the batch.
581 apply_ops(
582 &d,
583 r#"[
584 {"op":"set_block","slide":"s1","id":"a","props":{"children":[]}},
585 {"op":"set_block","slide":"s1","id":"c","props":{"children":["b","t1"]}}
586 ]"#,
587 )
588 .expect("reparenting in one batch applies");
589}
590
591/// Codex 4, the other reorder: move_block writes `order` on the maps
592/// rather than deleting and re-inserting one, so a concurrent edit to
593/// the moved block survives the merge, and the view reads the new
594/// order on both sides.
595#[test]
596fn move_block_keeps_a_concurrent_text_edit() {
597 use slides_core::{apply_update, state_bytes};
598 use yrs::updates::decoder::Decode;
599 use yrs::{ReadTxn, Transact, Update};
600
601 let a = doc(13);
602 apply_ops(
603 &a,
604 r#"[
605 {"op":"add_slide","id":"s1","layout":"flow"},
606 {"op":"add_block","slide":"s1","id":"one","type":"text","markdown":"one"},
607 {"op":"add_block","slide":"s1","id":"two","type":"text","markdown":"two"},
608 {"op":"add_block","slide":"s1","id":"three","type":"text","markdown":"three"}
609 ]"#,
610 )
611 .unwrap();
612 let b = doc(14);
613 apply_update(&b, &state_bytes(&a)).unwrap();
614
615 let sv_a = a.transact().state_vector();
616 apply_ops(
617 &a,
618 r#"[{"op":"move_block","slide":"s1","id":"three","after":null}]"#,
619 )
620 .unwrap();
621 let from_a = a.transact().encode_state_as_update_v1(&sv_a);
622
623 let sv_b = b.transact().state_vector();
624 apply_ops(
625 &b,
626 r#"[{"op":"set_text","slide":"s1","id":"three","markdown":"three, edited"}]"#,
627 )
628 .unwrap();
629 let from_b = b.transact().encode_state_as_update_v1(&sv_b);
630
631 a.transact_mut()
632 .apply_update(Update::decode_v1(&from_b).unwrap())
633 .unwrap();
634 b.transact_mut()
635 .apply_update(Update::decode_v1(&from_a).unwrap())
636 .unwrap();
637
638 for d in [&a, &b] {
639 let v = view(d);
640 let blocks = v["slides"][0]["blocks"].as_array().unwrap();
641 let ids: Vec<&str> = blocks.iter().map(|b| b["id"].as_str().unwrap()).collect();
642 assert_eq!(ids, vec!["three", "one", "two"]);
643 assert_eq!(
644 blocks[0]["text"],
645 serde_json::json!([[{"t": "three, edited"}]])
646 );
647 }
648 assert_eq!(view_json(&a), view_json(&b));
649
650 // And a block added after the move lands where `after` says, even
651 // though the array position no longer means anything.
652 apply_ops(
653 &a,
654 r#"[{"op":"add_block","slide":"s1","id":"four","type":"text","markdown":"four","after":"three"}]"#,
655 )
656 .unwrap();
657 let v = view(&a);
658 let ids: Vec<&str> = v["slides"][0]["blocks"]
659 .as_array()
660 .unwrap()
661 .iter()
662 .map(|b| b["id"].as_str().unwrap())
663 .collect();
664 assert_eq!(ids, vec!["three", "four", "one", "two"]);
665 apply_ops(
666 &a,
667 r#"[{"op":"add_block","slide":"s1","id":"five","type":"text","markdown":"five"},
668 {"op":"add_block","slide":"s1","id":"zero","type":"text","markdown":"zero","after":null}]"#,
669 )
670 .unwrap();
671 let v = view(&a);
672 let ids: Vec<&str> = v["slides"][0]["blocks"]
673 .as_array()
674 .unwrap()
675 .iter()
676 .map(|b| b["id"].as_str().unwrap())
677 .collect();
678 assert_eq!(ids, vec!["zero", "three", "four", "one", "two", "five"]);
679}
680
681/// Codex 4 at slide level: move_slide writes `order` on the slide maps
682/// rather than deleting one and inserting a copy, so text typed into a
683/// slide by one peer while another reorders the deck survives the
684/// merge — and both sides read the same new order.
685#[test]
686fn move_slide_keeps_a_concurrent_text_edit() {
687 use slides_core::{apply_update, state_bytes};
688 use yrs::updates::decoder::Decode;
689 use yrs::{ReadTxn, Transact, Update};
690
691 let a = doc(15);
692 apply_ops(
693 &a,
694 r#"[
695 {"op":"add_slide","id":"s1","layout":"flow"},
696 {"op":"add_slide","id":"s2","layout":"flow"},
697 {"op":"add_slide","id":"s3","layout":"flow"},
698 {"op":"add_block","slide":"s3","id":"t3","type":"text","markdown":"three"}
699 ]"#,
700 )
701 .unwrap();
702 let b = doc(16);
703 apply_update(&b, &state_bytes(&a)).unwrap();
704
705 // A moves s3 to the front; B, who has not seen that, types into the
706 // block on s3 and into its notes.
707 let sv_a = a.transact().state_vector();
708 apply_ops(&a, r#"[{"op":"move_slide","id":"s3","after":null}]"#).unwrap();
709 let from_a = a.transact().encode_state_as_update_v1(&sv_a);
710
711 let sv_b = b.transact().state_vector();
712 apply_ops(
713 &b,
714 r#"[{"op":"set_text","slide":"s3","id":"t3","markdown":"three, edited"},
715 {"op":"set_notes","id":"s3","markdown":"say this"}]"#,
716 )
717 .unwrap();
718 let from_b = b.transact().encode_state_as_update_v1(&sv_b);
719
720 a.transact_mut()
721 .apply_update(Update::decode_v1(&from_b).unwrap())
722 .unwrap();
723 b.transact_mut()
724 .apply_update(Update::decode_v1(&from_a).unwrap())
725 .unwrap();
726
727 for d in [&a, &b] {
728 let v = view(d);
729 let slides = v["slides"].as_array().unwrap();
730 let ids: Vec<&str> = slides.iter().map(|s| s["id"].as_str().unwrap()).collect();
731 assert_eq!(ids, vec!["s3", "s1", "s2"]);
732 assert_eq!(
733 slides[0]["blocks"][0]["text"],
734 serde_json::json!([[{"t": "three, edited"}]])
735 );
736 assert_eq!(slides[0]["notes"], serde_json::json!([[{"t": "say this"}]]));
737 // The `order` key is the document's, not the view's.
738 assert!(slides[0].get("order").is_none());
739 }
740 assert_eq!(view_json(&a), view_json(&b));
741
742 // A slide added after the move lands where `after` says, even
743 // though the array position no longer means anything, and a
744 // duplicate is still the next slide.
745 apply_ops(
746 &a,
747 r#"[{"op":"add_slide","id":"s4","layout":"flow","after":"s3"},
748 {"op":"add_slide","id":"s5","layout":"flow"},
749 {"op":"add_slide","id":"s0","layout":"flow","after":null},
750 {"op":"duplicate_slide","id":"s3","new_id":"s3b","new_block_ids":["t3b"]}]"#,
751 )
752 .unwrap();
753 let v = view(&a);
754 let ids: Vec<&str> = v["slides"]
755 .as_array()
756 .unwrap()
757 .iter()
758 .map(|s| s["id"].as_str().unwrap())
759 .collect();
760 assert_eq!(ids, vec!["s0", "s3", "s3b", "s4", "s1", "s2", "s5"]);
761
762 // And removing one leaves the rest in order.
763 apply_ops(&a, r#"[{"op":"remove_slide","id":"s3"}]"#).unwrap();
764 let v = view(&a);
765 let ids: Vec<&str> = v["slides"]
766 .as_array()
767 .unwrap()
768 .iter()
769 .map(|s| s["id"].as_str().unwrap())
770 .collect();
771 assert_eq!(ids, vec!["s0", "s3b", "s4", "s1", "s2", "s5"]);
772}
773