1use super::*;
2use library::{sb_library_set_offline, sb_library_sync_now};
3use notebook::session::SyncState;
4use std::path::{Path, PathBuf};
5
6const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus");
7
8fn corpus(path: &str) -> PathBuf {
9 Path::new(CORPUS).join(path)
10}
11
12/// A copy of the corpus section or notebook folder at `path` in a fresh directory, with a
13/// cache beside it.
14fn copy(path: &str) -> (tempfile::TempDir, PathBuf) {
15 let directory = tempfile::tempdir().unwrap();
16 let source = corpus(path);
17 let target = directory.path().join(source.file_name().unwrap());
18 let status = std::process::Command::new("cp")
19 .arg("-R")
20 .arg(&source)
21 .arg(&target)
22 .status()
23 .unwrap();
24 assert!(status.success());
25 (directory, target)
26}
27
28fn open(library: &Arc<Library>, path: &str) -> Section {
29 Section::open(Arc::clone(library), path, "Clover Test".into(), || {}).unwrap()
30}
31
32fn space(section: &Section, title: &str) -> ExGuid {
33 section
34 .shared
35 .section
36 .pages()
37 .unwrap()
38 .into_iter()
39 .find(|(_, heading, _)| heading == title)
40 .unwrap()
41 .0
42}
43
44/// A page of the Features section on a 402 × 874 point phone at 3 pixels per point, at
45/// 100% with the page origin at the view's corner.
46fn canvas(section: &Section, title: &str) -> Canvas {
47 let space = space(section, title);
48 let (page, _) = section.shared.page(space).unwrap();
49 let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap();
50 canvas.set_transform(1.0, [0.0; 2]);
51 canvas
52}
53
54fn features() -> (tempfile::TempDir, Section) {
55 let (directory, file) = copy("media-edit/candidate/Features.one");
56 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
57 let section = open(&library, &file.to_string_lossy());
58 (directory, section)
59}
60
61/// A view point on the outline holding `text`, `fraction` of the way along its first line.
62fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] {
63 let outline = canvas
64 .page
65 .editor
66 .visible_outlines()
67 .find(|outline| outline.shown_text().contains(text))
68 .unwrap();
69 let bounds = outline.bounds();
70 let x = bounds.x0 as f32 + (bounds.x1 - bounds.x0) as f32 * fraction;
71 let y = bounds.y0 as f32 + 6.0;
72 [x * POINT, y * POINT]
73}
74
75/// Focuses the outline holding `text` with a tap, and hands the section the edit that took.
76fn focus(canvas: &mut Canvas, text: &str) {
77 let point = point_on(canvas, text, 0.1);
78 canvas.press(point).unwrap();
79 canvas.release().unwrap();
80}
81
82#[test]
83fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
84 let directory = tempfile::tempdir().unwrap();
85 let library = Library::open(
86 &corpus("m6/native-features-01/notebook"),
87 directory.path(),
88 true,
89 )
90 .unwrap();
91 let tabs = library.tabs().unwrap();
92 let names: Vec<_> = tabs
93 .iter()
94 .map(|tab| (tab.group.as_str(), tab.name.as_str()))
95 .collect();
96 assert!(names.contains(&("", "Features")), "{names:?}");
97 assert!(tabs.iter().any(|tab| !tab.group.is_empty()));
98 let lone = Library::open(
99 &corpus("media-edit/candidate/Features.one"),
100 directory.path(),
101 true,
102 )
103 .unwrap();
104 assert_eq!(lone.tabs().unwrap().len(), 1);
105}
106
107#[test]
108fn a_new_notebook_opens_with_one_section_in_its_colour_and_refuses_a_taken_name() {
109 let directory = tempfile::tempdir().unwrap();
110 let root = directory.path().join("Mine");
111 let cache = directory.path().join("cache");
112 std::fs::create_dir(&cache).unwrap();
113 let c = |path: &Path| CString::new(path.to_str().unwrap()).unwrap();
114 let create = |error: &mut *mut c_char| unsafe {
115 library::sb_notebook_create(
116 c(&root).as_ptr(),
117 c(&cache).as_ptr(),
118 c"Clover Test".as_ptr(),
119 c"Tuesday, September 29, 2026".as_ptr(),
120 c"9:41 AM".as_ptr(),
121 error,
122 )
123 };
124 let mut error = std::ptr::null_mut();
125 assert!(create(&mut error));
126 assert!(error.is_null());
127 let library = Library::open(&root, &cache, true).unwrap();
128 let tabs = library.tabs().unwrap();
129 assert_eq!(tabs.len(), 1);
130 assert!(tabs[0].readable);
131 assert_eq!(library::sb_library_color(&library), 0x91baae);
132 assert!(!create(&mut error));
133 assert!(!error.is_null());
134 unsafe { sb_string_free(error) };
135}
136
137#[test]
138fn touches_route_to_text_the_page_and_the_focused_outlines_grip() {
139 let (_directory, section) = features();
140 let mut canvas = canvas(&section, "Paragraph controls");
141 let text = point_on(&canvas, "Collapsed parent", 0.1);
142 let first = canvas.target(text);
143 assert!(
144 matches!(first, Target::ActiveText | Target::Text),
145 "{first:?}"
146 );
147 assert!(canvas.press(text).unwrap());
148 canvas.release().unwrap();
149 assert_eq!(canvas.target(text), Target::ActiveText);
150 // The grip strip sits above the text; a finger reaches it from 16 points higher still.
151 let bounds = canvas.active().bounds();
152 let above = [
153 (bounds.x0 as f32 + 40.0) * POINT,
154 (bounds.y0 as f32 - 12.0) * POINT - 10.0,
155 ];
156 assert_eq!(canvas.target(above), Target::Grip);
157 // Unfocused, the grips hide and a touch there reaches the page.
158 let _ = canvas.page.focus_changed(false).unwrap();
159 assert_eq!(canvas.target(above), Target::Page);
160}
161
162#[test]
163fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() {
164 let (_directory, section) = features();
165 let mut canvas = canvas(&section, "Paragraph controls");
166 focus(&mut canvas, "Collapsed parent");
167 canvas.edit().unwrap();
168 let before = canvas.active().origin();
169 let grip = [(before[0] + 40.0) * POINT, (before[1] - 8.0) * POINT];
170 assert_eq!(canvas.target(grip), Target::Grip);
171 canvas.press(grip).unwrap();
172 for step in 1..=10 {
173 let step = step as f32;
174 canvas
175 .drag([grip[0] + step * 10.0, grip[1] + step * 7.0])
176 .unwrap();
177 }
178 canvas.release().unwrap();
179 let after = canvas.active().origin();
180 let margin = canvas.page.editor.margin_origin();
181 assert_ne!(after, before);
182 for axis in 0..2 {
183 let cells = (after[axis] - margin[axis]) / 18.0;
184 assert!(
185 (cells - cells.round()).abs() < 1e-3,
186 "{after:?} off the grid"
187 );
188 }
189 assert!(canvas.edit().unwrap().is_some());
190 assert!(canvas.page.editor.can_undo());
191}
192
193#[test]
194fn edits_leave_the_viewport_where_the_host_put_it() {
195 let (_directory, section) = features();
196 let mut canvas = canvas(&section, "Paragraph controls");
197 // Scrolled far past the content, where the canvas would clamp and reveal.
198 canvas.set_transform(1.0, [-2000.0, 5000.0]);
199 let origin = canvas.page.viewport.origin;
200 focus(&mut canvas, "Collapsed parent");
201 canvas.insert("typed".into()).unwrap();
202 assert_eq!(canvas.page.viewport.origin, origin);
203}
204
205#[test]
206fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {
207 let (_directory, section) = features();
208 let mut canvas = canvas(&section, "Paragraph controls");
209 focus(&mut canvas, "Collapsed parent");
210 let shown = canvas.active().shown_text();
211 let length = canvas.length();
212 assert_eq!(length as usize, shown.encode_utf16().count());
213 assert_eq!(canvas.text([0, length]), shown);
214 let start = shown.find("Collapsed").unwrap() as u32;
215 canvas.select([start, start + 9]).unwrap();
216 assert_eq!(canvas.selection().unwrap(), [start, start + 9]);
217 assert!(!canvas.range_rects([start, start + 9]).unwrap().is_empty());
218 let caret = canvas.caret_rect(start).unwrap();
219 let closest = canvas
220 .closest([caret[0] + 1.0, caret[1] + caret[3] / 2.0])
221 .unwrap();
222 assert!(closest.abs_diff(start) <= 1, "{closest} for {start}");
223 // Every offset has a caret, including those around the collapsed children.
224 for offset in 0..=length {
225 canvas.caret_rect(offset).unwrap();
226 }
227}
228
229#[test]
230fn select_more_widens_from_the_caret_paragraph_to_the_outline() {
231 let (_directory, section) = features();
232 let mut canvas = canvas(&section, "Paragraph controls");
233 focus(&mut canvas, "Collapsed parent");
234 let shown = canvas.active().shown_text();
235 let units = |text: &str| text.encode_utf16().count() as u32;
236 let start = units(&shown[..shown.find("Collapsed parent").unwrap()]);
237 let line = units(
238 shown
239 .split('\n')
240 .find(|line| line.contains("Collapsed parent"))
241 .unwrap(),
242 );
243 canvas.select([start + 2; 2]).unwrap();
244 canvas.page.editor.widen_selection().unwrap();
245 let [lo, hi] = canvas.selection().unwrap();
246 assert!(
247 lo == start && [start + line, start + line + 1].contains(&hi),
248 "{lo}..{hi}"
249 );
250 for _ in 0..8 {
251 canvas.page.editor.widen_selection().unwrap();
252 }
253 assert_eq!(canvas.selection().unwrap(), [0, canvas.length()]);
254}
255
256#[test]
257fn undo_restores_typing() {
258 let (_directory, section) = features();
259 let mut canvas = canvas(&section, "Paragraph controls");
260 focus(&mut canvas, "Collapsed parent");
261 let before = canvas.active().shown_text();
262 // UIKit's keystrokes, one call each, with the selection set again where it is: one run.
263 for text in ["h", "i", "x"] {
264 canvas.insert(text.into()).unwrap();
265 let selection = canvas.selection().unwrap();
266 canvas.select(selection).unwrap();
267 }
268 assert!(
269 canvas
270 .page
271 .key(&Key::Named(NamedKey::Backspace), None)
272 .unwrap()
273 .changed
274 );
275 canvas.insert("!".into()).unwrap();
276 assert_ne!(canvas.active().shown_text(), before);
277 assert!(moved(canvas.page.undo(false).unwrap()));
278 assert_eq!(canvas.active().shown_text(), before);
279 assert!(canvas.page.editor.can_redo());
280}
281
282/// The text of the page `space` of the section file `file`, read cold.
283fn stored_text(file: &Path, space: ExGuid) -> String {
284 let arena = onestore::Arena::default();
285 let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
286 canvas::search::page_text(&section.page(space).unwrap())
287}
288
289#[test]
290fn typing_is_stored_in_the_cache_and_published_to_the_file() {
291 let (directory, file) = copy("media-edit/candidate/Features.one");
292 let cache = directory.path().join("cache");
293 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
294 let section = open(&library, &file.to_string_lossy());
295 let mut canvas = canvas(&section, "Paragraph controls");
296 focus(&mut canvas, "Collapsed parent");
297 let shown = canvas.active().shown_text();
298 let at = shown[..shown.find("Collapsed parent").unwrap()]
299 .encode_utf16()
300 .count() as u32;
301 canvas.select([at, at]).unwrap();
302 canvas.insert("Saved ".into()).unwrap();
303 section
304 .shared
305 .apply(canvas.edit().unwrap().unwrap())
306 .unwrap();
307 assert!(section.flush(Duration::from_secs(20)));
308 let space = canvas.space;
309 assert!(stored_text(&file, space).contains("Saved Collapsed parent"));
310 // The replica reopens from the cache as the file now holds it.
311 let shared = Arc::clone(&section.shared);
312 drop((canvas, section));
313 Arc::into_inner(shared).unwrap().section.close().unwrap();
314 let reopened = open(&library, &file.to_string_lossy());
315 let (page, read_only) = reopened.shared.page(space).unwrap();
316 assert!(!read_only);
317 assert!(canvas::search::page_text(&page).contains("Saved Collapsed parent"));
318}
319
320#[test]
321fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {
322 let (directory, root) = copy("m6/native-features-01/notebook");
323 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
324 let section = open(&library, "Features.one");
325 let first = section.rows().unwrap()[0].id.parse().unwrap();
326 let page = section
327 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")
328 .unwrap();
329 let subpage = section
330 .new_page(Some(first), "Sunday, September 27, 2026", "3:05 AM")
331 .unwrap();
332 let rows = section.rows().unwrap();
333 assert_eq!(rows.last().unwrap().id, page.to_string());
334 assert_eq!(rows[1].id, subpage.to_string());
335 assert_eq!(rows[1].level, rows[0].level + 1);
336 let mut canvas = Canvas::new(
337 subpage,
338 section.shared.page(subpage).unwrap().0,
339 [1206, 2622],
340 3.0,
341 )
342 .unwrap();
343 assert!(canvas.focus_title().unwrap());
344 canvas.insert("Sketches".into()).unwrap();
345 assert_eq!(canvas.title().unwrap().shown_text(), "Sketches");
346 section
347 .shared
348 .apply(canvas.edit().unwrap().unwrap())
349 .unwrap();
350 assert!(
351 section
352 .rows()
353 .unwrap()
354 .iter()
355 .any(|row| row.title == "Sketches")
356 );
357 section
358 .delete_page(subpage, "Sunday, September 27, 2026", "3:06 AM")
359 .unwrap();
360 assert!(
361 section
362 .rows()
363 .unwrap()
364 .iter()
365 .all(|row| row.id != subpage.to_string())
366 );
367 assert!(
368 root.join("OneNote_RecycleBin/OneNote_DeletedPages.one")
369 .exists()
370 );
371}
372
373#[test]
374fn conflict_pages_list_under_their_page_and_take_no_edits() {
375 let (directory, root) = copy("conflict-page/candidate");
376 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
377 let section = open(&library, "synthetic.one");
378 let rows = section.rows().unwrap();
379 let version = rows
380 .iter()
381 .find_map(|row| row.versions.first())
382 .expect("a conflict page");
383 assert!(!version.user.is_empty());
384 let (page, read_only) = section.shared.page(version.id.parse().unwrap()).unwrap();
385 assert!(read_only);
386 // Conflicting paragraphs carry OneNote's highlight.
387 let highlighted = page.objects.iter().any(|object| match object {
388 onestore::page::PageObject::Outline(outline) => outline
389 .paragraphs
390 .iter()
391 .any(|paragraph| paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)),
392 _ => false,
393 });
394 assert!(highlighted);
395}
396
397#[test]
398fn search_finds_pages_by_title_and_text_across_sections() {
399 let directory = tempfile::tempdir().unwrap();
400 let library = Library::open(
401 &corpus("m6/native-features-01/notebook"),
402 directory.path(),
403 true,
404 )
405 .unwrap();
406 let found = library::search(&[&library], None, "collapsed PARENT").unwrap();
407 assert!(!found.is_empty());
408 assert!(found[0].snippet.to_lowercase().contains("collapsed parent"));
409 let [start, end] = found[0].snippet_hits[0];
410 let units: Vec<u16> = found[0].snippet.encode_utf16().collect();
411 assert_eq!(
412 String::from_utf16(&units[start..end])
413 .unwrap()
414 .to_lowercase(),
415 "collapsed"
416 );
417 assert!(
418 library::search(&[&library], None, "no such words anywhere")
419 .unwrap()
420 .is_empty()
421 );
422 let section = &found[0].section;
423 assert!(
424 library::search(&[&library], Some(section), "collapsed parent")
425 .unwrap()
426 .iter()
427 .all(|found| found.section == *section)
428 );
429}
430
431#[test]
432fn search_lists_title_matches_of_every_notebook_first() {
433 let directory = tempfile::tempdir().unwrap();
434 let personal = Library::open(
435 &corpus("search/notebook"),
436 &directory.path().join("a"),
437 true,
438 )
439 .unwrap();
440 let other = Library::open(
441 &corpus("m6/native-features-01/notebook"),
442 &directory.path().join("b"),
443 true,
444 )
445 .unwrap();
446 let found = library::search(&[&other, &personal], None, "tom").unwrap();
447 assert_eq!(found[0].title, "Tomatoes");
448 assert_eq!(found[0].notebook, 1);
449 assert_eq!(found[0].title_hits, [[0, 3]]);
450 assert!(found[1..].iter().all(|found| !found.in_title));
451 assert_eq!(found.len(), 5);
452}
453
454#[test]
455fn find_selects_the_match_and_formatting_reports_and_toggles() {
456 let (_directory, section) = features();
457 let mut canvas = canvas(&section, "Paragraph controls");
458 assert!(canvas.find("collapsed PARENT").unwrap());
459 let [start, end] = canvas.selection().unwrap();
460 assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed");
461 assert_eq!(canvas.format_bits().unwrap() & 1, 0);
462 assert!(canvas.format(0).unwrap());
463 assert_eq!(canvas.format_bits().unwrap() & 1, 1);
464 // To Do on the paragraph.
465 assert!(canvas.format(16).unwrap());
466 assert_ne!(canvas.format_bits().unwrap() & 1 << 16, 0);
467 assert!(canvas.edit().unwrap().is_some());
468}
469
470#[test]
471fn a_picture_goes_in_at_the_caret_and_undoes() {
472 let (_directory, section) = features();
473 let mut canvas = canvas(&section, "Paragraph controls");
474 focus(&mut canvas, "Collapsed parent");
475 canvas.edit().unwrap();
476 let png = std::fs::read(corpus(
477 "../crates/snowbound/assets/icon/Snowbound-SnowLeopard.png",
478 ))
479 .unwrap();
480 assert!(canvas.insert_picture(&png, [64.0, 64.0]).unwrap());
481 let edit = canvas.edit().unwrap().unwrap();
482 assert!(edit.ops.iter().any(|op| matches!(
483 op,
484 Op::Page {
485 op: onestore::op::PageOp::Insert { .. },
486 ..
487 }
488 )));
489 section.shared.apply(edit).unwrap();
490 let pictures = |page: &Page| {
491 page.objects
492 .iter()
493 .filter_map(|object| match object {
494 onestore::page::PageObject::Outline(outline) => Some(
495 outline
496 .paragraphs
497 .iter()
498 .filter(|paragraph| {
499 matches!(
500 paragraph.content,
501 onestore::page::ParagraphContent::Image(_)
502 )
503 })
504 .count(),
505 ),
506 _ => None,
507 })
508 .sum::<usize>()
509 };
510 let before = pictures(&section.shared.page(canvas.space).unwrap().0);
511 assert!(moved(canvas.page.undo(false).unwrap()));
512 section
513 .shared
514 .apply(canvas.edit().unwrap().unwrap())
515 .unwrap();
516 assert_eq!(
517 pictures(&section.shared.page(canvas.space).unwrap().0),
518 before - 1
519 );
520}
521
522#[test]
523fn to_do_marks_a_new_pages_body() {
524 let (directory, root) = copy("m6/native-features-01/notebook");
525 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
526 let section = open(&library, "Features.one");
527 let page = section
528 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")
529 .unwrap();
530 let mut canvas = Canvas::new(
531 page,
532 section.shared.page(page).unwrap().0,
533 [1206, 2622],
534 3.0,
535 )
536 .unwrap();
537 canvas.set_transform(1.0, [0.0; 2]);
538 assert!(canvas.focus_title().unwrap());
539 canvas.insert("Groceries".into()).unwrap();
540 canvas.insert("\n".into()).unwrap();
541 canvas.insert("Oat milk".into()).unwrap();
542 canvas.insert("\n".into()).unwrap();
543 canvas.insert("Pears".into()).unwrap();
544 assert!(canvas.format(16).unwrap());
545 section
546 .shared
547 .apply(canvas.edit().unwrap().unwrap())
548 .unwrap();
549 let stored = section.shared.page(page).unwrap().0;
550 let tags: usize = stored
551 .objects
552 .iter()
553 .map(|object| match object {
554 onestore::page::PageObject::Outline(outline) => outline
555 .paragraphs
556 .iter()
557 .filter_map(|paragraph| paragraph.text())
558 .map(|text| text.tags.len())
559 .sum(),
560 _ => 0,
561 })
562 .sum();
563 assert_eq!(tags, 1);
564}
565
566/// Against a disposable share `agent` (`ONESTORE_SMB_LAB=HOST:PORT`, as
567/// `SNOWBOUND_TEST_SMB_USER` with `SNOWBOUND_TEST_SMB_PASSWORD`, or a guest): a notebook
568/// folder copied to the share lists, a section opens and publishes typing, and the listing
569/// kept in the cache opens the section again while the server cannot be reached.
570#[test]
571#[ignore = "requires ONESTORE_SMB_LAB pointing to a disposable share"]
572fn a_notebook_on_a_share_saves_and_opens_offline() {
573 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
574 let variable = |name| std::env::var(name).unwrap_or_default();
575 let server = |address: &str| library::Server {
576 address: address.into(),
577 share: "agent".into(),
578 user: variable("SNOWBOUND_TEST_SMB_USER"),
579 password: variable("SNOWBOUND_TEST_SMB_PASSWORD"),
580 domain: String::new(),
581 };
582 let client = server(&address).connect().unwrap();
583 let root = format!("snowbound-test-mobile-{}", std::process::id());
584 client.create_directory(&root).unwrap();
585 let source = corpus("media-edit/candidate/Features.one");
586 client
587 .create(
588 &format!("{root}/Features.one"),
589 &std::fs::read(&source).unwrap(),
590 )
591 .unwrap();
592 let cache = tempfile::tempdir().unwrap();
593 let library = Arc::new(Library::server(server(&address), &root, cache.path()).unwrap());
594 assert_eq!(library.tabs().unwrap()[0].path, "Features.one");
595 let section = open(&library, "Features.one");
596 let mut canvas = canvas(&section, "Paragraph controls");
597 focus(&mut canvas, "Collapsed parent");
598 canvas.insert("Shared ".into()).unwrap();
599 section
600 .shared
601 .apply(canvas.edit().unwrap().unwrap())
602 .unwrap();
603 assert!(section.flush(Duration::from_secs(30)));
604 let published = client
605 .read(&format!("{root}/Features.one"), 1 << 26)
606 .unwrap();
607 let arena = onestore::Arena::default();
608 let stored = onestore::Section::open(&arena, published).unwrap();
609 assert!(canvas::search::page_text(&stored.page(canvas.space).unwrap()).contains("Shared "));
610 let shared = Arc::clone(&section.shared);
611 drop((canvas, section, library));
612 Arc::into_inner(shared).unwrap().section.close().unwrap();
613 // The same notebook while its server does not answer: a closed port on the lab's host.
614 let host = address.split(':').next().unwrap();
615 let mut unreachable = server(&format!("{host}:9"));
616 unreachable.share = "agent".into();
617 let listing = |address: &str| {
618 cache.path().join("smb").join(format!(
619 "{}.json",
620 format!("{address}/agent/{root}").replace(|c: char| !c.is_alphanumeric(), "_")
621 ))
622 };
623 std::fs::copy(listing(&address), listing(&format!("{host}:9"))).unwrap();
624 notebook::location::moved(
625 cache.path(),
626 &notebook::location::smb(&address, "agent", &root),
627 &notebook::location::smb(&format!("{host}:9"), "agent", &root),
628 )
629 .unwrap();
630 let offline = Arc::new(Library::server(unreachable, &root, cache.path()).unwrap());
631 assert_eq!(offline.tabs().unwrap()[0].path, "Features.one");
632 let reopened = open(&offline, "Features.one");
633 let page = reopened
634 .shared
635 .page(space(&reopened, "Paragraph controls"))
636 .unwrap()
637 .0;
638 assert!(canvas::search::page_text(&page).contains("Shared "));
639 client.delete(&format!("{root}/Features.one")).unwrap();
640 client.delete(&root).unwrap();
641}
642
643#[test]
644fn a_share_notebook_syncs_only_its_readable_sections() {
645 let (directory, root) = copy("native-encrypted/cold-encrypted-02/notebook");
646 std::fs::copy(
647 corpus("m6/native-features-01/notebook/Empty.one"),
648 root.join("Empty.one"),
649 )
650 .unwrap();
651 let notebook =
652 notebook::session::Notebook::open(&root, directory.path().join("cache")).unwrap();
653 let mut files = std::collections::BTreeMap::new();
654 library::files(notebook.catalog(), &mut files);
655 assert_eq!(files.keys().collect::<Vec<_>>(), ["Empty.one"]);
656}
657
658/// Paths the test coordinator was asked for, with whether to write.
659static COORDINATED: Mutex<Vec<(String, bool)>> = Mutex::new(Vec::new());
660
661extern "C" fn coordinator(
662 path: *const c_char,
663 write: bool,
664 body: extern "C" fn(*mut c_void),
665 context: *mut c_void,
666) {
667 COORDINATED.lock().unwrap().push((string(path), write));
668 body(context);
669}
670
671#[test]
672fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {
673 library::sb_set_coordinator(coordinator);
674 let (directory, root) = copy("m6/native-features-01/notebook");
675 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
676 let section = open(&library, "Features.one");
677 let mut canvas = canvas(&section, "Paragraph controls");
678 focus(&mut canvas, "Collapsed parent");
679 canvas.insert("Coordinated ".into()).unwrap();
680 section
681 .shared
682 .apply(canvas.edit().unwrap().unwrap())
683 .unwrap();
684 assert!(section.flush(Duration::from_secs(20)));
685 let file = root.canonicalize().unwrap().join("Features.one");
686 let file = file.to_string_lossy();
687 let coordinated = COORDINATED.lock().unwrap();
688 assert!(
689 coordinated
690 .iter()
691 .any(|(path, write)| path == &*file && !write)
692 );
693 assert!(
694 coordinated
695 .iter()
696 .any(|(path, write)| path == &*file && *write)
697 );
698}
699
700#[test]
701fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
702 let (_directory, section) = features();
703 let mut canvas = canvas(&section, "Paragraph controls");
704 // The first view point, scanning down the page, that lands on the date.
705 let date = (0..400)
706 .flat_map(|y| (0..130).map(move |x| [x as f32 * 3.0, y as f32 * 3.0]))
707 .find(|point| {
708 matches!(
709 canvas.page.hit(canvas.device(*point)),
710 Some(Hit::Date(DateField::Date))
711 )
712 })
713 .expect("the page shows its date");
714 canvas.press(date).unwrap();
715 canvas.release().unwrap();
716 assert_eq!(canvas.asked.take(), Some(DateField::Date));
717 let before = canvas.page.editor.date().unwrap().timestamp();
718 // 2 January 2026, 10:30 UTC.
719 assert!(
720 canvas
721 .change_date(
722 1_767_349_800,
723 ["Friday, January 2, 2026".into(), "10:30 AM".into()]
724 )
725 .unwrap()
726 );
727 let edit = canvas.edit().unwrap().unwrap();
728 assert!(edit.ops.iter().any(|op| matches!(
729 op,
730 Op::Page {
731 op: PageOp::Date { .. },
732 ..
733 }
734 )));
735 section.shared.apply(edit).unwrap();
736 let stored = section.shared.page(canvas.space).unwrap().0.date_text();
737 assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026");
738 let after = canvas.page.editor.date().unwrap().timestamp();
739 assert_ne!(after, before);
740 // `sb_view_date_request` hands the host the date in its seconds.
741 assert_eq!(library::unix(after), 1_767_349_800);
742}
743
744#[test]
745fn pages_open_with_the_snap_default_font_and_markdown_options_set() {
746 // The defaults, so pages other tests open meanwhile see no change.
747 sb_set_snap_to_grid(true);
748 unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) };
749 sb_set_markdown_shortcuts(true);
750 assert_eq!(
751 *options(),
752 (true, canvas::editor::DefaultFont::default(), true)
753 );
754 let (_directory, section) = features();
755 let canvas = canvas(&section, "Paragraph controls");
756 assert!(canvas.page.snap_to_grid);
757 assert_eq!(canvas.page.editor.default_font.face, "Calibri");
758 assert!(canvas.page.editor.markdown.is_some());
759}
760
761#[test]
762fn a_share_notebook_names_its_files_from_the_share_s_top() {
763 assert_eq!(library::on_share("", "Notes.one"), "Notes.one");
764 assert_eq!(
765 library::on_share("Team/Book", "Group/Notes.one"),
766 "Team/Book/Group/Notes.one"
767 );
768}
769
770/// A notebook copy with its library, and a section of it open for editing.
771fn notebook_open(path: &str) -> (tempfile::TempDir, PathBuf, Arc<Library>, Section) {
772 let (directory, root) = copy("m6/native-features-01/notebook");
773 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
774 let section = open(&library, path);
775 (directory, root, library, section)
776}
777
778/// Waits up to ten seconds for `ready`.
779fn eventually(mut ready: impl FnMut() -> bool) -> bool {
780 let deadline = Instant::now() + Duration::from_secs(10);
781 while Instant::now() < deadline {
782 if ready() {
783 return true;
784 }
785 std::thread::sleep(Duration::from_millis(50));
786 }
787 false
788}
789
790#[test]
791fn sync_status_lists_every_section_and_work_offline_holds_edits_until_sync_now() {
792 let (_directory, root, library, section) = notebook_open("Features.one");
793 // Every readable section, the open one's from its session.
794 assert!(eventually(|| {
795 let status = library.sync_status();
796 status.len()
797 == library
798 .tabs()
799 .unwrap()
800 .iter()
801 .filter(|tab| tab.readable)
802 .count()
803 && status
804 .iter()
805 .all(|sync| sync.state == SyncState::UpToDate as u8)
806 }));
807 sb_library_set_offline(&library, true);
808 let mut canvas = canvas(&section, "Paragraph controls");
809 focus(&mut canvas, "Collapsed parent");
810 canvas.insert("Offline ".into()).unwrap();
811 section
812 .shared
813 .apply(canvas.edit().unwrap().unwrap())
814 .unwrap();
815 let file = root.join("Features.one");
816 let queued = |library: &Library| {
817 library
818 .sync_status()
819 .into_iter()
820 .find(|sync| sync.path == "Features.one")
821 .unwrap()
822 .queued
823 };
824 assert!(eventually(|| queued(&library) == 1));
825 std::thread::sleep(Duration::from_millis(500));
826 assert!(!stored_text(&file, canvas.space).contains("Offline "));
827 sb_library_sync_now(&library);
828 assert!(eventually(|| queued(&library) == 0));
829 assert!(stored_text(&file, canvas.space).contains("Offline "));
830 sb_library_set_offline(&library, false);
831}
832
833#[test]
834fn sections_open_the_first_time_while_the_background_syncs() {
835 let (directory, root) = copy("m6/native-features-01/notebook");
836 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
837 let rounds = {
838 let library = Arc::clone(&library);
839 std::thread::spawn(move || {
840 for _ in 0..200 {
841 sb_library_sync_now(&library);
842 std::thread::sleep(Duration::from_millis(2));
843 }
844 })
845 };
846 for tab in library
847 .tabs()
848 .unwrap()
849 .into_iter()
850 .filter(|tab| tab.readable)
851 {
852 open(&library, &tab.path);
853 }
854 rounds.join().unwrap();
855 for tab in library
856 .tabs()
857 .unwrap()
858 .into_iter()
859 .filter(|tab| tab.readable)
860 {
861 open(&library, &tab.path);
862 }
863}
864
865#[test]
866fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {
867 let (directory, file) = copy("structural-probe/tag-gallery.one");
868 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
869 let path = file.to_string_lossy().into_owned();
870 let section = open(&library, &path);
871 let tagged = library.tagged().unwrap();
872 assert!(tagged.len() >= 9, "{}", tagged.len());
873 assert!(
874 tagged
875 .iter()
876 .any(|tag| tag.name == "To Do" && tag.shape == 3)
877 );
878 let first = &tagged[0];
879 let space = first.page.parse().unwrap();
880 let (page, _) = section.shared.page(space).unwrap();
881 let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap();
882 assert!(
883 canvas
884 .select_paragraph(first.paragraph.parse().unwrap())
885 .unwrap()
886 );
887 let [start, end] = canvas.selection().unwrap();
888 assert!(canvas.text([start, end]).contains(first.text.trim()));
889}
890
891#[test]
892fn every_tag_applies_and_reports_and_remove_tag_clears_them() {
893 let (_directory, section) = features();
894 let mut canvas = canvas(&section, "Paragraph controls");
895 focus(&mut canvas, "Collapsed parent");
896 for index in [0, 12, 28] {
897 assert!(canvas.format(16 + index).unwrap());
898 assert_ne!(canvas.format_bits().unwrap() & 1 << (16 + index), 0);
899 }
900 assert!(canvas.format(8).unwrap());
901 assert_eq!(canvas.format_bits().unwrap() >> 16, 0);
902 assert!(canvas.format(16 + NoteTag::defaults().len() as u8).is_err());
903}
904
905#[test]
906fn tag_icons_draw_their_art_and_highlighting_tags_have_none() {
907 let mut rgba = vec![0; 32 * 32 * 4];
908 for tag in NoteTag::defaults() {
909 let drawn = unsafe { sb_tag_icon(tag.shape, true, 32, rgba.as_mut_ptr()) };
910 assert_eq!(drawn, tag.shape != 0, "{tag:?}");
911 if drawn {
912 assert!(rgba.chunks_exact(4).any(|pixel| pixel[3] > 0), "{tag:?}");
913 }
914 }
915}
916
917#[test]
918fn page_colour_rule_lines_and_art_are_stored_and_undo() {
919 let (_directory, section) = features();
920 let mut canvas = canvas(&section, "Paragraph controls");
921 assert!(canvas.set_paper(Some(0xfef5ed), Some(1)).unwrap());
922 assert!(canvas.set_art(Some("Bamboo")).unwrap());
923 section
924 .shared
925 .apply(canvas.edit().unwrap().unwrap())
926 .unwrap();
927 let art = |page: &Page| {
928 page.objects
929 .iter()
930 .filter(|object| {
931 matches!(object, onestore::page::PageObject::Image(image) if image.background)
932 })
933 .count()
934 };
935 let stored = section.shared.page(canvas.space).unwrap().0;
936 assert_eq!(stored.color, Some(0xfef5ed));
937 assert_eq!(stored.rule_lines, Some(canvas::template::RULE_LINES[1].1));
938 assert_eq!(art(&stored), 1);
939 assert!(canvas.set_art(None).unwrap());
940 assert!(moved(canvas.page.undo(false).unwrap()));
941 assert!(moved(canvas.page.undo(false).unwrap()));
942 section
943 .shared
944 .apply(canvas.edit().unwrap().unwrap())
945 .unwrap();
946 let stored = section.shared.page(canvas.space).unwrap().0;
947 assert_eq!((stored.color, art(&stored)), (Some(0xfef5ed), 0));
948}
949
950#[test]
951fn insert_space_turns_the_next_touch_into_a_drag() {
952 let (_directory, section) = features();
953 let mut canvas = canvas(&section, "Paragraph controls");
954 assert_eq!(canvas.target([300.0, 1500.0]), Target::Page);
955 let _ = canvas.page.insert_space();
956 assert_eq!(canvas.target([300.0, 1500.0]), Target::Grip);
957}
958
959/// The page's drawings as the section stores them.
960fn stored_ink(section: &Section, space: ExGuid) -> Vec<onestore::page::Ink> {
961 let (page, _) = section.shared.page(space).unwrap();
962 page.objects
963 .into_iter()
964 .filter_map(|object| match object {
965 onestore::page::PageObject::Ink(ink) => Some(ink),
966 _ => None,
967 })
968 .collect()
969}
970
971/// A press, drags through `points` and a release, and the edit it made stored.
972fn trace(canvas: &mut Canvas, section: &Section, points: &[[f32; 2]]) {
973 canvas.press(points[0]).unwrap();
974 for &point in &points[1..] {
975 canvas.drag(point).unwrap();
976 }
977 canvas.release().unwrap();
978 if let Some(edit) = canvas.edit().unwrap() {
979 section.shared.apply(edit).unwrap();
980 }
981}
982
983fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> {
984 (0..=20)
985 .map(|step| {
986 let t = step as f32 / 20.0;
987 [
988 from[0] + (to[0] - from[0]) * t,
989 from[1] + (to[1] - from[1]) * t,
990 ]
991 })
992 .collect()
993}
994
995/// The tab colour of a section that stores none.
996const SECTION: u32 = 0x00e4_a88a;
997
998#[test]
999fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
1000 let (_directory, section) = features();
1001 let mut canvas = canvas(&section, "Paragraph controls");
1002 let space = canvas.space;
1003 let before = stored_ink(&section, space).len();
1004 // The red pen, second in OneNote's gallery after the section's accent.
1005 canvas.set_tool(1, 2, SECTION).unwrap();
1006 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0]));
1007 let drawn = stored_ink(&section, space);
1008 assert_eq!(drawn.len(), before + 1);
1009 let stroke = &drawn.last().unwrap().strokes[0];
1010 assert_eq!(stroke.color, Some(0x241ced));
1011 assert!(stroke.points.len() > 2);
1012 // A highlighter stroke is a drawing of its own.
1013 canvas.set_tool(1, 6, SECTION).unwrap();
1014 trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0]));
1015 assert_eq!(stored_ink(&section, space).len(), before + 2);
1016 assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9));
1017
1018 // The lasso picks the pen stroke; a touch on it then drags it.
1019 canvas.set_tool(3, 0, SECTION).unwrap();
1020 let lasso = [
1021 [80.0, 580.0],
1022 [320.0, 580.0],
1023 [320.0, 700.0],
1024 [80.0, 700.0],
1025 [80.0, 582.0],
1026 ];
1027 trace(&mut canvas, &section, &lasso);
1028 let frame = canvas.ink_selection().unwrap();
1029 assert!(frame[2] > 150.0 && frame[3] > 40.0, "{frame:?}");
1030 let on = [200.0, 630.0];
1031 assert_eq!(canvas.target(on), Target::Grip);
1032 trace(&mut canvas, &section, &[on, [230.0, 645.0], [260.0, 660.0]]);
1033 let moved = stored_ink(&section, space);
1034 let layout = &moved[before].layout;
1035 assert!(layout.x.is_some_and(|x| x > 0.0), "{layout:?}");
1036 let _ = canvas
1037 .page
1038 .key(&Key::Named(NamedKey::Backspace), None)
1039 .unwrap();
1040 section
1041 .shared
1042 .apply(canvas.edit().unwrap().unwrap())
1043 .unwrap();
1044 assert_eq!(stored_ink(&section, space).len(), before + 1);
1045 assert!(canvas.ink_selection().is_none());
1046
1047 // The eraser takes the highlighter's stroke it crosses; undo brings it back.
1048 canvas.set_tool(2, 0, SECTION).unwrap();
1049 trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0]));
1050 assert_eq!(stored_ink(&section, space).len(), before);
1051 let _ = canvas.page.undo(false).unwrap();
1052 section
1053 .shared
1054 .apply(canvas.edit().unwrap().unwrap())
1055 .unwrap();
1056 assert_eq!(stored_ink(&section, space).len(), before + 1);
1057}
1058
1059#[test]
1060fn the_accent_pen_and_shapes_draw_as_the_desktop_and_a_cancelled_sweep_stores_nothing() {
1061 let (_directory, section) = features();
1062 let mut canvas = canvas(&section, "Paragraph controls");
1063 let space = canvas.space;
1064 let before = stored_ink(&section, space).len();
1065 let accent = pens(canvas::gpu::colorref(SECTION))[0];
1066 canvas.set_tool(1, 0, SECTION).unwrap();
1067 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 600.0]));
1068 let drawn = stored_ink(&section, space);
1069 assert_eq!(drawn.last().unwrap().strokes[0].color, accent.color);
1070 assert!(accent.color.is_some());
1071 // An oval dragged out is one drawing that keeps its shape.
1072 canvas.set_tool(4, 3, SECTION).unwrap();
1073 trace(&mut canvas, &section, &line([100.0, 700.0], [300.0, 800.0]));
1074 let drawn = stored_ink(&section, space);
1075 assert_eq!(drawn.len(), before + 2);
1076 assert!(drawn.last().unwrap().shape.is_some());
1077
1078 // An eraser sweep a second finger cancels gives back what it took and stores nothing.
1079 canvas.set_tool(2, 0, SECTION).unwrap();
1080 canvas.press([200.0, 580.0]).unwrap();
1081 canvas.drag([200.0, 620.0]).unwrap();
1082 assert!(canvas.page.editor.ink_extent(&[drawn[before].id]).is_none());
1083 let _ = canvas.page.cancel_ink().unwrap();
1084 assert!(canvas.edit().unwrap().is_none());
1085 assert!(canvas.page.editor.ink_extent(&[drawn[before].id]).is_some());
1086 assert!(!canvas.page.editor.can_redo());
1087 assert_eq!(stored_ink(&section, space).len(), before + 2);
1088}
1089
1090/// Queues an offline edit to Features.one and closes its session, leaving the edit to the
1091/// background; the replicas in the cache, and the text edited in.
1092fn unpublished_edit() -> (tempfile::TempDir, PathBuf, Arc<Library>, ExGuid) {
1093 let (directory, root, library, section) = notebook_open("Features.one");
1094 sb_library_set_offline(&library, true);
1095 let mut canvas = canvas(&section, "Paragraph controls");
1096 focus(&mut canvas, "Collapsed parent");
1097 canvas.insert("Unpublished ".into()).unwrap();
1098 section
1099 .shared
1100 .apply(canvas.edit().unwrap().unwrap())
1101 .unwrap();
1102 assert!(eventually(|| !section
1103 .shared
1104 .section
1105 .pending()
1106 .unwrap()
1107 .is_empty()));
1108 let space = canvas.space;
1109 drop(canvas);
1110 drop(section);
1111 (directory, root, library, space)
1112}
1113
1114fn replicas(directory: &tempfile::TempDir) -> usize {
1115 let Ok(folders) = std::fs::read_dir(directory.path().join("cache/replicas")) else {
1116 return 0;
1117 };
1118 folders
1119 .filter_map(|folder| std::fs::read_dir(folder.ok()?.path()).ok())
1120 .flatten()
1121 .filter(|entry| {
1122 entry
1123 .as_ref()
1124 .is_ok_and(|entry| entry.path().extension() == Some("sqlite".as_ref()))
1125 })
1126 .count()
1127}
1128
1129#[test]
1130fn closing_publishes_a_pending_edit_so_the_notebook_renames_without_orphans() {
1131 let (directory, root, library, space) = unpublished_edit();
1132 library.close(Duration::from_secs(20)).unwrap();
1133 assert!(stored_text(&root.join("Features.one"), space).contains("Unpublished "));
1134 drop(library);
1135 assert!(eventually(|| replicas(&directory) == 0));
1136 let renamed = directory.path().join("Renamed");
1137 std::fs::rename(&root, &renamed).unwrap();
1138 let library = Arc::new(Library::open(&renamed, &directory.path().join("cache"), true).unwrap());
1139 let section = open(&library, "Features.one");
1140 let canvas = canvas(&section, "Paragraph controls");
1141 assert!(stored_text(&renamed.join("Features.one"), canvas.space).contains("Unpublished "));
1142}
1143
1144#[test]
1145fn closing_refuses_while_an_edit_cannot_publish_then_deletes_nothing_pending() {
1146 let (directory, root, library, space) = unpublished_edit();
1147 let away = directory.path().join("Away");
1148 std::fs::rename(&root, &away).unwrap();
1149 assert!(library.close(Duration::from_secs(2)).is_err());
1150 assert!(replicas(&directory) > 0);
1151 std::fs::rename(&away, &root).unwrap();
1152 library.close(Duration::from_secs(20)).unwrap();
1153 assert!(stored_text(&root.join("Features.one"), space).contains("Unpublished "));
1154 drop(library);
1155 std::fs::remove_dir_all(&root).unwrap();
1156 assert!(eventually(|| replicas(&directory) == 0));
1157}
1158
1159/// Print and Export as PDF make a PDF of the open page on the paper asked for.
1160#[test]
1161fn the_page_prints_as_a_pdf() {
1162 let (_directory, section) = features();
1163 let title = section.shared.section.pages().unwrap()[0].1.clone();
1164 let pdf = canvas(&section, &title)
1165 .pdf(canvas::print::A4, "Features")
1166 .unwrap();
1167 assert!(pdf.starts_with(b"%PDF"));
1168 let text = String::from_utf8_lossy(&pdf);
1169 assert!(
1170 text.contains("/MediaBox [0 0 595.276 841.89]"),
1171 "{}",
1172 &text[..400]
1173 );
1174}
1175
1176#[test]
1177fn a_recording_goes_in_as_the_desktop_stores_it_and_a_tap_plays_it() {
1178 use onestore::page::{PageObject, ParagraphContent};
1179 let (directory, section) = features();
1180 let mut canvas = canvas(&section, "Paragraph controls");
1181 focus(&mut canvas, "Collapsed parent");
1182 canvas.edit().unwrap();
1183 let date = ["Tuesday, September 29, 2026", "5:18 PM"];
1184 assert!(canvas.start_recording(false, date[0], date[1]).unwrap());
1185 canvas.insert("Linked note".into()).unwrap();
1186 let samples: Vec<i16> = (0..16_000).map(|n| (n % 64 * 256 - 8192) as i16).collect();
1187 let wav = canvas::recording::wave(canvas::recording::RATE, &samples);
1188 assert!(canvas.finish_recording(Some(wav), false).unwrap());
1189 section
1190 .shared
1191 .apply(canvas.edit().unwrap().unwrap())
1192 .unwrap();
1193
1194 let page = section.shared.page(canvas.space).unwrap().0;
1195 let paragraphs: Vec<_> = page
1196 .objects
1197 .iter()
1198 .filter_map(|object| match object {
1199 PageObject::Outline(outline) => Some(&outline.paragraphs),
1200 _ => None,
1201 })
1202 .flatten()
1203 .collect();
1204 let file = paragraphs
1205 .iter()
1206 .find_map(|paragraph| match &paragraph.content {
1207 ParagraphContent::Attachment(file) => Some(file),
1208 _ => None,
1209 })
1210 .unwrap();
1211 let recording = file.recording.unwrap();
1212 assert_eq!(file.filename, format!("{}.wav", page.title));
1213 assert_eq!((recording.kind, recording.duration_ms), (1, Some(1000)));
1214 // IMA ADPCM, as the desktop stores audio for OneNote (`corpus/recording`).
1215 let bytes = file.bytes.as_deref().unwrap();
1216 assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 0x11);
1217 let linked = |text: &str| {
1218 paragraphs
1219 .iter()
1220 .find(|paragraph| {
1221 paragraph
1222 .text()
1223 .is_some_and(|t| t.text.text().starts_with(text))
1224 })
1225 .map(|paragraph| (paragraph.media.recordings.clone(), paragraph.media.time_ms))
1226 .unwrap()
1227 };
1228 let line = linked("Audio recording started: 5:18 PM Tuesday, September 29, 2026");
1229 assert_eq!(line, (vec![recording.id], Some(0)));
1230 assert_eq!(linked("Linked note").0, [recording.id]);
1231
1232 // A tap on the recording plays it as PCM from the start.
1233 let tapped = (0..800)
1234 .flat_map(|y| (0..300).map(move |x| [x as f32 * 2.0, y as f32 * 2.0]))
1235 .find(|&point| {
1236 matches!(
1237 canvas.page.hit(canvas.device(point.map(|v| v * POINT))),
1238 Some(Hit::File(_))
1239 )
1240 })
1241 .unwrap()
1242 .map(|v| v * POINT);
1243 canvas.press(tapped).unwrap();
1244 canvas.release().unwrap();
1245 let folder = directory.path().join("playback");
1246 let request = canvas.take_play(&folder).unwrap().unwrap();
1247 assert_eq!(request["at_ms"], 0);
1248 assert!(request["video"].is_null());
1249 let sound = std::fs::read(request["sound"].as_str().unwrap()).unwrap();
1250 assert_eq!(u16::from_le_bytes([sound[20], sound[21]]), 1);
1251 assert!(canvas.take_play(&folder).unwrap().is_none());
1252 canvas.played(Some(5_000)).unwrap();
1253 canvas.played(None).unwrap();
1254}
1255
1256#[test]
1257fn a_movie_from_the_camera_becomes_the_desktop_s_avi() {
1258 let movie = recording::sb_movie_new();
1259 let bgra: Vec<u8> = (0..480 * 640 * 4).map(|n| (n % 251) as u8).collect();
1260 for index in 0..20u64 {
1261 unsafe {
1262 recording::sb_movie_picture(
1263 &mut *movie,
1264 index * 50_000,
1265 bgra.as_ptr(),
1266 640,
1267 480,
1268 640 * 4,
1269 )
1270 };
1271 }
1272 let pcm = vec![0u8; canvas::recording::RATE as usize * 2];
1273 unsafe { recording::sb_movie_sound(&mut *movie, pcm.as_ptr(), pcm.len()) };
1274 let mut length = 0;
1275 let avi = unsafe { recording::sb_movie_finish(movie, 1_000_000, &mut length) };
1276 let bytes = unsafe { std::slice::from_raw_parts(avi, length) }.to_vec();
1277 unsafe { sb_bytes_free(avi, length) };
1278 let parsed = canvas::recording::video::Movie::parse(&bytes).unwrap();
1279 assert_eq!((parsed.frames.len(), parsed.duration_ms()), (15, 1000));
1280 assert!(parsed.sound.is_some());
1281}
1282
1283fn levels(section: &Section) -> Vec<(ExGuid, u32)> {
1284 let pages = section.shared.section.pages().unwrap();
1285 pages
1286 .into_iter()
1287 .map(|(space, _, level)| (space, level))
1288 .collect()
1289}
1290
1291#[test]
1292fn pages_drag_indent_and_undo_as_one_edit_each() {
1293 let (_directory, _root, _library, section) = notebook_open("Features.one");
1294 let before = levels(&section);
1295 assert!(before.len() > 2);
1296 let last = before.last().unwrap().0;
1297 let mut dragged = before.clone();
1298 let moved = dragged.pop().unwrap();
1299 dragged.insert(0, moved);
1300 let revisions = section.shared.section.pending().unwrap().len();
1301 assert!(section.arrange(&dragged, &[last]).unwrap());
1302 assert_eq!(levels(&section), dragged);
1303 assert_eq!(
1304 section.shared.section.pending().unwrap().len(),
1305 revisions + 1
1306 );
1307 let second = dragged[1].0;
1308 let mut indented = dragged.clone();
1309 indented[1].1 = 2;
1310 assert!(section.arrange(&indented, &[second]).unwrap());
1311 assert_eq!(levels(&section), indented);
1312 assert!(section.arrange(&dragged, &[second]).unwrap());
1313 assert!(section.arrange(&before, &[last]).unwrap());
1314 assert_eq!(levels(&section), before);
1315 assert!(!section.arrange(&before, &[ExGuid::default()]).unwrap());
1316}
1317
1318#[test]
1319fn a_page_moves_to_another_section_and_back_where_it_was() {
1320 let (_directory, root, library, features) = notebook_open("Features.one");
1321 let before = levels(&features);
1322 let (space, level) = before[1];
1323 let title = features.shared.section.page(space).unwrap().title;
1324 let date = ["Thursday, October 1, 2026", "9:00 AM"];
1325 let moved = library
1326 .move_page(
1327 "Features.one",
1328 space,
1329 "Empty.one",
1330 None,
1331 1,
1332 None,
1333 "Clover Test",
1334 date,
1335 )
1336 .unwrap();
1337 let arrived: ExGuid = moved.page.parse().unwrap();
1338 assert_eq!(moved.added, None);
1339 assert_eq!(moved.before, Some(before[2].0.to_string()));
1340 assert_eq!(moved.level, level);
1341 assert!(levels(&features).iter().all(|(page, _)| *page != space));
1342 let empty = open(&library, "Empty.one");
1343 let listed = empty.shared.section.pages().unwrap();
1344 assert_eq!(listed.last().unwrap(), &(arrived, title.clone(), 1));
1345 drop(empty);
1346 let back = library
1347 .move_page(
1348 "Empty.one",
1349 arrived,
1350 "Features.one",
1351 moved.before.map(|before| before.parse().unwrap()),
1352 moved.level,
1353 None,
1354 "Clover Test",
1355 date,
1356 )
1357 .unwrap()
1358 .page
1359 .parse()
1360 .unwrap();
1361 let mut returned = before.clone();
1362 returned[1].0 = back;
1363 assert_eq!(levels(&features), returned);
1364 assert_eq!(features.shared.section.page(back).unwrap().title, title);
1365 drop(features);
1366 library.close(Duration::from_secs(20)).unwrap();
1367 let arena = onestore::Arena::default();
1368 let file = onestore::read_file(root.join("Features.one")).unwrap();
1369 let stored = onestore::Section::open(&arena, file).unwrap();
1370 assert_eq!(stored.page(back).unwrap().title, title);
1371}
1372
1373#[test]
1374fn a_sections_last_page_moving_leaves_a_page_that_undo_takes_out() {
1375 let (_directory, _root, library, features) = notebook_open("Features.one");
1376 let date = ["Thursday, October 1, 2026", "9:02 AM"];
1377 let page = features.new_page(None, date[0], date[1]).unwrap();
1378 let arrived: ExGuid = library
1379 .move_page(
1380 "Features.one",
1381 page,
1382 "Empty.one",
1383 None,
1384 1,
1385 None,
1386 "Clover Test",
1387 date,
1388 )
1389 .unwrap()
1390 .page
1391 .parse()
1392 .unwrap();
1393 assert_eq!(levels(&open(&library, "Empty.one")), [(arrived, 1)]);
1394 let returned = library
1395 .move_page(
1396 "Empty.one",
1397 arrived,
1398 "Features.one",
1399 None,
1400 1,
1401 None,
1402 "Clover Test",
1403 date,
1404 )
1405 .unwrap();
1406 let back = returned.page.parse().unwrap();
1407 let added: ExGuid = returned.added.unwrap().parse().unwrap();
1408 assert_eq!(levels(&open(&library, "Empty.one")), [(added, 1)]);
1409 library
1410 .move_page(
1411 "Features.one",
1412 back,
1413 "Empty.one",
1414 None,
1415 1,
1416 Some(added),
1417 "Clover Test",
1418 date,
1419 )
1420 .unwrap();
1421 assert_eq!(levels(&open(&library, "Empty.one")).len(), 1);
1422}
1423
1424#[test]
1425fn sections_reorder_and_move_between_groups_and_back() {
1426 let (_directory, root, library, features) = notebook_open("Features.one");
1427 drop(features);
1428 let top = |library: &Library| -> Vec<String> {
1429 let tabs = library.tabs().unwrap();
1430 tabs.into_iter()
1431 .filter(|tab| tab.group.is_empty())
1432 .map(|tab| tab.path)
1433 .collect()
1434 };
1435 let before = top(&library);
1436 let mut reversed = before.clone();
1437 reversed.reverse();
1438 assert_eq!(
1439 library.place("Empty.one", "", &reversed).unwrap(),
1440 "Empty.one"
1441 );
1442 assert_eq!(top(&library), reversed);
1443 assert_eq!(
1444 library.place("Empty.one", "Group A", &[]).unwrap(),
1445 "Group A/Empty.one"
1446 );
1447 assert!(root.join("Group A/Empty.one").exists());
1448 let tabs = library.tabs().unwrap();
1449 let grouped: Vec<&str> = tabs
1450 .iter()
1451 .filter(|tab| tab.group == "Group A")
1452 .map(|tab| tab.path.as_str())
1453 .collect();
1454 assert_eq!(grouped.last(), Some(&"Group A/Empty.one"));
1455 assert_eq!(
1456 library.place("Group A/Empty.one", "", &before).unwrap(),
1457 "Empty.one"
1458 );
1459 assert_eq!(top(&library), before);
1460 assert!(root.join("Empty.one").exists());
1461 open(&library, "Empty.one");
1462}