Files
Copies/tests/test_incremental_annotation_reads.py
T

135 lines
6.6 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.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)
# 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")