"""Large checked copies survive the tablet round trip as separate documents.""" import tempfile import unittest from pathlib import Path from unittest.mock import patch from PIL import Image, ImageDraw from copienator import configuration from copienator import CliError, EvaluationWorkspace, ExitCode, read_json from copienator.annotation_data import AnnotationLoadResult from copienator.annotation_documents import annotation_limits, copy_documents from copienator.commands import annotating_with_checks as checks from copienator.commands import export, import_annotations, reading_annotations as reader from copienator.commands import reading_grouped_annotations as grouped_reader class CheckedDocumentTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) self.workspace = EvaluationWorkspace(self.root / 'Exam') for name in ('Copies', 'Par label'): (self.workspace.root / name).mkdir(parents=True) (self.workspace.root / 'labels').write_text('Ex 1\nEx 2\nEx 3\n') (self.workspace.root / 'correction.json').write_text('{}') self.heights = {'Ex 1': 60, 'Ex 2': 40, 'Ex 3': 60} self.labels = {} for label in self.heights: pdf = self.workspace.root / 'Copies' / f'{label}.pdf' pdf.touch() self.labels[label] = {'pdf_path': pdf, 'result': {'score': 2, 'feedback': []}, 'coordinates': (0, 0)} self.output = self.workspace.root / 'Bnot' / 'Copie01' def compose(self, base, label, result, coordinates, **kwargs): image = Image.new('RGB', (100, self.heights[label]), 'white') callback = kwargs.get('draw_callback') if callback: callback.__self__.checkboxes.append({ 'type': 'score', 'label': label, 'value': 3, 'final_box': [10, 5, 30, 25], }) ImageDraw.Draw(image).rectangle((10, 5, 30, 25), outline='black', width=2) return image, 0 def generate(self, overwrite=False): with ( patch.object(configuration, 'ANNOTATION_MAX_HEIGHT_PX', 100), patch.object(checks.annotating, 'make_base_image', return_value=(None, 0, 0)), patch.object(checks.annotating, 'compose_label_image', side_effect=self.compose), ): return checks._render_student(self.workspace, '01', self.labels, overwrite=overwrite, output_mode='checks') def test_splits_on_labels_and_resets_geometry_at_each_part(self): self.assertEqual(annotation_limits()[0], 33000) self.assertEqual(self.generate(), 'success') parts = copy_documents(self.output) self.assertEqual([part.name for part in parts], ['Part1', 'Part2']) self.assertEqual([read_json(p / 'bnote.json')['height'] for p in parts], [100, 60]) self.assertEqual([item['hmin'] for item in read_json(self.output / 'bnote.json')['images']], [0, 60, 0]) self.assertEqual(read_json(parts[1] / 'checkboxes.json')[0]['global_box'], [10, 5, 30, 25]) self.assertTrue(checks._output_complete(self.output)) self.assertEqual(self.generate(), 'skipped') (parts[1] / 'Reference.jpg').unlink() self.assertFalse(checks._output_complete(self.output)) def test_overwrite_removes_stale_parts_and_keeps_small_copy_layout(self): self.generate() self.labels = {'Ex 1': self.labels['Ex 1']} self.generate(overwrite=True) self.assertEqual(copy_documents(self.output), [self.output]) self.assertTrue((self.output / 'Concat.pdf').is_file()) self.assertFalse((self.output / 'Part1').exists()) def test_pixel_limit_splits_documents_at_exact_boundary(self): with patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 6000): self.assertEqual(self.generate(), 'success') parts = copy_documents(self.output) self.assertEqual([read_json(p / 'bnote.json')['height'] for p in parts], [60, 40, 60]) def test_pixel_limit_accounts_for_width_growth_and_preserves_previous_copy(self): original = self.compose def wider(base, label, *args, **kwargs): image, header = original(base, label, *args, **kwargs) if label == 'Ex 2': image = image.resize((200, image.height)) return image, header with patch.object(self, 'compose', side_effect=wider), patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 12000): self.assertEqual(self.generate(), 'success') parts = copy_documents(self.output) self.assertEqual([read_json(p / 'bnote.json')['width'] for p in parts], [100, 200, 100]) sentinel = self.output / 'sentinel' sentinel.write_text('previous') with patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 5999): with self.assertRaisesRegex(CliError, 'Copie01.*Ex 1'): self.generate(overwrite=True) self.assertEqual(sentinel.read_text(), 'previous') def test_single_oversized_answer_and_save_failure_preserve_previous_copy(self): self.generate() sentinel = self.output / 'sentinel' sentinel.write_text('previous') self.heights['Ex 3'] = 101 with self.assertRaisesRegex(CliError, 'Copie01.*Ex 3'): self.generate(overwrite=True) self.assertEqual(sentinel.read_text(), 'previous') self.heights['Ex 3'] = 60 original = checks._save_document def fail_second(directory, *args): if directory.name == 'Part2': raise OSError('disk full') original(directory, *args) with patch.object(checks, '_save_document', side_effect=fail_second): with self.assertRaisesRegex(OSError, 'disk full'): self.generate(overwrite=True) self.assertEqual(sentinel.read_text(), 'previous') def returned_parts(self): self.generate() export_root = self.root / 'Export' with patch.object(export.configuration, 'EXPORT_DIR', export_root): self.assertEqual(export.run(self.workspace, annotation_dir='Bnot'), ExitCode.SUCCESS) self.assertEqual(sorted(p.name for p in (export_root / 'Exam').iterdir()), ['Copie01 Part1.pdf', 'Copie01 Part2.pdf']) import_root = self.root / 'Import' import_root.mkdir() for part in copy_documents(self.output): with Image.open(part / 'Reference.jpg') as reference: image = reference.convert('RGB') draw = ImageDraw.Draw(image) # Change Ex 3's score and add handwriting in its body. if part.name == 'Part2': draw.rectangle((14, 9, 26, 21), fill='black') draw.rectangle((70, 40, 85, 50), fill='black') image.save(import_root / f'Copie01 {part.name}.pdf', 'PDF', resolution=72) self.assertEqual(import_annotations.sync_annotated( self.workspace, annotation_dir_name='Bnot', import_dir=import_root), ExitCode.SUCCESS) def test_export_import_and_reader_merge_parts_without_moving_notes(self): self.returned_parts() with ( patch.object(reader, 'load_annotation_data', return_value=AnnotationLoadResult({'01': self.labels}, [])), patch.object(reader.annotating, 'make_base_image', return_value=(None, 0, 0)), patch.object(reader.annotating, 'compose_label_image', side_effect=self.compose), ): self.assertEqual(reader.run(self.workspace), ExitCode.SUCCESS) self.assertEqual(read_json(self.output / 'score.json'), {'Ex 1': '2', 'Ex 2': '2', 'Ex 3': '3'}) with Image.open(self.output / 'Ex 3.jpg') as image: self.assertLess(max(image.getpixel((77, 45))), 80) with Image.open(self.output / 'Ex 1.jpg') as image: self.assertGreater(min(image.getpixel((77, 45))), 230) def test_missing_return_part_preserves_previous_regenerated_files(self): self.returned_parts() (self.output / 'Part2' / 'Concat_annotated.pdf').unlink() (self.output / 'score.json').write_text('{"previous": true}') with patch.object(reader, 'load_annotation_data', return_value=AnnotationLoadResult({'01': self.labels}, [])): self.assertEqual(reader.run(self.workspace), ExitCode.PARTIAL) self.assertEqual(read_json(self.output / 'score.json'), {'previous': True}) def test_redo_reader_accepts_split_copy_documents(self): self.returned_parts() # Redo uses the same document layout under BRnot. self.output.parent.rename(self.workspace.root / 'BRnot') actions, notes, incomplete = grouped_reader._scan_redo_annotations( self.workspace.root / 'BRnot', {'01': set(self.labels)}) self.assertFalse(incomplete) self.assertEqual(set(notes['01']), set(self.labels)) self.assertEqual(actions['01'][0]['label'], 'Ex 3') (self.workspace.root / 'BRnot/Copie01/Part2/Concat_annotated.pdf').unlink() _, _, incomplete = grouped_reader._scan_redo_annotations( self.workspace.root / 'BRnot', {'01': set(self.labels)}) self.assertEqual(incomplete, {'01'}) if __name__ == '__main__': unittest.main()