1import base64
2from io import BytesIO
3import json
4import hashlib
5from pathlib import Path
6import re
7from tempfile import TemporaryDirectory
8import shutil
9import subprocess
10import unittest
11from unittest.mock import patch
12import xml.etree.ElementTree as ET
13
14from PIL import Image
15from notebook_report import generate
16from document_model import EXPORTER
17
18
19class NotebookReportTest(unittest.TestCase):
20 def test_external_assets_match_native_bytes_and_refresh_without_a_section_edit(self):
21 from notebook_editor import Session
22 fixture = Path(__file__).resolve().parent.parent / 'corpus/native-external-assets'
23 with TemporaryDirectory() as temporary:
24 root = Path(temporary)
25 source = root / 'source'
26 shutil.copytree(fixture / 'notebook', source)
27 session = Session(source, root / 'session')
28 first = root / 'session/g/0/report'
29 section = next(p.parent for p in (first / 'model').glob('*/assets.json')
30 if json.loads(p.read_text()))
31 rows = json.loads((section / 'assets.json').read_text())
32 expected = {row['sha256'] for row in json.loads((fixture / 'read/payloads.json').read_text(encoding='utf-8-sig'))}
33 actual = {hashlib.sha256((section / row['path']).read_bytes()).hexdigest() for row in rows}
34 self.assertTrue(expected <= actual)
35 for payload in source.rglob('*.onebin'):
36 self.assertEqual(payload.read_bytes(), (root / 'session/g/0/snapshot' / payload.relative_to(source)).read_bytes())
37 payload, = [p for p in source.rglob('*.onebin') if p.stat().st_size == 1024]
38 reference = {'External': payload.name}
39 old, = [row for row in rows if row['reference'] == reference]
40 previous_bytes = (section / old['path']).read_bytes()
41 payload.unlink()
42 generate(source, root / 'missing', previous=first)
43 missing, = [row for p in (root / 'missing/model').glob('*/assets.json')
44 for row in json.loads(p.read_text()) if row['reference'] == reference]
45 self.assertIsNone(missing['path'])
46 self.assertEqual(missing['error']['kind'], 'NotFound')
47 payload.write_bytes(b'Changed external payload')
48 generate(source, root / 'changed', previous=root / 'missing')
49 changed, = [(p.parent, row) for p in (root / 'changed/model').glob('*/assets.json')
50 for row in json.loads(p.read_text()) if row['reference'] == reference]
51 self.assertEqual((changed[0] / changed[1]['path']).read_bytes(), payload.read_bytes())
52 self.assertEqual((section / old['path']).read_bytes(), previous_bytes)
53
54 def test_locked_and_unreadable_sections_remain_visible_in_cached_reports(self):
55 fixture = Path(__file__).resolve().parent.parent / 'corpus/native-encrypted/cold-encrypted-02/notebook'
56 with TemporaryDirectory() as temporary:
57 root = Path(temporary)
58 generate(fixture, root / 'first')
59 generate(fixture, root / 'cached', previous=root / 'first')
60 index = (root / 'first/index.html').read_text()
61 self.assertIn('2 sections', index)
62 self.assertIn('Locked section', index)
63 self.assertIn('Cannot read Open Notebook.one', index)
64 self.assertIn('Object space has no revision manifest list', index)
65 self.assertEqual(json.loads((root / 'first/pages.json').read_text()), [])
66 self.assertEqual(index, (root / 'cached/index.html').read_text())
67
68 def test_tiff_preview_matches_native_pixels_and_retains_original_bytes(self):
69 fixture = Path(__file__).resolve().parent.parent / 'corpus/m6/native-features-01'
70 with TemporaryDirectory() as temporary:
71 output = Path(temporary) / 'report'
72 generate(fixture / 'notebook', output)
73 page, = [p for p in json.loads((output / 'pages.json').read_text()) if p['title'] == 'Image tiff']
74 source, = re.findall(r'<img[^>]*src="([^"]+)"', (output / page['report']).read_text())
75 preview = output / source
76 self.assertEqual(preview.suffix, '.png')
77 original, = (output / 'assets').glob('*.tiff')
78 self.assertEqual(original.read_bytes(), (fixture / 'notebook/fixture-data/pattern.tiff').read_bytes())
79 native, = [root for p in (fixture / 'read').glob('page-*.xml')
80 if (root := ET.parse(p).getroot()).get('name') == 'Image tiff']
81 data, = [n.text for n in native.iter() if n.tag.endswith('}Data')]
82 with Image.open(BytesIO(base64.b64decode(data))) as expected, Image.open(preview) as actual:
83 self.assertEqual(actual.size, expected.size)
84 self.assertEqual(actual.convert('RGBA').tobytes(), expected.convert('RGBA').tobytes())
85 references = [a['path'] for p in (output / 'model').glob('*/assets.json') for a in json.loads(p.read_text())]
86 self.assertIn('../../assets/' + original.name, references)
87
88 def test_reused_models_and_assets_match_a_fresh_report_after_source_order_changes(self):
89 fixture = Path(__file__).resolve().parent.parent / 'corpus/m6/native-features-01/notebook'
90 with TemporaryDirectory() as temporary:
91 root = Path(temporary)
92 source = root / 'notebook'
93 shutil.copytree(fixture, source)
94 generate(source, root / 'first')
95 before = {p.relative_to(root / 'first'): p.read_bytes() for p in (root / 'first').rglob('*') if p.is_file()}
96 def cached_run(command, *args, **kwargs):
97 self.assertNotEqual(command[0], EXPORTER, 'Unchanged model exported again')
98 return original(command, *args, **kwargs)
99
100 original = subprocess.run
101 with patch('notebook_report.subprocess.run', side_effect=cached_run):
102 generate(source, root / 'same', previous=root / 'first')
103 self.assertEqual(before, {p.relative_to(root / 'same'): p.read_bytes() for p in (root / 'same').rglob('*') if p.is_file()})
104 subprocess.run([EXPORTER.parent / 'create_section', source / 'a.one', 'Independent section', 'Fixture'], check=True)
105 with patch('notebook_report.subprocess.run', wraps=original) as run:
106 generate(source, root / 'changed', previous=root / 'first')
107 self.assertEqual([Path(call.args[0][1]).name for call in run.call_args_list if call.args[0][0] == EXPORTER], ['a.one'])
108 generate(source, root / 'fresh')
109 self.assertEqual({p.relative_to(root / 'fresh'): p.read_bytes() for p in (root / 'fresh').rglob('*') if p.is_file()},
110 {p.relative_to(root / 'changed'): p.read_bytes() for p in (root / 'changed').rglob('*') if p.is_file()})
111 for name, contents in before.items():
112 self.assertEqual((root / 'first' / name).read_bytes(), contents)
113 old_index = next(i for i, row in enumerate(json.loads((root / 'first/source.json').read_text())) if row['path'] == 'synthetic.one')
114 new_index = next(i for i, row in enumerate(json.loads((root / 'changed/source.json').read_text())) if row['path'] == 'synthetic.one')
115 self.assertEqual((root / f'first/model/{old_index}/document.json').stat().st_ino,
116 (root / f'changed/model/{new_index}/document.json').stat().st_ino)
117 (source / 'a.one').unlink()
118 with patch('notebook_report.subprocess.run', side_effect=cached_run):
119 generate(source, root / 'deleted', previous=root / 'changed')
120 self.assertEqual(before, {p.relative_to(root / 'deleted'): p.read_bytes() for p in (root / 'deleted').rglob('*') if p.is_file()})
121
122
123if __name__ == '__main__':
124 unittest.main()