Configuration de l'output final
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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
|
||||
|
||||
|
||||
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], ["001 - Ex 1.jpg", "002 - 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_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" / "001 - 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" / "002 - 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"})
|
||||
|
||||
|
||||
class CompiledMembershipTests(unittest.TestCase):
|
||||
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()
|
||||
@@ -502,11 +502,6 @@ class StandardCliTests(unittest.TestCase):
|
||||
"default",
|
||||
{"target": evaluation},
|
||||
),
|
||||
"verify_groups": (
|
||||
"verify_groups",
|
||||
"default",
|
||||
{"target": evaluation},
|
||||
),
|
||||
"annotating": (
|
||||
"annotation",
|
||||
"simple",
|
||||
@@ -690,6 +685,10 @@ class StandardCliTests(unittest.TestCase):
|
||||
annotations.mkdir(parents=True)
|
||||
atomic_write_json(copies / "Copie01.json", {"name": "Élève Test"})
|
||||
atomic_write_json(annotations / "score.json", {"total": 10})
|
||||
atomic_write_json(annotations / "info.json", {
|
||||
"total": {"present": False, "not_empty": False, "touched": False, "score": 10}
|
||||
})
|
||||
(evaluation / "labels").write_text("total\n")
|
||||
(annotations / "Concat.jpg").write_bytes(b"image")
|
||||
(evaluation / "names").write_text("Élève Test\n", encoding="utf-8")
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import contextlib
|
||||
import io
|
||||
import itertools
|
||||
import runpy
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, configuration
|
||||
from copienator.commands import clean, giving_names
|
||||
|
||||
|
||||
class ReturnOutputTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(self.temp.name))
|
||||
self.workspace.copies_dir.mkdir()
|
||||
atomic_write_json(self.workspace.copies_dir / "Copie01.json", {"name": "Student"})
|
||||
(self.workspace.copies_dir / "Copie01.pdf").write_bytes(b"original")
|
||||
atomic_write_json(self.workspace.correction_file, {})
|
||||
(self.workspace.root / "names").write_text("Student\n")
|
||||
self.source = self.workspace.root / "BGnot" / "Copie01"
|
||||
self.source.mkdir(parents=True)
|
||||
(self.source / "Concat.jpg").write_bytes(b"jpeg")
|
||||
(self.source / "Concat_F.pdf").write_bytes(b"pdf")
|
||||
atomic_write_json(self.source / "score.json", {"Ex 1": "4"})
|
||||
atomic_write_json(self.source / "info.json", {
|
||||
"Ex 1": {"present": True, "not_empty": True, "touched": True, "score": "4"}
|
||||
})
|
||||
self.answer_option = patch.object(configuration, "RETURN_ANSWERS_ENABLED", False)
|
||||
self.answer_option.start()
|
||||
self.addCleanup(self.answer_option.stop)
|
||||
self.destination = self.workspace.return_dir / "Student (01)"
|
||||
|
||||
def prepare(self, jpeg=True, pdf=True):
|
||||
with patch.object(configuration, "RETURN_JPEG_ENABLED", jpeg), patch.object(
|
||||
configuration, "RETURN_PDF_ENABLED", pdf
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(giving_names.run(self.workspace, annotation_dir="BGnot"), 0)
|
||||
|
||||
def test_all_combinations_and_reenable_preserve_sources_and_scores(self):
|
||||
for jpeg, pdf in itertools.product((True, False), repeat=2):
|
||||
with self.subTest(jpeg=jpeg, pdf=pdf):
|
||||
self.prepare()
|
||||
self.prepare(jpeg, pdf)
|
||||
expected = {"score.json", "info.json"}
|
||||
if jpeg:
|
||||
expected.add("Student.jpg")
|
||||
if pdf:
|
||||
expected.add("Student.pdf")
|
||||
self.assertEqual({p.name for p in self.destination.iterdir()}, expected)
|
||||
self.assertEqual((self.source / "Concat.jpg").read_bytes(), b"jpeg")
|
||||
self.assertEqual((self.source / "Concat_F.pdf").read_bytes(), b"pdf")
|
||||
self.assertEqual(
|
||||
(self.destination / "score.json").read_bytes(),
|
||||
(self.source / "score.json").read_bytes(),
|
||||
)
|
||||
self.prepare()
|
||||
self.assertEqual((self.destination / "Student.jpg").read_bytes(), b"jpeg")
|
||||
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
|
||||
|
||||
def test_disabling_removes_regular_files_and_broken_links_only(self):
|
||||
self.prepare()
|
||||
jpg = self.destination / "Student.jpg"
|
||||
jpg.unlink()
|
||||
jpg.write_bytes(b"old fallback copy")
|
||||
pdf = self.destination / "Student.pdf"
|
||||
pdf.unlink()
|
||||
pdf.symlink_to(self.source / "missing.pdf")
|
||||
unrelated = self.destination / "notes.txt"
|
||||
unrelated.write_text("keep")
|
||||
self.prepare(False, False)
|
||||
self.assertEqual({p.name for p in self.destination.iterdir()}, {"score.json", "info.json", "notes.txt"})
|
||||
self.assertTrue((self.source / "Concat_F.pdf").is_file())
|
||||
|
||||
def test_fallback_source_respects_options(self):
|
||||
self.source.parent.rename(self.workspace.root / "Anot")
|
||||
(self.workspace.root / "BGnot").mkdir()
|
||||
self.prepare(False, True)
|
||||
self.assertFalse((self.destination / "Student.jpg").exists())
|
||||
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
|
||||
self.assertTrue((self.destination / "score.json").is_file())
|
||||
|
||||
self.assertTrue((self.destination / "info.json").is_file())
|
||||
|
||||
def test_cleanup_preserves_enabled_returns_without_jpeg(self):
|
||||
self.prepare(False, True)
|
||||
with patch.object(configuration, "RETURN_JPEG_ENABLED", False):
|
||||
plan = clean.build_cleanup_plan(self.workspace)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
clean.apply_cleanup(self.workspace, plan)
|
||||
self.assertFalse(self.source.exists())
|
||||
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
|
||||
self.assertFalse((self.destination / "Student.pdf").is_symlink())
|
||||
self.assertTrue((self.destination / "score.json").is_file())
|
||||
self.assertTrue((self.destination / "info.json").is_file())
|
||||
|
||||
def test_cleanup_accepts_scores_only_but_still_requires_scores(self):
|
||||
self.prepare(False, False)
|
||||
with patch.object(configuration, "RETURN_JPEG_ENABLED", False):
|
||||
plan = clean.build_cleanup_plan(self.workspace)
|
||||
self.assertIn(self.destination / "score.json", plan.kept_files)
|
||||
(self.destination / "score.json").unlink()
|
||||
with self.assertRaisesRegex(clean.CliError, "score.json"):
|
||||
clean.build_cleanup_plan(self.workspace)
|
||||
|
||||
def test_older_config_defaults_to_enabled(self):
|
||||
old_config = self.workspace.root / "old_config.py"
|
||||
old_config.write_text("ALWAYS_CROP = False\n")
|
||||
with patch.dict("os.environ", {"COPIENATOR_CONFIG": str(old_config)}):
|
||||
loaded = runpy.run_path(configuration.__file__)
|
||||
self.assertIs(loaded["RETURN_JPEG_ENABLED"], True)
|
||||
self.assertIs(loaded["RETURN_PDF_ENABLED"], True)
|
||||
self.assertIs(loaded["RETURN_ANSWERS_ENABLED"], False)
|
||||
self.assertIs(loaded["RETURN_ANSWERS_CONTEXT"], False)
|
||||
self.assertIs(loaded["RETURN_ANSWERS_QUESTION"], True)
|
||||
self.assertIs(loaded["RETURN_ANSWERS_SOLUTION"], False)
|
||||
self.assertNotIn("RETURN_JSON_ENABLED", loaded)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user