From d60d5479d6a01c48c7188e0b622c3f6d2acfa09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Mon, 14 Sep 2026 09:42:13 +0200 Subject: [PATCH] Miscs personal GUI improvement --- copienator_gui/app.py | 121 +++++++++++++++++++++----- copienator_gui/workflow.py | 7 +- tests/test_gui_convenience.py | 6 +- tests/test_gui_core.py | 48 ++++++++++ tests/test_statement_personal_flow.py | 58 +++++++++++- 5 files changed, 207 insertions(+), 33 deletions(-) diff --git a/copienator_gui/app.py b/copienator_gui/app.py index 7cda0a8..be143c1 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -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 s’appeler 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 d’entré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 d’entré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)) diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index bdc2c68..6bb75f8 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -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( diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index ecbc51b..8211830 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -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) diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index d4f5d2a..6a7088f 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -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 diff --git a/tests/test_statement_personal_flow.py b/tests/test_statement_personal_flow.py index 982da9a..cf714f3 100644 --- a/tests/test_statement_personal_flow.py +++ b/tests/test_statement_personal_flow.py @@ -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())