1"""Rust page-model edits survive a cold native reopen and a native edit on top of them."""
2import json
3from pathlib import Path
4import runpy
5import subprocess
6from tempfile import TemporaryDirectory
7import unittest
8
9from document_model import EXPORTER, ordered_pages, walk
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/page-model'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16def projection(space, page_root):
17 """Outlines with their positions and pre-order paragraphs (text, parent index, collapsed)."""
18 page = space['nodes'][page_root]
19 outlines = []
20 for oid in page['children']:
21 node = space['nodes'][oid]
22 if node['kind']['type'] != 'Outline':
23 continue
24 paragraphs = []
25 index = {}
26 pending = [(child, None) for child in reversed(node['children'])]
27 while pending:
28 pid, parent = pending.pop()
29 paragraph = space['nodes'][pid]
30 if paragraph['kind']['type'] == 'OutlineGroup':
31 pending.extend((child, parent) for child in reversed(paragraph['children']))
32 continue
33 assert paragraph['kind']['type'] == 'Paragraph', paragraph['kind']['type']
34 text = None
35 for cid in paragraph['content']:
36 content = space['nodes'][cid]
37 if content['kind']['type'] == 'RichText':
38 text = content['kind']['text']
39 index[pid] = len(paragraphs)
40 paragraphs.append({'text': text, 'parent': index.get(parent), 'collapsed': paragraph['kind'].get('collapse_state') == 1})
41 pending.extend((child, pid) for child in reversed(paragraph['children']))
42 layout = node['layout']
43 outlines.append({'x': points(layout['x']), 'y': points(layout['y']), 'max_width': points(layout['max_width']),
44 'width_set_by_user': layout.get('width_set_by_user'), 'paragraphs': paragraphs})
45 return outlines
46
47
48def points(value):
49 """Single-precision points survive JSON with different digits in Rust and Python."""
50 return None if value is None else round(value, 3)
51
52
53def expected_projection(case):
54 return [{'x': points(o['x']), 'y': points(o['y']), 'max_width': points(o['max_width']), 'width_set_by_user': o['width_set_by_user'],
55 'paragraphs': [{'text': p['text'], 'parent': p['parent'], 'collapsed': p['collapsed']} for p in o['paragraphs']]}
56 for o in case['expected']['outlines']]
57
58
59def model(notebook):
60 with TemporaryDirectory() as temporary:
61 subprocess.run([EXPORTER, notebook / 'synthetic.one', Path(temporary) / 'model'], check=True)
62 return json.loads((Path(temporary) / 'model/document.json').read_text())
63
64
65class PageModelTest(unittest.TestCase):
66 def setUp(self):
67 self.manifest = json.loads((FIXTURE / 'candidate/manifest.json').read_text())
68 self.assertEqual(len(self.manifest['cases']), 8)
69
70 def check_cases(self, notebook, exceptions=()):
71 pages = list(ordered_pages(model(notebook)))
72 self.assertEqual(len(pages), 15)
73 for case in self.manifest['cases']:
74 with self.subTest(case=case['title']):
75 _, _, space, root = pages[case['index']]
76 actual = projection(space, root)
77 expected = expected_projection(case)
78 for index, replacement in exceptions:
79 if index == case['index']:
80 expected = replacement(expected)
81 self.assertEqual(actual, expected)
82
83 def assert_only_navigation_cache_changed(self, candidate, cold):
84 """OneNote adds per-series metadata copies on open (as it does for the untouched
85 outline-edit fixture); every page space must keep its exact active revision."""
86 context = '{00000000-0000-0000-0000-000000000000},0'
87 self.assertEqual(set(candidate['spaces']), set(cold['spaces']))
88 for sid, space in candidate['spaces'].items():
89 if sid == candidate['root']:
90 continue
91 self.assertEqual(space['contexts'][context], cold['spaces'][sid]['contexts'][context], sid)
92 before = candidate['spaces'][candidate['root']]
93 after = cold['spaces'][cold['root']]
94 before = before['revisions'][before['contexts'][context]]['nodes']
95 after = after['revisions'][after['contexts'][context]]['nodes']
96 self.assertTrue(set(before) <= set(after))
97 for oid, node in before.items():
98 other = after[oid]
99 if node['kind']['type'] == 'Metadata':
100 continue
101 if node['kind']['type'] == 'PageSeries':
102 strip = lambda n: {k: v for k, v in n.items() if k != 'extra'}
103 self.assertEqual(strip(node), strip(other), oid)
104 continue
105 self.assertEqual(node, other, oid)
106 for oid in set(after) - set(before):
107 self.assertEqual(after[oid]['kind']['type'], 'Metadata', oid)
108
109 def test_rust_page_model_edits_survive_cold_native_reopen(self):
110 compare(FIXTURE / 'candidate/notebook', FIXTURE / 'cold/read')
111 compare(FIXTURE / 'cold/notebook', FIXTURE / 'cold/read')
112 self.assert_only_navigation_cache_changed(model(FIXTURE / 'candidate/notebook'), model(FIXTURE / 'cold/notebook'))
113 self.check_cases(FIXTURE / 'candidate/notebook')
114 self.check_cases(FIXTURE / 'cold/notebook')
115
116 def test_native_edit_after_rust_page_model_edits(self):
117 compare(FIXTURE / 'followup-cold/notebook', FIXTURE / 'followup-cold/read')
118 native = (FIXTURE / 'followup/native-text.txt').read_text(encoding='utf-8-sig')
119 self.assertTrue(native.startswith('Native after Rust'))
120 moved = next(case for case in self.manifest['cases'] if case['title'] == 'Move leaf down')
121
122 def replace_rust_text(outlines):
123 outlines = json.loads(json.dumps(outlines))
124 hits = [p for o in outlines for p in o['paragraphs'] if p['text'].startswith('Rust ')]
125 assert len(hits) == 1
126 hits[0]['text'] = native
127 return outlines
128
129 self.check_cases(FIXTURE / 'followup-cold/notebook', exceptions=[(moved['index'], replace_rust_text)])
130
131
132if __name__ == '__main__':
133 unittest.main()