from __future__ import annotations import tempfile import unittest from pathlib import Path from unittest.mock import patch from PIL import Image from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json from copienator.annotation_modifications import ( APPLIED, BASELINE, DRAFT, body_image, read_instructions, stack, stage_baseline, ) from copienator.commands import modify_annotations from copienator.commands import reading_annotations as standard from copienator.commands import reading_grouped_annotations as grouped from copienator.filesystem import staged_files from copienator.return_answers import publish_answer_returns class ModificationTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) (self.root / "Copies" / "Copie43").mkdir(parents=True) (self.root / "labels").write_text("Ex 1\nEx 2\nEx 3\nEmpty\n") self.workspace = EvaluationWorkspace(self.root) self.mode = "Bnot" self.images = { "Ex 1": stack([Image.new("RGB", (100, 20), "red"), Image.new("RGB", (100, 40), "blue"), Image.new("RGB", (100, 60), "green")]), "Ex 2": stack([Image.new("RGB", (120, 30), "yellow"), Image.new("RGB", (120, 50), "purple")]), "Ex 3": stack([Image.new("RGB", (100, 10), "orange"), Image.new("RGB", (100, 30), "cyan")]), } self.heights = {"Ex 1": 20, "Ex 2": 30, "Ex 3": 10} self.scores = {"Ex 1": "3", "Ex 2": "4", "Ex 3": "4", "Empty": ""} self.info = {label: {"present": label != "Empty", "not_empty": label != "Empty", "touched": False, "score": score} for label, score in self.scores.items()} self.seed() @property def output(self): return self.root / self.mode / "Copie43" def seed(self): self.output.mkdir(parents=True, exist_ok=True) with staged_files(self.output) as staging: for label, image in self.images.items(): image.save(staging / f"{label}.jpg") atomic_write_json(staging / "score.json", self.scores) atomic_write_json(staging / "info.json", self.info) stage_baseline(self.root, self.mode, self.output, staging, self.images, self.heights, self.info, ["Ex 1"], self.scores) def apply(self, *lines): (self.root / DRAFT).write_text(f"### Source: {self.mode}\n" + "\n".join(lines)) return modify_annotations.run(self.workspace, annotation_dir=self.mode) def image(self, label): with Image.open(self.output / f"{label}.jpg") as image: return image.convert("RGB").copy() def assert_color(self, image, xy, expected): expected = Image.new("RGB", (1, 1), expected).getpixel((0, 0)) self.assertTrue(all(abs(a-b) < 12 for a, b in zip(image.getpixel(xy), expected)), (image.getpixel(xy), expected)) def test_cut_uses_body_height_preserves_own_headers_and_scores(self): self.apply("Copie43 Ex 1 c{40}2> |Ex 2") source, target = self.image("Ex 1"), self.image("Ex 2") self.assertEqual(source.size, (100, 80)) self.assertEqual(target.size, (120, 120)) self.assert_color(source, (50, 5), "red") self.assert_color(source, (50, 45), "green") self.assert_color(target, (50, 10), "yellow") self.assert_color(target, (50, 45), "blue") self.assert_color(target, (50, 90), "purple") self.assertEqual(read_json(self.output / "score.json"), self.scores) self.assertEqual(body_image(self.output, "Ex 1").size, (100, 100)) def test_repeated_application_edit_and_removal_rebuild_from_baseline(self): line = "Copie43 Ex 1 c{40}1> Ex 2|" self.apply(line) first = {p.name: p.read_bytes() for p in self.output.glob("*.jpg")} self.apply(line) self.assertEqual(first, {p.name: p.read_bytes() for p in self.output.glob("*.jpg")}) self.apply("Copie43 Ex 1 c{60}1> Ex 2|") self.assertEqual(self.image("Ex 2").height, 120) self.apply() self.assertEqual(self.image("Ex 1").size, self.images["Ex 1"].size) self.assertEqual(self.image("Ex 2").size, self.images["Ex 2"].size) self.assertEqual(read_json(self.output / "info.json"), self.info) def test_empty_target_order_multiple_sources_and_header_creation(self): self.apply("Copie43 Ex 1 c{40}1> Empty|", "Copie43 Ex 3 -> |Empty") image = self.image("Empty") self.assertEqual(image.height, 80 + 60 + 30) self.assert_color(image, (50, 90), "cyan") self.assert_color(image, (50, 140), "green") info = read_json(self.output / "info.json")["Empty"] self.assertTrue(info["present"] and info["not_empty"]) self.assertEqual(info["score"], "") self.apply() self.assertFalse((self.output / "Empty.jpg").exists()) self.assertFalse(read_json(self.output / "info.json")["Empty"]["not_empty"]) def test_invalid_file_does_not_apply_valid_first_instruction(self): before = {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()} with self.assertRaises(CliError): self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 3 -> Unknown|") self.assertEqual(before, {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()}) self.assertFalse((self.output.parent / APPLIED).exists()) with self.assertRaises(CliError): self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 2 -> Ex 3|") with self.assertRaises(CliError): self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 1 -> Ex 3|") with self.assertRaises(CliError): read_instructions("### Source: BGnot\n", "Bnot") def test_missing_baseline_requires_rereading_not_guessing_header(self): (self.output / BASELINE).unlink() with self.assertRaisesRegex(CliError, "Relancez"): self.apply("Copie43 Ex 1 -> Ex 2|") def test_grouped_supplements_use_destination_label_and_force_inclusion(self): self.mode = "BGnot" self.seed() extras = Image.new("RGB", (120, 12), "black") with patch.object(grouped, "get_extra_pdfs_as_images", return_value=[extras]) as get_extra, \ patch.object(grouped, "save_paginated_pdf") as save: self.apply("Copie43 Ex 1 c{40}2> Ex 2|") self.assertEqual([c.args[1] for c in get_extra.call_args_list], ["Ex 1", "Ex 2"]) self.assertIs(get_extra.call_args_list[0].kwargs["seen_contexts"], get_extra.call_args_list[1].kwargs["seen_contexts"]) groups = save.call_args.args[0] self.assertEqual([group[0].height for group in groups], [12, 12]) self.assertEqual(groups[1][1].height, 120) self.assertEqual(self.image("Ex 2").height, 120) # supplement never in answer self.assertTrue(read_json(self.output / "info.json")["Ex 2"]["touched"]) def test_rereading_replays_applied_not_draft_and_refreshes_baseline(self): self.apply("Copie43 Ex 1 c{40}1> Ex 2|") (self.root / DRAFT).write_text("broken unapplied draft") self.images["Ex 1"] = stack([Image.new("RGB", (100, 20), "red"), Image.new("RGB", (100, 100), "cyan")]) self.seed() self.assertEqual(self.image("Ex 2").height, 140) self.assert_color(self.image("Ex 2"), (50, 110), "cyan") self.assertEqual(body_image(self.output, "Ex 1").getpixel((20, 20)), (0, 255, 255)) def test_transaction_rolls_back_all_copies_and_applied_file(self): import shutil (self.root / "Copies/Copie44").mkdir() second = self.output.parent / "Copie44" shutil.copytree(self.output, second) self.apply("Copie43 Ex 1 -> Ex 2|") before = {str(p.relative_to(self.output.parent)): p.read_bytes() for p in self.output.parent.rglob("*") if p.is_file()} original = Path.replace def fail_once(path, target): if path.parent.name == "Copie44" and path.name == "Concat.jpg" and ".annotation-edits-" in str(path): raise OSError("simulated publish failure") return original(path, target) with patch.object(Path, "replace", fail_once), self.assertRaises(OSError): self.apply("Copie43 Ex 1 c{40}1> Ex 2|", "Copie44 Ex 1 -> Ex 2|") after = {str(p.relative_to(self.output.parent)): p.read_bytes() for p in self.output.parent.rglob("*") if p.is_file()} self.assertEqual(before, after) def test_individual_returns_contain_modified_body_with_original_target_header(self): from copienator import configuration self.apply("Copie43 Ex 1 c{40}2> |Ex 2") destination = self.root / "A Rendre/Student (43)" 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.output, destination) with Image.open(destination / "answers/Ex 2.jpg") as image: self.assertEqual(image.size, (120, 120)) self.assert_color(image, (50, 10), "yellow") self.assert_color(image, (50, 45), "blue") def test_empty_everything_clears_old_combined_outputs(self): self.apply("Copie43 Ex 1 xs Empty|", "Copie43 Ex 2 xs Empty|", "Copie43 Ex 3 xs Empty|") self.assertFalse((self.output / "Concat.jpg").exists()) self.assertFalse((self.output / "Concat_F.jpg").exists()) self.assertFalse(any(entry["not_empty"] for entry in read_json(self.output / "info.json").values())) def test_both_readers_capture_reviewed_header_and_handwriting_separately(self): from copy import deepcopy from copienator.commands import annotating for mode in ("BGnot", "Bnot"): with self.subTest(mode=mode): self.mode = mode self.seed() answer = self.root / "answer.pdf" answer.touch() data = {"43": {"Ex 1": {"pdf_path": answer, "coordinates": (0, 0), "result": {"score": 3, "feedback": [{"text": "delete me"}, {"text": "keep me"}]}}}} notes = Image.new("RGBA", (100, 110), (0, 0, 0, 0)) notes.paste((255, 0, 255, 255), (20, 40, 40, 60)) # body ink, old header = 10 notes.paste((0, 0, 0, 255), (20, 2, 40, 7)) # header ink stays there actions = [{"label": "Ex 1", "type": "del_global", "index": 0}] atomic_write_json(self.output / "bnote.json", {"images": [ {"label": "Ex 1", "hmin": 0, "hmax": 110, "header_height": 10}]}) def compose(_base, _label, result, *_args, **_kwargs): self.assertTrue(result["feedback"][0]["to_delete"]) self.assertNotIn("to_delete", result["feedback"][1]) return self.images["Ex 1"].copy(), 20 with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), \ patch.object(annotating, "compose_label_image", side_effect=compose), \ patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]): if mode == "Bnot": status = standard.apply_actions_and_regenerate( self.workspace, deepcopy(data), "43", actions, notes, list(self.scores)) else: status, _ = grouped.apply_actions_and_regenerate_grouped( self.workspace, deepcopy(data), "43", actions, {"Ex 1": {"img": notes, "old_header_h": 10}}, list(self.scores)) self.assertEqual(status, 0) body = body_image(self.output, "Ex 1") self.assertEqual(body.size, (100, 100)) self.assertEqual(body.getpixel((25, 35)), (255, 0, 255)) self.assertEqual(body.getpixel((25, 5)), (0, 0, 255)) self.apply("Copie43 Ex 1 c{40}2> Empty|") self.assert_color(self.image("Empty"), (25, 115), "magenta") self.assert_color(self.image("Ex 1"), (25, 4), "black") def test_selective_redo_does_not_bake_in_or_duplicate_previous_transfers(self): from copy import deepcopy from copienator.commands import annotating self.mode = "BGnot" self.seed() self.apply("Copie43 Ex 1 x> Ex 2|") self.assertFalse((self.output / "Ex 1.jpg").exists()) before = self.image("Ex 2").size answer = self.root / "answer.pdf" answer.touch() data = {"43": {label: {"result": {"score": self.scores[label], "feedback": []}, "pdf_path": answer, "coordinates": (0, 0)} for label in self.images}} with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), \ patch.object(annotating, "compose_label_image", return_value=(self.images["Ex 3"], 10)), \ patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]): for _ in range(2): status, _ = grouped.apply_actions_and_regenerate_grouped( self.workspace, deepcopy(data), "43", [], {}, list(self.scores), annotation_dir="BGnot", selected_labels={"Ex 3"}) self.assertEqual(status, 0) self.assertEqual(self.image("Ex 2").size, before) self.assertFalse((self.output / "Ex 1.jpg").exists()) self.assertEqual(body_image(self.output, "Ex 1").size, (100, 100))