| 1 | use super::*; |
| 2 | use library::{sb_library_set_offline, sb_library_sync_now}; |
| 3 | use notebook::session::SyncState; |
| 4 | use std::path::{Path, PathBuf}; |
| 5 | |
| 6 | const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus"); |
| 7 | |
| 8 | fn 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. |
| 14 | fn 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 | |
| 28 | fn open(library: &Arc<Library>, path: &str) -> Section { |
| 29 | Section::open(Arc::clone(library), path, "Clover Test".into(), || {}).unwrap() |
| 30 | } |
| 31 | |
| 32 | fn 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. |
| 46 | fn 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 | |
| 54 | fn 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. |
| 62 | fn 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. |
| 76 | fn 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] |
| 83 | fn 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] |
| 108 | fn 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] |
| 138 | fn 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] |
| 163 | fn 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] |
| 194 | fn 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] |
| 206 | fn 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] |
| 230 | fn 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] |
| 257 | fn 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. |
| 283 | fn 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] |
| 290 | fn 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] |
| 321 | fn 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] |
| 374 | fn 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] |
| 398 | fn 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] |
| 432 | fn 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] |
| 455 | fn 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] |
| 471 | fn 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] |
| 523 | fn 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"] |
| 572 | fn 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] |
| 644 | fn 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. |
| 659 | static COORDINATED: Mutex<Vec<(String, bool)>> = Mutex::new(Vec::new()); |
| 660 | |
| 661 | extern "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] |
| 672 | fn 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] |
| 701 | fn 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] |
| 745 | fn 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] |
| 762 | fn 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. |
| 771 | fn 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`. |
| 779 | fn 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] |
| 791 | fn 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] |
| 834 | fn 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] |
| 866 | fn 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] |
| 892 | fn 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] |
| 906 | fn 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] |
| 918 | fn 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] |
| 951 | fn 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. |
| 960 | fn 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. |
| 972 | fn 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 | |
| 983 | fn 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. |
| 996 | const SECTION: u32 = 0x00e4_a88a; |
| 997 | |
| 998 | #[test] |
| 999 | fn 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] |
| 1060 | fn 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. |
| 1092 | fn 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 | |
| 1114 | fn 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] |
| 1130 | fn 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] |
| 1145 | fn 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] |
| 1161 | fn 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] |
| 1177 | fn 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] |
| 1257 | fn 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 | |
| 1283 | fn 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] |
| 1292 | fn 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] |
| 1319 | fn 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] |
| 1374 | fn 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] |
| 1425 | fn 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 | } |