1#[path = "../../onestore/tests/support/ops.rs"]
2mod ops;
3use notebook::{
4 EditStatus, Recovery, Resolution,
5 session::{Event, Notebook, Section, SyncStatus},
6};
7use onestore::{
8 ExGuid,
9 op::{Edit, Op, PageOp},
10 page::Page,
11};
12use std::{
13 path::Path,
14 sync::{
15 Arc,
16 atomic::{AtomicUsize, Ordering},
17 },
18 time::{Duration, Instant},
19};
20
21#[path = "support/model_ops.rs"]
22mod model_ops;
23
24fn first_text(page: &Page) -> ExGuid {
25 page.objects
26 .iter()
27 .find_map(|object| match object {
28 onestore::page::PageObject::Outline(outline) => outline
29 .paragraphs
30 .iter()
31 .find_map(|p| p.text().map(|t| t.id)),
32 _ => None,
33 })
34 .unwrap()
35}
36
37fn edited(page: &Page, text: &str) -> Page {
38 let mut after = page.clone();
39 let id = first_text(page);
40 model_ops::replace_text(&mut after, id, 0..0, text);
41 after
42}
43
44/// Replaces `range` of the page's first body text as the editor does: the edit's id once
45/// it is durable.
46fn typed(
47 section: &Section,
48 space: ExGuid,
49 page: &Page,
50 range: std::ops::Range<u32>,
51 text: &str,
52) -> u64 {
53 let op = PageOp::Text {
54 text: first_text(page),
55 range,
56 with: text.into(),
57 };
58 let edit = Edit {
59 at: model_ops::now(),
60 ops: vec![Op::Page { space, op }],
61 };
62 section.replica().apply("Editor", edit).unwrap()
63}
64
65fn open(file: &Path, cache: &Path) -> (Section, Arc<AtomicUsize>) {
66 let notified = Arc::new(AtomicUsize::new(0));
67 let counter = Arc::clone(&notified);
68 let section = Section::open(file, cache, move || {
69 counter.fetch_add(1, Ordering::SeqCst);
70 })
71 .unwrap();
72 (section, notified)
73}
74
75fn wait(section: &Section, mut accept: impl FnMut(&Event) -> bool) {
76 let deadline = Instant::now() + Duration::from_secs(20);
77 loop {
78 for event in section.events() {
79 if accept(&event) {
80 return;
81 }
82 }
83 assert!(
84 Instant::now() < deadline,
85 "the expected event did not arrive"
86 );
87 std::thread::sleep(Duration::from_millis(20));
88 }
89}
90
91fn published(section: &Section, id: u64) {
92 let deadline = Instant::now() + Duration::from_secs(20);
93 while !matches!(
94 section.status(id).unwrap(),
95 Some(EditStatus::Published { .. })
96 ) {
97 assert!(Instant::now() < deadline, "the edit was not published");
98 std::thread::sleep(Duration::from_millis(20));
99 }
100}
101
102fn status_until(section: &Section, accept: impl Fn(&SyncStatus) -> bool) -> SyncStatus {
103 let deadline = Instant::now() + Duration::from_secs(20);
104 loop {
105 let status = section.sync_status().unwrap();
106 if accept(&status) {
107 return status;
108 }
109 assert!(Instant::now() < deadline, "the status stayed {status:?}");
110 std::thread::sleep(Duration::from_millis(20));
111 }
112}
113
114fn stored_page(file: &Path, space: ExGuid) -> Page {
115 model_ops::page_of(&onestore::read_file(file).unwrap(), space)
116}
117
118/// The page title is derived from its content on read; an edited model keeps the old one.
119fn assert_same(actual: Page, expected: &Page) {
120 let mut expected = expected.clone();
121 expected.title = actual.title.clone();
122 assert_eq!(actual, expected);
123}
124
125#[test]
126fn a_section_is_shared_between_threads() {
127 fn shared<T: Send + Sync>() {}
128 shared::<Section>();
129 shared::<notebook::Replica>();
130}
131
132#[test]
133fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_relaunch() {
134 let directory = tempfile::tempdir().unwrap();
135 let file = directory.path().join("notes.one");
136 let cache = directory.path().join("cache");
137 std::fs::write(
138 &file,
139 onestore::create_section("notes.one", "Original", "Author").unwrap(),
140 )
141 .unwrap();
142 let (section, notified) = open(&file, &cache);
143 let pages = section.pages().unwrap();
144 assert_eq!(pages.len(), 1);
145 let space = pages[0].0;
146 let before = section.page(space).unwrap();
147 let after = edited(&before, "Saved ");
148 let id = typed(&section, space, &before, 0..0, "Saved ");
149 assert_same(section.page(space).unwrap(), &after);
150 published(&section, id);
151 // The worker reports the step after writing its receipt.
152 let deadline = Instant::now() + Duration::from_secs(20);
153 while notified.load(Ordering::SeqCst) == 0 {
154 assert!(Instant::now() < deadline, "the host was not woken");
155 std::thread::sleep(Duration::from_millis(10));
156 }
157 assert_same(stored_page(&file, space), &after);
158 assert!(matches!(
159 section.status(id).unwrap(),
160 Some(EditStatus::Published { .. })
161 ));
162 section.close().unwrap();
163 let (section, _) = open(&file, &cache);
164 assert_same(section.page(space).unwrap(), &after);
165 assert!(section.pending().unwrap().is_empty());
166 assert!(matches!(
167 section.status(id).unwrap(),
168 Some(EditStatus::Published { .. })
169 ));
170 section.close().unwrap();
171 if let Some(destination) = std::env::var_os("ONESTORE_SESSION_NATIVE_EXPORT") {
172 let destination = std::path::PathBuf::from(destination);
173 std::fs::create_dir(&destination).unwrap();
174 let bytes = onestore::read_file(&file).unwrap();
175 let identity = onestore::Store::parse(&bytes).unwrap().header.file_id;
176 std::fs::write(destination.join("notes.one"), bytes).unwrap();
177 std::fs::write(
178 destination.join("Open Notebook.onetoc2"),
179 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("notes.one", identity)])
180 .unwrap(),
181 )
182 .unwrap();
183 }
184}
185
186#[test]
187fn saves_wait_for_an_unreachable_file_and_publish_after_relaunch() {
188 let directory = tempfile::tempdir().unwrap();
189 let file = directory.path().join("notes.one");
190 let cache = directory.path().join("cache");
191 std::fs::write(
192 &file,
193 onestore::create_section("notes.one", "Original", "Author").unwrap(),
194 )
195 .unwrap();
196 let (section, _) = open(&file, &cache);
197 let space = section.pages().unwrap()[0].0;
198 let before = section.page(space).unwrap();
199 let permissions = std::fs::metadata(&file).unwrap().permissions();
200 let mut read_only = permissions.clone();
201 read_only.set_readonly(true);
202 std::fs::set_permissions(&file, read_only).unwrap();
203 let after = edited(&before, "Offline ");
204 let id = typed(&section, space, &before, 0..0, "Offline ");
205 wait(&section, |event| matches!(event, Event::Unreachable(_)));
206 section.close().unwrap();
207 assert_eq!(
208 notebook::Replica::open(
209 walkdir(&cache)
210 .into_iter()
211 .find(|path| path.extension().is_some_and(|ext| ext == "sqlite"))
212 .expect("the section's replica")
213 )
214 .unwrap()
215 .status(id)
216 .unwrap(),
217 Some(EditStatus::Pending)
218 );
219 let (section, _) = open(&file, &cache);
220 assert_same(section.page(space).unwrap(), &after);
221 assert_eq!(section.pending().unwrap().len(), 1);
222 assert_eq!(stored_page(&file, space), before);
223 std::fs::set_permissions(&file, permissions).unwrap();
224 section.wake();
225 published(&section, id);
226 assert_same(stored_page(&file, space), &after);
227 section.close().unwrap();
228}
229
230#[test]
231fn an_external_change_reloads_the_page_and_later_edits_apply_to_it() {
232 let directory = tempfile::tempdir().unwrap();
233 let file = directory.path().join("notes.one");
234 let cache = directory.path().join("cache");
235 std::fs::write(
236 &file,
237 onestore::create_section("notes.one", "Original", "Author").unwrap(),
238 )
239 .unwrap();
240 let (section, _) = open(&file, &cache);
241 let space = section.pages().unwrap()[0].0;
242 let before = section.page(space).unwrap();
243 let native = edited(&before, "Native ");
244 let deadline = Instant::now() + Duration::from_secs(20);
245 loop {
246 let bytes = onestore::read_file(&file).unwrap();
247 match ops::save(&bytes, space, &native)
248 .unwrap()
249 .commit_file(&file)
250 {
251 Ok(()) => break,
252 Err(error) if error.error.kind() == std::io::ErrorKind::WouldBlock => {
253 assert!(Instant::now() < deadline);
254 std::thread::sleep(Duration::from_millis(20));
255 }
256 Err(error) => panic!("{error:?}"),
257 }
258 }
259 section.wake();
260 wait(
261 &section,
262 |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)),
263 );
264 assert_same(section.page(space).unwrap(), &native);
265 // An edit made on the page shown before the change applies to the page as it is now.
266 let id = typed(&section, space, &before, 0..0, "Local ");
267 let after = edited(&native, "Local ");
268 published(&section, id);
269 assert_same(stored_page(&file, space), &after);
270 section.close().unwrap();
271}
272
273#[test]
274fn edits_apply_in_order_without_waiting_and_finish_before_close() {
275 let directory = tempfile::tempdir().unwrap();
276 let file = directory.path().join("notes.one");
277 let cache = directory.path().join("cache");
278 std::fs::write(
279 &file,
280 onestore::create_section("notes.one", "Original", "Author").unwrap(),
281 )
282 .unwrap();
283 let (section, _) = open(&file, &cache);
284 let space = section.pages().unwrap()[0].0;
285 let original = section.page(space).unwrap();
286 let text = first_text(&original);
287 let apply = |word: &str| {
288 let op = PageOp::Text {
289 text,
290 range: 0..0,
291 with: word.into(),
292 };
293 let edit = Edit {
294 at: model_ops::now(),
295 ops: vec![Op::Page { space, op }],
296 };
297 section.apply("Editor", edit).unwrap();
298 };
299 let mut expected = original.clone();
300 for word in ["one ", "two ", "three ", "four ", "five "] {
301 apply(word);
302 expected = edited(&expected, word);
303 }
304 let deadline = Instant::now() + Duration::from_secs(20);
305 loop {
306 let mut stored = section.page(space).unwrap();
307 stored.title = expected.title.clone();
308 if stored == expected {
309 break;
310 }
311 assert!(Instant::now() < deadline, "the edits did not arrive");
312 std::thread::sleep(Duration::from_millis(20));
313 }
314 while !section.pending().unwrap().is_empty() {
315 assert!(Instant::now() < deadline, "the edits were not published");
316 std::thread::sleep(Duration::from_millis(20));
317 }
318 let native = edited(&section.page(space).unwrap(), "Native ");
319 let deadline = Instant::now() + Duration::from_secs(20);
320 loop {
321 let bytes = onestore::read_file(&file).unwrap();
322 match ops::save(&bytes, space, &native)
323 .unwrap()
324 .commit_file(&file)
325 {
326 Ok(()) => break,
327 Err(error) if error.error.kind() == std::io::ErrorKind::WouldBlock => {
328 assert!(Instant::now() < deadline);
329 std::thread::sleep(Duration::from_millis(20));
330 }
331 Err(error) => panic!("{error:?}"),
332 }
333 }
334 section.wake();
335 wait(
336 &section,
337 |event| matches!(event, Event::Changed(spaces) if spaces.contains(&space)),
338 );
339 apply("Local ");
340 section.close().unwrap();
341 let (section, _) = open(&file, &cache);
342 assert_same(section.page(space).unwrap(), &edited(&native, "Local "));
343 section.close().unwrap();
344}
345
346#[test]
347fn a_notebook_directory_lists_its_sections_and_opens_them() {
348 let directory = tempfile::tempdir().unwrap();
349 let root = directory.path().join("Personal");
350 std::fs::create_dir_all(root.join("Group")).unwrap();
351 for (name, text) in [
352 ("Personal/First.one", "One"),
353 ("Personal/Group/Second.one", "Two"),
354 ] {
355 std::fs::write(
356 directory.path().join(name),
357 onestore::create_section(
358 Path::new(name).file_name().unwrap().to_str().unwrap(),
359 text,
360 "Author",
361 )
362 .unwrap(),
363 )
364 .unwrap();
365 }
366 let notebook = Notebook::open(&root, directory.path().join("cache")).unwrap();
367 let catalog = notebook.catalog();
368 assert_eq!(catalog.sections.len(), 1);
369 assert_eq!(catalog.groups.len(), 1);
370 assert_eq!(catalog.groups[0].sections.len(), 1);
371 let path = catalog.groups[0].sections[0].path.clone();
372 let section = notebook.section(&path, || {}).unwrap();
373 let (space, _, _) = section.pages().unwrap()[0];
374 let page = section.page(space).unwrap();
375 let text = first_text(&page);
376 assert_eq!(
377 model_ops::paragraph_with(&page, text)
378 .unwrap()
379 .text()
380 .unwrap()
381 .text
382 .text(),
383 "Two"
384 );
385 section.close().unwrap();
386}
387
388/// The replicas in `cache`, in the folder of each location.
389fn replicas(cache: &Path) -> usize {
390 std::fs::read_dir(cache.join("replicas"))
391 .into_iter()
392 .flatten()
393 .flat_map(|folder| std::fs::read_dir(folder.unwrap().path()).unwrap())
394 .filter(|entry| entry.as_ref().unwrap().path().extension() == Some("sqlite".as_ref()))
395 .count()
396}
397
398#[test]
399fn a_notebook_only_opens_discovered_section_paths() {
400 let directory = tempfile::tempdir().unwrap();
401 let root = directory.path().join("Notebook");
402 let cache = directory.path().join("cache");
403 std::fs::create_dir(&root).unwrap();
404 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
405 std::fs::write(root.join("notes.one"), &source).unwrap();
406 let outside = directory.path().join("outside.one");
407 std::fs::write(&outside, &source).unwrap();
408 let notebook = Notebook::open(&root, &cache).unwrap();
409 std::fs::write(root.join("added.one"), &source).unwrap();
410 for path in ["../outside.one", outside.to_str().unwrap(), "added.one"] {
411 assert!(matches!(
412 notebook.section(path, || {}),
413 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound
414 ));
415 }
416 assert_eq!(replicas(&cache), 0);
417 assert_eq!(std::fs::read(&outside).unwrap(), source);
418}
419
420#[cfg(unix)]
421#[test]
422fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() {
423 let directory = tempfile::tempdir().unwrap();
424 let root = directory.path().join("Notebook");
425 let cache = directory.path().join("cache");
426 std::fs::create_dir(&root).unwrap();
427 let file = root.join("notes.one");
428 let outside = directory.path().join("outside.one");
429 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
430 std::fs::write(&file, &source).unwrap();
431 std::fs::write(&outside, &source).unwrap();
432 let notebook = Notebook::open(&root, &cache).unwrap();
433 std::fs::remove_file(&file).unwrap();
434 std::os::unix::fs::symlink(&outside, &file).unwrap();
435 assert!(matches!(
436 notebook.section("notes.one", || {}),
437 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied
438 ));
439 assert_eq!(replicas(&cache), 0);
440 assert_eq!(std::fs::read(&outside).unwrap(), source);
441}
442
443#[test]
444fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {
445 let directory = tempfile::tempdir().unwrap();
446 let file = directory.path().join("remote.one");
447 let cache = directory.path().join("replica.sqlite");
448 let source = onestore::create_section("remote.one", "Original", "Author").unwrap();
449 let replica = notebook::Replica::create(&cache, &source).unwrap();
450 let section = Section::resume(&file, replica, || {}).unwrap();
451 let space = section.pages().unwrap()[0].0;
452 let before = section.page(space).unwrap();
453 let after = edited(&before, "First ");
454 let id = typed(&section, space, &before, 0..0, "First ");
455 wait(
456 &section,
457 |event| matches!(event, Event::Unreachable(error) if error.kind() == std::io::ErrorKind::NotFound),
458 );
459 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
460 section.close().unwrap();
461 assert!(!file.exists());
462
463 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();
464 assert_same(section.page(space).unwrap(), &after);
465 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
466 let before = section.page(space).unwrap();
467 let after = edited(&before, "Second ");
468 let next = typed(&section, space, &before, 0..0, "Second ");
469 assert!(!file.exists());
470 let restored = directory.path().join("restored.one");
471 std::fs::write(&restored, &source).unwrap();
472 std::fs::rename(restored, &file).unwrap();
473 section.wake();
474 published(&section, next);
475 assert_same(stored_page(&file, space), &after);
476 section.close().unwrap();
477 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();
478 assert_same(section.page(space).unwrap(), &after);
479 assert!(section.pending().unwrap().is_empty());
480 section.close().unwrap();
481}
482
483#[test]
484fn resuming_against_another_document_preserves_both_remote_and_pending_edit() {
485 let directory = tempfile::tempdir().unwrap();
486 let file = directory.path().join("other.one");
487 let cache = directory.path().join("replica.sqlite");
488 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
489 let replica = notebook::Replica::create(&cache, &source).unwrap();
490 let section = Section::resume(&file, replica, || {}).unwrap();
491 let space = section.pages().unwrap()[0].0;
492 let before = section.page(space).unwrap();
493 let after = edited(&before, "Local ");
494 let id = typed(&section, space, &before, 0..0, "Local ");
495 wait(&section, |event| matches!(event, Event::Unreachable(_)));
496 section.close().unwrap();
497
498 let other = onestore::create_section("other.one", "Unrelated", "Other").unwrap();
499 std::fs::write(&file, &other).unwrap();
500 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();
501 wait(&section, |event| matches!(event, Event::Failed(_)));
502 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
503 assert_same(section.page(space).unwrap(), &after);
504 assert_eq!(std::fs::read(&file).unwrap(), other);
505 assert!(section.close().is_err());
506 let replica = notebook::Replica::open(&cache).unwrap();
507 assert_eq!(replica.status(id).unwrap(), Some(EditStatus::Pending));
508 assert_eq!(std::fs::read(&file).unwrap(), other);
509}
510
511#[test]
512fn offline_save_process() {
513 let Some(root) = std::env::var_os("ONENOTE_SESSION_CHILD") else {
514 return;
515 };
516 let root = std::path::PathBuf::from(root);
517 let replica = notebook::Replica::open(root.join("replica.sqlite")).unwrap();
518 let section = Section::resume(root.join("absent.one"), replica, || {}).unwrap();
519 let space = section.pages().unwrap()[0].0;
520 let before = section.page(space).unwrap();
521 let id = typed(&section, space, &before, 0..0, "Durable ");
522 std::fs::write(root.join("acknowledged"), id.to_string()).unwrap();
523 // Terminate without running Session or SQLite destructors after acknowledgement.
524 std::process::exit(0);
525}
526
527#[test]
528fn an_acknowledged_offline_save_survives_process_exit_without_cleanup() {
529 let directory = tempfile::tempdir().unwrap();
530 let cache = directory.path().join("replica.sqlite");
531 let source = onestore::create_section("absent.one", "Original", "Author").unwrap();
532 drop(notebook::Replica::create(&cache, &source).unwrap());
533 let output = std::process::Command::new(std::env::current_exe().unwrap())
534 .args(["--exact", "offline_save_process", "--nocapture"])
535 .env("ONENOTE_SESSION_CHILD", directory.path())
536 .output()
537 .unwrap();
538 assert!(output.status.success(), "{output:?}");
539 let id: u64 = std::fs::read_to_string(directory.path().join("acknowledged"))
540 .unwrap()
541 .parse()
542 .unwrap();
543 let file = directory.path().join("absent.one");
544 assert!(!file.exists());
545 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();
546 let space = section.pages().unwrap()[0].0;
547 let original = model_ops::page_of(&source, space);
548 let after = edited(&original, "Durable ");
549 assert_same(section.page(space).unwrap(), &after);
550 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
551 section.close().unwrap();
552}
553
554#[cfg(feature = "smb")]
555#[test]
556fn smb_connection_failure_keeps_the_session_locally_editable_and_retries() {
557 let directory = tempfile::tempdir().unwrap();
558 let cache = directory.path().join("replica.sqlite");
559 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
560 let replica = notebook::Replica::create(&cache, &source).unwrap();
561 let attempts = Arc::new(AtomicUsize::new(0));
562 let counter = Arc::clone(&attempts);
563 let section = Section::resume_smb(
564 "Folder/notes.one".into(),
565 replica,
566 1024 * 1024,
567 move || {
568 counter.fetch_add(1, Ordering::SeqCst);
569 Err(std::io::ErrorKind::PermissionDenied.into())
570 },
571 || {},
572 )
573 .unwrap();
574 wait(
575 &section,
576 |event| matches!(event, Event::Unreachable(error) if error.kind() == std::io::ErrorKind::PermissionDenied),
577 );
578 let space = section.pages().unwrap()[0].0;
579 let before = section.page(space).unwrap();
580 let after = edited(&before, "Offline SMB ");
581 let id = typed(&section, space, &before, 0..0, "Offline SMB ");
582 section.wake();
583 wait(
584 &section,
585 |event| matches!(event, Event::Unreachable(error) if error.kind() == std::io::ErrorKind::PermissionDenied),
586 );
587 assert!(attempts.load(Ordering::SeqCst) >= 2);
588 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
589 assert_same(section.page(space).unwrap(), &after);
590 assert_eq!(section.file(), Path::new("Folder/notes.one"));
591 section.close().unwrap();
592 assert_eq!(
593 notebook::Replica::open(&cache).unwrap().status(id).unwrap(),
594 Some(EditStatus::Pending)
595 );
596}
597
598#[cfg(feature = "smb")]
599#[test]
600#[ignore = "requires ONESTORE_SESSION_SMB address and a disposable session.one on its agent share"]
601fn live_smb_session_save_publishes_and_reopens() {
602 use notebook::smb::{Client, Credentials};
603 let address = std::env::var("ONESTORE_SESSION_SMB").unwrap();
604 let connect = move || {
605 Client::connect(
606 &address,
607 "agent",
608 Credentials::default(),
609 Duration::from_secs(5),
610 )
611 };
612 let client = connect().unwrap();
613 let source = client.read("session.one", 1024 * 1024).unwrap();
614 let directory = tempfile::tempdir().unwrap();
615 let cache = directory.path().join("replica.sqlite");
616 let replica = notebook::Replica::create(&cache, &source).unwrap();
617 let section = Section::resume_smb(
618 "session.one".into(),
619 replica,
620 1024 * 1024,
621 connect.clone(),
622 || {},
623 )
624 .unwrap();
625 let space = section.pages().unwrap()[0].0;
626 let before = section.page(space).unwrap();
627 let after = edited(&before, "Session SMB ");
628 let id = typed(&section, space, &before, 0..0, "Session SMB ");
629 published(&section, id);
630 let remote = client.read("session.one", 1024 * 1024).unwrap();
631 assert_same(model_ops::page_of(&remote, space), &after);
632 section.close().unwrap();
633 let section = Section::resume_smb(
634 "session.one".into(),
635 notebook::Replica::open(&cache).unwrap(),
636 1024 * 1024,
637 connect,
638 || {},
639 )
640 .unwrap();
641 assert_same(section.page(space).unwrap(), &after);
642 assert!(section.pending().unwrap().is_empty());
643 assert!(matches!(
644 section.status(id).unwrap(),
645 Some(EditStatus::Published { .. })
646 ));
647 section.close().unwrap();
648}
649
650#[cfg(feature = "smb")]
651#[test]
652fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {
653 use std::sync::mpsc;
654 let directory = tempfile::tempdir().unwrap();
655 let cache = directory.path().join("replica.sqlite");
656 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
657 let replica = notebook::Replica::create(&cache, &source).unwrap();
658 let (entered, connecting) = mpsc::channel();
659 let (release, stalled) = mpsc::channel();
660 let section = Section::resume_smb(
661 "notes.one".into(),
662 replica,
663 1024 * 1024,
664 move || {
665 entered.send(()).unwrap();
666 stalled.recv_timeout(Duration::from_secs(120)).unwrap();
667 Err(std::io::ErrorKind::TimedOut.into())
668 },
669 || {},
670 )
671 .unwrap();
672 connecting.recv_timeout(Duration::from_secs(120)).unwrap();
673 let space = section.pages().unwrap()[0].0;
674 let before = section.page(space).unwrap();
675 let after = edited(&before, "Saved during connection ");
676 let id = typed(&section, space, &before, 0..0, "Saved during connection ");
677 let (dropped, finished) = mpsc::channel();
678 let owner = std::thread::spawn(move || {
679 drop(section);
680 dropped.send(()).unwrap();
681 });
682 finished.recv_timeout(Duration::from_secs(60)).unwrap();
683 assert!(matches!(
684 notebook::Replica::open(&cache),
685 Err(notebook::Error::Database(error))
686 if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy)
687 ));
688 release.send(()).unwrap();
689 owner.join().unwrap();
690 let deadline = Instant::now() + Duration::from_secs(5);
691 let replica = loop {
692 match notebook::Replica::open(&cache) {
693 Ok(replica) => break replica,
694 Err(notebook::Error::Database(error))
695 if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) =>
696 {
697 assert!(Instant::now() < deadline, "worker retained cache ownership");
698 std::thread::sleep(Duration::from_millis(10));
699 }
700 Err(error) => panic!("{error}"),
701 }
702 };
703 assert_eq!(replica.status(id).unwrap(), Some(EditStatus::Pending));
704 assert_same(
705 model_ops::page_of(&server::snapshot(&replica), space),
706 &after,
707 );
708 assert!(connecting.try_recv().is_err());
709}
710
711#[test]
712fn a_conflicting_save_keeps_the_native_page_and_a_conflict_page_the_session_deletes() {
713 let directory = tempfile::tempdir().unwrap();
714 let file = directory.path().join("notes.one");
715 let cache = directory.path().join("cache");
716 std::fs::write(
717 &file,
718 onestore::create_section("notes.one", "Original", "Author").unwrap(),
719 )
720 .unwrap();
721 let (section, _) = open(&file, &cache);
722 let space = section.pages().unwrap()[0].0;
723 let before = section.page(space).unwrap();
724 let text = first_text(&before);
725 let permissions = std::fs::metadata(&file).unwrap().permissions();
726 let mut read_only = permissions.clone();
727 read_only.set_readonly(true);
728 std::fs::set_permissions(&file, read_only).unwrap();
729 let mut local = before.clone();
730 model_ops::replace_text(&mut local, text, 0..8, "Local");
731 let id = typed(&section, space, &before, 0..8, "Local");
732 wait(&section, |event| matches!(event, Event::Unreachable(_)));
733 std::fs::set_permissions(&file, permissions).unwrap();
734 let mut native = before.clone();
735 model_ops::replace_text(&mut native, text, 0..8, "Native");
736 let deadline = Instant::now() + Duration::from_secs(20);
737 loop {
738 let bytes = onestore::read_file(&file).unwrap();
739 match ops::save(&bytes, space, &native)
740 .unwrap()
741 .commit_file(&file)
742 {
743 Ok(()) => break,
744 Err(error) if error.error.kind() == std::io::ErrorKind::WouldBlock => {
745 assert!(Instant::now() < deadline);
746 std::thread::sleep(Duration::from_millis(20));
747 }
748 Err(error) => panic!("{error:?}"),
749 }
750 }
751 section.wake();
752 wait(
753 &section,
754 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n > id),
755 );
756 assert!(matches!(
757 section.status(id).unwrap(),
758 Some(EditStatus::Published { .. })
759 ));
760 // As OneNote does, the native version stays the page and the local one is kept beside it.
761 assert_same(section.page(space).unwrap(), &native);
762 assert_same(stored_page(&file, space), &native);
763 let listed = section.conflicts().unwrap();
764 assert_eq!(listed.len(), 1);
765 assert_eq!(listed[0].0, space);
766 let conflict = &listed[0].1[0];
767 assert_eq!(conflict.user, "Editor");
768 let kept = section.page(conflict.space).unwrap();
769 assert_eq!(
770 model_ops::paragraph_with(&kept, first_text(&kept))
771 .and_then(|paragraph| paragraph.text())
772 .map(|text| text.text.text().to_owned()),
773 Some("Local".to_owned())
774 );
775 assert_eq!(
776 server::conflicts(&onestore::read_file(&file).unwrap())[0].1,
777 [("Editor".to_owned(), vec!["Local".to_owned()])]
778 );
779 // Merged by hand, the conflict page is deleted.
780 let deleted = section.delete_pages(&[conflict.space]).unwrap();
781 published(&section, deleted);
782 assert!(section.conflicts().unwrap().is_empty());
783 assert!(server::conflicts(&onestore::read_file(&file).unwrap()).is_empty());
784 assert!(section.pending().unwrap().is_empty());
785 section.close().unwrap();
786}
787
788#[path = "support/server.rs"]
789mod server;
790
791/// An uncertain attempt survives a restart as `AwaitingConfirmation`; after exporting the
792/// archive the user either publishes the local edits again or abandons the branch. Neither
793/// path records a receipt for the uncertain attempt.
794#[test]
795fn an_uncertain_attempt_is_released_after_restart_by_review() {
796 for continued in [true, false] {
797 let directory = tempfile::tempdir().unwrap();
798 let file = directory.path().join("notes.one");
799 let cache = directory.path().join("cache.sqlite");
800 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
801 std::fs::write(&file, &source).unwrap();
802 let replica = notebook::Replica::create(&cache, &source).unwrap();
803 let space = space_of(&source);
804 let page = model_ops::page_of(&source, space);
805 let local = edited(&page, "Uncertain ");
806 let id = model_ops::save_as(&replica, space, &local, "Author")
807 .unwrap()
808 .unwrap();
809 let mut faulty = server::Server::new(&source);
810 faulty.fault = server::Fault::UnknownBefore;
811 assert!(replica.sync_once(&mut faulty).is_err());
812 assert!(matches!(
813 replica.status(id).unwrap(),
814 Some(EditStatus::AwaitingConfirmation { .. })
815 ));
816 drop(replica);
817
818 let notified = Arc::new(AtomicUsize::new(0));
819 let counter = Arc::clone(&notified);
820 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), move || {
821 counter.fetch_add(1, Ordering::SeqCst);
822 })
823 .unwrap();
824 wait(
825 &section,
826 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::AwaitingConfirmation { .. } } if *n == id),
827 );
828 assert_eq!(
829 section
830 .pending()
831 .unwrap()
832 .iter()
833 .map(|edit| edit.id)
834 .collect::<Vec<_>>(),
835 [id]
836 );
837 assert_same(section.page(space).unwrap(), &local);
838 let archive = directory.path().join("review.sqlite");
839 if continued {
840 section.release(id, &archive, Resolution::Mine).unwrap();
841 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
842 published(&section, id);
843 assert_same(stored_page(&file, space), &local);
844 } else {
845 section.release(id, &archive, Resolution::Theirs).unwrap();
846 assert_eq!(
847 section.status(id).unwrap(),
848 Some(EditStatus::Archived {
849 archive: archive.to_string_lossy().into_owned()
850 })
851 );
852 assert_same(section.page(space).unwrap(), &page);
853 assert_eq!(stored_page(&file, space), page);
854 }
855 let recovery = Recovery::open(&archive).unwrap();
856 assert_eq!(recovery.pending().unwrap().len(), 1);
857 assert!(matches!(
858 recovery.status(id).unwrap(),
859 Some(EditStatus::AwaitingConfirmation { .. })
860 ));
861 assert!(section.pending().unwrap().is_empty());
862 assert!(
863 section
864 .release(
865 id,
866 directory.path().join("again.sqlite"),
867 Resolution::Theirs
868 )
869 .is_err()
870 );
871 section.close().unwrap();
872 }
873}
874
875fn space_of(source: &[u8]) -> ExGuid {
876 let store = onestore::Store::parse(source).unwrap();
877 let index = onestore::RevisionIndex::parse(&store).unwrap();
878 onestore::document::Document::parse(&index)
879 .unwrap()
880 .pages()
881 .unwrap()[0]
882 .0
883}
884
885/// Restoring and deleting page versions go through the queue and reach the file as OneNote
886/// 2010 stores them (`corpus/page-versions`); a publication that changes only a page's
887/// versions confirms by the history revision it wrote.
888#[test]
889fn page_versions_restore_and_delete_through_the_session() {
890 let directory = tempfile::tempdir().unwrap();
891 let file = directory.path().join("History.one");
892 let cache = directory.path().join("cache");
893 std::fs::copy(
894 Path::new(env!("CARGO_MANIFEST_DIR"))
895 .join("../../corpus/page-versions/native/step-02/notebook/History.one"),
896 &file,
897 )
898 .unwrap();
899 let (section, _) = open(&file, &cache);
900 let listed = section.versions().unwrap();
901 let (space, versions) = listed[0].clone();
902 assert_eq!(versions.len(), 1);
903 let standing = section.page(space).unwrap();
904 let old = section.version(space, versions[0].context).unwrap();
905 let restored = section
906 .restore_version(space, versions[0].context, "Editor")
907 .unwrap();
908 published(&section, restored);
909 assert_eq!(section.page(space).unwrap().objects, old.objects);
910 let stored = |file: &Path| {
911 let arena = onestore::Arena::default();
912 let mut stored =
913 onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
914 let versions = stored.versions().unwrap();
915 (stored.page(space).unwrap(), versions)
916 };
917 let (page, after) = stored(&file);
918 assert_eq!(page.objects, old.objects);
919 assert_eq!(after[0].1.len(), 2);
920 assert_eq!(after[0].1[0].author.as_deref(), Some("Other Person"));
921 assert_eq!(
922 section.version(space, after[0].1[0].context).unwrap(),
923 standing
924 );
925 section.close().unwrap();
926
927 // After a relaunch, deleting every version publishes the history alone.
928 let (section, _) = open(&file, &cache);
929 let all: Vec<ExGuid> = section.versions().unwrap()[0]
930 .1
931 .iter()
932 .map(|version| version.context)
933 .collect();
934 let deleted = section.delete_versions(&[(space, all)]).unwrap();
935 published(&section, deleted);
936 assert!(section.versions().unwrap().is_empty());
937 let (page, after) = stored(&file);
938 assert!(after.is_empty());
939 assert_eq!(page.objects, old.objects);
940 section.close().unwrap();
941}
942
943#[test]
944fn working_offline_queues_edits_until_sync_now_or_working_online() {
945 let directory = tempfile::tempdir().unwrap();
946 let file = directory.path().join("notes.one");
947 let cache = directory.path().join("cache");
948 std::fs::write(
949 &file,
950 onestore::create_section("notes.one", "Original", "Author").unwrap(),
951 )
952 .unwrap();
953 let (section, _) = open(&file, &cache);
954 let space = section.pages().unwrap()[0].0;
955 let before = section.page(space).unwrap();
956 let reached = status_until(&section, |status| status.synced.is_some());
957 assert!(reached.error.is_none());
958 assert_eq!(reached.queued, 0);
959
960 section.set_offline(true);
961 let id = typed(&section, space, &before, 0..0, "Offline ");
962 std::thread::sleep(Duration::from_millis(500));
963 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
964 assert_eq!(section.sync_status().unwrap().queued, 1);
965 assert_eq!(stored_page(&file, space), before);
966
967 // Sync Now publishes while working offline.
968 section.wake();
969 published(&section, id);
970 let after = edited(&before, "Offline ");
971 assert_same(stored_page(&file, space), &after);
972 let synced = status_until(&section, |status| status.queued == 0);
973 assert!(synced.synced > reached.synced);
974
975 let next = typed(&section, space, &after, 0..0, "Again ");
976 std::thread::sleep(Duration::from_millis(500));
977 assert_eq!(section.status(next).unwrap(), Some(EditStatus::Pending));
978 section.set_offline(false);
979 published(&section, next);
980 status_until(&section, |status| status.queued == 0);
981 section.close().unwrap();
982}
983
984#[test]
985fn a_missing_section_file_reports_its_error_until_it_returns() {
986 let directory = tempfile::tempdir().unwrap();
987 let file = directory.path().join("notes.one");
988 let away = directory.path().join("away.one");
989 let cache = directory.path().join("cache");
990 std::fs::write(
991 &file,
992 onestore::create_section("notes.one", "Original", "Author").unwrap(),
993 )
994 .unwrap();
995 let (section, notified) = open(&file, &cache);
996 status_until(&section, |status| status.synced.is_some());
997 std::fs::rename(&file, &away).unwrap();
998 section.wake();
999 let failed = status_until(&section, |status| status.error.is_some());
1000 assert_eq!(
1001 failed.error.map(|error| error.kind()),
1002 Some(std::io::ErrorKind::NotFound)
1003 );
1004 let before = notified.load(Ordering::SeqCst);
1005 std::fs::rename(&away, &file).unwrap();
1006 section.wake();
1007 let back = status_until(&section, |status| status.error.is_none());
1008 assert!(back.synced > failed.synced);
1009 assert!(notified.load(Ordering::SeqCst) > before);
1010 section.close().unwrap();
1011}
1012
1013/// A notebook folder holding `First.one` and `Second.one`, and a cache beside it.
1014fn two_sections(directory: &Path) -> Notebook {
1015 let root = directory.join("Shared");
1016 std::fs::create_dir(&root).unwrap();
1017 for name in ["First", "Second"] {
1018 let file = format!("{name}.one");
1019 std::fs::write(
1020 root.join(&file),
1021 onestore::create_section(&file, name, "Author").unwrap(),
1022 )
1023 .unwrap();
1024 }
1025 Notebook::open(&root, directory.join("cache")).unwrap()
1026}
1027
1028fn until(what: &str, mut accept: impl FnMut() -> bool) {
1029 let deadline = Instant::now() + Duration::from_secs(20);
1030 while !accept() {
1031 assert!(Instant::now() < deadline, "{what}");
1032 std::thread::sleep(Duration::from_millis(20));
1033 }
1034}
1035
1036#[test]
1037fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
1038 let directory = tempfile::tempdir().unwrap();
1039 let mut notebook = two_sections(directory.path());
1040 let file = directory.path().join("Shared/Second.one");
1041 let background = notebook.background(false, false, || {}).unwrap();
1042 background.set_offline(true);
1043 background.watch(notebook.replicas());
1044 // The round started with the background ends before the edit exists.
1045 std::thread::sleep(Duration::from_millis(200));
1046 let section = notebook.section("Second.one", || {}).unwrap();
1047 section.set_offline(true);
1048 let space = section.pages().unwrap()[0].0;
1049 let before = section.page(space).unwrap();
1050 typed(&section, space, &before, 0..0, "Closed ");
1051 section.close().unwrap();
1052 std::thread::sleep(Duration::from_millis(300));
1053 assert_eq!(
1054 stored_page(&file, space),
1055 before,
1056 "working offline publishes nothing"
1057 );
1058 background.set_offline(false);
1059 let mut after = edited(&before, "Closed ");
1060 until("the closed section published", || {
1061 let stored = stored_page(&file, space);
1062 after.title = stored.title.clone();
1063 stored == after
1064 });
1065 until("the status shows nothing waiting", || {
1066 background.status().iter().any(|(path, status)| {
1067 path == "Second.one" && status.synced.is_some() && status.queued == 0
1068 })
1069 });
1070 drop(background);
1071 let mut reopened = None;
1072 until("the background released the replica", || {
1073 reopened = notebook.section("Second.one", || {}).ok();
1074 reopened.is_some()
1075 });
1076 let reopened = reopened.unwrap();
1077 assert!(reopened.pending().unwrap().is_empty());
1078 reopened.close().unwrap();
1079}
1080
1081#[test]
1082fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1083 let directory = tempfile::tempdir().unwrap();
1084 let mut notebook = two_sections(directory.path());
1085 // Second has a replica from being opened once; First has never been opened.
1086 notebook
1087 .section("Second.one", || {})
1088 .unwrap()
1089 .close()
1090 .unwrap();
1091 let notified = Arc::new(AtomicUsize::new(0));
1092 let counter = Arc::clone(&notified);
1093 let background = notebook
1094 .background(true, false, move || {
1095 counter.fetch_add(1, Ordering::SeqCst);
1096 })
1097 .unwrap();
1098 background.watch(notebook.replicas());
1099 until("both sections were reached", || {
1100 background
1101 .status()
1102 .iter()
1103 .all(|(_, status)| status.synced.is_some() && status.error.is_none())
1104 });
1105 assert!(background.changed().is_empty());
1106 let mut changed = Vec::new();
1107 for name in ["First.one", "Second.one"] {
1108 let file = directory.path().join("Shared").join(name);
1109 let bytes = onestore::read_file(&file).unwrap();
1110 let space = notebook::session::stored_pages(&bytes).unwrap()[0].space;
1111 let native = edited(&model_ops::page_of(&bytes, space), "Native ");
1112 ops::save(&bytes, space, &native)
1113 .unwrap()
1114 .commit_file(&file)
1115 .unwrap();
1116 changed.push((name, space, native));
1117 }
1118 // As a watch reports a folder when it cannot name the file.
1119 background.touched(&[String::new()]);
1120 let mut noticed = Vec::new();
1121 until("both changes were noticed", || {
1122 noticed.extend(background.changed());
1123 changed
1124 .iter()
1125 .all(|(name, ..)| noticed.iter().any(|path| path == name))
1126 });
1127 assert!(notified.load(Ordering::SeqCst) > 0);
1128 drop(background);
1129 let (_, space, native) = &changed[1];
1130 let replica = notebook.replica_path("Second.one").unwrap();
1131 let mut opened = None;
1132 until("the background released the replica", || {
1133 opened = notebook::Replica::open(&replica).ok();
1134 opened.is_some()
1135 });
1136 assert_same(opened.unwrap().page(*space).unwrap(), native);
1137}
1138
1139#[test]
1140fn a_reported_change_wakes_its_section_without_polling() {
1141 let directory = tempfile::tempdir().unwrap();
1142 let mut notebook = two_sections(directory.path());
1143 let background = notebook.background(true, false, || {}).unwrap();
1144 background.watch(notebook.replicas());
1145 until("both sections were reached", || {
1146 background
1147 .status()
1148 .iter()
1149 .all(|(_, status)| status.synced.is_some())
1150 });
1151 let file = directory.path().join("Shared/Second.one");
1152 let bytes = onestore::read_file(&file).unwrap();
1153 let space = notebook::session::stored_pages(&bytes).unwrap()[0].space;
1154 let native = edited(&model_ops::page_of(&bytes, space), "Native ");
1155 ops::save(&bytes, space, &native)
1156 .unwrap()
1157 .commit_file(&file)
1158 .unwrap();
1159 std::thread::sleep(Duration::from_secs(2));
1160 assert!(
1161 background.changed().is_empty(),
1162 "nothing polls an idle notebook"
1163 );
1164 background.touched(&["Second.one".to_owned()]);
1165 let mut noticed = Vec::new();
1166 until("the reported change was noticed", || {
1167 noticed.extend(background.changed());
1168 !noticed.is_empty()
1169 });
1170 assert_eq!(noticed, ["Second.one"]);
1171}
1172
1173#[test]
1174fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
1175 let directory = tempfile::tempdir().unwrap();
1176 let mut notebook = two_sections(directory.path());
1177 let replicas: Vec<_> = notebook
1178 .replicas()
1179 .into_iter()
1180 .map(|known| known.replica.unwrap())
1181 .collect();
1182 assert!(replicas.iter().all(|replica| !replica.exists()));
1183 let background = notebook.background(true, true, || {}).unwrap();
1184 background.watch(notebook.replicas());
1185 until("both sections have offline copies", || {
1186 replicas.iter().all(|replica| replica.exists())
1187 && background
1188 .status()
1189 .iter()
1190 .all(|(_, status)| status.synced.is_some())
1191 });
1192 // Opening a section starts from its copy, and edits made offline wait in it.
1193 let section = notebook.section("Second.one", || {}).unwrap();
1194 section.set_offline(true);
1195 let space = section.pages().unwrap()[0].0;
1196 let before = section.page(space).unwrap();
1197 typed(&section, space, &before, 0..0, "Waiting ");
1198 section.close().unwrap();
1199 background.discard();
1200 let second = notebook.replica_path("Second.one").unwrap();
1201 until("the copy with nothing waiting was discarded", || {
1202 !notebook.replica_path("First.one").unwrap().exists()
1203 });
1204 assert!(second.exists(), "a copy with edits waiting stays");
1205 // The stopping thread may still be peeking at it, as a host opening a section waits out.
1206 let mut kept = None;
1207 until("the background let go of the copy", || {
1208 match notebook::Replica::open(&second) {
1209 Ok(replica) => kept = Some(replica),
1210 Err(error) if error.busy() => {}
1211 Err(error) => panic!("{error}"),
1212 }
1213 kept.is_some()
1214 });
1215 assert_eq!(kept.unwrap().pending().unwrap().len(), 1);
1216}
1217
1218/// A section file whose stamp reads are counted.
1219struct Counted(std::path::PathBuf, Arc<AtomicUsize>);
1220
1221impl notebook::Remote for Counted {
1222 fn read(&mut self) -> std::io::Result<Vec<u8>> {
1223 onestore::read_file(&self.0)
1224 }
1225
1226 fn stamp(&mut self) -> std::io::Result<onestore::Stamp> {
1227 self.1.fetch_add(1, Ordering::SeqCst);
1228 onestore::Stamp::of(&self.read()?).map_err(std::io::Error::other)
1229 }
1230
1231 fn publish(
1232 &mut self,
1233 transaction: &onestore::Transaction,
1234 ) -> Result<(), onestore::CommitError> {
1235 transaction.commit_file(&self.0)
1236 }
1237
1238 fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), onestore::CommitError> {
1239 onestore::confirm_file(&self.0, base)
1240 }
1241}
1242
1243#[test]
1244fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() {
1245 let directory = tempfile::tempdir().unwrap();
1246 let mut notebook = two_sections(directory.path());
1247 let background = notebook.background(true, false, || {}).unwrap();
1248 background.watch(notebook.replicas());
1249 until("both sections were reached", || {
1250 background
1251 .status()
1252 .iter()
1253 .all(|(_, status)| status.synced.is_some())
1254 });
1255 let stamps = Arc::new(AtomicUsize::new(0));
1256 let counter = Arc::clone(&stamps);
1257 let section = notebook
1258 .section_with(
1259 "Second.one",
1260 move |file| Ok(Counted(file.to_owned(), Arc::clone(&counter))),
1261 || {},
1262 )
1263 .unwrap();
1264 background.hold("Second.one", &section);
1265 std::thread::sleep(Duration::from_secs(3));
1266 let before = stamps.load(Ordering::SeqCst);
1267 std::thread::sleep(Duration::from_secs(5));
1268 assert_eq!(
1269 stamps.load(Ordering::SeqCst),
1270 before,
1271 "an idle held section reads nothing"
1272 );
1273
1274 let file = directory.path().join("Shared/Second.one");
1275 let bytes = onestore::read_file(&file).unwrap();
1276 let space = notebook::session::stored_pages(&bytes).unwrap()[0].space;
1277 let native = edited(&model_ops::page_of(&bytes, space), "Native ");
1278 ops::save(&bytes, space, &native)
1279 .unwrap()
1280 .commit_file(&file)
1281 .unwrap();
1282 background.touched(&["Second.one".to_owned()]);
1283 let mut changed = Vec::new();
1284 until("the session heard of the change", || {
1285 changed.extend(
1286 section
1287 .events()
1288 .into_iter()
1289 .filter_map(|event| match event {
1290 Event::Changed(spaces) => Some(spaces),
1291 _ => None,
1292 }),
1293 );
1294 !changed.is_empty()
1295 });
1296 assert!(changed.concat().contains(&space));
1297 assert!(background.changed().is_empty(), "the session reports it");
1298
1299 // Once the session stops, the background takes the section over at once.
1300 section.set_offline(true);
1301 let before = section.page(space).unwrap();
1302 typed(&section, space, &before, 0..0, "Closed ");
1303 section.close().unwrap();
1304 let mut after = edited(&before, "Closed ");
1305 until("the background published the closed section's edit", || {
1306 let stored = stored_page(&file, space);
1307 after.title = stored.title.clone();
1308 stored == after
1309 });
1310}
1311
1312/// A section file whose whole reads are counted.
1313struct Reads(std::path::PathBuf, Arc<AtomicUsize>);
1314
1315impl notebook::Remote for Reads {
1316 fn read(&mut self) -> std::io::Result<Vec<u8>> {
1317 self.1.fetch_add(1, Ordering::SeqCst);
1318 onestore::read_file(&self.0)
1319 }
1320
1321 fn stamp(&mut self) -> std::io::Result<onestore::Stamp> {
1322 onestore::Stamp::of(&onestore::read_file(&self.0)?).map_err(std::io::Error::other)
1323 }
1324
1325 fn publish(
1326 &mut self,
1327 transaction: &onestore::Transaction,
1328 ) -> Result<(), onestore::CommitError> {
1329 transaction.commit_file(&self.0)
1330 }
1331
1332 fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), onestore::CommitError> {
1333 onestore::confirm_file(&self.0, base)
1334 }
1335}
1336
1337#[test]
1338fn the_background_takes_each_file_discovery_read_rather_than_reading_it_again() {
1339 let directory = tempfile::tempdir().unwrap();
1340 let mut notebook = two_sections(directory.path());
1341 let reads = Arc::new(AtomicUsize::new(0));
1342 let counter = Arc::clone(&reads);
1343 let remote = move |file: &Path| Reads(file.to_owned(), Arc::clone(&counter));
1344 let synced = |background: &notebook::session::Background| {
1345 let status = background.status();
1346 status.len() == 2 && status.iter().all(|(_, status)| status.synced.is_some())
1347 };
1348 // A cold launch makes each offline copy from the file as discovery read it.
1349 let background = notebook
1350 .background_with(true, true, remote.clone(), || {})
1351 .unwrap();
1352 background.watch(notebook.replicas());
1353 until("both sections have offline copies", || synced(&background));
1354 assert_eq!(reads.load(Ordering::SeqCst), 0);
1355 drop(background);
1356 let replica = notebook.replica_path("First.one").unwrap();
1357 drop(notebook);
1358
1359 // Another client changes a section while the notebook is closed.
1360 let root = directory.path().join("Shared");
1361 let file = root.join("First.one");
1362 let bytes = onestore::read_file(&file).unwrap();
1363 let space = notebook::session::stored_pages(&bytes).unwrap()[0].space;
1364 let native = edited(&model_ops::page_of(&bytes, space), "Native ");
1365 ops::save(&bytes, space, &native)
1366 .unwrap()
1367 .commit_file(&file)
1368 .unwrap();
1369 let mut notebook = Notebook::open(&root, directory.path().join("cache")).unwrap();
1370 let background = notebook.background_with(true, true, remote, || {}).unwrap();
1371 background.watch(notebook.replicas());
1372 until("both sections were checked", || synced(&background));
1373 assert_eq!(
1374 reads.load(Ordering::SeqCst),
1375 0,
1376 "the rebase takes the image discovery read"
1377 );
1378 drop(background);
1379 let rebased = notebook::Replica::open(&replica).unwrap();
1380 assert_eq!(rebased.page(space).unwrap(), stored_page(&file, space));
1381}
1382
1383/// Every file under `dir`, depth first.
1384fn walkdir(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
1385 let mut files = Vec::new();
1386 for entry in std::fs::read_dir(dir).unwrap() {
1387 let path = entry.unwrap().path();
1388 if path.is_dir() {
1389 files.extend(walkdir(&path));
1390 } else {
1391 files.push(path);
1392 }
1393 }
1394 files
1395}
1396
1397/// A stored page names who changed it last, which tells another author's changes from
1398/// one's own.
1399#[test]
1400fn a_stored_page_names_who_changed_it_last() {
1401 let image = onestore::create_section("Authors.one", "Body", "Alice").unwrap();
1402 let first = &notebook::session::stored_pages(&image).unwrap()[0];
1403 assert_eq!(first.author.as_deref(), Some("Alice"));
1404 let arena = onestore::Arena::default();
1405 let mut section = onestore::Section::open(&arena, image).unwrap();
1406 // Enter, which stamps both paragraphs with who pressed it.
1407 let new_id = || onestore::page::text::new_id().unwrap();
1408 let op = PageOp::Split {
1409 text: first_text(&first.page),
1410 at: 0,
1411 paragraph: new_id(),
1412 right: new_id(),
1413 lists: Vec::new(),
1414 };
1415 let edit = Edit {
1416 // A minute on, so the edit is the page's latest change.
1417 at: model_ops::now() + 600_000_000,
1418 ops: vec![Op::Page {
1419 space: first.space,
1420 op,
1421 }],
1422 };
1423 section.apply("Bob", &edit).unwrap();
1424 section.seal().unwrap();
1425 let edited = &notebook::session::stored_pages(&section.image()).unwrap()[0];
1426 assert_eq!(edited.author.as_deref(), Some("Bob"));
1427}