| 1 | import base64 |
| 2 | from io import BytesIO |
| 3 | import json |
| 4 | import hashlib |
| 5 | from pathlib import Path |
| 6 | import re |
| 7 | from tempfile import TemporaryDirectory |
| 8 | import shutil |
| 9 | import subprocess |
| 10 | import unittest |
| 11 | from unittest.mock import patch |
| 12 | import xml.etree.ElementTree as ET |
| 13 | |
| 14 | from PIL import Image |
| 15 | from notebook_report import generate |
| 16 | from document_model import EXPORTER |
| 17 | |
| 18 | |
| 19 | class 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 | |
| 123 | if __name__ == '__main__': |
| 124 | unittest.main() |