diff --git a/copienator/annotation_modifications.py b/copienator/annotation_modifications.py index 5cd2447..486e08a 100644 --- a/copienator/annotation_modifications.py +++ b/copienator/annotation_modifications.py @@ -9,6 +9,7 @@ from pathlib import Path from PIL import Image, ImageDraw from copienator import CliError, atomic_write_json, read_json, utils +from copienator.annotation_read_cache import STATE_FILE from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text DRAFT = "annotation_modifications.txt" @@ -153,7 +154,7 @@ def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruct def output_removals(directory: Path) -> tuple[str, ...]: info = read_json(directory / "info.json") if (directory / "info.json").exists() else {} - return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json", + return (STATE_FILE, "Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json", *(f"{label}.jpg" for label, entry in info.items() if Path(label).name == label)) diff --git a/copienator/annotation_read_cache.py b/copienator/annotation_read_cache.py new file mode 100644 index 0000000..3eca204 --- /dev/null +++ b/copienator/annotation_read_cache.py @@ -0,0 +1,77 @@ +"""Small content fingerprints for incremental --update-score reads.""" +from hashlib import file_digest, sha256 +import json +from pathlib import Path + +from copienator import atomic_write_json, configuration, read_json +from copienator.annotation_documents import copy_documents +from copienator.return_answers import load_return_answer_options + +STATE_FILE = ".annotation-read-state.json" +DOCUMENT_INPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat_annotated.pdf") + + +def _files_digest(paths) -> str: + digest = sha256() + for path in sorted(set(paths)): + digest.update(str(path).encode()) + if path.is_file(): + with path.open("rb") as stream: + digest.update(file_digest(stream, "sha256").digest()) + else: + digest.update(b"missing") + return digest.hexdigest() + + +class AnnotationReadCache: + def __init__(self, workspace, mode: str, data: dict, group_dirs=()): + self.workspace = workspace + self.mode = mode + self.signatures = {} + root = workspace.root + shared = [root / "labels", root / mode / "annotation_modifications_applied.txt"] + for folder in ("Text2", "Sol2"): + shared.extend((root / folder).glob("*.pdf")) + for directory in group_dirs: + shared.extend(directory / name for name in DOCUMENT_INPUTS) + common = (_files_digest(shared), load_return_answer_options(root), + configuration.RETURN_ANSWERS_ENABLED, + configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER) + for copy_id, labels in data.items(): + directory = root / mode / f"Copie{copy_id}" + paths = [directory / "score.json", directory / "bnote.json"] + for document in copy_documents(directory): + paths.extend(document / name for name in DOCUMENT_INPUTS) + for content in labels.values(): + pdf = Path(content["pdf_path"]) + paths.extend((pdf, pdf.with_suffix(".layout.json"))) + payload = json.dumps((1, common, labels, _files_digest(paths)), + sort_keys=True, default=str).encode() + self.signatures[copy_id] = sha256(payload).hexdigest() + + def unchanged(self, copy_id: str) -> bool: + directory = self.workspace.root / self.mode / f"Copie{copy_id}" + try: + state = read_json(directory / STATE_FILE) + return (state["signature"] == self.signatures[copy_id] + and bool(state["outputs"]) + and all(Path(path).is_file() for path in state["outputs"])) + except (OSError, ValueError, TypeError, KeyError): + return False + + def remember(self, copy_ids) -> None: + for copy_id in copy_ids: + directory = self.workspace.root / self.mode / f"Copie{copy_id}" + outputs = [directory / "info.json"] + info = read_json(directory / "info.json") + outputs.extend(directory / f"{label}.jpg" for label, entry in info.items() + if entry["present"]) + outputs.extend(directory.glob("Concat*.*")) + outputs.extend(directory.glob(".annotation-base-*.png")) + outputs.extend(directory.glob("annotation_baseline.json")) + for destination in self.workspace.return_dir.glob(f"* ({copy_id})"): + outputs.extend((destination / "answers").glob("*.jpg")) + atomic_write_json(directory / STATE_FILE, { + "signature": self.signatures[copy_id], + "outputs": [str(path) for path in outputs], + }) diff --git a/copienator/commands/add_final_score.py b/copienator/commands/add_final_score.py index 89d20c8..0acc022 100644 --- a/copienator/commands/add_final_score.py +++ b/copienator/commands/add_final_score.py @@ -2,11 +2,13 @@ import argparse import math import shutil import sys +from decimal import Decimal, InvalidOperation, ROUND_CEILING from pathlib import Path import pandas as pd from PIL import Image, ImageDraw, ImageFont +from copienator import atomic_write_json, read_json from copienator.configuration import ( FINAL_SCORE_FONT_PATH, FINAL_SCORE_HISTOGRAM_PATH, @@ -72,6 +74,39 @@ def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) - shutil.copy2(source, destination) print(f"Copied histogram: {destination}") + +def write_score_statistics(search_path: Path, output_dir: Path) -> None: + """Average available per-question scores, rounding upward to one decimal.""" + scores_by_label = {} + for path in sorted(search_path.glob("*/score.json")): + try: + scores = read_json(path) + if not isinstance(scores, dict): + raise ValueError("expected an object of per-label scores") + except (OSError, ValueError) as exc: + print(f"Warning: Could not read scores from '{path}': {exc}") + continue + for label, value in scores.items(): + values = scores_by_label.setdefault(label, []) + if value is None or (isinstance(value, str) and not value.strip()): + continue + try: + number = Decimal(str(value)) + if not number.is_finite(): + raise InvalidOperation + except InvalidOperation: + print(f"Warning: Invalid score '{value}' for '{label}' in '{path}'.") + continue + values.append(number) + statistics = { + label: str((sum(values) / len(values)).quantize(Decimal("0.1"), rounding=ROUND_CEILING)) + if values else "" + for label, values in scores_by_label.items() + } + atomic_write_json(output_dir / "stat.json", statistics) + print(f"Wrote per-label averages: {output_dir / 'stat.json'}") + + def process_images(base_dir, output_dir, *, copy_merged_histogramme=False): # 1. Load Data try: @@ -152,6 +187,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False): for pdf_path in pdf_paths: shutil.copy2(pdf_path, student_output / pdf_path.name) + write_score_statistics(search_path, output_dir) copy_histogram(output_dir, copy_merged_histogramme=copy_merged_histogramme) diff --git a/copienator/commands/giving_names.py b/copienator/commands/giving_names.py index 6b919cf..d621ef8 100644 --- a/copienator/commands/giving_names.py +++ b/copienator/commands/giving_names.py @@ -4,7 +4,7 @@ import argparse import re import sys from collections import defaultdict -from collections.abc import Sequence +from collections.abc import Collection, Sequence from pathlib import Path from copienator import ( @@ -77,8 +77,12 @@ def _annotation_source( def update_named_return_answers( workspace: EvaluationWorkspace, annotation_dir_name: str, + *, + copy_ids: Collection[str] | None = None, ) -> ExitCode: - """Refresh only answers/ in existing returns, preserving manual names.""" + """Refresh answers/ in existing returns, optionally only successful rereads.""" + if copy_ids is not None and (not copy_ids or not workspace.return_dir.is_dir()): + return ExitCode.SUCCESS workspace.require_directories(annotation_dir_name, "A Rendre") had_errors = False found = False @@ -86,6 +90,8 @@ def update_named_return_answers( if not destination.is_dir(): continue match = RETURN_COPY_ID.search(destination.name) + if copy_ids is not None and (match is None or match.group(1) not in copy_ids): + continue if match is None: print( f"Warning: cannot identify a copy ID in {destination.name!r}; skipped", @@ -116,7 +122,7 @@ def update_named_return_answers( except (OSError, TypeError, ValueError) as exc: print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr) had_errors = True - if not found: + if not found and copy_ids is None: print("Warning: no identifiable student folders found in A Rendre", file=sys.stderr) had_errors = True return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS diff --git a/copienator/commands/reading_annotations.py b/copienator/commands/reading_annotations.py index 0f8dd08..c528215 100644 --- a/copienator/commands/reading_annotations.py +++ b/copienator/commands/reading_annotations.py @@ -24,6 +24,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov from copienator.annotation_data import AnnotationData, load_annotation_data from copienator.annotation_documents import copy_documents from copienator.annotation_layout import legacy_layout +from copienator.annotation_read_cache import AnnotationReadCache from copienator.annotation_modifications import ( BASELINE, output_removals, @@ -31,6 +32,7 @@ from copienator.annotation_modifications import ( ) from copienator.answer_info import build_answer_info from copienator.commands import annotating +from copienator.commands.giving_names import update_named_return_answers from copienator.filesystem import staged_files from copienator.return_answers import save_return_answer_options @@ -324,7 +326,12 @@ def run( return ExitCode.PARTIAL status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS + cache = AnnotationReadCache(workspace, "Bnot", loaded.data) if update_score else None + regenerated: set[str] = set() for student_id in sorted(loaded.data, key=utils.natural_key): + if cache is not None and cache.unchanged(student_id): + print(f"Skipping unchanged Copie{student_id}") + continue output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}" only_empty = all(content["result"].get("error") == "empty-answer" for content in loaded.data[student_id].values()) @@ -372,6 +379,12 @@ def run( ) if result != ExitCode.SUCCESS: status = ExitCode.PARTIAL + else: + regenerated.add(student_id) + if update_named_return_answers(workspace, "Bnot", copy_ids=regenerated) != ExitCode.SUCCESS: + status = ExitCode.PARTIAL + elif cache is not None: + cache.remember(regenerated) return status diff --git a/copienator/commands/reading_grouped_annotations.py b/copienator/commands/reading_grouped_annotations.py index bc8e58c..22f8ab5 100644 --- a/copienator/commands/reading_grouped_annotations.py +++ b/copienator/commands/reading_grouped_annotations.py @@ -27,6 +27,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data from copienator.annotation_documents import copy_documents, tablet_documents from copienator.annotation_layout import legacy_layout +from copienator.annotation_read_cache import AnnotationReadCache from copienator.annotation_modifications import ( BASELINE, baseline, @@ -36,6 +37,7 @@ from copienator.annotation_modifications import ( ) from copienator.answer_info import build_answer_info from copienator.commands import annotating +from copienator.commands.giving_names import update_named_return_answers from copienator.commands.reading_annotations import ( concatenate, detect_checks_and_notes, @@ -594,6 +596,18 @@ def run( and path.is_dir() and not path.name.startswith("Copie") ] + # Redo reads deliberately remain full reads: their selected labels and + # preserved baseline images have different dependencies. + cache = (AnnotationReadCache(workspace, annotation_dir, loaded.data, group_dirs) + if update_score and not refaire else None) + pending_ids = set(loaded.data) + if cache is not None: + pending_ids = {copy_id for copy_id in loaded.data if not cache.unchanged(copy_id)} + for copy_id in sorted(set(loaded.data) - pending_ids, key=utils.natural_key): + print(f"Skipping unchanged Copie{copy_id}") + if not pending_ids: + return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS + only_ids = pending_ids # Each worker decodes a full-height returned group and its reference image. # Keep this stage deliberately narrow; answer regeneration below has its own # parallel executor and a much smaller per-task memory footprint. @@ -657,6 +671,7 @@ def run( if refaire else sorted(loaded.data, key=utils.natural_key) ) + regenerated: set[str] = set() with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: futures = { executor.submit( @@ -676,13 +691,19 @@ def run( ), ): student_id for student_id in student_ids - if student_id in loaded.data and student_id not in skipped_students + if student_id in pending_ids and student_id not in skipped_students } for future in concurrent.futures.as_completed(futures): result, output = future.result() print(output) if result != ExitCode.SUCCESS: status = ExitCode.PARTIAL + else: + regenerated.add(futures[future]) + if update_named_return_answers(workspace, annotation_dir, copy_ids=regenerated) != ExitCode.SUCCESS: + status = ExitCode.PARTIAL + elif cache is not None: + cache.remember(regenerated) return status diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 5573c21..4ef21fb 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -778,7 +778,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Étapes personnelles", "Renseigner le barème", "Exécute gestion_classe we.", - (CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",)),), + (CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",), fixed_args_before_positionals=True),), + arguments=(arg_evaluation_name(),), personal=True, ), StepDefinition( @@ -823,7 +824,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Ajouter le score final", "Prérequis immédiat : gestion_classe wse doit avoir été exécuté juste avant. " "Génère ensuite les dossiers de diffusion avec le score final, puis copie " - "gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation.", + "gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation. " + "Écrit aussi stat.json avec les moyennes par label, arrondies par excès au dixième.", (python("default", "Score final", "add-final-score", supports_verbose=False),), arguments=( arg_target("le dossier de l’évaluation"), diff --git a/docs/final_output.md b/docs/final_output.md index be0f82f..65a6391 100644 --- a/docs/final_output.md +++ b/docs/final_output.md @@ -261,8 +261,21 @@ Scores incorporate review checkbox changes and, when requested, existing score overrides via `read-grouped --update-score` (or `read-annotations --update-score` for `Bnot`). Editing the JSON alone does not update the rendered scores. Overrides are read from the annotation source folder: a return symlink points there, but -an independent fallback copy does not. After regeneration, rerun `giving-names` -to refresh copied return files. +an independent fallback copy does not. Successful annotation rereads automatically +refresh individual `answers/` images in existing return folders, matching their +trailing copy ID and preserving manual names and other return files. This also +applies the selected supplementary-material options. Copies whose regeneration +fails retain their existing exports. Rerun `giving-names` to refresh other copied +return files. + +With `--update-score`, both readers save a small content fingerprint per copy +after successful regeneration and answer publication. Subsequent runs skip +unchanged copies before scanning or rendering PDFs. A single edited `score.json` +therefore rebuilds only its copy. Changed shared documents or grouped annotation +inputs conservatively invalidate all copies; changed export options also trigger +regeneration. Missing generated files and failed runs are retried. The first run +establishes the fingerprints. Reads without `--update-score` always regenerate; +selective `--refaire` reads do not use this optimization. `update-ods` reads these return JSON files; empty values become `NT` in the normal per-question export. Its `--sum` option sums numeric values and skips nonnumeric @@ -280,9 +293,13 @@ ones. Weighting and the final overall mark belong to the separate grading flow. outputs whose source is now absent, including broken symlinks. - `add-final-score` writes to `FINAL_SCORE_OUTPUT_DIR//`. It stamps the overall mark from the configured ODS in red at the JPEG's upper right, - rounded down to one decimal place. It copies PDFs unchanged and does not export - either JSON file or the `answers/` directory. It does not add that mark to the - files inside `A Rendre`. + rounded down to one decimal place. It copies PDFs unchanged, together with + `score.json`, `info.json` and the optional `answers/` directory. Alongside the + student folders it writes `stat.json`, averaging each label across the current + `A Rendre/*/score.json` files and rounding upward to one decimal place, stored + as strings like the original scores (for example `"2.7"`). Blank + and missing scores are excluded; labels without numeric scores retain `""`. + It does not add the overall mark to the files inside `A Rendre`. - The `clean` command retains return images (including individual answers), PDFs, `score.json` and `info.json`, materializing retained symlinks before deleting their sources. diff --git a/docs/guide_utilisateur_gui.md b/docs/guide_utilisateur_gui.md index 1895560..9b94586 100644 --- a/docs/guide_utilisateur_gui.md +++ b/docs/guide_utilisateur_gui.md @@ -439,6 +439,17 @@ réponse. Le mode **Régénérer avec les nouveaux énoncés/corrigés et les score.json** sert à mettre à jour les scores sur les images finales à partir d'une modification manuelle des fichiers `score.json`. +Après une lecture réussie, les images `answers` des dossiers déjà présents dans +**A Rendre** sont également régénérées, avec les compléments sélectionnés. +Les dossiers sont identifiés par leur numéro de copie final entre parenthèses ; +leurs noms personnalisés et les autres fichiers sont conservés. +Dans ce mode, les copies dont les fichiers et options sont inchangés sont +ignorées avant la lecture des PDF et la génération des images. Le premier +passage établit une empreinte par copie ; modifier un seul `score.json` ne +régénère ensuite que cette copie. Une modification des documents partagés +(énoncés, corrigés, annotations groupées) peut relancer plusieurs copies. +La lecture sans cette option force une régénération ; le mode refaire conserve +son fonctionnement habituel. ### Modifier les réponses annotées diff --git a/tests/test_add_final_score.py b/tests/test_add_final_score.py index e957cf2..2e8710f 100644 --- a/tests/test_add_final_score.py +++ b/tests/test_add_final_score.py @@ -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: diff --git a/tests/test_answer_returns.py b/tests/test_answer_returns.py index 1c39969..b55db83 100644 --- a/tests/test_answer_returns.py +++ b/tests/test_answer_returns.py @@ -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: diff --git a/tests/test_incremental_annotation_reads.py b/tests/test_incremental_annotation_reads.py new file mode 100644 index 0000000..88a5a01 --- /dev/null +++ b/tests/test_incremental_annotation_reads.py @@ -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")