| 1 | //! Page-model saves: one intent per page until it is attempted, reconciliation against the |
| 2 | //! remote page, reviewed conflicts, and independence between pages. |
| 3 | |
| 4 | #[path = "../../onestore/tests/support/ops.rs"] |
| 5 | mod ops; |
| 6 | |
| 7 | use notebook::{EditStatus, Replica}; |
| 8 | use onestore::{ExGuid, RevisionIndex, Store, document::Document}; |
| 9 | |
| 10 | #[path = "support/server.rs"] |
| 11 | mod server; |
| 12 | use server::*; |
| 13 | #[path = "support/model_ops.rs"] |
| 14 | mod model_ops; |
| 15 | |
| 16 | use onestore::page::{Page, PageObject}; |
| 17 | |
| 18 | const OUTLINES: &[u8] = |
| 19 | include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); |
| 20 | |
| 21 | fn page_of(bytes: &[u8], space: ExGuid) -> Page { |
| 22 | let store = Store::parse(bytes).unwrap(); |
| 23 | let index = RevisionIndex::parse(&store).unwrap(); |
| 24 | let document = Document::parse(&index).unwrap(); |
| 25 | Page::from_space(&document, space).unwrap() |
| 26 | } |
| 27 | |
| 28 | fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) { |
| 29 | let store = Store::parse(bytes).unwrap(); |
| 30 | let index = RevisionIndex::parse(&store).unwrap(); |
| 31 | let document = Document::parse(&index).unwrap(); |
| 32 | document |
| 33 | .pages() |
| 34 | .unwrap() |
| 35 | .into_iter() |
| 36 | .find_map(|(sid, _)| { |
| 37 | let page = Page::from_space(&document, sid).unwrap(); |
| 38 | (page.title == title).then_some((sid, page)) |
| 39 | }) |
| 40 | .unwrap() |
| 41 | } |
| 42 | |
| 43 | /// Appends `suffix` to the first body paragraph of the first body outline. |
| 44 | fn append(page: &mut Page, suffix: &str) { |
| 45 | let outline = page |
| 46 | .objects |
| 47 | .iter_mut() |
| 48 | .find_map(|object| match object { |
| 49 | PageObject::Outline(outline) => Some(outline), |
| 50 | _ => None, |
| 51 | }) |
| 52 | .unwrap(); |
| 53 | let text = outline.paragraphs[0].text_mut().unwrap(); |
| 54 | let end = text.text.utf16_offset(text.text.text().len()).unwrap(); |
| 55 | let format = text.text.format_at(end).unwrap().clone(); |
| 56 | text.text |
| 57 | .apply(onestore::page::text::Edit { |
| 58 | range: end..end, |
| 59 | replacement: onestore::page::Paragraph::new(suffix.into(), format), |
| 60 | }) |
| 61 | .unwrap(); |
| 62 | } |
| 63 | |
| 64 | fn first_text(page: &Page) -> String { |
| 65 | page.objects |
| 66 | .iter() |
| 67 | .find_map(|object| match object { |
| 68 | PageObject::Outline(outline) => { |
| 69 | Some(outline.paragraphs[0].text().unwrap().text.text().to_owned()) |
| 70 | } |
| 71 | _ => None, |
| 72 | }) |
| 73 | .unwrap() |
| 74 | } |
| 75 | |
| 76 | #[test] |
| 77 | fn a_saved_page_model_publishes_and_reads_back() { |
| 78 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 79 | let directory = tempfile::tempdir().unwrap(); |
| 80 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 81 | append(&mut after, " saved 🦀"); |
| 82 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 83 | .unwrap() |
| 84 | .unwrap(); |
| 85 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 86 | assert_eq!(page_of(&snapshot(&cache), space), after); |
| 87 | let pending = cache.pending().unwrap(); |
| 88 | assert_eq!(pending.len(), 1); |
| 89 | assert_eq!(pending[0].author, "Model author"); |
| 90 | assert!( |
| 91 | pending[0] |
| 92 | .edit |
| 93 | .ops |
| 94 | .iter() |
| 95 | .all(|op| matches!(op, onestore::op::Op::Page { space: s, .. } if *s == space)) |
| 96 | ); |
| 97 | let mut server = Server::new(OUTLINES); |
| 98 | assert!( |
| 99 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 100 | ); |
| 101 | assert_eq!(page_of(&server.durable, space), after); |
| 102 | assert_eq!(server.publications, 1); |
| 103 | assert!(cache.pending().unwrap().is_empty()); |
| 104 | assert_eq!( |
| 105 | model_ops::save_as(&cache, space, &after, "Model author").unwrap(), |
| 106 | None |
| 107 | ); |
| 108 | } |
| 109 | |
| 110 | #[test] |
| 111 | fn saves_before_an_attempt_publish_together() { |
| 112 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 113 | let directory = tempfile::tempdir().unwrap(); |
| 114 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 115 | append(&mut after, " one"); |
| 116 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 117 | .unwrap() |
| 118 | .unwrap(); |
| 119 | let mut later = page_of(&snapshot(&cache), space); |
| 120 | append(&mut later, " two"); |
| 121 | let second = model_ops::save_as(&cache, space, &later, "Model author") |
| 122 | .unwrap() |
| 123 | .unwrap(); |
| 124 | assert!(second > id); |
| 125 | assert_eq!(cache.pending().unwrap().len(), 2); |
| 126 | let mut server = Server::new(OUTLINES); |
| 127 | assert!( |
| 128 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == second) |
| 129 | ); |
| 130 | assert!(matches!( |
| 131 | cache.status(id).unwrap(), |
| 132 | Some(EditStatus::Published { .. }) |
| 133 | )); |
| 134 | assert_eq!(server.publications, 1); |
| 135 | assert_eq!( |
| 136 | first_text(&page_of(&server.durable, space)), |
| 137 | first_text(&later) |
| 138 | ); |
| 139 | let mut third = page_of(&snapshot(&cache), space); |
| 140 | append(&mut third, " three"); |
| 141 | let next = model_ops::save_as(&cache, space, &third, "Model author") |
| 142 | .unwrap() |
| 143 | .unwrap(); |
| 144 | assert!(next > second); |
| 145 | } |
| 146 | |
| 147 | #[test] |
| 148 | fn an_attempted_save_is_never_rewritten() { |
| 149 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 150 | let directory = tempfile::tempdir().unwrap(); |
| 151 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 152 | append(&mut after, " uncertain"); |
| 153 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 154 | .unwrap() |
| 155 | .unwrap(); |
| 156 | let mut server = Server::new(OUTLINES); |
| 157 | server.fault = Fault::UnknownAfter; |
| 158 | assert!(cache.sync_once(&mut server).is_err()); |
| 159 | assert!(matches!( |
| 160 | cache.status(id).unwrap(), |
| 161 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 162 | )); |
| 163 | let mut more = page_of(&snapshot(&cache), space); |
| 164 | append(&mut more, " more"); |
| 165 | let next = model_ops::save_as(&cache, space, &more, "Model author") |
| 166 | .unwrap() |
| 167 | .unwrap(); |
| 168 | assert!(next > id); |
| 169 | let pending = cache.pending().unwrap(); |
| 170 | assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]); |
| 171 | assert_eq!(page_of(&snapshot(&cache), space), more); |
| 172 | assert!(matches!( |
| 173 | cache.status(id).unwrap(), |
| 174 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 175 | )); |
| 176 | assert_eq!(cache.status(next).unwrap(), Some(EditStatus::Pending)); |
| 177 | } |
| 178 | |
| 179 | /// A remote change to the saved text keeps the remote's page and the local one as its |
| 180 | /// conflict page; the writer's change made again publishes over the remote's. |
| 181 | #[test] |
| 182 | fn a_remote_change_to_the_saved_page_keeps_a_conflict_page() { |
| 183 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 184 | let directory = tempfile::tempdir().unwrap(); |
| 185 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 186 | append(&mut after, " local"); |
| 187 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 188 | .unwrap() |
| 189 | .unwrap(); |
| 190 | let mut remote_page = page_of(OUTLINES, space); |
| 191 | append(&mut remote_page, " remote"); |
| 192 | let remote = ops::saved(OUTLINES, space, &remote_page).unwrap(); |
| 193 | let mut server = Server::new(remote.as_slice()); |
| 194 | assert!(matches!( |
| 195 | cache.sync_once(&mut server).unwrap().edit, |
| 196 | Some((_, EditStatus::Published { .. })) |
| 197 | )); |
| 198 | assert!(matches!( |
| 199 | cache.status(id).unwrap(), |
| 200 | Some(EditStatus::Published { .. }) |
| 201 | )); |
| 202 | assert_eq!(server.publications, 1); |
| 203 | let published = page_of(&server.durable, space); |
| 204 | assert_eq!(first_text(&published), first_text(&remote_page)); |
| 205 | assert_eq!( |
| 206 | conflicts(&server.durable)[0].1[0].0, |
| 207 | "Model author", |
| 208 | "the conflict page names whose version it is" |
| 209 | ); |
| 210 | let text = published |
| 211 | .objects |
| 212 | .iter() |
| 213 | .find_map(|object| match object { |
| 214 | PageObject::Outline(outline) => outline.paragraphs[0].text().map(|text| text.id), |
| 215 | _ => None, |
| 216 | }) |
| 217 | .unwrap(); |
| 218 | let again = model_ops::save(&cache, text, |page| append(page, " local")) |
| 219 | .unwrap() |
| 220 | .unwrap(); |
| 221 | assert!( |
| 222 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == again) |
| 223 | ); |
| 224 | assert!(first_text(&page_of(&server.durable, space)).ends_with(" remote local")); |
| 225 | } |
| 226 | |
| 227 | #[test] |
| 228 | fn remote_changes_to_other_pages_do_not_conflict() { |
| 229 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 230 | let (other, mut other_page) = page_titled(OUTLINES, "Delete leaf"); |
| 231 | let directory = tempfile::tempdir().unwrap(); |
| 232 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 233 | append(&mut after, " local"); |
| 234 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 235 | .unwrap() |
| 236 | .unwrap(); |
| 237 | append(&mut other_page, " elsewhere"); |
| 238 | let remote = ops::saved(OUTLINES, other, &other_page).unwrap(); |
| 239 | let mut server = Server::new(remote.as_slice()); |
| 240 | assert!( |
| 241 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 242 | ); |
| 243 | assert_eq!(page_of(&server.durable, space), after); |
| 244 | assert_eq!(page_of(&server.durable, other), other_page); |
| 245 | assert_eq!(page_of(&snapshot(&cache), other), other_page); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn pending_saves_survive_reopening_the_cache() { |
| 250 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 251 | let directory = tempfile::tempdir().unwrap(); |
| 252 | let path = directory.path().join("cache.sqlite"); |
| 253 | let cache = Replica::create(&path, OUTLINES).unwrap(); |
| 254 | append(&mut after, " durable"); |
| 255 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 256 | .unwrap() |
| 257 | .unwrap(); |
| 258 | let pending = cache.pending().unwrap(); |
| 259 | drop(cache); |
| 260 | let reopened = Replica::open(&path).unwrap(); |
| 261 | assert_eq!(reopened.pending().unwrap(), pending); |
| 262 | assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending)); |
| 263 | assert_eq!(page_of(&snapshot(&reopened), space), after); |
| 264 | } |
| 265 | |
| 266 | /// Edits paragraph `index` of the first body outline: replaces its text range with `replacement`. |
| 267 | fn edit_paragraph(page: &mut Page, index: usize, range: std::ops::Range<u32>, replacement: &str) { |
| 268 | let outline = page |
| 269 | .objects |
| 270 | .iter_mut() |
| 271 | .find_map(|object| match object { |
| 272 | PageObject::Outline(outline) => Some(outline), |
| 273 | _ => None, |
| 274 | }) |
| 275 | .unwrap(); |
| 276 | let text = outline.paragraphs[index].text_mut().unwrap(); |
| 277 | let format = text.text.format_at(range.start).unwrap().clone(); |
| 278 | text.text |
| 279 | .apply(onestore::page::text::Edit { |
| 280 | range, |
| 281 | replacement: onestore::page::Paragraph::new(replacement.into(), format), |
| 282 | }) |
| 283 | .unwrap(); |
| 284 | } |
| 285 | |
| 286 | fn texts(page: &Page) -> Vec<String> { |
| 287 | page.objects |
| 288 | .iter() |
| 289 | .find_map(|object| match object { |
| 290 | PageObject::Outline(outline) => Some( |
| 291 | outline |
| 292 | .paragraphs |
| 293 | .iter() |
| 294 | .filter_map(|p| p.text().map(|t| t.text.text().to_owned())) |
| 295 | .collect(), |
| 296 | ), |
| 297 | _ => None, |
| 298 | }) |
| 299 | .unwrap() |
| 300 | } |
| 301 | |
| 302 | fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { |
| 303 | let mut page = page_of(OUTLINES, space); |
| 304 | change(&mut page); |
| 305 | Server::new(ops::saved(OUTLINES, space, &page).unwrap().as_slice()) |
| 306 | } |
| 307 | |
| 308 | #[test] |
| 309 | fn concurrent_edits_to_different_paragraphs_merge() { |
| 310 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 311 | let directory = tempfile::tempdir().unwrap(); |
| 312 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 313 | edit_paragraph(&mut after, 0, 0..0, "Local "); |
| 314 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 315 | .unwrap() |
| 316 | .unwrap(); |
| 317 | let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote ")); |
| 318 | assert!( |
| 319 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 320 | ); |
| 321 | let published = texts(&page_of(&server.durable, space)); |
| 322 | assert!(published[0].starts_with("Local Anchor"), "{published:?}"); |
| 323 | assert!(published[2].starts_with("Remote Trailing"), "{published:?}"); |
| 324 | assert_eq!( |
| 325 | cache |
| 326 | .status(id) |
| 327 | .unwrap() |
| 328 | .map(|s| matches!(s, EditStatus::Published { .. })), |
| 329 | Some(true) |
| 330 | ); |
| 331 | } |
| 332 | |
| 333 | #[test] |
| 334 | fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() { |
| 335 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 336 | let directory = tempfile::tempdir().unwrap(); |
| 337 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 338 | append(&mut after, " local"); |
| 339 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 340 | .unwrap() |
| 341 | .unwrap(); |
| 342 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote ")); |
| 343 | assert!( |
| 344 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 345 | ); |
| 346 | assert_eq!( |
| 347 | texts(&page_of(&server.durable, space))[0], |
| 348 | "Remote Anchor local" |
| 349 | ); |
| 350 | |
| 351 | let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap(); |
| 352 | let mut after = page_of(OUTLINES, space); |
| 353 | edit_paragraph(&mut after, 0, 0..6, "Local"); |
| 354 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 355 | .unwrap() |
| 356 | .unwrap(); |
| 357 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote")); |
| 358 | assert!(matches!( |
| 359 | cache.sync_once(&mut server).unwrap().edit, |
| 360 | Some((_, EditStatus::Published { .. })) |
| 361 | )); |
| 362 | assert!(matches!( |
| 363 | cache.status(id).unwrap(), |
| 364 | Some(EditStatus::Published { .. }) |
| 365 | )); |
| 366 | assert!(conflicted(&server.durable, space)); |
| 367 | assert_eq!(server.publications, 1); |
| 368 | } |
| 369 | |
| 370 | #[test] |
| 371 | fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() { |
| 372 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 373 | let directory = tempfile::tempdir().unwrap(); |
| 374 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 375 | let template = texts(&after); |
| 376 | assert_eq!(template.len(), 3); |
| 377 | { |
| 378 | let outline = after |
| 379 | .objects |
| 380 | .iter_mut() |
| 381 | .find_map(|object| match object { |
| 382 | PageObject::Outline(outline) => Some(outline), |
| 383 | _ => None, |
| 384 | }) |
| 385 | .unwrap(); |
| 386 | let mut fresh = outline.paragraphs[0].clone(); |
| 387 | fresh.id = onestore::page::text::new_id().unwrap(); |
| 388 | fresh.style = None; |
| 389 | let text = fresh.text_mut().unwrap(); |
| 390 | text.id = onestore::page::text::new_id().unwrap(); |
| 391 | text.text = onestore::page::Paragraph::new( |
| 392 | "Inserted locally".into(), |
| 393 | text.text.format_at(0).unwrap().clone(), |
| 394 | ); |
| 395 | outline.paragraphs.insert(1, fresh); |
| 396 | } |
| 397 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 398 | .unwrap() |
| 399 | .unwrap(); |
| 400 | let mut server = remote_with(space, |page| { |
| 401 | let outline = page |
| 402 | .objects |
| 403 | .iter_mut() |
| 404 | .find_map(|object| match object { |
| 405 | PageObject::Outline(outline) => Some(outline), |
| 406 | _ => None, |
| 407 | }) |
| 408 | .unwrap(); |
| 409 | outline.paragraphs.pop(); |
| 410 | }); |
| 411 | assert!( |
| 412 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 413 | ); |
| 414 | assert_eq!( |
| 415 | texts(&page_of(&server.durable, space)), |
| 416 | vec![ |
| 417 | template[0].clone(), |
| 418 | "Inserted locally".to_owned(), |
| 419 | template[1].clone() |
| 420 | ] |
| 421 | ); |
| 422 | } |
| 423 | |
| 424 | #[test] |
| 425 | fn a_remote_deletion_of_the_edited_paragraph_keeps_a_conflict_page() { |
| 426 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 427 | let directory = tempfile::tempdir().unwrap(); |
| 428 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 429 | edit_paragraph(&mut after, 2, 0..0, "Local "); |
| 430 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 431 | .unwrap() |
| 432 | .unwrap(); |
| 433 | let mut server = remote_with(space, |page| { |
| 434 | let outline = page |
| 435 | .objects |
| 436 | .iter_mut() |
| 437 | .find_map(|object| match object { |
| 438 | PageObject::Outline(outline) => Some(outline), |
| 439 | _ => None, |
| 440 | }) |
| 441 | .unwrap(); |
| 442 | outline.paragraphs.pop(); |
| 443 | }); |
| 444 | assert!(matches!( |
| 445 | cache.sync_once(&mut server).unwrap().edit, |
| 446 | Some((_, EditStatus::Published { .. })) |
| 447 | )); |
| 448 | assert!(matches!( |
| 449 | cache.status(id).unwrap(), |
| 450 | Some(EditStatus::Published { .. }) |
| 451 | )); |
| 452 | assert!(conflicted(&server.durable, space)); |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { |
| 457 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 458 | let directory = tempfile::tempdir().unwrap(); |
| 459 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 460 | let outline_id = { |
| 461 | let PageObject::Outline(outline) = after |
| 462 | .objects |
| 463 | .iter_mut() |
| 464 | .find(|o| matches!(o, PageObject::Outline(_))) |
| 465 | .unwrap() |
| 466 | else { |
| 467 | unreachable!() |
| 468 | }; |
| 469 | outline.layout.x = Some(200.0); |
| 470 | outline.layout.y = Some(300.0); |
| 471 | outline.id |
| 472 | }; |
| 473 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 474 | .unwrap() |
| 475 | .unwrap(); |
| 476 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote ")); |
| 477 | assert!( |
| 478 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 479 | ); |
| 480 | let published = page_of(&server.durable, space); |
| 481 | let PageObject::Outline(outline) = published |
| 482 | .objects |
| 483 | .iter() |
| 484 | .find(|o| o.id() == outline_id) |
| 485 | .unwrap() |
| 486 | else { |
| 487 | unreachable!() |
| 488 | }; |
| 489 | assert_eq!( |
| 490 | (outline.layout.x, outline.layout.y), |
| 491 | (Some(200.0), Some(300.0)) |
| 492 | ); |
| 493 | assert!( |
| 494 | outline.paragraphs[0] |
| 495 | .text() |
| 496 | .unwrap() |
| 497 | .text |
| 498 | .text() |
| 499 | .starts_with("Remote ") |
| 500 | ); |
| 501 | |
| 502 | let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap(); |
| 503 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 504 | .unwrap() |
| 505 | .unwrap(); |
| 506 | let mut server = remote_with(space, |page| { |
| 507 | let PageObject::Outline(outline) = page |
| 508 | .objects |
| 509 | .iter_mut() |
| 510 | .find(|o| matches!(o, PageObject::Outline(_))) |
| 511 | .unwrap() |
| 512 | else { |
| 513 | unreachable!() |
| 514 | }; |
| 515 | outline.layout.x = Some(50.0); |
| 516 | }); |
| 517 | assert!(matches!( |
| 518 | cache.sync_once(&mut server).unwrap().edit, |
| 519 | Some((_, EditStatus::Published { .. })) |
| 520 | )); |
| 521 | assert!(matches!( |
| 522 | cache.status(id).unwrap(), |
| 523 | Some(EditStatus::Published { .. }) |
| 524 | )); |
| 525 | assert!(conflicted(&server.durable, space)); |
| 526 | } |
| 527 | |
| 528 | #[test] |
| 529 | fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition() { |
| 530 | let (space, before) = page_titled(OUTLINES, "Move leaf down"); |
| 531 | let directory = tempfile::tempdir().unwrap(); |
| 532 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 533 | let mut after = before.clone(); |
| 534 | let bullet = onestore::page::text::new_id().unwrap(); |
| 535 | after.definitions.insert( |
| 536 | bullet, |
| 537 | onestore::page::Definition { |
| 538 | kind: onestore::document::Kind::List { |
| 539 | font: Some("Courier New".into()), |
| 540 | format: Some("\u{25cb}".into()), |
| 541 | restart: None, |
| 542 | bullet: Some(4), |
| 543 | }, |
| 544 | format: onestore::document::Format { |
| 545 | font_size: Some(11.0), |
| 546 | color: Some(0xff000000), |
| 547 | ..Default::default() |
| 548 | }, |
| 549 | }, |
| 550 | ); |
| 551 | let outline = after |
| 552 | .objects |
| 553 | .iter_mut() |
| 554 | .find_map(|object| match object { |
| 555 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 556 | _ => None, |
| 557 | }) |
| 558 | .unwrap(); |
| 559 | outline.paragraphs[0].lists = vec![bullet]; |
| 560 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 561 | .unwrap() |
| 562 | .unwrap(); |
| 563 | let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote ")); |
| 564 | assert!( |
| 565 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 566 | ); |
| 567 | let published = page_of(&server.durable, space); |
| 568 | let outline = published |
| 569 | .objects |
| 570 | .iter() |
| 571 | .find_map(|object| match object { |
| 572 | PageObject::Outline(outline) if !outline.title => Some(outline), |
| 573 | _ => None, |
| 574 | }) |
| 575 | .unwrap(); |
| 576 | assert_eq!(outline.paragraphs[0].lists, vec![bullet]); |
| 577 | assert!(matches!( |
| 578 | published.definitions[&bullet].kind, |
| 579 | onestore::document::Kind::List { |
| 580 | bullet: Some(4), |
| 581 | .. |
| 582 | } |
| 583 | )); |
| 584 | assert!(texts(&published)[1].starts_with("Remote ")); |
| 585 | } |