| 1 | //! Formatting and insertion as page-model differences: what reconciles with an independent |
| 2 | //! remote change, what keeps a conflict page, and what the edit made again publishes. |
| 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, Format}, |
| 11 | page::{Page, PageObject}, |
| 12 | }; |
| 13 | |
| 14 | #[path = "support/server.rs"] |
| 15 | mod server; |
| 16 | use server::{Fault, Server, conflicted, pages, snapshot}; |
| 17 | #[path = "support/model_ops.rs"] |
| 18 | mod model_ops; |
| 19 | #[path = "../../onestore/tests/support/sweep.rs"] |
| 20 | mod sweep; |
| 21 | use model_ops::{ |
| 22 | AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, save, |
| 23 | }; |
| 24 | |
| 25 | const OUTLINES: &[u8] = |
| 26 | include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); |
| 27 | const PAGE: &str = "Move subtree down"; |
| 28 | |
| 29 | fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) { |
| 30 | let store = Store::parse(bytes).unwrap(); |
| 31 | let index = RevisionIndex::parse(&store).unwrap(); |
| 32 | let document = Document::parse(&index).unwrap(); |
| 33 | document |
| 34 | .pages() |
| 35 | .unwrap() |
| 36 | .into_iter() |
| 37 | .find_map(|(space, _)| { |
| 38 | let page = Page::from_space(&document, space).unwrap(); |
| 39 | (page.title == title).then_some((space, page)) |
| 40 | }) |
| 41 | .unwrap() |
| 42 | } |
| 43 | |
| 44 | /// The text objects of a page that carry no hidden field, and so accept range formatting. |
| 45 | fn plain(page: &Page) -> Vec<ExGuid> { |
| 46 | page.objects |
| 47 | .iter() |
| 48 | .flat_map(|object| match object { |
| 49 | PageObject::Outline(outline) => outline.paragraphs.iter().collect(), |
| 50 | _ => Vec::new(), |
| 51 | }) |
| 52 | .filter_map(|paragraph| { |
| 53 | let text = paragraph.text()?; |
| 54 | (!text.text.text().contains('\u{fddf}')).then_some(text.id) |
| 55 | }) |
| 56 | .collect() |
| 57 | } |
| 58 | |
| 59 | fn text_of(page: &Page, text: ExGuid) -> String { |
| 60 | paragraph_with(page, text) |
| 61 | .unwrap() |
| 62 | .text() |
| 63 | .unwrap() |
| 64 | .text |
| 65 | .text() |
| 66 | .to_owned() |
| 67 | } |
| 68 | |
| 69 | /// The format of every UTF-16 unit of a text object. |
| 70 | fn formats(page: &Page, text: ExGuid) -> Vec<Format> { |
| 71 | let content = &paragraph_with(page, text).unwrap().text().unwrap().text; |
| 72 | let mut out = Vec::new(); |
| 73 | let mut from = 0; |
| 74 | for span in content.spans() { |
| 75 | let units = content.text()[from..span.end].encode_utf16().count(); |
| 76 | out.extend(std::iter::repeat_n(span.format.clone(), units)); |
| 77 | from = span.end; |
| 78 | } |
| 79 | out |
| 80 | } |
| 81 | |
| 82 | /// A remote holding the fixture page with `change` applied by a native author. |
| 83 | fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server { |
| 84 | let mut page = page_of(OUTLINES, space); |
| 85 | change(&mut page); |
| 86 | Server::new(ops::saved(OUTLINES, space, &page).unwrap().as_slice()) |
| 87 | } |
| 88 | |
| 89 | fn cache(directory: &tempfile::TempDir) -> Replica { |
| 90 | Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap() |
| 91 | } |
| 92 | |
| 93 | #[test] |
| 94 | fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() { |
| 95 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 96 | let ids = plain(&page); |
| 97 | let directory = tempfile::tempdir().unwrap(); |
| 98 | let cache = cache(&directory); |
| 99 | let id = save(&cache, ids[0], |page| { |
| 100 | restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); |
| 101 | }) |
| 102 | .unwrap() |
| 103 | .unwrap(); |
| 104 | let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); |
| 105 | assert!( |
| 106 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 107 | ); |
| 108 | let published = page_of(&server.durable, space); |
| 109 | assert!( |
| 110 | formats(&published, ids[0]) |
| 111 | .iter() |
| 112 | .all(|format| format.bold == Some(true)) |
| 113 | ); |
| 114 | assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling"); |
| 115 | assert_eq!(snapshot(&cache), server.durable); |
| 116 | } |
| 117 | |
| 118 | #[test] |
| 119 | fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() { |
| 120 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 121 | let ids = plain(&page); |
| 122 | for typed in [false, true] { |
| 123 | let directory = tempfile::tempdir().unwrap(); |
| 124 | let cache = cache(&directory); |
| 125 | let id = save(&cache, ids[0], |page| { |
| 126 | restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); |
| 127 | }) |
| 128 | .unwrap() |
| 129 | .unwrap(); |
| 130 | let local = snapshot(&cache); |
| 131 | let mut server = remote_with(space, |page| { |
| 132 | if typed { |
| 133 | replace_text(page, ids[0], 0..0, "Remote "); |
| 134 | } else { |
| 135 | restyle(page, ids[0], 0..3, |format| format.italic = Some(true)); |
| 136 | } |
| 137 | }); |
| 138 | let outcome = cache.sync_once(&mut server).unwrap().edit; |
| 139 | if typed { |
| 140 | // Text typed before the formatted range moves it; the ranges do not meet. |
| 141 | assert!(matches!(outcome, Some((n, EditStatus::Published { .. })) if n == id)); |
| 142 | let published = page_of(&server.durable, space); |
| 143 | assert!(text_of(&published, ids[0]).starts_with("Remote Anchor")); |
| 144 | let styles = formats(&published, ids[0]); |
| 145 | for (offset, format) in styles.iter().enumerate() { |
| 146 | assert_eq!( |
| 147 | format.bold == Some(true), |
| 148 | (7..13).contains(&offset), |
| 149 | "{offset}" |
| 150 | ); |
| 151 | } |
| 152 | } else { |
| 153 | assert!(matches!(outcome, Some((_, EditStatus::Published { .. })))); |
| 154 | assert!(conflicted(&server.durable, space)); |
| 155 | let remote = page_of(&server.durable, space); |
| 156 | assert!( |
| 157 | formats(&remote, ids[0]) |
| 158 | .iter() |
| 159 | .all(|format| format.bold != Some(true)) |
| 160 | ); |
| 161 | assert_eq!(pages(&snapshot(&cache)), pages(&server.durable)); |
| 162 | assert_ne!(pages(&local), pages(&server.durable)); |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn competing_formatting_of_one_paragraph_keeps_a_conflict_page_until_made_again() { |
| 169 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 170 | let ids = plain(&page); |
| 171 | let directory = tempfile::tempdir().unwrap(); |
| 172 | let path = directory.path().join("competing.sqlite"); |
| 173 | let cache = Replica::create(&path, OUTLINES).unwrap(); |
| 174 | let id = save(&cache, ids[0], |page| { |
| 175 | restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0)); |
| 176 | }) |
| 177 | .unwrap() |
| 178 | .unwrap(); |
| 179 | let mut server = remote_with(space, |page| { |
| 180 | restyle(page, ids[0], 0..3, |format| format.italic = Some(true)); |
| 181 | }); |
| 182 | assert!(matches!( |
| 183 | cache.sync_once(&mut server).unwrap().edit, |
| 184 | Some((_, EditStatus::Published { .. })) |
| 185 | )); |
| 186 | assert!(conflicted(&server.durable, space)); |
| 187 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 188 | drop(cache); |
| 189 | let cache = Replica::open(&path).unwrap(); |
| 190 | assert!(matches!( |
| 191 | cache.status(id).unwrap(), |
| 192 | Some(EditStatus::Published { .. }) |
| 193 | )); |
| 194 | save(&cache, ids[0], |page| { |
| 195 | restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0)); |
| 196 | }) |
| 197 | .unwrap(); |
| 198 | assert!(matches!( |
| 199 | cache.sync_once(&mut server).unwrap().edit, |
| 200 | Some((_, EditStatus::Published { .. })) |
| 201 | )); |
| 202 | let published = formats(&page_of(&server.durable, space), ids[0]); |
| 203 | assert!( |
| 204 | published |
| 205 | .iter() |
| 206 | .all(|format| format.font_size == Some(14.0)) |
| 207 | ); |
| 208 | assert_eq!( |
| 209 | published |
| 210 | .iter() |
| 211 | .filter(|format| format.italic == Some(true)) |
| 212 | .count(), |
| 213 | 3 |
| 214 | ); |
| 215 | } |
| 216 | |
| 217 | #[test] |
| 218 | fn every_visual_attribute_publishes_through_a_page_model_save() { |
| 219 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 220 | let ids = plain(&page); |
| 221 | for (name, apply) in [ |
| 222 | ( |
| 223 | "bold", |
| 224 | (|f: &mut Format| f.bold = Some(true)) as fn(&mut Format), |
| 225 | ), |
| 226 | ("italic", |f| f.italic = Some(true)), |
| 227 | ("underline", |f| f.underline = Some(true)), |
| 228 | ("strike", |f| f.strike = Some(true)), |
| 229 | ("superscript", |f| f.superscript = Some(true)), |
| 230 | ("subscript", |f| f.subscript = Some(true)), |
| 231 | ("font", |f| f.font = Some("Georgia".into())), |
| 232 | ("font size", |f| f.font_size = Some(21.0)), |
| 233 | ("color", |f| f.color = Some(0x0056_3412)), |
| 234 | ("highlight", |f| f.highlight = Some(0x0006_0504)), |
| 235 | ] { |
| 236 | let directory = tempfile::tempdir().unwrap(); |
| 237 | let cache = cache(&directory); |
| 238 | let id = save(&cache, ids[0], |page| restyle(page, ids[0], 2..6, apply)) |
| 239 | .unwrap() |
| 240 | .unwrap(); |
| 241 | let mut server = Server::new(OUTLINES); |
| 242 | assert!( |
| 243 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id), |
| 244 | "{name}" |
| 245 | ); |
| 246 | let mut expected = formats(&page_of(OUTLINES, space), ids[0]); |
| 247 | assert_eq!(expected.len(), 6); |
| 248 | for format in &mut expected[2..] { |
| 249 | apply(format); |
| 250 | } |
| 251 | assert_eq!( |
| 252 | formats(&page_of(&server.durable, space), ids[0]), |
| 253 | expected, |
| 254 | "{name}" |
| 255 | ); |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone() { |
| 261 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 262 | let ids = plain(&page); |
| 263 | let lengths: Vec<u32> = ids |
| 264 | .iter() |
| 265 | .map(|id| u32::try_from(text_of(&page, *id).encode_utf16().count()).unwrap()) |
| 266 | .collect(); |
| 267 | let (mut published, mut conflicts) = (0, 0); |
| 268 | let seeds = sweep::seeds(1..65, 16); |
| 269 | for seed in seeds.clone() { |
| 270 | let mut state = seed; |
| 271 | let mut next = || { |
| 272 | state = state |
| 273 | .wrapping_mul(6364136223846793005) |
| 274 | .wrapping_add(1442695040888963407); |
| 275 | usize::try_from(state >> 33).unwrap() |
| 276 | }; |
| 277 | let target = next() % ids.len(); |
| 278 | let start = u32::try_from(next()).unwrap() % lengths[target]; |
| 279 | let end = start + 1 + u32::try_from(next()).unwrap() % (lengths[target] - start); |
| 280 | let attribute: fn(&mut Format) = match seed % 3 { |
| 281 | 0 => |format| format.bold = Some(true), |
| 282 | 1 => |format| format.font_size = Some(21.0), |
| 283 | _ => |format| format.color = Some(0x0012_3456), |
| 284 | }; |
| 285 | let remote: Vec<usize> = (0..1 + next() % 3).map(|_| next() % ids.len()).collect(); |
| 286 | let directory = tempfile::tempdir().unwrap(); |
| 287 | let cache = cache(&directory); |
| 288 | let id = save(&cache, ids[target], |page| { |
| 289 | restyle(page, ids[target], start..end, attribute); |
| 290 | }) |
| 291 | .unwrap() |
| 292 | .unwrap(); |
| 293 | let local = snapshot(&cache); |
| 294 | let mut server = remote_with(space, |page| { |
| 295 | for (step, &at) in remote.iter().enumerate() { |
| 296 | if step % 2 == 0 { |
| 297 | replace_text(page, ids[at], 0..0, "\u{2022}"); |
| 298 | } else { |
| 299 | restyle(page, ids[at], 0..1, |format| format.underline = Some(true)); |
| 300 | } |
| 301 | } |
| 302 | }); |
| 303 | let remote_page = page_of(&server.durable, space); |
| 304 | let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 305 | // Bullets the remote typed before the target's text, and whether it underlined a |
| 306 | // character the target already had. |
| 307 | let mut bullets = vec![0_u32; ids.len()]; |
| 308 | let mut underlined = vec![false; ids.len()]; |
| 309 | for (step, &at) in remote.iter().enumerate() { |
| 310 | if step % 2 == 0 { |
| 311 | bullets[at] += 1; |
| 312 | } else if bullets[at] == 0 { |
| 313 | underlined[at] = true; |
| 314 | } |
| 315 | } |
| 316 | if underlined[target] && start == 0 { |
| 317 | conflicts += 1; |
| 318 | // The step names its batch by the newest edit: the conflict page the rebase queued. |
| 319 | assert!(outcome.0 > id, "seed {seed}"); |
| 320 | assert!( |
| 321 | matches!(outcome.1, EditStatus::Published { .. }), |
| 322 | "seed {seed}" |
| 323 | ); |
| 324 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1), "seed {seed}"); |
| 325 | assert!(conflicted(&server.durable, space), "seed {seed}"); |
| 326 | assert_eq!(page_of(&server.durable, space), remote_page, "seed {seed}"); |
| 327 | assert_ne!(pages(&snapshot(&cache)), pages(&local), "seed {seed}"); |
| 328 | continue; |
| 329 | } |
| 330 | published += 1; |
| 331 | assert_eq!(outcome.0, id, "seed {seed}"); |
| 332 | assert!( |
| 333 | matches!(outcome.1, EditStatus::Published { .. }), |
| 334 | "seed {seed}: {outcome:?}" |
| 335 | ); |
| 336 | let page = page_of(&server.durable, space); |
| 337 | let shift = bullets[target] as usize; |
| 338 | let mut expected: Vec<Format> = formats(&remote_page, ids[target]); |
| 339 | for format in &mut expected[start as usize + shift..end as usize + shift] { |
| 340 | attribute(format); |
| 341 | } |
| 342 | assert_eq!(formats(&page, ids[target]), expected, "seed {seed}"); |
| 343 | for &at in &remote { |
| 344 | assert!( |
| 345 | text_of(&page, ids[at]).starts_with('\u{2022}') |
| 346 | || formats(&page, ids[at])[0].underline == Some(true), |
| 347 | "seed {seed}" |
| 348 | ); |
| 349 | } |
| 350 | assert_eq!(snapshot(&cache), server.durable, "seed {seed}"); |
| 351 | } |
| 352 | assert!( |
| 353 | published * 4 >= seeds.end - seeds.start, |
| 354 | "{published} published, {conflicts} conflicts" |
| 355 | ); |
| 356 | } |
| 357 | |
| 358 | #[test] |
| 359 | fn offline_insertions_survive_reopen_and_carry_their_formatting() { |
| 360 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 361 | let ids = plain(&page); |
| 362 | let directory = tempfile::tempdir().unwrap(); |
| 363 | let path = directory.path().join("insert.sqlite"); |
| 364 | let mut cache = Replica::create(&path, OUTLINES).unwrap(); |
| 365 | let id = save(&cache, ids[0], |page| { |
| 366 | insert_outline(page, 72.0, 144.0, "Offline outline"); |
| 367 | insert_after(page, ids[0], "Offline paragraph 🦀"); |
| 368 | }) |
| 369 | .unwrap() |
| 370 | .unwrap(); |
| 371 | let local = snapshot(&cache); |
| 372 | let pending = cache.pending().unwrap(); |
| 373 | drop(cache); |
| 374 | cache = Replica::open(&path).unwrap(); |
| 375 | assert_eq!(pages(&snapshot(&cache)), pages(&local)); |
| 376 | assert_eq!(cache.pending().unwrap(), pending); |
| 377 | let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); |
| 378 | assert!( |
| 379 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 380 | ); |
| 381 | let published = page_of(&server.durable, space); |
| 382 | let inserted = plain(&published) |
| 383 | .into_iter() |
| 384 | .find(|id| text_of(&published, *id) == "Offline paragraph 🦀") |
| 385 | .unwrap(); |
| 386 | assert!( |
| 387 | plain(&published) |
| 388 | .iter() |
| 389 | .any(|id| text_of(&published, *id) == "Offline outline") |
| 390 | ); |
| 391 | assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling"); |
| 392 | let styled = save(&cache, inserted, |page| { |
| 393 | restyle(page, inserted, 8..17, |format| format.bold = Some(true)); |
| 394 | }) |
| 395 | .unwrap() |
| 396 | .unwrap(); |
| 397 | assert!( |
| 398 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == styled) |
| 399 | ); |
| 400 | let styles = formats(&page_of(&server.durable, space), inserted); |
| 401 | for (offset, format) in styles.iter().enumerate() { |
| 402 | assert_eq!(format.bold == Some(true), (8..17).contains(&offset)); |
| 403 | } |
| 404 | assert_eq!(snapshot(&cache), server.durable); |
| 405 | } |
| 406 | |
| 407 | #[test] |
| 408 | fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() { |
| 409 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 410 | let ids = plain(&page); |
| 411 | for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { |
| 412 | let directory = tempfile::tempdir().unwrap(); |
| 413 | let path = directory.path().join("uncertain.sqlite"); |
| 414 | let cache = Replica::create(&path, OUTLINES).unwrap(); |
| 415 | let id = save(&cache, ids[0], |page| { |
| 416 | restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); |
| 417 | }) |
| 418 | .unwrap() |
| 419 | .unwrap(); |
| 420 | let local = snapshot(&cache); |
| 421 | let mut server = Server::new(OUTLINES); |
| 422 | server.fault = fault; |
| 423 | assert!(cache.sync_once(&mut server).is_err()); |
| 424 | let state = cache.status(id).unwrap().unwrap(); |
| 425 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); |
| 426 | drop(cache); |
| 427 | let cache = Replica::open(&path).unwrap(); |
| 428 | assert_eq!(cache.status(id).unwrap(), Some(state.clone())); |
| 429 | if matches!(fault, Fault::UnknownBefore) { |
| 430 | for _ in 0..3 { |
| 431 | assert_eq!( |
| 432 | cache.sync_once(&mut server).unwrap().edit, |
| 433 | Some((id, state.clone())) |
| 434 | ); |
| 435 | } |
| 436 | assert_eq!(server.publications, 1); |
| 437 | assert_eq!(server.durable, OUTLINES); |
| 438 | assert_eq!(pages(&snapshot(&cache)), pages(&local)); |
| 439 | continue; |
| 440 | } |
| 441 | assert!(matches!( |
| 442 | cache.sync_once(&mut server).unwrap().edit, |
| 443 | Some((_, EditStatus::Published { .. })) |
| 444 | )); |
| 445 | assert_eq!(server.publications, 1); |
| 446 | assert!(cache.pending().unwrap().is_empty()); |
| 447 | assert!( |
| 448 | formats(&page_of(&server.durable, space), ids[0]) |
| 449 | .iter() |
| 450 | .all(|format| format.bold == Some(true)) |
| 451 | ); |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn a_format_the_remote_already_carries_is_confirmed_without_republishing() { |
| 457 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 458 | let ids = plain(&page); |
| 459 | let directory = tempfile::tempdir().unwrap(); |
| 460 | let cache = cache(&directory); |
| 461 | let id = save(&cache, ids[0], |page| { |
| 462 | restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); |
| 463 | }) |
| 464 | .unwrap() |
| 465 | .unwrap(); |
| 466 | let mut server = remote_with(space, |page| { |
| 467 | restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); |
| 468 | }); |
| 469 | server.durable = OUTLINES.to_vec(); |
| 470 | server.fault = Fault::Confirm; |
| 471 | assert!(cache.sync_once(&mut server).is_err()); |
| 472 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 473 | assert_eq!(server.durable, OUTLINES); |
| 474 | assert!( |
| 475 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 476 | ); |
| 477 | assert_eq!((server.publications, server.confirmations), (0, 2)); |
| 478 | assert!(cache.pending().unwrap().is_empty()); |
| 479 | let published = page_of(&snapshot(&cache), space); |
| 480 | assert_eq!(published, page_of(&server.durable, space)); |
| 481 | assert!( |
| 482 | formats(&published, ids[0]) |
| 483 | .iter() |
| 484 | .all(|format| format.bold == Some(true)) |
| 485 | ); |
| 486 | } |
| 487 | |
| 488 | #[test] |
| 489 | fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() { |
| 490 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 491 | let ids = plain(&page); |
| 492 | let mut after = page.clone(); |
| 493 | restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true)); |
| 494 | let directory = tempfile::tempdir().unwrap(); |
| 495 | let cache = cache(&directory); |
| 496 | let id = model_ops::save_as(&cache, space, &after, AUTHOR) |
| 497 | .unwrap() |
| 498 | .unwrap(); |
| 499 | let mut server = Server::new(OUTLINES); |
| 500 | server.fault = Fault::UnknownAfter; |
| 501 | assert!(cache.sync_once(&mut server).is_err()); |
| 502 | let attempt = cache.status(id).unwrap(); |
| 503 | assert!(matches!( |
| 504 | attempt, |
| 505 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 506 | )); |
| 507 | let partial = remote_with(space, |page| { |
| 508 | restyle(page, ids[0], 0..3, |format| format.bold = Some(true)); |
| 509 | }); |
| 510 | server.visible.clone_from(&partial.visible); |
| 511 | assert_eq!( |
| 512 | cache.sync_once(&mut server).unwrap().edit, |
| 513 | attempt.map(|state| (id, state)) |
| 514 | ); |
| 515 | assert_eq!(server.confirmations, 0); |
| 516 | let complete = remote_with(space, |page| { |
| 517 | restyle(page, ids[0], 0..6, |format| format.bold = Some(true)); |
| 518 | }); |
| 519 | server.visible.clone_from(&complete.visible); |
| 520 | assert_eq!(page_of(&server.visible, space), after); |
| 521 | assert!( |
| 522 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 523 | ); |
| 524 | assert_eq!((server.publications, server.confirmations), (1, 1)); |
| 525 | assert!(cache.pending().unwrap().is_empty()); |
| 526 | } |
| 527 | |
| 528 | #[test] |
| 529 | fn a_formatted_save_of_an_unchanged_model_queues_nothing() { |
| 530 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 531 | let directory = tempfile::tempdir().unwrap(); |
| 532 | let cache = cache(&directory); |
| 533 | assert_eq!( |
| 534 | model_ops::save_as(&cache, space, &page, AUTHOR).unwrap(), |
| 535 | None |
| 536 | ); |
| 537 | assert!(cache.pending().unwrap().is_empty()); |
| 538 | } |
| 539 | |
| 540 | #[test] |
| 541 | fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() { |
| 542 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 543 | let ids = plain(&page); |
| 544 | let attributes: &[fn(&mut Format)] = &[ |
| 545 | |f| f.alignment = Some(2), |
| 546 | |f| f.rtl = Some(true), |
| 547 | |f| f.space_before = Some(12.0), |
| 548 | |f| f.space_after = Some(6.0), |
| 549 | |f| f.line_spacing = Some(18.0), |
| 550 | ]; |
| 551 | for apply in attributes { |
| 552 | let directory = tempfile::tempdir().unwrap(); |
| 553 | let cache = cache(&directory); |
| 554 | let id = save(&cache, ids[0], |page| restyle(page, ids[0], 0..6, apply)) |
| 555 | .unwrap() |
| 556 | .unwrap(); |
| 557 | let expected = formats(&page_of(&snapshot(&cache), space), ids[0]); |
| 558 | drop(cache); |
| 559 | let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); |
| 560 | let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); |
| 561 | server.fault = Fault::UnknownAfter; |
| 562 | assert!( |
| 563 | matches!(cache.sync_once(&mut server), Err(notebook::Error::Remote(error)) if error.state == onestore::CommitState::Unknown) |
| 564 | ); |
| 565 | assert!(matches!( |
| 566 | cache.status(id).unwrap(), |
| 567 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 568 | )); |
| 569 | drop(cache); |
| 570 | let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); |
| 571 | assert!( |
| 572 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 573 | ); |
| 574 | assert_eq!(server.publications, 1); |
| 575 | let page = page_of(&server.durable, space); |
| 576 | assert_eq!(formats(&page, ids[0]), expected); |
| 577 | assert!(text_of(&page, ids[3]).starts_with("Remote ")); |
| 578 | } |
| 579 | } |