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:
25
gui_v6/fsutil.py
Normal file
25
gui_v6/fsutil.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
118
tests/unit/test_gui_v6_result_hint.py
Normal file
118
tests/unit/test_gui_v6_result_hint.py
Normal 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
|
||||
Reference in New Issue
Block a user