| 1 | import json |
| 2 | from pathlib import Path |
| 3 | import subprocess |
| 4 | from tempfile import TemporaryDirectory |
| 5 | import unittest |
| 6 | |
| 7 | from document_model import EXPORTER, ordered_pages, view, walk |
| 8 | from native_page_movement import PHASES |
| 9 | from verify_page_creation import compare |
| 10 | |
| 11 | FIXTURE = Path(__file__).resolve().parent.parent / 'corpus/page-lifecycle' |
| 12 | |
| 13 | |
| 14 | class PageMovementTest(unittest.TestCase): |
| 15 | def test_offline_batches_reconcile_native_movements_without_cold_repairs(self): |
| 16 | fixture = FIXTURE / 'offline-edits' |
| 17 | cases = json.loads((fixture / 'provenance.json').read_text())['cases'] |
| 18 | self.assertEqual(len(cases), 18) |
| 19 | conflicts = 0 |
| 20 | for case in cases: |
| 21 | with self.subTest(case=case): |
| 22 | source = fixture / case / 'candidate' |
| 23 | cold = fixture / case / 'cold' |
| 24 | manifest = json.loads((source / 'manifest.json').read_text()) |
| 25 | conflicts += manifest['conflict'] |
| 26 | self.assertEqual(compare(source, cold), 0) |
| 27 | self.assertEqual((source / 'Lifecycle.one').read_bytes()[1024:], |
| 28 | (cold / 'notebook/Lifecycle.one').read_bytes()[1024:]) |
| 29 | self.assertEqual(conflicts, 9) |
| 30 | |
| 31 | def test_twelve_offline_page_batches_retain_order_levels_and_bodies_natively(self): |
| 32 | fixture = FIXTURE / 'offline-edits/twelve-clients' |
| 33 | source = fixture / 'candidate' |
| 34 | cold = fixture / 'cold' |
| 35 | self.assertEqual(compare(source, cold), 0) |
| 36 | manifest = json.loads((source / 'manifest.json').read_text()) |
| 37 | self.assertEqual(manifest['publications'], 24) |
| 38 | with TemporaryDirectory() as temporary: |
| 39 | output = Path(temporary) / 'document' |
| 40 | subprocess.run([EXPORTER, cold / 'notebook/pages.one', output], check=True) |
| 41 | document = json.loads((output / 'document.json').read_text()) |
| 42 | pages = list(ordered_pages(document)) |
| 43 | self.assertEqual(len(pages), 37) |
| 44 | self.assertEqual([[sid, revision['nodes'][revision['roots']['2']]['kind']['level']] |
| 45 | for sid, _, revision, _ in pages], manifest['expected_order']) |
| 46 | bodies = [node['kind']['text'] for _, _, revision, page in pages |
| 47 | for _, node in walk(revision, page) |
| 48 | if node['kind']['type'] == 'RichText' and node['kind']['text'].startswith('Actor ')] |
| 49 | self.assertEqual(bodies, [f'Actor {i} 🦀 é' for i in range(12)]) |
| 50 | |
| 51 | def test_optional_metadata_restoration_matches_native(self): |
| 52 | fixture = FIXTURE / 'page-edits/optional-cache' |
| 53 | for source, cold in [('source', 'source-cold'), ('candidate', 'cold'), |
| 54 | ('followup/candidate', 'followup/cold')]: |
| 55 | with self.subTest(source=source): |
| 56 | self.assertEqual(compare(fixture / source, fixture / cold), 0) |
| 57 | if source != 'source': |
| 58 | self.assertEqual((fixture / source / 'Lifecycle.one').read_bytes()[1024:], |
| 59 | (fixture / cold / 'notebook/Lifecycle.one').read_bytes()[1024:]) |
| 60 | |
| 61 | def test_native_and_rust_edits_preserve_moved_pages(self): |
| 62 | fixture = FIXTURE / 'page-edits' |
| 63 | with TemporaryDirectory() as temporary: |
| 64 | temporary = Path(temporary) |
| 65 | subprocess.run([EXPORTER, fixture / '08-collapsed-group-move/candidate/Lifecycle.one', temporary / 'original'], check=True) |
| 66 | original = list(ordered_pages(json.loads((temporary / 'original/document.json').read_text()))) |
| 67 | for ordinal, (source, cold) in enumerate([ |
| 68 | ('native/notebook', 'native/cold'), ('rust-followup/candidate', 'rust-followup/cold'), |
| 69 | ]): |
| 70 | self.assertEqual(compare(fixture / source, fixture / cold), 0) |
| 71 | output = temporary / str(ordinal) |
| 72 | subprocess.run([EXPORTER, fixture / cold / 'notebook/Lifecycle.one', output], check=True) |
| 73 | pages = list(ordered_pages(json.loads((output / 'document.json').read_text()))) |
| 74 | self.assertEqual([(sid, page) for sid, _, _, page in pages], |
| 75 | [(sid, page) for sid, _, _, page in original]) |
| 76 | for i, ((_, _, revision, page), (_, _, old, old_page)) in enumerate(zip(pages, original, strict=True)): |
| 77 | expected = [node['kind']['text'] for _, node in walk(old, old_page) if node['kind']['type'] == 'RichText'] |
| 78 | if i == 8: |
| 79 | expected[0] = 'Native moved 🦋 é' |
| 80 | expected.append(('Rust + ' if ordinal else '') + 'Native body after a Rust page move.') |
| 81 | self.assertEqual(revision['nodes'][revision['roots']['2']]['kind'], |
| 82 | {'type': 'Metadata', 'title': expected[0], 'level': ordinal + 1}) |
| 83 | self.assertEqual([node['kind']['text'] for _, node in walk(revision, page) if node['kind']['type'] == 'RichText'], expected) |
| 84 | |
| 85 | def test_rust_page_edits_survive_native_reopen_without_repairs(self): |
| 86 | for phase in [phase for phase, *_ in PHASES] + ['single-page']: |
| 87 | with self.subTest(phase=phase): |
| 88 | fixture = FIXTURE / 'page-edits' / phase |
| 89 | self.assertEqual((fixture / 'candidate/Lifecycle.one').read_bytes()[1024:], |
| 90 | (fixture / 'cold/notebook/Lifecycle.one').read_bytes()[1024:]) |
| 91 | self.assertEqual(compare(fixture / 'candidate', fixture / 'cold'), 0) |
| 92 | |
| 93 | def test_series_rows_survive_native_reopen(self): |
| 94 | """Rust moves heading series of their own, and offline batches merged as page series.""" |
| 95 | def series(one): |
| 96 | with TemporaryDirectory() as temporary: |
| 97 | subprocess.run([EXPORTER, one, Path(temporary) / 'm'], check=True, capture_output=True) |
| 98 | document = json.loads((Path(temporary) / 'm/document.json').read_text()) |
| 99 | _, root = view(document, document['root']) |
| 100 | section = root['nodes'][root['roots']['1']] |
| 101 | return {space: oid for oid in section['children'] for space in root['nodes'][oid]['spaces']} |
| 102 | edits = FIXTURE / 'page-edits-series' |
| 103 | before = series(FIXTURE / '04-nested/notebook/Lifecycle.one') |
| 104 | base, previous = list(before), before |
| 105 | # The page each phase moves; `05` moves a subpage into its parent's series. |
| 106 | moved = {'03-selected-group-move': 3, '04-subpage-move': 4, '08-collapsed-group-move': 4, 'single-page': 3} |
| 107 | for phase in sorted(p.name for p in edits.iterdir() if p.is_dir()): |
| 108 | with self.subTest(phase=phase): |
| 109 | fixture = edits / phase |
| 110 | self.assertEqual((fixture / 'candidate/Lifecycle.one').read_bytes()[1024:], |
| 111 | (fixture / 'cold/notebook/Lifecycle.one').read_bytes()[1024:]) |
| 112 | self.assertEqual(compare(fixture / 'candidate', fixture / 'cold'), 0) |
| 113 | after = series(fixture / 'candidate/Lifecycle.one') |
| 114 | if phase in moved: |
| 115 | start = before if phase == 'single-page' else previous |
| 116 | self.assertNotIn(after[base[moved[phase]]], start.values()) |
| 117 | if phase != 'single-page': |
| 118 | previous = after |
| 119 | offline = FIXTURE / 'offline-edits-series' |
| 120 | for case in sorted(p.name for p in offline.iterdir() if p.is_dir() and p.name != 'twelve-clients'): |
| 121 | with self.subTest(case=case): |
| 122 | source, cold = offline / case / 'candidate', offline / case / 'cold' |
| 123 | manifest = json.loads((source / 'manifest.json').read_text()) |
| 124 | self.assertEqual((manifest['conflict'], manifest['reviewed']), (True, [])) |
| 125 | repaired = case == 'indent-02-promoted-parent' |
| 126 | self.assertEqual(compare(source, cold, refresh_metadata=repaired), int(repaired)) |
| 127 | if not repaired: |
| 128 | self.assertEqual((source / 'Lifecycle.one').read_bytes()[1024:], |
| 129 | (cold / 'notebook/Lifecycle.one').read_bytes()[1024:]) |
| 130 | twelve = offline / 'twelve-clients' |
| 131 | self.assertEqual(compare(twelve / 'candidate', twelve / 'cold'), 0) |
| 132 | self.assertEqual(json.loads((twelve / 'candidate/manifest.json').read_text())['publications'], 12) |
| 133 | |
| 134 | def test_repeated_page_nesting_survives_native_reopen_after_checkpoints(self): |
| 135 | fixture = FIXTURE / 'page-edits/stress' |
| 136 | self.assertEqual((fixture / 'candidate/movement.one').read_bytes()[1024:], |
| 137 | (fixture / 'cold/notebook/movement.one').read_bytes()[1024:]) |
| 138 | self.assertEqual(compare(fixture / 'candidate', fixture / 'cold'), 0) |
| 139 | |
| 140 | def test_native_selection_indentation_and_order_survive_cold_reopen(self): |
| 141 | with TemporaryDirectory() as temporary: |
| 142 | temporary = Path(temporary) |
| 143 | subprocess.run([EXPORTER, FIXTURE / '04-nested/notebook/Lifecycle.one', temporary / 'baseline'], check=True) |
| 144 | baseline = json.loads((temporary / 'baseline/document.json').read_text()) |
| 145 | original = list(ordered_pages(baseline)) |
| 146 | expected = [(phase, order, levels) for phase, _, _, order, levels in PHASES] |
| 147 | expected.append(('single-page', [0, 1, 2, 4, 5, 6, 7, 8, 3], [1, 1, 1, 2, 3, 1, 1, 1, 1])) |
| 148 | for phase, order, levels in expected: |
| 149 | with self.subTest(phase=phase): |
| 150 | fixture = FIXTURE / 'movement' / phase |
| 151 | if phase == '02-promoted-parent': |
| 152 | with self.assertRaises(AssertionError): |
| 153 | compare(fixture / 'notebook', fixture / 'cold') |
| 154 | refreshed = compare(fixture / 'notebook', fixture / 'cold', |
| 155 | refresh_metadata=phase == '02-promoted-parent') |
| 156 | self.assertEqual(refreshed, int(phase == '02-promoted-parent')) |
| 157 | output = temporary / phase |
| 158 | subprocess.run([EXPORTER, fixture / 'notebook/Lifecycle.one', output], check=True) |
| 159 | document = json.loads((output / 'document.json').read_text()) |
| 160 | pages = list(ordered_pages(document)) |
| 161 | self.assertEqual([(sid, page) for sid, _, _, page in pages], |
| 162 | [(original[i][0], original[i][3]) for i in order]) |
| 163 | self.assertEqual([r['nodes'][r['roots']['2']]['kind']['level'] for _, _, r, _ in pages], levels) |
| 164 | groups = [] |
| 165 | for (sid, _, revision, page), index, level in zip(pages, order, levels, strict=True): |
| 166 | if level == 1: |
| 167 | groups.append([]) |
| 168 | groups[-1].append(sid) |
| 169 | old = dict(walk(original[index][2], original[index][3])) |
| 170 | current = dict(walk(revision, page)) |
| 171 | self.assertEqual(old.keys(), current.keys()) |
| 172 | for oid in old: |
| 173 | for field in ('children', 'content', 'structure', 'format'): |
| 174 | self.assertEqual(old[oid][field], current[oid][field], (phase, oid, field)) |
| 175 | if oid != page: |
| 176 | self.assertEqual(old[oid]['kind'], current[oid]['kind'], (phase, oid)) |
| 177 | _, section = view(document, document['root']) |
| 178 | self.assertEqual([section['nodes'][oid]['spaces'] |
| 179 | for oid in section['nodes'][section['roots']['1']]['children']], groups) |
| 180 | |
| 181 | |
| 182 | if __name__ == '__main__': |
| 183 | unittest.main() |