| 1 | #[path = "support/ops.rs"] |
| 2 | mod ops; |
| 3 | use onestore::{ |
| 4 | ExGuid, RevisionIndex, Store, |
| 5 | document::{Document, Format, Kind}, |
| 6 | page::{ |
| 7 | Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id, |
| 8 | }, |
| 9 | }; |
| 10 | use std::collections::BTreeMap; |
| 11 | |
| 12 | const TREES: &[u8] = |
| 13 | include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); |
| 14 | |
| 15 | fn page_by_title(bytes: &[u8], title: &str) -> (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 | .find_map(|(space, _)| { |
| 24 | let page = Page::from_space(&document, space).unwrap(); |
| 25 | (page.title == title).then_some((space, page)) |
| 26 | }) |
| 27 | .unwrap() |
| 28 | } |
| 29 | |
| 30 | fn page_in(bytes: &[u8], space: ExGuid) -> Page { |
| 31 | let store = Store::parse(bytes).unwrap(); |
| 32 | let index = RevisionIndex::parse(&store).unwrap(); |
| 33 | Page::from_space(&Document::parse(&index).unwrap(), space).unwrap() |
| 34 | } |
| 35 | |
| 36 | fn body_paragraphs(page: &mut Page) -> &mut Vec<PageParagraph> { |
| 37 | page.objects |
| 38 | .iter_mut() |
| 39 | .find_map(|object| match object { |
| 40 | PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), |
| 41 | _ => None, |
| 42 | }) |
| 43 | .unwrap() |
| 44 | } |
| 45 | |
| 46 | fn list_ids(page: &Page) -> Vec<Vec<ExGuid>> { |
| 47 | page.objects |
| 48 | .iter() |
| 49 | .filter_map(|object| match object { |
| 50 | PageObject::Outline(outline) if !outline.title => { |
| 51 | Some(outline.paragraphs.iter().map(|p| p.lists.clone()).collect()) |
| 52 | } |
| 53 | _ => None, |
| 54 | }) |
| 55 | .next() |
| 56 | .unwrap() |
| 57 | } |
| 58 | |
| 59 | fn raw_object(bytes: &[u8], space: ExGuid, id: ExGuid) -> String { |
| 60 | let store = Store::parse(bytes).unwrap(); |
| 61 | let index = RevisionIndex::parse(&store).unwrap(); |
| 62 | format!( |
| 63 | "{:?}", |
| 64 | index.resolve_active(space).unwrap().objects[&id].data |
| 65 | ) |
| 66 | } |
| 67 | |
| 68 | fn bullet_definition() -> Definition { |
| 69 | Definition { |
| 70 | kind: Kind::List { |
| 71 | font: Some("Courier New".into()), |
| 72 | format: Some("\u{25cb}".into()), |
| 73 | restart: None, |
| 74 | bullet: Some(4), |
| 75 | }, |
| 76 | format: Format { |
| 77 | font_size: Some(11.0), |
| 78 | color: Some(0xff000000), |
| 79 | ..Default::default() |
| 80 | }, |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | fn plain_paragraph(template: &PageParagraph, text: &str) -> PageParagraph { |
| 85 | let mut paragraph = template.clone(); |
| 86 | paragraph.id = new_id().unwrap(); |
| 87 | paragraph.parent = None; |
| 88 | paragraph.level = 1; |
| 89 | paragraph.lists.clear(); |
| 90 | paragraph.tags.clear(); |
| 91 | paragraph.style = None; |
| 92 | paragraph.collapsed = false; |
| 93 | paragraph.content = ParagraphContent::Text(TextObject { |
| 94 | id: new_id().unwrap(), |
| 95 | date_field: None, |
| 96 | text: onestore::page::Paragraph::new( |
| 97 | text.into(), |
| 98 | Format { |
| 99 | font: Some("Calibri".into()), |
| 100 | font_size: Some(11.0), |
| 101 | language: Some(1033), |
| 102 | ..Default::default() |
| 103 | }, |
| 104 | ), |
| 105 | tags: Vec::new(), |
| 106 | }); |
| 107 | paragraph |
| 108 | } |
| 109 | |
| 110 | #[test] |
| 111 | fn a_numbered_paragraph_becomes_plain_while_its_neighbours_keep_their_nodes() { |
| 112 | let (space, before) = page_by_title(TREES, "Move numbered subtree down"); |
| 113 | let lists = list_ids(&before); |
| 114 | let numbered: Vec<usize> = (0..lists.len()).filter(|i| !lists[*i].is_empty()).collect(); |
| 115 | assert!(numbered.len() >= 2, "{lists:?}"); |
| 116 | let mut after = before.clone(); |
| 117 | body_paragraphs(&mut after)[numbered[0]].lists.clear(); |
| 118 | let written = ops::saved(TREES, space, &after).unwrap(); |
| 119 | let stored = page_in(written.as_slice(), space); |
| 120 | let mut expected = after.clone(); |
| 121 | expected.title = stored.title.clone(); |
| 122 | expected.definitions.remove(&lists[numbered[0]][0]); |
| 123 | assert_eq!(stored, expected); |
| 124 | for other in &numbered[1..] { |
| 125 | for node in &lists[*other] { |
| 126 | assert_eq!( |
| 127 | raw_object(written.as_slice(), space, *node), |
| 128 | raw_object(TREES, space, *node) |
| 129 | ); |
| 130 | } |
| 131 | } |
| 132 | assert_eq!( |
| 133 | ops::saved(written.as_slice(), space, &stored) |
| 134 | .unwrap() |
| 135 | .as_slice(), |
| 136 | written.as_slice() |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | #[test] |
| 141 | fn bullets_and_numbering_write_from_definitions_and_read_back() { |
| 142 | let (space, before) = page_by_title(TREES, "Move numbered subtree down"); |
| 143 | let lists = list_ids(&before); |
| 144 | let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); |
| 145 | let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); |
| 146 | let mut after = before.clone(); |
| 147 | let bullet = new_id().unwrap(); |
| 148 | after.definitions.insert(bullet, bullet_definition()); |
| 149 | let template = body_paragraphs(&mut after)[plain].clone(); |
| 150 | body_paragraphs(&mut after)[plain].lists = vec![bullet]; |
| 151 | let number = new_id().unwrap(); |
| 152 | let mut numbering = after.definitions[&lists[numbered][0]].clone(); |
| 153 | let Kind::List { restart, .. } = &mut numbering.kind else { |
| 154 | panic!() |
| 155 | }; |
| 156 | *restart = Some(3); |
| 157 | after.definitions.insert(number, numbering); |
| 158 | let mut fresh = plain_paragraph(&template, "Numbered from three"); |
| 159 | fresh.lists = vec![number]; |
| 160 | body_paragraphs(&mut after).push(fresh); |
| 161 | let written = ops::saved(TREES, space, &after).unwrap(); |
| 162 | let stored = page_in(written.as_slice(), space); |
| 163 | let mut expected = after.clone(); |
| 164 | expected.title = stored.title.clone(); |
| 165 | assert_eq!(stored, expected); |
| 166 | assert_eq!( |
| 167 | ops::saved(written.as_slice(), space, &stored) |
| 168 | .unwrap() |
| 169 | .as_slice(), |
| 170 | written.as_slice() |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | #[test] |
| 175 | fn a_list_definition_changes_in_place() { |
| 176 | let (space, before) = page_by_title(TREES, "Move numbered subtree down"); |
| 177 | let lists = list_ids(&before); |
| 178 | let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); |
| 179 | let node = lists[numbered][0]; |
| 180 | let mut after = before.clone(); |
| 181 | let definition = after.definitions.get_mut(&node).unwrap(); |
| 182 | let Kind::List { restart, .. } = &mut definition.kind else { |
| 183 | panic!() |
| 184 | }; |
| 185 | *restart = Some(7); |
| 186 | definition.format.bold = Some(true); |
| 187 | let written = ops::saved(TREES, space, &after).unwrap(); |
| 188 | let stored = page_in(written.as_slice(), space); |
| 189 | let mut expected = after.clone(); |
| 190 | expected.title = stored.title.clone(); |
| 191 | assert_eq!(stored, expected); |
| 192 | assert_eq!(list_ids(&stored)[numbered], vec![node]); |
| 193 | } |
| 194 | |
| 195 | #[test] |
| 196 | fn a_definition_shared_between_paragraphs_becomes_a_node_per_paragraph() { |
| 197 | let (space, before) = page_by_title(TREES, "Move numbered subtree down"); |
| 198 | let lists = list_ids(&before); |
| 199 | let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); |
| 200 | let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); |
| 201 | let mut shared = before.clone(); |
| 202 | body_paragraphs(&mut shared)[plain].lists = lists[numbered].clone(); |
| 203 | let written = ops::saved(TREES, space, &shared).unwrap(); |
| 204 | let stored = page_in(written.as_slice(), space); |
| 205 | let after = list_ids(&stored); |
| 206 | assert_eq!(after[numbered], lists[numbered]); |
| 207 | assert_eq!(after[plain].len(), 1); |
| 208 | assert_ne!(after[plain], lists[numbered]); |
| 209 | assert_eq!( |
| 210 | stored.definitions[&after[plain][0]], |
| 211 | before.definitions[&lists[numbered][0]] |
| 212 | ); |
| 213 | } |
| 214 | |
| 215 | #[test] |
| 216 | fn missing_and_foreign_list_definitions_are_refused() { |
| 217 | let (space, before) = page_by_title(TREES, "Move numbered subtree down"); |
| 218 | let lists = list_ids(&before); |
| 219 | let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); |
| 220 | let mut missing = before.clone(); |
| 221 | body_paragraphs(&mut missing)[plain].lists = vec![new_id().unwrap()]; |
| 222 | assert!(ops::saved(TREES, space, &missing).is_err()); |
| 223 | let mut foreign = before.clone(); |
| 224 | let paragraph = body_paragraphs(&mut foreign)[plain].id; |
| 225 | foreign.definitions.insert(paragraph, bullet_definition()); |
| 226 | body_paragraphs(&mut foreign)[plain].lists = vec![paragraph]; |
| 227 | assert!(ops::saved(TREES, space, &foreign).is_err()); |
| 228 | let mut style: BTreeMap<ExGuid, Definition> = before.definitions.clone(); |
| 229 | if let Some((id, definition)) = style |
| 230 | .iter_mut() |
| 231 | .find(|(_, d)| matches!(d.kind, Kind::Style { .. })) |
| 232 | { |
| 233 | definition.format.bold = Some(true); |
| 234 | let mut edited = before.clone(); |
| 235 | edited.definitions.insert(*id, definition.clone()); |
| 236 | assert!(ops::saved(TREES, space, &edited).is_err()); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | /// A fresh page gains a bullet, two numbered paragraphs with a nested numbered child, a |
| 241 | /// numbering restart and a trailing plain paragraph; `ONESTORE_LIST_EXPORT` names a new |
| 242 | /// directory receiving the candidate for a cold native reopen. |
| 243 | #[test] |
| 244 | fn bullets_numbering_nesting_and_restarts_publish_on_a_fresh_page() { |
| 245 | let source = onestore::create_section("lists.one", "Bullet item", "Author").unwrap(); |
| 246 | let store = Store::parse(&source).unwrap(); |
| 247 | let index = RevisionIndex::parse(&store).unwrap(); |
| 248 | let document = Document::parse(&index).unwrap(); |
| 249 | let (space, _) = document.pages().unwrap()[0]; |
| 250 | let before = Page::from_space(&document, space).unwrap(); |
| 251 | let (_, trees) = page_by_title(TREES, "Move numbered subtree down"); |
| 252 | let numbering = trees |
| 253 | .definitions |
| 254 | .values() |
| 255 | .find(|d| { |
| 256 | matches!( |
| 257 | d.kind, |
| 258 | Kind::List { |
| 259 | format: Some(_), |
| 260 | .. |
| 261 | } |
| 262 | ) |
| 263 | }) |
| 264 | .unwrap() |
| 265 | .clone(); |
| 266 | let scenario = std::env::var("ONESTORE_LIST_SCENARIO").unwrap_or_else(|_| "all".into()); |
| 267 | let wants = |name: &str| scenario == "all" || scenario == name; |
| 268 | let mut after = before.clone(); |
| 269 | let template = body_paragraphs(&mut after)[0].clone(); |
| 270 | if wants("bullet") { |
| 271 | let bullet = new_id().unwrap(); |
| 272 | after.definitions.insert(bullet, bullet_definition()); |
| 273 | body_paragraphs(&mut after)[0].lists = vec![bullet]; |
| 274 | } |
| 275 | for (text, level, restart, name) in [ |
| 276 | ("First numbered", 1, None, "numbered"), |
| 277 | ("Second numbered", 1, None, "numbered"), |
| 278 | ("Nested numbered", 2, None, "all"), |
| 279 | ("Restarted at three", 1, Some(3), "restart"), |
| 280 | ("Nested plain", 2, None, "nested"), |
| 281 | ] { |
| 282 | if !wants(name) { |
| 283 | continue; |
| 284 | } |
| 285 | let mut paragraph = plain_paragraph(&template, text); |
| 286 | if name != "nested" { |
| 287 | let id = new_id().unwrap(); |
| 288 | let mut definition = numbering.clone(); |
| 289 | let Kind::List { restart: value, .. } = &mut definition.kind else { |
| 290 | panic!() |
| 291 | }; |
| 292 | *value = restart; |
| 293 | after.definitions.insert(id, definition); |
| 294 | paragraph.lists = vec![id]; |
| 295 | } |
| 296 | paragraph.level = level; |
| 297 | if level == 2 { |
| 298 | paragraph.parent = Some(body_paragraphs(&mut after).last().unwrap().id); |
| 299 | } |
| 300 | body_paragraphs(&mut after).push(paragraph); |
| 301 | } |
| 302 | body_paragraphs(&mut after).push(plain_paragraph(&template, "Plain again")); |
| 303 | let written = ops::saved(&source, space, &after).unwrap(); |
| 304 | let stored = page_in(written.as_slice(), space); |
| 305 | let mut expected = after.clone(); |
| 306 | expected.title = stored.title.clone(); |
| 307 | assert_eq!(stored, expected); |
| 308 | if let Some(directory) = std::env::var_os("ONESTORE_LIST_EXPORT") { |
| 309 | let directory = std::path::PathBuf::from(directory); |
| 310 | std::fs::create_dir(&directory).unwrap(); |
| 311 | std::fs::write(directory.join("lists.one"), written.as_slice()).unwrap(); |
| 312 | let written_store = Store::parse(written.as_slice()).unwrap(); |
| 313 | std::fs::write( |
| 314 | directory.join("Open Notebook.onetoc2"), |
| 315 | onestore::create_table_of_contents( |
| 316 | "Open Notebook.onetoc2", |
| 317 | &[("lists.one", written_store.header.file_id)], |
| 318 | ) |
| 319 | .unwrap(), |
| 320 | ) |
| 321 | .unwrap(); |
| 322 | } |
| 323 | } |