| 1 | """Conflict pages (corpus/conflict-page): what OneNote 2010 stores and shows for one, and |
| 2 | that it reads the ones Rust writes and deletes.""" |
| 3 | import json |
| 4 | from pathlib import Path |
| 5 | import runpy |
| 6 | import shutil |
| 7 | import subprocess |
| 8 | from tempfile import TemporaryDirectory |
| 9 | import unittest |
| 10 | import hashlib |
| 11 | |
| 12 | from native_xml import pages, texts |
| 13 | from document_model import EXPORTER, ordered_pages, view |
| 14 | from verify_page_creation import compare as compare_graphs |
| 15 | |
| 16 | ROOT = Path(__file__).resolve().parent.parent |
| 17 | FIXTURE = ROOT / 'corpus/conflict-page' |
| 18 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] |
| 19 | DEFAULT = '{00000000-0000-0000-0000-000000000000},0' |
| 20 | RENDER, SELECT = 0x88001D96, 0x88001DDB |
| 21 | |
| 22 | |
| 23 | def 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 | |
| 30 | def 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 | |
| 47 | def 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 | |
| 52 | TITLES = {'One', 'Two', 'Three', 'Four', 'Target'} |
| 53 | |
| 54 | |
| 55 | def 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 | |
| 63 | def 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 | |
| 69 | class 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 | |
| 173 | if __name__ == '__main__': |
| 174 | unittest.main() |