import contextlib from copy import deepcopy import io from pathlib import Path import tempfile import unittest from unittest.mock import patch from PIL import Image from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configuration from copienator.annotation_data import AnnotationLoadResult from copienator.annotation_read_cache import STATE_FILE from copienator.custom_remarks import set_remark from copienator.general_remarks import set_remarks, IMAGE_FILENAME from copienator.commands import annotating, reading_annotations as standard from copienator.commands import reading_grouped_annotations as grouped class IncrementalAnnotationReadsTests(unittest.TestCase): def exercise(self, module, mode): with tempfile.TemporaryDirectory() as temporary, contextlib.ExitStack() as stack: root = Path(temporary) workspace = EvaluationWorkspace(root) for folder in ("Copies", "Par label", mode, "Text2"): (root / folder).mkdir() (root / "labels").write_text("Ex 1\n") atomic_write_json(root / "correction.json", {}) question = root / "Text2" / "Ex 1.pdf" question.write_bytes(b"original statement") data = {} for copy_id in ("01", "02"): output = root / mode / f"Copie{copy_id}" output.mkdir() atomic_write_json(output / "score.json", {"Ex 1": "2"}) atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]}) pdf = root / "Copies" / f"{copy_id}.pdf" pdf.write_bytes(b"student answer") data[copy_id] = {"Ex 1": {"pdf_path": pdf, "result": {"score": 1, "feedback": []}, "coordinates": (0, 0)}} destination = workspace.return_dir / f"Renamed ({copy_id})" / "answers" destination.mkdir(parents=True) group = root / mode / "Ex 1 G1" if mode == "BGnot": group.mkdir() atomic_write_json(group / "bnote.json", {"images": [ {"id": "01", "label": "Ex 1"}, {"id": "02", "label": "Ex 1"}]}) stack.enter_context(patch.object(configuration, "RETURN_ANSWERS_ENABLED", True)) stack.enter_context(patch.object(module, "load_annotation_data", side_effect=lambda _: AnnotationLoadResult(deepcopy(data), []))) detect = stack.enter_context(patch.object(standard, "detect_checks_and_notes", return_value=([], None))) scan = stack.enter_context(patch.object(grouped, "_scan_annotation_directory", return_value=({}, {}))) stack.enter_context(patch.object(annotating, "make_base_image", return_value=(Image.new("RGB", (100, 20), "white"), 0, 0))) compose = stack.enter_context(patch.object(annotating, "compose_label_image", return_value=(Image.new("RGB", (100, 50), "white"), 10))) stack.enter_context(patch.object(grouped, "get_extra_pdfs_as_images", return_value=[])) stack.enter_context(patch.object(grouped, "save_paginated_pdf")) stack.enter_context(patch.object(grouped, "save_checked_pdf")) stack.enter_context(contextlib.redirect_stdout(io.StringIO())) stack.enter_context(contextlib.redirect_stderr(io.StringIO())) def run(expected_count, **options): compose.reset_mock() detect.reset_mock() scan.reset_mock() status = module.run(workspace, update_score=True, return_answers_context=False, return_answers_question=options.get("question", False), return_answers_solution=False) self.assertEqual(status, ExitCode.SUCCESS) self.assertEqual(compose.call_count, expected_count) if expected_count == 0: detect.assert_not_called() scan.assert_not_called() run(2) # First run records fingerprints. untouched = root / mode / "Copie02" / "Ex 1.jpg" untouched_time = untouched.stat().st_mtime_ns run(0) score = root / mode / "Copie01" / "score.json" score.touch() # Timestamp-only changes do not cause rendering. run(0) score.write_text('{"Ex 1": "3.5"}') run(1) self.assertEqual(compose.call_args.args[2]["score"], "3.5") self.assertEqual(untouched.stat().st_mtime_ns, untouched_time) if mode == "BGnot": self.assertEqual(scan.call_args.args[1], {"01"}) run(0) # Remarks are per-copy inputs, including their removal. set_remark(root, "1", "Ex 1", "Justifier cette étape.") run(1) self.assertEqual(compose.call_args.args[2]["feedback"][-1]["text"], "Justifier cette étape.") run(0) set_remark(root, "01", "Ex 1", "Détailler le calcul.") run(1) self.assertEqual(len(compose.call_args.args[2]["feedback"]), 1) set_remark(root, "1", "Ex 1", "") run(1) self.assertEqual(compose.call_args.args[2]["feedback"], []) run(0) with patch.object(annotating, "render_real_latex_text", return_value=Image.new("RGB", (100, 30), "white")): set_remarks(root, "1", ["First", "Second"]) run(1) run(0) set_remarks(root, "1", ["Second", "First"]) run(1) (root / mode / "Copie01" / IMAGE_FILENAME).unlink() run(1) set_remarks(root, "1", []) run(1) run(0) # Per-copy sources and correction data invalidate only that copy. (root / "Copies" / "01.pdf").write_bytes(b"replaced answer PDF") run(1) data["02"]["Ex 1"]["result"]["feedback"] = [{"text": "new feedback"}] run(1) # Changed tablet input cannot be mistaken for a score-only update. annotation = (group if mode == "BGnot" else score.parent) / "Concat_annotated.pdf" annotation.write_bytes(b"new tablet annotations") run(2 if mode == "BGnot" else 1) run(0) # New shared documents and changed export options must not be skipped. question.write_bytes(b"updated statement") run(2) run(2, question=True) run(0, question=True) # Missing generated files are rebuilt even when inputs match. (root / mode / "Copie01" / "Ex 1.jpg").unlink() run(1, question=True) (workspace.return_dir / "Renamed (02)" / "answers" / "Ex 1.jpg").unlink() run(1, question=True) # A failed publication must be retried on the next run. score.write_text('{"Ex 1": "3"}') with patch.object(module, "update_named_return_answers", return_value=ExitCode.PARTIAL): self.assertEqual(module.run(workspace, update_score=True, return_answers_context=False, return_answers_question=True, return_answers_solution=False), ExitCode.PARTIAL) self.assertFalse((score.parent / STATE_FILE).exists()) run(1, question=True) run(0, question=True) # Normal reads still force regeneration and invalidate the snapshot. if mode == "Bnot": detect.return_value = ([{"label": "Ex 1", "type": "score", "value": 2}], None) self.assertEqual(module.run(workspace), ExitCode.SUCCESS) self.assertFalse((score.parent / STATE_FILE).exists()) def test_standard(self): self.exercise(standard, "Bnot") def test_grouped(self): self.exercise(grouped, "BGnot")