Files
Copies/tests/test_answer_returns.py
T

344 lines
17 KiB
Python

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()