1#[path = "support/ops.rs"]
2mod ops;
3use onestore::{
4 ExGuid, RevisionIndex, Store,
5 document::{Document, Format},
6 page::{
7 Page, PageObject, PageParagraph, ParagraphContent, Table, TableCell, TableColumn, TableRow,
8 TextObject, text::new_id,
9 },
10};
11
12const TREES: &[u8] =
13 include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one");
14
15fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) {
16 let store = Store::parse(bytes).unwrap();
17 assert!(store.checksum_mismatches.is_empty());
18 let index = RevisionIndex::parse(&store).unwrap();
19 let document = Document::parse(&index).unwrap();
20 document
21 .pages()
22 .unwrap()
23 .into_iter()
24 .find_map(|(space, _)| {
25 let page = Page::from_space(&document, space).unwrap();
26 (page.title == title).then_some((space, page))
27 })
28 .unwrap()
29}
30
31fn page_in(bytes: &[u8], space: ExGuid) -> Page {
32 let store = Store::parse(bytes).unwrap();
33 assert!(store.checksum_mismatches.is_empty());
34 let index = RevisionIndex::parse(&store).unwrap();
35 Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
36}
37
38fn body_paragraphs(page: &mut Page) -> &mut Vec<PageParagraph> {
39 page.objects
40 .iter_mut()
41 .find_map(|object| match object {
42 PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs),
43 _ => None,
44 })
45 .unwrap()
46}
47
48fn table_mut(page: &mut Page) -> &mut Table {
49 body_paragraphs(page)
50 .iter_mut()
51 .find_map(|p| match &mut p.content {
52 ParagraphContent::Table(table) => Some(table),
53 _ => None,
54 })
55 .unwrap()
56}
57
58/// Definitions are read back only while a paragraph references them.
59fn referenced(page: &Page) -> std::collections::BTreeSet<ExGuid> {
60 fn walk(paragraphs: &[PageParagraph], out: &mut std::collections::BTreeSet<ExGuid>) {
61 for paragraph in paragraphs {
62 out.extend(paragraph.lists.iter().copied());
63 out.extend(paragraph.style);
64 out.extend(paragraph.tags.iter().filter_map(|t| t.definition));
65 match &paragraph.content {
66 ParagraphContent::Text(text) => {
67 out.extend(text.tags.iter().filter_map(|t| t.definition));
68 }
69 ParagraphContent::Table(table) => {
70 for row in &table.rows {
71 for cell in &row.cells {
72 walk(&cell.paragraphs, out);
73 }
74 }
75 }
76 ParagraphContent::Image(_)
77 | ParagraphContent::Attachment(_)
78 | ParagraphContent::Ink(_)
79 | ParagraphContent::Unsupported(_) => {}
80 }
81 }
82 }
83 let mut out = std::collections::BTreeSet::new();
84 for object in &page.objects {
85 match object {
86 PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out),
87 PageObject::Title(title) => {
88 for outline in &title.outlines {
89 walk(&outline.paragraphs, &mut out);
90 }
91 }
92 _ => {}
93 }
94 }
95 out
96}
97
98/// A new cell without its own or a sibling's indentation table takes OneNote's.
99fn with_native_indents(table: &mut Table) {
100 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
101 cell.indents = vec![18.0, 0.0, 27.0, 27.0];
102 }
103}
104
105fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page {
106 let stored = page_in(written, space);
107 let mut expected = expected.clone();
108 expected.title = stored.title.clone();
109 let live = referenced(&expected);
110 expected.definitions.retain(|id, _| live.contains(id));
111 assert_eq!(stored, expected);
112 stored
113}
114
115fn plain_text(text: &str) -> TextObject {
116 TextObject {
117 id: new_id().unwrap(),
118 date_field: None,
119 text: onestore::page::Paragraph::new(
120 text.into(),
121 Format {
122 font: Some("Calibri".into()),
123 font_size: Some(11.0),
124 language: Some(1033),
125 ..Default::default()
126 },
127 ),
128 tags: Vec::new(),
129 }
130}
131
132fn cell_paragraph(template: &PageParagraph, text: &str) -> PageParagraph {
133 let mut paragraph = template.clone();
134 paragraph.id = new_id().unwrap();
135 paragraph.parent = None;
136 paragraph.level = 1;
137 paragraph.lists.clear();
138 paragraph.tags.clear();
139 paragraph.style = None;
140 paragraph.collapsed = false;
141 paragraph.content = ParagraphContent::Text(plain_text(text));
142 paragraph
143}
144
145fn new_cell(template: &TableCell, text: &str) -> TableCell {
146 TableCell {
147 id: new_id().unwrap(),
148 layout: Default::default(),
149 indents: template.indents.clone(),
150 shading: None,
151 paragraphs: vec![cell_paragraph(&template.paragraphs[0], text)],
152 unsupported: Vec::new(),
153 }
154}
155
156#[test]
157fn a_row_and_a_column_are_added_to_a_native_table_and_removed_again() {
158 let (space, before) = page_by_title(TREES, "Delete cell subtree");
159 let mut after = before.clone();
160 let table = table_mut(&mut after);
161 assert_eq!((table.rows.len(), table.columns.len()), (1, 2));
162 let template = table.rows[0].cells[0].clone();
163 table.columns.push(TableColumn {
164 width: 120.0,
165 locked: false,
166 });
167 table.rows[0]
168 .cells
169 .push(new_cell(&template, "Third column"));
170 table.rows.push(TableRow {
171 id: new_id().unwrap(),
172 cells: vec![
173 new_cell(&template, "Second row"),
174 new_cell(&template, "Second row, second column"),
175 new_cell(&template, "Second row, third column"),
176 ],
177 });
178 let written = ops::saved(TREES, space, &after).unwrap();
179 let stored = assert_same(written.as_slice(), space, &after);
180 assert_eq!(
181 ops::saved(written.as_slice(), space, &stored)
182 .unwrap()
183 .as_slice(),
184 written.as_slice()
185 );
186 if let Some(directory) = std::env::var_os("ONESTORE_TABLE_EDIT_EXPORT") {
187 let directory = std::path::PathBuf::from(directory);
188 std::fs::create_dir(&directory).unwrap();
189 std::fs::write(directory.join("synthetic.one"), written.as_slice()).unwrap();
190 let written_store = Store::parse(written.as_slice()).unwrap();
191 std::fs::write(
192 directory.join("Open Notebook.onetoc2"),
193 onestore::create_table_of_contents(
194 "Open Notebook.onetoc2",
195 &[("synthetic.one", written_store.header.file_id)],
196 )
197 .unwrap(),
198 )
199 .unwrap();
200 }
201 let mut smaller = stored.clone();
202 let table = table_mut(&mut smaller);
203 table.columns.remove(1);
204 for row in &mut table.rows {
205 row.cells.remove(1);
206 }
207 table.rows.remove(0);
208 let again = ops::saved(written.as_slice(), space, &smaller).unwrap();
209 let mut stored = assert_same(again.as_slice(), space, &smaller);
210 let table = table_mut(&mut stored);
211 assert_eq!((table.rows.len(), table.columns.len()), (1, 2));
212}
213
214#[test]
215fn column_widths_locks_and_borders_change_in_place() {
216 let (space, before) = page_by_title(TREES, "Delete cell subtree");
217 let mut after = before.clone();
218 let table = table_mut(&mut after);
219 table.columns[0].width = 300.0;
220 table.columns[1].locked = false;
221 table.borders = Some(false);
222 let written = ops::saved(TREES, space, &after).unwrap();
223 assert_same(written.as_slice(), space, &after);
224}
225
226#[test]
227fn cell_shading_and_indents_change_in_place() {
228 let (space, before) = page_by_title(TREES, "Delete cell subtree");
229 let mut after = before.clone();
230 let table = table_mut(&mut after);
231 let cell = &mut table.rows[0].cells[0];
232 assert_eq!(cell.shading, None);
233 cell.shading = Some(0x0000ffff);
234 let original_indents = cell.indents.clone();
235 cell.indents.clear();
236 assert!(ops::saved(TREES, space, &after).is_err());
237 let cell = &mut table_mut(&mut after).rows[0].cells[0];
238 cell.indents = vec![18.0, 0.0, 27.0, 27.0];
239 let written = ops::saved(TREES, space, &after).unwrap();
240 let stored = assert_same(written.as_slice(), space, &after);
241 let mut reset = stored.clone();
242 let cell = &mut table_mut(&mut reset).rows[0].cells[0];
243 cell.shading = None;
244 cell.indents = original_indents;
245 let again = ops::saved(written.as_slice(), space, &reset).unwrap();
246 assert_eq!(assert_same(again.as_slice(), space, &reset), before);
247}
248
249/// `ONESTORE_NESTED_TABLE_EXPORT` names a new directory receiving the candidate for a cold reopen.
250#[test]
251fn a_table_nests_inside_a_cell_and_is_removed_again() {
252 let (space, before) = page_by_title(TREES, "Delete cell subtree");
253 let mut after = before.clone();
254 let template = table_mut(&mut after).rows[0].cells[0].paragraphs[0].clone();
255 let cell = |text: &str| TableCell {
256 id: new_id().unwrap(),
257 layout: Default::default(),
258 indents: Vec::new(),
259 shading: None,
260 paragraphs: vec![cell_paragraph(&template, text)],
261 unsupported: Vec::new(),
262 };
263 let nested = Table {
264 id: new_id().unwrap(),
265 columns: vec![TableColumn {
266 width: 72.0,
267 locked: true,
268 }],
269 rows: vec![
270 TableRow {
271 id: new_id().unwrap(),
272 cells: vec![cell("Inner one")],
273 },
274 TableRow {
275 id: new_id().unwrap(),
276 cells: vec![cell("Inner two")],
277 },
278 ],
279 borders: Some(true),
280 layout: Default::default(),
281 tags: Vec::new(),
282 };
283 let mut holder = cell_paragraph(&template, "");
284 holder.content = ParagraphContent::Table(nested);
285 holder.format = Format::default();
286 let holder_id = holder.id;
287 table_mut(&mut after).rows[0].cells[1]
288 .paragraphs
289 .push(holder);
290 let written = ops::saved(TREES, space, &after).unwrap();
291 let ParagraphContent::Table(nested) = &mut table_mut(&mut after).rows[0].cells[1]
292 .paragraphs
293 .last_mut()
294 .unwrap()
295 .content
296 else {
297 panic!()
298 };
299 with_native_indents(nested);
300 let stored = assert_same(written.as_slice(), space, &after);
301 if let Some(directory) = std::env::var_os("ONESTORE_NESTED_TABLE_EXPORT") {
302 let directory = std::path::PathBuf::from(directory);
303 std::fs::create_dir(&directory).unwrap();
304 std::fs::write(directory.join("synthetic.one"), written.as_slice()).unwrap();
305 let written_store = Store::parse(written.as_slice()).unwrap();
306 std::fs::write(
307 directory.join("Open Notebook.onetoc2"),
308 onestore::create_table_of_contents(
309 "Open Notebook.onetoc2",
310 &[("synthetic.one", written_store.header.file_id)],
311 )
312 .unwrap(),
313 )
314 .unwrap();
315 }
316 let mut removed = stored.clone();
317 table_mut(&mut removed).rows[0].cells[1]
318 .paragraphs
319 .retain(|paragraph| paragraph.id != holder_id);
320 let again = ops::saved(written.as_slice(), space, &removed).unwrap();
321 assert_eq!(assert_same(again.as_slice(), space, &removed), before);
322}
323
324#[test]
325fn inconsistent_tables_are_refused() {
326 let (space, before) = page_by_title(TREES, "Delete cell subtree");
327 let mut ragged = before.clone();
328 let template = table_mut(&mut ragged).rows[0].cells[0].clone();
329 table_mut(&mut ragged).rows[0]
330 .cells
331 .push(new_cell(&template, "Extra"));
332 assert!(ops::saved(TREES, space, &ragged).is_err());
333 // A cell keeps a paragraph.
334 let mut empty = before.clone();
335 table_mut(&mut empty).rows[0].cells[0].paragraphs.clear();
336 assert!(ops::saved(TREES, space, &empty).is_err());
337 let mut narrow = before.clone();
338 table_mut(&mut narrow).columns[0].width = 10.0;
339 assert!(ops::saved(TREES, space, &narrow).is_err());
340 let mut rowless = before.clone();
341 table_mut(&mut rowless).rows.clear();
342 assert!(ops::saved(TREES, space, &rowless).is_err());
343}
344
345/// `ONESTORE_TABLE_EXPORT` names a new directory receiving the candidate for a cold reopen.
346#[test]
347fn a_new_table_is_created_on_a_fresh_page() {
348 let source = onestore::create_section("tables.one", "Before the table", "Author").unwrap();
349 let store = Store::parse(&source).unwrap();
350 let index = RevisionIndex::parse(&store).unwrap();
351 let document = Document::parse(&index).unwrap();
352 let (space, _) = document.pages().unwrap()[0];
353 let before = Page::from_space(&document, space).unwrap();
354 let mut after = before.clone();
355 let template = body_paragraphs(&mut after)[0].clone();
356 let cell = |text: &str| TableCell {
357 id: new_id().unwrap(),
358 layout: Default::default(),
359 indents: Vec::new(),
360 shading: None,
361 paragraphs: vec![cell_paragraph(&template, text)],
362 unsupported: Vec::new(),
363 };
364 let table = Table {
365 id: new_id().unwrap(),
366 columns: vec![
367 TableColumn {
368 width: 144.0,
369 locked: true,
370 },
371 TableColumn {
372 width: 216.0,
373 locked: true,
374 },
375 ],
376 rows: vec![
377 TableRow {
378 id: new_id().unwrap(),
379 cells: vec![cell("Name"), cell("Value")],
380 },
381 TableRow {
382 id: new_id().unwrap(),
383 cells: vec![cell("Rust 🦀"), cell("Written natively")],
384 },
385 ],
386 borders: Some(true),
387 layout: Default::default(),
388 tags: Vec::new(),
389 };
390 let mut holder = cell_paragraph(&template, "");
391 holder.content = ParagraphContent::Table(table);
392 holder.format = Format::default();
393 body_paragraphs(&mut after).push(holder);
394 body_paragraphs(&mut after).push(cell_paragraph(&template, "After the table"));
395 let written = ops::saved(&source, space, &after).unwrap();
396 with_native_indents(table_mut(&mut after));
397 assert_same(written.as_slice(), space, &after);
398 if let Some(directory) = std::env::var_os("ONESTORE_TABLE_EXPORT") {
399 let directory = std::path::PathBuf::from(directory);
400 std::fs::create_dir(&directory).unwrap();
401 std::fs::write(directory.join("tables.one"), written.as_slice()).unwrap();
402 let written_store = Store::parse(written.as_slice()).unwrap();
403 std::fs::write(
404 directory.join("Open Notebook.onetoc2"),
405 onestore::create_table_of_contents(
406 "Open Notebook.onetoc2",
407 &[("tables.one", written_store.header.file_id)],
408 )
409 .unwrap(),
410 )
411 .unwrap();
412 }
413}