| 1 | //! A rewritten object stores the global id table entries it names, not the table of the |
| 2 | //! revision it was read from. |
| 3 | |
| 4 | #[path = "support/ops.rs"] |
| 5 | mod ops; |
| 6 | |
| 7 | use onestore::{ |
| 8 | RevisionIndex, Store, |
| 9 | document::Document, |
| 10 | page::{Page, PageObject, Paragraph}, |
| 11 | }; |
| 12 | |
| 13 | const SOURCE: &[u8] = include_bytes!("../../../corpus/native/20260905-05/notebook/synthetic.one"); |
| 14 | |
| 15 | fn pages(bytes: &[u8]) -> Vec<(onestore::ExGuid, Page)> { |
| 16 | let store = Store::parse(bytes).unwrap(); |
| 17 | let index = RevisionIndex::parse(&store).unwrap(); |
| 18 | let document = Document::parse(&index).unwrap(); |
| 19 | document |
| 20 | .pages() |
| 21 | .unwrap() |
| 22 | .into_iter() |
| 23 | .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) |
| 24 | .collect() |
| 25 | } |
| 26 | |
| 27 | /// Table entries per object group written past `from`, as `(entries, highest index + 1)`. |
| 28 | fn tables(bytes: &[u8], from: usize) -> Vec<(usize, u32)> { |
| 29 | let store = Store::parse(bytes).unwrap(); |
| 30 | store |
| 31 | .lists |
| 32 | .values() |
| 33 | .filter(|list| list.nodes.first().is_some_and(|node| node.offset >= from)) |
| 34 | .map(|list| { |
| 35 | let entries: Vec<u32> = list |
| 36 | .nodes |
| 37 | .iter() |
| 38 | .filter(|node| node.id == 0x24) |
| 39 | .map(|node| u32::from_le_bytes(node.payload[..4].try_into().unwrap())) |
| 40 | .collect(); |
| 41 | (entries.len(), entries.iter().max().map_or(0, |max| max + 1)) |
| 42 | }) |
| 43 | .filter(|(entries, _)| *entries > 0) |
| 44 | .collect() |
| 45 | } |
| 46 | |
| 47 | /// `ONESTORE_TABLE_EXPORT` names a directory receiving the edited notebook for a cold reopen. |
| 48 | #[test] |
| 49 | fn a_text_edit_stores_the_table_entries_it_names() { |
| 50 | let mut bytes = SOURCE.to_vec(); |
| 51 | let mut sparse = false; |
| 52 | let mut edited = Vec::new(); |
| 53 | for (space, mut page) in pages(SOURCE) { |
| 54 | let Some(text) = page |
| 55 | .objects |
| 56 | .iter_mut() |
| 57 | .filter_map(|object| match object { |
| 58 | PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), |
| 59 | _ => None, |
| 60 | }) |
| 61 | .flatten() |
| 62 | .find_map(|paragraph| paragraph.text_mut()) |
| 63 | else { |
| 64 | continue; |
| 65 | }; |
| 66 | let format = text.text.format_at(0).unwrap().clone(); |
| 67 | text.text |
| 68 | .append(Paragraph::new(" and a few more words".to_owned(), format)) |
| 69 | .unwrap(); |
| 70 | let written = ops::saved(&bytes, space, &page) |
| 71 | .unwrap() |
| 72 | .as_slice() |
| 73 | .to_vec(); |
| 74 | for (entries, span) in tables(&written, bytes.len()) { |
| 75 | sparse |= (entries as u32) < span; |
| 76 | } |
| 77 | assert_eq!( |
| 78 | ops::saved(&written, space, &page).unwrap().as_slice(), |
| 79 | written |
| 80 | ); |
| 81 | bytes = written; |
| 82 | edited.push((space, page)); |
| 83 | } |
| 84 | // The fixture's pages were written over several sessions, so a text edit names a |
| 85 | // subset of its revision's table and the stored indices have gaps. |
| 86 | assert!(sparse); |
| 87 | let stored = pages(&bytes); |
| 88 | for (space, page) in &edited { |
| 89 | let (_, stored) = stored.iter().find(|(id, _)| id == space).unwrap(); |
| 90 | assert!(stored.objects == page.objects); |
| 91 | } |
| 92 | if let Some(export) = std::env::var_os("ONESTORE_TABLE_EXPORT") { |
| 93 | let export = std::path::Path::new(&export); |
| 94 | std::fs::create_dir_all(export).unwrap(); |
| 95 | std::fs::write(export.join("synthetic.one"), &bytes).unwrap(); |
| 96 | std::fs::copy( |
| 97 | "../../corpus/native/20260905-05/notebook/Open Notebook.onetoc2", |
| 98 | export.join("Open Notebook.onetoc2"), |
| 99 | ) |
| 100 | .unwrap(); |
| 101 | } |
| 102 | } |