Refaire session.

This commit is contained in:
2026-09-06 19:20:51 +02:00
parent 2ff1a9b7b9
commit 2313d51a62
8 changed files with 469 additions and 10 deletions
+35 -2
View File
@@ -377,6 +377,39 @@ Ce parcours a sa propre progression : les boutons de navigation du
passage principal ne l'ouvrent pas automatiquement. Après la fusion, passage principal ne l'ouvrent pas automatiquement. Après la fusion,
les étapes de restitution déjà terminées sont marquées à revalider. les étapes de restitution déjà terminées sont marquées à revalider.
Pour enchaîner les reprises, deux boutons sont disponibles dans ce parcours :
- =Nouvelle reprise= vide la sélection et remet les étapes de reprise à zéro.
- =Refaire la même sélection= conserve les copies et les questions du dernier
enregistrement, mais remet également les étapes de reprise à zéro.
Les choix du passage principal et de présentation des PDF sont conservés.
Enregistrer ensuite la sélection avant de poursuivre.
Attention : appeler =Nouvelle reprise= seulement après avoir importé les
résultats et exécuté =Mettre à jour les copies finales=. La même précaution
s'applique à =Refaire la même sélection=. Un avertissement est affiché avant
les deux actions, avec une mention supplémentaire si la fusion n'est pas
marquée réussie. Annuler conserve la reprise actuelle. Après une fusion
réussie, utiliser ces boutons pour recommencer, plutôt que modifier la
sélection du passage terminé.
Chaque nouveau passage utilise =Reprises/reprise-DATE-HEURE-ID/BRnot=.
Le passage précédent garde ses PDF, ses retours manuscrits, sa sélection
et une copie de la progression du GUI. Au premier changement de passage,
l'ancien =BRnot= à la racine est également copié dans =Reprises=.
Ces archives concernent les fichiers de vérification, pas un mécanisme
permettant d'annuler les modifications des copies finales.
Le fichier =refaire-session.json= désigne le passage actif ; les commandes
habituelles =--refaire= le suivent automatiquement. Sans ce fichier, le
fonctionnement historique dans =BRnot= à la racine reste disponible.
Dans la suite, =BRnot= désigne le dossier de la reprise active.
L'export d'un passage identifié utilise son propre sous-dossier dans
=EXPORT_DIR/Évaluation= et préfixe les noms des PDF par son identifiant.
Conserver les noms complets au retour et placer les PDF directement dans
=IMPORT_DIR=. L'import ignore les retours des autres passages ; aucun retour
du passage actif donne un résultat partiel. Ainsi, deux reprises de la même
question ne partagent pas les mêmes noms de fichiers exportés.
Ce flux fonctionne après =annotate-grouped= (=BGnot=), Ce flux fonctionne après =annotate-grouped= (=BGnot=),
=annotate-checks= (=Bnot=) ou =annotate-simple= (=Anot=). =annotate-checks= (=Bnot=) ou =annotate-simple= (=Anot=).
Terminer d'abord la lecture des annotations du passage principal Terminer d'abord la lecture des annotations du passage principal
@@ -405,8 +438,8 @@ Conserver les dossiers d'annotation et leurs fichiers de référence.
principal. Le mode groupé garde les identifiants des élèves et regroupe principal. Le mode groupé garde les identifiants des élèves et regroupe
les réponses par label sans demander de modifier =label_groups=. les réponses par label sans demander de modifier =label_groups=.
Cela ne nécessite pas d'avoir généré =Bnot= ou =BGnot= auparavant. Cela ne nécessite pas d'avoir généré =Bnot= ou =BGnot= auparavant.
Attention : =--overwrite= remplace tout le précédent passage dans Attention : =--overwrite= remplace le contenu du =BRnot= actif,
=BRnot=, y compris ses annotations manuscrites. Une génération incomplète y compris ses annotations manuscrites, mais pas les autres reprises. Une génération incomplète
conserve l'ancien =BRnot=. Sans =--overwrite=, un =BRnot= existant est refusé. conserve l'ancien =BRnot=. Sans =--overwrite=, un =BRnot= existant est refusé.
5. Vider les dossiers personnels d'export/import des anciens fichiers, 5. Vider les dossiers personnels d'export/import des anciens fichiers,
puis =python -m copienator export Interro --refaire=. puis =python -m copienator export Interro --refaire=.
+7 -3
View File
@@ -3,7 +3,6 @@ import sys
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from copienator.configuration import EXPORT_DIR
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -11,6 +10,7 @@ from copienator import (
execute, execute,
workspace_from_args, workspace_from_args,
) )
from copienator.configuration import EXPORT_DIR
from copienator.platform import replace_with_link_or_copy from copienator.platform import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
@@ -21,8 +21,12 @@ def export_directory(
source_dir_name: str, source_dir_name: str,
) -> ExitCode: ) -> ExitCode:
workspace.require_directories(source_dir_name) workspace.require_directories(source_dir_name)
source_dir = workspace.root / source_dir_name source_dir = workspace.annotation_dir("refaire") if source_dir_name == "BRnot" else workspace.root / source_dir_name
session_id = workspace.refaire_session_id if source_dir_name == "BRnot" else None
prefix = f"{session_id}__" if session_id else ""
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name
if session_id:
sync_dir /= session_id
sync_dir.mkdir(parents=True, exist_ok=True) sync_dir.mkdir(parents=True, exist_ok=True)
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()] subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
@@ -48,7 +52,7 @@ def export_directory(
) )
missing_outputs += 1 missing_outputs += 1
continue continue
destination = sync_dir / f"{subdir.name}{concat_file.suffix.lower()}" destination = sync_dir / f"{prefix}{subdir.name}{concat_file.suffix.lower()}"
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink") method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
print(f"Exported: {destination} ({method})") print(f"Exported: {destination} ({method})")
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
+13 -3
View File
@@ -4,7 +4,6 @@ import sys
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from copienator.configuration import IMPORT_DIR
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -12,6 +11,7 @@ from copienator import (
execute, execute,
workspace_from_args, workspace_from_args,
) )
from copienator.configuration import IMPORT_DIR
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
@@ -23,7 +23,10 @@ def sync_annotated(
import_dir: Path, import_dir: Path,
) -> ExitCode: ) -> ExitCode:
workspace.require_directories(annotation_dir_name) workspace.require_directories(annotation_dir_name)
annotation_dir = workspace.root / annotation_dir_name annotation_dir = workspace.annotation_dir("refaire") if annotation_dir_name == "BRnot" else workspace.root / annotation_dir_name
session_id = workspace.refaire_session_id if annotation_dir_name == "BRnot" else None
prefix = f"{session_id}__" if session_id else ""
accepted = 0
annotated_dir = Path(import_dir).expanduser() annotated_dir = Path(import_dir).expanduser()
if not annotated_dir.is_dir(): if not annotated_dir.is_dir():
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr) print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
@@ -39,7 +42,10 @@ def sync_annotated(
key=lambda path: path.name.casefold(), key=lambda path: path.name.casefold(),
) )
for annotated_file in annotated_files: for annotated_file in annotated_files:
target_subdir = annotation_dir / annotated_file.stem if prefix and not annotated_file.stem.startswith(prefix):
print(f"Ignoring return from another pass: {annotated_file.name}")
continue
target_subdir = annotation_dir / annotated_file.stem.removeprefix(prefix)
if not target_subdir.is_dir(): if not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr) print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
@@ -49,6 +55,10 @@ def sync_annotated(
dest_file = target_subdir / f"Concat_annotated{suffix}" dest_file = target_subdir / f"Concat_annotated{suffix}"
print(f"Copying {annotated_file} to {dest_file}") print(f"Copying {annotated_file} to {dest_file}")
shutil.copy2(annotated_file, dest_file) shutil.copy2(annotated_file, dest_file)
accepted += 1
if prefix and not accepted:
print(f"No returns for the active pass {session_id} were imported.")
return ExitCode.PARTIAL
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
+20 -1
View File
@@ -165,7 +165,26 @@ class EvaluationWorkspace:
def return_dir(self) -> Path: def return_dir(self) -> Path:
return self.root / "A Rendre" return self.root / "A Rendre"
@property
def refaire_session_id(self) -> str | None:
from .json_io import read_json
session = read_json(self.root / "refaire-session.json", default=None)
if session is None:
return None
ident = session.get("id") if isinstance(session, dict) else None
if not isinstance(ident, str) or not re.fullmatch(r"reprise-[0-9]{8}-[0-9]{6}-[a-f0-9]{8}", ident):
raise ValueError("Invalid refaire-session.json")
return ident
@property
def refaire_session_dir(self) -> Path | None:
ident = self.refaire_session_id
return self.root / "Reprises" / ident if ident else None
def annotation_dir(self, mode: str) -> Path: def annotation_dir(self, mode: str) -> Path:
if mode == "refaire" and self.refaire_session_dir is not None:
return self.refaire_session_dir / "BRnot"
directories = { directories = {
"simple": "Anot", "simple": "Anot",
"checks": "Bnot", "checks": "Bnot",
@@ -215,7 +234,7 @@ class EvaluationWorkspace:
missing = [ missing = [
relative_path relative_path
for relative_path in relative_paths for relative_path in relative_paths
if not (self.root / relative_path).is_dir() if not (self.annotation_dir("refaire") if relative_path == "BRnot" else self.root / relative_path).is_dir()
] ]
if missing: if missing:
raise WorkspaceValidationError(self.root, missing) raise WorkspaceValidationError(self.root, missing)
+60 -1
View File
@@ -25,6 +25,7 @@ from .refaire import (
resolve_layout, resolve_layout,
validate_selection, validate_selection,
) )
from .refaire_sessions import RESTART_WARNING, begin_pass
from .runner import ProcessRunner from .runner import ProcessRunner
from .state import StateStore from .state import StateStore
from .workflow import ( from .workflow import (
@@ -470,7 +471,8 @@ class CopienatorApp(tk.Tk):
if any(char in pattern for char in "*?["): if any(char in pattern for char in "*?["):
exists = next(evaluation.glob(pattern), None) is not None exists = next(evaluation.glob(pattern), None) is not None
else: else:
exists = (evaluation / pattern).exists() path = EvaluationWorkspace(evaluation).annotation_dir("refaire") if pattern == "BRnot" else evaluation / pattern
exists = path.exists()
if not exists: if not exists:
missing.append(pattern) missing.append(pattern)
return missing return missing
@@ -635,6 +637,7 @@ class CopienatorApp(tk.Tk):
evaluation = self.evaluation evaluation = self.evaluation
if step.id in {"refaire_selection", "refaire_correct"}: if step.id in {"refaire_selection", "refaire_correct"}:
row = self._correction_folder_buttons(row) row = self._correction_folder_buttons(row)
row = self._refaire_restart_controls(row)
if step.id == "refaire_selection" and evaluation: if step.id == "refaire_selection" and evaluation:
choices, preferred = self._annotation_directory_choices("export") choices, preferred = self._annotation_directory_choices("export")
draft = self.state_store.step(step.id).get("values", {}) draft = self.state_store.step(step.id).get("values", {})
@@ -699,6 +702,50 @@ class CopienatorApp(tk.Tk):
row += 1 row += 1
return row return row
def _refaire_restart_controls(self, row: int) -> int:
controls = ttk.Frame(self.form)
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 5))
running = bool(self.active_step_id) or self.runner.running
ttk.Button(controls, text="Nouvelle reprise", command=lambda: self._start_refaire_pass(False),
state="disabled" if running else "normal").pack(side="left", padx=(0, 6))
ttk.Button(controls, text="Refaire la même sélection", command=lambda: self._start_refaire_pass(True),
state="disabled" if running else "normal").pack(side="left")
ttk.Label(self.form, text="« Nouvelle reprise » : à utiliser après avoir importé les résultats et mis à jour les copies finales.",
wraplength=580, justify="left").grid(row=row + 1, column=0, columnspan=2, sticky="w", pady=(0, 6))
return row + 2
def _start_refaire_pass(self, keep_selection: bool) -> None:
workspace = self.state_store.workspace
if workspace is None:
messagebox.showerror("Évaluation absente", "Chargez dabord une évaluation.")
return
if self.active_step_id or self.runner.running:
messagebox.showwarning("Traitement en cours", "Attendez la fin du traitement avant de commencer une reprise.")
return
try:
if keep_selection:
load_selection(workspace.root)
title = "Refaire la même sélection" if keep_selection else "Nouvelle reprise"
message = RESTART_WARNING
if self.state_store.step("refaire_merge").get("status") != "success":
message += "\n\nLa reprise actuelle nest pas marquée comme fusionnée. Les résultats non fusionnés ne seront pas intégrés aux copies finales."
message += "\n\nLes fichiers de la reprise précédente seront conservés. Continuer ?"
if not messagebox.askyesno(title, message, icon="warning", default="no"):
return
self._save_current_form()
updated, ident = begin_pass(workspace, self.state_store.data, keep_selection=keep_selection)
self.state_store.data = updated
# Do not save the old form over the fresh pass during navigation.
self.current_step = None
self.refaire_panel = None
self.arg_vars.clear()
self._populate_tree()
self.tree.selection_set("refaire_selection")
self.tree.see("refaire_selection")
self.info_var.set(f"Nouvelle reprise : {ident}. Enregistrez la sélection pour continuer.")
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Nouvelle reprise impossible", str(exc))
def _correction_folder_buttons(self, row: int) -> int: def _correction_folder_buttons(self, row: int) -> int:
buttons = ttk.Frame(self.form) buttons = ttk.Frame(self.form)
buttons.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 6)) buttons.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 6))
@@ -804,11 +851,23 @@ class CopienatorApp(tk.Tk):
if not self.refaire_panel or not self.evaluation: if not self.refaire_panel or not self.evaluation:
return return
try: try:
if self.state_store.step("refaire_merge").get("status") == "success":
messagebox.showinfo("Reprise terminée", "Utilisez « Nouvelle reprise » ou « Refaire la même sélection » pour commencer un autre passage.")
return
values = self.refaire_panel.values() values = self.refaire_panel.values()
entries = validate_selection(values["selection"], available_copies(self.evaluation), EvaluationWorkspace(self.evaluation).read_labels()) entries = validate_selection(values["selection"], available_copies(self.evaluation), EvaluationWorkspace(self.evaluation).read_labels())
source = values["annotation_dir"] source = values["annotation_dir"]
if source not in detected_annotation_directories(self.evaluation): if source not in detected_annotation_directories(self.evaluation):
raise ValueError("Choisissez un dossier existant pour le passage principal.") raise ValueError("Choisissez un dossier existant pour le passage principal.")
workspace = EvaluationWorkspace(self.evaluation)
output = workspace.annotation_dir("refaire")
if output.is_dir() and any(output.iterdir()) and workspace.refaire_file.is_file():
from copienator import read_json
if read_json(workspace.refaire_file) != entries:
raise ValueError("Des PDF de vérification existent déjà. Importez et fusionnez leurs résultats, puis utilisez « Nouvelle reprise » pour changer de sélection.")
if workspace.refaire_session_dir is not None:
atomic_write_json(workspace.refaire_session_dir / "refaire.json", entries)
atomic_write_json(workspace.refaire_session_dir / "session.json", {"id": workspace.refaire_session_id, "values": values})
atomic_write_json(self.evaluation / "refaire.json", entries) atomic_write_json(self.evaluation / "refaire.json", entries)
self._mark_step("success") self._mark_step("success")
self._move_selection_from("refaire_selection", 1) self._move_selection_from("refaire_selection", 1)
+86
View File
@@ -0,0 +1,86 @@
"""Preserve completed redo passes and activate a fresh working directory."""
from __future__ import annotations
import copy
import shutil
import uuid
from datetime import datetime
from typing import Any
from copienator import EvaluationWorkspace, atomic_write_json, read_json
from copienator.filesystem import staged_files
RESTART_WARNING = (
"Appelez « Nouvelle reprise » seulement après avoir importé les résultats "
"de la reprise en cours et exécuté « Mettre à jour les copies finales ». "
"Cette consigne sapplique aussi à « Refaire la même sélection »."
)
def _identifier() -> str:
return f"reprise-{datetime.now().astimezone():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:8]}"
def begin_pass(
workspace: EvaluationWorkspace,
state: dict[str, Any],
*,
keep_selection: bool,
) -> tuple[dict[str, Any], str]:
"""Activate a pass atomically with its selection and reset GUI state.
Existing review files remain in place. Legacy BRnot is copied once into
the archive; failures before activation leave the old pass active.
"""
selection = read_json(workspace.refaire_file, default=[])
previous = workspace.refaire_session_dir
if previous is None and (
workspace.refaire_file.exists() or workspace.annotation_dir("refaire").exists()
):
previous = workspace.root / "Reprises" / _identifier()
previous.mkdir(parents=True)
legacy = workspace.annotation_dir("refaire")
if legacy.is_dir():
shutil.copytree(legacy, previous / "BRnot")
if previous is not None:
atomic_write_json(previous / "refaire.json", selection)
atomic_write_json(
previous / "progression.json",
{
name: entry
for name, entry in state.get("steps", {}).items()
if name.startswith("refaire_")
},
)
ident = _identifier()
directory = workspace.root / "Reprises" / ident
directory.mkdir(parents=True)
new_selection = selection if keep_selection else []
updated = copy.deepcopy(state)
values = dict(
updated.get("steps", {}).get("refaire_selection", {}).get("values", {})
)
values["selection"] = new_selection
updated["steps"] = {
name: entry
for name, entry in updated.get("steps", {}).items()
if not name.startswith("refaire_")
}
updated["steps"]["refaire_selection"] = {"values": values}
updated.setdefault("history", []).append(
{
"step": "refaire_selection",
"action": "repeat" if keep_selection else "new",
"session": ident,
"timestamp": datetime.now().astimezone().isoformat(),
}
)
atomic_write_json(directory / "refaire.json", new_selection)
atomic_write_json(directory / "session.json", {"id": ident, "values": values})
with staged_files(workspace.root) as staging:
atomic_write_json(staging / workspace.refaire_file.name, new_selection)
atomic_write_json(staging / workspace.gui_state_file.name, updated)
atomic_write_json(staging / "refaire-session.json", {"id": ident})
return updated, ident
+43
View File
@@ -194,6 +194,49 @@ class RefaireGuiTests(unittest.TestCase):
[self.root / "Sol", self.root / "Persp"], [self.root / "Sol", self.root / "Persp"],
) )
def test_restart_actions_warn_reset_and_preserve_or_clear_selection(self):
self.save_selection()
self.app.state_store.update_step("refaire_merge", status="success")
self.app.state_store.update_step("annotation", status="success")
selection = read_json(self.root / "refaire.json")
with patch(
"copienator_gui.app.messagebox.askyesno", return_value=False
) as confirm:
self.app._start_refaire_pass(False)
self.assertIn("importé", confirm.call_args.args[1])
self.assertIn("Mettre à jour", confirm.call_args.args[1])
self.assertEqual(read_json(self.root / "refaire.json"), selection)
with patch("copienator_gui.app.messagebox.askyesno", return_value=True):
self.app._start_refaire_pass(True)
self.app.update()
self.assertEqual(self.app.current_step.id, "refaire_selection")
self.assertEqual(self.app.refaire_panel.values()["selection"], selection)
self.assertIsNone(self.app.state_store.step("refaire_merge").get("status"))
self.assertEqual(self.app.state_store.step("annotation")["status"], "success")
first = self.app.state_store.workspace.refaire_session_id
with patch(
"copienator_gui.app.messagebox.askyesno", return_value=True
) as confirm:
self.app._start_refaire_pass(False)
self.app.update()
self.assertIn("nest pas marquée comme fusionnée", confirm.call_args.args[1])
self.assertEqual(self.app.refaire_panel.entries, {})
self.assertEqual(read_json(self.root / "refaire.json"), [])
self.assertNotEqual(self.app.state_store.workspace.refaire_session_id, first)
self.assertEqual(self.app.refaire_panel.source_var.get(), "Anot")
def test_restart_is_blocked_while_a_command_is_active(self):
self.save_selection()
self.app.active_step_id = "refaire_correct"
with (
patch("copienator_gui.app.messagebox.showwarning") as warning,
patch("copienator_gui.app.messagebox.askyesno") as confirm,
):
self.app._start_refaire_pass(False)
warning.assert_called_once()
confirm.assert_not_called()
self.assertIsNone(self.app.state_store.workspace.refaire_session_id)
def test_small_window_keeps_selection_accessible_by_scrolling(self): def test_small_window_keeps_selection_accessible_by_scrolling(self):
self.select("refaire_selection") self.select("refaire_selection")
self.app.geometry("900x640") self.app.geometry("900x640")
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
import copy
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
from copienator.annotation_data import AnnotationLoadResult
from copienator.commands import annotating_with_checks as checks
from copienator.commands import export, import_annotations
from copienator.commands import reading_grouped_annotations as reader
from copienator_gui.refaire_sessions import begin_pass
class RefaireSessionTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) / "Exam"
self.root.mkdir()
self.workspace = EvaluationWorkspace(self.root)
atomic_write_json(self.workspace.refaire_file, [["Copie01", ["Ex 1"]]])
(self.root / "BRnot/Copie01").mkdir(parents=True)
(self.root / "BRnot/Copie01/Concat_annotated.pdf").write_bytes(b"legacy return")
self.state = {
"steps": {
"annotation": {"status": "success"},
"refaire_selection": {
"status": "success",
"values": {
"selection": [["Copie01", ["Ex 1"]]],
"annotation_dir": "Anot",
"layout": "grouped",
},
},
"refaire_merge": {"status": "success"},
},
"history": [],
}
atomic_write_json(self.workspace.gui_state_file, self.state)
def tearDown(self):
self.temp.cleanup()
def test_new_then_repeat_preserve_each_pass_and_reset_only_redo_progress(self):
state, first = begin_pass(self.workspace, self.state, keep_selection=False)
self.assertEqual(
state["steps"]["annotation"], self.state["steps"]["annotation"]
)
self.assertNotIn("refaire_merge", state["steps"])
self.assertEqual(state["steps"]["refaire_selection"]["values"]["selection"], [])
self.assertEqual(
state["steps"]["refaire_selection"]["values"]["layout"], "grouped"
)
self.assertEqual(read_json(self.workspace.refaire_file), [])
archives = [
path for path in (self.root / "Reprises").iterdir() if path.name != first
]
self.assertEqual(len(archives), 1)
self.assertEqual(
(archives[0] / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
b"legacy return",
)
selection = [["Copie02", ["Ex 2"]]]
atomic_write_json(self.workspace.refaire_file, selection)
first_output = self.workspace.annotation_dir("refaire")
first_output.mkdir(parents=True)
(first_output / "review.pdf").write_bytes(b"first review")
state["steps"]["refaire_merge"] = {"status": "success"}
updated, second = begin_pass(self.workspace, state, keep_selection=True)
self.assertNotEqual(first, second)
self.assertEqual(read_json(self.workspace.refaire_file), selection)
self.assertEqual(
updated["steps"]["refaire_selection"]["values"]["selection"], selection
)
self.assertEqual((first_output / "review.pdf").read_bytes(), b"first review")
self.assertEqual(read_json(first_output.parent / "refaire.json"), selection)
self.assertNotIn("status", updated["steps"]["refaire_selection"])
self.assertEqual(
self.workspace.annotation_dir("refaire"),
self.root / "Reprises" / second / "BRnot",
)
reloaded = EvaluationWorkspace(self.root)
self.assertEqual(reloaded.refaire_session_id, second)
def test_failed_activation_leaves_current_pass_and_state_unchanged(self):
before = copy.deepcopy(self.state)
with (
patch(
"copienator_gui.refaire_sessions.staged_files",
side_effect=OSError("disk unavailable"),
),
self.assertRaises(OSError),
):
begin_pass(self.workspace, self.state, keep_selection=False)
self.assertIsNone(self.workspace.refaire_session_id)
self.assertEqual(read_json(self.workspace.gui_state_file), before)
self.assertEqual(
read_json(self.workspace.refaire_file), [["Copie01", ["Ex 1"]]]
)
self.assertEqual(self.state, before)
self.assertEqual(
(self.root / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
b"legacy return",
)
def test_exports_are_unique_and_old_returns_cannot_enter_new_pass(self):
export_root = Path(self.temp.name) / "Export"
import_root = Path(self.temp.name) / "Import"
import_root.mkdir()
state, first = begin_pass(self.workspace, self.state, keep_selection=True)
for expected_id in (first, None):
if expected_id is None:
state, second = begin_pass(self.workspace, state, keep_selection=True)
expected_id = second
output = self.workspace.annotation_dir("refaire") / "Ex 1 G1"
output.mkdir(parents=True)
(output / "Concat.pdf").write_bytes(expected_id.encode())
with patch.object(export, "EXPORT_DIR", export_root):
self.assertEqual(
export.run(self.workspace, refaire=True), ExitCode.SUCCESS
)
exported = (
export_root / "Exam" / expected_id / f"{expected_id}__Ex 1 G1.pdf"
)
self.assertEqual(exported.read_bytes(), expected_id.encode())
if expected_id == first:
(import_root / exported.name).write_bytes(b"old return")
current = self.workspace.annotation_dir("refaire") / "Ex 1 G1"
with patch.object(import_annotations, "IMPORT_DIR", import_root):
self.assertEqual(
import_annotations.run(self.workspace, refaire=True), ExitCode.PARTIAL
)
self.assertFalse((current / "Concat_annotated.pdf").exists())
(import_root / f"{second}__Ex 1 G1.pdf").write_bytes(b"new return")
self.assertEqual(
import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS
)
self.assertEqual((current / "Concat_annotated.pdf").read_bytes(), b"new return")
self.workspace.require_directories("BRnot")
def test_generation_and_merge_follow_the_active_pass(self):
for directory in ("Copies", "Par label", "Anot"):
(self.root / directory).mkdir()
(self.root / "labels").write_text("Ex 1\n")
atomic_write_json(self.root / "correction.json", {})
answer = self.root / "answer.pdf"
answer.touch()
loaded = AnnotationLoadResult(
{
"01": {
"Ex 1": {
"pdf_path": answer,
"result": {"score": 2, "feedback": []},
"coordinates": (0, 0),
}
}
},
[],
)
begin_pass(self.workspace, self.state, keep_selection=True)
with (
patch.object(checks, "load_annotation_data", return_value=loaded),
patch.object(reader, "load_annotation_data", return_value=loaded),
patch.object(
checks.annotating,
"make_base_image",
return_value=(Image.new("RGB", (100, 100), "white"), 0, 0),
),
patch.object(
checks.annotating,
"compose_label_image",
return_value=(Image.new("RGB", (100, 100), "white"), 0),
),
):
self.assertEqual(
checks.run(self.workspace, self.root, refaire=True, overwrite=True),
ExitCode.SUCCESS,
)
output = self.workspace.annotation_dir("refaire") / "Copie01"
shutil.copy2(output / "Concat.pdf", output / "Concat_annotated.pdf")
self.assertEqual(
reader.run(self.workspace, refaire=True, annotation_dir="Anot"),
ExitCode.SUCCESS,
)
self.assertEqual(
read_json(self.root / "Anot/Copie01/score.json"), {"Ex 1": "2"}
)
self.assertEqual(
(self.root / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
b"legacy return",
)
def test_invalid_session_id_cannot_escape_workspace(self):
atomic_write_json(self.root / "refaire-session.json", {"id": "../elsewhere"})
with self.assertRaises(ValueError):
self.workspace.annotation_dir("refaire")
if __name__ == "__main__":
unittest.main()