| author | |
| committer | |
| log | c0d4cbf0d0de5d6a8f0db4455c2165bb952802af |
| tree | c050308b9d24958cb4d388fab9392f9aa2333158 |
| parent | 4cd338a8b4d4e4acf3fc1f4e48cf94ac7596e5ea |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Follow generated outline ancestry so readers retain split suffixes and nested children. Record ordered paragraph graphs with text observations and include removed identities in publication differences.
Validate every advertised graph and candidate delta against independent insertion intents; preserve older capture verification. Exercise split/join observation, child transfer and missing or invented structure. Affected example tests, 132 Python tests and workspace Clippy pass.
Assisted-by: gpt-6-astra4 files changed, 309 insertions(+), 33 deletions(-)
crates/onestore-offline/examples/smb_offline_client.rs+69-16| ... | @@ -20,9 +20,27 @@ use std::{ | ... | @@ -20,9 +20,27 @@ use std::{ |
| 20 | mod support { | 20 | mod support { |
| 21 | pub mod view; | 21 | pub mod view; |
| 22 | } | 22 | } |
| 23 | use concurrent::document_view; | 23 | use concurrent::{DocumentView, document_view}; |
| 24 | use support::view::view; | 24 | use support::view::view; |
| 25 | 25 | ||
| 26 | impl DocumentView { | ||
| 27 | fn changes(&self, before: &Self) -> Self { | ||
| 28 | let difference = | ||
| 29 | |old: &serde_json::Map<String, serde_json::Value>, | ||
| 30 | new: &serde_json::Map<String, serde_json::Value>| { | ||
| 31 | old.keys() | ||
| 32 | .chain(new.keys()) | ||
| 33 | .filter(|id| old.get(*id) != new.get(*id)) | ||
| 34 | .map(|id| (id.clone(), new.get(id).cloned().unwrap_or_default())) | ||
| 35 | .collect() | ||
| 36 | }; | ||
| 37 | Self { | ||
| 38 | texts: difference(&before.texts, &self.texts), | ||
| 39 | graph: difference(&before.graph, &self.graph), | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 26 | const DOCUMENT_OPERATIONS: [&str; 3] = ["insert", "format", "text"]; | 44 | const DOCUMENT_OPERATIONS: [&str; 3] = ["insert", "format", "text"]; |
| 27 | 45 | ||
| 28 | fn now() -> u128 { | 46 | fn now() -> u128 { |
| ... | @@ -40,7 +58,7 @@ enum Pause { | ... | @@ -40,7 +58,7 @@ enum Pause { |
| 40 | 58 | ||
| 41 | struct Traced { | 59 | struct Traced { |
| 42 | remote: SmbRemote, | 60 | remote: SmbRemote, |
| 43 | before: Option<(String, Option<serde_json::Value>)>, | 61 | before: Option<(String, Option<DocumentView>)>, |
| 44 | pause: Option<Pause>, | 62 | pause: Option<Pause>, |
| 45 | documents: bool, | 63 | documents: bool, |
| 46 | } | 64 | } |
| ... | @@ -57,7 +75,7 @@ impl Remote for Traced { | ... | @@ -57,7 +75,7 @@ impl Remote for Traced { |
| 57 | }; | 75 | }; |
| 58 | println!( | 76 | println!( |
| 59 | "{}", | 77 | "{}", |
| 60 | json!({"event":"read", "started_us":started, "finished_us":now(), "text":observed.text, "documents":documents}) | 78 | json!({"event":"read", "started_us":started, "finished_us":now(), "text":observed.text, "documents":documents.as_ref().map(|view| &view.texts), "document_graph":documents.as_ref().map(|view| &view.graph)}) |
| 61 | ); | 79 | ); |
| 62 | self.before = Some((observed.text, documents)); | 80 | self.before = Some((observed.text, documents)); |
| 63 | Ok(bytes) | 81 | Ok(bytes) |
| ... | @@ -84,15 +102,7 @@ impl Remote for Traced { | ... | @@ -84,15 +102,7 @@ impl Remote for Traced { |
| 84 | state: CommitState::NotCommitted, | 102 | state: CommitState::NotCommitted, |
| 85 | error: io::Error::other("Document publication has no observed source"), | 103 | error: io::Error::other("Document publication has no observed source"), |
| 86 | })?; | 104 | })?; |
| 87 | Some( | 105 | Some(after.changes(before)) |
| 88 | after | ||
| 89 | .as_object() | ||
| 90 | .unwrap() | ||
| 91 | .iter() | ||
| 92 | .filter(|(id, value)| before.get(*id) != Some(*value)) | ||
| 93 | .map(|(id, value)| (id.clone(), value.clone())) | ||
| 94 | .collect::<serde_json::Map<_, _>>(), | ||
| 95 | ) | ||
| 96 | } else { | 106 | } else { |
| 97 | None | 107 | None |
| 98 | }; | 108 | }; |
| ... | @@ -104,12 +114,14 @@ impl Remote for Traced { | ... | @@ -104,12 +114,14 @@ impl Remote for Traced { |
| 104 | )), | 114 | )), |
| 105 | Some(Pause::FormatReply(marker)) | 115 | Some(Pause::FormatReply(marker)) |
| 106 | if changes.as_ref().is_some_and(|changes| { | 116 | if changes.as_ref().is_some_and(|changes| { |
| 107 | changes.len() == 1 | 117 | changes.texts.len() == 1 |
| 108 | && self | 118 | && self |
| 109 | .before | 119 | .before |
| 110 | .as_ref() | 120 | .as_ref() |
| 111 | .and_then(|(_, before)| before.as_ref()) | 121 | .and_then(|(_, before)| before.as_ref()) |
| 112 | .is_some_and(|before| changes.keys().all(|id| before.get(id).is_some())) | 122 | .is_some_and(|before| { |
| 123 | changes.texts.keys().all(|id| before.texts.contains_key(id)) | ||
| 124 | }) | ||
| 113 | }) => | 125 | }) => |
| 114 | { | 126 | { |
| 115 | Some((marker, marker.with_extension("resume"), "format")) | 127 | Some((marker, marker.with_extension("resume"), "format")) |
| ... | @@ -149,7 +161,8 @@ impl Remote for Traced { | ... | @@ -149,7 +161,8 @@ impl Remote for Traced { |
| 149 | json!({"event":"remote_attempt", "started_us":started, "finished_us":finished, | 161 | json!({"event":"remote_attempt", "started_us":started, "finished_us":finished, |
| 150 | "revision":after.revision.to_string(), "space":after.space.to_string(), "object":after.object.to_string(), "before":self.before.as_ref().map(|(text,_)|text), "after":after.text, | 162 | "revision":after.revision.to_string(), "space":after.space.to_string(), "object":after.object.to_string(), "before":self.before.as_ref().map(|(text,_)|text), "after":after.text, |
| 151 | "state":format!("{:?}", result.as_ref().map_or_else(|error| error.state, |_| CommitState::Committed)), | 163 | "state":format!("{:?}", result.as_ref().map_or_else(|error| error.state, |_| CommitState::Committed)), |
| 152 | "documents":documents, "document_changes":changes}) | 164 | "documents":documents.as_ref().map(|view| &view.texts), "document_graph":documents.as_ref().map(|view| &view.graph), |
| 165 | "document_changes":changes.as_ref().map(|view| &view.texts), "document_graph_changes":changes.as_ref().map(|view| &view.graph)}) | ||
| 153 | ); | 166 | ); |
| 154 | if result | 167 | if result |
| 155 | .as_ref() | 168 | .as_ref() |
| ... | @@ -417,7 +430,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -417,7 +430,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 417 | let cache = Arc::new(Replica::create(&cache_path, &source)?); | 430 | let cache = Arc::new(Replica::create(&cache_path, &source)?); |
| 418 | println!( | 431 | println!( |
| 419 | "{}", | 432 | "{}", |
| 420 | json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents,"document_kinds":if documents {DOCUMENT_OPERATIONS.as_slice()} else {&[]}}) | 433 | json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents,"document_graph":documents,"document_kinds":if documents {DOCUMENT_OPERATIONS.as_slice()} else {&[]}}) |
| 421 | ); | 434 | ); |
| 422 | while !Path::new(&args[4]).exists() { | 435 | while !Path::new(&args[4]).exists() { |
| 423 | if Instant::now() >= deadline { | 436 | if Instant::now() >= deadline { |
| ... | @@ -615,3 +628,43 @@ fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() { | ... | @@ -615,3 +628,43 @@ fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() { |
| 615 | None | 628 | None |
| 616 | ); | 629 | ); |
| 617 | } | 630 | } |
| 631 | |||
| 632 | #[cfg(test)] | ||
| 633 | mod tests { | ||
| 634 | use super::*; | ||
| 635 | |||
| 636 | #[test] | ||
| 637 | fn publication_differences_retain_removed_text_and_graph_identities() { | ||
| 638 | let before = DocumentView { | ||
| 639 | texts: serde_json::from_value(json!({"left":{"text":"ab"}, "right":{"text":"cd"}, "untouched":{"text":"ef"}})).unwrap(), | ||
| 640 | graph: serde_json::from_value(json!({"outline":{"children":["a","b"]}, "a":{"content":["left"]}, "b":{"content":["right"]}})).unwrap(), | ||
| 641 | }; | ||
| 642 | let after = DocumentView { | ||
| 643 | texts: serde_json::from_value( | ||
| 644 | json!({"left":{"text":"abcd"}, "untouched":{"text":"ef"}}), | ||
| 645 | ) | ||
| 646 | .unwrap(), | ||
| 647 | graph: serde_json::from_value( | ||
| 648 | json!({"outline":{"children":["a"]}, "a":{"content":["left"]}}), | ||
| 649 | ) | ||
| 650 | .unwrap(), | ||
| 651 | }; | ||
| 652 | let changes = after.changes(&before); | ||
| 653 | assert_eq!( | ||
| 654 | changes.texts, | ||
| 655 | serde_json::from_value::<serde_json::Map<_, _>>( | ||
| 656 | json!({"left":{"text":"abcd"}, "right":null}) | ||
| 657 | ) | ||
| 658 | .unwrap() | ||
| 659 | ); | ||
| 660 | assert_eq!( | ||
| 661 | changes.graph, | ||
| 662 | serde_json::from_value::<serde_json::Map<_, _>>( | ||
| 663 | json!({"outline":{"children":["a"]}, "b":null}) | ||
| 664 | ) | ||
| 665 | .unwrap() | ||
| 666 | ); | ||
| 667 | assert_eq!(before.changes(&after).texts["right"], before.texts["right"]); | ||
| 668 | assert_eq!(before.changes(&before), DocumentView::default()); | ||
| 669 | } | ||
| 670 | } |
crates/onestore/examples/support/concurrent.rs+161-13| ... | @@ -11,25 +11,76 @@ use std::{ | ... | @@ -11,25 +11,76 @@ use std::{ |
| 11 | time::{Duration, Instant, SystemTime, UNIX_EPOCH}, | 11 | time::{Duration, Instant, SystemTime, UNIX_EPOCH}, |
| 12 | }; | 12 | }; |
| 13 | 13 | ||
| 14 | pub fn document_view(bytes: &[u8]) -> Result<serde_json::Value, onestore::Error> { | 14 | #[derive(Debug, Default, PartialEq)] |
| 15 | pub struct DocumentView { | ||
| 16 | pub texts: serde_json::Map<String, serde_json::Value>, | ||
| 17 | pub graph: serde_json::Map<String, serde_json::Value>, | ||
| 18 | } | ||
| 19 | |||
| 20 | pub fn document_view(bytes: &[u8]) -> Result<DocumentView, onestore::Error> { | ||
| 15 | let store = Store::parse(bytes)?; | 21 | let store = Store::parse(bytes)?; |
| 16 | let index = RevisionIndex::parse(&store)?; | 22 | let index = RevisionIndex::parse(&store)?; |
| 17 | index.validate_current()?; | 23 | index.validate_current()?; |
| 18 | let document = Document::parse(&index)?; | 24 | let document = Document::parse(&index)?; |
| 19 | let mut texts = serde_json::Map::new(); | 25 | let mut observed = DocumentView::default(); |
| 20 | for (sid, _) in document.pages()? { | 26 | for (sid, page) in document.pages()? { |
| 21 | let space = &document.spaces[&sid]; | 27 | let space = &document.spaces[&sid]; |
| 22 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | 28 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 23 | for (id, node) in &revision.nodes { | 29 | for outline in &revision.nodes[&page].children { |
| 24 | if let Kind::RichText { text, .. } = &node.kind | 30 | if !matches!(revision.nodes[outline].kind, Kind::Outline { .. }) { |
| 25 | && text.starts_with("Document w") | 31 | continue; |
| 26 | { | 32 | } |
| 27 | let runs=revision.text_runs(*id)?.into_iter().map(|run|json!({"text":run.text,"bold":run.format.bold.unwrap_or(false),"size":run.format.font_size,"color":run.format.color.unwrap_or(0xff000000)})).collect::<Vec<_>>(); | 33 | let mut pending = vec![(*outline, page)]; |
| 28 | texts.insert(id.to_string(), json!({"text":text,"runs":runs})); | 34 | let mut parents = std::collections::BTreeMap::new(); |
| 35 | while let Some((id, parent)) = pending.pop() { | ||
| 36 | if parents.insert(id, parent).is_some() { | ||
| 37 | return Err(onestore::Error { | ||
| 38 | offset: 0, | ||
| 39 | message: "Document observation contains a repeated object", | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | let node = &revision.nodes[&id]; | ||
| 43 | pending.extend( | ||
| 44 | node.children | ||
| 45 | .iter() | ||
| 46 | .chain(&node.content) | ||
| 47 | .map(|child| (*child, id)), | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | // The workload keeps its marker in the left paragraph through boundary edits. | ||
| 51 | if !parents.keys().any(|id| { | ||
| 52 | matches!(&revision.nodes[id].kind, | ||
| 53 | Kind::RichText { text, .. } if text.starts_with("Document w")) | ||
| 54 | }) { | ||
| 55 | continue; | ||
| 56 | } | ||
| 57 | for (id, parent) in parents { | ||
| 58 | let key = id.to_string(); | ||
| 59 | if observed.texts.contains_key(&key) || observed.graph.contains_key(&key) { | ||
| 60 | return Err(onestore::Error { | ||
| 61 | offset: 0, | ||
| 62 | message: "Document observation contains a repeated object", | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | let node = &revision.nodes[&id]; | ||
| 66 | if let Kind::RichText { text, .. } = &node.kind { | ||
| 67 | let runs = revision.text_runs(id)?.into_iter().map(|run| json!({ | ||
| 68 | "text": run.text, "bold": run.format.bold.unwrap_or(false), | ||
| 69 | "size": run.format.font_size, "color": run.format.color.unwrap_or(0xff000000) | ||
| 70 | })).collect::<Vec<_>>(); | ||
| 71 | observed.texts.insert(key, json!({"text":text,"runs":runs})); | ||
| 72 | } else { | ||
| 73 | observed.graph.insert(key, json!({ | ||
| 74 | "parent": parent.to_string(), "children": node.children.iter().map(ToString::to_string).collect::<Vec<_>>(), | ||
| 75 | "content": node.content.iter().map(ToString::to_string).collect::<Vec<_>>(), | ||
| 76 | "child_level": node.child_level, | ||
| 77 | "position": if id == *outline { Some(json!({"x":node.layout.x,"y":node.layout.y})) } else { None } | ||
| 78 | })); | ||
| 79 | } | ||
| 29 | } | 80 | } |
| 30 | } | 81 | } |
| 31 | } | 82 | } |
| 32 | Ok(texts.into()) | 83 | Ok(observed) |
| 33 | } | 84 | } |
| 34 | 85 | ||
| 35 | pub fn run( | 86 | pub fn run( |
| ... | @@ -70,14 +121,16 @@ pub fn run( | ... | @@ -70,14 +121,16 @@ pub fn run( |
| 70 | writeln!(output, "{event}")?; | 121 | writeln!(output, "{event}")?; |
| 71 | output.flush() | 122 | output.flush() |
| 72 | }; | 123 | }; |
| 73 | log(json!({"event": "ready", "pid": std::process::id(), "actor": args[2]}))?; | 124 | let documents = std::env::var_os("ONESTORE_OFFLINE_DOCUMENTS").is_some(); |
| 125 | log( | ||
| 126 | json!({"event": "ready", "pid": std::process::id(), "actor": args[2], "document_graph": documents}), | ||
| 127 | )?; | ||
| 74 | while !Path::new(&args[4]).exists() { | 128 | while !Path::new(&args[4]).exists() { |
| 75 | if Instant::now() > deadline { | 129 | if Instant::now() > deadline { |
| 76 | return Err("Start barrier timed out.".into()); | 130 | return Err("Start barrier timed out.".into()); |
| 77 | } | 131 | } |
| 78 | thread::sleep(Duration::from_millis(5)); | 132 | thread::sleep(Duration::from_millis(5)); |
| 79 | } | 133 | } |
| 80 | let documents = std::env::var_os("ONESTORE_OFFLINE_DOCUMENTS").is_some(); | ||
| 81 | let maintenance = std::env::var_os("ONESTORE_MAINTENANCE_DIR").map(std::path::PathBuf::from); | 134 | let maintenance = std::env::var_os("ONESTORE_MAINTENANCE_DIR").map(std::path::PathBuf::from); |
| 82 | let mut completed = 0; | 135 | let mut completed = 0; |
| 83 | let mut attempts = 0; | 136 | let mut attempts = 0; |
| ... | @@ -187,10 +240,15 @@ pub fn run( | ... | @@ -187,10 +240,15 @@ pub fn run( |
| 187 | }) | 240 | }) |
| 188 | .into()); | 241 | .into()); |
| 189 | }; | 242 | }; |
| 243 | let observed = if documents { | ||
| 244 | Some(document_view(&source).map_err(preserve)?) | ||
| 245 | } else { | ||
| 246 | None | ||
| 247 | }; | ||
| 190 | let read_finished = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros(); | 248 | let read_finished = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros(); |
| 191 | log( | 249 | log( |
| 192 | json!({"event": "read", "attempt": attempts, "started_us": started, "finished_us": read_finished, | 250 | json!({"event": "read", "attempt": attempts, "started_us": started, "finished_us": read_finished, |
| 193 | "transaction": store.header.transaction_count, "text": text, "documents":if documents {Some(document_view(&source).map_err(preserve)?)}else{None}}), | 251 | "transaction": store.header.transaction_count, "text": text, "documents":observed.as_ref().map(|view| &view.texts), "document_graph":observed.as_ref().map(|view| &view.graph)}), |
| 194 | )?; | 252 | )?; |
| 195 | if args[0] == "read" { | 253 | if args[0] == "read" { |
| 196 | completed += 1; | 254 | completed += 1; |
| ... | @@ -256,3 +314,93 @@ pub fn run( | ... | @@ -256,3 +314,93 @@ pub fn run( |
| 256 | log(json!({"event": "done", "completed": completed, "attempts": attempts}))?; | 314 | log(json!({"event": "done", "completed": completed, "attempts": attempts}))?; |
| 257 | Ok(()) | 315 | Ok(()) |
| 258 | } | 316 | } |
| 317 | |||
| 318 | #[cfg(test)] | ||
| 319 | mod tests { | ||
| 320 | use super::*; | ||
| 321 | use onestore::{Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, TextAttribute}; | ||
| 322 | |||
| 323 | #[test] | ||
| 324 | fn observation_follows_split_suffixes_and_moved_children_through_active_ancestry() { | ||
| 325 | let source = onestore::create_section("observation.one", "Original", "Author").unwrap(); | ||
| 326 | assert_eq!(document_view(&source).unwrap(), DocumentView::default()); | ||
| 327 | let store = Store::parse(&source).unwrap(); | ||
| 328 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 329 | let document = Document::parse(&index).unwrap(); | ||
| 330 | let (space, page) = document.pages().unwrap()[0]; | ||
| 331 | let insertion = Insertion::outline(page, 144.0, 216.0, "Document w0:0 🦀", "Author") | ||
| 332 | .unwrap() | ||
| 333 | .with_formatting(0..16, &[TextAttribute::Bold(true)]) | ||
| 334 | .unwrap(); | ||
| 335 | let inserted = PreparedEdit::insert(&source, space, &insertion).unwrap(); | ||
| 336 | let before = document_view(inserted.as_bytes()).unwrap(); | ||
| 337 | assert_eq!(before.texts.len(), 1); | ||
| 338 | assert_eq!(before.graph.len(), 2); | ||
| 339 | let outline = insertion.object().to_string(); | ||
| 340 | let paragraph = before.graph[&outline]["children"][0] | ||
| 341 | .as_str() | ||
| 342 | .unwrap() | ||
| 343 | .to_owned(); | ||
| 344 | let child = | ||
| 345 | Insertion::paragraph(paragraph.parse().unwrap(), None, "Unmarked child", "Author") | ||
| 346 | .unwrap(); | ||
| 347 | let with_child = PreparedEdit::insert(inserted.as_bytes(), space, &child).unwrap(); | ||
| 348 | let before = document_view(with_child.as_bytes()).unwrap(); | ||
| 349 | assert_eq!(before.texts.len(), 2); | ||
| 350 | assert_eq!(before.graph.len(), 3); | ||
| 351 | assert_eq!( | ||
| 352 | before.graph[&outline], | ||
| 353 | json!({"parent":page.to_string(), "children":[paragraph], "content":[], "child_level":1, "position":{"x":144.0,"y":216.0}}) | ||
| 354 | ); | ||
| 355 | for offset in [14, 16] { | ||
| 356 | let split = ParagraphSplit::new(insertion.text_object(), offset, "Author").unwrap(); | ||
| 357 | let split_edit = PreparedEdit::split(with_child.as_bytes(), space, &split).unwrap(); | ||
| 358 | let observed = document_view(split_edit.as_bytes()).unwrap(); | ||
| 359 | assert_eq!(observed.texts.len(), 3); | ||
| 360 | assert_eq!(observed.graph.len(), 4); | ||
| 361 | assert_eq!( | ||
| 362 | observed.texts[&split.text_object().to_string()]["text"], | ||
| 363 | if offset == 14 { "🦀" } else { "" } | ||
| 364 | ); | ||
| 365 | assert_eq!( | ||
| 366 | observed.graph[&outline]["children"], | ||
| 367 | json!([paragraph, split.object().to_string()]) | ||
| 368 | ); | ||
| 369 | assert_eq!(observed.graph[&paragraph]["children"], json!([])); | ||
| 370 | assert_eq!( | ||
| 371 | observed.graph[&split.object().to_string()]["children"], | ||
| 372 | json!([child.object().to_string()]) | ||
| 373 | ); | ||
| 374 | assert_eq!( | ||
| 375 | observed.graph[&child.object().to_string()]["parent"], | ||
| 376 | split.object().to_string() | ||
| 377 | ); | ||
| 378 | let join = | ||
| 379 | ParagraphJoin::new(insertion.text_object(), split.text_object(), "Author").unwrap(); | ||
| 380 | let joined = PreparedEdit::join(split_edit.as_bytes(), space, &join).unwrap(); | ||
| 381 | let joined = document_view(joined.as_bytes()).unwrap(); | ||
| 382 | assert_eq!(joined.graph, before.graph); | ||
| 383 | let characters = |view: &DocumentView| { | ||
| 384 | view.texts | ||
| 385 | .iter() | ||
| 386 | .map(|(id, text)| { | ||
| 387 | let runs = text["runs"] | ||
| 388 | .as_array() | ||
| 389 | .unwrap() | ||
| 390 | .iter() | ||
| 391 | .flat_map(|run| { | ||
| 392 | run["text"] | ||
| 393 | .as_str() | ||
| 394 | .unwrap() | ||
| 395 | .chars() | ||
| 396 | .map(|c| json!([c, run["bold"], run["size"], run["color"]])) | ||
| 397 | }) | ||
| 398 | .collect::<Vec<_>>(); | ||
| 399 | (id.clone(), (text["text"].clone(), runs)) | ||
| 400 | }) | ||
| 401 | .collect::<std::collections::BTreeMap<_, _>>() | ||
| 402 | }; | ||
| 403 | assert_eq!(characters(&joined), characters(&before)); | ||
| 404 | } | ||
| 405 | } | ||
| 406 | } |
tools/offline_document_history.py+34| ... | @@ -114,6 +114,40 @@ def document_history(logs, operations): | ... | @@ -114,6 +114,40 @@ def document_history(logs, operations): |
| 114 | states['text'] = {'characters': final, 'attempt': attempt} | 114 | states['text'] = {'characters': final, 'attempt': attempt} |
| 115 | documents[target] = {'insertion': insertion, 'space': inserted['space'], 'states': states} | 115 | documents[target] = {'insertion': insertion, 'space': inserted['space'], 'states': states} |
| 116 | assert documents, 'No document operations were recorded' | 116 | assert documents, 'No document operations were recorded' |
| 117 | if any(events[0].get('document_graph') for events in logs.values()): | ||
| 118 | for events in logs.values(): | ||
| 119 | assert events[0].get('document_graph') is True, 'Client omitted structural observations' | ||
| 120 | before = None | ||
| 121 | for row in events: | ||
| 122 | if row['event'] not in ('read', 'document_read', 'remote_attempt'): continue | ||
| 123 | observed = row.get('documents') | ||
| 124 | assert isinstance(observed, dict), 'Snapshot omitted document text' | ||
| 125 | graph = row.get('document_graph') | ||
| 126 | assert isinstance(graph, dict), 'Snapshot omitted document graph' | ||
| 127 | expected = {} | ||
| 128 | for target, document in documents.items(): | ||
| 129 | if target not in observed: continue | ||
| 130 | insertion = document['insertion'] | ||
| 131 | outline = insertion['parent'] | ||
| 132 | paragraph = identity(insertion, 1) | ||
| 133 | if 'Outline' in insertion['placement']: | ||
| 134 | outline = paragraph | ||
| 135 | paragraph = identity(insertion, 3) | ||
| 136 | expected[outline] = {'parent': insertion['parent'], 'children': [], 'content': [], | ||
| 137 | 'child_level': 1, 'position': insertion['placement']['Outline']} | ||
| 138 | assert outline in expected, 'Snapshot omitted the inserted outline' | ||
| 139 | expected[outline]['children'].append(paragraph) | ||
| 140 | expected[paragraph] = {'parent': outline, 'children': [], 'content': [target], | ||
| 141 | 'child_level': 1, 'position': None} | ||
| 142 | assert graph == expected, 'Snapshot contains partial, reordered or invented paragraph structure' | ||
| 143 | if row['event'] == 'remote_attempt': | ||
| 144 | assert before is not None, 'Publication omitted its observed source' | ||
| 145 | for image, delta in [('documents', 'document_changes'), ('document_graph', 'document_graph_changes')]: | ||
| 146 | expected_delta = {key: row[image].get(key) for key in before[image].keys() | row[image].keys() | ||
| 147 | if before[image].get(key) != row[image].get(key)} | ||
| 148 | assert row.get(delta) == expected_delta, 'Publication diff omitted or invented a changed object' | ||
| 149 | else: | ||
| 150 | before = row | ||
| 117 | for actor, events in logs.items(): | 151 | for actor, events in logs.items(): |
| 118 | previous = {} | 152 | previous = {} |
| 119 | reads = [row for row in events if row['event'] in ('read', 'document_read') and row.get('documents') is not None] | 153 | reads = [row for row in events if row['event'] in ('read', 'document_read') and row.get('documents') is not None] |
tools/test_offline_document_history.py+45-4| ... | @@ -6,9 +6,11 @@ from unittest.mock import patch | ... | @@ -6,9 +6,11 @@ from unittest.mock import patch |
| 6 | from offline_document_history import document_history, identity, verify_model, verify_native | 6 | from offline_document_history import document_history, identity, verify_model, verify_native |
| 7 | 7 | ||
| 8 | 8 | ||
| 9 | def history(text_edits=False): | 9 | def history(text_edits=False, graph=False): |
| 10 | logs = {'w0': [{'event': 'ready', 'document_operations': True, 'document_kinds': ['insert', 'format', 'text'] if text_edits else ['insert', 'format']}]} | 10 | logs = {'w0': [{'event': 'ready', 'document_operations': True, 'document_kinds': ['insert', 'format', 'text'] if text_edits else ['insert', 'format']}]} |
| 11 | events = logs['w0'] | 11 | events = logs['w0'] |
| 12 | if graph: events[0]['document_graph'] = True | ||
| 13 | structure = {} | ||
| 12 | observed = {} | 14 | observed = {} |
| 13 | previous = None | 15 | previous = None |
| 14 | for operation in range(2): | 16 | for operation in range(2): |
| ... | @@ -29,6 +31,19 @@ def history(text_edits=False): | ... | @@ -29,6 +31,19 @@ def history(text_edits=False): |
| 29 | end = len(insertion['text'].encode('utf-16-le'))//2 | 31 | end = len(insertion['text'].encode('utf-16-le'))//2 |
| 30 | event.update(range=[end-3, end], replacement=' e\u0301🐈') | 32 | event.update(range=[end-3, end], replacement=' e\u0301🐈') |
| 31 | events.append(event) | 33 | events.append(event) |
| 34 | prior_structure = copy.deepcopy(structure) | ||
| 35 | if graph: | ||
| 36 | events.append({'event': 'read', 'started_us': timestamp-1, 'finished_us': timestamp, | ||
| 37 | 'documents': copy.deepcopy(observed), 'document_graph': prior_structure}) | ||
| 38 | if step == 0: | ||
| 39 | parent, paragraph = insertion['parent'], identity(insertion, 1) | ||
| 40 | if operation == 0: | ||
| 41 | parent, paragraph = paragraph, identity(insertion, 3) | ||
| 42 | structure[parent] = {'parent': insertion['parent'], 'children': [], 'content': [], | ||
| 43 | 'child_level': 1, 'position': {'x': 144, 'y': 144}} | ||
| 44 | structure[parent]['children'].append(paragraph) | ||
| 45 | structure[paragraph] = {'parent': parent, 'children': [], 'content': [target], | ||
| 46 | 'child_level': 1, 'position': None} | ||
| 32 | value = insertion['text'].replace(' 🦀', ' e\u0301🐈') if kind == 'text' else insertion['text'] | 47 | value = insertion['text'].replace(' 🦀', ' e\u0301🐈') if kind == 'text' else insertion['text'] |
| 33 | runs = [] | 48 | runs = [] |
| 34 | for index, char in enumerate(value): | 49 | for index, char in enumerate(value): |
| ... | @@ -40,14 +55,17 @@ def history(text_edits=False): | ... | @@ -40,14 +55,17 @@ def history(text_edits=False): |
| 40 | events.append({'event': 'remote_attempt', 'revision': revision, 'state': 'Committed', | 55 | events.append({'event': 'remote_attempt', 'revision': revision, 'state': 'Committed', |
| 41 | 'document_changes': {target: copy.deepcopy(observed[target])}, | 56 | 'document_changes': {target: copy.deepcopy(observed[target])}, |
| 42 | 'documents': copy.deepcopy(observed), 'started_us': timestamp, 'finished_us': timestamp+1}) | 57 | 'documents': copy.deepcopy(observed), 'started_us': timestamp, 'finished_us': timestamp+1}) |
| 58 | if graph: | ||
| 59 | events[-1].update(document_graph=copy.deepcopy(structure), document_graph_changes={ | ||
| 60 | key: copy.deepcopy(value) for key, value in structure.items() if prior_structure.get(key) != value}) | ||
| 43 | events.append({'event': 'document_receipt', 'id': local_id, 'revision': revision, 'at_us': timestamp+2}) | 61 | events.append({'event': 'document_receipt', 'id': local_id, 'revision': revision, 'at_us': timestamp+2}) |
| 44 | if text_edits: | 62 | if text_edits: |
| 45 | events.append({'event': 'read', 'started_us': timestamp+3, 'finished_us': timestamp+4, 'documents': copy.deepcopy(observed)}) | 63 | events.append({'event': 'read', 'started_us': timestamp+3, 'finished_us': timestamp+4, 'documents': copy.deepcopy(observed), **({'document_graph': copy.deepcopy(structure)} if graph else {})}) |
| 46 | events.extend({'event': 'reopened_document_receipt', 'id': row['id'], 'revision': row['revision']} | 64 | events.extend({'event': 'reopened_document_receipt', 'id': row['id'], 'revision': row['revision']} |
| 47 | for row in list(events) if row['event'] == 'document_receipt') | 65 | for row in list(events) if row['event'] == 'document_receipt') |
| 48 | read = {'event': 'read', 'started_us': 100, 'finished_us': 101, 'documents': observed} | 66 | read = {'event': 'read', 'started_us': 100, 'finished_us': 101, 'documents': observed, **({'document_graph': structure} if graph else {})} |
| 49 | events.extend([read, {'event': 'done'}]) | 67 | events.extend([read, {'event': 'done'}]) |
| 50 | logs['r0'] = [{'event': 'ready'}, copy.deepcopy(read), {'event': 'done'}] | 68 | logs['r0'] = [{'event': 'ready', **({'document_graph': True} if graph else {})}, copy.deepcopy(read), {'event': 'done'}] |
| 51 | return logs | 69 | return logs |
| 52 | 70 | ||
| 53 | 71 | ||
| ... | @@ -65,6 +83,29 @@ class DocumentHistoryTests(unittest.TestCase): | ... | @@ -65,6 +83,29 @@ class DocumentHistoryTests(unittest.TestCase): |
| 65 | paragraphs[0][1][1]['font_size'] = 19 | 83 | paragraphs[0][1][1]['font_size'] = 19 |
| 66 | with self.assertRaisesRegex(AssertionError, 'font size'): verify_native(paragraphs, expected) | 84 | with self.assertRaisesRegex(AssertionError, 'font size'): verify_native(paragraphs, expected) |
| 67 | 85 | ||
| 86 | def test_structural_snapshots_and_publication_differences_are_complete(self): | ||
| 87 | baseline = history(text_edits=True, graph=True) | ||
| 88 | self.assertEqual(len(document_history(baseline, 2)), 2) | ||
| 89 | for mutation in ('missing-graph', 'missing-client', 'reorder', 'duplicate', 'reparent', | ||
| 90 | 'missing-content', 'invented', 'level', 'position', 'delta'): | ||
| 91 | logs = copy.deepcopy(baseline) | ||
| 92 | row = logs['r0'][1] | ||
| 93 | outline, first, second = list(row['document_graph']) | ||
| 94 | graph = row['document_graph'] | ||
| 95 | if mutation == 'missing-graph': del row['document_graph'] | ||
| 96 | elif mutation == 'missing-client': del logs['r0'][0]['document_graph'] | ||
| 97 | elif mutation == 'reorder': graph[outline]['children'].reverse() | ||
| 98 | elif mutation == 'duplicate': graph[outline]['children'].append(first) | ||
| 99 | elif mutation == 'reparent': graph[second]['parent'] = first | ||
| 100 | elif mutation == 'missing-content': graph[first]['content'] = [] | ||
| 101 | elif mutation == 'invented': graph['unrecorded'] = copy.deepcopy(graph[first]) | ||
| 102 | elif mutation == 'level': graph[first]['child_level'] = 2 | ||
| 103 | elif mutation == 'position': graph[outline]['position']['x'] = 145 | ||
| 104 | else: | ||
| 105 | next(row for row in logs['w0'] if row['event'] == 'remote_attempt')['document_graph_changes'] = {} | ||
| 106 | with self.subTest(mutation=mutation), self.assertRaises(AssertionError): | ||
| 107 | document_history(logs, 2) | ||
| 108 | |||
| 68 | def test_cross_run_text_requires_complete_ordered_states_and_exact_receipts(self): | 109 | def test_cross_run_text_requires_complete_ordered_states_and_exact_receipts(self): |
| 69 | logs = history(True) | 110 | logs = history(True) |
| 70 | documents = document_history(logs, 2) | 111 | documents = document_history(logs, 2) |