Bnot : split large copies into pieces.

This commit is contained in:
sebastien committed 2026-10-23 08:32:17 +02:00
1 parent 834ffe32f2
commit 1959c5d3bb
10 files changed
+333 -52

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
"""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 CliError, EvaluationWorkspace, ExitCode, read_json
from copienator.annotation_data import AnnotationLoadResult
from copienator.annotation_documents import MAX_HEIGHT_PX, 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(checks, '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(MAX_HEIGHT_PX, 32000)
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_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, '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()
+22 -6
View File
@@ -2338,12 +2338,28 @@ class StandardCliTests(unittest.TestCase):
("01", "Ex 2", image, 0, []),
("02", "Ex 1", image, 0, []),
]
with patch.object(module, "MAX_HEIGHT_PX", 100), self.assertRaises(
CliError
) as raised:
module.split_batches(rendered)
self.assertIn("Copie01", str(raised.exception))
self.assertIn("cannot be split", str(raised.exception))
with patch.object(module, "MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered)
self.assertEqual(batches, [rendered[:2], rendered[2:]])
def test_grouped_oversized_answer_is_saved_as_its_own_group(self) -> None:
module = self.modules["annotating_by_label"]
rendered = [
("01", "Ex 1", Image.new("RGB", (10, 40)), 0, []),
("02", "Ex 1", Image.new("RGB", (10, 120)), 0, []),
("03", "Ex 1", Image.new("RGB", (10, 50)), 0, []),
]
with tempfile.TemporaryDirectory() as directory, patch.object(module, "MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered)
self.assertEqual(batches, [[rendered[0], rendered[2]], [rendered[1]]])
for index, batch in enumerate(batches, start=1):
module.save_batch(batch, "Ex 1", index, Path(directory))
metadata = read_json(Path(directory) / "Ex 1 G2/bnote.json")
self.assertEqual(metadata["height"], 120)
self.assertEqual([item["id"] for item in metadata["images"]], ["02"])
self.assertTrue((Path(directory) / "Ex 1 G2/Concat.pdf").is_file())
with self.assertRaises(CliError):
module.save_batch(rendered[:2], "Ex 1", 3, Path(directory))
def test_grouped_batching_reuses_space_in_earlier_groups(self) -> None:
module = self.modules["annotating_by_label"]