1//! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it
2//! reported after each keystroke: each outline is built from, and compared with, the
3//! one-line-per-paragraph summaries in `corpus/structural-probe/summaries/`, whose `L` counts
4//! paragraph nesting.
5
6use super::format::{ListStyle, NoteTag, list_definition};
7use super::*;
8use onestore::document::Tag;
9use onestore::page::ParagraphContent;
10use onestore::page::text::new_id;
11
12fn summary(name: &str) -> String {
13 std::fs::read_to_string(format!(
14 "{}/../../corpus/structural-probe/summaries/{name}.txt",
15 env!("CARGO_MANIFEST_DIR")
16 ))
17 .unwrap()
18 .replace("\r\n", "\n")
19}
20
21fn calibri(size: f32, bold: bool) -> Format {
22 Format {
23 font: Some("Calibri".into()),
24 font_size: Some(size),
25 bold: Some(bold),
26 ..Format::default()
27 }
28}
29
30/// The quick styles a summary's `qs0` and `qs1` name.
31const STYLES: [&str; 2] = ["p", "h1"];
32
33/// An editor holding one outline shaped as a summary describes it.
34fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
35 let mut definitions = BTreeMap::new();
36 let mut ids = BTreeMap::new();
37 let mut define = |key: String, definition: Definition| {
38 *ids.entry(key).or_insert_with(|| {
39 let id = new_id().unwrap();
40 definitions.insert(id, definition);
41 id
42 })
43 };
44 let styles = STYLES.map(|name| {
45 define(
46 name.into(),
47 Definition {
48 kind: Kind::Style {
49 name: Some(name.into()),
50 next: (name == "h1").then(|| "p".into()),
51 },
52 format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"),
53 },
54 )
55 });
56 let mut nodes: Vec<PageParagraph> = Vec::new();
57 for line in summary.lines() {
58 let line = line.trim_start().strip_prefix('L').unwrap();
59 let (level, line) = line.split_once(" [").unwrap();
60 let level = level.parse::<u32>().unwrap();
61 let (tags, line) = line.split_once("] [").unwrap();
62 let (list, line) = line.split_once("] ").unwrap();
63 let (style, text) = line.split_once(' ').unwrap();
64 let style = STYLES
65 .iter()
66 .position(|name| style == format!("qs{}", usize::from(*name == "h1")))
67 .map(|index| styles[index]);
68 let format = calibri(11.0, false);
69 let mut node =
70 crate::document::node(Paragraph::new(text.into(), format.clone()), format.clone())
71 .unwrap();
72 node.level = level;
73 node.style = style;
74 node.parent = nodes
75 .iter()
76 .rev()
77 .find(|parent| parent.level + 1 == level)
78 .map(|parent| parent.id);
79 if !list.is_empty() {
80 let definition = list_definition(
81 if list.starts_with("num") {
82 ListStyle::NUMBER
83 } else {
84 ListStyle::BULLET
85 },
86 &format,
87 );
88 node.lists = vec![define(format!("list {}", nodes.len()), definition)];
89 }
90 for tag in tags.split(',').filter(|tag| !tag.is_empty()) {
91 let (label, completed) = match tag.strip_suffix("[x]") {
92 Some(label) => (label, true),
93 None => (tag, false),
94 };
95 let defaults = NoteTag::defaults();
96 let place = defaults.iter().position(|tag| tag.label == label).unwrap();
97 let definition = define(label.into(), defaults[place].definition(place as u16));
98 node.text_mut().unwrap().tags.push(Tag {
99 definition: Some(definition),
100 action_type: None,
101 shape: None,
102 property_status: None,
103 status: u16::from(completed),
104 created: Some(1),
105 completed: Some(u32::from(completed)),
106 start: None,
107 due: None,
108 task_id: None,
109 extra_set: 0,
110 });
111 }
112 nodes.push(node);
113 }
114 let outline = Outline {
115 id: new_id().unwrap(),
116 title: false,
117 min_width: None,
118 layout: onestore::document::Layout {
119 max_width: Some(400.0),
120 ..Default::default()
121 },
122 indents: vec![18.0, 0.0, 27.0, 27.0],
123 paragraphs: nodes,
124 unsupported: Vec::new(),
125 };
126 CanvasEditor::from_outlines(engine, vec![outline], definitions).unwrap()
127}
128
129/// The outline in the summaries' form.
130fn render(editor: &CanvasEditor) -> String {
131 let outline = editor.active_outline();
132 let mut depths = BTreeMap::new();
133 let mut lines = Vec::new();
134 for node in outline.document.nodes() {
135 let depth = node.parent.map_or(1, |parent| depths[&parent] + 1);
136 depths.insert(node.id, depth);
137 let tags = node
138 .tags
139 .iter()
140 .chain(&node.text().unwrap().tags)
141 .map(|tag| {
142 let kind = &editor.definitions[&tag.definition.unwrap()].kind;
143 let Kind::TagDefinition { label, .. } = kind else {
144 panic!("{kind:?}")
145 };
146 let checked = if tag.status & 1 == 1 { "[x]" } else { "" };
147 (
148 NoteTag::of(kind).and_then(|(tag, action_type)| {
149 NoteTag::defaults()
150 .iter()
151 .position(|known| *known == tag)
152 .filter(|place| *place == usize::from(action_type))
153 }),
154 format!("{}{checked}", label.as_deref().unwrap()),
155 )
156 })
157 .collect::<BTreeMap<_, _>>();
158 let list = match node.lists.first().map(|id| &editor.definitions[id].kind) {
159 None => String::new(),
160 Some(Kind::List {
161 bullet: Some(_), ..
162 }) => "bullet".into(),
163 Some(Kind::List {
164 format: Some(format),
165 ..
166 }) => {
167 let (_, rest) = format.split_once('\u{fffd}').unwrap();
168 let mut rest = rest.chars();
169 let number = outline
170 .shaped
171 .paragraphs
172 .iter()
173 .find(|paragraph| paragraph.id == node.id)
174 .and_then(|paragraph| paragraph.number.as_ref())
175 .unwrap()
176 .0
177 .number;
178 let numeral = crate::outline::numeral(rest.next(), number).unwrap();
179 format!("num:{numeral}{}", rest.as_str())
180 }
181 Some(kind) => panic!("{kind:?}"),
182 };
183 let style = node
184 .style
185 .map(|style| match &editor.definitions[&style].kind {
186 Kind::Style { name, .. } => {
187 format!("qs{}", usize::from(name.as_deref() == Some("h1")))
188 }
189 kind => panic!("{kind:?}"),
190 })
191 .unwrap_or_default();
192 lines.push(format!(
193 "{}L{depth} [{}] [{list}] {style} {}",
194 " ".repeat(depth as usize),
195 tags.into_values().collect::<Vec<_>>().join(","),
196 node.text().unwrap().text.text()
197 ));
198 }
199 lines.join("\n").trim_end().to_owned()
200}
201
202#[derive(Clone, Copy)]
203enum Offset {
204 Start,
205 End,
206 /// Characters before the end.
207 Back(u32),
208 /// Characters after the start.
209 Forward(u32),
210}
211
212#[derive(Clone, Copy)]
213enum Key {
214 Enter,
215 Backspace,
216 Delete,
217 Tab,
218 ShiftTab,
219 Type(&'static str),
220 Paste(&'static str, u32),
221 Bullets,
222}
223
224/// Puts the caret at `offset` in paragraph `paragraph`.
225fn place(editor: &mut CanvasEditor, (paragraph, offset): (usize, Offset)) {
226 let text = editor
227 .active_outline()
228 .document
229 .paragraph(paragraph)
230 .unwrap();
231 let length = text.text().encode_utf16().count() as u32;
232 let offset = match offset {
233 Offset::Start => 0,
234 Offset::End => length,
235 Offset::Back(count) => length - count,
236 Offset::Forward(count) => count,
237 };
238 editor
239 .select([TextPosition { paragraph, offset }; 2].into())
240 .unwrap();
241}
242
243fn press(editor: &mut CanvasEditor, engine: &mut TextEngine, key: Key) {
244 match key {
245 Key::Enter => editor.enter(engine, false).unwrap(),
246 Key::Backspace => assert!(editor.delete(engine, true).unwrap()),
247 Key::Delete => assert!(editor.delete(engine, false).unwrap()),
248 Key::Tab => editor.tab(engine, false).unwrap(),
249 Key::ShiftTab => editor.tab(engine, true).unwrap(),
250 Key::Type(text) => editor.insert(engine, text).unwrap(),
251 Key::Paste(text, language) => editor.paste(engine, text, language).unwrap(),
252 Key::Bullets => editor.format(engine, Formatting::Bullets).unwrap(),
253 }
254}
255
256/// Replays `steps` from `caret` in `before`, comparing the outline after each key with the
257/// summary it names; returns the editor for further checks.
258fn replay(
259 engine: &mut TextEngine,
260 before: &str,
261 caret: (usize, Offset),
262 steps: &[(Key, &str)],
263) -> CanvasEditor {
264 let steps = steps
265 .iter()
266 .map(|(key, name)| (*key, summary(name).trim_end().to_owned()))
267 .collect::<Vec<_>>();
268 expect(engine, before, caret, &steps)
269}
270
271/// [`replay`] against outlines given in the summaries' form.
272fn expect(
273 engine: &mut TextEngine,
274 before: &str,
275 caret: (usize, Offset),
276 steps: &[(Key, String)],
277) -> CanvasEditor {
278 let mut editor = open(engine, before);
279 place(&mut editor, caret);
280 let original = editor.active_outline().document.clone();
281 for (key, after) in steps {
282 press(&mut editor, engine, *key);
283 assert_eq!(&render(&editor), after);
284 let outline = editor.active_outline();
285 let fresh = OutlineLayout::flow(
286 outline.document.nodes().iter(),
287 &outline.indents,
288 outline.wrap_width(),
289 true,
290 0,
291 None,
292 &mut |node, previous, width, indents| {
293 ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions)
294 },
295 )
296 .unwrap();
297 let numbers = |layout: &OutlineLayout| {
298 layout
299 .paragraphs
300 .iter()
301 .map(|paragraph| paragraph.number.clone())
302 .collect::<Vec<_>>()
303 };
304 assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}");
305 }
306 let edited = editor.active_outline().document.clone();
307 while editor.undo(engine).unwrap() {}
308 assert_eq!(editor.active_outline().document, original);
309 while editor.redo(engine).unwrap() {}
310 assert_eq!(editor.active_outline().document, edited);
311 editor
312}
313
314/// The run that recorded Enter at the end of a paragraph of `kind`.
315fn first_run(kind: &str) -> &'static str {
316 match kind {
317 "num" | "child" | "tagbullet" => "c1b",
318 _ => "c1",
319 }
320}
321
322/// The setup every single-paragraph case starts from: `Above`, then `Target text` of `kind`.
323fn target(kind: &str) -> String {
324 summary(&format!("{}-{kind}-0", first_run(kind)))
325}
326
327const KINDS: [&str; 9] = [
328 "todo",
329 "done",
330 "imp",
331 "q",
332 "rem",
333 "bullet",
334 "num",
335 "child",
336 "tagbullet",
337];
338
339#[test]
340fn enter_carries_level_lists_and_children_but_no_other_tags() {
341 let mut engine = TextEngine::default();
342 for kind in KINDS.into_iter().filter(|kind| !CHECKED.contains(kind)) {
343 let prefix = first_run(kind);
344 replay(
345 &mut engine,
346 &target(kind),
347 (1, Offset::End),
348 &[
349 (Key::Enter, &format!("{prefix}-{kind}-1")),
350 (Key::Type("New"), &format!("{prefix}-{kind}-2")),
351 ],
352 );
353 replay(
354 &mut engine,
355 &target(kind),
356 (1, Offset::Back(4)),
357 &[
358 (Key::Enter, &format!("c2-{kind}-1")),
359 (Key::Type("X"), &format!("c2-{kind}-2")),
360 ],
361 );
362 replay(
363 &mut engine,
364 &target(kind),
365 (1, Offset::Start),
366 &[
367 (Key::Enter, &format!("c2s-{kind}-1")),
368 (Key::Type("X"), &format!("c2s-{kind}-2")),
369 ],
370 );
371 replay(
372 &mut engine,
373 &summary(&format!("c3-{kind}-0")),
374 (2, Offset::Start),
375 &[
376 (Key::Enter, &format!("c3-{kind}-1")),
377 (Key::Enter, &format!("c3-{kind}-2")),
378 (Key::Type("New"), &format!("c3-{kind}-3")),
379 ],
380 );
381 }
382 replay(
383 &mut engine,
384 &summary("pbe-0"),
385 (2, Offset::Start),
386 &[(Key::Enter, "pbe-1")],
387 );
388 replay(
389 &mut engine,
390 &summary("c8-enter-empty-0"),
391 (1, Offset::End),
392 &[
393 (Key::Enter, "c8-enter-empty-1"),
394 (Key::Enter, "c8-enter-empty-2"),
395 ],
396 );
397 replay(
398 &mut engine,
399 &summary("c8-split-0"),
400 (2, Offset::Forward(1)),
401 &[(Key::Enter, "c8-split-1")],
402 );
403 replay(
404 &mut engine,
405 &summary("c10-enter-end-0"),
406 (1, Offset::End),
407 &[(Key::Enter, "c10-enter-end-1")],
408 );
409}
410
411/// The kinds with a To Do box, whose Enter departs from OneNote 2010's.
412const CHECKED: [&str; 3] = ["todo", "done", "tagbullet"];
413
414/// Enter continues a to-do list with unchecked items and an empty item ends it, as Enter does
415/// a bulleted list; OneNote 2010 gives the new paragraph no tag and keeps an empty item's.
416#[test]
417fn enter_continues_a_to_do_list_unchecked_and_an_empty_item_ends_it() {
418 let mut engine = TextEngine::default();
419 for kind in CHECKED {
420 let (level, tag, list) = match kind {
421 "todo" => (" L1", "To Do", ""),
422 "done" => (" L1", "To Do[x]", ""),
423 _ => (" L2", "To Do", "bullet"),
424 };
425 // Below `Above`, a paragraph per `(tag, list, text)`.
426 let page = |lines: &[(&str, &str, &str)]| {
427 std::iter::once(" L1 [] [] qs0 Above".to_owned())
428 .chain(
429 lines
430 .iter()
431 .map(|(tag, list, text)| format!("{level} [{tag}] [{list}] qs0 {text}")),
432 )
433 .collect::<Vec<_>>()
434 .join("\n")
435 .trim_end()
436 .to_owned()
437 };
438 let mut enter = |caret, [split, typed]: [&[(&str, &str, &str)]; 2], text| {
439 expect(
440 &mut engine,
441 &target(kind),
442 caret,
443 &[(Key::Enter, page(split)), (Key::Type(text), page(typed))],
444 );
445 };
446 enter(
447 (1, Offset::End),
448 [
449 &[(tag, list, "Target text"), ("To Do", list, "")],
450 &[(tag, list, "Target text"), ("To Do", list, "New")],
451 ],
452 "New",
453 );
454 enter(
455 (1, Offset::Back(4)),
456 [
457 &[(tag, list, "Target "), ("To Do", list, "text")],
458 &[(tag, list, "Target "), ("To Do", list, "Xtext")],
459 ],
460 "X",
461 );
462 enter(
463 (1, Offset::Start),
464 [
465 &[("To Do", list, ""), (tag, list, "Target text")],
466 &[("To Do", list, ""), (tag, list, "XTarget text")],
467 ],
468 "X",
469 );
470 let item = (tag, list, "Target text");
471 expect(
472 &mut engine,
473 &summary(&format!("c3-{kind}-0")),
474 (2, Offset::Start),
475 &[
476 (Key::Enter, page(&[item, ("", "", ""), ("", "", "")])),
477 (
478 Key::Enter,
479 page(&[item, ("", "", ""), ("", "", ""), ("", "", "")]),
480 ),
481 ],
482 );
483 }
484}
485
486#[test]
487fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() {
488 let mut engine = TextEngine::default();
489 for kind in KINDS {
490 let steps = ["-1", "-2", "-3"].map(|step| format!("c4-{kind}{step}"));
491 replay(
492 &mut engine,
493 &summary(&format!("c4-{kind}-0")),
494 (2, Offset::Start),
495 &steps.each_ref().map(|step| (Key::Backspace, step.as_str())),
496 );
497 }
498 for pair in [
499 "imp-todo",
500 "plain-todo",
501 "todo-plain",
502 "todo-done",
503 "done-todo",
504 "q-rem",
505 ] {
506 replay(
507 &mut engine,
508 &summary(&format!("c4m-{pair}-0")),
509 (1, Offset::Start),
510 &[(Key::Backspace, &format!("c4m-{pair}-1"))],
511 );
512 }
513 replay(
514 &mut engine,
515 &summary("c4m-emptybullet-0"),
516 (2, Offset::Start),
517 &[
518 (Key::Backspace, "c4m-emptybullet-1"),
519 (Key::Backspace, "c4m-emptybullet-2"),
520 (Key::Backspace, "c4m-emptybullet-3"),
521 ],
522 );
523 replay(
524 &mut engine,
525 &summary("c4m-emptytag-0"),
526 (1, Offset::Start),
527 &[
528 (Key::Backspace, "c4m-emptytag-1"),
529 (Key::Backspace, "c4m-emptytag-2"),
530 ],
531 );
532 replay(
533 &mut engine,
534 &summary("c8-bs-0"),
535 (2, Offset::Start),
536 &[(Key::Backspace, "c8-bs-1"), (Key::Backspace, "c8-bs-2")],
537 );
538 replay(
539 &mut engine,
540 &summary("c10-bs-parent-0"),
541 (1, Offset::Start),
542 &[(Key::Backspace, "c10-bs-parent-1")],
543 );
544}
545
546#[test]
547fn delete_at_the_end_joins_the_next_paragraph_into_this_one() {
548 let mut engine = TextEngine::default();
549 for (pair, paragraph) in [
550 ("imp-todo", 0),
551 ("plain-todo", 0),
552 ("todo-plain", 0),
553 ("done-todo", 0),
554 ("plain-bullet", 0),
555 ("plain-num", 0),
556 ("bullet-plain", 1),
557 ("bullet-bullet", 1),
558 ] {
559 replay(
560 &mut engine,
561 &summary(&format!("c5b-{pair}-0")),
562 (paragraph, Offset::End),
563 &[(Key::Delete, &format!("c5b-{pair}-1"))],
564 );
565 }
566 replay(
567 &mut engine,
568 &summary("c8-del-0"),
569 (1, Offset::End),
570 &[(Key::Delete, "c8-del-1")],
571 );
572 for (name, paragraph) in [("del-alpha", 0), ("del-parent", 1)] {
573 replay(
574 &mut engine,
575 &summary(&format!("c10-{name}-0")),
576 (paragraph, Offset::End),
577 &[(Key::Delete, &format!("c10-{name}-1"))],
578 );
579 }
580}
581
582/// The list definition of the outline's paragraph `index`.
583fn list(editor: &CanvasEditor, index: usize) -> &Kind<'static> {
584 let node = &editor.active_outline().document.nodes()[index];
585 &editor.definitions[&node.lists[0]].kind
586}
587
588#[test]
589fn tab_nests_under_the_previous_sibling_and_shift_tab_adopts_the_siblings_after() {
590 let mut engine = TextEngine::default();
591 for kind in ["plain", "todo", "bullet", "num", "child", "tagbullet"] {
592 let editor = replay(
593 &mut engine,
594 &summary(&format!("c6-{kind}-0")),
595 (1, Offset::Start),
596 &[(Key::Tab, &format!("c6-{kind}-tab"))],
597 );
598 // Without a previous sibling OneNote indents within the group (`<OEChildren indent="2">`
599 // in `c6-{bullet,num,child,tagbullet}-tab.xml`), stepping bullets to ○ and numbers to a.
600 let first_child = matches!(kind, "bullet" | "num" | "child" | "tagbullet");
601 let node = &editor.active_outline().document.nodes()[1];
602 assert_eq!(node.level, if first_child { 3 } else { 2 }, "{kind}");
603 if matches!(kind, "bullet" | "tagbullet") {
604 assert!(matches!(
605 list(&editor, 1),
606 Kind::List { format: Some(glyph), bullet: Some(4), .. } if glyph == "\u{25cb}"
607 ));
608 }
609 replay(
610 &mut engine,
611 &summary(&format!("c6-{kind}-0")),
612 (1, Offset::Start),
613 &[
614 (Key::Tab, &format!("c6-{kind}-tab")),
615 (Key::ShiftTab, &format!("c6-{kind}-stab1")),
616 (Key::ShiftTab, &format!("c6-{kind}-stab2")),
617 ],
618 );
619 }
620 let first = replay(
621 &mut engine,
622 " L1 [] [] qs0 Target text",
623 (0, Offset::Start),
624 &[(Key::Tab, "c6-first-tab")],
625 );
626 assert_eq!(
627 (
628 first.active_outline().document.nodes()[0].level,
629 first.active_outline().document.nodes()[0].parent
630 ),
631 (2, None)
632 );
633 replay(
634 &mut engine,
635 &summary("c8-stab-0"),
636 (2, Offset::Start),
637 &[(Key::ShiftTab, "c8-stab-1")],
638 );
639 replay(
640 &mut engine,
641 &summary("c8-tab-0"),
642 (2, Offset::Start),
643 &[(Key::Tab, "c8-tab-1"), (Key::ShiftTab, "c8-tab-2")],
644 );
645 replay(
646 &mut engine,
647 &summary("c10-stab-child-0"),
648 (2, Offset::Start),
649 &[(Key::ShiftTab, "c10-stab-child-1")],
650 );
651}
652
653#[test]
654fn pasted_lines_are_plain_paragraphs_between_the_halves() {
655 let mut engine = TextEngine::default();
656 for kind in ["todo", "done", "bullet", "num", "tagbullet", "child"] {
657 // The native run's clipboard held a stray backquote for the numbered case.
658 let text = if kind == "num" {
659 "Line one\r\nLine two`"
660 } else {
661 "Line one\r\nLine two"
662 };
663 let editor = replay(
664 &mut engine,
665 &target(kind),
666 (1, Offset::Back(4)),
667 &[(Key::Paste(text, 1033), &format!("c7-{kind}-1"))],
668 );
669 let document = &editor.active_outline().document;
670 let format = Format {
671 bold: None,
672 language: Some(1033),
673 ..calibri(11.0, false)
674 };
675 assert_eq!(document.paragraph(2).unwrap().spans()[0].format, format);
676 let last = document.paragraph(3).unwrap().text();
677 assert_eq!(
678 editor.selection().positions[1],
679 TextPosition {
680 paragraph: 3,
681 offset: last.len() as u32
682 }
683 );
684 }
685 replay(
686 &mut engine,
687 " L1 [] [] qs0 Above\n L2 [] [num:1.] qs0 First\n L2 [] [num:2.] qs0 Second",
688 (1, Offset::Back(1)),
689 &[(Key::Paste("Line one\r\nLine two", 1033), "c7-num2-1")],
690 );
691}
692
693/// OneNote 2010 typing and pasting into a French run: typed text stays French, pasted text,
694/// one line or several, takes the clipboard's language (en-US in the captures).
695#[test]
696fn pasted_text_takes_the_clipboard_language_and_typed_text_the_runs() {
697 let mut engine = TextEngine::default();
698 let (fr, en, de) = (Some(1036), Some(1033), Some(1031));
699 let french = Format {
700 language: fr,
701 ..calibri(11.0, false)
702 };
703 for (key, languages) in [
704 (Key::Type("Typed"), vec![fr]),
705 (Key::Paste("Pasted", 1033), vec![fr, en, fr]),
706 (
707 Key::Paste("Line one\r\nLine two", 1033),
708 vec![fr, en, en, fr],
709 ),
710 (Key::Paste("Pasted", 1031), vec![fr, de, fr]),
711 (
712 Key::Paste("Line one\r\nLine two", 1031),
713 vec![fr, de, de, fr],
714 ),
715 ] {
716 let mut editor = open(&mut engine, " L1 [] [] qs0 Target text");
717 editor
718 .select(
719 [0, 11]
720 .map(|offset| TextPosition {
721 paragraph: 0,
722 offset,
723 })
724 .into(),
725 )
726 .unwrap();
727 editor
728 .replace(
729 &mut engine,
730 vec![Paragraph::new("Target text".into(), french.clone())],
731 )
732 .unwrap();
733 place(&mut editor, (0, Offset::Back(4)));
734 press(&mut editor, &mut engine, key);
735 let document = &editor.active_outline().document;
736 let paragraphs = (0..).map_while(|paragraph| document.paragraph(paragraph));
737 assert_eq!(
738 paragraphs
739 .flat_map(|paragraph| paragraph.spans())
740 .map(|span| span.format.language)
741 .collect::<Vec<_>>(),
742 languages
743 );
744 }
745}
746
747#[test]
748fn a_list_applied_after_a_plain_paragraph_nests_under_it_and_stays_nested() {
749 let mut engine = TextEngine::default();
750 replay(
751 &mut engine,
752 &summary("pb-0"),
753 (1, Offset::Start),
754 &[(Key::Bullets, "pb-1"), (Key::Bullets, "pb-2")],
755 );
756}
757
758#[test]
759fn a_heading_continues_as_body_text_unless_split() {
760 let mut engine = TextEngine::default();
761 // `c9-h1-1.xml` and `c9-h1-2.xml`; their summaries also carry the heading's inline runs.
762 let mut editor = open(&mut engine, " L1 [] [] qs1 Heading");
763 let end = TextPosition {
764 paragraph: 0,
765 offset: 7,
766 };
767 editor.select([end; 2].into()).unwrap();
768 editor.enter(&mut engine, false).unwrap();
769 editor.insert(&mut engine, "body").unwrap();
770 assert_eq!(
771 render(&editor),
772 " L1 [] [] qs1 Heading\n L1 [] [] qs0 body"
773 );
774 let body = editor.active_outline().document.paragraph(1).unwrap();
775 assert_eq!(body.spans()[0].format, calibri(11.0, false));
776 let middle = TextPosition {
777 paragraph: 0,
778 offset: 4,
779 };
780 editor.select([middle; 2].into()).unwrap();
781 editor.enter(&mut engine, false).unwrap();
782 assert_eq!(
783 render(&editor),
784 " L1 [] [] qs1 Head\n L1 [] [] qs1 ing\n L1 [] [] qs0 body"
785 );
786}
787
788#[test]
789fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() {
790 let mut engine = TextEngine::default();
791 // `c9-multitag-{1,2,3}.xml`.
792 let mut editor = open(
793 &mut engine,
794 " L1 [] [] qs0 Alpha\n L1 [To Do,Important[x]] [] qs0 Two tags",
795 );
796 let at = |offset| TextPosition {
797 paragraph: 1,
798 offset,
799 };
800 editor.select([at(4); 2].into()).unwrap();
801 editor.enter(&mut engine, false).unwrap();
802 // OneNote 2010 leaves the lower half untagged (`c9-multitag-1`); its To Do continues here.
803 assert_eq!(
804 render(&editor),
805 " L1 [] [] qs0 Alpha\n L1 [To Do,Important[x]] [] qs0 Two \n L1 [To Do] [] qs0 tags"
806 );
807 editor.delete(&mut engine, true).unwrap();
808 assert_eq!(render(&editor), summary("c9-multitag-2").trim_end());
809 editor.select([at(0); 2].into()).unwrap();
810 editor.delete(&mut engine, true).unwrap();
811 assert_eq!(render(&editor), summary("c9-multitag-3").trim_end());
812}
813
814/// Nesting, adopted siblings, restyled and removed lists, an outdent, a tagged split and a join
815/// that hands on children, on pages OneNote wrote, read back from the page writer unchanged.
816#[test]
817fn structural_edits_on_native_pages_survive_the_page_writer() {
818 use onestore::{RevisionIndex, Store, document::Document};
819 let section = std::fs::read(concat!(
820 env!("CARGO_MANIFEST_DIR"),
821 "/../../corpus/structural-probe/probe.one"
822 ))
823 .unwrap();
824 let page = |bytes: &[u8], title: &str| {
825 let store = Store::parse(bytes).unwrap();
826 let index = RevisionIndex::parse(&store).unwrap();
827 let document = Document::parse(&index).unwrap();
828 let (space, _) = document
829 .pages()
830 .unwrap()
831 .into_iter()
832 .find(|(space, _)| Page::from_space(&document, *space).unwrap().title == title)
833 .unwrap();
834 (space, Page::from_space(&document, space).unwrap())
835 };
836 // Tags keep their place in the element's property arena only once read.
837 let settled = |mut page: Page| {
838 for object in &mut page.objects {
839 if let PageObject::Outline(outline) = object {
840 for paragraph in &mut outline.paragraphs {
841 for tag in &mut paragraph.text_mut().unwrap().tags {
842 tag.extra_set = 0;
843 }
844 }
845 }
846 }
847 page
848 };
849 let mut engine = TextEngine::default();
850 for (title, keys) in [
851 (
852 "c8-tab",
853 &[
854 ((2, Offset::Start), Key::ShiftTab),
855 ((4, Offset::Start), Key::Tab),
856 ((3, Offset::Start), Key::Backspace),
857 ][..],
858 ),
859 (
860 "c4-tagbullet",
861 &[
862 ((1, Offset::Start), Key::Backspace),
863 ((1, Offset::Start), Key::Backspace),
864 ],
865 ),
866 ("c2-todo", &[((1, Offset::Forward(3)), Key::Enter)]),
867 (
868 "c10-stab-child",
869 &[
870 ((2, Offset::Start), Key::Tab),
871 ((3, Offset::Start), Key::Tab),
872 ((1, Offset::End), Key::Delete),
873 ],
874 ),
875 ] {
876 let (space, source) = page(&section, title);
877 let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap();
878 let body = editor
879 .outlines()
880 .iter()
881 .find(|outline| !outline.title)
882 .unwrap()
883 .id;
884 editor.focus_outline(body).unwrap();
885 for (caret, key) in keys {
886 place(&mut editor, *caret);
887 press(&mut editor, &mut engine, *key);
888 }
889 let edited = settled(editor.page().unwrap());
890 let written = super::ops::saved(&section, space, &mut editor);
891 let (_, reread) = page(&written, title);
892 assert_eq!(settled(reread).objects, edited.objects, "{title}");
893 }
894}
895
896#[test]
897fn tab_inside_text_makes_a_table_whose_paragraph_keeps_the_list_tags_and_children() {
898 let mut engine = TextEngine::default();
899 // `c6-tagbullet-midtab.xml`: the table's paragraph holds the tag and bullet, its cells none.
900 let mut editor = open(&mut engine, &summary("c6-tagbullet-stab2"));
901 place(&mut editor, (1, Offset::Back(4)));
902 press(&mut editor, &mut engine, Key::Tab);
903 let nodes = editor.active_outline().document.nodes();
904 let ParagraphContent::Table(table) = &nodes[1].content else {
905 panic!("{:?}", nodes[1].content)
906 };
907 assert_eq!(
908 (nodes[1].level, nodes[1].lists.len(), nodes[1].tags.len()),
909 (1, 1, 1)
910 );
911 for cell in table.rows.iter().flat_map(|row| &row.cells) {
912 let text = cell.paragraphs[0].text().unwrap();
913 assert!(cell.paragraphs[0].lists.is_empty() && text.tags.is_empty());
914 }
915 // `c10-tab-parent-1.xml`: the children stay under the table's paragraph.
916 let mut editor = open(&mut engine, &summary("c10-tab-parent-0"));
917 place(&mut editor, (1, Offset::End));
918 press(&mut editor, &mut engine, Key::Tab);
919 let nodes = editor.active_outline().document.nodes();
920 assert!(matches!(nodes[1].content, ParagraphContent::Table(_)));
921 assert_eq!((nodes[2].parent, nodes[2].level), (Some(nodes[1].id), 2));
922}