1import json
2from pathlib import Path
3import subprocess
4from tempfile import TemporaryDirectory
5import unittest
6
7from document_model import EXPORTER, ordered_pages, view, walk
8from native_page_movement import PHASES
9from verify_page_creation import compare
10
11FIXTURE = Path(__file__).resolve().parent.parent / 'corpus/page-lifecycle'
12
13
14class 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
182if __name__ == '__main__':
183 unittest.main()