1"""Conflict pages (corpus/conflict-page): what OneNote 2010 stores and shows for one, and
2that it reads the ones Rust writes and deletes."""
3import json
4from pathlib import Path
5import runpy
6import shutil
7import subprocess
8from tempfile import TemporaryDirectory
9import unittest
10import hashlib
11
12from native_xml import pages, texts
13from document_model import EXPORTER, ordered_pages, view
14from verify_page_creation import compare as compare_graphs
15
16ROOT = Path(__file__).resolve().parent.parent
17FIXTURE = ROOT / 'corpus/conflict-page'
18compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
19DEFAULT = '{00000000-0000-0000-0000-000000000000},0'
20RENDER, SELECT = 0x88001D96, 0x88001DDB
21
22
23def model(notebook, section='synthetic.one'):
24 with TemporaryDirectory() as temporary:
25 output = Path(temporary) / 'model'
26 subprocess.run([EXPORTER, notebook / section, output], check=True, capture_output=True)
27 return json.loads((output / 'document.json').read_text())
28
29
30def conflicts(document):
31 """Each listed page's conflict pages: whose version, its texts and the marked texts."""
32 listed = {}
33 for sid, _, page, _ in ordered_pages(document):
34 manifest = page['nodes'][page['roots']['1']]
35 versions = []
36 for space in manifest['spaces']:
37 _, revision = view(document, space)
38 metadata = revision['nodes'][revision['roots']['2']]['kind']
39 rich = [node for node in revision['nodes'].values() if node['kind']['type'] == 'RichText']
40 flagged = lambda node, flag: any(field['id'] == flag for fields in node['extra'] for field in fields)
41 versions.append({'user': metadata['author'], 'texts': sorted(node['kind']['text'] for node in rich),
42 'marked': sorted(node['kind']['text'] for node in rich if flagged(node, RENDER) and flagged(node, SELECT))})
43 listed[sid] = versions
44 return listed
45
46
47def page_texts(document, sid):
48 _, page = view(document, sid)
49 return sorted(node['kind']['text'] for node in page['nodes'].values() if node['kind']['type'] == 'RichText')
50
51
52TITLES = {'One', 'Two', 'Three', 'Four', 'Target'}
53
54
55def series(document):
56 """The section's page series in order, each as the titles of its pages."""
57 _, root = view(document, document['root'])
58 section = root['nodes'][root['roots']['1']]
59 return {oid: [next((text for text in page_texts(document, space) if text in TITLES), '')
60 for space in root['nodes'][oid]['spaces']] for oid in section['children']}
61
62
63def spaces(document):
64 """Each listed page's space by its title."""
65 return {next((text for text in page_texts(document, sid) if text in TITLES), ''): sid
66 for sid, *_ in ordered_pages(document)}
67
68
69class ConflictPageTest(unittest.TestCase):
70 def test_onenote_keeps_the_remote_version_and_the_local_one_as_a_conflict_page(self):
71 document = model(FIXTURE / 'native/conflict/notebook')
72 (sid, versions), = conflicts(document).items()
73 # Client A's disjoint edit merged; the paragraph both changed keeps client B's text.
74 self.assertIn('Client A disjoint edit.', page_texts(document, sid))
75 self.assertIn('Client B conflicting edit.', page_texts(document, sid))
76 self.assertEqual(versions, [{'user': 'snow',
77 'texts': ['', 'Client A conflicting edit.', 'Client A disjoint edit.', 'Left cell', 'Right cell'],
78 'marked': ['Client A conflicting edit.']}])
79 # COM lists the page alone; the conflict page shows only through its bar.
80 for client in 'ab':
81 self.assertTrue((FIXTURE / f'native/{client}/{client}-shown.png').is_file())
82
83 def test_onenote_deletes_a_conflict_page_from_its_page_only(self):
84 document = model(FIXTURE / 'native-delete/notebook')
85 self.assertEqual(conflicts(document), {sid: [] for sid in conflicts(document)})
86 self.assertEqual(len(pages(FIXTURE / 'native-delete/read')), 1)
87
88 def test_three_clients_conflict_pages_list_the_last_stored_first(self):
89 document = model(FIXTURE / 'native-three/notebook')
90 (_, versions), = conflicts(document).items()
91 self.assertEqual([version['user'] for version in versions],
92 ['ONE-M6-DDE10E0D', 'ONE-M6-9451B6C6', 'ONE-M6-053BD357'])
93
94 def test_onenote_reads_a_rust_conflict_page_shows_its_bar_and_deletes_it(self):
95 source = json.loads((FIXTURE / 'cold/source.json').read_text())
96 written = hashlib.sha256((FIXTURE / 'candidate/synthetic.one').read_bytes()).hexdigest()
97 self.assertIn(written, [item['sha256'] for item in source])
98 with TemporaryDirectory() as temporary:
99 read = Path(temporary) / 'read'
100 shutil.copytree(FIXTURE / 'cold/before-read', read)
101 compare(FIXTURE / 'candidate', read)
102 (page,) = pages(FIXTURE / 'cold/before-read')
103 self.assertIn('Remote: café, 東京, مرحبا', texts(page))
104 document = model(FIXTURE / 'candidate')
105 (_, versions), = conflicts(document).items()
106 self.assertEqual(versions[0]['user'], 'Clover Snow')
107 self.assertEqual(versions[0]['marked'], ['Snowbound: café, 東京, مرحبا'])
108 # The bar on the page opens the conflict page, as OneNote's own do, and its menu
109 # deletes it, leaving the page without a bar.
110 titles = {name: json.loads((FIXTURE / f'cold/cold-{name}.json').read_text())['win']['title']
111 for name in ('main', 'shown', 'delete')}
112 self.assertEqual(titles, {'main': 'Remote: café, 東京, مرحبا - Microsoft OneNote',
113 'shown': 'Snowbound: café, 東京, مرحبا - Microsoft OneNote',
114 'delete': 'Remote: café, 東京, مرحبا - Microsoft OneNote'})
115 deleted = model(FIXTURE / 'cold/notebook')
116 self.assertEqual(conflicts(deleted), {sid: [] for sid in conflicts(deleted)})
117
118 def test_onenote_reads_a_conflict_page_rust_deleted(self):
119 document = model(FIXTURE / 'candidate-delete')
120 self.assertEqual(conflicts(document), {sid: [] for sid in conflicts(document)})
121 with TemporaryDirectory() as temporary:
122 read = Path(temporary) / 'read'
123 shutil.copytree(FIXTURE / 'cold-delete/read', read)
124 compare(FIXTURE / 'candidate-delete', read)
125 (page,) = pages(FIXTURE / 'cold-delete/read')
126 self.assertIn('Client B conflicting edit.', texts(page))
127
128 def test_page_moves_merge_as_page_series_do(self):
129 """Each client gives a page it moves a series of its own; the merge keeps each page
130 in the series of the client that gave it one, the server's where both did, and
131 leaves the other client's series empty. A page the server deleted comes back in a
132 new series where the offline client had it."""
133 for capture, order, empty, server, kept, client in (
134 ('native-pages', ['One', 'Two', 'Four', 'Three', 'Target'], 2, ['One', 'Two', 'Four'], [], ['Three', 'Target']),
135 ('native-restore', ['Four', 'One', 'Two', 'Target', 'Three'], 0, [], ['Four'], ['One', 'Two', 'Three', 'Target'])):
136 root = FIXTURE / capture
137 initial, published, merged = (series(model(root / phase / 'notebook'))
138 for phase in ('initial', 'b-published', 'merged'))
139 self.assertEqual([titles for titles in merged.values() if titles][1:], [[title] for title in order])
140 self.assertEqual(sum(not titles for titles in merged.values()), empty, capture)
141 self.assertEqual(json.loads((root / 'merged.json').read_text())['order'][1:], order)
142 for listed in 'ab':
143 self.assertEqual(json.loads((root / listed / 'listed.json').read_text())[1:], order)
144 owner = lambda state, title: next((oid for oid, titles in state.items() if title in titles), None)
145 for title in server:
146 self.assertEqual(owner(merged, title), owner(published, title), title)
147 self.assertNotIn(owner(merged, title), initial, title)
148 for title in kept:
149 self.assertEqual(owner(merged, title), owner(initial, title), title)
150 for title in client:
151 self.assertNotIn(owner(merged, title), {**initial, **published}, title)
152
153 def test_onenote_reads_a_rust_move_that_drops_empty_series(self):
154 root = FIXTURE / 'series-move'
155 self.assertEqual((root / 'candidate/synthetic.one').read_bytes()[1024:],
156 (root / 'cold/notebook/synthetic.one').read_bytes()[1024:])
157 self.assertEqual(compare_graphs(root / 'candidate', root / 'cold'), 0)
158 self.assertTrue(all(series(model(root / 'candidate')).values()))
159
160 def test_a_page_edited_offline_and_deleted_online_comes_back_as_a_new_page(self):
161 for capture in ('native-pages', 'native-restore'):
162 root = FIXTURE / capture
163 before = spaces(model(root / 'initial/notebook'))
164 document = model(root / 'merged/notebook')
165 target = spaces(document)['Target']
166 self.assertNotEqual(target, before['Target'], capture)
167 self.assertIn('Body Target edited offline.', page_texts(document, target))
168 # The deleting client's version stays in the recycle bin.
169 deleted = model(root / 'merged/notebook/OneNote_RecycleBin', 'OneNote_DeletedPages.one')
170 self.assertIn('Body Target.', page_texts(deleted, spaces(deleted)['Target']), capture)
171
172
173if __name__ == '__main__':
174 unittest.main()