Files
Copies/tests/test_annotation_modifications.py
T

304 lines
16 KiB
Python

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_general_remarks_survive_repeated_body_edits(self):
from copienator.general_remarks import IMAGE_FILENAME
for mode in ("Bnot", "BGnot"):
with self.subTest(mode=mode):
self.mode = mode
self.seed()
image = Image.new("RGB", (120, 60), "magenta")
image.save(self.output / IMAGE_FILENAME)
original = (self.output / IMAGE_FILENAME).read_bytes()
for _ in range(2):
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
self.assertEqual((self.output / IMAGE_FILENAME).read_bytes(), original)
self.assert_color(self.image("Concat"), (50, 30), "magenta")
self.assertTrue((self.output / "Concat_F.pdf").is_file())
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_retained_empty_scan_stays_empty_after_unrelated_edits_and_can_be_replaced(self):
self.images["Empty"] = Image.new("RGB", (100, 60), "white")
self.heights["Empty"] = 10
self.info["Empty"]["present"] = True
self.seed()
self.apply("Copie43 Ex 1 -> Ex 2|")
info = read_json(self.output / "info.json")
self.assertTrue((self.output / "Empty.jpg").is_file())
self.assertEqual(info["Empty"], self.info["Empty"])
with Image.open(self.output / "Concat.jpg") as image:
self.assertEqual(image.height, 120 + 180 + 40)
self.apply("Copie43 Ex 1 -> Empty")
self.assertTrue(read_json(self.output / "info.json")["Empty"]["not_empty"])
self.apply()
self.assertEqual(read_json(self.output / "info.json")["Empty"], self.info["Empty"])
def test_removed_body_is_absent_and_not_exported_as_an_empty_scan(self):
self.apply("Copie43 Ex 1 x> Ex 2")
info = read_json(self.output / "info.json")["Ex 1"]
self.assertFalse(info["present"])
self.assertFalse(info["not_empty"])
self.assertFalse((self.output / "Ex 1.jpg").exists())
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))