Recorrection GUI step ; LaTeX issues debugging
This commit is contained in:
1 parent
2d0f7a219f
commit
48602c24f1
12 files changed
+306
-8
No files matched your search
@@ -4,6 +4,7 @@ import argparse
|
|||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
@@ -175,6 +176,10 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N
|
|||||||
final_img.alpha_composite(img)
|
final_img.alpha_composite(img)
|
||||||
return final_img
|
return final_img
|
||||||
|
|
||||||
|
class AnnotationLatexError(RuntimeError):
|
||||||
|
"""A feedback comment failed to render; diagnostics have been retained."""
|
||||||
|
|
||||||
|
|
||||||
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
|
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
|
||||||
dpi = 100
|
dpi = 100
|
||||||
width_in = width_px / dpi
|
width_in = width_px / dpi
|
||||||
@@ -194,16 +199,37 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
|
|||||||
f.write(latex_template)
|
f.write(latex_template)
|
||||||
|
|
||||||
# Compile to PDF
|
# Compile to PDF
|
||||||
subprocess.run(
|
compilation = subprocess.run(
|
||||||
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
|
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
|
||||||
cwd=temp_dir,
|
cwd=temp_dir,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.STDOUT,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(pdf_path):
|
if not os.path.exists(pdf_path):
|
||||||
raise RuntimeError("LaTeX compilation failed. Check your LaTeX syntax.")
|
diagnostics = Path(tempfile.mkdtemp(prefix="copienator-latex-error-"))
|
||||||
|
shutil.copy2(tex_path, diagnostics / "text.tex")
|
||||||
|
log_path = Path(temp_dir) / "text.log"
|
||||||
|
output = (compilation.stdout or b"").decode("utf-8", errors="replace")
|
||||||
|
(diagnostics / "compiler-output.txt").write_text(output, encoding="utf-8")
|
||||||
|
if log_path.exists():
|
||||||
|
shutil.copy2(log_path, diagnostics / "text.log")
|
||||||
|
log = log_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
log = output
|
||||||
|
lines = log.splitlines()
|
||||||
|
first_error = next((i for i, line in enumerate(lines) if line.startswith("!")), max(0, len(lines) - 20))
|
||||||
|
excerpt = "\n".join(lines[first_error:first_error + 25])
|
||||||
|
raise AnnotationLatexError(
|
||||||
|
f"Échec de compilation LaTeX (code {compilation.returncode}).\n"
|
||||||
|
f"Commentaire LaTeX fautif :\n{text}\n\n"
|
||||||
|
f"Extrait du journal :\n{excerpt}\n\n"
|
||||||
|
f"Source LaTeX complète : {diagnostics / 'text.tex'}\n"
|
||||||
|
f"Diagnostics conservés dans : {diagnostics}\n"
|
||||||
|
"Modifiez le commentaire concerné dans correction.json, enregistrez le fichier, "
|
||||||
|
"puis relancez cette étape de génération des annotations."
|
||||||
|
)
|
||||||
|
|
||||||
# Convert PDF to grayscale (ignoring pdf2image's broken transparency)
|
# Convert PDF to grayscale (ignoring pdf2image's broken transparency)
|
||||||
images = convert_from_path(pdf_path, dpi=dpi)
|
images = convert_from_path(pdf_path, dpi=dpi)
|
||||||
@@ -295,7 +321,7 @@ def compose_label_image(base_img, label, result, hmin,
|
|||||||
with_error=True,
|
with_error=True,
|
||||||
with_empty=False,
|
with_empty=False,
|
||||||
more_right=False,
|
more_right=False,
|
||||||
with_id=None):
|
with_id=None, source_pdf=None):
|
||||||
"""
|
"""
|
||||||
Composes the final image with annotations.
|
Composes the final image with annotations.
|
||||||
|
|
||||||
@@ -309,6 +335,21 @@ def compose_label_image(base_img, label, result, hmin,
|
|||||||
called when elements are placed. Used for checkboxes.
|
called when elements are placed. Used for checkboxes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
original_render = render_fn
|
||||||
|
|
||||||
|
def render_fn(text, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return original_render(text, *args, **kwargs)
|
||||||
|
except AnnotationLatexError as exc:
|
||||||
|
context = f"Question : {label}"
|
||||||
|
if source_pdf is not None:
|
||||||
|
source = Path(source_pdf)
|
||||||
|
context = f"Copie : {source.parent.name} — {context}\nPDF source : {source}"
|
||||||
|
context += f"\nFichier à modifier : {source.parent.parent.parent / 'correction.json'}"
|
||||||
|
elif with_id is not None:
|
||||||
|
context = f"Copie : {with_id} — {context}"
|
||||||
|
raise AnnotationLatexError(f"{context}\n{exc}") from exc
|
||||||
|
|
||||||
left_pad = 0
|
left_pad = 0
|
||||||
if base_img.width < TARGET_MIN_WIDTH:
|
if base_img.width < TARGET_MIN_WIDTH:
|
||||||
total_missing = TARGET_MIN_WIDTH - base_img.width
|
total_missing = TARGET_MIN_WIDTH - base_img.width
|
||||||
@@ -489,6 +530,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
|||||||
coordinates[0],
|
coordinates[0],
|
||||||
with_empty=True,
|
with_empty=True,
|
||||||
render_fn=render_real_latex_text,
|
render_fn=render_real_latex_text,
|
||||||
|
source_pdf=pdf_full_path,
|
||||||
)
|
)
|
||||||
final_img.save(staging / f"{label}.jpg")
|
final_img.save(staging / f"{label}.jpg")
|
||||||
if result.get('error', "") != "empty-answer":
|
if result.get('error', "") != "empty-answer":
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ def render_item(item):
|
|||||||
draw_callback=checkbox_renderer.callback,
|
draw_callback=checkbox_renderer.callback,
|
||||||
more_right=True,
|
more_right=True,
|
||||||
with_id=student_id,
|
with_id=student_id,
|
||||||
|
source_pdf=pdf_path,
|
||||||
)
|
)
|
||||||
if final_image is None:
|
if final_image is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ def _render_student(
|
|||||||
content["result"],
|
content["result"],
|
||||||
content["coordinates"][0],
|
content["coordinates"][0],
|
||||||
draw_callback=checkbox_renderer.callback,
|
draw_callback=checkbox_renderer.callback,
|
||||||
|
source_pdf=pdf_path,
|
||||||
)
|
)
|
||||||
if final_image is None:
|
if final_image is None:
|
||||||
problems = True
|
problems = True
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ def read_json(path: str | Path, *, default: T | object = _MISSING) -> T:
|
|||||||
target = Path(path)
|
target = Path(path)
|
||||||
try:
|
try:
|
||||||
with target.open("r", encoding="utf-8") as stream:
|
with target.open("r", encoding="utf-8") as stream:
|
||||||
|
if target.name == "refaire.json":
|
||||||
|
# This user-edited selection supports whole-line // comments.
|
||||||
|
# Preserve line numbers in JSON diagnostics and keep other JSON strict.
|
||||||
|
text = "".join(
|
||||||
|
"\n" if line.lstrip().startswith("//") else line
|
||||||
|
for line in stream
|
||||||
|
)
|
||||||
|
return cast(T, json.loads(text))
|
||||||
return cast(T, json.load(stream))
|
return cast(T, json.load(stream))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
if default is _MISSING:
|
if default is _MISSING:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from .scores import ScoreEditorPanel
|
|||||||
from .notifications import notify_desktop
|
from .notifications import notify_desktop
|
||||||
from .syncthing import SyncthingPanel
|
from .syncthing import SyncthingPanel
|
||||||
from .manual_resolution import ManualResolutionPanel
|
from .manual_resolution import ManualResolutionPanel
|
||||||
|
from .recorrection import prepare_selection_file
|
||||||
from .refaire import SECTION as REFAIRE_SECTION
|
from .refaire import SECTION as REFAIRE_SECTION
|
||||||
from .refaire import (
|
from .refaire import (
|
||||||
RefaireSelection,
|
RefaireSelection,
|
||||||
@@ -930,6 +931,14 @@ class CopienatorApp(tk.Tk):
|
|||||||
self.arg_vars['target'].trace_add('write', lambda *_args: self._refresh_persp_inventory())
|
self.arg_vars['target'].trace_add('write', lambda *_args: self._refresh_persp_inventory())
|
||||||
self._refresh_persp_inventory()
|
self._refresh_persp_inventory()
|
||||||
|
|
||||||
|
if step.id == "prepare_recorrection":
|
||||||
|
self.edit_recorrection_button = ttk.Button(
|
||||||
|
self.form, text="Ouvrir refaire.json (avec exemples)",
|
||||||
|
command=self._edit_recorrection,
|
||||||
|
)
|
||||||
|
self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
|
||||||
|
row += 1
|
||||||
|
|
||||||
if step.id == "manual_resolution":
|
if step.id == "manual_resolution":
|
||||||
def manual_evaluation():
|
def manual_evaluation():
|
||||||
target = self.arg_vars.get("target")
|
target = self.arg_vars.get("target")
|
||||||
@@ -1117,6 +1126,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
"cutleft",
|
"cutleft",
|
||||||
"labels",
|
"labels",
|
||||||
"plotting",
|
"plotting",
|
||||||
|
"splitting",
|
||||||
}
|
}
|
||||||
if step.id in copy_selection_steps:
|
if step.id in copy_selection_steps:
|
||||||
evaluation = self.evaluation
|
evaluation = self.evaluation
|
||||||
@@ -1310,6 +1320,16 @@ class CopienatorApp(tk.Tk):
|
|||||||
except (OSError, RuntimeError) as exc:
|
except (OSError, RuntimeError) as exc:
|
||||||
messagebox.showerror("Ouverture impossible", str(exc))
|
messagebox.showerror("Ouverture impossible", str(exc))
|
||||||
|
|
||||||
|
def _edit_recorrection(self) -> None:
|
||||||
|
if self.evaluation is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path = prepare_selection_file(self.evaluation)
|
||||||
|
except OSError as exc:
|
||||||
|
messagebox.showerror("Préparation impossible", str(exc))
|
||||||
|
return
|
||||||
|
self._open_desktop_path(path, "fichier refaire.json")
|
||||||
|
|
||||||
def _select_variant(self, index: int) -> None:
|
def _select_variant(self, index: int) -> None:
|
||||||
if not self.current_step or index < 0:
|
if not self.current_step or index < 0:
|
||||||
return
|
return
|
||||||
@@ -1546,6 +1566,15 @@ class CopienatorApp(tk.Tk):
|
|||||||
if step.id == "refaire_selection":
|
if step.id == "refaire_selection":
|
||||||
self._save_refaire_selection()
|
self._save_refaire_selection()
|
||||||
return
|
return
|
||||||
|
if step.id == "prepare_recorrection":
|
||||||
|
try:
|
||||||
|
load_selection(evaluation)
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
messagebox.showerror("Sélection à vérifier", str(exc))
|
||||||
|
return
|
||||||
|
self._mark_step("success")
|
||||||
|
self._select_refaire_correction()
|
||||||
|
return
|
||||||
if step.section == REFAIRE_SECTION:
|
if step.section == REFAIRE_SECTION:
|
||||||
try:
|
try:
|
||||||
self._refaire_selection()
|
self._refaire_selection()
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Editable examples for the optional correction redo step."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from copienator import atomic_write_text
|
||||||
|
|
||||||
|
|
||||||
|
EXAMPLES = """// Exemples de sélection pour refaire la correction
|
||||||
|
// Les lignes commençant par // sont des commentaires et sont ignorées.
|
||||||
|
// Modifiez la liste JSON ci-dessous, puis enregistrez le fichier.
|
||||||
|
// Une copie entière : [["Copie46", []]]
|
||||||
|
// Certaines questions : [["Copie46", ["II)8)", "II)9)"]]
|
||||||
|
// Plusieurs copies : [["Copie46", []], ["Copie03", ["Ex 1"]]]
|
||||||
|
// Utilisez les noms exacts des copies et des labels de votre évaluation.
|
||||||
|
// [] après une copie signifie toutes ses questions ; [] seul ne sélectionne rien.
|
||||||
|
// Séparez les entrées par des virgules, sans virgule après la dernière.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_selection_file(evaluation: Path) -> Path:
|
||||||
|
path = evaluation / "refaire.json"
|
||||||
|
existing = path.read_text(encoding="utf-8") if path.exists() else "[]\n"
|
||||||
|
if not existing.startswith(EXAMPLES):
|
||||||
|
atomic_write_text(path, EXAMPLES + existing)
|
||||||
|
return path
|
||||||
@@ -371,12 +371,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"splitting",
|
"splitting",
|
||||||
"Labels et regroupement",
|
"Labels et regroupement",
|
||||||
"Découper les réponses par question",
|
"Découper les réponses par question",
|
||||||
"Découpe les copies à partir des coordonnées de labels vérifiées.",
|
"Découpe les copies à partir des coordonnées de labels vérifiées. "
|
||||||
|
"Ciblez une copie avec le sélecteur ou tout le dossier, puis cliquez sur Exécuter.",
|
||||||
(python("default", "Découpage", "split-answers"),),
|
(python("default", "Découpage", "split-answers"),),
|
||||||
arguments=(arg_target(),),
|
arguments=(arg_target(),),
|
||||||
requires=("Copies",),
|
requires=("Copies",),
|
||||||
artifacts=("Copies/Copie*/*",),
|
artifacts=("Copies/Copie*/*",),
|
||||||
auto_start_first_visit=True,
|
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"crop_exercise_bottoms",
|
"crop_exercise_bottoms",
|
||||||
@@ -511,6 +511,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
requires=("manual_resolutions.txt", "correction.json"),
|
requires=("manual_resolutions.txt", "correction.json"),
|
||||||
skip_without_manual_conflicts=True,
|
skip_without_manual_conflicts=True,
|
||||||
),
|
),
|
||||||
|
StepDefinition(
|
||||||
|
"prepare_recorrection",
|
||||||
|
"Correction",
|
||||||
|
"Choisir des copies à recorriger",
|
||||||
|
"Facultatif : ouvrez refaire.json et indiquez les copies entières ou les questions à recorriger. "
|
||||||
|
"Les exemples commentés expliquent la syntaxe. Enregistrez le fichier, puis marquez cette étape terminée : "
|
||||||
|
"vous reviendrez à Correction avec --refaire sélectionné, sans lancement automatique. "
|
||||||
|
"Les corrections sélectionnées seront remplacées ; celles des autres copies seront conservées. "
|
||||||
|
"Si vous avez modifié les découpes, régénérez les réponses avant la recorrection. "
|
||||||
|
"Ignorez cette étape si aucune reprise n’est nécessaire.",
|
||||||
|
(manual("default"),),
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"post_correction",
|
"post_correction",
|
||||||
"Correction",
|
"Correction",
|
||||||
|
|||||||
@@ -251,6 +251,31 @@ coupure, et quelle moitié doit être gardée. L'interface graphique vous
|
|||||||
fournira alors une instruction texte à copier et à mettre dans le
|
fournira alors une instruction texte à copier et à mettre dans le
|
||||||
fichier texte.
|
fichier texte.
|
||||||
|
|
||||||
|
Vous pouvez aussi ajouter vos propres instructions dans
|
||||||
|
`manual_resolutions.txt`, même si Gemini n’a pas signalé ces conflits.
|
||||||
|
Les lignes sont appliquées **de haut en bas**, avec une distinction importante :
|
||||||
|
|
||||||
|
- La **source** est toujours le PDF tel qu’il existait au début de cette
|
||||||
|
exécution, avant les modifications des lignes précédentes.
|
||||||
|
- La **cible** est sa version courante, tenant compte des lignes précédentes.
|
||||||
|
|
||||||
|
Par exemple, pour une copie ayant les labels `A`, `B` et `C` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Copie46 A c{40}1> B|
|
||||||
|
Copie46 C -> A|
|
||||||
|
```
|
||||||
|
|
||||||
|
La première ligne coupe A à 40 % de sa hauteur cumulée, conserve la première
|
||||||
|
partie sous le label A et ajoute les 60 % restants après B. La seconde ligne
|
||||||
|
ajoute C après **la partie conservée de A**, et non après A entier. C reste
|
||||||
|
également disponible sous son propre label.
|
||||||
|
|
||||||
|
En revanche, si une ligne ultérieure utilise **A comme source**, elle utilise
|
||||||
|
le PDF A entier du début de l’exécution, pas seulement sa partie conservée.
|
||||||
|
Une seule coupe par label source et par copie est autorisée dans une même
|
||||||
|
exécution. Remplacez `A`, `B` et `C` par les labels exacts de votre évaluation.
|
||||||
|
|
||||||
Enregistrez le fichier texte, éventuellement le **Recharger**, puis
|
Enregistrez le fichier texte, éventuellement le **Recharger**, puis
|
||||||
**Exécuter**.
|
**Exécuter**.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from copienator.commands import annotating
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationLatexErrorTests(unittest.TestCase):
|
||||||
|
def test_failure_preserves_source_and_log_and_explains_recovery(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
diagnostics = Path(directory) / "diagnostics"
|
||||||
|
diagnostics.mkdir()
|
||||||
|
|
||||||
|
def compile_latex(command, **kwargs):
|
||||||
|
Path(kwargs["cwd"], "text.log").write_bytes(
|
||||||
|
b"preamble\n! Missing \\endcsname inserted.\nl.12 \\end{document}\n\xff"
|
||||||
|
)
|
||||||
|
return CompletedProcess(command, 1, stdout=b"compiler output\xff")
|
||||||
|
|
||||||
|
# Patch only the retained diagnostics directory, not TemporaryDirectory.
|
||||||
|
original_mkdtemp = tempfile.mkdtemp
|
||||||
|
|
||||||
|
def make_directory(*args, **kwargs):
|
||||||
|
if kwargs.get("prefix") == "copienator-latex-error-":
|
||||||
|
return str(diagnostics)
|
||||||
|
return original_mkdtemp(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(annotating.subprocess, "run", side_effect=compile_latex), patch.object(
|
||||||
|
annotating.tempfile, "mkdtemp", side_effect=make_directory
|
||||||
|
):
|
||||||
|
with self.assertRaises(annotating.AnnotationLatexError) as caught:
|
||||||
|
annotating.render_real_latex_text(r"Antislash \.", 400)
|
||||||
|
message = str(caught.exception)
|
||||||
|
for expected in ("code 1", r"Antislash \.", "Missing", "correction.json", "relancez cette étape", str(diagnostics)):
|
||||||
|
self.assertIn(expected, message)
|
||||||
|
self.assertIn(r"Antislash \.", (diagnostics / "text.tex").read_text())
|
||||||
|
self.assertTrue((diagnostics / "text.log").exists())
|
||||||
|
self.assertTrue((diagnostics / "compiler-output.txt").exists())
|
||||||
|
|
||||||
|
def test_comment_error_identifies_copy_label_and_correction_file(self):
|
||||||
|
for box in (None, [0, 0, 20, 20]):
|
||||||
|
with self.subTest(box=box), patch.object(
|
||||||
|
annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40))
|
||||||
|
):
|
||||||
|
with self.assertRaises(annotating.AnnotationLatexError) as caught:
|
||||||
|
annotating.compose_label_image(
|
||||||
|
Image.new("RGB", (800, 200)), "I)7)",
|
||||||
|
{"feedback": [{"text": "Bad comment", "box_2d": box}]}, 0,
|
||||||
|
render_fn=Mock(side_effect=annotating.AnnotationLatexError("Bad comment")),
|
||||||
|
source_pdf=Path("/exam/Copies/Copie39/I)7).pdf"),
|
||||||
|
)
|
||||||
|
message = str(caught.exception)
|
||||||
|
for expected in ("Copie39", "I)7)", "/exam/correction.json", "Bad comment"):
|
||||||
|
self.assertIn(expected, message)
|
||||||
@@ -214,6 +214,49 @@ class GuiConvenienceTests(unittest.TestCase):
|
|||||||
self.assertNotIn("overwrite", self.app.arg_vars)
|
self.assertNotIn("overwrite", self.app.arg_vars)
|
||||||
self.assertIn("--refaire", self.app._make_command())
|
self.assertIn("--refaire", self.app._make_command())
|
||||||
|
|
||||||
|
def test_optional_recorrection_opens_examples_and_returns_to_correction(self):
|
||||||
|
from copienator import read_json
|
||||||
|
ids = [step.id for step in self.app.steps]
|
||||||
|
self.assertEqual(ids[ids.index("manual_resolution") + 1], "prepare_recorrection")
|
||||||
|
copies = self.evaluation / "Copies"
|
||||||
|
copies.mkdir()
|
||||||
|
(copies / "Copie46.pdf").touch()
|
||||||
|
(self.evaluation / "labels").write_text("II)9)\n")
|
||||||
|
self.app.tree.selection_set("prepare_recorrection")
|
||||||
|
self.app.update()
|
||||||
|
with patch("copienator_gui.app.open_path") as opened:
|
||||||
|
self.app.edit_recorrection_button.invoke()
|
||||||
|
path = self.evaluation / "refaire.json"
|
||||||
|
opened.assert_called_once_with(path)
|
||||||
|
self.assertEqual(read_json(path), [])
|
||||||
|
path.write_text(path.read_text().replace("\n[]\n", '\n[["Copie46", []]]\n'))
|
||||||
|
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("prepare_recorrection")["status"], "success")
|
||||||
|
self.assertEqual(self.app.current_step.id, "correction")
|
||||||
|
self.assertEqual(self.app.variant_var.get(), "refaire")
|
||||||
|
self.assertIn("--refaire", self.app._make_command())
|
||||||
|
self.assertNotIn("--overwrite", self.app._make_command())
|
||||||
|
|
||||||
|
def test_ignoring_optional_recorrection_continues_without_creating_file(self):
|
||||||
|
self.app.tree.selection_set("prepare_recorrection")
|
||||||
|
self.app.update()
|
||||||
|
self.app._skip_step()
|
||||||
|
self.app.update()
|
||||||
|
self.assertEqual(self.app.current_step.id, "post_correction")
|
||||||
|
self.assertEqual(self.app.state_store.step("prepare_recorrection")["status"], "skipped")
|
||||||
|
self.assertFalse((self.evaluation / "refaire.json").exists())
|
||||||
|
|
||||||
|
def test_optional_recorrection_requires_a_saved_selection(self):
|
||||||
|
self.app.tree.selection_set("prepare_recorrection")
|
||||||
|
self.app.update()
|
||||||
|
with patch("copienator_gui.app.messagebox.showerror") as error:
|
||||||
|
self.app.run_button.invoke()
|
||||||
|
error.assert_called_once()
|
||||||
|
self.assertEqual(self.app.current_step.id, "prepare_recorrection")
|
||||||
|
|
||||||
def test_giving_names_waits_for_explicit_manual_completion(self):
|
def test_giving_names_waits_for_explicit_manual_completion(self):
|
||||||
return_dir = self.evaluation / "A Rendre" / "Student (01)"
|
return_dir = self.evaluation / "A Rendre" / "Student (01)"
|
||||||
return_dir.mkdir(parents=True)
|
return_dir.mkdir(parents=True)
|
||||||
@@ -560,6 +603,25 @@ class GuiConvenienceTests(unittest.TestCase):
|
|||||||
self.assertIn("Cibler la copie sélectionnée", controls)
|
self.assertIn("Cibler la copie sélectionnée", controls)
|
||||||
self.assertIn("Cibler tout le dossier", controls)
|
self.assertIn("Cibler tout le dossier", controls)
|
||||||
|
|
||||||
|
def test_answer_splitting_waits_for_target_and_can_select_one_copy(self):
|
||||||
|
copies = self.evaluation / "Copies"
|
||||||
|
copies.mkdir()
|
||||||
|
for name in ("Copie01.pdf", "Copie46.pdf"):
|
||||||
|
(copies / name).touch()
|
||||||
|
with patch.object(self.app, "_run_current_step") as run:
|
||||||
|
self.app.tree.selection_set("splitting")
|
||||||
|
self.app.update()
|
||||||
|
run.assert_not_called()
|
||||||
|
self.assertIn("Copie46.pdf", self.app.copy_paths)
|
||||||
|
self.app.copy_var.set("Copie46.pdf")
|
||||||
|
self.app._target_selected_copy()
|
||||||
|
command = self.app._make_command()
|
||||||
|
self.assertIn("split-answers", command)
|
||||||
|
self.assertEqual(Path(command[5]), copies / "Copie46.pdf")
|
||||||
|
self.app._target_all_pages()
|
||||||
|
command = self.app._make_command()
|
||||||
|
self.assertEqual((self.repository / command[5]).resolve(), self.evaluation)
|
||||||
|
|
||||||
def test_label_review_can_show_and_target_one_copy(self):
|
def test_label_review_can_show_and_target_one_copy(self):
|
||||||
copies = self.evaluation / "Copies"
|
copies = self.evaluation / "Copies"
|
||||||
copies.mkdir()
|
copies.mkdir()
|
||||||
|
|||||||
@@ -2378,7 +2378,7 @@ class WorkflowTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
auto_start,
|
auto_start,
|
||||||
{"crop_blank_margins", "splitting", "crop_exercise_bottoms", "grouping"},
|
{"crop_blank_margins", "crop_exercise_bottoms", "grouping"},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
skip_for_live, {"submit_batches", "batch_status", "fetch_batches"}
|
skip_for_live, {"submit_batches", "batch_status", "fetch_batches"}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from copienator import read_json
|
||||||
|
from copienator_gui.recorrection import prepare_selection_file
|
||||||
|
|
||||||
|
|
||||||
|
class RecorrectionFileTests(unittest.TestCase):
|
||||||
|
def test_examples_are_inactive_and_opening_preserves_selection(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
path = prepare_selection_file(root)
|
||||||
|
self.assertEqual(read_json(path), [])
|
||||||
|
self.assertIn('// Une copie entière', path.read_text())
|
||||||
|
selection = [["Copie46", ["II)9)"]]]
|
||||||
|
path.write_text(json.dumps(selection))
|
||||||
|
prepare_selection_file(root)
|
||||||
|
self.assertEqual(read_json(path), selection)
|
||||||
|
before = path.read_bytes()
|
||||||
|
prepare_selection_file(root)
|
||||||
|
self.assertEqual(path.read_bytes(), before)
|
||||||
|
|
||||||
|
def test_comments_do_not_affect_strings_or_other_json_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
path = root / "refaire.json"
|
||||||
|
path.write_text('// Example\n[["Copie01", ["a//b"]]]\n')
|
||||||
|
self.assertEqual(read_json(path), [["Copie01", ["a//b"]]])
|
||||||
|
other = root / "correction.json"
|
||||||
|
other.write_text(path.read_text())
|
||||||
|
with self.assertRaises(json.JSONDecodeError):
|
||||||
|
read_json(other)
|
||||||
Reference in new issue
Block a user