Cache for reading annotation
This commit is contained in:
1 parent
55d5cf16c9
commit
ff7496b113
12 files changed
+466
-13
No files matched your search
@@ -9,9 +9,31 @@ import pandas as pd
|
||||
from PIL import Image
|
||||
|
||||
from copienator.commands import add_final_score
|
||||
from copienator import atomic_write_json, read_json
|
||||
|
||||
|
||||
class AddFinalScoreTests(unittest.TestCase):
|
||||
def test_statistics_average_all_score_files_and_round_up_without_float_drift(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
returns = root / 'A Rendre'
|
||||
atomic_write_json(returns / 'Alice/score.json', {
|
||||
'Ex 1': '2.0', 'Ex 2': '0.1', 'Ex 3': '', 'Empty': '', 'Zero': 0,
|
||||
})
|
||||
atomic_write_json(returns / 'Bob/score.json', {
|
||||
'Ex 1': 2.01, 'Ex 2': '0.1', 'Ex 3': '3.5', 'Empty': None,
|
||||
})
|
||||
output = root / 'output'
|
||||
output.mkdir()
|
||||
add_final_score.write_score_statistics(returns, output)
|
||||
self.assertEqual(read_json(output / 'stat.json'), {
|
||||
'Ex 1': '2.1', 'Ex 2': '0.1', 'Ex 3': '3.5', 'Empty': '', 'Zero': '0.0',
|
||||
})
|
||||
# Rebuilding uses current returns, without accumulating old averages.
|
||||
atomic_write_json(returns / 'Bob/score.json', {'Ex 1': 4})
|
||||
add_final_score.write_score_statistics(returns, output)
|
||||
self.assertEqual(read_json(output / 'stat.json')['Ex 1'], '3.0')
|
||||
|
||||
def test_merged_histogram_is_copied_only_when_selected(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
@@ -80,6 +102,7 @@ class AddFinalScoreTests(unittest.TestCase):
|
||||
self.assertFalse((output / "Student.pdf").exists())
|
||||
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
|
||||
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram")
|
||||
self.assertEqual(read_json(output / "stat.json"), {"Ex 1": "4.0"})
|
||||
|
||||
def test_omits_answers_directory_when_it_was_not_generated(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
||||
@@ -8,8 +8,10 @@ from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, configuration, read_json
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configuration, read_json
|
||||
from copienator.annotation_data import AnnotationLoadResult
|
||||
from copienator.commands import annotating, giving_names
|
||||
from copienator.commands import reading_annotations as standard
|
||||
from copienator.commands import reading_grouped_annotations as reader
|
||||
from copienator.return_answers import (
|
||||
publish_answer_returns,
|
||||
@@ -216,6 +218,116 @@ class AnswerReturnTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class RereadAnswerReturnTests(unittest.TestCase):
|
||||
def check_reread(self, module, mode, *, existing_returns=True, fail_publication=False,
|
||||
failed_copy=False):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
root = Path(temporary.name)
|
||||
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.touch()
|
||||
data = {}
|
||||
original_score = b'{\n "Ex 1": "3.5"\n}\n'
|
||||
destinations = {}
|
||||
for copy_id in ("01", "02"):
|
||||
output = root / mode / f"Copie{copy_id}"
|
||||
output.mkdir()
|
||||
(output / "score.json").write_bytes(original_score)
|
||||
atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]})
|
||||
pdf = root / "Copies" / f"{copy_id}.pdf"
|
||||
if copy_id == "01":
|
||||
pdf.touch()
|
||||
data[copy_id] = {"Ex 1": {
|
||||
"result": {"score": 1, "feedback": []},
|
||||
"pdf_path": pdf,
|
||||
"coordinates": (0, 0),
|
||||
}}
|
||||
destination = workspace.return_dir / f"Renamed student ({copy_id})"
|
||||
destinations[copy_id] = destination
|
||||
if existing_returns:
|
||||
(destination / "answers").mkdir(parents=True)
|
||||
(destination / "answers" / "old.jpg").write_bytes(b"previous answers")
|
||||
(destination / "custom.pdf").write_bytes(b"keep PDF")
|
||||
(destination / "info.json").write_bytes(b"keep info")
|
||||
|
||||
if not failed_copy:
|
||||
del data["02"]
|
||||
|
||||
def compose(base, label, result, *args, **kwargs):
|
||||
# Make the rendered score observable in the published answer pixels.
|
||||
color = "red" if str(result["score"]) == "3.5" else "black"
|
||||
return Image.new("RGB", (100, 50), color), 10
|
||||
|
||||
def render(path):
|
||||
if Path(path) == question and fail_publication:
|
||||
raise ValueError("cannot render supplement")
|
||||
return Image.new("RGB", (100, 20), "blue"), 0, 0
|
||||
|
||||
with (
|
||||
patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True),
|
||||
patch.object(module, "load_annotation_data", return_value=AnnotationLoadResult(data, [])),
|
||||
patch.object(standard, "detect_checks_and_notes", return_value=([], None)),
|
||||
patch.object(annotating, "make_base_image", side_effect=render),
|
||||
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"),
|
||||
patch.object(reader, "save_checked_pdf"),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
contextlib.redirect_stderr(io.StringIO()),
|
||||
):
|
||||
status = module.run(
|
||||
workspace, update_score=True,
|
||||
return_answers_context=False, return_answers_question=True,
|
||||
return_answers_solution=False,
|
||||
)
|
||||
|
||||
expected = ExitCode.PARTIAL if failed_copy or fail_publication else ExitCode.SUCCESS
|
||||
self.assertEqual(status, expected)
|
||||
self.assertEqual((root / mode / "Copie01" / "score.json").read_bytes(), original_score)
|
||||
if not existing_returns:
|
||||
self.assertFalse(workspace.return_dir.exists())
|
||||
return
|
||||
for destination in destinations.values():
|
||||
self.assertEqual((destination / "custom.pdf").read_bytes(), b"keep PDF")
|
||||
self.assertEqual((destination / "info.json").read_bytes(), b"keep info")
|
||||
self.assertEqual((destinations["02"] / "answers" / "old.jpg").read_bytes(), b"previous answers")
|
||||
answers = destinations["01"] / "answers"
|
||||
if fail_publication:
|
||||
self.assertEqual((answers / "old.jpg").read_bytes(), b"previous answers")
|
||||
return
|
||||
self.assertEqual([p.name for p in answers.iterdir()], ["Ex 1.jpg"])
|
||||
with Image.open(answers / "Ex 1.jpg") as image:
|
||||
self.assertEqual(image.size, (100, 70))
|
||||
self.assertGreater(image.getpixel((50, 10))[2], 240)
|
||||
self.assertGreater(image.getpixel((50, 45))[0], 240)
|
||||
self.assertLess(image.getpixel((50, 45))[2], 10)
|
||||
|
||||
def test_reread_refreshes_answers_from_new_scores_and_supplements(self):
|
||||
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
|
||||
with self.subTest(mode=mode):
|
||||
self.check_reread(module, mode)
|
||||
|
||||
def test_reread_does_not_create_named_returns(self):
|
||||
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
|
||||
with self.subTest(mode=mode):
|
||||
self.check_reread(module, mode, existing_returns=False)
|
||||
|
||||
def test_failed_copy_does_not_republish_stale_annotations(self):
|
||||
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
|
||||
with self.subTest(mode=mode):
|
||||
self.check_reread(module, mode, failed_copy=True)
|
||||
|
||||
def test_failed_publication_preserves_previous_answers(self):
|
||||
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
|
||||
with self.subTest(mode=mode):
|
||||
self.check_reread(module, mode, fail_publication=True)
|
||||
|
||||
|
||||
class CompiledMembershipTests(unittest.TestCase):
|
||||
def test_update_score_preserves_file_and_manual_value_wins(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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")
|
||||
Reference in new issue
Block a user