feat(gui): localiser les documents livrés + bouton ouvrir le dossier (P1-5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 19:10:14 +02:00
parent 416b347d7f
commit 1d65d42430
3 changed files with 193 additions and 0 deletions

25
gui_v6/fsutil.py Normal file
View File

@@ -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

View File

@@ -18,6 +18,7 @@ import customtkinter as ctk
from gui_v6 import theme as theme_mod from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit 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 from gui_v6.processing_runner import ProcessingRunner, default_output_dir
_STEPS = ["📖 Extraction", "🧠 Détection", "🔒 Masquage", "📄 PDF final"] _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): class UsageTab(ctk.CTkFrame):
def __init__( def __init__(
self, self,
@@ -59,6 +83,7 @@ class UsageTab(ctk.CTkFrame):
self._input_path: Path | None = None self._input_path: Path | None = None
self._output_dir: Path | None = None self._output_dir: Path | None = None
self._last_output_dir: Path | None = None
self._stop_event: threading.Event | None = None self._stop_event: threading.Event | None = None
self._is_running = False self._is_running = False
self._events: "queue.Queue[tuple]" = queue.Queue() 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 = ctk.CTkFrame(self._rsec, fg_color="transparent")
self._stats_row.pack(fill="x", padx=16, pady=(0, 14)) self._stats_row.pack(fill="x", padx=16, pady=(0, 14))
self._result_built = False self._result_built = False
self._hint_row = None
# -- thème ------------------------------------------------------------ # -- thème ------------------------------------------------------------
@@ -211,6 +237,7 @@ class UsageTab(ctk.CTkFrame):
return return
self._is_running = True self._is_running = True
run_runner, run_output_dir = self._build_run_runner() 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._stop_event = threading.Event()
self._run_btn.configure(state="disabled") self._run_btn.configure(state="disabled")
self._psec.pack(fill="x", padx=14, pady=7) self._psec.pack(fill="x", padx=14, pady=7)
@@ -281,6 +308,7 @@ class UsageTab(ctk.CTkFrame):
self._progress.set(1.0) self._progress.set(1.0)
self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.") self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.")
self._show_results(summary) self._show_results(summary)
self._show_failure_hint(summary)
self._send_usage_telemetry(summary) self._send_usage_telemetry(summary)
def _send_usage_telemetry(self, summary) -> None: 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) 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)) 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 -------------------------------------------------- # -- helpers widgets --------------------------------------------------
def _set_status(self, text: str) -> None: def _set_status(self, text: str) -> None:

View File

@@ -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