1use super::*;
2use library::{sb_library_set_offline, sb_library_sync_now};
3use notebook::session::SyncState;
4use std::path::{Path, PathBuf};
5
6#[path = "../../notebook/tests/support/vfs.rs"]
7mod vfs;
8
9const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus");
10
11fn corpus(path: &str) -> PathBuf {
12 Path::new(CORPUS).join(path)
13}
14
15/// A copy of the corpus section or notebook folder at `path` in a fresh directory, with a
16/// cache beside it.
17fn copy(path: &str) -> (tempfile::TempDir, PathBuf) {
18 let directory = tempfile::tempdir().unwrap();
19 let source = corpus(path);
20 let target = directory.path().join(source.file_name().unwrap());
21 let status = std::process::Command::new("cp")
22 .arg("-R")
23 .arg(&source)
24 .arg(&target)
25 .status()
26 .unwrap();
27 assert!(status.success());
28 (directory, target)
29}
30
31fn open(library: &Arc<Library>, path: &str) -> Section {
32 Section::open(Arc::clone(library), path, "Clover Test".into(), || {}).unwrap()
33}
34
35fn space(section: &Section, title: &str) -> ExGuid {
36 section
37 .shared
38 .section
39 .pages()
40 .unwrap()
41 .into_iter()
42 .find(|(_, heading, _)| heading == title)
43 .unwrap()
44 .0
45}
46
47/// A page of the Features section on a 402 × 874 point phone at 3 pixels per point, at
48/// 100% with the page origin at the view's corner.
49fn canvas(section: &Section, title: &str) -> Canvas {
50 let space = space(section, title);
51 let (page, _) = section.shared.page(space).unwrap();
52 let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap();
53 canvas.set_transform(1.0, [0.0; 2]);
54 canvas
55}
56
57fn features() -> (tempfile::TempDir, Section) {
58 let (directory, file) = copy("media-edit/candidate/Features.one");
59 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
60 let section = open(&library, &file.to_string_lossy());
61 (directory, section)
62}
63
64/// A view point on the outline holding `text`, `fraction` of the way along its first line.
65fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] {
66 let outline = canvas
67 .page
68 .editor
69 .visible_outlines()
70 .find(|outline| outline.shown_text().contains(text))
71 .unwrap();
72 let bounds = outline.bounds();
73 let x = bounds.x0 as f32 + (bounds.x1 - bounds.x0) as f32 * fraction;
74 let y = bounds.y0 as f32 + 6.0;
75 [x * POINT, y * POINT]
76}
77
78/// Focuses the outline holding `text` with a tap, and hands the section the edit that took.
79fn focus(canvas: &mut Canvas, text: &str) {
80 let point = point_on(canvas, text, 0.1);
81 canvas.press(point).unwrap();
82 canvas.release().unwrap();
83}
84
85#[test]
86fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
87 let directory = tempfile::tempdir().unwrap();
88 let library = Library::open(
89 &corpus("m6/native-features-01/notebook"),
90 directory.path(),
91 true,
92 )
93 .unwrap();
94 let tabs = library.tabs().unwrap();
95 let names: Vec<_> = tabs
96 .iter()
97 .map(|tab| (tab.group.as_str(), tab.name.as_str()))
98 .collect();
99 assert!(names.contains(&("", "Features")), "{names:?}");
100 assert!(tabs.iter().any(|tab| !tab.group.is_empty()));
101 let lone = Library::open(
102 &corpus("media-edit/candidate/Features.one"),
103 directory.path(),
104 true,
105 )
106 .unwrap();
107 assert_eq!(lone.tabs().unwrap().len(), 1);
108}
109
110#[test]
111fn a_new_notebook_opens_with_one_section_in_its_colour_and_refuses_a_taken_name() {
112 let directory = tempfile::tempdir().unwrap();
113 let root = directory.path().join("Mine");
114 let cache = directory.path().join("cache");
115 std::fs::create_dir(&cache).unwrap();
116 let c = |path: &Path| CString::new(path.to_str().unwrap()).unwrap();
117 let create = |error: &mut *mut c_char| unsafe {
118 library::sb_notebook_create(
119 c(&root).as_ptr(),
120 c(&cache).as_ptr(),
121 c"Clover Test".as_ptr(),
122 c"Tuesday, September 29, 2026".as_ptr(),
123 c"9:41 AM".as_ptr(),
124 error,
125 )
126 };
127 let mut error = std::ptr::null_mut();
128 assert!(create(&mut error));
129 assert!(error.is_null());
130 let library = Library::open(&root, &cache, true).unwrap();
131 let tabs = library.tabs().unwrap();
132 assert_eq!(tabs.len(), 1);
133 assert!(tabs[0].readable);
134 assert_eq!(library::sb_library_color(&library), 0x91baae);
135 assert!(!create(&mut error));
136 assert!(!error.is_null());
137 unsafe { sb_string_free(error) };
138}
139
140#[test]
141fn touches_route_to_text_the_page_and_the_focused_outlines_grip() {
142 let (_directory, section) = features();
143 let mut canvas = canvas(&section, "Paragraph controls");
144 let text = point_on(&canvas, "Collapsed parent", 0.1);
145 let first = canvas.target(text);
146 assert!(
147 matches!(first, Target::ActiveText | Target::Text),
148 "{first:?}"
149 );
150 assert!(canvas.press(text).unwrap());
151 canvas.release().unwrap();
152 assert_eq!(canvas.target(text), Target::ActiveText);
153 // The grip strip sits above the text; a finger reaches it from 16 points higher still.
154 let bounds = canvas.active().bounds();
155 let above = [
156 (bounds.x0 as f32 + 40.0) * POINT,
157 (bounds.y0 as f32 - 12.0) * POINT - 10.0,
158 ];
159 assert_eq!(canvas.target(above), Target::Grip);
160 // Unfocused, the grips hide and a touch there reaches the page.
161 let _ = canvas.page.focus_changed(false).unwrap();
162 assert_eq!(canvas.target(above), Target::Page);
163}
164
165#[test]
166fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() {
167 let (_directory, section) = features();
168 let mut canvas = canvas(&section, "Paragraph controls");
169 focus(&mut canvas, "Collapsed parent");
170 canvas.edit().unwrap();
171 let before = canvas.active().origin();
172 let grip = [(before[0] + 40.0) * POINT, (before[1] - 8.0) * POINT];
173 assert_eq!(canvas.target(grip), Target::Grip);
174 canvas.press(grip).unwrap();
175 for step in 1..=10 {
176 let step = step as f32;
177 canvas
178 .drag([grip[0] + step * 10.0, grip[1] + step * 7.0])
179 .unwrap();
180 }
181 canvas.release().unwrap();
182 let after = canvas.active().origin();
183 let margin = canvas.page.editor.margin_origin();
184 assert_ne!(after, before);
185 for axis in 0..2 {
186 let cells = (after[axis] - margin[axis]) / 18.0;
187 assert!(
188 (cells - cells.round()).abs() < 1e-3,
189 "{after:?} off the grid"
190 );
191 }
192 assert!(canvas.edit().unwrap().is_some());
193 assert!(canvas.page.editor.can_undo());
194}
195
196#[test]
197fn edits_leave_the_viewport_where_the_host_put_it() {
198 let (_directory, section) = features();
199 let mut canvas = canvas(&section, "Paragraph controls");
200 // Scrolled far past the content, where the canvas would clamp and reveal.
201 canvas.set_transform(1.0, [-2000.0, 5000.0]);
202 let origin = canvas.page.viewport.origin;
203 focus(&mut canvas, "Collapsed parent");
204 canvas.insert("typed".into()).unwrap();
205 assert_eq!(canvas.page.viewport.origin, origin);
206}
207
208#[test]
209fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {
210 let (_directory, section) = features();
211 let mut canvas = canvas(&section, "Paragraph controls");
212 focus(&mut canvas, "Collapsed parent");
213 let shown = canvas.active().shown_text();
214 let length = canvas.length();
215 assert_eq!(length as usize, shown.encode_utf16().count());
216 assert_eq!(canvas.text([0, length]), shown);
217 let start = shown.find("Collapsed").unwrap() as u32;
218 canvas.select([start, start + 9]).unwrap();
219 assert_eq!(canvas.selection().unwrap(), [start, start + 9]);
220 assert!(!canvas.range_rects([start, start + 9]).unwrap().is_empty());
221 let caret = canvas.caret_rect(start).unwrap();
222 let closest = canvas
223 .closest([caret[0] + 1.0, caret[1] + caret[3] / 2.0])
224 .unwrap();
225 assert!(closest.abs_diff(start) <= 1, "{closest} for {start}");
226 // Every offset has a caret, including those around the collapsed children.
227 for offset in 0..=length {
228 canvas.caret_rect(offset).unwrap();
229 }
230}
231
232#[test]
233fn select_more_widens_from_the_caret_paragraph_to_the_outline() {
234 let (_directory, section) = features();
235 let mut canvas = canvas(&section, "Paragraph controls");
236 focus(&mut canvas, "Collapsed parent");
237 let shown = canvas.active().shown_text();
238 let units = |text: &str| text.encode_utf16().count() as u32;
239 let start = units(&shown[..shown.find("Collapsed parent").unwrap()]);
240 let line = units(
241 shown
242 .split('\n')
243 .find(|line| line.contains("Collapsed parent"))
244 .unwrap(),
245 );
246 canvas.select([start + 2; 2]).unwrap();
247 canvas.page.editor.widen_selection().unwrap();
248 let [lo, hi] = canvas.selection().unwrap();
249 assert!(
250 lo == start && [start + line, start + line + 1].contains(&hi),
251 "{lo}..{hi}"
252 );
253 for _ in 0..8 {
254 canvas.page.editor.widen_selection().unwrap();
255 }
256 assert_eq!(canvas.selection().unwrap(), [0, canvas.length()]);
257}
258
259#[test]
260fn undo_restores_typing() {
261 let (_directory, section) = features();
262 let mut canvas = canvas(&section, "Paragraph controls");
263 focus(&mut canvas, "Collapsed parent");
264 let before = canvas.active().shown_text();
265 // UIKit's keystrokes, one call each, with the selection set again where it is: one run.
266 for text in ["h", "i", "x"] {
267 canvas.insert(text.into()).unwrap();
268 let selection = canvas.selection().unwrap();
269 canvas.select(selection).unwrap();
270 }
271 assert!(
272 canvas
273 .page
274 .key(&Key::Named(NamedKey::Backspace), None)
275 .unwrap()
276 .changed
277 );
278 canvas.insert("!".into()).unwrap();
279 assert_ne!(canvas.active().shown_text(), before);
280 assert!(moved(canvas.page.undo(false).unwrap()));
281 assert_eq!(canvas.active().shown_text(), before);
282 assert!(canvas.page.editor.can_redo());
283}
284
285/// The text of the page `space` of the section file `file`, read cold.
286fn stored_text(file: &Path, space: ExGuid) -> String {
287 let arena = onestore::Arena::default();
288 let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
289 canvas::search::page_text(&section.page(space).unwrap())
290}
291
292#[test]
293fn typing_is_stored_in_the_cache_and_published_to_the_file() {
294 let (directory, file) = copy("media-edit/candidate/Features.one");
295 let cache = directory.path().join("cache");
296 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
297 let section = open(&library, &file.to_string_lossy());
298 let mut canvas = canvas(&section, "Paragraph controls");
299 focus(&mut canvas, "Collapsed parent");
300 let shown = canvas.active().shown_text();
301 let at = shown[..shown.find("Collapsed parent").unwrap()]
302 .encode_utf16()
303 .count() as u32;
304 canvas.select([at, at]).unwrap();
305 canvas.insert("Saved ".into()).unwrap();
306 section
307 .shared
308 .apply(canvas.edit().unwrap().unwrap())
309 .unwrap();
310 assert!(section.flush(Duration::from_secs(20)));
311 let space = canvas.space;
312 assert!(stored_text(&file, space).contains("Saved Collapsed parent"));
313 // The replica reopens from the cache as the file now holds it.
314 let shared = Arc::clone(&section.shared);
315 drop((canvas, section));
316 Arc::into_inner(shared).unwrap().section.close().unwrap();
317 let reopened = open(&library, &file.to_string_lossy());
318 let (page, read_only) = reopened.shared.page(space).unwrap();
319 assert!(!read_only);
320 assert!(canvas::search::page_text(&page).contains("Saved Collapsed parent"));
321}
322
323#[test]
324fn cached_cloud_sections_reopen_with_offline_edits_after_eviction() {
325 let directory = tempfile::tempdir().unwrap();
326 let root = directory.path().join("notebook");
327 let cache = directory.path().join("cache");
328 let creation = onestore::PageCreation::new(None, Some("Kept offline"), "Fixture").unwrap();
329 let notebook =
330 notebook::session::Notebook::create(&root, &cache, 0x00f0_a0c0, &creation).unwrap();
331 let path = notebook.catalog().sections[0].path.clone();
332 drop(notebook);
333 let library = Arc::new(Library::open(&root, &cache, false).unwrap());
334 sb_library_set_offline(&library, true);
335 let section = open(&library, &path);
336 let space = space(&section, "Kept offline");
337 section
338 .shared
339 .apply(Edit {
340 at: library::filetime(),
341 ops: vec![Op::Page {
342 space,
343 op: PageOp::Color(Some(0x00ef_cdab)),
344 }],
345 })
346 .unwrap();
347 let shared = Arc::clone(&section.shared);
348 drop((section, library));
349 Arc::into_inner(shared).unwrap().section.close().unwrap();
350 let file = root.join(&path);
351 let held = directory.path().join("held.one");
352 std::fs::rename(&file, &held).unwrap();
353 let placeholder = root.join(format!(".{path}.icloud"));
354 std::fs::write(&placeholder, b"placeholder").unwrap();
355
356 let library = Arc::new(Library::open(&root, &cache, false).unwrap());
357 sb_library_set_offline(&library, true);
358 for _ in 0..2 {
359 let tabs = library.tabs().unwrap();
360 assert_eq!(tabs[0].path, path);
361 assert!(tabs[0].readable && !tabs[0].downloading);
362 }
363 let section = open(&library, &path);
364 assert_eq!(section.rows().unwrap()[0].title, "Kept offline");
365 assert_eq!(
366 section.shared.page(space).unwrap().0.color,
367 Some(0x00ef_cdab)
368 );
369 assert_eq!(section.shared.section.pending().unwrap().len(), 1);
370 assert!(!file.exists());
371
372 std::fs::rename(held, &file).unwrap();
373 std::fs::remove_file(placeholder).unwrap();
374 assert!(library.tabs().unwrap()[0].readable);
375 sb_library_set_offline(&library, false);
376 assert!(section.flush(Duration::from_secs(20)));
377 let arena = onestore::Arena::default();
378 let stored = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
379 assert_eq!(stored.page(space).unwrap().color, Some(0x00ef_cdab));
380}
381
382#[test]
383fn reopening_a_reachable_source_while_offline_retains_its_original_queue() {
384 extern "C" fn wake(_: usize) {}
385
386 for lone in [false, true] {
387 let directory = tempfile::tempdir().unwrap();
388 let root = directory.path().join("notebook");
389 let cache = directory.path().join("cache");
390 let creation = onestore::PageCreation::new(None, Some("Original"), "Fixture").unwrap();
391 let notebook =
392 notebook::session::Notebook::create(&root, &cache, 0x00f0_a0c0, &creation).unwrap();
393 let path = notebook.catalog().sections[0].path.clone();
394 drop(notebook);
395 let file = root.join(&path);
396 let source = std::fs::read(&file).unwrap();
397 let (location, path) = if lone {
398 (file.clone(), file.to_string_lossy().into_owned())
399 } else {
400 (root, path)
401 };
402 let library = Arc::new(Library::open(&location, &cache, false).unwrap());
403 sb_library_set_offline(&library, true);
404 let section = open(&library, &path);
405 let creation =
406 onestore::PageCreation::new(None, Some("Mobile offline reopen marker"), "Fixture")
407 .unwrap();
408 section
409 .shared
410 .apply(Edit {
411 at: library::filetime(),
412 ops: vec![Op::Section(onestore::op::SectionOp::Create(creation))],
413 })
414 .unwrap();
415 section.shared.section.replica().written().unwrap();
416 let pending = section.shared.section.pending().unwrap();
417 assert_eq!(pending.len(), 1);
418 let shared = Arc::clone(&section.shared);
419 drop(section);
420 Arc::into_inner(shared).unwrap().section.close().unwrap();
421 let path = std::ffi::CString::new(path).unwrap();
422 for reopen in 0..2 {
423 let mut error = std::ptr::null_mut();
424 let raw = unsafe {
425 library::sb_section_open(
426 &library,
427 path.as_ptr(),
428 c"Fixture".as_ptr(),
429 wake,
430 0,
431 &mut error,
432 )
433 };
434 assert!(error.is_null());
435 assert!(!raw.is_null());
436 let section = unsafe { Box::from_raw(raw) };
437 for _ in 0..20 {
438 assert_eq!(section.shared.section.pending().unwrap(), pending);
439 assert_eq!(std::fs::read(&file).unwrap(), source);
440 std::thread::sleep(Duration::from_millis(10));
441 }
442 if reopen == 1 {
443 sb_library_sync_now(&library);
444 assert!(eventually(|| section
445 .shared
446 .section
447 .pending()
448 .unwrap()
449 .is_empty()));
450 let pages =
451 notebook::session::stored_pages(&std::fs::read(&file).unwrap()).unwrap();
452 assert_eq!(
453 pages
454 .iter()
455 .filter(|page| page.page.title == "Mobile offline reopen marker")
456 .count(),
457 1
458 );
459 }
460 let shared = Arc::clone(&section.shared);
461 drop(section);
462 Arc::into_inner(shared).unwrap().section.close().unwrap();
463 }
464 }
465}
466
467#[test]
468fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {
469 let (directory, root) = copy("m6/native-features-01/notebook");
470 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
471 let section = open(&library, "Features.one");
472 let first = section.rows().unwrap()[0].id.parse().unwrap();
473 let page = section
474 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")
475 .unwrap();
476 let subpage = section
477 .new_page(Some(first), "Sunday, September 27, 2026", "3:05 AM")
478 .unwrap();
479 let rows = section.rows().unwrap();
480 assert_eq!(rows.last().unwrap().id, page.to_string());
481 assert_eq!(rows[1].id, subpage.to_string());
482 assert_eq!(rows[1].level, rows[0].level + 1);
483 let mut canvas = Canvas::new(
484 subpage,
485 section.shared.page(subpage).unwrap().0,
486 [1206, 2622],
487 3.0,
488 )
489 .unwrap();
490 assert!(canvas.focus_title().unwrap());
491 canvas.insert("Sketches".into()).unwrap();
492 assert_eq!(canvas.title().unwrap().shown_text(), "Sketches");
493 section
494 .shared
495 .apply(canvas.edit().unwrap().unwrap())
496 .unwrap();
497 section.shared.section.written().unwrap();
498 assert!(
499 section
500 .rows()
501 .unwrap()
502 .iter()
503 .any(|row| row.title == "Sketches")
504 );
505 let deleted = section.shared.section.page(subpage).unwrap();
506 assert!(deleted.identity.is_some());
507 section
508 .delete_page(subpage, "Sunday, September 27, 2026", "3:06 AM")
509 .unwrap();
510 section.shared.section.written().unwrap();
511 assert!(
512 section
513 .rows()
514 .unwrap()
515 .iter()
516 .all(|row| row.id != subpage.to_string())
517 );
518 let bytes = std::fs::read(root.join("OneNote_RecycleBin/OneNote_DeletedPages.one")).unwrap();
519 assert!(
520 notebook::session::stored_pages(&bytes)
521 .unwrap()
522 .iter()
523 .any(|stored| stored.page.identity == deleted.identity
524 && stored.page.title == "Sketches")
525 );
526}
527
528#[test]
529fn deleting_a_lone_section_page_still_creates_its_replacement_without_a_bin() {
530 let directory = tempfile::tempdir().unwrap();
531 let file = directory.path().join("Lone.one");
532 std::fs::write(
533 &file,
534 onestore::create_section("Lone.one", "Standalone page", "Fixture").unwrap(),
535 )
536 .unwrap();
537 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
538 sb_library_set_offline(&library, true);
539 let section = open(&library, &file.to_string_lossy());
540 let space = section.shared.section.pages().unwrap()[0].0;
541 section
542 .delete_page(space, "Sunday, September 27, 2026", "3:06 AM")
543 .unwrap();
544 section.shared.section.written().unwrap();
545 let pages = section.shared.section.pages().unwrap();
546 assert_eq!(pages.len(), 1);
547 assert_ne!(pages[0].0, space);
548 assert!(section.shared.section.page(space).is_err());
549 assert!(!directory.path().join("OneNote_RecycleBin").exists());
550}
551
552#[test]
553fn conflict_pages_list_under_their_page_and_take_no_edits() {
554 let (directory, root) = copy("conflict-page/candidate");
555 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
556 let section = open(&library, "synthetic.one");
557 let rows = section.rows().unwrap();
558 let version = rows
559 .iter()
560 .find_map(|row| row.versions.first())
561 .expect("a conflict page");
562 assert!(!version.user.is_empty());
563 let (page, read_only) = section.shared.page(version.id.parse().unwrap()).unwrap();
564 assert!(read_only);
565 // Conflicting paragraphs carry OneNote's highlight.
566 let highlighted = page.objects.iter().any(|object| match object {
567 onestore::page::PageObject::Outline(outline) => outline
568 .paragraphs
569 .iter()
570 .any(|paragraph| paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)),
571 _ => false,
572 });
573 assert!(highlighted);
574}
575
576#[test]
577fn a_refused_edit_is_kept_as_a_conflict_page_or_a_copy() {
578 use onestore::page::PageObject;
579
580 let directory = tempfile::tempdir().unwrap();
581 let file = directory.path().join("Keep.one");
582 let cache = directory.path().join("cache");
583 std::fs::write(
584 &file,
585 onestore::create_section("Keep.one", "Original body", "Fixture").unwrap(),
586 )
587 .unwrap();
588 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
589 let section = Section::open(
590 Arc::clone(&library),
591 file.to_str().unwrap(),
592 "Fixture".into(),
593 || {},
594 )
595 .unwrap();
596 let space = section.shared.section.pages().unwrap()[0].0;
597 let (page, read_only) = section.shared.page(space).unwrap();
598 let outline = page
599 .objects
600 .iter()
601 .find_map(|object| match object {
602 PageObject::Outline(outline) => Some(outline.id),
603 _ => None,
604 })
605 .unwrap();
606 let mut view = View {
607 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
608 presentation: None,
609 frame: Arc::default(),
610 section: Arc::clone(&section.shared),
611 read_only,
612 reload_pending: false,
613 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
614 };
615 focus(&mut view.canvas, "Original body");
616 assert!(sb_select(&mut view, 0, 0));
617 // Another writer deletes the outline the view still shows.
618 section
619 .shared
620 .section
621 .apply(
622 "Peer",
623 page_edit(space, vec![PageOp::Delete { object: outline }]),
624 )
625 .unwrap();
626 section.shared.section.written().unwrap();
627 if sb_view_format(&view) & 1 == 0 {
628 assert!(sb_view_apply(&mut view, 0));
629 }
630 assert!(unsafe { sb_insert(&mut view, c"Stale rich 😀 ".as_ptr()) });
631 let deadline = Instant::now() + Duration::from_secs(10);
632 while section.poll() & 4 == 0 {
633 assert!(Instant::now() < deadline);
634 std::thread::sleep(Duration::from_millis(10));
635 }
636 assert!(sb_view_keep(&view));
637 assert!(sb_view_reload(&mut view, true, None));
638 assert!(!sb_view_keep(&view));
639 section.shared.section.written().unwrap();
640 let kept = |conflicts: Vec<(ExGuid, Vec<onestore::ConflictPage>)>| {
641 let [(of, versions)] = conflicts.as_slice() else {
642 panic!("{conflicts:?}");
643 };
644 assert_eq!(*of, space);
645 let [version] = versions.as_slice() else {
646 panic!("{versions:?}");
647 };
648 assert_eq!(version.user, "Fixture");
649 version.space
650 };
651 let version = kept(section.shared.section.conflicts().unwrap());
652 let (page, read_only) = section.shared.page(version).unwrap();
653 assert!(read_only);
654 let stale = page.objects.iter().any(|object| match object {
655 PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| {
656 paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)
657 && match &paragraph.content {
658 onestore::page::ParagraphContent::Text(text) => {
659 text.text.text().starts_with("Stale rich 😀 Original body")
660 && text.text.spans()[0].format.bold == Some(true)
661 }
662 _ => false,
663 }
664 }),
665 _ => false,
666 });
667 assert!(stale, "{page:?}");
668 let shared = Arc::clone(&section.shared);
669 drop((view, section, library));
670 Arc::into_inner(shared)
671 .expect("No other writer")
672 .section
673 .close()
674 .unwrap();
675 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
676 let section = open(&library, "Keep.one");
677 assert_eq!(kept(section.shared.section.conflicts().unwrap()), version);
678
679 // A change outside the text, to a page deleted meanwhile, is kept as a copy of the page.
680 let (page, read_only) = section.shared.page(space).unwrap();
681 let title = page.title.clone();
682 let mut view = View {
683 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
684 presentation: None,
685 frame: Arc::default(),
686 section: Arc::clone(&section.shared),
687 read_only,
688 reload_pending: false,
689 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
690 };
691 section
692 .shared
693 .section
694 .apply(
695 "Peer",
696 Edit {
697 at: library::filetime(),
698 ops: vec![Op::Section(onestore::op::SectionOp::Delete(vec![space]))],
699 },
700 )
701 .unwrap();
702 section.shared.section.written().unwrap();
703 assert!(sb_view_set_paper(&mut view, 0x77, 0x88, 0x99, -1));
704 let deadline = Instant::now() + Duration::from_secs(10);
705 while section.poll() & 4 == 0 {
706 assert!(Instant::now() < deadline);
707 std::thread::sleep(Duration::from_millis(10));
708 }
709 assert!(sb_view_keep(&view));
710 let copies: Vec<_> = section
711 .shared
712 .section
713 .pages()
714 .unwrap()
715 .into_iter()
716 .filter(|(listed, _, _)| *listed != space)
717 .map(|(listed, _, _)| section.shared.section.page(listed).unwrap())
718 .collect();
719 let [copy] = copies.as_slice() else {
720 panic!("{copies:?}");
721 };
722 assert_eq!(copy.color, Some(0x0099_8877));
723 assert_eq!(copy.title, title);
724}
725
726#[test]
727fn search_finds_pages_by_title_and_text_across_sections() {
728 let directory = tempfile::tempdir().unwrap();
729 let library = Library::open(
730 &corpus("m6/native-features-01/notebook"),
731 directory.path(),
732 true,
733 )
734 .unwrap();
735 let found = library::search(&[&library], None, "collapsed PARENT").unwrap();
736 assert!(!found.is_empty());
737 assert!(found[0].snippet.to_lowercase().contains("collapsed parent"));
738 let [start, end] = found[0].snippet_hits[0];
739 let units: Vec<u16> = found[0].snippet.encode_utf16().collect();
740 assert_eq!(
741 String::from_utf16(&units[start..end])
742 .unwrap()
743 .to_lowercase(),
744 "collapsed"
745 );
746 assert!(
747 library::search(&[&library], None, "no such words anywhere")
748 .unwrap()
749 .is_empty()
750 );
751 let section = &found[0].section;
752 assert!(
753 library::search(&[&library], Some(section), "collapsed parent")
754 .unwrap()
755 .iter()
756 .all(|found| found.section == *section)
757 );
758}
759
760#[test]
761fn search_lists_title_matches_of_every_notebook_first() {
762 let directory = tempfile::tempdir().unwrap();
763 let personal = Library::open(
764 &corpus("search/notebook"),
765 &directory.path().join("a"),
766 true,
767 )
768 .unwrap();
769 let other = Library::open(
770 &corpus("m6/native-features-01/notebook"),
771 &directory.path().join("b"),
772 true,
773 )
774 .unwrap();
775 let found = library::search(&[&other, &personal], None, "tom").unwrap();
776 assert_eq!(found[0].title, "Tomatoes");
777 assert_eq!(found[0].notebook, 1);
778 assert_eq!(found[0].title_hits, [[0, 3]]);
779 assert!(found[1..].iter().all(|found| !found.in_title));
780 assert_eq!(found.len(), 5);
781}
782
783#[test]
784fn find_selects_the_match_and_formatting_reports_and_toggles() {
785 let (_directory, section) = features();
786 let mut canvas = canvas(&section, "Paragraph controls");
787 assert!(canvas.find("collapsed PARENT").unwrap());
788 let [start, end] = canvas.selection().unwrap();
789 assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed");
790 assert_eq!(canvas.format_bits().unwrap() & 1, 0);
791 assert!(canvas.format(0).unwrap());
792 assert_eq!(canvas.format_bits().unwrap() & 1, 1);
793 // To Do on the paragraph.
794 assert!(canvas.format(16).unwrap());
795 assert_ne!(canvas.format_bits().unwrap() & 1 << 16, 0);
796 assert!(canvas.edit().unwrap().is_some());
797}
798
799#[test]
800fn highlight_stores_any_colour_and_none_takes_it_away() {
801 let (_directory, section) = features();
802 let mut canvas = canvas(&section, "Paragraph controls");
803 assert!(canvas.find("collapsed PARENT").unwrap());
804 let stored = |canvas: &mut Canvas| {
805 section
806 .shared
807 .apply(canvas.edit().unwrap().unwrap())
808 .unwrap();
809 let (page, _) = section.shared.page(canvas.space).unwrap();
810 let mut highlights = Vec::new();
811 for object in &page.objects {
812 let onestore::page::PageObject::Outline(outline) = object else {
813 continue;
814 };
815 for paragraph in &outline.paragraphs {
816 if let onestore::page::ParagraphContent::Text(text) = &paragraph.content {
817 highlights.extend(
818 text.text
819 .spans()
820 .iter()
821 .filter_map(|span| span.format.highlight),
822 );
823 }
824 }
825 }
826 highlights
827 };
828 // Bright Green, which `sb_view_apply` has no number for.
829 assert!(canvas.highlight(Some(0x00ff00)).unwrap());
830 assert!(stored(&mut canvas).contains(&0x00ff00));
831 assert!(canvas.highlight(None).unwrap());
832 assert!(!stored(&mut canvas).contains(&0x00ff00));
833}
834
835#[test]
836fn a_picture_goes_in_at_the_caret_and_undoes() {
837 let (_directory, section) = features();
838 let mut canvas = canvas(&section, "Paragraph controls");
839 focus(&mut canvas, "Collapsed parent");
840 canvas.edit().unwrap();
841 let png = std::fs::read(corpus(
842 "../crates/snowbound/assets/icon/Snowbound-SnowLeopard.png",
843 ))
844 .unwrap();
845 assert!(canvas.insert_picture(&png, [64.0, 64.0]).unwrap());
846 let edit = canvas.edit().unwrap().unwrap();
847 assert!(edit.ops.iter().any(|op| matches!(
848 op,
849 Op::Page {
850 op: onestore::op::PageOp::Insert { .. },
851 ..
852 }
853 )));
854 section.shared.apply(edit).unwrap();
855 let pictures = |page: &Page| {
856 page.objects
857 .iter()
858 .filter_map(|object| match object {
859 onestore::page::PageObject::Outline(outline) => Some(
860 outline
861 .paragraphs
862 .iter()
863 .filter(|paragraph| {
864 matches!(
865 paragraph.content,
866 onestore::page::ParagraphContent::Image(_)
867 )
868 })
869 .count(),
870 ),
871 _ => None,
872 })
873 .sum::<usize>()
874 };
875 let before = pictures(&section.shared.page(canvas.space).unwrap().0);
876 assert!(moved(canvas.page.undo(false).unwrap()));
877 section
878 .shared
879 .apply(canvas.edit().unwrap().unwrap())
880 .unwrap();
881 assert_eq!(
882 pictures(&section.shared.page(canvas.space).unwrap().0),
883 before - 1
884 );
885}
886
887#[test]
888fn to_do_marks_a_new_pages_body() {
889 let (directory, root) = copy("m6/native-features-01/notebook");
890 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
891 let section = open(&library, "Features.one");
892 let page = section
893 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")
894 .unwrap();
895 let mut canvas = Canvas::new(
896 page,
897 section.shared.page(page).unwrap().0,
898 [1206, 2622],
899 3.0,
900 )
901 .unwrap();
902 canvas.set_transform(1.0, [0.0; 2]);
903 assert!(canvas.focus_title().unwrap());
904 canvas.insert("Groceries".into()).unwrap();
905 canvas.insert("\n".into()).unwrap();
906 canvas.insert("Oat milk".into()).unwrap();
907 canvas.insert("\n".into()).unwrap();
908 canvas.insert("Pears".into()).unwrap();
909 assert!(canvas.format(16).unwrap());
910 section
911 .shared
912 .apply(canvas.edit().unwrap().unwrap())
913 .unwrap();
914 let stored = section.shared.page(page).unwrap().0;
915 let tags: usize = stored
916 .objects
917 .iter()
918 .map(|object| match object {
919 onestore::page::PageObject::Outline(outline) => outline
920 .paragraphs
921 .iter()
922 .filter_map(|paragraph| paragraph.text())
923 .map(|text| text.tags.len())
924 .sum(),
925 _ => 0,
926 })
927 .sum();
928 assert_eq!(tags, 1);
929}
930
931/// Against a disposable share `agent` (`ONESTORE_SMB_LAB=HOST:PORT`, as
932/// `SNOWBOUND_TEST_SMB_USER` with `SNOWBOUND_TEST_SMB_PASSWORD`, or a guest): a notebook
933/// folder copied to the share lists, a section opens and publishes typing, and the listing
934/// kept in the cache opens the section again while the server cannot be reached.
935#[test]
936#[ignore = "requires ONESTORE_SMB_LAB pointing to a disposable share"]
937fn a_notebook_on_a_share_saves_and_opens_offline() {
938 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
939 let variable = |name| std::env::var(name).unwrap_or_default();
940 let server = |address: &str| library::Server {
941 address: address.into(),
942 share: "agent".into(),
943 user: variable("SNOWBOUND_TEST_SMB_USER"),
944 password: variable("SNOWBOUND_TEST_SMB_PASSWORD"),
945 domain: String::new(),
946 };
947 let client = server(&address).connect().unwrap();
948 let root = format!("snowbound-test-mobile-{}", std::process::id());
949 client.create_directory(&root).unwrap();
950 let source = corpus("media-edit/candidate/Features.one");
951 client
952 .create(
953 &format!("{root}/Features.one"),
954 &std::fs::read(&source).unwrap(),
955 )
956 .unwrap();
957 let cache = tempfile::tempdir().unwrap();
958 let library = Arc::new(Library::server(server(&address), &root, cache.path()).unwrap());
959 assert_eq!(library.tabs().unwrap()[0].path, "Features.one");
960 let section = open(&library, "Features.one");
961 let mut canvas = canvas(&section, "Paragraph controls");
962 focus(&mut canvas, "Collapsed parent");
963 canvas.insert("Shared ".into()).unwrap();
964 section
965 .shared
966 .apply(canvas.edit().unwrap().unwrap())
967 .unwrap();
968 assert!(section.flush(Duration::from_secs(30)));
969 let published = client
970 .read(&format!("{root}/Features.one"), 1 << 26)
971 .unwrap();
972 let arena = onestore::Arena::default();
973 let stored = onestore::Section::open(&arena, published).unwrap();
974 assert!(canvas::search::page_text(&stored.page(canvas.space).unwrap()).contains("Shared "));
975 sb_library_set_offline(&library, true);
976 let source = client
977 .read(&format!("{root}/Features.one"), 1 << 26)
978 .unwrap();
979 canvas.insert("Reachable offline marker ".into()).unwrap();
980 section
981 .shared
982 .apply(canvas.edit().unwrap().unwrap())
983 .unwrap();
984 section.shared.section.replica().written().unwrap();
985 let pending = section.shared.section.pending().unwrap();
986 assert_eq!(pending.len(), 1);
987 let page_space = canvas.space;
988 let shared = Arc::clone(&section.shared);
989 drop((canvas, section));
990 Arc::into_inner(shared).unwrap().section.close().unwrap();
991 let section = open(&library, "Features.one");
992 for _ in 0..10 {
993 assert_eq!(section.shared.section.pending().unwrap(), pending);
994 assert_eq!(
995 client
996 .read(&format!("{root}/Features.one"), 1 << 26)
997 .unwrap(),
998 source
999 );
1000 std::thread::sleep(Duration::from_millis(20));
1001 }
1002 sb_library_sync_now(&library);
1003 assert!(eventually(|| section
1004 .shared
1005 .section
1006 .pending()
1007 .unwrap()
1008 .is_empty()));
1009 let arena = onestore::Arena::default();
1010 let published = client
1011 .read(&format!("{root}/Features.one"), 1 << 26)
1012 .unwrap();
1013 let stored = onestore::Section::open(&arena, published).unwrap();
1014 assert_eq!(
1015 canvas::search::page_text(&stored.page(page_space).unwrap())
1016 .matches("Reachable offline marker ")
1017 .count(),
1018 1
1019 );
1020 let shared = Arc::clone(&section.shared);
1021 drop((section, library));
1022 Arc::into_inner(shared).unwrap().section.close().unwrap();
1023 // The same notebook while its server does not answer: a closed port on the lab's host.
1024 let host = address.split(':').next().unwrap();
1025 let mut unreachable = server(&format!("{host}:9"));
1026 unreachable.share = "agent".into();
1027 let listing = |address: &str| {
1028 cache.path().join("smb").join(format!(
1029 "{}.json",
1030 format!("{address}/agent/{root}").replace(|c: char| !c.is_alphanumeric(), "_")
1031 ))
1032 };
1033 std::fs::copy(listing(&address), listing(&format!("{host}:9"))).unwrap();
1034 notebook::location::moved(
1035 cache.path(),
1036 &notebook::location::smb(&address, "agent", &root),
1037 &notebook::location::smb(&format!("{host}:9"), "agent", &root),
1038 )
1039 .unwrap();
1040 let offline = Arc::new(Library::server(unreachable, &root, cache.path()).unwrap());
1041 assert_eq!(offline.tabs().unwrap()[0].path, "Features.one");
1042 let reopened = open(&offline, "Features.one");
1043 let page = reopened
1044 .shared
1045 .page(space(&reopened, "Paragraph controls"))
1046 .unwrap()
1047 .0;
1048 assert!(canvas::search::page_text(&page).contains("Shared "));
1049 sb_library_set_offline(&offline, true);
1050 let space = space(&reopened, "Paragraph controls");
1051 let pending = reopened.shared.section.pending().unwrap();
1052 assert!(
1053 reopened
1054 .delete_page(space, "Sunday, September 27, 2026", "3:06 AM")
1055 .is_err()
1056 );
1057 reopened.shared.section.written().unwrap();
1058 assert_eq!(reopened.shared.section.page(space).unwrap(), page);
1059 assert_eq!(reopened.shared.section.pending().unwrap(), pending);
1060 client.delete(&format!("{root}/Features.one")).unwrap();
1061 client.delete(&root).unwrap();
1062}
1063
1064#[test]
1065fn a_share_notebook_syncs_only_its_readable_sections() {
1066 let (directory, root) = copy("native-encrypted/cold-encrypted-02/notebook");
1067 std::fs::copy(
1068 corpus("m6/native-features-01/notebook/Empty.one"),
1069 root.join("Empty.one"),
1070 )
1071 .unwrap();
1072 let notebook =
1073 notebook::session::Notebook::open(&root, directory.path().join("cache")).unwrap();
1074 let mut files = std::collections::BTreeMap::new();
1075 library::files(notebook.catalog(), &mut files);
1076 assert_eq!(files.keys().collect::<Vec<_>>(), ["Empty.one"]);
1077}
1078
1079/// Paths the test coordinator was asked for, with whether to write.
1080static COORDINATED: Mutex<Vec<(String, bool)>> = Mutex::new(Vec::new());
1081
1082/// A file whose next coordinated access the test coordinator holds, as another app may: who
1083/// hears that it is held, and what lets it go.
1084type Held = (
1085 String,
1086 std::sync::mpsc::Sender<()>,
1087 std::sync::mpsc::Receiver<()>,
1088);
1089static HELD: Mutex<Option<Held>> = Mutex::new(None);
1090
1091extern "C" fn coordinator(
1092 path: *const c_char,
1093 write: bool,
1094 body: extern "C" fn(*mut c_void),
1095 context: *mut c_void,
1096) {
1097 let path = string(path);
1098 COORDINATED.lock().unwrap().push((path.clone(), write));
1099 let held = {
1100 let mut held = HELD.lock().unwrap();
1101 match &*held {
1102 Some((file, ..)) if *file == path => held.take(),
1103 _ => None,
1104 }
1105 };
1106 if let Some((_, entered, released)) = held {
1107 let _ = entered.send(());
1108 let _ = released.recv();
1109 }
1110 body(context);
1111}
1112
1113#[test]
1114fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {
1115 library::sb_set_coordinator(coordinator);
1116 let (directory, root) = copy("m6/native-features-01/notebook");
1117 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
1118 let section = open(&library, "Features.one");
1119 let mut canvas = canvas(&section, "Paragraph controls");
1120 focus(&mut canvas, "Collapsed parent");
1121 canvas.insert("Coordinated ".into()).unwrap();
1122 section
1123 .shared
1124 .apply(canvas.edit().unwrap().unwrap())
1125 .unwrap();
1126 assert!(section.flush(Duration::from_secs(20)));
1127 let file = root.canonicalize().unwrap().join("Features.one");
1128 let file = file.to_string_lossy();
1129 let coordinated = COORDINATED.lock().unwrap();
1130 assert!(
1131 coordinated
1132 .iter()
1133 .any(|(path, write)| path == &*file && !write)
1134 );
1135 assert!(
1136 coordinated
1137 .iter()
1138 .any(|(path, write)| path == &*file && *write)
1139 );
1140}
1141
1142#[test]
1143fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
1144 let (_directory, section) = features();
1145 let mut canvas = canvas(&section, "Paragraph controls");
1146 // The first view point, scanning down the page, that lands on the date.
1147 let date = (0..400)
1148 .flat_map(|y| (0..130).map(move |x| [x as f32 * 3.0, y as f32 * 3.0]))
1149 .find(|point| {
1150 matches!(
1151 canvas.page.hit(canvas.device(*point)),
1152 Some(Hit::Date(DateField::Date))
1153 )
1154 })
1155 .expect("the page shows its date");
1156 canvas.press(date).unwrap();
1157 canvas.release().unwrap();
1158 assert_eq!(canvas.asked.take(), Some(DateField::Date));
1159 let before = canvas.page.editor.date().unwrap().timestamp();
1160 // 2 January 2026, 10:30 UTC.
1161 assert!(
1162 canvas
1163 .change_date(
1164 1_767_349_800,
1165 ["Friday, January 2, 2026".into(), "10:30 AM".into()]
1166 )
1167 .unwrap()
1168 );
1169 let edit = canvas.edit().unwrap().unwrap();
1170 assert!(edit.ops.iter().any(|op| matches!(
1171 op,
1172 Op::Page {
1173 op: PageOp::Date { .. },
1174 ..
1175 }
1176 )));
1177 section.shared.apply(edit).unwrap();
1178 let stored = section.shared.page(canvas.space).unwrap().0.date_text();
1179 assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026");
1180 let after = canvas.page.editor.date().unwrap().timestamp();
1181 assert_ne!(after, before);
1182 // `sb_view_date_request` hands the host the date in its seconds.
1183 assert_eq!(library::unix(after), 1_767_349_800);
1184}
1185
1186#[test]
1187fn pages_open_with_the_snap_default_font_and_markdown_options_set() {
1188 // The defaults, so pages other tests open meanwhile see no change.
1189 sb_set_snap_to_grid(true);
1190 unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) };
1191 sb_set_markdown_shortcuts(true);
1192 assert_eq!(
1193 *options(),
1194 (true, canvas::editor::DefaultFont::default(), true)
1195 );
1196 let (_directory, section) = features();
1197 let canvas = canvas(&section, "Paragraph controls");
1198 assert!(canvas.page.snap_to_grid);
1199 assert_eq!(canvas.page.editor.default_font.face, "Calibri");
1200 assert!(canvas.page.editor.markdown.is_some());
1201}
1202
1203#[test]
1204fn a_share_notebook_names_its_files_from_the_share_s_top() {
1205 assert_eq!(library::on_share("", "Notes.one"), "Notes.one");
1206 assert_eq!(
1207 library::on_share("Team/Book", "Group/Notes.one"),
1208 "Team/Book/Group/Notes.one"
1209 );
1210}
1211
1212/// A notebook copy with its library, and a section of it open for editing. Working
1213/// `offline` from the start, no sync step can publish the section's edits.
1214fn notebook_open(path: &str, offline: bool) -> (tempfile::TempDir, PathBuf, Arc<Library>, Section) {
1215 let (directory, root) = copy("m6/native-features-01/notebook");
1216 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
1217 if offline {
1218 sb_library_set_offline(&library, true);
1219 }
1220 let section = open(&library, path);
1221 (directory, root, library, section)
1222}
1223
1224/// Waits up to ten seconds for `ready`.
1225fn eventually(mut ready: impl FnMut() -> bool) -> bool {
1226 let deadline = Instant::now() + Duration::from_secs(10);
1227 while Instant::now() < deadline {
1228 if ready() {
1229 return true;
1230 }
1231 std::thread::sleep(Duration::from_millis(50));
1232 }
1233 false
1234}
1235
1236#[test]
1237fn sync_status_lists_every_section_and_work_offline_holds_edits_until_sync_now() {
1238 let (_directory, root, library, section) = notebook_open("Features.one", false);
1239 // Every readable section, the open one's from its session.
1240 assert!(eventually(|| {
1241 let status = library.sync_status();
1242 status.len()
1243 == library
1244 .tabs()
1245 .unwrap()
1246 .iter()
1247 .filter(|tab| tab.readable)
1248 .count()
1249 && status
1250 .iter()
1251 .all(|sync| sync.state == SyncState::UpToDate as u8)
1252 }));
1253 sb_library_set_offline(&library, true);
1254 let mut canvas = canvas(&section, "Paragraph controls");
1255 focus(&mut canvas, "Collapsed parent");
1256 canvas.insert("Offline ".into()).unwrap();
1257 section
1258 .shared
1259 .apply(canvas.edit().unwrap().unwrap())
1260 .unwrap();
1261 let file = root.join("Features.one");
1262 let queued = |library: &Library| {
1263 library
1264 .sync_status()
1265 .into_iter()
1266 .find(|sync| sync.path == "Features.one")
1267 .unwrap()
1268 .queued
1269 };
1270 assert!(eventually(|| queued(&library) == 1));
1271 std::thread::sleep(Duration::from_millis(500));
1272 assert!(!stored_text(&file, canvas.space).contains("Offline "));
1273 sb_library_sync_now(&library);
1274 assert!(eventually(|| queued(&library) == 0));
1275 assert!(stored_text(&file, canvas.space).contains("Offline "));
1276 sb_library_set_offline(&library, false);
1277}
1278
1279#[test]
1280fn sections_open_the_first_time_while_the_background_syncs() {
1281 let (directory, root) = copy("m6/native-features-01/notebook");
1282 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
1283 let rounds = {
1284 let library = Arc::clone(&library);
1285 std::thread::spawn(move || {
1286 for _ in 0..200 {
1287 sb_library_sync_now(&library);
1288 std::thread::sleep(Duration::from_millis(2));
1289 }
1290 })
1291 };
1292 for tab in library
1293 .tabs()
1294 .unwrap()
1295 .into_iter()
1296 .filter(|tab| tab.readable)
1297 {
1298 open(&library, &tab.path);
1299 }
1300 rounds.join().unwrap();
1301 for tab in library
1302 .tabs()
1303 .unwrap()
1304 .into_iter()
1305 .filter(|tab| tab.readable)
1306 {
1307 open(&library, &tab.path);
1308 }
1309}
1310
1311#[test]
1312fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {
1313 let (directory, file) = copy("structural-probe/tag-gallery.one");
1314 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
1315 let path = file.to_string_lossy().into_owned();
1316 let section = open(&library, &path);
1317 let tagged = library.tagged().unwrap();
1318 assert!(tagged.len() >= 9, "{}", tagged.len());
1319 assert!(
1320 tagged
1321 .iter()
1322 .any(|tag| tag.name == "To Do" && tag.shape == 3)
1323 );
1324 let first = &tagged[0];
1325 let space = first.page.parse().unwrap();
1326 let (page, _) = section.shared.page(space).unwrap();
1327 let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap();
1328 assert!(
1329 canvas
1330 .select_paragraph(first.paragraph.parse().unwrap())
1331 .unwrap()
1332 );
1333 let [start, end] = canvas.selection().unwrap();
1334 assert!(canvas.text([start, end]).contains(first.text.trim()));
1335}
1336
1337#[test]
1338fn every_tag_applies_and_reports_and_remove_tag_clears_them() {
1339 let (_directory, section) = features();
1340 let mut canvas = canvas(&section, "Paragraph controls");
1341 focus(&mut canvas, "Collapsed parent");
1342 for index in [0, 12, 28] {
1343 assert!(canvas.format(16 + index).unwrap());
1344 assert_ne!(canvas.format_bits().unwrap() & 1 << (16 + index), 0);
1345 }
1346 assert!(canvas.format(8).unwrap());
1347 assert_eq!(canvas.format_bits().unwrap() >> 16, 0);
1348 assert!(canvas.format(16 + NoteTag::defaults().len() as u8).is_err());
1349}
1350
1351#[test]
1352fn tag_icons_draw_their_art_and_highlighting_tags_have_none() {
1353 let mut rgba = vec![0; 32 * 32 * 4];
1354 for tag in NoteTag::defaults() {
1355 let drawn = unsafe { sb_tag_icon(tag.shape, true, 32, rgba.as_mut_ptr()) };
1356 assert_eq!(drawn, tag.shape != 0, "{tag:?}");
1357 if drawn {
1358 assert!(
1359 rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[3] > 0),
1360 "{tag:?}"
1361 );
1362 }
1363 }
1364}
1365
1366#[test]
1367fn page_colour_rule_lines_and_art_are_stored_and_undo() {
1368 let (_directory, section) = features();
1369 let mut canvas = canvas(&section, "Paragraph controls");
1370 assert!(canvas.set_paper(Some(0xfef5ed), Some(1)).unwrap());
1371 assert!(canvas.set_art(Some("Bamboo")).unwrap());
1372 section
1373 .shared
1374 .apply(canvas.edit().unwrap().unwrap())
1375 .unwrap();
1376 let art = |page: &Page| {
1377 page.objects
1378 .iter()
1379 .filter(|object| {
1380 matches!(object, onestore::page::PageObject::Image(image) if image.background)
1381 })
1382 .count()
1383 };
1384 let stored = section.shared.page(canvas.space).unwrap().0;
1385 assert_eq!(stored.color, Some(0xfef5ed));
1386 assert_eq!(stored.rule_lines, Some(canvas::template::RULE_LINES[1].1));
1387 assert_eq!(art(&stored), 1);
1388 assert!(canvas.set_art(None).unwrap());
1389 assert!(moved(canvas.page.undo(false).unwrap()));
1390 assert!(moved(canvas.page.undo(false).unwrap()));
1391 section
1392 .shared
1393 .apply(canvas.edit().unwrap().unwrap())
1394 .unwrap();
1395 let stored = section.shared.page(canvas.space).unwrap().0;
1396 assert_eq!((stored.color, art(&stored)), (Some(0xfef5ed), 0));
1397}
1398
1399#[test]
1400fn insert_space_turns_the_next_touch_into_a_drag() {
1401 let (_directory, section) = features();
1402 let mut canvas = canvas(&section, "Paragraph controls");
1403 assert_eq!(canvas.target([300.0, 1500.0]), Target::Page);
1404 let _ = canvas.page.insert_space();
1405 assert_eq!(canvas.target([300.0, 1500.0]), Target::Grip);
1406}
1407
1408/// The page's drawings as the section stores them.
1409fn stored_ink(section: &Section, space: ExGuid) -> Vec<onestore::page::Ink> {
1410 let (page, _) = section.shared.page(space).unwrap();
1411 page.objects
1412 .into_iter()
1413 .filter_map(|object| match object {
1414 onestore::page::PageObject::Ink(ink) => Some(ink),
1415 _ => None,
1416 })
1417 .collect()
1418}
1419
1420/// A press, drags through `points` and a release, and the edit it made stored.
1421fn trace(canvas: &mut Canvas, section: &Section, points: &[[f32; 2]]) {
1422 canvas.press(points[0]).unwrap();
1423 for &point in &points[1..] {
1424 canvas.drag(point).unwrap();
1425 }
1426 canvas.release().unwrap();
1427 if let Some(edit) = canvas.edit().unwrap() {
1428 section.shared.apply(edit).unwrap();
1429 }
1430}
1431
1432fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> {
1433 (0..=20)
1434 .map(|step| {
1435 let t = step as f32 / 20.0;
1436 [
1437 from[0] + (to[0] - from[0]) * t,
1438 from[1] + (to[1] - from[1]) * t,
1439 ]
1440 })
1441 .collect()
1442}
1443
1444/// The tab colour of a section that stores none.
1445const SECTION: u32 = 0x00e4_a88a;
1446
1447#[test]
1448fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
1449 let (_directory, section) = features();
1450 let mut canvas = canvas(&section, "Paragraph controls");
1451 let space = canvas.space;
1452 let before = stored_ink(&section, space).len();
1453 // The red pen, second in OneNote's gallery after the section's accent.
1454 canvas.set_tool(1, 2, SECTION).unwrap();
1455 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0]));
1456 let drawn = stored_ink(&section, space);
1457 assert_eq!(drawn.len(), before + 1);
1458 let stroke = &drawn.last().unwrap().strokes[0];
1459 assert_eq!(stroke.color, Some(0x241ced));
1460 assert!(stroke.points.len() > 2);
1461 // A highlighter stroke is a drawing of its own.
1462 canvas.set_tool(1, 6, SECTION).unwrap();
1463 trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0]));
1464 assert_eq!(stored_ink(&section, space).len(), before + 2);
1465 assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9));
1466
1467 // The lasso picks the pen stroke; a touch on it then drags it.
1468 canvas.set_tool(3, 0, SECTION).unwrap();
1469 let lasso = [
1470 [80.0, 580.0],
1471 [320.0, 580.0],
1472 [320.0, 700.0],
1473 [80.0, 700.0],
1474 [80.0, 582.0],
1475 ];
1476 trace(&mut canvas, &section, &lasso);
1477 let frame = canvas.ink_selection().unwrap();
1478 assert!(frame[2] > 150.0 && frame[3] > 40.0, "{frame:?}");
1479 let on = [200.0, 630.0];
1480 assert_eq!(canvas.target(on), Target::Grip);
1481 trace(&mut canvas, &section, &[on, [230.0, 645.0], [260.0, 660.0]]);
1482 let moved = stored_ink(&section, space);
1483 let layout = &moved[before].layout;
1484 assert!(layout.x.is_some_and(|x| x > 0.0), "{layout:?}");
1485 let _ = canvas
1486 .page
1487 .key(&Key::Named(NamedKey::Backspace), None)
1488 .unwrap();
1489 section
1490 .shared
1491 .apply(canvas.edit().unwrap().unwrap())
1492 .unwrap();
1493 assert_eq!(stored_ink(&section, space).len(), before + 1);
1494 assert!(canvas.ink_selection().is_none());
1495
1496 // The eraser takes the highlighter's stroke it crosses; undo brings it back.
1497 canvas.set_tool(2, 0, SECTION).unwrap();
1498 trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0]));
1499 assert_eq!(stored_ink(&section, space).len(), before);
1500 let _ = canvas.page.undo(false).unwrap();
1501 section
1502 .shared
1503 .apply(canvas.edit().unwrap().unwrap())
1504 .unwrap();
1505 assert_eq!(stored_ink(&section, space).len(), before + 1);
1506}
1507
1508#[test]
1509fn the_accent_pen_and_shapes_draw_as_the_desktop_and_a_cancelled_sweep_stores_nothing() {
1510 let (_directory, section) = features();
1511 let mut canvas = canvas(&section, "Paragraph controls");
1512 let space = canvas.space;
1513 let before = stored_ink(&section, space).len();
1514 let accent = pens(canvas::gpu::colorref(SECTION))[0];
1515 canvas.set_tool(1, 0, SECTION).unwrap();
1516 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 600.0]));
1517 let drawn = stored_ink(&section, space);
1518 assert_eq!(drawn.last().unwrap().strokes[0].color, accent.color);
1519 assert!(accent.color.is_some());
1520 // An oval dragged out is one drawing that keeps its shape.
1521 canvas.set_tool(4, 3, SECTION).unwrap();
1522 trace(&mut canvas, &section, &line([100.0, 700.0], [300.0, 800.0]));
1523 let drawn = stored_ink(&section, space);
1524 assert_eq!(drawn.len(), before + 2);
1525 assert!(drawn.last().unwrap().shape.is_some());
1526
1527 // An eraser sweep a second finger cancels gives back what it took and stores nothing.
1528 canvas.set_tool(2, 0, SECTION).unwrap();
1529 canvas.press([200.0, 580.0]).unwrap();
1530 canvas.drag([200.0, 620.0]).unwrap();
1531 assert!(canvas.page.editor.ink_extent(&[drawn[before].id]).is_none());
1532 let _ = canvas.page.cancel_ink().unwrap();
1533 assert!(canvas.edit().unwrap().is_none());
1534 assert!(canvas.page.editor.ink_extent(&[drawn[before].id]).is_some());
1535 assert!(!canvas.page.editor.can_redo());
1536 assert_eq!(stored_ink(&section, space).len(), before + 2);
1537}
1538
1539/// Queues an offline edit to Features.one and closes its session, leaving the edit to the
1540/// background; the replicas in the cache, and the text edited in.
1541fn unpublished_edit() -> (tempfile::TempDir, PathBuf, Arc<Library>, ExGuid) {
1542 let (directory, root, library, section) = notebook_open("Features.one", true);
1543 let mut canvas = canvas(&section, "Paragraph controls");
1544 focus(&mut canvas, "Collapsed parent");
1545 canvas.insert("Unpublished ".into()).unwrap();
1546 section
1547 .shared
1548 .apply(canvas.edit().unwrap().unwrap())
1549 .unwrap();
1550 assert!(eventually(|| !section
1551 .shared
1552 .section
1553 .pending()
1554 .unwrap()
1555 .is_empty()));
1556 let space = canvas.space;
1557 drop(canvas);
1558 drop(section);
1559 (directory, root, library, space)
1560}
1561
1562fn replicas(directory: &tempfile::TempDir) -> usize {
1563 let Ok(folders) = std::fs::read_dir(directory.path().join("cache/replicas")) else {
1564 return 0;
1565 };
1566 folders
1567 .filter_map(|folder| std::fs::read_dir(folder.ok()?.path()).ok())
1568 .flatten()
1569 .filter(|entry| {
1570 entry
1571 .as_ref()
1572 .is_ok_and(|entry| entry.path().extension() == Some("sqlite".as_ref()))
1573 })
1574 .count()
1575}
1576
1577#[test]
1578fn closing_publishes_a_pending_edit_so_the_notebook_renames_without_orphans() {
1579 let (directory, root, library, space) = unpublished_edit();
1580 library.close(Duration::from_secs(20)).unwrap();
1581 assert!(stored_text(&root.join("Features.one"), space).contains("Unpublished "));
1582 drop(library);
1583 assert!(eventually(|| replicas(&directory) == 0));
1584 let renamed = directory.path().join("Renamed");
1585 std::fs::rename(&root, &renamed).unwrap();
1586 let library = Arc::new(Library::open(&renamed, &directory.path().join("cache"), true).unwrap());
1587 let section = open(&library, "Features.one");
1588 let canvas = canvas(&section, "Paragraph controls");
1589 assert!(stored_text(&renamed.join("Features.one"), canvas.space).contains("Unpublished "));
1590}
1591
1592#[test]
1593fn closing_refuses_while_an_edit_cannot_publish_then_deletes_nothing_pending() {
1594 let (directory, root, library, space) = unpublished_edit();
1595 let away = directory.path().join("Away");
1596 std::fs::rename(&root, &away).unwrap();
1597 assert!(library.close(Duration::from_secs(2)).is_err());
1598 assert!(replicas(&directory) > 0);
1599 std::fs::rename(&away, &root).unwrap();
1600 library.close(Duration::from_secs(20)).unwrap();
1601 assert!(stored_text(&root.join("Features.one"), space).contains("Unpublished "));
1602 drop(library);
1603 std::fs::remove_dir_all(&root).unwrap();
1604 assert!(eventually(|| replicas(&directory) == 0));
1605}
1606
1607/// Print and Export as PDF make a PDF of the open page on the paper asked for.
1608#[test]
1609fn the_page_prints_as_a_pdf() {
1610 let (_directory, section) = features();
1611 let title = section.shared.section.pages().unwrap()[0].1.clone();
1612 let pdf = canvas(&section, &title)
1613 .pdf(canvas::print::A4, "Features")
1614 .unwrap();
1615 assert!(pdf.starts_with(b"%PDF"));
1616 let text = String::from_utf8_lossy(&pdf);
1617 assert!(
1618 text.contains("/MediaBox [0 0 595.276 841.89]"),
1619 "{}",
1620 &text[..400]
1621 );
1622}
1623
1624#[test]
1625fn a_recording_goes_in_as_the_desktop_stores_it_and_a_tap_plays_it() {
1626 use onestore::page::{PageObject, ParagraphContent};
1627 let (directory, section) = features();
1628 let mut canvas = canvas(&section, "Paragraph controls");
1629 focus(&mut canvas, "Collapsed parent");
1630 canvas.edit().unwrap();
1631 let date = ["Tuesday, September 29, 2026", "5:18 PM"];
1632 assert!(canvas.start_recording(false, date[0], date[1]).unwrap());
1633 canvas.insert("Linked note".into()).unwrap();
1634 let samples: Vec<i16> = (0..16_000).map(|n| (n % 64 * 256 - 8192) as i16).collect();
1635 let wav = canvas::recording::wave(canvas::recording::RATE, &samples);
1636 assert!(canvas.finish_recording(Some(wav), false).unwrap());
1637 section
1638 .shared
1639 .apply(canvas.edit().unwrap().unwrap())
1640 .unwrap();
1641
1642 let page = section.shared.page(canvas.space).unwrap().0;
1643 let paragraphs: Vec<_> = page
1644 .objects
1645 .iter()
1646 .filter_map(|object| match object {
1647 PageObject::Outline(outline) => Some(&outline.paragraphs),
1648 _ => None,
1649 })
1650 .flatten()
1651 .collect();
1652 let file = paragraphs
1653 .iter()
1654 .find_map(|paragraph| match &paragraph.content {
1655 ParagraphContent::Attachment(file) => Some(file),
1656 _ => None,
1657 })
1658 .unwrap();
1659 let recording = file.recording.unwrap();
1660 assert_eq!(file.filename, format!("{}.wav", page.title));
1661 assert_eq!((recording.kind, recording.duration_ms), (1, Some(1000)));
1662 // IMA ADPCM, as the desktop stores audio for OneNote (`corpus/recording`).
1663 let bytes = file.bytes.as_deref().unwrap();
1664 assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 0x11);
1665 let linked = |text: &str| {
1666 paragraphs
1667 .iter()
1668 .find(|paragraph| {
1669 paragraph
1670 .text()
1671 .is_some_and(|t| t.text.text().starts_with(text))
1672 })
1673 .map(|paragraph| (paragraph.media.recordings.clone(), paragraph.media.time_ms))
1674 .unwrap()
1675 };
1676 let line = linked("Audio recording started: 5:18 PM Tuesday, September 29, 2026");
1677 assert_eq!(line, (vec![recording.id], Some(0)));
1678 assert_eq!(linked("Linked note").0, [recording.id]);
1679
1680 // A tap on the recording plays it as PCM from the start.
1681 let tapped = (0..800)
1682 .flat_map(|y| (0..300).map(move |x| [x as f32 * 2.0, y as f32 * 2.0]))
1683 .find(|&point| {
1684 matches!(
1685 canvas.page.hit(canvas.device(point.map(|v| v * POINT))),
1686 Some(Hit::File(_))
1687 )
1688 })
1689 .unwrap()
1690 .map(|v| v * POINT);
1691 canvas.press(tapped).unwrap();
1692 canvas.release().unwrap();
1693 let folder = directory.path().join("playback");
1694 let request = canvas.take_play(&folder).unwrap().unwrap();
1695 assert_eq!(request["at_ms"], 0);
1696 assert!(request["video"].is_null());
1697 let sound = std::fs::read(request["sound"].as_str().unwrap()).unwrap();
1698 assert_eq!(u16::from_le_bytes([sound[20], sound[21]]), 1);
1699 assert!(canvas.take_play(&folder).unwrap().is_none());
1700 canvas.played(Some(5_000)).unwrap();
1701 canvas.played(None).unwrap();
1702}
1703
1704#[test]
1705fn a_movie_from_the_camera_becomes_the_desktop_s_avi() {
1706 let movie = recording::sb_movie_new();
1707 let bgra: Vec<u8> = (0..480 * 640 * 4).map(|n| (n % 251) as u8).collect();
1708 for index in 0..20u64 {
1709 unsafe {
1710 recording::sb_movie_picture(
1711 &mut *movie,
1712 index * 50_000,
1713 bgra.as_ptr(),
1714 640,
1715 480,
1716 640 * 4,
1717 )
1718 };
1719 }
1720 let pcm = vec![0u8; canvas::recording::RATE as usize * 2];
1721 unsafe { recording::sb_movie_sound(&mut *movie, pcm.as_ptr(), pcm.len()) };
1722 let mut length = 0;
1723 let avi = unsafe { recording::sb_movie_finish(movie, 1_000_000, &mut length) };
1724 let bytes = unsafe { std::slice::from_raw_parts(avi, length) }.to_vec();
1725 unsafe { sb_bytes_free(avi, length) };
1726 let parsed = canvas::recording::video::Movie::parse(&bytes).unwrap();
1727 assert_eq!((parsed.frames.len(), parsed.duration_ms()), (15, 1000));
1728 assert!(parsed.sound.is_some());
1729}
1730
1731fn levels(section: &Section) -> Vec<(ExGuid, u32)> {
1732 let pages = section.shared.section.pages().unwrap();
1733 pages
1734 .into_iter()
1735 .map(|(space, _, level)| (space, level))
1736 .collect()
1737}
1738
1739#[test]
1740fn pages_drag_indent_and_undo_as_one_edit_each() {
1741 let (_directory, _root, _library, section) = notebook_open("Features.one", true);
1742 let queued = || {
1743 section.shared.section.written().unwrap();
1744 section.shared.section.pending().unwrap().len()
1745 };
1746 let before = levels(&section);
1747 assert!(before.len() > 2);
1748 let last = before.last().unwrap().0;
1749 let mut dragged = before.clone();
1750 let moved = dragged.pop().unwrap();
1751 dragged.insert(0, moved);
1752 let mut edits = queued();
1753 let mut arrange = |order: &[(ExGuid, u32)], moved: ExGuid| {
1754 assert!(section.arrange(order, &[moved]).unwrap());
1755 assert_eq!(levels(&section), order);
1756 edits += 1;
1757 assert_eq!(queued(), edits);
1758 };
1759 arrange(&dragged, last);
1760 let second = dragged[1].0;
1761 let mut indented = dragged.clone();
1762 indented[1].1 = 2;
1763 arrange(&indented, second);
1764 arrange(&dragged, second);
1765 arrange(&before, last);
1766 assert!(!section.arrange(&before, &[ExGuid::default()]).unwrap());
1767 assert_eq!(queued(), edits);
1768}
1769
1770#[test]
1771fn a_page_moves_to_another_section_and_back_where_it_was() {
1772 let (_directory, root, library, features) = notebook_open("Features.one", false);
1773 let before = levels(&features);
1774 let (space, level) = before[1];
1775 let title = features.shared.section.page(space).unwrap().title;
1776 let date = ["Thursday, October 1, 2026", "9:00 AM"];
1777 let moved = library
1778 .move_page(
1779 "Features.one",
1780 space,
1781 "Empty.one",
1782 None,
1783 1,
1784 None,
1785 "Clover Test",
1786 date,
1787 )
1788 .unwrap();
1789 let arrived: ExGuid = moved.page.parse().unwrap();
1790 assert_eq!(moved.added, None);
1791 assert_eq!(moved.before, Some(before[2].0.to_string()));
1792 assert_eq!(moved.level, level);
1793 assert!(levels(&features).iter().all(|(page, _)| *page != space));
1794 let empty = open(&library, "Empty.one");
1795 let listed = empty.shared.section.pages().unwrap();
1796 assert_eq!(listed.last().unwrap(), &(arrived, title.clone(), 1));
1797 drop(empty);
1798 let back = library
1799 .move_page(
1800 "Empty.one",
1801 arrived,
1802 "Features.one",
1803 moved.before.map(|before| before.parse().unwrap()),
1804 moved.level,
1805 None,
1806 "Clover Test",
1807 date,
1808 )
1809 .unwrap()
1810 .page
1811 .parse()
1812 .unwrap();
1813 let mut returned = before.clone();
1814 returned[1].0 = back;
1815 assert_eq!(levels(&features), returned);
1816 assert_eq!(features.shared.section.page(back).unwrap().title, title);
1817 drop(features);
1818 library.close(Duration::from_secs(20)).unwrap();
1819 let arena = onestore::Arena::default();
1820 let file = onestore::read_file(root.join("Features.one")).unwrap();
1821 let stored = onestore::Section::open(&arena, file).unwrap();
1822 assert_eq!(stored.page(back).unwrap().title, title);
1823}
1824
1825/// A page edited while it moves stays, with the edit, beside the copy that arrived; a page added
1826/// meanwhile lets it go. The files hold both once the notebook closes.
1827#[test]
1828fn a_page_edited_while_it_moves_stays_with_its_edit() {
1829 library::sb_set_coordinator(coordinator);
1830 for edited in [true, false] {
1831 let (_directory, root, library, features) = notebook_open("Features.one", false);
1832 assert!(eventually(|| {
1833 let status = library.sync_status();
1834 !status.is_empty()
1835 && status
1836 .iter()
1837 .all(|sync| sync.state == SyncState::UpToDate as u8)
1838 }));
1839 let space = space(&features, "Task controls");
1840 // Empty.one is held for the move, which opens it, until the edit below is made.
1841 let (entered, holding) = std::sync::mpsc::channel();
1842 let (release, released) = std::sync::mpsc::channel();
1843 let empty = root.canonicalize().unwrap().join("Empty.one");
1844 *HELD.lock().unwrap() = Some((empty.to_string_lossy().into_owned(), entered, released));
1845 let moving = Arc::clone(&library);
1846 let date = ["Thursday, October 1, 2026", "9:00 AM"];
1847 let move_page = std::thread::spawn(move || {
1848 moving
1849 .move_page(
1850 "Features.one",
1851 space,
1852 "Empty.one",
1853 None,
1854 1,
1855 None,
1856 "Clover Test",
1857 date,
1858 )
1859 .map(|moved| moved.copied)
1860 .map_err(|error| error.to_string())
1861 });
1862 holding.recv_timeout(Duration::from_secs(10)).unwrap();
1863 if edited {
1864 let mut canvas = canvas(&features, "Task controls");
1865 focus(&mut canvas, "Disabled standalone task");
1866 canvas.insert("Edited ".into()).unwrap();
1867 features
1868 .shared
1869 .apply(canvas.edit().unwrap().unwrap())
1870 .unwrap();
1871 } else {
1872 features.new_page(None, date[0], date[1]).unwrap();
1873 }
1874 features.shared.section.written().unwrap();
1875 drop(release);
1876 let copied = move_page.join().unwrap().unwrap();
1877 assert_eq!(copied.is_some(), edited, "{copied:?}");
1878 drop(features);
1879 library.close(Duration::from_secs(20)).unwrap();
1880 let arena = onestore::Arena::default();
1881 let stored = onestore::read_file(root.join("Features.one")).unwrap();
1882 let stayed = onestore::Section::open(&arena, stored).unwrap().page(space);
1883 assert_eq!(stayed.is_ok(), edited);
1884 if edited {
1885 assert!(canvas::search::page_text(&stayed.unwrap()).contains("Edited "));
1886 }
1887 let arena = onestore::Arena::default();
1888 let stored = onestore::read_file(root.join("Empty.one")).unwrap();
1889 let mut empty = onestore::Section::open(&arena, stored).unwrap();
1890 let copies: Vec<String> = (empty.pages().unwrap().into_iter())
1891 .filter(|(_, title, _)| title == "Task controls")
1892 .map(|(copy, ..)| canvas::search::page_text(&empty.page(copy).unwrap()))
1893 .collect();
1894 assert_eq!(copies.len(), 1);
1895 assert!(!copies[0].contains("Edited "));
1896 }
1897}
1898
1899/// A page whose section cannot write as it leaves stays there beside the copy that arrived,
1900/// and the move says so; both files hold it once the notebook closes.
1901#[test]
1902fn a_page_its_section_cannot_retire_stays_beside_its_copy() {
1903 vfs::install();
1904 let _exclusive = vfs::EXCLUSIVE
1905 .lock()
1906 .unwrap_or_else(std::sync::PoisonError::into_inner);
1907 let (directory, root, library, empty) = notebook_open("Empty.one", false);
1908 let features = open(&library, "Features.one");
1909 let space = space(&features, "Task controls");
1910 drop(features);
1911 let file = std::fs::read(root.join("Features.one")).unwrap();
1912 let identity = onestore::RevisionIndex::parse(&onestore::Store::parse(&file).unwrap())
1913 .unwrap()
1914 .root
1915 .guid;
1916 let location = notebook::location::local(&root).unwrap();
1917 let replica =
1918 notebook::location::folder(&directory.path().join("cache"), &location).join(format!(
1919 "{}.sqlite",
1920 (identity.iter())
1921 .map(|byte| format!("{byte:02x}"))
1922 .collect::<String>()
1923 ));
1924 *vfs::FAILING.lock().unwrap() = Some(replica.canonicalize().unwrap());
1925 let date = ["Thursday, October 1, 2026", "9:00 AM"];
1926 let moved = library.move_page(
1927 "Features.one",
1928 space,
1929 "Empty.one",
1930 None,
1931 1,
1932 None,
1933 "Clover Test",
1934 date,
1935 );
1936 *vfs::FAILING.lock().unwrap() = None;
1937 assert!(moved.unwrap().copied.is_some());
1938 drop(empty);
1939 library.close(Duration::from_secs(20)).unwrap();
1940 let titles = |name: &str| {
1941 let arena = onestore::Arena::default();
1942 let stored = onestore::read_file(root.join(name)).unwrap();
1943 let mut section = onestore::Section::open(&arena, stored).unwrap();
1944 (section.pages().unwrap().into_iter())
1945 .filter(|(_, title, _)| title == "Task controls")
1946 .count()
1947 };
1948 assert_eq!((titles("Features.one"), titles("Empty.one")), (1, 1));
1949}
1950
1951/// A page the section it moves to refuses stays where it was, and the move fails.
1952#[test]
1953fn a_page_the_other_section_refuses_stays() {
1954 let (_directory, _root, library, features) = notebook_open("Features.one", false);
1955 let before = levels(&features);
1956 let (space, _) = before[1];
1957 let page = features.shared.section.page(space).unwrap();
1958 let empty = open(&library, "Empty.one");
1959 let listed = levels(&empty);
1960 // A section's first page has level 1: Empty.one, holding none, refuses this one.
1961 assert!(
1962 library
1963 .move_page(
1964 "Features.one",
1965 space,
1966 "Empty.one",
1967 None,
1968 2,
1969 None,
1970 "Clover Test",
1971 ["Thursday, October 1, 2026", "9:00 AM"],
1972 )
1973 .is_err()
1974 );
1975 empty.shared.section.written().unwrap();
1976 features.shared.section.written().unwrap();
1977 assert_eq!(levels(&empty), listed);
1978 assert_eq!(levels(&features), before);
1979 assert_eq!(features.shared.section.page(space).unwrap(), page);
1980}
1981
1982#[test]
1983fn a_sections_last_page_moving_leaves_a_page_that_undo_takes_out() {
1984 let (_directory, _root, library, features) = notebook_open("Features.one", false);
1985 let date = ["Thursday, October 1, 2026", "9:02 AM"];
1986 let page = features.new_page(None, date[0], date[1]).unwrap();
1987 let arrived: ExGuid = library
1988 .move_page(
1989 "Features.one",
1990 page,
1991 "Empty.one",
1992 None,
1993 1,
1994 None,
1995 "Clover Test",
1996 date,
1997 )
1998 .unwrap()
1999 .page
2000 .parse()
2001 .unwrap();
2002 assert_eq!(levels(&open(&library, "Empty.one")), [(arrived, 1)]);
2003 let returned = library
2004 .move_page(
2005 "Empty.one",
2006 arrived,
2007 "Features.one",
2008 None,
2009 1,
2010 None,
2011 "Clover Test",
2012 date,
2013 )
2014 .unwrap();
2015 let back = returned.page.parse().unwrap();
2016 let added: ExGuid = returned.added.unwrap().parse().unwrap();
2017 assert_eq!(levels(&open(&library, "Empty.one")), [(added, 1)]);
2018 library
2019 .move_page(
2020 "Features.one",
2021 back,
2022 "Empty.one",
2023 None,
2024 1,
2025 Some(added),
2026 "Clover Test",
2027 date,
2028 )
2029 .unwrap();
2030 assert_eq!(levels(&open(&library, "Empty.one")).len(), 1);
2031}
2032
2033#[test]
2034fn sections_reorder_and_move_between_groups_and_back() {
2035 let (_directory, root, library, features) = notebook_open("Features.one", false);
2036 drop(features);
2037 let top = |library: &Library| -> Vec<String> {
2038 let tabs = library.tabs().unwrap();
2039 tabs.into_iter()
2040 .filter(|tab| tab.group.is_empty())
2041 .map(|tab| tab.path)
2042 .collect()
2043 };
2044 let before = top(&library);
2045 let mut reversed = before.clone();
2046 reversed.reverse();
2047 assert_eq!(
2048 library.place("Empty.one", "", &reversed).unwrap(),
2049 "Empty.one"
2050 );
2051 assert_eq!(top(&library), reversed);
2052 assert_eq!(
2053 library.place("Empty.one", "Group A", &[]).unwrap(),
2054 "Group A/Empty.one"
2055 );
2056 assert!(root.join("Group A/Empty.one").exists());
2057 let tabs = library.tabs().unwrap();
2058 let grouped: Vec<&str> = tabs
2059 .iter()
2060 .filter(|tab| tab.group == "Group A")
2061 .map(|tab| tab.path.as_str())
2062 .collect();
2063 assert_eq!(grouped.last(), Some(&"Group A/Empty.one"));
2064 assert_eq!(
2065 library.place("Group A/Empty.one", "", &before).unwrap(),
2066 "Empty.one"
2067 );
2068 assert_eq!(top(&library), before);
2069 assert!(root.join("Empty.one").exists());
2070 open(&library, "Empty.one");
2071}
2072
2073#[test]
2074fn dimensions_round_in_pixels_and_keep_subpixel_windows_nonempty() {
2075 assert_eq!(
2076 view_pixels([320.25, 480.75], 2.0, 8192).unwrap(),
2077 [641, 962]
2078 );
2079 assert_eq!(view_pixels([0.1, 0.2], 0.5, 8192).unwrap(), [1, 1]);
2080 assert_eq!(view_pixels([8192.0, 1.0], 1.0, 8192).unwrap(), [8192, 1]);
2081}
2082
2083#[test]
2084fn dimensions_reject_nonpositive_and_nonfinite_inputs() {
2085 for bad in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
2086 assert!(view_pixels([bad, 1.0], 1.0, 8192).is_err());
2087 assert!(view_pixels([1.0, bad], 1.0, 8192).is_err());
2088 assert!(view_pixels([1.0, 1.0], bad, 8192).is_err());
2089 }
2090}
2091
2092#[test]
2093fn dimensions_reject_overflow_and_the_device_texture_limit() {
2094 assert!(view_pixels([f32::MAX, 1.0], f32::MAX, u32::MAX).is_err());
2095 assert!(view_pixels([u32::MAX as f32, 1.0], 1.0, u32::MAX).is_err());
2096 assert!(view_pixels([8193.0, 1.0], 1.0, 8192).is_err());
2097 assert!(view_pixels([1.0, 4097.0], 2.0, 8192).is_err());
2098}
2099
2100#[test]
2101fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() {
2102 let (_directory, section) = features();
2103 let space = section.shared.section.pages().unwrap()[0].0;
2104 let id = CString::new(space.to_string()).unwrap();
2105 assert!(
2106 unsafe {
2107 sb_view_new(
2108 std::ptr::null_mut(),
2109 &section,
2110 id.as_ptr(),
2111 320.0,
2112 480.0,
2113 2.0,
2114 )
2115 }
2116 .is_null()
2117 );
2118 let (page, read_only) = section.shared.page(space).unwrap();
2119 let mut view = View {
2120 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
2121 presentation: None,
2122 frame: Arc::default(),
2123 section: Arc::clone(&section.shared),
2124 read_only,
2125 reload_pending: false,
2126 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
2127 };
2128 assert!(sb_view_focus_title(&mut view));
2129 assert!(unsafe { sb_insert(&mut view, c" retained after surface loss".as_ptr()) });
2130 let text = view.canvas.active().shown_text();
2131 let history = view.canvas.page.editor.history_depth();
2132 let mut selection = [0; 2];
2133 sb_selection(&view, &mut selection);
2134 assert!(sb_view_can_undo(&view, false));
2135
2136 sb_view_detach_android(&mut view);
2137 sb_view_detach_android(&mut view);
2138 assert!(view.render().is_ok());
2139 assert!(!unsafe { sb_view_attach_android(&mut view, std::ptr::null_mut()) });
2140 let mut after = [0; 2];
2141 sb_selection(&view, &mut after);
2142 assert!(view.presentation.is_none());
2143 assert_eq!(view.canvas.page.viewport.size, [640, 960]);
2144 assert_eq!(view.canvas.scale, 2.0);
2145 assert_eq!(after, selection);
2146 assert_eq!(view.canvas.active().shown_text(), text);
2147 assert_eq!(view.canvas.page.editor.history_depth(), history);
2148 assert!(sb_view_undo(&mut view, false));
2149 assert!(
2150 !view
2151 .canvas
2152 .active()
2153 .shown_text()
2154 .contains("retained after surface loss")
2155 );
2156 assert!(sb_view_undo(&mut view, true));
2157 assert_eq!(view.canvas.active().shown_text(), text);
2158}
2159
2160struct LiveRelay(std::process::Child);
2161
2162impl LiveRelay {
2163 fn start(test: &str, root: &Path) -> (Self, String) {
2164 let receipt = root.join("relay-address");
2165 let child = std::process::Command::new(std::env::current_exe().unwrap())
2166 .args(["--exact", test, "--nocapture"])
2167 .env("SNOWBOUND_MOBILE_TEST_RELAY", &receipt)
2168 .stdout(std::process::Stdio::null())
2169 .spawn()
2170 .unwrap();
2171 let mut relay = Self(child);
2172 assert!(eventually(|| {
2173 assert!(relay.0.try_wait().unwrap().is_none());
2174 receipt.exists()
2175 }));
2176 (relay, std::fs::read_to_string(receipt).unwrap())
2177 }
2178}
2179
2180impl Drop for LiveRelay {
2181 fn drop(&mut self) {
2182 let _ = self.0.kill();
2183 let _ = self.0.wait();
2184 }
2185}
2186
2187fn live_relay_child() {
2188 if let Some(receipt) = std::env::var_os("SNOWBOUND_MOBILE_TEST_RELAY") {
2189 std::thread::spawn(|| {
2190 std::thread::sleep(Duration::from_secs(180));
2191 std::process::exit(124);
2192 });
2193 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2194 let receipt = PathBuf::from(receipt);
2195 let partial = receipt.with_extension("partial");
2196 std::fs::write(&partial, format!("ws://{}", listener.local_addr().unwrap())).unwrap();
2197 std::fs::rename(partial, receipt).unwrap();
2198 relay::server::serve(listener, Default::default()).unwrap();
2199 }
2200}
2201
2202fn live_host(
2203 root: &Path,
2204 cache: &Path,
2205 sharing: notebook::live::share::Sharing,
2206 relay: &str,
2207) -> notebook::live::share::Host {
2208 let kept = cache.join("sharing.json");
2209 let notebook = notebook::session::Notebook::open(root, cache).unwrap();
2210 notebook::live::share::Host::start(
2211 notebook.into_storage(),
2212 notebook::live::Hello::new("Host".into(), None).unwrap(),
2213 sharing,
2214 "Mobile fixture",
2215 None,
2216 Some(relay),
2217 || {},
2218 move |sharing| keep_live_fixture(&kept, sharing),
2219 )
2220 .unwrap()
2221}
2222
2223fn keep_live_fixture(path: &Path, value: &impl serde::Serialize) -> std::io::Result<()> {
2224 use std::io::Write;
2225 let mut options = std::fs::OpenOptions::new();
2226 options.write(true).create(true).truncate(true);
2227 #[cfg(unix)]
2228 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
2229 let partial = path.with_extension("partial");
2230 let mut output = options.open(&partial)?;
2231 output.write_all(&serde_json::to_vec(value)?)?;
2232 output.sync_all()?;
2233 std::fs::rename(partial, path)?;
2234 #[cfg(unix)]
2235 std::fs::File::open(path.parent().unwrap())?.sync_all()?;
2236 Ok(())
2237}
2238
2239struct JoinControl {
2240 deadline: Instant,
2241 cancelled: AtomicBool,
2242 approving: AtomicBool,
2243}
2244
2245impl JoinControl {
2246 fn new() -> Self {
2247 Self {
2248 deadline: Instant::now() + Duration::from_secs(30),
2249 cancelled: AtomicBool::new(false),
2250 approving: AtomicBool::new(false),
2251 }
2252 }
2253}
2254
2255extern "C" fn live_join_wait(token: usize, approving: bool) -> bool {
2256 // SAFETY: join_live keeps its borrowed JoinControl alive until sb_live_join returns.
2257 let control = unsafe { &*(token as *const JoinControl) };
2258 control.approving.store(approving, Ordering::Release);
2259 !control.cancelled.load(Ordering::Acquire) && Instant::now() < control.deadline
2260}
2261
2262fn take_json(raw: *mut c_char) -> serde_json::Value {
2263 assert!(!raw.is_null());
2264 let text = unsafe { CString::from_raw(raw) };
2265 serde_json::from_slice(text.as_bytes()).unwrap()
2266}
2267
2268fn join_live(code: &str, password: &str, relay: &str, control: &JoinControl) -> serde_json::Value {
2269 let code = CString::new(code).unwrap();
2270 let password = CString::new(password).unwrap();
2271 let relay = CString::new(relay).unwrap();
2272 take_json(unsafe {
2273 library::sb_live_join(
2274 c"Mobile guest".as_ptr(),
2275 c"Owned test device".as_ptr(),
2276 code.as_ptr(),
2277 password.as_ptr(),
2278 relay.as_ptr(),
2279 false,
2280 live_join_wait,
2281 control as *const JoinControl as usize,
2282 )
2283 })
2284}
2285
2286fn hosted_c(
2287 credential: &serde_json::Value,
2288 relay: &str,
2289 offline: bool,
2290 cache: &Path,
2291) -> Arc<Library> {
2292 let credential = CString::new(serde_json::to_vec(credential).unwrap()).unwrap();
2293 let relay = CString::new(relay).unwrap();
2294 let cache = CString::new(cache.to_str().unwrap()).unwrap();
2295 let mut error = std::ptr::null_mut();
2296 let raw = unsafe {
2297 library::sb_library_hosted(
2298 credential.as_ptr(),
2299 c"Mobile guest".as_ptr(),
2300 c"Owned test device".as_ptr(),
2301 relay.as_ptr(),
2302 false,
2303 offline,
2304 cache.as_ptr(),
2305 &mut error,
2306 )
2307 };
2308 if !error.is_null() {
2309 let error = unsafe { CString::from_raw(error) };
2310 panic!("hosted open: {}", error.to_string_lossy());
2311 }
2312 assert!(!raw.is_null());
2313 // SAFETY: sb_library_hosted hands the caller one owned Arc reference.
2314 unsafe { Arc::from_raw(raw) }
2315}
2316
2317#[test]
2318fn live_join_cancels_approval_and_returns_private_per_device_credentials() {
2319 live_relay_child();
2320 let directory = tempfile::tempdir().unwrap();
2321 let (_relay, url) = LiveRelay::start(
2322 "tests::live_join_cancels_approval_and_returns_private_per_device_credentials",
2323 directory.path(),
2324 );
2325 let root = directory.path().join("notebook");
2326 let cache = directory.path().join("host-cache");
2327 let creation = onestore::PageCreation::new(None, Some("Mobile fixture"), "Fixture").unwrap();
2328 drop(notebook::session::Notebook::create(&root, &cache, 0, &creation).unwrap());
2329 let mut sharing = notebook::live::share::Sharing::new("join password").unwrap();
2330 sharing.approve = true;
2331 let host = live_host(&root, &cache, sharing, &url);
2332 assert!(eventually(|| host.code().is_some()));
2333 let code = host.code().unwrap();
2334 assert_eq!(
2335 join_live("malformed", "", &url, &JoinControl::new())["refusal"],
2336 "malformed"
2337 );
2338 assert_eq!(
2339 join_live(&code, "wrong password", &url, &JoinControl::new())["refusal"],
2340 "wrong"
2341 );
2342 assert!(host.devices().is_empty());
2343 for decision in ["cancel", "decline", "allow"] {
2344 let control = JoinControl::new();
2345 let reply = std::thread::scope(|scope| {
2346 let joined = scope.spawn(|| join_live(&code, "join password", &url, &control));
2347 assert!(eventually(
2348 || control.approving.load(Ordering::Acquire) && host.requests().len() == 1
2349 ));
2350 match decision {
2351 "cancel" => control.cancelled.store(true, Ordering::Release),
2352 "decline" => host.decline(&host.requests()[0].peer),
2353 "allow" => host.allow(&host.requests()[0].peer).unwrap(),
2354 _ => unreachable!(),
2355 }
2356 joined.join().unwrap()
2357 });
2358 match decision {
2359 "allow" => {
2360 let credential = &reply["credential"];
2361 assert!(credential.is_object());
2362 let persisted: notebook::live::share::Sharing =
2363 serde_json::from_slice(&std::fs::read(cache.join("sharing.json")).unwrap())
2364 .unwrap();
2365 assert!(credential["secret"] == serde_json::json!(persisted.members[0].secret));
2366 assert!(credential["secret"] != serde_json::json!(persisted.secret));
2367 assert!(credential.get("room").is_none());
2368 assert!(credential.get("code").is_none());
2369 assert!(credential.get("password").is_none());
2370 assert_eq!(credential["protocol"], notebook::live::wire::VERSION);
2371 let kept = directory.path().join("joined.json");
2372 keep_live_fixture(&kept, credential).unwrap();
2373 let credential: serde_json::Value =
2374 serde_json::from_slice(&std::fs::read(kept).unwrap()).unwrap();
2375 let library = hosted_c(
2376 &credential,
2377 &url,
2378 false,
2379 &directory.path().join("guest-cache"),
2380 );
2381 assert!(eventually(|| take_json(library::sb_library_live_status(
2382 &library
2383 ))["state"]
2384 == "connected"));
2385 assert_eq!(library.tabs().unwrap().len(), 1);
2386 }
2387 _ => {
2388 assert_eq!(
2389 reply["refusal"],
2390 if decision == "cancel" {
2391 "cancelled"
2392 } else {
2393 "declined"
2394 }
2395 );
2396 assert!(reply.get("credential").is_none());
2397 assert!(host.devices().is_empty());
2398 assert!(eventually(|| host.requests().is_empty()));
2399 }
2400 }
2401 }
2402}
2403
2404#[test]
2405fn live_guests_reopen_original_offline_edits_search_and_resume_closed_sections() {
2406 live_relay_child();
2407 let directory = tempfile::tempdir().unwrap();
2408 let (_relay, url) = LiveRelay::start(
2409 "tests::live_guests_reopen_original_offline_edits_search_and_resume_closed_sections",
2410 directory.path(),
2411 );
2412 let root = directory.path().join("notebook");
2413 std::fs::create_dir_all(&root).unwrap();
2414 for (file, text) in [
2415 ("Read.one", "Original mobile text"),
2416 ("Other.one", "Closed section text"),
2417 ] {
2418 std::fs::write(
2419 root.join(file),
2420 onestore::create_section(file, text, "Fixture").unwrap(),
2421 )
2422 .unwrap();
2423 }
2424 let sealed = onestore::create_section("Sealed.one", "Protected fixture", "Fixture").unwrap();
2425 std::fs::write(
2426 root.join("Sealed.one"),
2427 onestore::protected::rekey(
2428 &sealed,
2429 None,
2430 Some(&onestore::protected::Key::new("section password").unwrap()),
2431 )
2432 .unwrap(),
2433 )
2434 .unwrap();
2435 let sealed = std::fs::read(root.join("Sealed.one")).unwrap();
2436 let host_cache = directory.path().join("host-cache");
2437 let host = live_host(
2438 &root,
2439 &host_cache,
2440 notebook::live::share::Sharing::new("").unwrap(),
2441 &url,
2442 );
2443 assert!(eventually(|| host.code().is_some()));
2444 let joined = join_live(&host.code().unwrap(), "", &url, &JoinControl::new());
2445 let kept = directory.path().join("joined.json");
2446 assert!(joined["credential"].is_object());
2447 keep_live_fixture(&kept, &joined["credential"]).unwrap();
2448 let credential: serde_json::Value =
2449 serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap();
2450 let credential = &credential;
2451 let cache = directory.path().join("guest-cache");
2452 let library = hosted_c(credential, &url, false, &cache);
2453 let tabs = library.tabs().unwrap();
2454 assert!(
2455 tabs.iter()
2456 .any(|tab| tab.path == "Sealed.one" && tab.locked && !tab.readable)
2457 );
2458 let share: [u8; 16] = serde_json::from_value(credential["share"].clone()).unwrap();
2459 let copies = notebook::location::folder(&cache, &notebook::live::share::location(&share));
2460 let replicas = || {
2461 let mut paths: Vec<_> = std::fs::read_dir(&copies)
2462 .unwrap()
2463 .map(|entry| entry.unwrap().path())
2464 .filter(|path| {
2465 path.extension()
2466 .is_some_and(|extension| extension == "sqlite")
2467 })
2468 .collect();
2469 paths.sort();
2470 paths
2471 };
2472 assert!(eventually(|| copies.exists() && replicas().len() == 2));
2473 let original_replicas = replicas();
2474 assert!(eventually(|| library
2475 .sync_status()
2476 .iter()
2477 .filter(|sync| sync.state == SyncState::UpToDate as u8)
2478 .count()
2479 == 2));
2480 let sharing = host.sharing();
2481 drop(host);
2482 assert!(eventually(|| take_json(library::sb_library_live_status(
2483 &library
2484 ))["state"]
2485 != "connected"));
2486 sb_library_set_offline(&library, true);
2487 let section = open(&library, "Read.one");
2488 let space = section.shared.section.pages().unwrap()[0].0;
2489 let page = section.shared.section.page(space).unwrap();
2490 let text = page
2491 .objects
2492 .iter()
2493 .find_map(|object| match object {
2494 onestore::page::PageObject::Outline(outline) => {
2495 outline.paragraphs.iter().find_map(|paragraph| {
2496 paragraph
2497 .text()
2498 .filter(|text| text.text.text().contains("Original mobile text"))
2499 .map(|text| text.id)
2500 })
2501 }
2502 _ => None,
2503 })
2504 .unwrap();
2505 section
2506 .shared
2507 .apply(Edit {
2508 at: library::filetime(),
2509 ops: vec![Op::Page {
2510 space,
2511 op: PageOp::Text {
2512 text,
2513 range: 0..0,
2514 with: "OFFLINE_GUEST_TOKEN ".into(),
2515 },
2516 }],
2517 })
2518 .unwrap();
2519 section.shared.section.written().unwrap();
2520 let pending = section.shared.section.pending().unwrap();
2521 assert_eq!(pending.len(), 1);
2522 let identity = section.shared.section.replica().identity().unwrap();
2523 let shared = Arc::clone(&section.shared);
2524 drop((section, library));
2525 Arc::into_inner(shared).unwrap().section.close().unwrap();
2526
2527 let reopened: serde_json::Value =
2528 serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap();
2529 let library = hosted_c(&reopened, &url, true, &cache);
2530 assert!(
2531 library
2532 .tabs()
2533 .unwrap()
2534 .iter()
2535 .any(|tab| tab.path == "Read.one" && tab.readable)
2536 );
2537 assert_eq!(replicas(), original_replicas);
2538 let section = open(&library, "Read.one");
2539 assert_eq!(
2540 section.shared.section.replica().identity().unwrap(),
2541 identity
2542 );
2543 assert_eq!(section.shared.section.pending().unwrap(), pending);
2544 assert!(
2545 canvas::search::page_text(&section.shared.section.page(space).unwrap())
2546 .contains("OFFLINE_GUEST_TOKEN")
2547 );
2548 let shared = Arc::clone(&section.shared);
2549 drop(section);
2550 Arc::into_inner(shared).unwrap().section.close().unwrap();
2551 assert_eq!(
2552 library::search(&[&library], None, "OFFLINE_GUEST_TOKEN")
2553 .unwrap()
2554 .len(),
2555 1
2556 );
2557 assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed);
2558 assert!(!stored_text(&root.join("Read.one"), space).contains("OFFLINE_GUEST_TOKEN"));
2559 let host = live_host(&root, &host_cache, sharing, &url);
2560 assert!(eventually(|| take_json(library::sb_library_live_status(
2561 &library
2562 ))["state"]
2563 == "connected"));
2564 let original = std::fs::read(root.join("Read.one")).unwrap();
2565 drop(library);
2566 let library = hosted_c(&reopened, &url, true, &cache);
2567 assert!(eventually(|| take_json(library::sb_library_live_status(
2568 &library
2569 ))["state"]
2570 == "connected"));
2571 assert_eq!(replicas(), original_replicas);
2572 for _ in 0..10 {
2573 let retained: Vec<_> = original_replicas
2574 .iter()
2575 .flat_map(|path| notebook::Replica::open(path).unwrap().pending().unwrap())
2576 .collect();
2577 assert_eq!(retained, pending);
2578 assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original);
2579 std::thread::sleep(Duration::from_millis(20));
2580 }
2581 // Closed-section search cannot override Work Offline while the host is reachable.
2582 for _ in 0..32 {
2583 assert_eq!(
2584 library::search(&[&library], None, "OFFLINE_GUEST_TOKEN")
2585 .unwrap()
2586 .len(),
2587 1
2588 );
2589 assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original);
2590 }
2591 let retained: Vec<_> = original_replicas
2592 .iter()
2593 .flat_map(|path| notebook::Replica::open(path).unwrap().pending().unwrap())
2594 .collect();
2595 assert_eq!(retained, pending);
2596 let section = open(&library, "Read.one");
2597 assert_eq!(
2598 section.shared.section.replica().identity().unwrap(),
2599 identity
2600 );
2601 for _ in 0..10 {
2602 assert_eq!(section.shared.section.pending().unwrap(), pending);
2603 assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original);
2604 std::thread::sleep(Duration::from_millis(20));
2605 }
2606 assert!(library.sync_status().iter().any(|sync| sync.queued == 1));
2607 sb_library_sync_now(&library);
2608 assert!(eventually(|| library
2609 .sync_status()
2610 .iter()
2611 .all(|sync| sync.queued == 0)));
2612 assert_eq!(
2613 stored_text(&root.join("Read.one"), space)
2614 .matches("OFFLINE_GUEST_TOKEN")
2615 .count(),
2616 1
2617 );
2618 assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed);
2619 assert!(section.shared.section.pending().unwrap().is_empty());
2620 let shared = Arc::clone(&section.shared);
2621 drop(section);
2622 Arc::into_inner(shared).unwrap().section.close().unwrap();
2623 sb_library_set_offline(&library, false);
2624 let other_join = join_live(&host.code().unwrap(), "", &url, &JoinControl::new());
2625 let other_kept = directory.path().join("other-joined.json");
2626 keep_live_fixture(&other_kept, &other_join["credential"]).unwrap();
2627 let other_credential: serde_json::Value =
2628 serde_json::from_slice(&std::fs::read(other_kept).unwrap()).unwrap();
2629 let other = hosted_c(
2630 &other_credential,
2631 &url,
2632 false,
2633 &directory.path().join("other-guest-cache"),
2634 );
2635 let secret: [u8; 16] = serde_json::from_value(credential["secret"].clone()).unwrap();
2636 host.remove(&secret).unwrap();
2637 assert!(eventually(|| take_json(library::sb_library_live_status(
2638 &library
2639 ))["state"]
2640 == "removed"));
2641 let unaffected = open(&other, "Read.one");
2642 unaffected
2643 .shared
2644 .apply(Edit {
2645 at: library::filetime(),
2646 ops: vec![Op::Page {
2647 space,
2648 op: PageOp::Text {
2649 text,
2650 range: 0..0,
2651 with: "OTHER_GUEST_TOKEN ".into(),
2652 },
2653 }],
2654 })
2655 .unwrap();
2656 assert!(unaffected.flush(Duration::from_secs(20)));
2657 assert_eq!(
2658 stored_text(&root.join("Read.one"), space)
2659 .matches("OTHER_GUEST_TOKEN")
2660 .count(),
2661 1
2662 );
2663 sb_library_set_offline(&library, true);
2664 let section = open(&library, "Read.one");
2665 section
2666 .shared
2667 .apply(Edit {
2668 at: library::filetime(),
2669 ops: vec![Op::Page {
2670 space,
2671 op: PageOp::Text {
2672 text,
2673 range: 0..0,
2674 with: "REMOVED_GUEST_TOKEN ".into(),
2675 },
2676 }],
2677 })
2678 .unwrap();
2679 section.shared.section.written().unwrap();
2680 let pending = section.shared.section.pending().unwrap();
2681 sb_library_sync_now(&library);
2682 assert!(eventually(|| library.sync_status().iter().any(|sync| sync
2683 .path
2684 == "Read.one"
2685 && sync.state == SyncState::NotConnected as u8
2686 && sync.queued == 1)));
2687 assert_eq!(section.shared.section.pending().unwrap(), pending);
2688 let shared = Arc::clone(&section.shared);
2689 drop((section, library));
2690 Arc::into_inner(shared).unwrap().section.close().unwrap();
2691 let reopened: serde_json::Value =
2692 serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap();
2693 let library = hosted_c(&reopened, &url, true, &cache);
2694 let section = open(&library, "Read.one");
2695 assert_eq!(section.shared.section.pending().unwrap(), pending);
2696 assert!(
2697 canvas::search::page_text(&section.shared.section.page(space).unwrap())
2698 .contains("REMOVED_GUEST_TOKEN")
2699 );
2700 assert!(!stored_text(&root.join("Read.one"), space).contains("REMOVED_GUEST_TOKEN"));
2701 assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed);
2702}
2703
2704#[test]
2705fn hosted_credentials_reject_unknown_versions_without_echoing_secret_input() {
2706 let directory = tempfile::tempdir().unwrap();
2707 let cache = directory.path().join("must-not-be-created");
2708 let cache = CString::new(cache.to_str().unwrap()).unwrap();
2709 let wrong_version = serde_json::json!({
2710 "share": ([1u8; 16]), "secret": ([2u8; 16]),
2711 "protocol": notebook::live::wire::VERSION + 1,
2712 "notebook": "Fixture", "host": "Host",
2713 })
2714 .to_string();
2715 for input in ["{SECRET_MUST_NOT_BE_ECHOED", wrong_version.as_str()] {
2716 let input = CString::new(input).unwrap();
2717 let mut error = std::ptr::null_mut();
2718 let opened = unsafe {
2719 library::sb_library_hosted(
2720 input.as_ptr(),
2721 c"Guest".as_ptr(),
2722 std::ptr::null(),
2723 std::ptr::null(),
2724 false,
2725 true,
2726 cache.as_ptr(),
2727 &mut error,
2728 )
2729 };
2730 assert!(opened.is_null());
2731 assert!(!error.is_null());
2732 let error = unsafe { CString::from_raw(error) };
2733 assert!(
2734 !error
2735 .to_bytes()
2736 .windows(b"SECRET_MUST_NOT_BE_ECHOED".len())
2737 .any(|bytes| bytes == b"SECRET_MUST_NOT_BE_ECHOED")
2738 );
2739 }
2740 assert!(!Path::new(cache.to_str().unwrap()).exists());
2741}
2742
2743#[test]
2744fn reader_native_selection_commits_marked_ops_and_keeps_the_original_offline_editor() {
2745 let (directory, section) = features();
2746 sb_library_set_offline(&section.library, true);
2747 let file = directory.path().join("Features.one");
2748 let canonical = std::fs::read(&file).unwrap();
2749 let space = section.new_page(None, "Mon", "6:14 AM").unwrap();
2750 let (page, read_only) = section.shared.page(space).unwrap();
2751 let mut view = View {
2752 canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()),
2753 presentation: None,
2754 frame: Arc::default(),
2755 section: Arc::clone(&section.shared),
2756 read_only,
2757 reload_pending: false,
2758 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
2759 };
2760 view.canvas.set_transform(1.0, [0.0; 2]);
2761 let reading = |view: &View| {
2762 let query = sb_view_reading(view, 240.0);
2763 let status = serde_json::from_str::<serde_json::Value>(&string(query)).unwrap();
2764 unsafe { sb_string_free(query) };
2765 status
2766 };
2767 assert_eq!(reading(&view)["offered"], true);
2768 assert_eq!(reading(&view)["shown"], false);
2769 assert!(sb_view_focus_title(&mut view));
2770 assert!(unsafe { sb_insert(&mut view, c"A title that wraps on a narrow phone".as_ptr()) });
2771 let title = view.canvas.active().id;
2772 view.canvas.set_transform(1.5, [0.0; 2]);
2773 let before = view
2774 .canvas
2775 .page
2776 .caret_at(
2777 title,
2778 TextPosition {
2779 paragraph: 0,
2780 offset: 0,
2781 },
2782 Affinity::Downstream,
2783 0.0,
2784 )
2785 .unwrap();
2786 let mut anchor = [
2787 before.x0 as f32 * POINT - 4.0,
2788 before.y0 as f32 * POINT - 7.0,
2789 ];
2790 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
2791 let after = view
2792 .canvas
2793 .page
2794 .caret_at(
2795 title,
2796 TextPosition {
2797 paragraph: 0,
2798 offset: 0,
2799 },
2800 Affinity::Downstream,
2801 0.0,
2802 )
2803 .unwrap();
2804 for (axis, caret) in [after.x0, after.y0].into_iter().enumerate() {
2805 assert!(
2806 (caret * f64::from(POINT) - f64::from(anchor[axis]) - [4.0, 7.0][axis] * 1.5).abs()
2807 < 0.001
2808 );
2809 }
2810 view.canvas.set_transform(1.0, [0.0; 2]);
2811 assert!(!sb_view_read_only(&view));
2812 assert_eq!(reading(&view)["offered"], true);
2813 assert_eq!(reading(&view)["shown"], true);
2814 assert!(unsafe { sb_insert(&mut view, c"\n".as_ptr()) });
2815 assert!(unsafe {
2816 sb_insert(
2817 &mut view,
2818 c"Body 😀 with several wrapped words before the last line".as_ptr(),
2819 )
2820 });
2821 let body = view.canvas.active().id;
2822 let original = view.canvas.page.editor.page().unwrap();
2823 let history = view.canvas.page.editor.history_depth();
2824 let mut anchored_moved = false;
2825 for (source, offset) in [(title, 0), (body, 35)] {
2826 for width in [320.0, 240.0] {
2827 let before = view
2828 .canvas
2829 .page
2830 .caret_at(
2831 source,
2832 TextPosition {
2833 paragraph: 0,
2834 offset,
2835 },
2836 Affinity::Downstream,
2837 0.0,
2838 )
2839 .unwrap();
2840 anchor = [
2841 before.x0 as f32 * POINT,
2842 (before.y0 + (before.y1 - before.y0) / 2.0) as f32 * POINT,
2843 ];
2844 let old_anchor = anchor;
2845 assert!(sb_view_set_reading(&mut view, width, &mut anchor));
2846 let after = view
2847 .canvas
2848 .page
2849 .caret_at(
2850 source,
2851 TextPosition {
2852 paragraph: 0,
2853 offset,
2854 },
2855 Affinity::Downstream,
2856 0.0,
2857 )
2858 .unwrap();
2859 assert!(
2860 (f64::from(anchor[1] - old_anchor[1]) - (after.y0 - before.y0) * f64::from(POINT))
2861 .abs()
2862 < 0.001
2863 );
2864 eprintln!(
2865 "Reader anchor source={source} utf16={offset} width={width} before=({},{}) after=({},{}) anchor_delta_y={}",
2866 before.x0,
2867 before.y0,
2868 after.x0,
2869 after.y0,
2870 anchor[1] - old_anchor[1]
2871 );
2872 anchored_moved |= (after.y0 - before.y0).abs() > 0.1;
2873 for offset in [0, 8, 24] {
2874 let mut rect = [0.0; 4];
2875 assert!(sb_caret_rect(&view, offset, &mut rect));
2876 assert_eq!(
2877 view.canvas
2878 .closest([rect[0], rect[1] + rect[3] / 2.0])
2879 .unwrap(),
2880 offset
2881 );
2882 assert_eq!(
2883 view.canvas.target([rect[0] + 1.0, rect[1] + rect[3] / 2.0]),
2884 Target::ActiveText
2885 );
2886 }
2887 assert!(
2888 view.canvas
2889 .range_rects([0, view.canvas.length()])
2890 .unwrap()
2891 .len()
2892 > 1
2893 );
2894 assert_eq!(view.canvas.page.editor.page().unwrap(), original);
2895 assert_eq!(view.canvas.page.editor.history_depth(), history);
2896 assert_eq!(view.canvas.active().id, body);
2897 }
2898 }
2899 assert!(anchored_moved);
2900 assert!(sb_select(&mut view, 0, 0));
2901 assert!(unsafe { sb_set_marked(&mut view, c"marked 😀 ".as_ptr(), 10, 10) });
2902 let mut marked = [0; 2];
2903 assert!(sb_marked(&view, &mut marked));
2904 let preview = view.canvas.page.editor.page().unwrap();
2905 let selection = view.canvas.page.editor.selection();
2906 let history = view.canvas.page.editor.history_depth();
2907 for width in [320.0, 0.0, 240.0] {
2908 assert_eq!(
2909 sb_view_set_reading(&mut view, width, &mut anchor),
2910 width > 0.0
2911 );
2912 let mut unchanged = [0; 2];
2913 assert!(sb_marked(&view, &mut unchanged));
2914 assert_eq!(unchanged, marked);
2915 assert_eq!(view.canvas.page.editor.selection(), selection);
2916 assert_eq!(view.canvas.page.editor.history_depth(), history);
2917 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
2918 assert_eq!(view.canvas.active().id, body);
2919 assert!(!sb_view_reload(&mut view, false, Some(&mut anchor)));
2920 assert!(sb_marked(&view, &mut unchanged));
2921 assert_eq!(unchanged, marked);
2922 assert_eq!(view.canvas.page.editor.selection(), selection);
2923 assert_eq!(view.canvas.page.editor.history_depth(), history);
2924 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
2925 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2926 }
2927 assert!(sb_select(&mut view, 0, 0));
2928 assert!(!sb_marked(&view, &mut marked));
2929 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2930 let stored = section.shared.page(space).unwrap().0;
2931 let mut engine = pooled_engine();
2932 let stored = canvas::editor::CanvasEditor::from_page(stored, &mut engine).unwrap();
2933 pool_engine(engine);
2934 assert!(
2935 stored
2936 .outlines()
2937 .iter()
2938 .any(|outline| outline.id == body && outline.shown_text().contains("marked 😀"))
2939 );
2940 assert!(sb_view_undo(&mut view, false));
2941 assert!(!view.canvas.active().shown_text().contains("marked 😀"));
2942 assert!(sb_view_undo(&mut view, true));
2943 assert!(view.canvas.active().shown_text().contains("marked 😀"));
2944 for route in 0..6 {
2945 assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) });
2946 assert!(sb_select(&mut view, 0, 0));
2947 let paragraph = CString::new(
2948 view.canvas
2949 .active()
2950 .document()
2951 .text_nodes()
2952 .next()
2953 .unwrap()
2954 .id
2955 .to_string(),
2956 )
2957 .unwrap();
2958 if route == 5 {
2959 let mut rect = [0.0; 4];
2960 assert!(sb_caret_rect(&view, 0, &mut rect));
2961 assert!(sb_view_press(&mut view, rect[0], rect[1] + rect[3] / 2.0));
2962 }
2963 let text = format!("route{route} 😀 ");
2964 let marked_text = CString::new(text.clone()).unwrap();
2965 let end = u32::try_from(text.encode_utf16().count()).unwrap();
2966 assert!(unsafe { sb_set_marked(&mut view, marked_text.as_ptr(), end, end) });
2967 assert!(sb_marked(&view, &mut marked));
2968 match route {
2969 0 => assert!(sb_view_focus_title(&mut view)),
2970 1 => assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) }),
2971 2 => assert!(unsafe { sb_view_select_paragraph(&mut view, paragraph.as_ptr()) }),
2972 3 => assert!(sb_select_more(&mut view)),
2973 4 => sb_unmark(&mut view),
2974 _ => {
2975 let mut rect = [0.0; 4];
2976 assert!(sb_caret_rect(&view, end, &mut rect));
2977 assert!(sb_view_drag(&mut view, rect[0], rect[1] + rect[3] / 2.0));
2978 sb_view_release(&mut view);
2979 }
2980 }
2981 assert!(!sb_marked(&view, &mut marked));
2982 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
2983 let mut rect = [0.0; 4];
2984 assert!(sb_caret_rect(&view, 0, &mut rect));
2985 let mut engine = pooled_engine();
2986 let stored = canvas::editor::CanvasEditor::from_page(
2987 section.shared.page(space).unwrap().0,
2988 &mut engine,
2989 )
2990 .unwrap();
2991 pool_engine(engine);
2992 assert!(
2993 stored
2994 .outlines()
2995 .iter()
2996 .any(|outline| outline.id == body && outline.shown_text().contains(text.trim()))
2997 );
2998 assert_eq!(reading(&view)["shown"], true);
2999 }
3000 let title_text = view
3001 .canvas
3002 .page
3003 .editor
3004 .visible_outlines()
3005 .find(|outline| outline.title)
3006 .unwrap()
3007 .document()
3008 .text_nodes()
3009 .next()
3010 .unwrap()
3011 .text()
3012 .unwrap()
3013 .id;
3014 for shown in [true, false] {
3015 for select in [true, false] {
3016 assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) });
3017 assert!(sb_select(&mut view, 0, 0));
3018 assert_eq!(
3019 sb_view_set_reading(&mut view, if shown { 240.0 } else { 0.0 }, &mut anchor),
3020 shown
3021 );
3022 if sb_view_format(&view) & 1 == 0 {
3023 assert!(sb_view_apply(&mut view, 0));
3024 }
3025 assert_ne!(sb_view_format(&view) & 1, 0);
3026 let local_before = view.canvas.active().shown_text();
3027 let text = view
3028 .canvas
3029 .active()
3030 .document()
3031 .text_nodes()
3032 .next()
3033 .unwrap()
3034 .text()
3035 .unwrap()
3036 .id;
3037 let history = view.canvas.page.editor.history_depth();
3038 let local = format!("retained {shown} {select} 😀 ");
3039 let local_text = CString::new(local.clone()).unwrap();
3040 let end = u32::try_from(local.encode_utf16().count()).unwrap();
3041 assert!(unsafe { sb_set_marked(&mut view, local_text.as_ptr(), end, end) });
3042 let preview = view.canvas.page.editor.page().unwrap();
3043 assert_eq!(
3044 view.canvas.active().shown_text(),
3045 format!("{local}{local_before}")
3046 );
3047 let local_nodes = view.canvas.active().document().nodes().to_vec();
3048 let selection = view.canvas.page.editor.selection();
3049 let peer = format!("peer {shown} {select} ");
3050 section
3051 .shared
3052 .section
3053 .apply(
3054 "Reader peer",
3055 page_edit(
3056 space,
3057 vec![PageOp::Text {
3058 text: title_text,
3059 range: 0..0,
3060 with: peer.clone(),
3061 }],
3062 ),
3063 )
3064 .unwrap();
3065 section.shared.section.written().unwrap();
3066 let pending_before = section.shared.section.pending().unwrap();
3067 let mut predicted = section.shared.page(space).unwrap().0;
3068 assert!(!sb_view_reload(&mut view, false, Some(&mut anchor)));
3069 assert!(view.reload_pending);
3070 assert!(sb_marked(&view, &mut marked));
3071 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
3072 assert_eq!(view.canvas.page.editor.selection(), selection);
3073 assert_eq!(view.canvas.page.editor.history_depth(), history);
3074 if select {
3075 assert!(sb_select(&mut view, 0, 0));
3076 } else {
3077 sb_unmark(&mut view);
3078 }
3079 assert!(!sb_marked(&view, &mut marked));
3080 let deadline = Instant::now() + Duration::from_secs(10);
3081 while view.reload_pending && !sb_view_reload_ready(&view) {
3082 let _ = section.poll();
3083 assert!(Instant::now() < deadline, "Original apply did not complete");
3084 std::thread::sleep(Duration::from_millis(10));
3085 }
3086 if sb_view_reload_ready(&view) {
3087 sb_view_reload(&mut view, false, Some(&mut anchor));
3088 }
3089 assert!(!view.reload_pending);
3090 assert_eq!(reading(&view)["shown"], shown);
3091 assert_eq!(view.canvas.active().id, body);
3092 assert!(view.canvas.page.editor.history_depth()[0] <= history[0] + 1);
3093 let expected_selection = if select {
3094 Selection::from(
3095 [TextPosition {
3096 paragraph: 0,
3097 offset: 0,
3098 }; 2],
3099 )
3100 } else {
3101 selection
3102 };
3103 assert_eq!(view.canvas.page.editor.selection(), expected_selection);
3104 assert!(
3105 view.canvas.page.editor.visible_outlines().any(|outline| {
3106 outline.id == title && outline.shown_text().starts_with(&peer)
3107 })
3108 );
3109 section.shared.section.written().unwrap();
3110 let pending = section.shared.section.pending().unwrap();
3111 assert_eq!(pending.len(), pending_before.len() + 1);
3112 assert_eq!(&pending[..pending_before.len()], pending_before.as_slice());
3113 let row = pending.last().unwrap();
3114 assert_eq!(row.author, "Clover Test");
3115 let edit = &row.edit;
3116 assert!(edit.ops.iter().any(|op| matches!(op,
3117 Op::Page { space: target, op: PageOp::Text { text: target_text, .. } }
3118 if *target == space && *target_text == text
3119 )));
3120 for op in &edit.ops {
3121 let Op::Page { space: target, op } = op else {
3122 panic!("Composition changed the section")
3123 };
3124 assert_eq!(*target, space);
3125 onestore::op::predict(&mut predicted, op).unwrap();
3126 }
3127 let mut normalized = preview.clone();
3128 for node in &local_nodes {
3129 let text = node.text().unwrap();
3130 onestore::op::predict(
3131 &mut normalized,
3132 &PageOp::Format {
3133 text: text.id,
3134 range: 0..text.text.utf16_offset(text.text.text().len()).unwrap(),
3135 set: Vec::new(),
3136 clear: Vec::new(),
3137 },
3138 )
3139 .unwrap();
3140 }
3141 let [stored_body, marked_body] = [&predicted, &normalized].map(|page| {
3142 page.objects
3143 .iter()
3144 .find_map(|object| match object {
3145 onestore::page::PageObject::Outline(outline) if outline.id == body => {
3146 Some(&outline.paragraphs)
3147 }
3148 _ => None,
3149 })
3150 .unwrap()
3151 });
3152 assert_eq!(stored_body, marked_body);
3153 assert_eq!(section.shared.page(space).unwrap().0, predicted);
3154 assert_eq!(view.canvas.active().document().nodes(), local_nodes);
3155 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
3156 assert!(sb_view_undo(&mut view, false));
3157 assert_eq!(view.canvas.active().shown_text(), local_before);
3158 assert!(
3159 view.canvas.page.editor.visible_outlines().any(|outline| {
3160 outline.id == title && outline.shown_text().starts_with(&peer)
3161 })
3162 );
3163 assert!(sb_view_undo(&mut view, true));
3164 assert_eq!(
3165 view.canvas.active().shown_text(),
3166 format!("{local}{local_before}")
3167 );
3168 assert_eq!(reading(&view)["shown"], shown);
3169 }
3170 }
3171 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3172 let before = view
3173 .canvas
3174 .page
3175 .caret_at(
3176 body,
3177 TextPosition {
3178 paragraph: 0,
3179 offset: 8,
3180 },
3181 Affinity::Downstream,
3182 0.0,
3183 )
3184 .unwrap();
3185 anchor = [
3186 before.x0 as f32 * POINT,
3187 (before.y0 + (before.y1 - before.y0) / 2.0) as f32 * POINT,
3188 ];
3189 let old_anchor = anchor;
3190 let selection = view.canvas.page.editor.selection();
3191 let history = view.canvas.page.editor.history_depth();
3192 section
3193 .shared
3194 .section
3195 .apply(
3196 "Reader peer",
3197 page_edit(
3198 space,
3199 vec![PageOp::Text {
3200 text: title_text,
3201 range: 0..0,
3202 with: "Peer title text before the original title ".into(),
3203 }],
3204 ),
3205 )
3206 .unwrap();
3207 section.shared.section.written().unwrap();
3208 let pending_before_reload = section.shared.section.pending().unwrap();
3209 assert!(sb_view_reload(&mut view, false, Some(&mut anchor)));
3210 let after = view
3211 .canvas
3212 .page
3213 .caret_at(
3214 body,
3215 TextPosition {
3216 paragraph: 0,
3217 offset: 8,
3218 },
3219 Affinity::Downstream,
3220 0.0,
3221 )
3222 .unwrap();
3223 assert!((after.y0 - before.y0).abs() > 0.1);
3224 assert!(
3225 (f64::from(anchor[1] - old_anchor[1]) - (after.y0 - before.y0) * f64::from(POINT)).abs()
3226 < 0.001
3227 );
3228 assert_eq!(view.canvas.page.editor.selection(), selection);
3229 assert!(view.canvas.page.editor.history_depth()[0] <= history[0]);
3230 assert_eq!(view.canvas.active().id, body);
3231 assert_eq!(reading(&view)["shown"], true);
3232 assert_eq!(
3233 section.shared.section.pending().unwrap(),
3234 pending_before_reload
3235 );
3236 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
3237 let page = view.canvas.page.editor.page().unwrap();
3238 let created = page.created.unwrap();
3239 let fields: Vec<_> = view
3240 .canvas
3241 .page
3242 .editor
3243 .date()
3244 .unwrap()
3245 .source()
3246 .paragraphs
3247 .iter()
3248 .map(|node| {
3249 let text = node.text().unwrap();
3250 (text.id, text.text.text().to_owned())
3251 })
3252 .collect();
3253 for overwide in [true, false] {
3254 let mut next = fields.clone();
3255 if overwide {
3256 next[0].1 = "W".repeat(80);
3257 }
3258 section
3259 .shared
3260 .section
3261 .apply(
3262 "Reader peer",
3263 page_edit(
3264 space,
3265 vec![PageOp::Date {
3266 created,
3267 fields: next,
3268 }],
3269 ),
3270 )
3271 .unwrap();
3272 section.shared.section.written().unwrap();
3273 let pending_before_reload = section.shared.section.pending().unwrap();
3274 let history = view.canvas.page.editor.history_depth();
3275 assert!(sb_view_reload(&mut view, false, Some(&mut anchor)));
3276 assert_eq!(reading(&view)["shown"], false);
3277 assert_eq!(reading(&view)["offered"], !overwide);
3278 assert_eq!(view.canvas.page.editor.selection(), selection);
3279 assert_eq!(view.canvas.page.editor.history_depth(), history);
3280 assert_eq!(view.canvas.active().id, body);
3281 assert_eq!(
3282 section.shared.section.pending().unwrap(),
3283 pending_before_reload
3284 );
3285 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
3286 }
3287 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3288 assert!(unsafe { sb_view_find(&mut view, c"Body".as_ptr()) });
3289 assert!(!sb_view_set_reading(&mut view, 0.0, &mut anchor));
3290 assert_eq!(reading(&view)["shown"], false);
3291 assert_eq!(view.canvas.active().id, body);
3292 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3293 sb_view_set_tool(&mut view, 1, 0, 0);
3294 assert_eq!(reading(&view)["shown"], false);
3295 assert_eq!(reading(&view)["offered"], true);
3296 sb_view_set_tool(&mut view, 0, 0, 0);
3297 assert_eq!(reading(&view)["shown"], false);
3298 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3299 section.shared.section.written().unwrap();
3300 let mut predicted = section.shared.page(space).unwrap().0;
3301 let pending_before = section.shared.section.pending().unwrap();
3302 let result = view.canvas.page.insert_table(2, 2).map(moved);
3303 assert!(view.stored(result));
3304 assert_eq!(reading(&view)["shown"], false);
3305 assert_eq!(reading(&view)["offered"], false);
3306 assert!(!sb_view_read_only(&view));
3307 assert!(view.canvas.page.editor.take_ops().unwrap().is_empty());
3308 section.shared.section.written().unwrap();
3309 let pending = section.shared.section.pending().unwrap();
3310 assert_eq!(pending.len(), pending_before.len() + 1);
3311 assert_eq!(&pending[..pending_before.len()], pending_before.as_slice());
3312 let table = view
3313 .canvas
3314 .active()
3315 .document()
3316 .nodes()
3317 .iter()
3318 .find(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_)))
3319 .unwrap();
3320 let edit = &pending.last().unwrap().edit;
3321 assert!(edit.ops.iter().any(|op| matches!(op, Op::Page { space: target, op: PageOp::Insert { container, paragraphs, .. } }
3322 if *target == space && *container == body && paragraphs.iter().any(|node| node.id == table.id))));
3323 let mut normalized = predicted.clone();
3324 let definitions = &view.canvas.page.editor.page().unwrap().definitions;
3325 for op in
3326 onestore::op::lower(body, &[], std::slice::from_ref(table), None, definitions).unwrap()
3327 {
3328 onestore::op::predict(&mut normalized, &op).unwrap();
3329 }
3330 for op in &edit.ops {
3331 let Op::Page { space: target, op } = op else {
3332 panic!("Table insertion changed the section")
3333 };
3334 assert_eq!(*target, space);
3335 onestore::op::predict(&mut predicted, op).unwrap();
3336 }
3337 let tables = [&normalized, &predicted].map(|page| {
3338 page.objects
3339 .iter()
3340 .find_map(|object| match object {
3341 onestore::page::PageObject::Outline(outline) if outline.id == body => {
3342 outline.paragraphs.iter().find(|node| node.id == table.id)
3343 }
3344 _ => None,
3345 })
3346 .unwrap()
3347 });
3348 assert_eq!(tables[0], tables[1]);
3349 assert_eq!(section.shared.page(space).unwrap().0, predicted);
3350 assert!(pending.iter().all(|edit| {
3351 section.shared.section.status(edit.id).unwrap() == Some(notebook::EditStatus::Pending)
3352 }));
3353 assert_eq!(std::fs::read(file).unwrap(), canonical);
3354}
3355
3356#[test]
3357fn reader_stacks_text_boxes_and_stores_an_edit_in_the_second_one() {
3358 let (directory, section) = features();
3359 let file = directory.path().join("Features.one");
3360 let space = section.new_page(None, "Mon", "6:14 AM").unwrap();
3361 let (page, read_only) = section.shared.page(space).unwrap();
3362 let mut view = View {
3363 canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()),
3364 presentation: None,
3365 frame: Arc::default(),
3366 section: Arc::clone(&section.shared),
3367 read_only,
3368 reload_pending: false,
3369 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3370 };
3371 view.canvas.set_transform(1.0, [0.0; 2]);
3372 assert!(sb_view_focus_title(&mut view));
3373 assert!(unsafe { sb_insert(&mut view, c"Two boxes".as_ptr()) });
3374 let mut boxes = Vec::new();
3375 for (text, at) in [(c"Lower box", [72.0, 360.0]), (c"Upper box", [72.0, 200.0])] {
3376 assert!(sb_view_press(&mut view, at[0] * POINT, at[1] * POINT));
3377 assert!(sb_view_release(&mut view));
3378 assert!(unsafe { sb_insert(&mut view, text.as_ptr()) });
3379 boxes.push(view.canvas.active().id);
3380 }
3381 let [lower, upper] = boxes[..] else {
3382 unreachable!()
3383 };
3384 assert_ne!(lower, upper);
3385 let mut anchor = [0.0; 2];
3386 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3387 let start = |view: &View, id| {
3388 view.canvas
3389 .page
3390 .caret_at(
3391 id,
3392 TextPosition {
3393 paragraph: 0,
3394 offset: 0,
3395 },
3396 Affinity::Downstream,
3397 0.0,
3398 )
3399 .unwrap()
3400 };
3401 let [first, second] = [start(&view, upper), start(&view, lower)];
3402 assert_eq!(first.x0, second.x0);
3403 assert!(first.y1 < second.y0);
3404 assert!(sb_view_press(
3405 &mut view,
3406 second.x0 as f32 * POINT + 1.0,
3407 (second.y0 + second.y1) as f32 / 2.0 * POINT
3408 ));
3409 assert!(sb_view_release(&mut view));
3410 assert_eq!(view.canvas.active().id, lower);
3411 let end = view.canvas.length();
3412 assert!(sb_select(&mut view, end, end));
3413 assert!(unsafe { sb_insert(&mut view, c", edited in reading 😀".as_ptr()) });
3414 assert_eq!(
3415 view.canvas.active().shown_text(),
3416 "Lower box, edited in reading 😀"
3417 );
3418 let mut status = sb_view_reading(&view, 240.0);
3419 let json = serde_json::from_str::<serde_json::Value>(&string(status)).unwrap();
3420 unsafe { sb_string_free(status) };
3421 assert_eq!(json["shown"], true);
3422 assert!(section.flush(Duration::from_secs(20)));
3423 let stored = stored_text(&file, space);
3424 assert!(stored.contains("Upper box"));
3425 assert!(stored.contains("Lower box, edited in reading 😀"));
3426 assert!(sb_view_undo(&mut view, false));
3427 assert_eq!(view.canvas.active().shown_text(), "Lower box");
3428 assert!(sb_view_undo(&mut view, true));
3429 assert!(!sb_view_set_reading(&mut view, 0.0, &mut anchor));
3430 status = sb_view_reading(&view, 240.0);
3431 let json = serde_json::from_str::<serde_json::Value>(&string(status)).unwrap();
3432 unsafe { sb_string_free(status) };
3433 assert_eq!(
3434 json,
3435 serde_json::json!({ "offered": true, "shown": false, "verdict": "Reflows" })
3436 );
3437 assert!(section.flush(Duration::from_secs(20)));
3438 let shared = Arc::clone(&view.section);
3439 drop(view);
3440 drop(section);
3441 Arc::into_inner(shared).unwrap().section.close().unwrap();
3442 let arena = onestore::Arena::default();
3443 let cold = onestore::Section::open(&arena, onestore::read_file(&file).unwrap()).unwrap();
3444 let mut engine = pooled_engine();
3445 let cold =
3446 canvas::editor::CanvasEditor::from_page(cold.page(space).unwrap(), &mut engine).unwrap();
3447 pool_engine(engine);
3448 let texts: Vec<_> = cold
3449 .outlines()
3450 .iter()
3451 .filter(|outline| !outline.title)
3452 .map(|outline| outline.shown_text())
3453 .collect();
3454 assert!(texts.contains(&"Upper box".to_string()));
3455 assert!(texts.contains(&"Lower box, edited in reading 😀".to_string()));
3456}
3457
3458/// A view of the page `title` of the corpus section `path`, copied, at 100%.
3459fn reader_corpus_view(path: &str, title: &str) -> (tempfile::TempDir, Section, View) {
3460 let (directory, file) = copy(path);
3461 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
3462 let section = open(&library, &file.to_string_lossy());
3463 let space = space(&section, title);
3464 let (page, read_only) = section.shared.page(space).unwrap();
3465 let mut view = View {
3466 canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()),
3467 presentation: None,
3468 frame: Arc::default(),
3469 section: Arc::clone(&section.shared),
3470 read_only,
3471 reload_pending: false,
3472 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3473 };
3474 view.canvas.set_transform(1.0, [0.0; 2]);
3475 (directory, section, view)
3476}
3477
3478#[test]
3479fn reader_shows_a_corpus_pages_picture_no_wider_than_the_column() {
3480 let (_directory, _section, mut view) =
3481 reader_corpus_view("print/native/Print.one", "Printing test");
3482 let query = sb_view_reading(&view, 240.0);
3483 let status = serde_json::from_str::<serde_json::Value>(&string(query)).unwrap();
3484 unsafe { sb_string_free(query) };
3485 assert_eq!(status["offered"], true);
3486 let mut page = [0.0; 4];
3487 sb_view_content(&mut view, &mut page);
3488 let mut anchor = [0.0; 2];
3489 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3490 let mut column = [0.0; 4];
3491 sb_view_content(&mut view, &mut column);
3492 assert!(column[2] < page[2]);
3493 // The picture follows the long outline: the whole column in view.
3494 view.canvas.set_transform(0.05, [0.0; 2]);
3495 let paper = view.canvas.page_paper();
3496 let deadline = std::time::Instant::now() + Duration::from_secs(20);
3497 let pictures = loop {
3498 view.canvas
3499 .page
3500 .update_pictures(paper, std::task::Waker::noop());
3501 let pictures: Vec<[f32; 4]> = view
3502 .canvas
3503 .page
3504 .primitives(view.canvas.colors())
3505 .unwrap()
3506 .iter()
3507 .filter_map(|primitive| match primitive {
3508 draw::Primitive::Image { rect, .. } => Some(*rect),
3509 _ => None,
3510 })
3511 .collect();
3512 if !pictures.is_empty() || std::time::Instant::now() > deadline {
3513 break pictures;
3514 }
3515 std::thread::sleep(Duration::from_millis(10));
3516 };
3517 assert_eq!(pictures.len(), 1);
3518 assert!(pictures[0][2] - pictures[0][0] <= 240.0 / POINT + 0.01);
3519}
3520
3521#[test]
3522fn reader_leaves_a_small_drawing_out_and_refuses_a_mostly_drawn_page() {
3523 let reading = |view: &View| {
3524 let query = sb_view_reading(view, 240.0);
3525 let status = serde_json::from_str::<serde_json::Value>(&string(query)).unwrap();
3526 unsafe { sb_string_free(query) };
3527 status
3528 };
3529 let (_directory, _section, drawn) =
3530 reader_corpus_view("ink-edit/drawing/candidate/ink.one", "Beside the drawing");
3531 assert_eq!(reading(&drawn)["offered"], false);
3532 let (directory, section) = features();
3533 let space = section.new_page(None, "Mon", "6:14 AM").unwrap();
3534 let (page, read_only) = section.shared.page(space).unwrap();
3535 let mut view = View {
3536 canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()),
3537 presentation: None,
3538 frame: Arc::default(),
3539 section: Arc::clone(&section.shared),
3540 read_only,
3541 reload_pending: false,
3542 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3543 };
3544 view.canvas.set_transform(1.0, [0.0; 2]);
3545 assert!(sb_view_focus_title(&mut view));
3546 assert!(unsafe { sb_insert(&mut view, c"Notes with a doodle".as_ptr()) });
3547 assert!(unsafe { sb_insert(&mut view, c"\n".as_ptr()) });
3548 assert!(unsafe {
3549 sb_insert(
3550 &mut view,
3551 c"A paragraph long enough to fill several lines of the column on a phone, with a small drawing beside it."
3552 .as_ptr(),
3553 )
3554 });
3555 sb_view_set_tool(&mut view, 1, 0, 0);
3556 let stroke =
3557 [[300.0, 400.0], [320.0, 410.0], [340.0, 400.0]].map(|[x, y]| [x * POINT, y * POINT]);
3558 assert!(sb_view_press(&mut view, stroke[0][0], stroke[0][1]));
3559 for [x, y] in &stroke[1..] {
3560 sb_view_drag(&mut view, *x, *y);
3561 }
3562 sb_view_release(&mut view);
3563 sb_view_set_tool(&mut view, 0, 0, 0);
3564 let strokes = |view: &View| {
3565 view.canvas
3566 .page
3567 .primitives(view.canvas.colors())
3568 .unwrap()
3569 .iter()
3570 .filter(|primitive| {
3571 matches!(
3572 primitive,
3573 draw::Primitive::Segment { .. } | draw::Primitive::Taper { .. }
3574 )
3575 })
3576 .count()
3577 };
3578 assert!(strokes(&view) > 0);
3579 assert_eq!(reading(&view)["offered"], true);
3580 let mut anchor = [0.0; 2];
3581 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3582 assert_eq!(strokes(&view), 0);
3583 // A tap where the drawing lies on the page picks nothing to delete.
3584 assert!(sb_view_press(&mut view, stroke[1][0], stroke[1][1]));
3585 sb_view_release(&mut view);
3586 let mut rect = [0.0; 4];
3587 assert!(!sb_view_ink_selection(&view, &mut rect));
3588 assert!(!sb_view_set_reading(&mut view, 0.0, &mut anchor));
3589 assert!(strokes(&view) > 0);
3590 assert!(section.flush(Duration::from_secs(20)));
3591 let arena = onestore::Arena::default();
3592 let file = directory.path().join("Features.one");
3593 let stored = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
3594 assert!(
3595 stored
3596 .page(space)
3597 .unwrap()
3598 .objects
3599 .iter()
3600 .any(|object| matches!(object, onestore::page::PageObject::Ink(_)))
3601 );
3602}
3603
3604#[test]
3605fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() {
3606 use std::sync::mpsc;
3607
3608 let (directory, file) = copy("media-edit/candidate/Features.one");
3609 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
3610 sb_library_set_offline(&library, true);
3611 let section = open(&library, file.to_str().unwrap());
3612 let space = space(&section, "Paragraph controls");
3613 let (page, read_only) = section.shared.page(space).unwrap();
3614 let mut view = View {
3615 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
3616 presentation: None,
3617 frame: Arc::default(),
3618 section: Arc::clone(&section.shared),
3619 read_only,
3620 reload_pending: true,
3621 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3622 };
3623 focus(&mut view.canvas, "Collapsed parent");
3624 assert!(sb_view_reload_ready(&view));
3625 let before = section.shared.section.pending().unwrap();
3626 let (started, start) = mpsc::channel();
3627 let (release, released) = mpsc::channel();
3628 assert!(view.canvas.insert("first 🦀 ".into()).unwrap());
3629 let first = view.canvas.edit().unwrap().unwrap();
3630 let expected_first = first.clone();
3631 let first_reply = ApplyReply::new(&view.local_edits);
3632 section
3633 .shared
3634 .apply_with(first, move |result| {
3635 started.send(()).unwrap();
3636 released.recv_timeout(Duration::from_secs(10)).unwrap();
3637 first_reply.complete(result);
3638 })
3639 .unwrap();
3640 start.recv_timeout(Duration::from_secs(10)).unwrap();
3641 assert!(!sb_view_reload_ready(&view));
3642 assert!(view.canvas.insert("second 😀 ".into()).unwrap());
3643 let second = view.canvas.edit().unwrap().unwrap();
3644 let expected_second = second.clone();
3645 let second_reply = ApplyReply::new(&view.local_edits);
3646 let (started, start) = mpsc::channel();
3647 let (release_second, released_second) = mpsc::channel();
3648 section
3649 .shared
3650 .apply_with(second, move |result| {
3651 started.send(()).unwrap();
3652 released_second
3653 .recv_timeout(Duration::from_secs(10))
3654 .unwrap();
3655 second_reply.complete(result);
3656 })
3657 .unwrap();
3658 assert_eq!(view.local_edits.lock().unwrap().pending, 2);
3659 release.send(()).unwrap();
3660 start.recv_timeout(Duration::from_secs(10)).unwrap();
3661 assert_eq!(view.local_edits.lock().unwrap().pending, 1);
3662 assert!(!sb_view_reload_ready(&view));
3663 release_second.send(()).unwrap();
3664 let deadline = Instant::now() + Duration::from_secs(10);
3665 while !sb_view_reload_ready(&view) {
3666 assert!(Instant::now() < deadline);
3667 std::thread::sleep(Duration::from_millis(10));
3668 }
3669 sb_view_reload(&mut view, false, None);
3670 assert!(!view.reload_pending);
3671 assert!(!sb_view_reload_ready(&view));
3672 let pending = section.shared.section.pending().unwrap();
3673 assert_eq!(pending.len(), before.len() + 2);
3674 assert_eq!(&pending[..before.len()], before.as_slice());
3675 assert_eq!(pending[before.len()].edit, expected_first);
3676 assert_eq!(pending[before.len() + 1].edit, expected_second);
3677 assert!(pending[before.len()].id < pending[before.len() + 1].id);
3678 assert!(view.canvas.active().shown_text().contains("first 🦀"));
3679 assert!(view.canvas.active().shown_text().contains("second 😀"));
3680
3681 let (started, start) = mpsc::channel();
3682 let (release, released) = mpsc::channel();
3683 let (finished, finish) = mpsc::channel();
3684 assert!(view.canvas.insert("late reply ".into()).unwrap());
3685 let third = view.canvas.edit().unwrap().unwrap();
3686 let third_reply = ApplyReply::new(&view.local_edits);
3687 let weak = Arc::downgrade(&view.local_edits);
3688 section
3689 .shared
3690 .apply_with(third, move |result| {
3691 started.send(()).unwrap();
3692 released.recv_timeout(Duration::from_secs(10)).unwrap();
3693 third_reply.complete(result);
3694 finished.send(()).unwrap();
3695 })
3696 .unwrap();
3697 start.recv_timeout(Duration::from_secs(10)).unwrap();
3698 drop(view);
3699 assert!(weak.upgrade().is_some());
3700 release.send(()).unwrap();
3701 finish.recv_timeout(Duration::from_secs(10)).unwrap();
3702 assert!(weak.upgrade().is_none());
3703 section.shared.section.written().unwrap();
3704 assert_eq!(
3705 section.shared.section.pending().unwrap().len(),
3706 before.len() + 3
3707 );
3708 drop((section, directory));
3709}
3710
3711#[test]
3712fn reader_dropped_and_synchronous_failed_apply_callbacks_never_release_the_reload_fence() {
3713 for synchronous in [false, true] {
3714 let edits = Arc::new(Mutex::new(LocalEdits::default()));
3715 let reply = ApplyReply::new(&edits);
3716 let callback: Box<dyn FnOnce(std::result::Result<u64, notebook::Error>)> =
3717 Box::new(move |result| reply.complete(result));
3718 if synchronous {
3719 callback(Err(
3720 std::io::Error::other("Disposable ingress failure").into()
3721 ));
3722 } else {
3723 drop(callback);
3724 }
3725 assert_eq!(edits.lock().unwrap().pending, 0);
3726 assert!(edits.lock().unwrap().failed);
3727 }
3728}
3729
3730#[test]
3731fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_failure() {
3732 let _exclusive = vfs::EXCLUSIVE
3733 .lock()
3734 .unwrap_or_else(std::sync::PoisonError::into_inner);
3735 for select in [false, true] {
3736 let directory = tempfile::tempdir().unwrap();
3737 let file = directory.path().join("Reader fault.one");
3738 let cache = directory.path().join("cache");
3739 let original =
3740 onestore::create_section("Reader fault.one", "Original body", "Fixture").unwrap();
3741 std::fs::write(&file, &original).unwrap();
3742 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
3743 sb_library_set_offline(&library, true);
3744 let section = Section::open(
3745 Arc::clone(&library),
3746 file.to_str().unwrap(),
3747 "Fixture".into(),
3748 || {},
3749 )
3750 .unwrap();
3751 let identity = section.shared.section.identity().unwrap();
3752 let space = section.shared.section.pages().unwrap()[0].0;
3753 let (page, read_only) = section.shared.page(space).unwrap();
3754 let mut view = View {
3755 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
3756 presentation: None,
3757 frame: Arc::default(),
3758 section: Arc::clone(&section.shared),
3759 read_only,
3760 reload_pending: false,
3761 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3762 };
3763 focus(&mut view.canvas, "Original body");
3764 assert!(sb_select(&mut view, 0, 0));
3765 if sb_view_format(&view) & 1 == 0 {
3766 assert!(sb_view_apply(&mut view, 0));
3767 }
3768 assert!(unsafe { sb_insert(&mut view, c"SEED ".as_ptr()) });
3769 section.shared.section.written().unwrap();
3770 assert_eq!(view.local_edits.lock().unwrap().pending, 0);
3771 assert!(!view.local_edits.lock().unwrap().failed);
3772 let seed = section.shared.section.pending().unwrap();
3773 assert_eq!(seed.len(), 1);
3774 let location = notebook::location::local(&file).unwrap();
3775 let root = onestore::RevisionIndex::parse(&onestore::Store::parse(&original).unwrap())
3776 .unwrap()
3777 .root
3778 .guid;
3779 let database = notebook::location::folder(&cache, &location).join(format!(
3780 "{}.sqlite",
3781 root.iter()
3782 .map(|byte| format!("{byte:02x}"))
3783 .collect::<String>(),
3784 ));
3785 assert!(database.is_file());
3786 let shared = Arc::clone(&section.shared);
3787 drop((view, section, library));
3788 Arc::into_inner(shared)
3789 .expect("No other seed writer")
3790 .section
3791 .close()
3792 .unwrap();
3793 // The cache's WAL takes no writes until the test has seen the edits wait.
3794 vfs::install();
3795 *vfs::FAILING.lock().unwrap() = Some(database.canonicalize().unwrap());
3796
3797 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
3798 sb_library_set_offline(&library, true);
3799 let section = Section::open(
3800 Arc::clone(&library),
3801 file.to_str().unwrap(),
3802 "Fixture".into(),
3803 || {},
3804 )
3805 .unwrap();
3806 assert_eq!(section.shared.section.pending().unwrap(), seed);
3807 let (page, read_only) = section.shared.page(space).unwrap();
3808 let mut view = View {
3809 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
3810 presentation: None,
3811 frame: Arc::default(),
3812 section: Arc::clone(&section.shared),
3813 read_only,
3814 reload_pending: false,
3815 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3816 };
3817 focus(&mut view.canvas, "Original body");
3818 assert!(sb_select(&mut view, 0, 0));
3819 let mut anchor = [0.0; 2];
3820 assert_eq!(
3821 sb_view_set_reading(&mut view, if select { 0.0 } else { 240.0 }, &mut anchor),
3822 !select
3823 );
3824 assert_ne!(sb_view_format(&view) & 1, 0);
3825 assert!(unsafe { sb_set_marked(&mut view, c"FAILED rich 😀 ".as_ptr(), 15, 15) });
3826 if select {
3827 assert!(sb_select(&mut view, 0, 0));
3828 } else {
3829 sb_unmark(&mut view);
3830 }
3831 assert!(sb_view_set_paper(&mut view, 0x77, 0x88, 0x99, -1));
3832 let preview = view.canvas.page.editor.page().unwrap();
3833 let deadline = Instant::now() + Duration::from_secs(10);
3834 let mut flags = 0;
3835 while section.shared.status.load(Ordering::Relaxed) != library::Status::NotSaving as u8 {
3836 flags |= section.poll();
3837 assert!(Instant::now() < deadline);
3838 std::thread::sleep(Duration::from_millis(10));
3839 }
3840 flags |= section.poll();
3841 assert_eq!(flags & 4, 0);
3842 assert_eq!(section.shared.section.pending().unwrap(), seed);
3843 let (waiting, _) = section.shared.page(space).unwrap();
3844 assert!(canvas::search::page_text(&waiting).contains("FAILED rich 😀"));
3845 assert_eq!(waiting.color, Some(0x0099_8877));
3846 assert_eq!(view.local_edits.lock().unwrap().pending, 2);
3847 assert!(!view.local_edits.lock().unwrap().failed);
3848 assert!(library.close(Duration::from_millis(100)).is_err());
3849 *vfs::FAILING.lock().unwrap() = None;
3850
3851 let deadline = Instant::now() + Duration::from_secs(10);
3852 while view.local_edits.lock().unwrap().pending > 0 {
3853 assert!(Instant::now() < deadline);
3854 std::thread::sleep(Duration::from_millis(10));
3855 }
3856 assert!(!view.local_edits.lock().unwrap().failed);
3857 let pending = section.shared.section.pending().unwrap();
3858 assert_eq!(pending.len(), seed.len() + 2);
3859 assert_eq!(&pending[..seed.len()], seed.as_slice());
3860 sb_view_reload(&mut view, false, None);
3861 assert!(!view.reload_pending);
3862 assert_eq!(view.canvas.page.editor.page().unwrap(), preview);
3863 let shared = Arc::clone(&section.shared);
3864 drop((view, section, library));
3865 Arc::into_inner(shared)
3866 .expect("No other writer")
3867 .section
3868 .close()
3869 .unwrap();
3870 let cold = notebook::Replica::open(&database).unwrap();
3871 assert_eq!(cold.identity().unwrap(), identity);
3872 assert_eq!(cold.pending().unwrap(), pending);
3873 let page = cold.page(space).unwrap();
3874 assert_eq!(
3875 canvas::search::page_text(&page)
3876 .matches("FAILED rich 😀")
3877 .count(),
3878 1
3879 );
3880 assert_eq!(page.color, Some(0x0099_8877));
3881 }
3882}
3883
3884/// A document provider over real folders, documents named by their paths, failing as the code
3885/// `fail` set for a folder says.
3886mod provider_fake {
3887 use crate::provider::{Found, Table, sb_set_provider};
3888 use crate::string;
3889 use std::{
3890 ffi::{CString, c_char, c_void},
3891 os::fd::IntoRawFd,
3892 path::{Path, PathBuf},
3893 sync::Mutex,
3894 };
3895
3896 static FAILING: Mutex<Vec<(PathBuf, i32)>> = Mutex::new(Vec::new());
3897 /// Folders whose documents list but don't open, as a cloud provider's offline.
3898 static UNOPENABLE: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
3899
3900 pub(super) fn unopenable(root: &Path, unopenable: bool) {
3901 let mut roots = UNOPENABLE.lock().unwrap();
3902 roots.retain(|held| held != root);
3903 roots.extend(unopenable.then(|| root.to_owned()));
3904 }
3905
3906 /// Has every call on documents in `root` fail with `code`, or none.
3907 pub(super) fn fail(root: &Path, code: Option<i32>) {
3908 let mut failing = FAILING.lock().unwrap();
3909 failing.retain(|(failed, _)| failed != root);
3910 failing.extend(code.map(|code| (root.to_owned(), code)));
3911 }
3912
3913 fn failing(id: *const c_char) -> Result<PathBuf, i32> {
3914 let path = PathBuf::from(string(id));
3915 let failing = FAILING.lock().unwrap();
3916 match failing.iter().find(|(root, _)| path.starts_with(root)) {
3917 Some((_, code)) => Err(*code),
3918 None => Ok(path),
3919 }
3920 }
3921
3922 fn describe(path: &Path, found: Found, context: *mut c_void) -> i32 {
3923 let Ok(metadata) = std::fs::metadata(path) else {
3924 return 3;
3925 };
3926 let id = CString::new(path.to_str().unwrap()).unwrap();
3927 let name = CString::new(path.file_name().unwrap().to_str().unwrap()).unwrap();
3928 let modified = metadata.modified().unwrap();
3929 let modified = modified.duration_since(std::time::UNIX_EPOCH).unwrap();
3930 let (size, modified) = (metadata.len() as i64, modified.as_millis() as i64);
3931 found(
3932 context,
3933 id.as_ptr(),
3934 name.as_ptr(),
3935 metadata.is_dir(),
3936 size,
3937 modified,
3938 true,
3939 );
3940 0
3941 }
3942
3943 extern "C" fn document(id: *const c_char, found: Found, context: *mut c_void) -> i32 {
3944 failing(id).map_or_else(|code| code, |path| describe(&path, found, context))
3945 }
3946
3947 extern "C" fn children(id: *const c_char, found: Found, context: *mut c_void) -> i32 {
3948 let path = match failing(id) {
3949 Ok(path) => path,
3950 Err(code) => return code,
3951 };
3952 let Ok(entries) = std::fs::read_dir(path) else {
3953 return 3;
3954 };
3955 for entry in entries {
3956 describe(&entry.unwrap().path(), found, context);
3957 }
3958 0
3959 }
3960
3961 extern "C" fn open(
3962 id: *const c_char,
3963 mode: *const c_char,
3964 fd: *mut i32,
3965 handle: *mut u64,
3966 ) -> i32 {
3967 let path = match failing(id) {
3968 Ok(path) => path,
3969 Err(code) => return code,
3970 };
3971 if UNOPENABLE
3972 .lock()
3973 .unwrap()
3974 .iter()
3975 .any(|root| path.starts_with(root))
3976 {
3977 return 1;
3978 }
3979 let write = string(mode) == "rw";
3980 match std::fs::File::options().read(true).write(write).open(path) {
3981 Ok(file) => {
3982 // SAFETY: the caller passes places for one descriptor and one handle.
3983 unsafe {
3984 fd.write(file.into_raw_fd());
3985 handle.write(1);
3986 }
3987 0
3988 }
3989 Err(_) => 3,
3990 }
3991 }
3992
3993 extern "C" fn close(handle: u64) -> i32 {
3994 assert_eq!(handle, 1);
3995 0
3996 }
3997
3998 extern "C" fn rename(
3999 id: *const c_char,
4000 name: *const c_char,
4001 renamed: extern "C" fn(*mut c_void, *const c_char),
4002 context: *mut c_void,
4003 ) -> i32 {
4004 let path = match failing(id) {
4005 Ok(path) => path,
4006 Err(code) => return code,
4007 };
4008 let new = path.with_file_name(string(name));
4009 if std::fs::rename(&path, &new).is_err() {
4010 return 3;
4011 }
4012 let new = CString::new(new.to_str().unwrap()).unwrap();
4013 renamed(context, new.as_ptr());
4014 0
4015 }
4016
4017 pub(super) fn register() {
4018 // SAFETY: every function keeps the header's contract; `open` hands over its own file.
4019 unsafe {
4020 sb_set_provider(&Table {
4021 document,
4022 children,
4023 open,
4024 close,
4025 rename,
4026 })
4027 };
4028 }
4029}
4030
4031/// The library `sb_library_provider` opens on `root`, or its error.
4032fn provided(root: &Path, folder: bool, cache: &Path) -> std::result::Result<Arc<Library>, String> {
4033 provider_fake::register();
4034 let (root, cache) = (
4035 CString::new(root.to_str().unwrap()).unwrap(),
4036 CString::new(cache.to_str().unwrap()).unwrap(),
4037 );
4038 let mut error = std::ptr::null_mut();
4039 // SAFETY: every string is NUL-terminated and `error` a place for one.
4040 let library = unsafe {
4041 library::sb_library_provider(
4042 root.as_ptr(),
4043 folder,
4044 true,
4045 c"Test".as_ptr(),
4046 cache.as_ptr(),
4047 &mut error,
4048 )
4049 };
4050 if library.is_null() {
4051 let message = string(error);
4052 // SAFETY: `error` came from the call above.
4053 unsafe { sb_string_free(error) };
4054 return Err(message);
4055 }
4056 // SAFETY: a library the call above made, freed when the Arc drops (`sb_library_free`).
4057 Ok(unsafe { Arc::from_raw(library) })
4058}
4059
4060/// Types `text` before "Collapsed parent" on Paragraph controls, returning the page.
4061fn typed(section: &Section, text: &str) -> ExGuid {
4062 let mut canvas = canvas(section, "Paragraph controls");
4063 focus(&mut canvas, "Collapsed parent");
4064 canvas.insert(text.into()).unwrap();
4065 section
4066 .shared
4067 .apply(canvas.edit().unwrap().unwrap())
4068 .unwrap();
4069 section.shared.section.replica().written().unwrap();
4070 canvas.space
4071}
4072
4073fn closed(section: Section) {
4074 let shared = Arc::clone(&section.shared);
4075 drop(section);
4076 Arc::into_inner(shared).unwrap().section.close().unwrap();
4077}
4078
4079fn flushed(library: &Library, seconds: f64) -> bool {
4080 // SAFETY: a null error is allowed.
4081 unsafe { library::sb_library_flush(library, seconds, std::ptr::null_mut()) }
4082}
4083
4084#[test]
4085fn a_provider_folder_opens_through_the_c_surface_and_publishes_in_place() {
4086 let (directory, root) = copy("m6/native-features-01/notebook");
4087 let library = provided(&root, true, &directory.path().join("cache")).unwrap();
4088 let tabs = library.tabs().unwrap();
4089 assert!(
4090 tabs.iter()
4091 .any(|tab| tab.path == "Features.one" && tab.readable)
4092 );
4093 let section = open(&library, "Features.one");
4094 let space = typed(&section, "Provided ");
4095 assert!(section.flush(Duration::from_secs(20)));
4096 assert!(stored_text(&root.join("Features.one"), space).contains("Provided "));
4097 closed(section);
4098}
4099
4100#[test]
4101fn a_provider_notebook_cold_opens_offline_as_last_listed_with_its_queued_edits() {
4102 let (directory, root) = copy("m6/native-features-01/notebook");
4103 let cache = directory.path().join("cache");
4104 let library = provided(&root, true, &cache).unwrap();
4105 let listed: Vec<String> = library
4106 .tabs()
4107 .unwrap()
4108 .into_iter()
4109 .map(|tab| tab.path)
4110 .collect();
4111 let section = open(&library, "Features.one");
4112 provider_fake::fail(&root, Some(1));
4113 let space = typed(&section, "Offline ");
4114 assert!(!flushed(&library, 0.5));
4115 closed(section);
4116 drop(library);
4117
4118 let library = provided(&root, true, &cache).unwrap();
4119 let tabs: Vec<String> = library
4120 .tabs()
4121 .unwrap()
4122 .into_iter()
4123 .map(|tab| tab.path)
4124 .collect();
4125 assert_eq!(tabs, listed);
4126 let section = open(&library, "Features.one");
4127 let (page, _) = section.shared.page(space).unwrap();
4128 assert!(canvas::search::page_text(&page).contains("Offline "));
4129 assert_eq!(section.shared.section.pending().unwrap().len(), 1);
4130 assert!(eventually(|| library.sync_status().iter().any(|sync| {
4131 sync.path == "Features.one" && sync.state == SyncState::NotConnected as u8
4132 })));
4133 assert!(!stored_text(&root.join("Features.one"), space).contains("Offline "));
4134 provider_fake::fail(&root, None);
4135 sb_library_sync_now(&library);
4136 assert!(section.flush(Duration::from_secs(20)));
4137 assert!(stored_text(&root.join("Features.one"), space).contains("Offline "));
4138 closed(section);
4139}
4140
4141#[test]
4142fn a_revoked_grant_refuses_to_open_and_keeps_the_queued_edits() {
4143 let (directory, root) = copy("m6/native-features-01/notebook");
4144 let cache = directory.path().join("cache");
4145 let library = provided(&root, true, &cache).unwrap();
4146 let section = open(&library, "Features.one");
4147 provider_fake::fail(&root, Some(2));
4148 let space = typed(&section, "Kept ");
4149 closed(section);
4150 drop(library);
4151 let refused = provided(&root, true, &cache).err().unwrap();
4152 assert!(refused.contains("no longer has access"), "{refused}");
4153 assert!(replicas(&directory) > 0);
4154
4155 provider_fake::fail(&root, None);
4156 let library = provided(&root, true, &cache).unwrap();
4157 let section = open(&library, "Features.one");
4158 assert_eq!(section.shared.section.pending().unwrap().len(), 1);
4159 assert!(section.flush(Duration::from_secs(20)));
4160 assert!(stored_text(&root.join("Features.one"), space).contains("Kept "));
4161 closed(section);
4162}
4163
4164#[test]
4165fn finishing_keeps_offline_copies_and_close_notebook_deletes_them() {
4166 let (directory, root) = copy("m6/native-features-01/notebook");
4167 let cache = directory.path().join("cache");
4168 let library = provided(&root, true, &cache).unwrap();
4169 let readable = library
4170 .tabs()
4171 .unwrap()
4172 .iter()
4173 .filter(|tab| tab.readable)
4174 .count();
4175 assert!(eventually(|| replicas(&directory) == readable));
4176 assert!(flushed(&library, 10.0));
4177 drop(library);
4178 std::thread::sleep(Duration::from_millis(200));
4179 assert_eq!(replicas(&directory), readable);
4180
4181 let library = provided(&root, true, &cache).unwrap();
4182 // SAFETY: a null error is allowed.
4183 assert!(unsafe { library::sb_library_close(&library, 10.0, std::ptr::null_mut()) });
4184 drop(library);
4185 assert!(eventually(|| replicas(&directory) == 0));
4186}
4187
4188#[test]
4189fn a_lone_provider_document_opens_and_publishes() {
4190 let (directory, file) = copy("media-edit/candidate/Features.one");
4191 let library = provided(&file, false, &directory.path().join("cache")).unwrap();
4192 let tabs = library.tabs().unwrap();
4193 assert_eq!(
4194 (tabs[0].name.as_str(), tabs[0].path.as_str()),
4195 ("Features", "")
4196 );
4197 let section = open(&library, "");
4198 let space = typed(&section, "Alone ");
4199 // As finishing flushes: the library, its open section and no background.
4200 assert!(flushed(&library, 20.0));
4201 assert!(stored_text(&file, space).contains("Alone "));
4202 closed(section);
4203}
4204
4205#[test]
4206fn a_provider_that_lists_but_cannot_open_opens_from_the_offline_copies() {
4207 let (directory, root) = copy("m6/native-features-01/notebook");
4208 let cache = directory.path().join("cache");
4209 let library = provided(&root, true, &cache).unwrap();
4210 let section = open(&library, "Features.one");
4211 let space = typed(&section, "Before ");
4212 assert!(flushed(&library, 20.0));
4213 closed(section);
4214 drop(library);
4215 // Listed otherwise than when last read, so discovery would read it again.
4216 let file = root.join("Features.one");
4217 let touched = std::fs::metadata(&file).unwrap().modified().unwrap() + Duration::from_secs(5);
4218 std::fs::File::options()
4219 .write(true)
4220 .open(&file)
4221 .unwrap()
4222 .set_modified(touched)
4223 .unwrap();
4224 provider_fake::unopenable(&root, true);
4225
4226 let library = provided(&root, true, &cache).unwrap();
4227 assert!(
4228 library
4229 .tabs()
4230 .unwrap()
4231 .iter()
4232 .any(|tab| tab.path == "Features.one" && tab.readable)
4233 );
4234 let section = open(&library, "Features.one");
4235 let (page, _) = section.shared.page(space).unwrap();
4236 assert!(canvas::search::page_text(&page).contains("Before "));
4237 typed(&section, "Offline");
4238 let (page, _) = section.shared.page(space).unwrap();
4239 assert!(canvas::search::page_text(&page).contains("Offline"));
4240 assert!(eventually(|| library.sync_status().iter().any(|sync| {
4241 sync.path == "Features.one"
4242 && sync.state == SyncState::NotConnected as u8
4243 && sync.queued == 1
4244 })));
4245 provider_fake::unopenable(&root, false);
4246 sb_library_sync_now(&library);
4247 assert!(flushed(&library, 20.0));
4248 assert!(stored_text(&file, space).contains("Offline"));
4249 closed(section);
4250}