From 8918d64452625dd84a632cf394f8a2a6b4f743f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Mon, 26 Oct 2026 12:44:29 +0100 Subject: [PATCH] Add per label remark, post correction --- copienator/annotation_read_cache.py | 8 +- copienator/commands/giving_names.py | 15 +++- copienator/commands/reading_annotations.py | 4 +- .../commands/reading_grouped_annotations.py | 4 +- copienator/custom_remarks.py | 66 ++++++++++++++ copienator_gui/app.py | 23 ++++- copienator_gui/scores.py | 39 ++++++++- copienator_gui/workflow.py | 11 +-- docs/guide_utilisateur_gui.md | 38 +++++++-- tests/test_custom_remarks.py | 85 +++++++++++++++++++ tests/test_gui_scores.py | 37 ++++++++ tests/test_incremental_annotation_reads.py | 14 +++ tests/test_return_outputs.py | 22 ++++- 13 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 copienator/custom_remarks.py create mode 100644 tests/test_custom_remarks.py diff --git a/copienator/annotation_read_cache.py b/copienator/annotation_read_cache.py index 3eca204..5846d6e 100644 --- a/copienator/annotation_read_cache.py +++ b/copienator/annotation_read_cache.py @@ -6,6 +6,7 @@ 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 +from copienator.custom_remarks import load_remarks, copy_key STATE_FILE = ".annotation-read-state.json" DOCUMENT_INPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat_annotated.pdf") @@ -36,7 +37,9 @@ class AnnotationReadCache: shared.extend(directory / name for name in DOCUMENT_INPUTS) common = (_files_digest(shared), load_return_answer_options(root), configuration.RETURN_ANSWERS_ENABLED, + configuration.RETURN_JPEG_ENABLED, configuration.RETURN_PDF_ENABLED, configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER) + remarks = load_remarks(root) for copy_id, labels in data.items(): directory = root / mode / f"Copie{copy_id}" paths = [directory / "score.json", directory / "bnote.json"] @@ -45,7 +48,7 @@ class AnnotationReadCache: 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)), + payload = json.dumps((2, common, labels, remarks.get(copy_key(copy_id), {}), _files_digest(paths)), sort_keys=True, default=str).encode() self.signatures[copy_id] = sha256(payload).hexdigest() @@ -71,6 +74,9 @@ class AnnotationReadCache: outputs.extend(directory.glob("annotation_baseline.json")) for destination in self.workspace.return_dir.glob(f"* ({copy_id})"): outputs.extend((destination / "answers").glob("*.jpg")) + stem = destination.name.rsplit(" (", 1)[0] + outputs.extend(path for suffix in (".jpg", ".pdf") + if (path := destination / (stem + suffix)).is_file()) atomic_write_json(directory / STATE_FILE, { "signature": self.signatures[copy_id], "outputs": [str(path) for path in outputs], diff --git a/copienator/commands/giving_names.py b/copienator/commands/giving_names.py index d621ef8..44a149e 100644 --- a/copienator/commands/giving_names.py +++ b/copienator/commands/giving_names.py @@ -79,8 +79,9 @@ def update_named_return_answers( annotation_dir_name: str, *, copy_ids: Collection[str] | None = None, + refresh_named: bool = False, ) -> ExitCode: - """Refresh answers/ in existing returns, optionally only successful rereads.""" + """Refresh answers/; readers also refresh named outputs after 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") @@ -118,6 +119,18 @@ def update_named_return_answers( destination, answers_only=True, ) + if refresh_named: + stem = destination.name[:match.start()].rstrip() + for source_name, suffix, enabled in ( + ("Concat.jpg", ".jpg", configuration.RETURN_JPEG_ENABLED), + ("Concat_F.pdf", ".pdf", configuration.RETURN_PDF_ENABLED), + ): + source = source_folder / source_name + target = destination / (stem + suffix) + if enabled and source.is_file(): + replace_with_link_or_copy(source, target, prefer="symlink") + else: + target.unlink(missing_ok=True) print(f"Updated answers for {destination.name} from Copie{copy_id}") except (OSError, TypeError, ValueError) as exc: print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr) diff --git a/copienator/commands/reading_annotations.py b/copienator/commands/reading_annotations.py index c528215..f682571 100644 --- a/copienator/commands/reading_annotations.py +++ b/copienator/commands/reading_annotations.py @@ -25,6 +25,7 @@ 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.custom_remarks import apply_custom_remarks from copienator.annotation_modifications import ( BASELINE, output_removals, @@ -175,6 +176,7 @@ def apply_actions_and_regenerate( labels_data = data[student_id] apply_checkbox_actions(labels_data, actions, print) + apply_custom_remarks(workspace.root, student_id, labels_data) score_path = output_dir / "score.json" preserve_score_file = update_score and score_path.is_file() if update_score: @@ -381,7 +383,7 @@ def run( status = ExitCode.PARTIAL else: regenerated.add(student_id) - if update_named_return_answers(workspace, "Bnot", copy_ids=regenerated) != ExitCode.SUCCESS: + if update_named_return_answers(workspace, "Bnot", copy_ids=regenerated, refresh_named=True) != ExitCode.SUCCESS: status = ExitCode.PARTIAL elif cache is not None: cache.remember(regenerated) diff --git a/copienator/commands/reading_grouped_annotations.py b/copienator/commands/reading_grouped_annotations.py index 22f8ab5..8d58844 100644 --- a/copienator/commands/reading_grouped_annotations.py +++ b/copienator/commands/reading_grouped_annotations.py @@ -28,6 +28,7 @@ from copienator.annotation_data import AnnotationData, RefaireList, load_annotat 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.custom_remarks import apply_custom_remarks from copienator.annotation_modifications import ( BASELINE, baseline, @@ -235,6 +236,7 @@ def apply_actions_and_regenerate_grouped( output_dir = workspace.root / annotation_dir / f"Copie{student_id}" labels_data = data.get(student_id, {}) apply_checkbox_actions(labels_data, actions, logs.append) + apply_custom_remarks(workspace.root, student_id, labels_data) score_path = output_dir / "score.json" preserve_score_file = update_score and score_path.is_file() if update_score: @@ -700,7 +702,7 @@ def run( status = ExitCode.PARTIAL else: regenerated.add(futures[future]) - if update_named_return_answers(workspace, annotation_dir, copy_ids=regenerated) != ExitCode.SUCCESS: + if update_named_return_answers(workspace, annotation_dir, copy_ids=regenerated, refresh_named=True) != ExitCode.SUCCESS: status = ExitCode.PARTIAL elif cache is not None: cache.remember(regenerated) diff --git a/copienator/custom_remarks.py b/copienator/custom_remarks.py new file mode 100644 index 0000000..6d64404 --- /dev/null +++ b/copienator/custom_remarks.py @@ -0,0 +1,66 @@ +"""Teacher-authored feedback, independent of the generated correction data.""" +from pathlib import Path + +from copienator import atomic_update_json, read_json + +FILENAME = "custom_remarks.json" + + +def copy_key(copy_id: str) -> str: + if not copy_id.strip().isascii() or not copy_id.strip().isdecimal(): + raise ValueError("Numéro de copie invalide.") + return str(int(copy_id)) + + +def load_remarks(root: Path) -> dict[str, dict[str, str]]: + data = read_json(root / FILENAME, default={}) + validate(data) + return data + + +def validate(data): + if not isinstance(data, dict) or any( + not isinstance(key, str) or copy_key(key) != key + or not isinstance(labels, dict) + or any(not isinstance(label, str) or not isinstance(text, str) + for label, text in labels.items()) + for key, labels in data.items() + ): + raise ValueError(f"Format invalide dans {FILENAME}.") + + +def remarks_for_copy(root: Path, copy_id: str) -> dict[str, str]: + return load_remarks(root).get(copy_key(copy_id), {}) + + +def set_remark(root: Path, copy_id: str, label: str, text: str) -> str: + key = copy_key(copy_id) + text = text.strip() + previous = "" + + def update(data): + nonlocal previous + validate(data) + labels = data.setdefault(key, {}) + previous = labels.get(label, "") + if text: + labels[label] = text + else: + labels.pop(label, None) + if not labels: + data.pop(key, None) + + atomic_update_json(root / FILENAME, update, default_factory=dict) + return previous + + +def apply_custom_remarks(root: Path, copy_id: str, labels_data: dict) -> None: + """Append after checkbox actions; replace our own entries on repeated calls.""" + remarks = remarks_for_copy(root, copy_id) + for label, content in labels_data.items(): + result = content["result"] + feedback = [item for item in result.get("feedback", []) + if not item.get("custom_remark")] + if remarks.get(label): + feedback.append({"text": remarks[label], "box_2d": None, "custom_remark": True}) + result["feedback"] = feedback diff --git a/copienator_gui/app.py b/copienator_gui/app.py index d0701e2..ec9434d 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -1086,7 +1086,7 @@ class CopienatorApp(tk.Tk): return row if step.id == "edit_scores" and self.evaluation: self.score_editor_panel = ScoreEditorPanel( - self.form, self.evaluation, self._score_changed + self.form, self.evaluation, self._score_changed, self._remark_changed ) self.score_editor_panel.grid(row=row, column=0, columnspan=2, sticky="ew", pady=8) row += 1 @@ -1644,7 +1644,16 @@ class CopienatorApp(tk.Tk): if (step.is_manual or step.id == "personal_create" or (step.id == "personal_read" and review_command is None)): self._mark_step("success") - self._move_selection_from(step.id, 1) + if step.id == "edit_scores": + entry = self.state_store.step("read_annotations") + values = dict(entry.get("values", {})) + values["update_score"] = True + self.state_store.update_step("read_annotations", status="stale", values=values) + self._populate_tree() + self.tree.selection_set("read_annotations") + self.tree.see("read_annotations") + else: + self._move_selection_from(step.id, 1) return if os.name == "nt" and step.id != "statement": labels_path = evaluation / "labels" @@ -1799,13 +1808,19 @@ class CopienatorApp(tk.Tk): self._populate_tree() self.info_var.set("Renommage validé — aucun fichier modifié.") - def _score_changed(self, copy_id: str, label: str, previous: str, current: str) -> None: + def _remark_changed(self, copy_id: str, label: str, previous: str, current: str) -> None: + self._score_changed(copy_id, label, previous, current, remark=True) + + def _score_changed(self, copy_id: str, label: str, previous: str, current: str, + *, remark: bool = False) -> None: step = self.step_by_id["edit_scores"] self._invalidate_after_step(step, "default") self.state_store.update_step(step.id, status="detected") + self.state_store.update_step("read_annotations", status="stale") self.state_store.add_history({ "step": step.id, "copy_id": copy_id, "label": label, - "previous_score": previous, "score": current, "manual": True, + ("previous_remark" if remark else "previous_score"): previous, + ("remark" if remark else "score"): current, "manual": True, }) # Refresh statuses without rebuilding the form between score edits. for item in self.steps: diff --git a/copienator_gui/scores.py b/copienator_gui/scores.py index 5c51c3c..f8dcb32 100644 --- a/copienator_gui/scores.py +++ b/copienator_gui/scores.py @@ -8,6 +8,7 @@ from tkinter import messagebox, ttk from copienator import EvaluationWorkspace, atomic_update_json, read_json from copienator.utils import natural_key +from copienator.custom_remarks import remarks_for_copy, set_remark from .giving_names import RETURN_FOLDER_PATTERN @@ -61,10 +62,11 @@ def set_score(path: Path, label: str, value: str) -> str: class ScoreEditorPanel(ttk.Frame): - def __init__(self, parent, evaluation: Path, on_change): + def __init__(self, parent, evaluation: Path, on_change, on_remark_change=None): super().__init__(parent) self.evaluation = evaluation self.on_change = on_change + self.on_remark_change = on_remark_change self.current_path = None self.busy = False self.id_var = tk.StringVar() @@ -81,10 +83,17 @@ class ScoreEditorPanel(ttk.Frame): ttk.Label(self, text="Score").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=4) self.score_entry = ttk.Entry(self, textvariable=self.score_var) self.score_entry.grid(row=2, column=1, sticky="ew", pady=4) - self.set_button = ttk.Button(self, text="Set", command=self.save_score) + self.set_button = ttk.Button(self, text="Enregistrer le score", command=self.save_score) self.set_button.grid(row=2, column=2, padx=(8, 0), pady=4) + ttk.Label(self, text="Remarque personnelle").grid(row=3, column=0, sticky="nw", pady=4) + self.remark_entry = tk.Text(self, height=4, width=45, wrap="word", undo=True) + self.remark_entry.grid(row=3, column=1, sticky="ew", pady=4) + self.remark_button = ttk.Button(self, text="Enregistrer la remarque", command=self.save_remark) + self.remark_button.grid(row=3, column=2, padx=(8, 0), pady=4) + ttk.Label(self, text="Videz le champ puis enregistrez pour supprimer votre remarque.").grid( + row=4, column=1, columnspan=2, sticky="w") ttk.Label(self, textvariable=self.status_var, wraplength=650).grid( - row=3, column=0, columnspan=3, sticky="w", pady=8 + row=5, column=0, columnspan=3, sticky="w", pady=8 ) self.id_var.trace_add("write", self.load_score) self.label_var.trace_add("write", self.load_score) @@ -94,6 +103,8 @@ class ScoreEditorPanel(ttk.Frame): def load_score(self, *_args): self.current_path = None self.score_var.set("") + self.remark_entry.configure(state="normal") + self.remark_entry.delete("1.0", "end") self.label_entry.configure(values=()) try: path = score_path_for_id(self.evaluation, self.id_var.get()) @@ -105,11 +116,14 @@ class ScoreEditorPanel(ttk.Frame): elif label not in scores: self.status_var.set(f"Label absent de score.json : {label}") else: + remark = remarks_for_copy(self.evaluation, self.id_var.get()).get(label, "") + self.remark_entry.insert("1.0", remark) self.current_path = path self.score_var.set(str(scores[label])) self.status_var.set(path.parent.name) except (OSError, ValueError) as exc: self.status_var.set(str(exc)) + self.remark_entry.edit_reset() self.set_busy(self.busy) def set_busy(self, busy: bool): @@ -119,6 +133,8 @@ class ScoreEditorPanel(ttk.Frame): state = "normal" if self.current_path is not None and not busy else "disabled" self.score_entry.configure(state=state) self.set_button.configure(state=state) + self.remark_entry.configure(state=state) + self.remark_button.configure(state=state) def save_score(self): if self.current_path is None or self.busy: @@ -130,6 +146,21 @@ class ScoreEditorPanel(ttk.Frame): self.score_var.set(current) if previous != current: self.on_change(self.id_var.get().strip(), label, previous, current) - self.status_var.set(f"Score enregistré : {self.current_path.parent.name} — {label} : {current}") + self.status_var.set(f"Score enregistré : {self.current_path.parent.name} — {label} : {current}. Régénération nécessaire.") except (OSError, ValueError) as exc: messagebox.showerror("Modification du score", str(exc)) + + def save_remark(self): + if self.current_path is None or self.busy: + return + label = self.label_var.get().strip() + try: + if label not in read_scores(self.current_path): + raise ValueError(f"Label absent de score.json : {label}") + current = self.remark_entry.get("1.0", "end-1c").strip() + previous = set_remark(self.evaluation, self.id_var.get(), label, current) + if previous != current and self.on_remark_change: + self.on_remark_change(self.id_var.get().strip(), label, previous, current) + self.status_var.set("Remarque enregistrée — régénération nécessaire dans « Lire les annotations manuscrites ».") + except (OSError, ValueError) as exc: + messagebox.showerror("Modification de la remarque", str(exc)) diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 4ef21fb..e2a6bae 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -682,10 +682,10 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: arg_target("le dossier de l’évaluation"), ArgumentSpec( "update_score", - "Régénérer avec les nouveaux énoncés/corrigés et les score.json", + "Régénérer avec les nouveaux énoncés/corrigés, scores et remarques", "bool", "--update-score", - help="Régénère les images avec les PDF actuels d’énoncé et de correction ; pour chaque label, le score.json existant prévaut sur le score relu dans l’annotation manuscrite.", + help="Régénère les images avec les PDF actuels d’énoncé et de correction ; pour chaque label, le score.json existant prévaut sur le score relu dans l’annotation manuscrite. Les remarques personnelles sont ajoutées aux commentaires conservés.", ), ArgumentSpec( "refaire", @@ -756,10 +756,11 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: StepDefinition( "edit_scores", "Finalisation", - "Modifier des scores", + "Modifier des scores et remarques", "Saisissez l’id de copie, puis choisissez ou saisissez un label. " - "Le score actuel est chargé automatiquement. Modifiez-le puis cliquez sur Set " - "pour l’enregistrer dans score.json. Cliquez sur « Marquer terminée » quand vous avez fini.", + "Le score et la remarque personnelle sont chargés automatiquement. Enregistrez chacun " + "séparément, puis relancez « Lire les annotations manuscrites » avec « Régénérer ». " + "Videz la remarque et enregistrez pour la supprimer. Cliquez sur « Marquer terminée » quand vous avez fini.", (manual("default"),), optional=True, requires=("A Rendre",), diff --git a/docs/guide_utilisateur_gui.md b/docs/guide_utilisateur_gui.md index c8cbcab..62034c8 100644 --- a/docs/guide_utilisateur_gui.md +++ b/docs/guide_utilisateur_gui.md @@ -455,22 +455,50 @@ ne reste à y inclure. Contrôlez donc aussi les scores et l’image complète, sans assimiler automatiquement une omission à une perte de 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`. +Le mode **Régénérer avec les nouveaux énoncés/corrigés, scores et remarques** +met à jour les scores sur les images finales à partir des fichiers `score.json` +et ajoute les remarques personnelles enregistrées pour chaque copie et label. 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. +leurs noms personnalisés sont conservés. Lors de la lecture des annotations, +les PDF/JPEG portant le nom du dossier (sans le suffixe de copie) sont également +actualisés, même lorsque ce sont des copies de fichiers plutôt que des liens. +Un PDF filtré devenu inutile est supprimé. Les autres fichiers sont conservés. +Le mode « Mettre à jour uniquement les images de answers » de l’attribution +des noms conserve, lui, son comportement limité aux images individuelles. 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 +régénère ensuite que cette copie. Ajouter, modifier ou supprimer une remarque +personnelle ne régénère également que la copie concernée. 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 des scores et remarques + +Après la préparation de **A Rendre**, saisissez l’id de copie et choisissez le +label. Le score actuel et votre remarque personnelle sont chargés automatiquement. +Les boutons **Enregistrer le score** et **Enregistrer la remarque** sont indépendants. +La remarque accepte plusieurs lignes et le même texte LaTeX que les commentaires +de correction. Pour la supprimer, videz le champ puis enregistrez. + +Les remarques sont enregistrées dans `custom_remarks.json`, à la racine de +l’évaluation, par numéro de copie et label. Elles restent distinctes de +`correction.json` et s’ajoutent aux commentaires Gemini conservés, sous la forme +de remarques non localisées. Une nouvelle saisie remplace votre remarque précédente. + +Après les modifications, cliquez sur **Marquer terminée** : l’interface ouvre +automatiquement **Lire les annotations manuscrites**, marquée **À revalider**, +avec **Régénérer** déjà cochée. Le mode et les autres options sont conservés ; +vérifiez le mode **Bnot** ou **BGnot**, puis cliquez sur **Exécuter**. Aucun nouvel appel à Gemini +n’est nécessaire. La remarque apparaît dans l’en-tête et fait conserver dans le PDF +filtré une réponse non vide qui aurait autrement été omise pour son score parfait. +Cette régénération actualise aussi les images individuelles et les PDF/JPEG nommés +décrits ci-dessus. Les remarques sont également conservées lors d’une lecture normale. + ### Modifier les réponses annotées Cette étape facultative se trouve après la lecture des annotations, pour **Bnot** diff --git a/tests/test_custom_remarks.py b/tests/test_custom_remarks.py new file mode 100644 index 0000000..c0dd8e8 --- /dev/null +++ b/tests/test_custom_remarks.py @@ -0,0 +1,85 @@ +from copy import deepcopy +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from PIL import Image, ImageDraw + +from copienator import EvaluationWorkspace, ExitCode, atomic_write_json +from copienator.custom_remarks import apply_custom_remarks, set_remark, remarks_for_copy +from copienator.commands import annotating, reading_annotations as standard +from copienator.commands import reading_grouped_annotations as grouped + + +class CustomRemarkTests(unittest.TestCase): + def test_storage_preserves_other_students_and_feedback_is_idempotent(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + set_remark(root, "01", "Ex 1", "First") + set_remark(root, "2", "Ex 1", "Other student") + set_remark(root, "1", "Ex 2", "Other label") + labels = {"Ex 1": {"result": {"feedback": [{"text": "Gemini"}]}}} + apply_custom_remarks(root, "1", labels) + apply_custom_remarks(root, "01", labels) + self.assertEqual([f["text"] for f in labels["Ex 1"]["result"]["feedback"]], ["Gemini", "First"]) + set_remark(root, "01", "Ex 1", " ") + apply_custom_remarks(root, "1", labels) + self.assertEqual(labels["Ex 1"]["result"]["feedback"], [{"text": "Gemini"}]) + self.assertEqual(remarks_for_copy(root, "1"), {"Ex 2": "Other label"}) + self.assertEqual(remarks_for_copy(root, "02"), {"Ex 1": "Other student"}) + + def test_readers_render_remark_after_checkbox_deletion_and_preserve_ink(self): + for mode in ("Bnot", "BGnot"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = EvaluationWorkspace(root) + output = root / mode / "Copie01" + output.mkdir(parents=True) + answer = root / "answer.pdf" + answer.touch() + atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]}) + atomic_write_json(output / "score.json", {"Ex 1": "4"}) + original = {"01": {"Ex 1": {"pdf_path": answer, "coordinates": (0, 0), + "result": {"score": 4, "feedback": [{"text": "Gemini"}]}}}} + set_remark(root, "1", "Ex 1", "Teacher") + ink = Image.new("RGBA", (1500, 110), (0, 0, 0, 0)) + draw = ImageDraw.Draw(ink) + draw.rectangle((100, 1, 120, 8), fill="red") + draw.rectangle((100, 30, 120, 50), fill="blue") + real_compose = annotating.compose_label_image + def compose(*args, **kwargs): + return real_compose(*args, **kwargs, + render_fn=lambda text, *a, **k: Image.new("RGB", (1500, 20), "green")) + with patch.object(annotating, "make_base_image", return_value=(Image.new("RGB", (1500, 100), "white"), 0, 0)), \ + patch.object(annotating, "render_score_text", return_value=Image.new("RGB", (1500, 10), "white")), \ + patch.object(annotating, "compose_label_image", side_effect=compose), \ + patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]): + def run(with_ink=True): + data = deepcopy(original) + actions = [{"label": "Ex 1", "type": "clear_all"}] + if mode == "Bnot": + # The tablet header was ten pixels tall. + atomic_write_json(output / "bnote.json", {"images": [ + {"label": "Ex 1", "header_height": 10}]}) + return standard.apply_actions_and_regenerate(workspace, data, "01", actions, + None, ["Ex 1"], update_score=True, label_notes={"Ex 1": ink} if with_ink else {}) + status, _ = grouped.apply_actions_and_regenerate_grouped(workspace, data, "01", + actions, {"Ex 1": {"img": ink, "old_header_h": 10}} if with_ink else {}, ["Ex 1"], update_score=True) + return status + self.assertEqual(run(), ExitCode.SUCCESS) + self.assertTrue((output / "Concat_F.pdf").is_file()) + with Image.open(output / "Ex 1.jpg") as image: + self.assertEqual(image.height, 130) + self.assertGreater(image.getpixel((110, 4))[0], 180) + self.assertGreater(image.getpixel((110, 65))[2], 180) + # Even with no handwritten ink, a personal remark includes a perfect answer. + original["01"]["Ex 1"]["result"]["feedback"] = [] + self.assertEqual(run(with_ink=False), ExitCode.SUCCESS) + self.assertTrue((output / "Concat_F.pdf").is_file()) + set_remark(root, "1", "Ex 1", "") + self.assertEqual(run(), ExitCode.SUCCESS) + with Image.open(output / "Ex 1.jpg") as image: + self.assertEqual(image.height, 110) + self.assertEqual(run(with_ink=False), ExitCode.SUCCESS) + self.assertFalse((output / "Concat_F.pdf").exists()) diff --git a/tests/test_gui_scores.py b/tests/test_gui_scores.py index c79bbc8..270d921 100644 --- a/tests/test_gui_scores.py +++ b/tests/test_gui_scores.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest.mock import patch from copienator import read_json +from copienator.custom_remarks import remarks_for_copy from copienator_gui.app import CopienatorApp from copienator_gui.scores import score_path_for_id, set_score from copienator_gui.workflow import build_workflow @@ -111,3 +112,39 @@ class ScoreEditorGuiTests(ScoreEditorTests): self.app._skip_step() self.assertEqual(self.app.state_store.step("edit_scores")["status"], "skipped") self.assertEqual(self.app.state_store.step("final_score")["status"], "success") + + def test_remark_saves_independently_reloads_and_clears(self): + self.panel.id_var.set("1") + self.panel.label_var.set("Ex 1") + self.panel.score_var.set("bad unsaved score") + self.panel.remark_entry.insert("1.0", "Justifier.\nDétailler le calcul.") + self.panel.remark_button.invoke() + self.assertEqual(read_json(self.path)["Ex 1"], "2") + self.assertEqual(remarks_for_copy(self.evaluation, "01")["Ex 1"], "Justifier.\nDétailler le calcul.") + self.assertEqual(self.app.state_store.step("read_annotations")["status"], "stale") + self.panel.label_var.set("Ex 2") + self.assertEqual(self.panel.remark_entry.get("1.0", "end-1c"), "") + self.panel.label_var.set("Ex 1") + self.assertIn("Justifier.", self.panel.remark_entry.get("1.0", "end-1c")) + self.panel.remark_entry.delete("1.0", "end") + self.panel.remark_button.invoke() + self.assertEqual(remarks_for_copy(self.evaluation, "1"), {}) + self.panel.set_busy(True) + self.assertEqual(str(self.panel.remark_button.cget("state")), "disabled") + + def test_completing_editor_selects_regeneration_and_preserves_options(self): + self.app.state_store.update_step("read_annotations", status="success", variant="grouped", + values={"update_score": False, "return_answers_solution": False, "annotation_dir": "BGnot"}) + with patch.object(self.app.runner, "start") as start: + self.app.run_button.invoke() + self.app.update() + start.assert_not_called() + self.assertEqual(self.app.state_store.step("edit_scores")["status"], "success") + self.assertEqual(self.app.current_step.id, "read_annotations") + self.assertEqual(self.app.tree.selection(), ("read_annotations",)) + self.assertEqual(self.app.tree.item("read_annotations", "values"), ("À revalider",)) + self.assertTrue(self.app.arg_vars["update_score"].get()) + self.assertEqual(self.app.variant_var.get(), "grouped") + self.assertFalse(self.app.arg_vars["return_answers_solution"].get()) + self.assertTrue(self.app.state_store.step("read_annotations")["values"]["update_score"]) + self.assertIn("--update-score", self.app._make_command()) diff --git a/tests/test_incremental_annotation_reads.py b/tests/test_incremental_annotation_reads.py index 88a5a01..a34f7c0 100644 --- a/tests/test_incremental_annotation_reads.py +++ b/tests/test_incremental_annotation_reads.py @@ -11,6 +11,7 @@ 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 @@ -89,6 +90,19 @@ class IncrementalAnnotationReadsTests(unittest.TestCase): 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) diff --git a/tests/test_return_outputs.py b/tests/test_return_outputs.py index b85b284..d83e12d 100644 --- a/tests/test_return_outputs.py +++ b/tests/test_return_outputs.py @@ -7,7 +7,7 @@ import unittest from pathlib import Path from unittest.mock import patch -from copienator import EvaluationWorkspace, atomic_write_json, configuration +from copienator import ExitCode, EvaluationWorkspace, atomic_write_json, configuration from copienator.commands import clean, giving_names @@ -40,6 +40,26 @@ class ReturnOutputTests(unittest.TestCase): ), contextlib.redirect_stdout(io.StringIO()): self.assertEqual(giving_names.run(self.workspace, annotation_dir="BGnot"), 0) + def test_regeneration_refreshes_copied_named_files_and_removes_obsolete_pdf(self): + self.prepare() + for suffix in (".jpg", ".pdf"): + target = self.destination / ("Student" + suffix) + target.unlink() + target.write_bytes(b"old copied output") + (self.destination / "custom.pdf").write_bytes(b"keep") + with patch.multiple(configuration, RETURN_JPEG_ENABLED=True, RETURN_PDF_ENABLED=True): + status = giving_names.update_named_return_answers( + self.workspace, "BGnot", copy_ids={"01"}, refresh_named=True) + self.assertEqual(status, ExitCode.SUCCESS) + self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf") + self.assertEqual((self.destination / "Student.jpg").read_bytes(), b"jpeg") + (self.source / "Concat_F.pdf").unlink() + giving_names.update_named_return_answers( + self.workspace, "BGnot", copy_ids={"01"}, refresh_named=True) + self.assertFalse((self.destination / "Student.pdf").is_symlink()) + self.assertFalse((self.destination / "Student.pdf").exists()) + self.assertEqual((self.destination / "custom.pdf").read_bytes(), b"keep") + 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):