Miscs personal GUI improvement

This commit is contained in:
2026-09-14 09:42:13 +02:00
parent e8b8a11c5b
commit d60d5479d6
5 changed files with 207 additions and 33 deletions
+97 -24
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import os
import queue
import re
import shutil
import tkinter as tk
from dataclasses import replace
from pathlib import Path
@@ -10,6 +12,7 @@ from typing import Any
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json
from copienator.copy_errors import copy_errors
from copienator.filesystem import staged_files
from copienator.platform import (
WindowsLabelError,
add_platform_executable_paths,
@@ -52,6 +55,7 @@ STATUS_LABELS = {
}
DEFAULT_HTTPS_PROXY = "http://10.0.0.1:3128"
PERSONAL_INTERRO_SOURCE = Path("/home/sebastien/Prépa/Staging/Interro")
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
ANNOTATION_VARIANT_DIRECTORIES = {
"simple": "Anot",
@@ -60,6 +64,33 @@ ANNOTATION_VARIANT_DIRECTORIES = {
}
def get_personal_interro_files(
evaluation: Path, source_directory: Path = PERSONAL_INTERRO_SOURCE
) -> tuple[Path, Path, Path]:
"""Copy an Interro project's statement sources into the evaluation."""
match = re.fullmatch(r"Interro(\d+)", evaluation.name)
if match is None:
raise ValueError("Le dossier d’évaluation doit sappeler Interro{id}, avec un identifiant numérique.")
project_name = evaluation.name
source_to_destination = (
(source_directory / f"{project_name}.pdf", "enonce.pdf"),
(source_directory / f"{project_name}.tex", "enonce.tex"),
(source_directory / f"{project_name}c.tex", "correction.tex"),
)
missing = [source.name for source, _destination in source_to_destination if not source.is_file()]
if missing:
raise FileNotFoundError(
"Fichier(s) source introuvable(s) dans "
f"{source_directory} : {', '.join(missing)}"
)
with staged_files(evaluation) as staging:
for source, destination_name in source_to_destination:
shutil.copy2(source, staging / destination_name)
return tuple(evaluation / name for _source, name in source_to_destination)
class Tooltip:
"""Small delayed tooltip for Tk and ttk widgets."""
@@ -338,13 +369,23 @@ class CopienatorApp(tk.Tk):
self.detail = ttk.Frame(main_pane, padding=(12, 6, 4, 4))
self.detail.columnconfigure(0, weight=1)
self.detail.rowconfigure(2, weight=1)
self.detail.rowconfigure(3, weight=1)
self.title_label = ttk.Label(self.detail, text="Sélectionnez une étape", font=("TkDefaultFont", 15, "bold"))
self.title_label.grid(row=0, column=0, sticky="w")
self.description_label = ttk.Label(self.detail, text="", wraplength=680, justify="left")
self.description_label.grid(row=1, column=0, sticky="ew", pady=(5, 8))
ttk.Style(self).configure("MissingPrerequisite.TLabel", foreground="#c62828")
self.missing_requirements_label = ttk.Label(
self.detail,
text="",
style="MissingPrerequisite.TLabel",
wraplength=680,
justify="left",
)
self.missing_requirements_label.grid(row=2, column=0, sticky="ew", pady=(0, 8))
self.missing_requirements_label.grid_remove()
form_container = ttk.Frame(self.detail)
form_container.grid(row=2, column=0, sticky="nsew")
form_container.grid(row=3, column=0, sticky="nsew")
form_container.columnconfigure(0, weight=1)
form_container.rowconfigure(0, weight=1)
self.form_canvas = tk.Canvas(form_container, highlightthickness=0, width=1, height=1)
@@ -359,7 +400,7 @@ class CopienatorApp(tk.Tk):
self.form.columnconfigure(1, weight=1)
command_box = ttk.LabelFrame(self.detail, text="Commande", padding=6)
command_box.grid(row=3, column=0, sticky="ew", pady=(8, 5))
command_box.grid(row=4, column=0, sticky="ew", pady=(8, 5))
command_box.columnconfigure(0, weight=1)
ttk.Label(command_box, textvariable=self.command_var, wraplength=690, justify="left").grid(
row=0, column=0, sticky="ew"
@@ -368,7 +409,7 @@ class CopienatorApp(tk.Tk):
self.copy_command_button.grid(row=1, column=0, sticky="w", pady=(5, 0))
buttons = ttk.Frame(self.detail)
buttons.grid(row=4, column=0, sticky="ew", pady=(5, 0))
buttons.grid(row=5, column=0, sticky="ew", pady=(5, 0))
self.previous_button = ttk.Button(buttons, text="← Précédente", command=lambda: self._move_selection(-1))
self.previous_button.pack(side="left")
self.next_button = ttk.Button(buttons, text="Suivante →", command=lambda: self._move_selection(1))
@@ -603,6 +644,40 @@ class CopienatorApp(tk.Tk):
self._render_step()
self.info_var.set("Fichiers revérifiés — " + ("manquants : " + ", ".join(missing) if missing else "tous les fichiers dentrée sont présents."))
def _get_input_files(self) -> None:
evaluation = self.evaluation
if (
not self.show_personal_steps
or not evaluation
or not self.state_store.evaluation
or self.active_step_id
or self.runner.running
):
return
try:
copied = get_personal_interro_files(evaluation)
except (OSError, ValueError) as exc:
messagebox.showerror("Récupération impossible", str(exc))
return
missing = self._missing_requirements(self.step_by_id["inputs"])
if missing:
self.state_store.update_step("inputs", status="ready")
self._populate_tree()
self._render_step()
messagebox.showwarning(
"Prérequis encore manquant",
"Les trois fichiers ont été copiés, mais l’étape ne peut pas encore "
"être terminée :\n\n" + "\n".join(missing),
)
return
self._mark_step("success", automatic=True, reason="Fichiers dentrée récupérés")
self.info_var.set(
"Fichiers récupérés : " + ", ".join(path.name for path in copied)
)
self._move_selection_from("inputs", 1)
def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None:
selection = self.tree.selection()
if not selection or selection[0].startswith("section:"):
@@ -692,10 +767,15 @@ class CopienatorApp(tk.Tk):
if step.id == "statement" and saved_variant == "personal":
description = ("Lit les blocs SHEETINFO de enonce.tex et récupère les énoncés, solutions "
"et barèmes du service personnel (localhost:8080). Crée un groupe par exercice. "
"Les actions Gemini ci-dessous restent facultatives.")
if missing:
description += "\nPrérequis non détectés : " + ", ".join(missing)
"Les deux étapes Gemini suivantes restent facultatives.")
self.description_label.configure(text=description)
if missing:
self.missing_requirements_label.configure(
text="Prérequis non détectés : " + ", ".join(missing)
)
self.missing_requirements_label.grid()
else:
self.missing_requirements_label.grid_remove()
row = self._render_context_controls(step, 0)
if len(step.variants) > 1:
@@ -765,14 +845,6 @@ class CopienatorApp(tk.Tk):
attach_tooltip(extra_entry, step.extra_arguments_help)
row += 1
if self.show_personal_steps and step.id == "statement":
actions = ttk.LabelFrame(self.form, text="Après génération — facultatif", padding=7)
actions.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(10, 8))
for ident, title in (("statement_groups", "Regrouper avec Gemini…"),
("statement_persp", "Remplacer Persp avec Gemini…")):
ttk.Button(actions, text=title, command=lambda target=ident: self._select_statement_action(target)).pack(
side="left", padx=(0, 6))
self.run_button.configure(text="Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
self.skip_button.configure(state="normal" if step.optional else "disabled")
self._rendering = False
@@ -790,7 +862,16 @@ class CopienatorApp(tk.Tk):
ttk.Button(self.form, text="Recharger — vérifier à nouveau", command=self._reload_inputs).grid(
row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)
)
return row + 1
row += 1
if self.show_personal_steps:
self.get_input_files_button = ttk.Button(
self.form, text="Get the files", command=self._get_input_files
)
self.get_input_files_button.grid(
row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)
)
row += 1
return row
if step.section == REFAIRE_SECTION:
evaluation = self.evaluation
if step.id in {"refaire_selection", "refaire_correct"}:
@@ -891,14 +972,6 @@ class CopienatorApp(tk.Tk):
row += 1
return row
def _select_statement_action(self, step_id: str) -> None:
self._save_current_form()
target = self.arg_vars.get("target")
if target:
self.state_store.update_step(step_id, values={"target": target.get()})
self.tree.selection_set(step_id)
self.tree.see(step_id)
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))
+3 -4
View File
@@ -102,10 +102,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement de l’énoncé",
"Analyser l’énoncé",
"Détecte les questions, leurs groupes, le corrigé et les indications de barème.",
(
python("gemini", "Analyse avec Gemini", "statement"),
) + ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
if show_personal_steps else ()),
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
if show_personal_steps else ())
+ (python("gemini", "Analyse avec Gemini", "statement"),),
arguments=(
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
+5 -1
View File
@@ -52,7 +52,11 @@ class GuiConvenienceTests(unittest.TestCase):
(self.evaluation / "enonce.pdf").unlink()
self.app._reload_inputs()
self.assertEqual(self.app.state_store.step("inputs")["status"], "ready")
self.assertIn("enonce.pdf", self.app.description_label.cget("text"))
self.assertIn("enonce.pdf", self.app.missing_requirements_label.cget("text"))
style = ttk.Style(self.app)
self.assertEqual(
style.lookup("MissingPrerequisite.TLabel", "foreground"), "#c62828"
)
def test_compact_evaluation_input_and_open_folder_button(self):
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
+48
View File
@@ -38,6 +38,7 @@ from copienator_gui.app import (
build_runner_environment,
copy_pdf_paths,
detected_annotation_directories,
get_personal_interro_files,
has_manual_conflicts,
plotting_shortcut_lines,
process_status,
@@ -1660,6 +1661,53 @@ class WorkflowTests(unittest.TestCase):
self.assertNotIn("update_ods", standard_ids)
self.assertIn("update_ods", personal_ids)
def test_get_personal_interro_files_copies_the_three_expected_sources(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source"
evaluation = root / "Interro07"
source.mkdir()
evaluation.mkdir()
contents = {
"Interro07.pdf": b"pdf",
"Interro07.tex": b"statement",
"Interro07c.tex": b"correction",
}
for name, content in contents.items():
(source / name).write_bytes(content)
copied = get_personal_interro_files(evaluation, source)
self.assertEqual(
copied,
(
evaluation / "enonce.pdf",
evaluation / "enonce.tex",
evaluation / "correction.tex",
),
)
self.assertEqual((evaluation / "enonce.pdf").read_bytes(), b"pdf")
self.assertEqual((evaluation / "enonce.tex").read_bytes(), b"statement")
self.assertEqual((evaluation / "correction.tex").read_bytes(), b"correction")
def test_get_personal_interro_files_validates_name_and_all_sources_first(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source"
source.mkdir()
invalid = root / "DS07"
invalid.mkdir()
with self.assertRaisesRegex(ValueError, "Interro"):
get_personal_interro_files(invalid, source)
evaluation = root / "Interro07"
evaluation.mkdir()
(evaluation / "enonce.pdf").write_bytes(b"existing")
(source / "Interro07.pdf").write_bytes(b"new")
with self.assertRaisesRegex(FileNotFoundError, "Interro07.tex"):
get_personal_interro_files(evaluation, source)
self.assertEqual((evaluation / "enonce.pdf").read_bytes(), b"existing")
def test_first_visit_automation_is_declared_on_expected_steps(self) -> None:
auto_start = {
step.id for step in self.steps.values() if step.auto_start_first_visit
+54 -4
View File
@@ -4,13 +4,14 @@ import os
import tempfile
import unittest
from pathlib import Path
from tkinter import ttk
from types import SimpleNamespace
from unittest.mock import Mock, patch
from copienator import CliError, EvaluationWorkspace, ExitCode
from copienator.commands import enonce_info as personal
from copienator.commands import gemini_for_enonce as gemini
from copienator_gui.app import CopienatorApp
from copienator_gui.app import CopienatorApp, get_personal_interro_files
from copienator_gui.workflow import build_workflow
@@ -53,7 +54,7 @@ class PersonalStatementTests(unittest.TestCase):
enabled = {step.id: step for step in build_workflow(True)}
self.assertEqual([variant.id for variant in standard["statement"].variants], ["gemini"])
self.assertEqual([variant.program for variant in enabled["statement"].variants],
["statement", "statement-personal"])
["statement-personal", "statement"])
for ident in ("statement_groups", "statement_persp"):
self.assertNotIn(ident, standard)
self.assertTrue(enabled[ident].optional)
@@ -140,6 +141,48 @@ class SelectiveGeminiTests(unittest.TestCase):
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
class PersonalStatementGuiTests(unittest.TestCase):
def test_get_file_button_completes_inputs_and_advances(self):
with tempfile.TemporaryDirectory() as temporary:
repository = Path(temporary)
evaluation = repository / "Interro12"
source = repository / "source"
evaluation.mkdir()
source.mkdir()
(repository / "names").touch()
for name, content in (
("Interro12.pdf", b"pdf"),
("Interro12.tex", b"statement"),
("Interro12c.tex", b"correction"),
):
(source / name).write_bytes(content)
app = CopienatorApp(repository, True, evaluation)
try:
app.update()
buttons = [
child
for child in app.form.winfo_children()
if isinstance(child, ttk.Button)
]
get_button = next(
button for button in buttons if button.cget("text") == "Get the files"
)
with patch(
"copienator_gui.app.get_personal_interro_files",
side_effect=lambda target: get_personal_interro_files(target, source),
):
get_button.invoke()
app.update()
self.assertEqual(app.state_store.step("inputs")["status"], "success")
self.assertEqual(app.current_step.id, "statement")
self.assertEqual((evaluation / "enonce.pdf").read_bytes(), b"pdf")
self.assertEqual((evaluation / "enonce.tex").read_bytes(), b"statement")
self.assertEqual((evaluation / "correction.tex").read_bytes(), b"correction")
finally:
for callback in app.tk.splitlist(app.tk.call("after", "info")):
app.after_cancel(callback)
app.destroy()
def test_personal_requirements_and_optional_button_command_previews(self):
with tempfile.TemporaryDirectory() as temporary:
evaluation = Path(temporary)
@@ -149,12 +192,19 @@ class PersonalStatementGuiTests(unittest.TestCase):
app.update()
app.tree.selection_set("statement")
app.update()
app._select_variant(1)
self.assertEqual(app.variant_var.get(), "personal")
self.assertIn("statement-personal", app.command_var.get())
self.assertEqual(app._missing_requirements(app.current_step), [])
self.assertIn("SHEETINFO", app.description_label.cget("text"))
self.assertFalse(
any(
isinstance(child, ttk.LabelFrame)
and child.cget("text") == "Après génération — facultatif"
for child in app.form.winfo_children()
)
)
for ident, flag in (("statement_groups", "--groups-only"), ("statement_persp", "--persp-only")):
app._select_statement_action(ident)
app.tree.selection_set(ident)
app.update()
self.assertEqual(app.current_step.id, ident)
self.assertIn(flag, app.command_var.get())