//! The operations API end to end: a JSON batch in, a Yjs update out, and //! the view JSON as the witness of what changed. use slides_core::{apply_ops, new_doc, view_json}; use yrs::Doc; fn doc(n: u64) -> Doc { new_doc(&format!("test-guid-{n}"), n) } fn view(doc: &Doc) -> serde_json::Value { serde_json::from_str(&view_json(doc)).expect("view is JSON") } #[test] fn add_slide_then_view_lists_it() { let d = doc(1); let ops = r#"[ {"op":"set_meta","title":"T"}, {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title"} ]"#; apply_ops(&d, ops).expect("ops apply"); let v = view(&d); assert_eq!(v["meta"]["title"], "T"); assert_eq!(v["meta"]["theme"], "paper"); assert_eq!(v["meta"]["aspect"], "16:9"); let slides = v["slides"].as_array().expect("slides array"); assert_eq!(slides.len(), 1); assert_eq!(slides[0]["id"], "aaaaaaaaaaaa"); assert_eq!(slides[0]["template"], "title"); assert_eq!(slides[0]["layout"], "flow"); assert_eq!(slides[0]["blocks"], serde_json::json!([])); assert_eq!(slides[0]["notes"], serde_json::json!([])); } #[test] fn add_block_with_markdown_produces_runs() { let d = doc(2); let ops = r#"[ {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title"}, {"op":"add_block","slide":"aaaaaaaaaaaa","id":"bbbbbbbbbbbb","type":"heading", "props":{"level":1},"region":"heading","markdown":"Hello **world**"} ]"#; apply_ops(&d, ops).expect("ops apply"); let v = view(&d); let block = &v["slides"][0]["blocks"][0]; assert_eq!(block["id"], "bbbbbbbbbbbb"); assert_eq!(block["type"], "heading"); assert_eq!(block["props"]["level"], 1); assert_eq!(block["region"], "heading"); assert_eq!(block["box"], serde_json::Value::Null); assert_eq!( block["text"], serde_json::json!([[{"t": "Hello "}, {"t": "world", "b": true}]]) ); } #[test] fn ops_are_atomic() { let d = doc(3); let before = view_json(&d); let ops = r#"[ {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow"}, {"op":"frobnicate","id":"aaaaaaaaaaaa"} ]"#; let err = apply_ops(&d, ops).expect_err("unknown op is an error"); assert!( err.to_string().contains("frobnicate"), "message names the op: {err}" ); assert_eq!(view_json(&d), before, "a failed batch applies nothing"); // A missing id is the same kind of failure: nothing applied. let ops = r#"[{"op":"add_slide","layout":"flow"}]"#; let err = apply_ops(&d, ops).expect_err("missing id is an error"); assert!( err.to_string().contains("id"), "message names the field: {err}" ); assert_eq!(view_json(&d), before); // A reference that only becomes wrong mid-batch is caught before // anything is written too: the first op is valid on its own. let ops = r#"[ {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow"}, {"op":"remove_block","slide":"aaaaaaaaaaaa","id":"nope"} ]"#; apply_ops(&d, ops).expect_err("unknown block is an error"); assert_eq!(view_json(&d), before); } #[test] fn layout_switch_assigns_boxes() { let d = doc(4); let ops = r#"[ {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title-body"}, {"op":"add_block","slide":"aaaaaaaaaaaa","id":"bbbbbbbbbbbb","type":"heading", "props":{"level":1},"region":"heading","markdown":"Title"}, {"op":"add_block","slide":"aaaaaaaaaaaa","id":"cccccccccccc","type":"text", "props":{"size":"m"},"region":"body","markdown":"Body"} ]"#; apply_ops(&d, ops).expect("ops apply"); apply_ops( &d, r#"[{"op":"set_slide","id":"aaaaaaaaaaaa","layout":"canvas"}]"#, ) .expect("switch to canvas"); let v = view(&d); let slide = &v["slides"][0]; assert_eq!(slide["layout"], "canvas"); let blocks = slide["blocks"].as_array().unwrap(); assert_eq!(blocks.len(), 2); for b in blocks { assert!(b["box"].is_object(), "canvas block has a box: {b}"); assert!(b["region"].is_null(), "canvas block has no region: {b}"); } assert_eq!( blocks[0]["box"], serde_json::json!({"x":0.06,"y":0.08,"w":0.88,"h":0.18}) ); assert_eq!( blocks[1]["box"], serde_json::json!({"x":0.06,"y":0.30,"w":0.88,"h":0.62}) ); assert_eq!(blocks[0]["z"], 0); assert_eq!(blocks[1]["z"], 1); apply_ops( &d, r#"[{"op":"set_slide","id":"aaaaaaaaaaaa","layout":"flow"}]"#, ) .expect("switch back to flow"); let v = view(&d); let slide = &v["slides"][0]; assert_eq!(slide["layout"], "flow"); let blocks = slide["blocks"].as_array().unwrap(); assert_eq!(blocks.len(), 2); for b in blocks { assert!(b["box"].is_null(), "flow block has no box: {b}"); assert!(b["region"].is_string(), "flow block has a region: {b}"); } assert_eq!(blocks[0]["id"], "bbbbbbbbbbbb"); assert_eq!(blocks[0]["region"], "heading"); assert_eq!(blocks[1]["id"], "cccccccccccc"); assert_eq!(blocks[1]["region"], "body"); } #[test] fn update_roundtrip() { let a = doc(5); let b = doc(6); let ops = r##"[ {"op":"set_meta","title":"Roundtrip","theme":"ink","accent":"#ff0000"}, {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title-body"}, {"op":"add_block","slide":"aaaaaaaaaaaa","id":"bbbbbbbbbbbb","type":"heading", "props":{"level":1},"region":"heading","markdown":"Hello *there* [link](https://x.example)"}, {"op":"add_block","slide":"aaaaaaaaaaaa","id":"cccccccccccc","type":"list", "props":{"style":"bullet"},"region":"body","markdown":"- one\n- two ==hi=="}, {"op":"add_block","slide":"aaaaaaaaaaaa","id":"dddddddddddd","type":"table", "props":{"rows":2,"cols":2},"region":"body"}, {"op":"set_notes","id":"aaaaaaaaaaaa","markdown":"Say `hello`"}, {"op":"register_asset","id":"asset1","ref":"blobs/1","name":"pic.png","width":10,"height":20,"alt":"a picture"} ]"##; let out = apply_ops(&a, ops).expect("ops apply"); assert!(!out.is_empty(), "an applied batch yields an update"); slides_core::apply_update(&b, &out).expect("update applies to B"); assert_eq!(view_json(&a), view_json(&b)); // And the view really carries what was written. let v = view(&a); assert_eq!(v["meta"]["accent"], "#ff0000"); assert_eq!( v["slides"][0]["blocks"][0]["text"], serde_json::json!([[ {"t": "Hello "}, {"t": "there", "i": true}, {"t": " "}, {"t": "link", "href": "https://x.example"} ]]) ); assert_eq!( v["slides"][0]["blocks"][1]["text"], serde_json::json!([[{"t": "one"}], [{"t": "two "}, {"t": "hi", "h": true}]]) ); let cells = v["slides"][0]["blocks"][2]["props"]["cells"] .as_array() .unwrap(); assert_eq!(cells.len(), 4); assert_eq!( v["slides"][0]["notes"], serde_json::json!([[{"t": "Say "}, {"t": "hello", "c": true}]]) ); assert_eq!(v["assets"]["asset1"]["ref"], "blobs/1"); assert_eq!(v["assets"]["asset1"]["width"], 10); } /// The ops the tests above do not reach, each checked through the view /// and through a second document fed only the returned update. #[test] fn remaining_ops_roundtrip() { let a = doc(7); let b = doc(8); let mut updates = Vec::new(); let mut run = |ops: &str| { let out = apply_ops(&a, ops).unwrap_or_else(|e| panic!("{ops}: {e}")); updates.push(out); }; run(r##"[ {"op":"add_slide","id":"s1","layout":"flow","template":"title"}, {"op":"add_slide","id":"s2","layout":"canvas"}, {"op":"add_slide","id":"s0","layout":"flow","after":null}, {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"One"}, {"op":"add_block","slide":"s1","id":"b2","type":"code","props":{"language":"go"},"markdown":"x := 1\n\ny := **2**"}, {"op":"add_block","slide":"s1","id":"b3","type":"columns","props":{"count":2,"children":["b1","b2"]}}, {"op":"add_block","slide":"s1","id":"b0","type":"divider","after":null}, {"op":"add_block","slide":"s2","id":"c1","type":"text","markdown":"Canvas"}, {"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} ]"##); let v = view(&a); let ids: Vec<_> = v["slides"] .as_array() .unwrap() .iter() .map(|s| s["id"].clone()) .collect(); assert_eq!( ids, serde_json::json!(["s0", "s1", "s2"]) .as_array() .unwrap() .clone() ); let s1 = &v["slides"][1]; let bids: Vec<_> = s1["blocks"] .as_array() .unwrap() .iter() .map(|b| b["id"].clone()) .collect(); assert_eq!(bids, vec!["b0", "b1", "b2", "b3"]); assert_eq!( s1["blocks"][1]["region"], "heading", "heading defaults to its region" ); assert_eq!( s1["blocks"][2]["text"], serde_json::json!([[{"t": "x := 1"}], [], [{"t": "y := **2**"}]]), "code is one plain paragraph per line" ); assert!( s1["blocks"][3].get("text").is_none(), "columns carry no text" ); let s2 = &v["slides"][2]; assert_eq!( s2["blocks"][0]["box"], serde_json::json!({"x":0.06,"y":0.30,"w":0.88,"h":0.62}) ); assert_eq!(s2["blocks"][0]["z"], 0); assert_eq!( s2["blocks"][1]["box"], serde_json::json!({"x":0.5,"y":0.5,"w":0.25,"h":0.25}) ); assert_eq!(s2["blocks"][1]["z"], 7); run(r##"[ {"op":"move_slide","id":"s2","after":null}, {"op":"move_block","slide":"s1","id":"b0","after":"b3"}, {"op":"duplicate_slide","id":"s1","new_id":"s1copy","new_block_ids":["d1","d2","d3","d4"]}, {"op":"set_slide","id":"s0","template":"section","background":{"kind":"color","value":"#123456"},"phone":"reflow","transition":"fade"}, {"op":"set_notes","id":"s0","markdown":"Notes\n\nMore *notes*"}, {"op":"set_meta","fonts":{"heading":"Inter","body":"Georgia"}}, {"op":"register_asset","id":"a1","ref":"r","name":"n","width":1,"height":2,"alt":"z"} ]"##); let v = view(&a); let ids: Vec<_> = v["slides"] .as_array() .unwrap() .iter() .map(|s| s["id"].clone()) .collect(); assert_eq!(ids, vec!["s2", "s0", "s1", "s1copy"]); let s1 = &v["slides"][2]; let bids: Vec<_> = s1["blocks"] .as_array() .unwrap() .iter() .map(|b| b["id"].clone()) .collect(); assert_eq!(bids, vec!["b1", "b2", "b3", "b0"]); let copy = &v["slides"][3]; let cids: Vec<_> = copy["blocks"] .as_array() .unwrap() .iter() .map(|b| b["id"].clone()) .collect(); assert_eq!(cids, vec!["d1", "d2", "d3", "d4"]); assert_eq!( copy["blocks"][0]["text"], serde_json::json!([[{"t": "One"}]]), "the copy keeps its text" ); assert_eq!( copy["blocks"][2]["props"]["children"], serde_json::json!(["d1", "d2"]), "columns children follow the new ids" ); let s0 = &v["slides"][1]; assert_eq!(s0["template"], "section"); assert_eq!( s0["background"], serde_json::json!({"kind":"color","value":"#123456"}) ); assert_eq!(s0["phone"], "reflow"); assert_eq!(s0["transition"], "fade"); assert_eq!( s0["notes"], serde_json::json!([[{"t":"Notes"}],[{"t":"More "},{"t":"notes","i":true}]]) ); assert_eq!( v["meta"]["fonts"], serde_json::json!({"heading":"Inter","body":"Georgia"}) ); run(r##"[ {"op":"add_block","slide":"s0","id":"t1","type":"table","props":{"rows":2,"cols":3,"header":true}}, {"op":"set_block","slide":"s0","id":"t1","props":{"rows":3,"header":null},"region":"aside"}, {"op":"set_text","slide":"s1","id":"b1","markdown":"Changed"}, {"op":"set_phone","slide":"s1","id":"b1","phone":{"hidden":true,"order":2,"size":"s"}}, {"op":"set_phone","slide":"s1","id":"b2","phone":{}}, {"op":"remove_block","slide":"s1","id":"b3"}, {"op":"remove_slide","id":"s1copy"}, {"op":"remove_asset","id":"a1"}, {"op":"set_meta","accent":null,"fonts":null} ]"##); let v = view(&a); let t1 = &v["slides"][1]["blocks"][0]; assert_eq!(t1["props"]["rows"], 3); assert_eq!(t1["props"]["cols"], 3); assert!(t1["props"].get("header").is_none(), "null removes a prop"); assert_eq!( t1["props"]["cells"].as_array().unwrap().len(), 9, "cells follow rows*cols" ); assert_eq!(t1["region"], "aside"); let s1 = &v["slides"][2]; assert_eq!( s1["blocks"][0]["text"], serde_json::json!([[{"t": "Changed"}]]) ); assert_eq!( s1["blocks"][0]["phone"], serde_json::json!({"hidden":true,"order":2,"size":"s"}) ); assert_eq!(s1["blocks"][1]["phone"], serde_json::json!({})); assert_eq!(s1["blocks"].as_array().unwrap().len(), 3); assert_eq!(v["slides"].as_array().unwrap().len(), 3); assert_eq!(v["assets"], serde_json::json!({})); assert_eq!(v["meta"]["accent"], serde_json::Value::Null); assert_eq!(v["meta"]["fonts"], serde_json::Value::Null); // Refusals the model catches. for bad in [ r#"[{"op":"add_slide","id":"s0","layout":"flow"}]"#, r#"[{"op":"add_slide","id":"new","layout":"sideways"}]"#, r#"[{"op":"set_slide","id":"s0","template":"nope"}]"#, r#"[{"op":"set_text","slide":"s0","id":"t1","markdown":"x"}]"#, r#"[{"op":"duplicate_slide","id":"s0","new_id":"n","new_block_ids":[]}]"#, r#"[{"op":"set_phone","slide":"s1","id":"b1","phone":{"colour":"red"}}]"#, r#"[{"op":"set_block","slide":"s0","id":"t1","props":{"rows":0}}]"#, r#"[{"op":"move_block","slide":"s1","id":"b1","after":"b1"}]"#, r#"[{"op":"remove_asset","id":"a1"}]"#, r#"{"op":"set_meta"}"#, ] { let before = view_json(&a); apply_ops(&a, bad).expect_err(bad); assert_eq!(view_json(&a), before, "{bad}"); } for u in &updates { slides_core::apply_update(&b, u).expect("update applies"); } assert_eq!(view_json(&a), view_json(&b)); } /// Spec §2.4: a `columns` block's `children` names blocks in the same /// slide. `add_block` and `set_block` must reject a child that isn't. #[test] fn columns_children_must_name_blocks_on_the_slide() { let d = doc(9); let setup = r#"[ {"op":"add_slide","id":"s1","layout":"flow"}, {"op":"add_slide","id":"s2","layout":"flow"}, {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"}, {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"}, {"op":"add_block","slide":"s2","id":"c1","type":"text","markdown":"C"} ]"#; apply_ops(&d, setup).expect("setup applies"); let before = view_json(&d); // An id that names no block anywhere. let bad = r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns", "props":{"count":2,"children":["b1","nope"]}}]"#; let err = apply_ops(&d, bad).expect_err("unknown child id is rejected"); assert!(err.to_string().contains("nope"), "{err}"); assert_eq!(view_json(&d), before, "nothing applied"); // An id that names a block, but on a different slide. let bad = r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns", "props":{"count":2,"children":["b1","c1"]}}]"#; let err = apply_ops(&d, bad).expect_err("a block on another slide is rejected"); assert!(err.to_string().contains("c1"), "{err}"); assert_eq!(view_json(&d), before, "nothing applied"); // Valid children apply; then set_block naming the columns block // itself is rejected the same way. apply_ops( &d, r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns", "props":{"count":2,"children":["b1","b2"]}}]"#, ) .expect("valid children apply"); let before = view_json(&d); let bad = r#"[{"op":"set_block","slide":"s1","id":"b3","props":{"children":["b1","b3"]}}]"#; let err = apply_ops(&d, bad).expect_err("a columns block cannot name itself"); assert!(err.to_string().contains("b3"), "{err}"); assert_eq!(view_json(&d), before, "nothing applied"); } /// Spec §2.4: removing a block that a `columns` block's `children` /// names drops it from that list rather than rejecting the removal. #[test] fn remove_block_drops_it_from_columns_children() { let d = doc(10); apply_ops( &d, r#"[ {"op":"add_slide","id":"s1","layout":"flow"}, {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"}, {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"}, {"op":"add_block","slide":"s1","id":"b3","type":"columns", "props":{"count":2,"children":["b1","b2"]}} ]"#, ) .expect("setup applies"); apply_ops(&d, r#"[{"op":"remove_block","slide":"s1","id":"b1"}]"#).expect("remove applies"); let v = view(&d); let columns = v["slides"][0]["blocks"] .as_array() .unwrap() .iter() .find(|b| b["id"] == "b3") .expect("columns block still there"); assert_eq!( columns["props"]["children"], serde_json::json!(["b2"]), "the removed block is dropped from children, not the whole op rejected" ); // The model saw the drop too: a later op naming the now-removed // block is still rejected as unknown. let before = view_json(&d); let bad = r#"[{"op":"set_block","slide":"s1","id":"b3","props":{"children":["b1"]}}]"#; apply_ops(&d, bad).expect_err("b1 no longer exists"); assert_eq!(view_json(&d), before); } /// Spec §2.4 + duplicate_slide: a copied `columns` block's children /// follow the copy's new block ids, and the model tracks the remap so /// later ops against the copy validate against its ids, not the /// original's. #[test] fn duplicate_slide_remaps_columns_children() { let d = doc(11); apply_ops( &d, r#"[ {"op":"add_slide","id":"s1","layout":"flow"}, {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"}, {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"}, {"op":"add_block","slide":"s1","id":"b3","type":"columns", "props":{"count":2,"children":["b1","b2"]}} ]"#, ) .expect("setup applies"); apply_ops( &d, r#"[{"op":"duplicate_slide","id":"s1","new_id":"s1copy","new_block_ids":["d1","d2","d3"]}]"#, ) .expect("duplicate applies"); let v = view(&d); let copy = &v["slides"][1]; assert_eq!(copy["id"], "s1copy"); assert_eq!( copy["blocks"][2]["props"]["children"], serde_json::json!(["d1", "d2"]), "columns children follow the new ids" ); // The model tracked the remap: a later op naming the copy's new // children validates... apply_ops( &d, r#"[{"op":"set_block","slide":"s1copy","id":"d3","props":{"children":["d2","d1"]}}]"#, ) .expect("remapped children still validate"); // ...and one naming the original slide's old ids against the copy // is rejected: they aren't blocks on s1copy. let before = view_json(&d); let bad = r#"[{"op":"set_block","slide":"s1copy","id":"d3","props":{"children":["b1"]}}]"#; apply_ops(&d, bad).expect_err("the original's id does not exist on the copy"); assert_eq!(view_json(&d), before); } /// Codex 7: the children of a slide's columns blocks form a forest. A /// block may be named by at most one columns block, once, and a /// columns block may not be put under one of its own descendants — /// the two-block cycle the renderer used to lose in silence. The /// messages are the ones fixtures/ops-model.mjs throws, word for word. #[test] fn columns_children_have_one_parent_and_no_cycles() { let d = doc(12); apply_ops( &d, r#"[ {"op":"add_slide","id":"s1","layout":"flow"}, {"op":"add_block","slide":"s1","id":"t1","type":"text","markdown":"T"}, {"op":"add_block","slide":"s1","id":"t2","type":"text","markdown":"U"}, {"op":"add_block","slide":"s1","id":"a","type":"columns","props":{"children":["t1"]}}, {"op":"add_block","slide":"s1","id":"b","type":"columns","props":{"children":["a"]}}, {"op":"add_block","slide":"s1","id":"c","type":"columns","props":{"children":[]}} ]"#, ) .expect("a forest applies"); let before = view_json(&d); let refused = |ops: &str, want: &str| { let err = apply_ops(&d, ops).expect_err(want); assert!(err.to_string().contains(want), "want {want:?} in {err}"); assert_eq!(view_json(&d), before, "nothing applied"); }; // A → B, B → A: the cycle codex reproduced. refused( r#"[{"op":"set_block","slide":"s1","id":"a","props":{"children":["t1","b"]}}]"#, r#"columns children names "b", which would put columns "a" inside itself"#, ); // A shared child: t1 is already under a. refused( r#"[{"op":"set_block","slide":"s1","id":"c","props":{"children":["t1"]}}]"#, r#"columns children names "t1", already a child of columns "a""#, ); // The same child twice in one list. refused( r#"[{"op":"set_block","slide":"s1","id":"c","props":{"children":["t2","t2"]}}]"#, r#"columns children names "t2" twice"#, ); // A longer cycle: c → b → a, then a → c. apply_ops( &d, r#"[{"op":"set_block","slide":"s1","id":"c","props":{"children":["b"]}}]"#, ) .expect("c over b applies"); let before = view_json(&d); let err = apply_ops( &d, r#"[{"op":"set_block","slide":"s1","id":"a","props":{"children":["t1","c"]}}]"#, ) .expect_err("a three-block cycle is refused"); assert!(err.to_string().contains("inside itself"), "{err}"); assert_eq!(view_json(&d), before); // Moving a child from one columns block to another in one batch // is fine: the model follows the batch. apply_ops( &d, r#"[ {"op":"set_block","slide":"s1","id":"a","props":{"children":[]}}, {"op":"set_block","slide":"s1","id":"c","props":{"children":["b","t1"]}} ]"#, ) .expect("reparenting in one batch applies"); } /// Codex 4, the other reorder: move_block writes `order` on the maps /// rather than deleting and re-inserting one, so a concurrent edit to /// the moved block survives the merge, and the view reads the new /// order on both sides. #[test] fn move_block_keeps_a_concurrent_text_edit() { use slides_core::{apply_update, state_bytes}; use yrs::updates::decoder::Decode; use yrs::{ReadTxn, Transact, Update}; let a = doc(13); apply_ops( &a, r#"[ {"op":"add_slide","id":"s1","layout":"flow"}, {"op":"add_block","slide":"s1","id":"one","type":"text","markdown":"one"}, {"op":"add_block","slide":"s1","id":"two","type":"text","markdown":"two"}, {"op":"add_block","slide":"s1","id":"three","type":"text","markdown":"three"} ]"#, ) .unwrap(); let b = doc(14); apply_update(&b, &state_bytes(&a)).unwrap(); let sv_a = a.transact().state_vector(); apply_ops( &a, r#"[{"op":"move_block","slide":"s1","id":"three","after":null}]"#, ) .unwrap(); let from_a = a.transact().encode_state_as_update_v1(&sv_a); let sv_b = b.transact().state_vector(); apply_ops( &b, r#"[{"op":"set_text","slide":"s1","id":"three","markdown":"three, edited"}]"#, ) .unwrap(); let from_b = b.transact().encode_state_as_update_v1(&sv_b); a.transact_mut() .apply_update(Update::decode_v1(&from_b).unwrap()) .unwrap(); b.transact_mut() .apply_update(Update::decode_v1(&from_a).unwrap()) .unwrap(); for d in [&a, &b] { let v = view(d); let blocks = v["slides"][0]["blocks"].as_array().unwrap(); let ids: Vec<&str> = blocks.iter().map(|b| b["id"].as_str().unwrap()).collect(); assert_eq!(ids, vec!["three", "one", "two"]); assert_eq!( blocks[0]["text"], serde_json::json!([[{"t": "three, edited"}]]) ); } assert_eq!(view_json(&a), view_json(&b)); // And a block added after the move lands where `after` says, even // though the array position no longer means anything. apply_ops( &a, r#"[{"op":"add_block","slide":"s1","id":"four","type":"text","markdown":"four","after":"three"}]"#, ) .unwrap(); let v = view(&a); let ids: Vec<&str> = v["slides"][0]["blocks"] .as_array() .unwrap() .iter() .map(|b| b["id"].as_str().unwrap()) .collect(); assert_eq!(ids, vec!["three", "four", "one", "two"]); apply_ops( &a, r#"[{"op":"add_block","slide":"s1","id":"five","type":"text","markdown":"five"}, {"op":"add_block","slide":"s1","id":"zero","type":"text","markdown":"zero","after":null}]"#, ) .unwrap(); let v = view(&a); let ids: Vec<&str> = v["slides"][0]["blocks"] .as_array() .unwrap() .iter() .map(|b| b["id"].as_str().unwrap()) .collect(); assert_eq!(ids, vec!["zero", "three", "four", "one", "two", "five"]); } /// Codex 4 at slide level: move_slide writes `order` on the slide maps /// rather than deleting one and inserting a copy, so text typed into a /// slide by one peer while another reorders the deck survives the /// merge — and both sides read the same new order. #[test] fn move_slide_keeps_a_concurrent_text_edit() { use slides_core::{apply_update, state_bytes}; use yrs::updates::decoder::Decode; use yrs::{ReadTxn, Transact, Update}; let a = doc(15); apply_ops( &a, r#"[ {"op":"add_slide","id":"s1","layout":"flow"}, {"op":"add_slide","id":"s2","layout":"flow"}, {"op":"add_slide","id":"s3","layout":"flow"}, {"op":"add_block","slide":"s3","id":"t3","type":"text","markdown":"three"} ]"#, ) .unwrap(); let b = doc(16); apply_update(&b, &state_bytes(&a)).unwrap(); // A moves s3 to the front; B, who has not seen that, types into the // block on s3 and into its notes. let sv_a = a.transact().state_vector(); apply_ops(&a, r#"[{"op":"move_slide","id":"s3","after":null}]"#).unwrap(); let from_a = a.transact().encode_state_as_update_v1(&sv_a); let sv_b = b.transact().state_vector(); apply_ops( &b, r#"[{"op":"set_text","slide":"s3","id":"t3","markdown":"three, edited"}, {"op":"set_notes","id":"s3","markdown":"say this"}]"#, ) .unwrap(); let from_b = b.transact().encode_state_as_update_v1(&sv_b); a.transact_mut() .apply_update(Update::decode_v1(&from_b).unwrap()) .unwrap(); b.transact_mut() .apply_update(Update::decode_v1(&from_a).unwrap()) .unwrap(); for d in [&a, &b] { let v = view(d); let slides = v["slides"].as_array().unwrap(); let ids: Vec<&str> = slides.iter().map(|s| s["id"].as_str().unwrap()).collect(); assert_eq!(ids, vec!["s3", "s1", "s2"]); assert_eq!( slides[0]["blocks"][0]["text"], serde_json::json!([[{"t": "three, edited"}]]) ); assert_eq!(slides[0]["notes"], serde_json::json!([[{"t": "say this"}]])); // The `order` key is the document's, not the view's. assert!(slides[0].get("order").is_none()); } assert_eq!(view_json(&a), view_json(&b)); // A slide added after the move lands where `after` says, even // though the array position no longer means anything, and a // duplicate is still the next slide. apply_ops( &a, r#"[{"op":"add_slide","id":"s4","layout":"flow","after":"s3"}, {"op":"add_slide","id":"s5","layout":"flow"}, {"op":"add_slide","id":"s0","layout":"flow","after":null}, {"op":"duplicate_slide","id":"s3","new_id":"s3b","new_block_ids":["t3b"]}]"#, ) .unwrap(); let v = view(&a); let ids: Vec<&str> = v["slides"] .as_array() .unwrap() .iter() .map(|s| s["id"].as_str().unwrap()) .collect(); assert_eq!(ids, vec!["s0", "s3", "s3b", "s4", "s1", "s2", "s5"]); // And removing one leaves the rest in order. apply_ops(&a, r#"[{"op":"remove_slide","id":"s3"}]"#).unwrap(); let v = view(&a); let ids: Vec<&str> = v["slides"] .as_array() .unwrap() .iter() .map(|s| s["id"].as_str().unwrap()) .collect(); assert_eq!(ids, vec!["s0", "s3b", "s4", "s1", "s2", "s5"]); }