import contextlib import io import itertools import tempfile import unittest from pathlib import Path from unittest.mock import patch from PIL import Image from copienator import EvaluationWorkspace, atomic_write_json, configuration, read_json from copienator.commands import annotating, giving_names from copienator.commands import reading_grouped_annotations as reader from copienator.return_answers import ( publish_answer_returns, save_return_answer_options, ) class AnswerReturnTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.source = self.root / "BGnot" / "Copie01" self.source.mkdir(parents=True) self.destination = self.root / "A Rendre" / "Student (01)" self.destination.mkdir(parents=True) (self.root / "labels").write_text("Ex 1\nEx 2\nEmpty\n") atomic_write_json(self.source / "score.json", {"Ex 1": "4", "Ex 2": "2", "Empty": "0"}) atomic_write_json(self.source / "info.json", { "Ex 1": {"present": True, "not_empty": True, "touched": False, "score": "4"}, "Ex 2": {"present": True, "not_empty": True, "touched": True, "score": "2"}, "Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"}, }) for label in ("Ex 1", "Ex 2", "Empty"): Image.new("RGB", (100, 30), "red").save(self.source / f"{label}.jpg") for folder in ("Text2", "Sol2"): (self.root / folder).mkdir() for label in ("Ex 1", "Ex 2"): (self.root / "Text2" / f"{label}.pdf").touch() (self.root / "Sol2" / f"{label}.pdf").touch() (self.root / "Text2" / "CTXT Ex 1 -> Ex 2.pdf").touch() @staticmethod def supplement(path): path = Path(path) color = "blue" if path.name.startswith("CTXT") else "green" if path.parent.name == "Text2" else "yellow" return Image.new("RGB", (100, 20), color), 0, 0 def test_all_supplement_combinations_always_include_annotated_answer(self): for context, question, solution in itertools.product((False, True), repeat=3): with self.subTest(context=context, question=question, solution=solution), patch.multiple( configuration, RETURN_ANSWERS_ENABLED=True, RETURN_ANSWERS_CONTEXT=context, RETURN_ANSWERS_QUESTION=question, RETURN_ANSWERS_SOLUTION=solution, ), patch.object(annotating, "make_base_image", side_effect=self.supplement): publish_answer_returns(self.root, self.source, self.destination) files = sorted((self.destination / "answers").glob("*.jpg")) self.assertEqual([p.name for p in files], ["Ex 1.jpg", "Ex 2.jpg"]) with Image.open(files[0]) as image: self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution)))) colors = [] if context: colors.append((0, 0, 255)) if question: colors.append((0, 128, 0)) if solution: colors.append((255, 255, 0)) colors.append((255, 0, 0)) for index, color in enumerate(colors): pixel = image.getpixel((50, index * 20 + 10)) self.assertTrue(all(abs(a - b) < 10 for a, b in zip(pixel, color))) self.assertEqual(read_json(self.destination / "info.json"), read_json(self.source / "info.json")) def test_saved_reading_options_override_configuration_for_answers(self): save_return_answer_options( self.root, context=True, question=False, solution=True, ) with patch.multiple( configuration, RETURN_ANSWERS_ENABLED=True, RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=True, RETURN_ANSWERS_SOLUTION=False, ), patch.object( annotating, "make_base_image", side_effect=self.supplement ): publish_answer_returns(self.root, self.source, self.destination) with Image.open(self.destination / "answers" / "Ex 1.jpg") as image: self.assertEqual(image.size, (100, 70)) for y, color in ( (10, (0, 0, 255)), (30, (255, 255, 0)), (50, (255, 0, 0)), ): self.assertTrue( all( abs(actual - expected) < 10 for actual, expected in zip(image.getpixel((50, y)), color) ) ) def test_missing_supplement_is_optional_and_failure_preserves_old_answers(self): with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True, RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=True, RETURN_ANSWERS_SOLUTION=False), patch.object( annotating, "make_base_image", side_effect=self.supplement ): (self.root / "Text2" / "Ex 1.pdf").unlink() publish_answer_returns(self.root, self.source, self.destination) path = self.destination / "answers" / "Ex 1.jpg" with Image.open(path) as image: self.assertEqual(image.size, (100, 30)) original = path.read_bytes() (self.source / "Ex 2.jpg").unlink() with self.assertRaises(FileNotFoundError): publish_answer_returns(self.root, self.source, self.destination) self.assertEqual(path.read_bytes(), original) self.assertTrue((self.destination / "answers" / "Ex 2.jpg").exists()) def test_disabling_clears_individual_images_but_retains_info(self): with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True, RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False, RETURN_ANSWERS_SOLUTION=False): publish_answer_returns(self.root, self.source, self.destination) with patch.object(configuration, "RETURN_ANSWERS_ENABLED", False): publish_answer_returns(self.root, self.source, self.destination) self.assertEqual(list((self.destination / "answers").iterdir()), []) self.assertTrue(read_json(self.destination / "info.json")["Ex 2"]["touched"]) def test_missing_info_requires_recompilation_instead_of_guessing(self): (self.source / "info.json").unlink() (self.source / "Concat_F.pdf").touch() with self.assertRaisesRegex(ValueError, "recompile"): publish_answer_returns(self.root, self.source, self.destination) def test_info_replaces_touched_and_tracks_manual_score_edits(self): atomic_write_json(self.destination / "touched.json", {"obsolete": True}) atomic_write_json(self.source / "score.json", {"Ex 1": "3.5", "Ex 2": "2", "Empty": "0"}) with patch.object(configuration, "RETURN_ANSWERS_ENABLED", False): publish_answer_returns(self.root, self.source, self.destination) self.assertFalse((self.destination / "touched.json").exists()) self.assertEqual(read_json(self.destination / "info.json")["Ex 1"], { "present": True, "not_empty": True, "touched": False, "score": "3.5" }) def test_invalid_info_preserves_previous_return(self): atomic_write_json(self.destination / "info.json", {"previous": "keep"}) info = read_json(self.source / "info.json") info["Empty"]["touched"] = True atomic_write_json(self.source / "info.json", info) with self.assertRaisesRegex(ValueError, "Invalid question information"): publish_answer_returns(self.root, self.source, self.destination) self.assertEqual(read_json(self.destination / "info.json"), {"previous": "keep"}) def test_publication_is_independent_of_full_jpeg_and_pdf_options(self): workspace = EvaluationWorkspace(self.root) workspace.copies_dir.mkdir() atomic_write_json(workspace.copies_dir / "Copie01.json", {"name": "Student"}) (self.root / "names").write_text("Student\n") with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True, RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False, RETURN_ANSWERS_SOLUTION=False, RETURN_JPEG_ENABLED=False, RETURN_PDF_ENABLED=False), contextlib.redirect_stdout(io.StringIO()): self.assertEqual(giving_names.run(workspace, annotation_dir="BGnot"), 0) self.assertEqual({p.name for p in self.destination.iterdir()}, {"answers", "score.json", "info.json"}) def test_update_uses_copy_id_from_renamed_folder_and_touches_only_answers(self): workspace = EvaluationWorkspace(self.root) workspace.copies_dir.mkdir() atomic_write_json(workspace.copies_dir / "Copie01.json", {"name": "Student"}) renamed = self.destination.with_name("Nom modifié manuellement (01)") self.destination.rename(renamed) (renamed / "Nom personnalisé.jpg").write_bytes(b"keep-jpeg") (renamed / "Nom personnalisé.pdf").write_bytes(b"keep-pdf") (renamed / "score.json").write_bytes(b"keep-score") (renamed / "info.json").write_bytes(b"keep-info") answers = renamed / "answers" answers.mkdir() (answers / "obsolete.jpg").write_bytes(b"obsolete") preserved = { path.name: path.read_bytes() for path in renamed.iterdir() if path.is_file() } with patch.multiple( configuration, RETURN_ANSWERS_ENABLED=True, RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False, RETURN_ANSWERS_SOLUTION=False, ), contextlib.redirect_stdout(io.StringIO()): self.assertEqual( giving_names.run(workspace, annotation_dir="BGnot", update=True), 0, ) self.assertFalse((workspace.return_dir / "Student (01)").exists()) self.assertEqual( { path.name: path.read_bytes() for path in renamed.iterdir() if path.is_file() }, preserved, ) self.assertEqual( sorted(path.name for path in answers.iterdir()), ["Ex 1.jpg", "Ex 2.jpg"], ) class CompiledMembershipTests(unittest.TestCase): def test_update_score_preserves_file_and_manual_value_wins(self): with tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory)) output = workspace.root / "BGnot" / "Copie01" output.mkdir(parents=True) original_score = b'{\n "Ex 1": "3.5"\n}\n' (output / "score.json").write_bytes(original_score) answer = workspace.root / "answer.pdf" answer.touch() data = { "01": { "Ex 1": { "result": {"score": 1, "feedback": []}, "pdf_path": answer, "coordinates": (0, 0), } } } rendered_scores = [] def compose(base, label, result, *args, **kwargs): rendered_scores.append(result["score"]) return Image.new("RGB", (100, 50), "white"), 0 with patch.object( annotating, "make_base_image", return_value=(None, 0, 0) ), patch.object( annotating, "compose_label_image", side_effect=compose ), patch.object( reader, "get_extra_pdfs_as_images", return_value=[] ), patch.object(reader, "save_paginated_pdf"): status, _ = reader.apply_actions_and_regenerate_grouped( workspace, data, "01", [{"label": "Ex 1", "type": "score", "value": "2"}], {}, ["Ex 1"], update_score=True, ) self.assertEqual(status, 0) self.assertEqual(rendered_scores, ["3.5"]) self.assertEqual((output / "score.json").read_bytes(), original_score) self.assertEqual(read_json(output / "info.json")["Ex 1"]["score"], "3.5") def test_membership_matches_actual_groups_and_saves_every_nonempty_answer(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) workspace = EvaluationWorkspace(root) output = root / "BGnot" / "Copie01" output.mkdir(parents=True) answer = root / "answer.pdf" answer.touch() results = { "Perfect": {"score": 4, "feedback": []}, "Low": {"score": 2, "feedback": []}, "Feedback": {"score": 4, "feedback": [{"text": "keep"}]}, "Deleted": {"score": 4, "feedback": [{"text": "delete", "to_delete": True}]}, "Handwriting": {"score": 4, "feedback": []}, "Empty": {"score": 0, "error": "empty-answer"}, } data = {"01": {label: {"result": result, "pdf_path": answer, "coordinates": (0, 0)} for label, result in results.items()}} all_labels = [*results, "Absent"] rendered = [] def compose(base, label, *args, **kwargs): rendered.append(label) return Image.new("RGB", (100, 50), "white"), 0 notes = {"Handwriting": {"img": Image.new("RGBA", (100, 50), "red"), "old_header_h": 0}} with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), patch.object( annotating, "compose_label_image", side_effect=compose ), patch.object(reader, "get_extra_pdfs_as_images", return_value=[]), patch.object( reader, "save_paginated_pdf" ) as save_pdf: status, _ = reader.apply_actions_and_regenerate_grouped(workspace, data, "01", [], notes, all_labels) self.assertEqual(status, 0) info = read_json(output / "info.json") touched = {label: entry["touched"] for label, entry in info.items()} self.assertEqual({label for label, value in touched.items() if value}, {"Low", "Feedback", "Handwriting"}) self.assertEqual(len(save_pdf.call_args.args[0]), sum(touched.values())) self.assertEqual({label for label, entry in info.items() if entry["present"] and entry["not_empty"]}, set(results) - {"Empty"}) self.assertEqual(info["Empty"], {"present": True, "not_empty": False, "touched": False, "score": "0"}) self.assertEqual(info["Absent"], {"present": False, "not_empty": False, "touched": False, "score": ""}) self.assertEqual(info["Perfect"], {"present": True, "not_empty": True, "touched": False, "score": "4"}) self.assertNotIn("Empty", rendered) for label in rendered: self.assertTrue((output / f"{label}.jpg").is_file()) # Selective redo keeps unselected saved answers in the PDF, # including previously perfect answers; touched must follow it. status, _ = reader.apply_actions_and_regenerate_grouped( workspace, data, "01", [], {}, all_labels, selected_labels={"Low"} ) self.assertEqual(status, 0) self.assertTrue(read_json(output / "info.json")["Perfect"]["touched"]) self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"]) def test_all_empty_removes_stale_concat_and_records_false(self): with tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory)) output = workspace.root / "BGnot" / "Copie01" output.mkdir(parents=True) for name in ("Concat.jpg", "Concat_F.pdf"): (output / name).write_bytes(b"stale") atomic_write_json(output / "touched.json", {"Empty": True}) atomic_write_json(output / "answer_labels.json", ["Empty"]) status, _ = reader.apply_actions_and_regenerate_grouped( workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"}}}}, "01", [], {}, ["Empty"] ) self.assertEqual(status, 0) self.assertEqual(read_json(output / "info.json"), { "Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"} }) self.assertFalse((output / "Concat.jpg").exists()) self.assertFalse((output / "Concat_F.pdf").exists()) self.assertFalse((output / "touched.json").exists()) self.assertFalse((output / "answer_labels.json").exists()) if __name__ == "__main__": unittest.main()