Add per label remark, post correction

This commit is contained in:
sebastien committed 2026-10-26 12:44:29 +01:00
1 parent 3abd7873e6
commit 8918d64452
13 files changed
+342 -22

No files matched your search

+7 -1
View File
@@ -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],
+14 -1
View File
@@ -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)
+3 -1
View File
@@ -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)
@@ -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)
+66
View File
@@ -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
+18 -3
View File
@@ -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,6 +1644,15 @@ 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")
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":
@@ -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:
+35 -4
View File
@@ -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))
+6 -5
View File
@@ -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",),
+33 -5
View File
@@ -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**
+85
View File
@@ -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())
+37
View File
@@ -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())
@@ -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)
+21 -1
View File
@@ -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):