149 lines
7.2 KiB
Python
149 lines
7.2 KiB
Python
import contextlib
|
|
from copy import deepcopy
|
|
import io
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from PIL import Image
|
|
|
|
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configuration
|
|
from copienator.annotation_data import AnnotationLoadResult
|
|
from copienator.annotation_read_cache import STATE_FILE
|
|
from copienator.custom_remarks import set_remark
|
|
from copienator.commands import annotating, reading_annotations as standard
|
|
from copienator.commands import reading_grouped_annotations as grouped
|
|
|
|
|
|
class IncrementalAnnotationReadsTests(unittest.TestCase):
|
|
def exercise(self, module, mode):
|
|
with tempfile.TemporaryDirectory() as temporary, contextlib.ExitStack() as stack:
|
|
root = Path(temporary)
|
|
workspace = EvaluationWorkspace(root)
|
|
for folder in ("Copies", "Par label", mode, "Text2"):
|
|
(root / folder).mkdir()
|
|
(root / "labels").write_text("Ex 1\n")
|
|
atomic_write_json(root / "correction.json", {})
|
|
question = root / "Text2" / "Ex 1.pdf"
|
|
question.write_bytes(b"original statement")
|
|
data = {}
|
|
for copy_id in ("01", "02"):
|
|
output = root / mode / f"Copie{copy_id}"
|
|
output.mkdir()
|
|
atomic_write_json(output / "score.json", {"Ex 1": "2"})
|
|
atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]})
|
|
pdf = root / "Copies" / f"{copy_id}.pdf"
|
|
pdf.write_bytes(b"student answer")
|
|
data[copy_id] = {"Ex 1": {"pdf_path": pdf,
|
|
"result": {"score": 1, "feedback": []}, "coordinates": (0, 0)}}
|
|
destination = workspace.return_dir / f"Renamed ({copy_id})" / "answers"
|
|
destination.mkdir(parents=True)
|
|
group = root / mode / "Ex 1 G1"
|
|
if mode == "BGnot":
|
|
group.mkdir()
|
|
atomic_write_json(group / "bnote.json", {"images": [
|
|
{"id": "01", "label": "Ex 1"}, {"id": "02", "label": "Ex 1"}]})
|
|
|
|
stack.enter_context(patch.object(configuration, "RETURN_ANSWERS_ENABLED", True))
|
|
stack.enter_context(patch.object(module, "load_annotation_data",
|
|
side_effect=lambda _: AnnotationLoadResult(deepcopy(data), [])))
|
|
detect = stack.enter_context(patch.object(standard, "detect_checks_and_notes",
|
|
return_value=([], None)))
|
|
scan = stack.enter_context(patch.object(grouped, "_scan_annotation_directory",
|
|
return_value=({}, {})))
|
|
stack.enter_context(patch.object(annotating, "make_base_image",
|
|
return_value=(Image.new("RGB", (100, 20), "white"), 0, 0)))
|
|
compose = stack.enter_context(patch.object(annotating, "compose_label_image",
|
|
return_value=(Image.new("RGB", (100, 50), "white"), 10)))
|
|
stack.enter_context(patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]))
|
|
stack.enter_context(patch.object(grouped, "save_paginated_pdf"))
|
|
stack.enter_context(patch.object(grouped, "save_checked_pdf"))
|
|
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
|
stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
|
|
|
|
def run(expected_count, **options):
|
|
compose.reset_mock()
|
|
detect.reset_mock()
|
|
scan.reset_mock()
|
|
status = module.run(workspace, update_score=True,
|
|
return_answers_context=False, return_answers_question=options.get("question", False),
|
|
return_answers_solution=False)
|
|
self.assertEqual(status, ExitCode.SUCCESS)
|
|
self.assertEqual(compose.call_count, expected_count)
|
|
if expected_count == 0:
|
|
detect.assert_not_called()
|
|
scan.assert_not_called()
|
|
|
|
run(2) # First run records fingerprints.
|
|
untouched = root / mode / "Copie02" / "Ex 1.jpg"
|
|
untouched_time = untouched.stat().st_mtime_ns
|
|
run(0)
|
|
score = root / mode / "Copie01" / "score.json"
|
|
score.touch() # Timestamp-only changes do not cause rendering.
|
|
run(0)
|
|
score.write_text('{"Ex 1": "3.5"}')
|
|
run(1)
|
|
self.assertEqual(compose.call_args.args[2]["score"], "3.5")
|
|
self.assertEqual(untouched.stat().st_mtime_ns, untouched_time)
|
|
if mode == "BGnot":
|
|
self.assertEqual(scan.call_args.args[1], {"01"})
|
|
run(0)
|
|
|
|
# Remarks are per-copy inputs, including their removal.
|
|
set_remark(root, "1", "Ex 1", "Justifier cette étape.")
|
|
run(1)
|
|
self.assertEqual(compose.call_args.args[2]["feedback"][-1]["text"], "Justifier cette étape.")
|
|
run(0)
|
|
set_remark(root, "01", "Ex 1", "Détailler le calcul.")
|
|
run(1)
|
|
self.assertEqual(len(compose.call_args.args[2]["feedback"]), 1)
|
|
set_remark(root, "1", "Ex 1", "")
|
|
run(1)
|
|
self.assertEqual(compose.call_args.args[2]["feedback"], [])
|
|
run(0)
|
|
|
|
# Per-copy sources and correction data invalidate only that copy.
|
|
(root / "Copies" / "01.pdf").write_bytes(b"replaced answer PDF")
|
|
run(1)
|
|
data["02"]["Ex 1"]["result"]["feedback"] = [{"text": "new feedback"}]
|
|
run(1)
|
|
# Changed tablet input cannot be mistaken for a score-only update.
|
|
annotation = (group if mode == "BGnot" else score.parent) / "Concat_annotated.pdf"
|
|
annotation.write_bytes(b"new tablet annotations")
|
|
run(2 if mode == "BGnot" else 1)
|
|
run(0)
|
|
|
|
# New shared documents and changed export options must not be skipped.
|
|
question.write_bytes(b"updated statement")
|
|
run(2)
|
|
run(2, question=True)
|
|
run(0, question=True)
|
|
# Missing generated files are rebuilt even when inputs match.
|
|
(root / mode / "Copie01" / "Ex 1.jpg").unlink()
|
|
run(1, question=True)
|
|
(workspace.return_dir / "Renamed (02)" / "answers" / "Ex 1.jpg").unlink()
|
|
run(1, question=True)
|
|
|
|
# A failed publication must be retried on the next run.
|
|
score.write_text('{"Ex 1": "3"}')
|
|
with patch.object(module, "update_named_return_answers", return_value=ExitCode.PARTIAL):
|
|
self.assertEqual(module.run(workspace, update_score=True,
|
|
return_answers_context=False, return_answers_question=True,
|
|
return_answers_solution=False), ExitCode.PARTIAL)
|
|
self.assertFalse((score.parent / STATE_FILE).exists())
|
|
run(1, question=True)
|
|
run(0, question=True)
|
|
|
|
# Normal reads still force regeneration and invalidate the snapshot.
|
|
if mode == "Bnot":
|
|
detect.return_value = ([{"label": "Ex 1", "type": "score", "value": 2}], None)
|
|
self.assertEqual(module.run(workspace), ExitCode.SUCCESS)
|
|
self.assertFalse((score.parent / STATE_FILE).exists())
|
|
|
|
def test_standard(self):
|
|
self.exercise(standard, "Bnot")
|
|
|
|
def test_grouped(self):
|
|
self.exercise(grouped, "BGnot")
|