| 1 | //! Paragraph splits and joins expressed as page-model differences: reconciliation against |
| 2 | //! native OneNote captures, dependent edits, uncertain attempts and competing clients. |
| 3 | |
| 4 | #[path = "../../onestore/tests/support/ops.rs"] |
| 5 | mod ops; |
| 6 | |
| 7 | use notebook::{EditStatus, Replica}; |
| 8 | use onestore::{ |
| 9 | ExGuid, RevisionIndex, Store, |
| 10 | document::Document, |
| 11 | page::{Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, |
| 12 | }; |
| 13 | use std::path::Path; |
| 14 | |
| 15 | #[path = "support/server.rs"] |
| 16 | mod server; |
| 17 | use server::{Fault, Server, conflicts, pages, snapshot, typed}; |
| 18 | #[path = "support/model_ops.rs"] |
| 19 | mod model_ops; |
| 20 | use model_ops::{AUTHOR, outlines_mut, page_of, replace_text}; |
| 21 | |
| 22 | const BEFORE: &[u8] = |
| 23 | include_bytes!("../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one"); |
| 24 | const KEYBOARD: &[u8] = |
| 25 | include_bytes!("../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one"); |
| 26 | const REPLACED: &[u8] = |
| 27 | include_bytes!("../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one"); |
| 28 | const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/paragraph-edit"); |
| 29 | |
| 30 | fn at(list: &[PageParagraph], text: ExGuid) -> Option<usize> { |
| 31 | list.iter() |
| 32 | .position(|p| p.text().is_some_and(|t| t.id == text)) |
| 33 | } |
| 34 | |
| 35 | /// Applies `edit` to the paragraph list holding `text`, descending into table cells. |
| 36 | fn in_list<T>( |
| 37 | list: &mut Vec<PageParagraph>, |
| 38 | text: ExGuid, |
| 39 | edit: &mut impl FnMut(&mut Vec<PageParagraph>, usize) -> T, |
| 40 | ) -> Option<T> { |
| 41 | if let Some(index) = at(list, text) { |
| 42 | return Some(edit(list, index)); |
| 43 | } |
| 44 | for paragraph in list { |
| 45 | if let ParagraphContent::Table(table) = &mut paragraph.content { |
| 46 | for row in &mut table.rows { |
| 47 | for cell in &mut row.cells { |
| 48 | if let Some(value) = in_list(&mut cell.paragraphs, text, edit) { |
| 49 | return Some(value); |
| 50 | } |
| 51 | } |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | None |
| 56 | } |
| 57 | |
| 58 | fn locate_in<T>( |
| 59 | page: &mut Page, |
| 60 | text: ExGuid, |
| 61 | mut edit: impl FnMut(&mut Vec<PageParagraph>, usize) -> T, |
| 62 | ) -> T { |
| 63 | for outline in outlines_mut(page) { |
| 64 | if let Some(value) = in_list(&mut outline.paragraphs, text, &mut edit) { |
| 65 | return value; |
| 66 | } |
| 67 | } |
| 68 | panic!("the edited paragraph is on the page"); |
| 69 | } |
| 70 | |
| 71 | /// Splits the paragraph carrying `text` at a UTF-16 offset: the text tail and the children |
| 72 | /// move to a new following paragraph, as pressing Enter mid-paragraph does. |
| 73 | fn split_paragraph(page: &mut Page, text: ExGuid, offset: u32) { |
| 74 | locate_in(page, text, |list, index| { |
| 75 | // One identity per split, numbered as OneNote numbers the paragraph it creates. |
| 76 | // A GUID of the split's own: `new_id` shares its GUID across identities. |
| 77 | let ExGuid { mut guid, n } = new_id().unwrap(); |
| 78 | guid[14] ^= n as u8; |
| 79 | guid[15] ^= 0xff; |
| 80 | let mut right = list[index].clone(); |
| 81 | right.id = ExGuid { guid, n: 1 }; |
| 82 | right.tags.clear(); |
| 83 | let source = list[index].text().unwrap().text.clone(); |
| 84 | let end = source.utf16_offset(source.text().len()).unwrap(); |
| 85 | list[index].text_mut().unwrap().text = source.slice(0..offset).unwrap(); |
| 86 | right.content = ParagraphContent::Text(TextObject { |
| 87 | id: ExGuid { guid, n: 2 }, |
| 88 | date_field: None, |
| 89 | text: source.slice(offset..end).unwrap(), |
| 90 | tags: Vec::new(), |
| 91 | }); |
| 92 | let (left, tail) = (list[index].id, right.id); |
| 93 | for paragraph in &mut list[index + 1..] { |
| 94 | if paragraph.parent == Some(left) { |
| 95 | paragraph.parent = Some(tail); |
| 96 | } |
| 97 | } |
| 98 | list.insert(index + 1, right); |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | /// Joins the paragraph carrying `right` into the one carrying `left`; an empty left text |
| 103 | /// object is replaced by a non-empty right one, which is the identity OneNote keeps. The |
| 104 | /// right paragraph's children keep their depth, under the left paragraph or the nearest of |
| 105 | /// its ancestors above them. |
| 106 | fn join_paragraphs(page: &mut Page, left: ExGuid, right: ExGuid) { |
| 107 | locate_in(page, left, |list, first| { |
| 108 | let second = at(list, right).expect("the joined paragraphs share a container"); |
| 109 | let (parent, orphaned, removed) = ( |
| 110 | list[first].id, |
| 111 | list[second].id, |
| 112 | list[second].text().unwrap().clone(), |
| 113 | ); |
| 114 | let adopts = !removed.text.text().is_empty(); |
| 115 | let target = list[first].text_mut().unwrap(); |
| 116 | let mut joined = target.text.clone(); |
| 117 | joined.append(removed.text).unwrap(); |
| 118 | if adopts && target.text.text().is_empty() { |
| 119 | target.id = removed.id; |
| 120 | } |
| 121 | target.text = joined; |
| 122 | list.remove(second); |
| 123 | let nesting: std::collections::BTreeMap<_, _> = |
| 124 | list.iter().map(|p| (p.id, (p.level, p.parent))).collect(); |
| 125 | for paragraph in list { |
| 126 | if paragraph.parent == Some(orphaned) { |
| 127 | let mut adopter = Some(parent); |
| 128 | while let Some(id) = adopter |
| 129 | && nesting[&id].0 >= paragraph.level |
| 130 | { |
| 131 | adopter = nesting[&id].1; |
| 132 | } |
| 133 | paragraph.parent = adopter; |
| 134 | } |
| 135 | } |
| 136 | }); |
| 137 | } |
| 138 | |
| 139 | /// The paragraphs of a page's single body outline. |
| 140 | fn body(page: &Page) -> &[PageParagraph] { |
| 141 | page.objects |
| 142 | .iter() |
| 143 | .find_map(|object| match object { |
| 144 | PageObject::Outline(outline) => Some(&outline.paragraphs[..]), |
| 145 | _ => None, |
| 146 | }) |
| 147 | .unwrap() |
| 148 | } |
| 149 | |
| 150 | fn texts(page: &Page) -> Vec<String> { |
| 151 | body(page) |
| 152 | .iter() |
| 153 | .filter_map(|p| p.text().map(|t| t.text.text().to_owned())) |
| 154 | .collect() |
| 155 | } |
| 156 | |
| 157 | /// The page space and model holding a text object anywhere on a page, table cells included. |
| 158 | fn holder(bytes: &[u8], text: ExGuid) -> (ExGuid, Page) { |
| 159 | let store = Store::parse(bytes).unwrap(); |
| 160 | let index = RevisionIndex::parse(&store).unwrap(); |
| 161 | let document = Document::parse(&index).unwrap(); |
| 162 | document |
| 163 | .pages() |
| 164 | .unwrap() |
| 165 | .into_iter() |
| 166 | .find_map(|(space, _)| { |
| 167 | let mut page = Page::from_space(&document, space).ok()?; |
| 168 | let found = outlines_mut(&mut page) |
| 169 | .into_iter() |
| 170 | .any(|outline| in_list(&mut outline.paragraphs, text, &mut |_, _| ()).is_some()); |
| 171 | found.then_some((space, page)) |
| 172 | }) |
| 173 | .expect("text object belongs to an active page") |
| 174 | } |
| 175 | |
| 176 | fn save_page( |
| 177 | cache: &Replica, |
| 178 | text: ExGuid, |
| 179 | edit: impl FnOnce(&mut Page), |
| 180 | ) -> Result<Option<u64>, notebook::Error> { |
| 181 | let source = snapshot(cache); |
| 182 | let (space, mut page) = holder(&source, text); |
| 183 | edit(&mut page); |
| 184 | model_ops::save_as(cache, space, &page, AUTHOR) |
| 185 | } |
| 186 | |
| 187 | /// Makes `edit` again on the page as the remote's version left it, as a writer does after |
| 188 | /// a conflict page kept the first try. |
| 189 | fn redo_page(cache: &Replica, text: ExGuid, edit: impl FnOnce(&mut Page)) { |
| 190 | save_page(cache, text, edit).unwrap().unwrap(); |
| 191 | } |
| 192 | |
| 193 | /// The conflict pages `image` lists under page `space`. |
| 194 | fn conflict_pages(image: &[u8], space: ExGuid) -> usize { |
| 195 | conflicts(image) |
| 196 | .into_iter() |
| 197 | .filter(|(page, _)| *page == space) |
| 198 | .map(|(_, pages)| pages.len()) |
| 199 | .sum() |
| 200 | } |
| 201 | |
| 202 | /// The styled text of an object anywhere on a page. |
| 203 | fn content(page: &Page, text: ExGuid) -> onestore::page::Paragraph { |
| 204 | locate_in(&mut page.clone(), text, |list, index| { |
| 205 | list[index].text().unwrap().text.clone() |
| 206 | }) |
| 207 | } |
| 208 | |
| 209 | /// Rewrites one character of a text object, the dependent edit the native lane checks. |
| 210 | fn dependent_edit(page: &mut Page, text: ExGuid) { |
| 211 | let content = content(page, text); |
| 212 | match content.text().find('c') { |
| 213 | Some(byte) => { |
| 214 | let start = content.utf16_offset(byte).unwrap(); |
| 215 | replace_text(page, text, start..start + 1, "C"); |
| 216 | } |
| 217 | None => replace_text(page, text, 1..2, "I"), |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | /// The reconciliation fixture's split and join controls, in page order. |
| 222 | fn controls() -> Vec<(ExGuid, String, bool)> { |
| 223 | let store = Store::parse(BEFORE).unwrap(); |
| 224 | let index = RevisionIndex::parse(&store).unwrap(); |
| 225 | let document = Document::parse(&index).unwrap(); |
| 226 | document |
| 227 | .pages() |
| 228 | .unwrap() |
| 229 | .into_iter() |
| 230 | .filter_map(|(space, _)| { |
| 231 | let title = Page::from_space(&document, space).unwrap().title; |
| 232 | let split = title.starts_with("Split "); |
| 233 | (split || title.starts_with("Join ")).then_some((space, title, split)) |
| 234 | }) |
| 235 | .collect() |
| 236 | } |
| 237 | |
| 238 | /// Publishes every control's split or join against the native keyboard capture, reviewing |
| 239 | /// whatever conflicts, then a dependent edit of the resulting paragraph. Returns the |
| 240 | /// reconciled remote image and one manifest entry per control. |
| 241 | fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) { |
| 242 | let mut server = Server::new(KEYBOARD); |
| 243 | let mut recorded = Vec::new(); |
| 244 | let mut automatic = 0; |
| 245 | for (space, name, split) in controls() { |
| 246 | let original = page_of(BEFORE, space); |
| 247 | let left = body(&original)[0].text().unwrap().id; |
| 248 | let right = (!split).then(|| body(&original)[1].text().unwrap().id); |
| 249 | let apply = |page: &mut Page| match right { |
| 250 | Some(right) => join_paragraphs(page, left, right), |
| 251 | None => split_paragraph(page, left, 2), |
| 252 | }; |
| 253 | let directory = tempfile::tempdir().unwrap(); |
| 254 | let path = directory.path().join("reconciliation.sqlite"); |
| 255 | let mut cache = Replica::create(&path, BEFORE).unwrap(); |
| 256 | let mut after = original.clone(); |
| 257 | apply(&mut after); |
| 258 | let id = model_ops::save_as(&cache, space, &after, AUTHOR) |
| 259 | .unwrap() |
| 260 | .unwrap(); |
| 261 | let pending = cache.pending().unwrap(); |
| 262 | drop(cache); |
| 263 | cache = Replica::open(&path).unwrap(); |
| 264 | assert_eq!(cache.pending().unwrap(), pending); |
| 265 | let kept = conflict_pages(&server.visible, space); |
| 266 | let (_, status) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 267 | assert!(matches!( |
| 268 | cache.status(id).unwrap(), |
| 269 | Some(EditStatus::Published { .. }) |
| 270 | )); |
| 271 | let reviewed = conflict_pages(&server.visible, space) > kept; |
| 272 | let receipt = if reviewed { |
| 273 | redo_page(&cache, left, apply); |
| 274 | cache.sync_once(&mut server).unwrap().edit.unwrap().1 |
| 275 | } else { |
| 276 | automatic += 1; |
| 277 | status |
| 278 | }; |
| 279 | let EditStatus::Published { revision } = receipt else { |
| 280 | panic!("{name}: {receipt:?}") |
| 281 | }; |
| 282 | let published = page_of(&server.visible, space); |
| 283 | let index = body(&published) |
| 284 | .iter() |
| 285 | .position(|p| { |
| 286 | p.text() |
| 287 | .is_some_and(|t| t.id == left || Some(t.id) == right) |
| 288 | }) |
| 289 | .unwrap(); |
| 290 | let intent = match right { |
| 291 | Some(right) => serde_json::json!({"author": AUTHOR, "left": left, "right": right}), |
| 292 | None => { |
| 293 | // The native lane derives both new identities from the guid recorded here. |
| 294 | let tail = &body(&published)[index + 1]; |
| 295 | let guid = tail.id.guid; |
| 296 | assert_eq!(tail.id, ExGuid { guid, n: 1 }, "{name}"); |
| 297 | assert_eq!(tail.text().unwrap().id, ExGuid { guid, n: 2 }, "{name}"); |
| 298 | serde_json::json!({"author": AUTHOR, "offset": 2, "text": left, "guid": guid}) |
| 299 | } |
| 300 | }; |
| 301 | let target = body(&published)[if split { index + 1 } else { index }] |
| 302 | .text() |
| 303 | .unwrap() |
| 304 | .id; |
| 305 | drop(cache); |
| 306 | cache = Replica::open(&path).unwrap(); |
| 307 | let dependent = save_page(&cache, target, |page| dependent_edit(page, target)) |
| 308 | .unwrap() |
| 309 | .unwrap(); |
| 310 | let (actual, status) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 311 | assert_eq!(actual, dependent); |
| 312 | let EditStatus::Published { |
| 313 | revision: dependent_revision, |
| 314 | } = status |
| 315 | else { |
| 316 | panic!("{name} dependent: {status:?}") |
| 317 | }; |
| 318 | assert!(cache.pending().unwrap().is_empty()); |
| 319 | assert_eq!(snapshot(&cache), server.durable); |
| 320 | recorded.push( |
| 321 | serde_json::json!({"case": name, "space": space, "intent": intent, |
| 322 | "outcome": if reviewed { "reviewed" } else { "automatic" }, |
| 323 | "receipt": revision, "dependent_receipt": dependent_revision}), |
| 324 | ); |
| 325 | } |
| 326 | assert_eq!((recorded.len(), automatic), (16, 8)); |
| 327 | // Each edit and its dependent, and each conflict page before its edit made again. |
| 328 | assert_eq!(server.publications, 32 + 16 - automatic); |
| 329 | (server.durable, recorded) |
| 330 | } |
| 331 | |
| 332 | #[test] |
| 333 | fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_keep_a_conflict_page() { |
| 334 | let (durable, cases) = reconcile_controls(); |
| 335 | let expected = [ |
| 336 | ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]), |
| 337 | ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]), |
| 338 | ("Split remote child", "reviewed", vec!["ab", "🦀Cd"]), |
| 339 | // A split names copies of its paragraph's list nodes; the remote's new list has none. |
| 340 | ("Split remote list", "reviewed", vec!["ab", "🦀Cd"]), |
| 341 | ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]), |
| 342 | ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]), |
| 343 | ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]), |
| 344 | ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]), |
| 345 | ("Join left boundary", "automatic", vec!["ab🦀CdXRight"]), |
| 346 | ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]), |
| 347 | ("Join remote child", "automatic", vec!["ab🦀CdRight"]), |
| 348 | ("Join remote list", "reviewed", vec!["ab🦀CdRight"]), |
| 349 | ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]), |
| 350 | ("Join remote format", "reviewed", vec!["ab🦀CdRight"]), |
| 351 | ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]), |
| 352 | ("Join empty adoption", "automatic", vec!["XIight"]), |
| 353 | ]; |
| 354 | for (case, (name, outcome, prefix)) in cases.iter().zip(expected) { |
| 355 | assert_eq!(case["case"], name); |
| 356 | assert_eq!(case["outcome"], outcome, "{name}"); |
| 357 | let space: ExGuid = serde_json::from_value(case["space"].clone()).unwrap(); |
| 358 | let published = texts(&page_of(&durable, space)); |
| 359 | assert_eq!(&published[..prefix.len()], prefix, "{name}"); |
| 360 | assert!( |
| 361 | published.iter().any(|text| text.contains("sibling")), |
| 362 | "{name}: {published:?}" |
| 363 | ); |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | #[test] |
| 368 | fn a_native_text_object_replacement_keeps_a_conflict_page_for_every_offline_split_and_join() { |
| 369 | let mut server = Server::new(REPLACED); |
| 370 | let mut count = 0; |
| 371 | for (space, name, split) in controls() { |
| 372 | let original = page_of(BEFORE, space); |
| 373 | let left = body(&original)[0].text().unwrap().id; |
| 374 | let directory = tempfile::tempdir().unwrap(); |
| 375 | let path = directory.path().join("replaced.sqlite"); |
| 376 | let cache = Replica::create(&path, BEFORE).unwrap(); |
| 377 | let mut after = original.clone(); |
| 378 | if split { |
| 379 | split_paragraph(&mut after, left, 2); |
| 380 | } else { |
| 381 | join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id); |
| 382 | } |
| 383 | let id = model_ops::save_as(&cache, space, &after, AUTHOR) |
| 384 | .unwrap() |
| 385 | .unwrap(); |
| 386 | let remote = page_of(&server.visible, space); |
| 387 | assert!( |
| 388 | matches!( |
| 389 | cache.sync_once(&mut server).unwrap().edit, |
| 390 | Some((_, EditStatus::Published { .. })) |
| 391 | ), |
| 392 | "{name}" |
| 393 | ); |
| 394 | assert!( |
| 395 | matches!( |
| 396 | cache.status(id).unwrap(), |
| 397 | Some(EditStatus::Published { .. }) |
| 398 | ), |
| 399 | "{name}" |
| 400 | ); |
| 401 | assert_eq!(page_of(&server.visible, space), remote, "{name}"); |
| 402 | assert_eq!(conflict_pages(&server.visible, space), 1, "{name}"); |
| 403 | count += 1; |
| 404 | } |
| 405 | assert_eq!(server.publications, count); |
| 406 | } |
| 407 | |
| 408 | /// Replays a recorded native split or join intent as a page-model difference. |
| 409 | fn replay(page: &mut Page, intent: &serde_json::Value, split: bool) { |
| 410 | if split { |
| 411 | split_paragraph( |
| 412 | page, |
| 413 | serde_json::from_value(intent["text"].clone()).unwrap(), |
| 414 | u32::try_from(intent["offset"].as_u64().unwrap()).unwrap(), |
| 415 | ); |
| 416 | } else { |
| 417 | join_paragraphs( |
| 418 | page, |
| 419 | serde_json::from_value(intent["left"].clone()).unwrap(), |
| 420 | serde_json::from_value(intent["right"].clone()).unwrap(), |
| 421 | ); |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | /// The text object a recorded intent starts from. |
| 426 | fn origin(intent: &serde_json::Value, split: bool) -> ExGuid { |
| 427 | serde_json::from_value(intent[if split { "text" } else { "left" }].clone()).unwrap() |
| 428 | } |
| 429 | |
| 430 | /// Replays every recorded native split and join intent as a page-model save, against a |
| 431 | /// remote that prefixed the edited text wherever a prefix is distinguishable. |
| 432 | fn offline_paragraphs(output: Option<&Path>) { |
| 433 | let root = Path::new(CORPUS); |
| 434 | for (name, source, recorded, split) in [ |
| 435 | ( |
| 436 | "splits", |
| 437 | "before/notebook/synthetic.one", |
| 438 | "rust-split/manifest.json", |
| 439 | true, |
| 440 | ), |
| 441 | ( |
| 442 | "joins", |
| 443 | "split/notebook/synthetic.one", |
| 444 | "rust-join/split/manifest.json", |
| 445 | false, |
| 446 | ), |
| 447 | ( |
| 448 | "inheritance", |
| 449 | "join-edges/before/notebook/synthetic.one", |
| 450 | "rust-join/inheritance/manifest.json", |
| 451 | false, |
| 452 | ), |
| 453 | ( |
| 454 | "tags", |
| 455 | "join-tags/before/notebook/synthetic.one", |
| 456 | "rust-join/tags/manifest.json", |
| 457 | false, |
| 458 | ), |
| 459 | ] { |
| 460 | let source = std::fs::read(root.join(source)).unwrap(); |
| 461 | let directory = tempfile::tempdir().unwrap(); |
| 462 | let folder = output.map_or_else(|| directory.path().to_owned(), |at| at.join(name)); |
| 463 | std::fs::create_dir_all(&folder).unwrap(); |
| 464 | let path = folder.join("cache.sqlite"); |
| 465 | let mut cache = Replica::create(&path, &source).unwrap(); |
| 466 | let mut server = Server::new(&source); |
| 467 | let manifest: serde_json::Value = |
| 468 | serde_json::from_slice(&std::fs::read(root.join(recorded)).unwrap()).unwrap(); |
| 469 | let cases = if split { &manifest["cases"] } else { &manifest }; |
| 470 | let mut entries = Vec::new(); |
| 471 | let mut refused: Vec<String> = Vec::new(); |
| 472 | let mut reviewed: Vec<String> = Vec::new(); |
| 473 | for case in cases |
| 474 | .as_array() |
| 475 | .unwrap() |
| 476 | .iter() |
| 477 | .filter(|case| case.get("intent").is_some()) |
| 478 | { |
| 479 | let intent = &case["intent"]; |
| 480 | let left = origin(intent, split); |
| 481 | let (space, page) = holder(&snapshot(&cache), left); |
| 482 | let offset = if split { |
| 483 | intent["offset"].as_u64().unwrap() |
| 484 | } else { |
| 485 | 1 |
| 486 | }; |
| 487 | let prefixed = offset > 0 && !content(&page, left).text().is_empty(); |
| 488 | if prefixed { |
| 489 | server.visible = typed(&server.visible, space, left, 0..0, "\u{2602}"); |
| 490 | server.durable.clone_from(&server.visible); |
| 491 | } |
| 492 | let Ok(queued) = save_page(&cache, left, |page| replay(page, intent, split)) else { |
| 493 | // Joining runs that differ in language or spacing is not a page-model edit. |
| 494 | refused.push(case["case"].as_str().unwrap().to_owned()); |
| 495 | continue; |
| 496 | }; |
| 497 | let id = queued.unwrap(); |
| 498 | let mut entry = serde_json::json!({"case": case["case"], "id": id, "space": space, |
| 499 | "intent": intent, "remote_prefix": prefixed}); |
| 500 | drop(cache); |
| 501 | cache = Replica::open(&path).unwrap(); |
| 502 | let case = entry["case"].as_str().unwrap().to_owned(); |
| 503 | let intent = entry["intent"].clone(); |
| 504 | let mut uncertain = id % 2 == 0; |
| 505 | let mut kept = conflict_pages(&server.visible, space); |
| 506 | let mut id = id; |
| 507 | let revision = loop { |
| 508 | if uncertain { |
| 509 | server.fault = Fault::UnknownAfter; |
| 510 | } |
| 511 | match cache.sync_once(&mut server) { |
| 512 | Err(_) => { |
| 513 | assert!(matches!( |
| 514 | cache.status(id).unwrap(), |
| 515 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 516 | )); |
| 517 | uncertain = false; |
| 518 | drop(cache); |
| 519 | cache = Replica::open(&path).unwrap(); |
| 520 | } |
| 521 | Ok(notebook::Synced { |
| 522 | edit: Some((_, EditStatus::Published { revision })), |
| 523 | .. |
| 524 | }) if conflict_pages(&server.visible, space) == kept => { |
| 525 | assert!(matches!( |
| 526 | cache.status(id).unwrap(), |
| 527 | Some(EditStatus::Published { .. }) |
| 528 | )); |
| 529 | break revision; |
| 530 | } |
| 531 | Ok(notebook::Synced { |
| 532 | edit: Some((_, EditStatus::Published { .. })), |
| 533 | .. |
| 534 | }) => { |
| 535 | kept += 1; |
| 536 | server.fault = Fault::None; |
| 537 | reviewed.push(case.clone()); |
| 538 | // The split made again sits after the remote's one-unit prefix. |
| 539 | let mut placed = intent.clone(); |
| 540 | if split && entry["remote_prefix"] == true { |
| 541 | placed["offset"] = (intent["offset"].as_u64().unwrap() + 1).into(); |
| 542 | } |
| 543 | redo_page(&cache, origin(&intent, split), |page| { |
| 544 | replay(page, &placed, split) |
| 545 | }); |
| 546 | id = cache.pending().unwrap().last().unwrap().id; |
| 547 | } |
| 548 | other => panic!("{name} {case}: {other:?}"), |
| 549 | } |
| 550 | }; |
| 551 | entry["revision"] = serde_json::to_value(revision).unwrap(); |
| 552 | entries.push(entry); |
| 553 | } |
| 554 | let unrepresentable = ["Join inherited styles".to_owned()]; |
| 555 | let expected: (usize, &[String], &[String]) = match name { |
| 556 | "splits" | "joins" => (12, &[], &[]), |
| 557 | "inheritance" => (4, &[], &unrepresentable), |
| 558 | _ => (3, &[], &[]), |
| 559 | }; |
| 560 | assert_eq!( |
| 561 | (entries.len(), reviewed.as_slice(), refused.as_slice()), |
| 562 | expected, |
| 563 | "{name}" |
| 564 | ); |
| 565 | assert_eq!(server.publications, entries.len()); |
| 566 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 567 | assert_eq!(snapshot(&cache), server.durable); |
| 568 | if output.is_some() { |
| 569 | cache |
| 570 | .export_recovery(folder.join("recovery.sqlite")) |
| 571 | .unwrap(); |
| 572 | std::fs::create_dir(folder.join("candidate")).unwrap(); |
| 573 | std::fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap(); |
| 574 | std::fs::write( |
| 575 | folder.join("manifest.json"), |
| 576 | serde_json::to_vec_pretty(&entries).unwrap(), |
| 577 | ) |
| 578 | .unwrap(); |
| 579 | } |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | /// A control page of the reconciliation fixture, by title. |
| 584 | fn control(title: &str) -> (ExGuid, Page) { |
| 585 | let (space, ..) = controls() |
| 586 | .into_iter() |
| 587 | .find(|(_, name, _)| name == title) |
| 588 | .unwrap(); |
| 589 | (space, page_of(BEFORE, space)) |
| 590 | } |
| 591 | |
| 592 | /// A remote holding the control page with `change` applied by a native author. |
| 593 | fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { |
| 594 | let mut page = page_of(BEFORE, space); |
| 595 | change(&mut page); |
| 596 | Server::new(ops::saved(BEFORE, space, &page).unwrap().as_slice()) |
| 597 | } |
| 598 | |
| 599 | #[test] |
| 600 | fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() { |
| 601 | let (space, page) = control("Join remote prefix"); |
| 602 | let left = body(&page)[0].text().unwrap().id; |
| 603 | let directory = tempfile::tempdir().unwrap(); |
| 604 | let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap(); |
| 605 | let mut server = Server::new(BEFORE); |
| 606 | save_page(&cache, left, |page| split_paragraph(page, left, 2)) |
| 607 | .unwrap() |
| 608 | .unwrap(); |
| 609 | assert!(matches!( |
| 610 | cache.sync_once(&mut server).unwrap().edit, |
| 611 | Some((_, EditStatus::Published { .. })) |
| 612 | )); |
| 613 | let published = page_of(&server.durable, space); |
| 614 | assert_eq!( |
| 615 | texts(&published), |
| 616 | ["ab", "\u{1f980}cd", "Right", "Preserved sibling"] |
| 617 | ); |
| 618 | let right = body(&published)[1].text().unwrap().id; |
| 619 | save_page(&cache, left, |page| join_paragraphs(page, left, right)) |
| 620 | .unwrap() |
| 621 | .unwrap(); |
| 622 | assert!(matches!( |
| 623 | cache.sync_once(&mut server).unwrap().edit, |
| 624 | Some((_, EditStatus::Published { .. })) |
| 625 | )); |
| 626 | assert_eq!( |
| 627 | texts(&page_of(&server.durable, space)), |
| 628 | ["ab\u{1f980}cd", "Right", "Preserved sibling"] |
| 629 | ); |
| 630 | assert_eq!(server.publications, 2); |
| 631 | assert_eq!(snapshot(&cache), server.durable); |
| 632 | } |
| 633 | |
| 634 | #[test] |
| 635 | fn a_split_merges_with_a_remote_edit_to_another_paragraph() { |
| 636 | let (space, page) = control("Join remote prefix"); |
| 637 | let (left, other) = ( |
| 638 | body(&page)[0].text().unwrap().id, |
| 639 | body(&page)[1].text().unwrap().id, |
| 640 | ); |
| 641 | let directory = tempfile::tempdir().unwrap(); |
| 642 | let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap(); |
| 643 | let id = save_page(&cache, left, |page| split_paragraph(page, left, 2)) |
| 644 | .unwrap() |
| 645 | .unwrap(); |
| 646 | let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote ")); |
| 647 | assert!( |
| 648 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 649 | ); |
| 650 | assert_eq!( |
| 651 | texts(&page_of(&server.durable, space)), |
| 652 | ["ab", "\u{1f980}cd", "Remote Right", "Preserved sibling"] |
| 653 | ); |
| 654 | } |
| 655 | |
| 656 | #[test] |
| 657 | fn a_join_keeps_a_conflict_page_when_the_remote_changed_the_paragraph_it_removes() { |
| 658 | let (space, page) = control("Join remote prefix"); |
| 659 | let (left, right) = ( |
| 660 | body(&page)[0].text().unwrap().id, |
| 661 | body(&page)[1].text().unwrap().id, |
| 662 | ); |
| 663 | let directory = tempfile::tempdir().unwrap(); |
| 664 | let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap(); |
| 665 | let id = save_page(&cache, left, |page| join_paragraphs(page, left, right)) |
| 666 | .unwrap() |
| 667 | .unwrap(); |
| 668 | let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote ")); |
| 669 | assert!(matches!( |
| 670 | cache.sync_once(&mut server).unwrap().edit, |
| 671 | Some((_, EditStatus::Published { .. })) |
| 672 | )); |
| 673 | assert!(matches!( |
| 674 | cache.status(id).unwrap(), |
| 675 | Some(EditStatus::Published { .. }) |
| 676 | )); |
| 677 | assert_eq!(conflict_pages(&server.durable, space), 1); |
| 678 | redo_page(&cache, left, |page| join_paragraphs(page, left, right)); |
| 679 | assert!(matches!( |
| 680 | cache.sync_once(&mut server).unwrap().edit, |
| 681 | Some((_, EditStatus::Published { .. })) |
| 682 | )); |
| 683 | assert_eq!( |
| 684 | texts(&page_of(&server.durable, space))[0], |
| 685 | "ab\u{1f980}cdRemote Right" |
| 686 | ); |
| 687 | } |
| 688 | |
| 689 | #[test] |
| 690 | fn edits_dependent_on_a_published_split_survive_reopen() { |
| 691 | let (space, page) = control("Join remote prefix"); |
| 692 | let (left, other) = ( |
| 693 | body(&page)[0].text().unwrap().id, |
| 694 | body(&page)[1].text().unwrap().id, |
| 695 | ); |
| 696 | let directory = tempfile::tempdir().unwrap(); |
| 697 | let path = directory.path().join("dependent.sqlite"); |
| 698 | let mut cache = Replica::create(&path, BEFORE).unwrap(); |
| 699 | let mut server = Server::new(BEFORE); |
| 700 | save_page(&cache, left, |page| split_paragraph(page, left, 2)) |
| 701 | .unwrap() |
| 702 | .unwrap(); |
| 703 | cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 704 | let tail = body(&page_of(&snapshot(&cache), space))[1] |
| 705 | .text() |
| 706 | .unwrap() |
| 707 | .id; |
| 708 | let dependent = save_page(&cache, tail, |page| { |
| 709 | replace_text(page, tail, 0..0, "Local ") |
| 710 | }) |
| 711 | .unwrap() |
| 712 | .unwrap(); |
| 713 | let local = snapshot(&cache); |
| 714 | let pending = cache.pending().unwrap(); |
| 715 | drop(cache); |
| 716 | cache = Replica::open(&path).unwrap(); |
| 717 | assert_eq!(pages(&snapshot(&cache)), pages(&local)); |
| 718 | assert_eq!(cache.pending().unwrap(), pending); |
| 719 | let mut remote = page_of(&server.durable, space); |
| 720 | replace_text(&mut remote, other, 0..0, "Remote "); |
| 721 | server.visible = ops::saved(&server.durable, space, &remote) |
| 722 | .unwrap() |
| 723 | .as_slice() |
| 724 | .to_vec(); |
| 725 | server.durable.clone_from(&server.visible); |
| 726 | assert!( |
| 727 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == dependent) |
| 728 | ); |
| 729 | assert_eq!( |
| 730 | texts(&page_of(&server.durable, space)), |
| 731 | [ |
| 732 | "ab", |
| 733 | "Local \u{1f980}cd", |
| 734 | "Remote Right", |
| 735 | "Preserved sibling" |
| 736 | ] |
| 737 | ); |
| 738 | } |
| 739 | |
| 740 | #[test] |
| 741 | fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { |
| 742 | for join in [false, true] { |
| 743 | for fault in [ |
| 744 | Fault::Before, |
| 745 | Fault::UnknownBefore, |
| 746 | Fault::UnknownAfter, |
| 747 | Fault::Committed, |
| 748 | ] { |
| 749 | let (space, page) = control("Join remote prefix"); |
| 750 | let (left, right) = ( |
| 751 | body(&page)[0].text().unwrap().id, |
| 752 | body(&page)[1].text().unwrap().id, |
| 753 | ); |
| 754 | let directory = tempfile::tempdir().unwrap(); |
| 755 | let path = directory.path().join("uncertain.sqlite"); |
| 756 | let cache = Replica::create(&path, BEFORE).unwrap(); |
| 757 | let id = save_page(&cache, left, |page| { |
| 758 | if join { |
| 759 | join_paragraphs(page, left, right); |
| 760 | } else { |
| 761 | split_paragraph(page, left, 2); |
| 762 | } |
| 763 | }) |
| 764 | .unwrap() |
| 765 | .unwrap(); |
| 766 | let local = snapshot(&cache); |
| 767 | let pending = cache.pending().unwrap(); |
| 768 | let mut server = Server::new(BEFORE); |
| 769 | server.fault = fault; |
| 770 | assert!(cache.sync_once(&mut server).is_err()); |
| 771 | let state = cache.status(id).unwrap().unwrap(); |
| 772 | drop(cache); |
| 773 | let cache = Replica::open(&path).unwrap(); |
| 774 | assert_eq!(cache.status(id).unwrap(), Some(state.clone())); |
| 775 | match fault { |
| 776 | Fault::Before => { |
| 777 | assert_eq!(state, EditStatus::Pending); |
| 778 | assert_eq!(cache.pending().unwrap(), pending); |
| 779 | assert!(matches!( |
| 780 | cache.sync_once(&mut server).unwrap().edit.unwrap().1, |
| 781 | EditStatus::Published { .. } |
| 782 | )); |
| 783 | assert_eq!(server.publications, 2); |
| 784 | } |
| 785 | Fault::UnknownBefore => { |
| 786 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); |
| 787 | assert_eq!(cache.pending().unwrap(), pending); |
| 788 | assert_eq!(pages(&snapshot(&cache)), pages(&local)); |
| 789 | for _ in 0..3 { |
| 790 | assert_eq!( |
| 791 | cache.sync_once(&mut server).unwrap().edit, |
| 792 | Some((id, state.clone())) |
| 793 | ); |
| 794 | } |
| 795 | assert_eq!(server.publications, 1); |
| 796 | assert_eq!(server.durable, BEFORE); |
| 797 | continue; |
| 798 | } |
| 799 | Fault::UnknownAfter => { |
| 800 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); |
| 801 | assert_eq!(pages(&snapshot(&cache)), pages(&local)); |
| 802 | assert!(matches!( |
| 803 | cache.sync_once(&mut server).unwrap().edit.unwrap().1, |
| 804 | EditStatus::Published { .. } |
| 805 | )); |
| 806 | assert_eq!(server.publications, 1); |
| 807 | } |
| 808 | _ => { |
| 809 | assert!(matches!(state, EditStatus::Published { .. })); |
| 810 | assert_eq!(server.publications, 1); |
| 811 | } |
| 812 | } |
| 813 | assert!(cache.pending().unwrap().is_empty()); |
| 814 | let expected: &[&str] = if join { |
| 815 | &["ab\u{1f980}cdRight", "Preserved sibling"] |
| 816 | } else { |
| 817 | &["ab", "\u{1f980}cd", "Right", "Preserved sibling"] |
| 818 | }; |
| 819 | assert_eq!(texts(&page_of(&server.durable, space)), expected); |
| 820 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 821 | assert_eq!(snapshot(&cache), server.durable); |
| 822 | } |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | #[test] |
| 827 | fn a_competing_join_leaves_the_other_client_its_split_on_a_conflict_page() { |
| 828 | let (space, page) = control("Join remote prefix"); |
| 829 | let (left, right) = ( |
| 830 | body(&page)[0].text().unwrap().id, |
| 831 | body(&page)[1].text().unwrap().id, |
| 832 | ); |
| 833 | let directory = tempfile::tempdir().unwrap(); |
| 834 | let first = Replica::create(directory.path().join("first.sqlite"), BEFORE).unwrap(); |
| 835 | let path = directory.path().join("second.sqlite"); |
| 836 | let second = Replica::create(&path, BEFORE).unwrap(); |
| 837 | save_page(&first, left, |page| join_paragraphs(page, left, right)) |
| 838 | .unwrap() |
| 839 | .unwrap(); |
| 840 | let id = save_page(&second, right, |page| split_paragraph(page, right, 2)) |
| 841 | .unwrap() |
| 842 | .unwrap(); |
| 843 | let mut server = Server::new(BEFORE); |
| 844 | assert!(matches!( |
| 845 | first.sync_once(&mut server).unwrap().edit.unwrap().1, |
| 846 | EditStatus::Published { .. } |
| 847 | )); |
| 848 | assert!(matches!( |
| 849 | second.sync_once(&mut server).unwrap().edit, |
| 850 | Some((_, EditStatus::Published { .. })) |
| 851 | )); |
| 852 | assert_eq!(server.publications, 2); |
| 853 | assert_eq!(conflict_pages(&server.durable, space), 1); |
| 854 | drop(second); |
| 855 | let second = Replica::open(&path).unwrap(); |
| 856 | assert!(matches!( |
| 857 | second.status(id).unwrap(), |
| 858 | Some(EditStatus::Published { .. }) |
| 859 | )); |
| 860 | redo_page(&second, left, |page| { |
| 861 | split_paragraph(page, left, 2); |
| 862 | }); |
| 863 | assert!(matches!( |
| 864 | second.sync_once(&mut server).unwrap().edit.unwrap().1, |
| 865 | EditStatus::Published { .. } |
| 866 | )); |
| 867 | assert_eq!( |
| 868 | texts(&page_of(&server.durable, space)), |
| 869 | ["ab", "\u{1f980}cdRight", "Preserved sibling"] |
| 870 | ); |
| 871 | } |
| 872 | |
| 873 | #[test] |
| 874 | fn recorded_native_splits_and_joins_replay_over_a_remote_prefix() { |
| 875 | offline_paragraphs(None); |
| 876 | } |
| 877 | |
| 878 | #[test] |
| 879 | #[ignore = "exports native-controlled reconciliation for cold OneNote validation"] |
| 880 | fn export_native_reconciliation() { |
| 881 | let output = std::path::PathBuf::from( |
| 882 | std::env::var_os("ONESTORE_NATIVE_RECONCILIATION_OUTPUT").unwrap(), |
| 883 | ); |
| 884 | assert!(output.is_absolute()); |
| 885 | std::fs::create_dir(&output).unwrap(); |
| 886 | let (bytes, cases) = reconcile_controls(); |
| 887 | std::fs::create_dir(output.join("candidate")).unwrap(); |
| 888 | std::fs::write(output.join("candidate/synthetic.one"), bytes).unwrap(); |
| 889 | std::fs::write( |
| 890 | output.join("manifest.json"), |
| 891 | serde_json::to_vec_pretty(&cases).unwrap(), |
| 892 | ) |
| 893 | .unwrap(); |
| 894 | } |
| 895 | |
| 896 | #[test] |
| 897 | #[ignore = "exports reconciled paragraph edits for independent native validation"] |
| 898 | fn export_native_offline_paragraphs() { |
| 899 | let output = |
| 900 | std::path::PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap()); |
| 901 | assert!(output.is_absolute()); |
| 902 | std::fs::create_dir(&output).unwrap(); |
| 903 | offline_paragraphs(Some(&output)); |
| 904 | } |