289 lines
15 KiB
Python
289 lines
15 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_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))
|