From 1d65d42430d8b7b0a19aa193e4b96a20ae610dea Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Mon, 29 Jun 2026 19:10:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20localiser=20les=20documents=20livr?= =?UTF-8?q?=C3=A9s=20+=20bouton=20ouvrir=20le=20dossier=20(P1-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- gui_v6/fsutil.py | 25 ++++++ gui_v6/tabs/tab_usage.py | 50 +++++++++++ tests/unit/test_gui_v6_result_hint.py | 118 ++++++++++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 gui_v6/fsutil.py create mode 100644 tests/unit/test_gui_v6_result_hint.py diff --git a/gui_v6/fsutil.py b/gui_v6/fsutil.py new file mode 100644 index 0000000..bf37855 --- /dev/null +++ b/gui_v6/fsutil.py @@ -0,0 +1,25 @@ +"""Ouverture du gestionnaire de fichiers sur un dossier (cross-plateforme). + +Best-effort : ne lève jamais (un échec d'ouverture ne doit pas casser l'UI). +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def open_in_file_manager(path) -> None: + """Ouvre ``path`` dans l'explorateur de fichiers du système.""" + target = str(Path(path)) + try: + if sys.platform.startswith("win"): + import os + + os.startfile(target) # type: ignore[attr-defined] # noqa: S606 + elif sys.platform == "darwin": + subprocess.Popen(["open", target]) + else: + subprocess.Popen(["xdg-open", target]) + except Exception: + pass diff --git a/gui_v6/tabs/tab_usage.py b/gui_v6/tabs/tab_usage.py index 7b3bd7b..d78c782 100644 --- a/gui_v6/tabs/tab_usage.py +++ b/gui_v6/tabs/tab_usage.py @@ -18,6 +18,7 @@ import customtkinter as ctk from gui_v6 import theme as theme_mod from gui_v6 import ui_kit +from gui_v6.fsutil import open_in_file_manager from gui_v6.processing_runner import ProcessingRunner, default_output_dir _STEPS = ["📖 Extraction", "🧠 Détection", "🔒 Masquage", "📄 PDF final"] @@ -33,6 +34,29 @@ _HELP_USAGE = ( ) +def failure_hint(summary, output_dir) -> str | None: + """Message localisant les documents livrés, ou None si run nominal. + + Honnête : les documents en échec / quarantaine ne sont PAS anonymisés et + ne sont donc pas écrits. Si AUCUN document n'a abouti, on ne prétend pas + qu'un dossier de sortie contient des documents anonymisés. + """ + if summary is None or output_dir is None: + return None + if summary.failed == 0 and not getattr(summary, "stopped", False): + return None + if summary.succeeded == 0: + return ( + "Aucun document n'a été anonymisé. Les documents en échec ou en " + "quarantaine ne sont PAS anonymisés et n'ont pas été écrits." + ) + return ( + f"Documents anonymisés écrits dans : {output_dir}\n" + "Les documents en échec ou en quarantaine ne sont PAS anonymisés et " + "n'ont pas été écrits." + ) + + class UsageTab(ctk.CTkFrame): def __init__( self, @@ -59,6 +83,7 @@ class UsageTab(ctk.CTkFrame): self._input_path: Path | None = None self._output_dir: Path | None = None + self._last_output_dir: Path | None = None self._stop_event: threading.Event | None = None self._is_running = False self._events: "queue.Queue[tuple]" = queue.Queue() @@ -158,6 +183,7 @@ class UsageTab(ctk.CTkFrame): self._stats_row = ctk.CTkFrame(self._rsec, fg_color="transparent") self._stats_row.pack(fill="x", padx=16, pady=(0, 14)) self._result_built = False + self._hint_row = None # -- thème ------------------------------------------------------------ @@ -211,6 +237,7 @@ class UsageTab(ctk.CTkFrame): return self._is_running = True run_runner, run_output_dir = self._build_run_runner() + self._last_output_dir = run_output_dir or default_output_dir(self._input_path) self._stop_event = threading.Event() self._run_btn.configure(state="disabled") self._psec.pack(fill="x", padx=14, pady=7) @@ -281,6 +308,7 @@ class UsageTab(ctk.CTkFrame): self._progress.set(1.0) self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.") self._show_results(summary) + self._show_failure_hint(summary) self._send_usage_telemetry(summary) def _send_usage_telemetry(self, summary) -> None: @@ -311,6 +339,28 @@ class UsageTab(ctk.CTkFrame): ui_kit.StatCard(self._stats_row, p, value, label, value_color=color).pack(side="left", expand=True, fill="x", padx=4) self._rsec.pack(fill="x", padx=14, pady=(7, 14)) + def _show_failure_hint(self, summary) -> None: + # Nettoyage inconditionnel : éviter l'empilement et les hints périmés. + if getattr(self, "_hint_row", None) is not None: + self._hint_row.destroy() + self._hint_row = None + hint = failure_hint(summary, getattr(self, "_last_output_dir", None)) + if hint is None: + return + p = self._p + self._hint_row = ctk.CTkFrame(self._rsec, fg_color="transparent") + self._hint_row.pack(fill="x", padx=16, pady=(0, 12)) + ctk.CTkLabel( + self._hint_row, text=hint, text_color=p["text_dim"], font=ui_kit.font(11), + anchor="w", justify="left", + ).pack(side="left", fill="x", expand=True) + # Bouton « ouvrir » uniquement s'il y a réellement des documents livrés. + if summary is not None and summary.succeeded > 0: + ui_kit.secondary_button( + self._hint_row, p, "📂 Ouvrir le dossier", + command=lambda: open_in_file_manager(self._last_output_dir), + ).pack(side="right") + # -- helpers widgets -------------------------------------------------- def _set_status(self, text: str) -> None: diff --git a/tests/unit/test_gui_v6_result_hint.py b/tests/unit/test_gui_v6_result_hint.py new file mode 100644 index 0000000..8ae06fc --- /dev/null +++ b/tests/unit/test_gui_v6_result_hint.py @@ -0,0 +1,118 @@ +"""Message d'aide localisant les documents non livrés (P1-5) + ouverture dossier. + +Pur : pas de display. ``failure_hint`` formate un texte ; ``open_in_file_manager`` +dispatch vers la bonne commande OS (monkeypatchée). +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +import gui_v6.fsutil as fsutil +from gui_v6.processing_runner import RunSummary +from gui_v6.tabs.tab_usage import failure_hint + + +def test_no_hint_when_all_ok(): + s = RunSummary(total=3, succeeded=3, failed=0) + assert failure_hint(s, Path("/out")) is None + + +def test_hint_when_failures_mentions_output_dir(): + s = RunSummary(total=3, succeeded=2, failed=1) + hint = failure_hint(s, Path("/out/anonymise")) + assert hint is not None + assert "/out/anonymise" in hint + # Honnêteté : préciser que les échecs ne sont PAS anonymisés. + assert "pas" in hint.lower() + + +def test_hint_when_stopped(): + s = RunSummary(total=3, succeeded=1, failed=0, stopped=True) + assert failure_hint(s, Path("/out")) is not None + + +def test_no_hint_without_output_dir(): + s = RunSummary(total=1, succeeded=0, failed=1) + assert failure_hint(s, None) is None + + +def test_open_in_file_manager_dispatches(monkeypatch): + calls = {} + monkeypatch.setattr(fsutil.sys, "platform", "linux") + monkeypatch.setattr(fsutil.subprocess, "Popen", lambda args, **k: calls.setdefault("args", args)) + fsutil.open_in_file_manager(Path("/out")) + assert calls["args"][0] == "xdg-open" + assert calls["args"][1] == "/out" + + +def test_no_claim_written_when_zero_succeeded(): + """0 succès : ne pas prétendre qu'un dossier contient des documents écrits.""" + s = RunSummary(total=2, succeeded=0, failed=2) + hint = failure_hint(s, Path("/out")) + assert hint is not None + assert "écrits dans" not in hint + assert "Aucun document" in hint + + +def test_hint_with_path_when_some_succeeded(): + """≥1 succès : localiser le dossier de sortie effectif.""" + s = RunSummary(total=3, succeeded=2, failed=1) + hint = failure_hint(s, Path("/out")) + assert hint is not None + assert "/out" in hint + assert "écrits dans" in hint + + +# -- garde anti-régression du bug Critical (empilement de widgets) ----------- + + +@pytest.fixture +def usage_tab(): + """``UsageTab`` headless (Xvfb) — skip propre si pas de display.""" + pytest.importorskip("customtkinter") + try: + import customtkinter as ctk + + from gui_v6.tabs.tab_usage import UsageTab + + root = ctk.CTk() + root.withdraw() + tab = UsageTab(root) + except Exception as exc: # pas de display Tk + pytest.skip(f"display Tk indisponible: {exc}") + try: + yield tab + finally: + try: + root.destroy() + except Exception: + pass + + +def test_show_failure_hint_does_not_accumulate(usage_tab): + """Bug Critical : deux runs en échec ne doivent pas empiler de hints sous + ``_rsec`` (handle ``_hint_row`` détruit inconditionnellement).""" + usage_tab._last_output_dir = Path("/out") + summary = RunSummary(total=3, succeeded=2, failed=1) + + usage_tab._show_failure_hint(summary) + count_after_first = len(usage_tab._rsec.winfo_children()) + + usage_tab._show_failure_hint(summary) + count_after_second = len(usage_tab._rsec.winfo_children()) + + assert count_after_second == count_after_first + + +def test_show_failure_hint_clears_stale_hint(usage_tab): + """Un run en échec suivi d'un run nominal ne doit pas laisser de hint périmé.""" + usage_tab._last_output_dir = Path("/out") + usage_tab._show_failure_hint(RunSummary(total=3, succeeded=2, failed=1)) + with_hint = len(usage_tab._rsec.winfo_children()) + + usage_tab._show_failure_hint(RunSummary(total=3, succeeded=3, failed=0)) + without_hint = len(usage_tab._rsec.winfo_children()) + + assert without_hint == with_hint - 1