8 Commits

Author SHA1 Message Date
675e328d8c docs(plan): Plan 1c honnêteté UI (P1-4/NER/P1-5/P1-3/P1-1/P1-6/P1-11)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:42:16 +02:00
4813f9439e fix(core): corriger la provenance OCR de l'audit (docTR → OnnxTR, P1-11)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:36:58 +02:00
ee1f86d55e feat(gui): échec amont clair si dossier de sortie non inscriptible (P1-6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:33:16 +02:00
3a981eb15a feat(gui): dropzone cliquable + libellé honnête (P1-1, DnD natif différé)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:29:12 +02:00
d3189d5bb7 feat(gui): recâbler import/export de configuration par email (P1-3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:27:33 +02:00
1d65d42430 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>
2026-06-29 19:15:28 +02:00
416b347d7f feat(gui): confirmation explicite avant anonymisation regex-only (NER off)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:07:38 +02:00
880a75873d feat(gui): charger le dictionnaires.yml externe éditable en frozen (P1-4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:00:49 +02:00
14 changed files with 1582 additions and 14 deletions

View File

@@ -5679,7 +5679,7 @@ def process_pdf(
# Log OCR dans l'audit
if ocr_used:
anon.audit.insert(0, PiiHit(page=-1, kind="OCR_USED", original="docTR", placeholder=""))
anon.audit.insert(0, PiiHit(page=-1, kind="OCR_USED", original="OnnxTR", placeholder=""))
# Filtrer les faux positifs hospitaliers
if _HOSPITAL_FILTER_AVAILABLE:

View File

@@ -0,0 +1,900 @@
# GUI V6 → bêta — Plan 1c : honnêteté UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Supprimer les derniers contrôles UI trompeurs / non fonctionnels de GUI V6 pour la bêta, et garantir le comportement honnête attendu d'un outil médical (config établissement réellement chargée, avertissement avant de dégrader la détection, erreurs localisables, échange de config par email recâblé).
**Architecture :** Chaque correctif est isolé derrière une **fonction pure testable sans display ni modèle** (résolution de chemin, décision de confirmation, formatage de message, export/import JSON), le widget se contentant de câbler cette fonction. Aucune logique de détection n'est touchée (le gating catégories est déjà livré en Plan 1b). On suit le pattern V5 existant pour la config externe (`Pseudonymisation_Gui_V5._resolve_config`) et le format d'échange JSON consommé par `scripts/merge_params.merge_params`.
**Tech Stack :** Python 3.10-3.12, customtkinter (CTk), tkinter.messagebox/filedialog, pytest (tests purs + `pytest.importorskip("customtkinter")` pour les rares smokes widget), PyYAML.
**Portée (spec chantier D — `docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md`) :**
- Task 1 — **P1-4** : `config_path` (dictionnaires.yml) réellement résolu et chargé en frozen.
- Task 2 — **Confirmation NER off** (décision Dom 2026-06-29) : dialogue avant de tomber en regex-only.
- Task 3 — **P1-5** : erreurs / quarantaine localisables + bouton « Ouvrir le dossier ».
- Task 4 — **P1-3** : Import / Export de configuration recâblés (workflow email V5).
- Task 5 — **P1-1** : dropzone honnête (cliquable) — DnD natif différé.
- Task 6 (optionnel) — **P1-6** : validation d'inscriptibilité du dossier de sortie.
- Task 7 (optionnel) — **P1-11** : provenance audit `original="docTR"``"OnnxTR"`.
**Hors portée (différé, acté) :** DnD natif `tkinterdnd2` (lib native tkdnd à bundler — repoussé pour ne pas alourdir le build torch-free du Plan 3, voir Task 5) ; P2-1/P2-2 (progression/cartes format) traités au mieux mais non bloquants.
**Convention de commit :** un commit atomique par task (core+test ensemble), préfixe conventionnel, finir par `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Brancher sur `feature/q1-quarantine-mvp`, pousser sur `gitea` (jamais `git add -A` — stager par chemin).
**Commande de test de référence :**
`.venv/bin/python -m pytest tests/unit/test_gui_v6_*.py -q` (+ gate `synthetic_regression` avant push).
---
### Task 1 : P1-4 — Config externe `dictionnaires.yml` réellement résolue en frozen
**Problème :** `AnonymisationApp` crée `UsageTab` **sans** `config_path` (`gui_v6/app.py:186-193`), donc `to_engine_settings(self._config_path)` reçoit `None` (`tab_usage.py:176`). En frozen, le `dictionnaires.yml` éditable à côté de l'EXE n'est jamais chargé → personnalisations établissement ignorées.
**Files:**
- Create: `gui_v6/config_paths.py`
- Create: `tests/unit/test_gui_v6_config_paths.py`
- Modify: `gui_v6/app.py` (import + `__init__` + appel `UsageTab(...)`)
- [ ] **Step 1 : Écrire les tests (échouent — module absent)**
`tests/unit/test_gui_v6_config_paths.py` :
```python
"""Résolution du dictionnaires.yml externe éditable (P1-4).
Pur : on simule frozen via monkeypatch (sys.frozen / sys.executable / _MEIPASS),
aucun display, aucun modèle.
"""
from __future__ import annotations
from pathlib import Path
import gui_v6.config_paths as cp
def _make_bundle(tmp_path: Path) -> Path:
bundle = tmp_path / "bundle"
(bundle / "config").mkdir(parents=True)
(bundle / "config" / "dictionnaires.yml").write_text("whitelist_phrases: []\n", encoding="utf-8")
return bundle
def test_dev_returns_repo_config_when_present(monkeypatch):
# En dev (non frozen) : pointe la config embarquée si elle existe.
monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
path = cp.resolve_user_config_path()
assert path is not None
assert path.name == "dictionnaires.yml"
assert path.exists()
def test_frozen_copies_bundle_on_first_launch(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
exe_dir.mkdir()
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
expected = exe_dir / "config" / "dictionnaires.yml"
assert out == expected
assert expected.exists() # copié depuis le bundle au 1er lancement
assert expected.read_text(encoding="utf-8") == "whitelist_phrases: []\n"
def test_frozen_keeps_existing_user_config(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
(exe_dir / "config").mkdir(parents=True)
user_cfg = exe_dir / "config" / "dictionnaires.yml"
user_cfg.write_text("whitelist_phrases: [HOPITAL_LOCAL]\n", encoding="utf-8")
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
assert out == user_cfg
# Ne JAMAIS écraser la perso établissement existante.
assert "HOPITAL_LOCAL" in out.read_text(encoding="utf-8")
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_paths.py -q`
Expected: FAIL (`ModuleNotFoundError: No module named 'gui_v6.config_paths'`).
- [ ] **Step 3 : Implémenter `gui_v6/config_paths.py`**
```python
"""Résolution du fichier de configuration externe éditable (dictionnaires.yml).
En frozen (PyInstaller), la config doit vivre À CÔTÉ de l'exécutable pour que
l'établissement puisse l'éditer sans recompiler ; on copie la version embarquée
au premier lancement si elle est absente. En développement, on pointe directement
la config du dépôt. Aligné sur le pattern V5
(``Pseudonymisation_Gui_V5._resolve_config``), best-effort (jamais de crash).
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from typing import Optional
_CONFIG_RELATIVE = Path("config") / "dictionnaires.yml"
def _frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def _bundled_config() -> Path:
"""Config embarquée : ``_MEIPASS`` en frozen, racine du dépôt en dev."""
if _frozen():
base = Path(getattr(sys, "_MEIPASS"))
else:
base = Path(__file__).resolve().parent.parent
return base / _CONFIG_RELATIVE
def resolve_user_config_path() -> Optional[Path]:
"""Chemin du ``dictionnaires.yml`` éditable par l'utilisateur.
- dev : la config du dépôt (éditable en place) ;
- frozen : ``<dossier de l'exe>/config/dictionnaires.yml`` ; copie la version
embarquée au premier lancement si absente, sans jamais écraser une config
existante (perso établissement).
Renvoie ``None`` si rien n'est résoluble (le moteur retombe alors sur sa
config runtime par défaut).
"""
if not _frozen():
bundled = _bundled_config()
return bundled if bundled.exists() else None
user_cfg = Path(sys.executable).resolve().parent / _CONFIG_RELATIVE
if user_cfg.exists():
return user_cfg
try:
user_cfg.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(_bundled_config(), user_cfg)
return user_cfg
except Exception:
bundled = _bundled_config()
return bundled if bundled.exists() else None
```
- [ ] **Step 4 : Câbler dans `gui_v6/app.py`**
En tête (après les imports `gui_v6`), ajouter :
```python
from gui_v6.config_paths import resolve_user_config_path
```
Dans `AnonymisationApp.__init__`, après `self._config = ConfigState()` :
```python
self._user_config_path = resolve_user_config_path()
```
Dans `_create_tab`, branche `"use"`, ajouter le kwarg à `UsageTab(...)` :
```python
return UsageTab(
self._content,
palette=p,
config_provider=lambda: self._config,
config_path=self._user_config_path,
on_theme_change=self.set_theme,
current_theme=self._theme_name,
usage_reporter=self._report_usage,
)
```
- [ ] **Step 5 : Lancer → vérifier le succès + non-régression socle**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_paths.py tests/unit/test_gui_v6_app_shell.py -q`
Expected: PASS (3 nouveaux + app_shell inchangés).
- [ ] **Step 6 : Commit**
```bash
git add gui_v6/config_paths.py gui_v6/app.py tests/unit/test_gui_v6_config_paths.py
git commit -m "feat(gui): charger le dictionnaires.yml externe éditable en frozen (P1-4)"
```
---
### Task 2 : Confirmation avant désactivation du NER (regex-only) — décision Dom 2026-06-29
**Problème :** Couper le toggle CamemBERT-bio (`tab_config.py:393-395`, callback `_on_ner:893`) bascule en anonymisation **regex seule** sans avertissement. Sur un outil médical, on confirme explicitement.
**Files:**
- Modify: `gui_v6/tabs/tab_config.py` (constante + helper pur + `_on_ner`)
- Create: `tests/unit/test_gui_v6_ner_confirm.py`
- [ ] **Step 1 : Écrire les tests (échouent — symbole absent)**
`tests/unit/test_gui_v6_ner_confirm.py` :
```python
"""Confirmation avant de désactiver le NER (regex-only) — outil médical.
Pur : la décision est isolée dans ``confirm_ner_disable(asker)`` ; ``asker`` est
injecté (pas de messagebox réel, pas de display).
"""
from __future__ import annotations
from gui_v6.tabs.tab_config import NER_DISABLE_WARNING, confirm_ner_disable
def test_confirm_true_when_user_accepts():
assert confirm_ner_disable(lambda: True) is True
def test_confirm_false_when_user_declines():
assert confirm_ner_disable(lambda: False) is False
def test_warning_text_is_explicit_for_medical_use():
txt = NER_DISABLE_WARNING.lower()
# L'avertissement DOIT nommer la dégradation : règles/regex + risque noms.
assert "règles" in txt or "regex" in txt
assert "nom" in txt
assert "recommand" in txt # « fortement recommandé »
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_ner_confirm.py -q`
Expected: FAIL (`ImportError: cannot import name 'confirm_ner_disable'`).
- [ ] **Step 3 : Ajouter la constante + le helper pur dans `tab_config.py`**
Après les imports (sous `from tkinter import filedialog, messagebox`), au niveau module :
```python
NER_DISABLE_WARNING = (
"Vous allez désactiver le moteur d'intelligence artificielle "
"(CamemBERT-bio).\n\n"
"Sans lui, la détection des NOMS de personnes repose uniquement sur des "
"règles (expressions régulières) : des noms peuvent rester EN CLAIR dans "
"les documents.\n\n"
"Pour un usage médical, garder ce moteur activé est fortement recommandé.\n\n"
"Confirmer la désactivation ?"
)
def confirm_ner_disable(asker) -> bool:
"""Décision de désactivation du NER.
``asker`` est une fonction ``() -> bool`` (ex. ``messagebox.askyesno``),
injectée pour rester testable sans display. Retourne True si l'utilisateur
CONFIRME la désactivation (regex-only), False sinon.
"""
return bool(asker())
```
- [ ] **Step 4 : Câbler `_on_ner` (intercepter le passage à OFF)**
Remplacer `_on_ner` (`tab_config.py:893-894`) par :
```python
def _on_ner(self) -> None:
new_value = self._tog_ner.get()
if not new_value:
confirmed = confirm_ner_disable(
lambda: messagebox.askyesno(
"Moteur de détection", NER_DISABLE_WARNING, icon="warning"
)
)
if not confirmed:
# Refus : rétablir l'affichage du switch et garder le NER actif.
self._tog_ner.var.set(True)
self._state.use_local_ner = True
return
self._state.use_local_ner = new_value
```
- [ ] **Step 5 : Lancer → vérifier le succès**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_ner_confirm.py tests/unit/test_gui_v6_config_mockup_sections.py -q`
Expected: PASS (3 nouveaux + sections inchangées).
- [ ] **Step 6 : Commit**
```bash
git add gui_v6/tabs/tab_config.py tests/unit/test_gui_v6_ner_confirm.py
git commit -m "feat(gui): confirmation explicite avant anonymisation regex-only (NER off)"
```
---
### Task 3 : P1-5 — Erreurs / quarantaine localisables + « Ouvrir le dossier »
**Problème :** En fin de run, `_finish` (`tab_usage.py:271-283`) n'indique pas OÙ trouver les documents livrés ni que les documents en échec/quarantaine ne sont **pas** anonymisés. Le testeur reste bloqué.
**Files:**
- Create: `gui_v6/fsutil.py` (ouverture cross-plateforme du dossier)
- Create: `tests/unit/test_gui_v6_result_hint.py`
- Modify: `gui_v6/tabs/tab_usage.py` (helper pur `failure_hint`, mémoriser le dossier de sortie, afficher hint + bouton)
- [ ] **Step 1 : Écrire les tests (échouent)**
`tests/unit/test_gui_v6_result_hint.py` :
```python
"""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 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"
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_result_hint.py -q`
Expected: FAIL (`ModuleNotFoundError: gui_v6.fsutil` / `failure_hint` absent).
- [ ] **Step 3 : Implémenter `gui_v6/fsutil.py`**
```python
"""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
```
- [ ] **Step 4 : Ajouter `failure_hint` (pur) dans `tab_usage.py`**
Au niveau module (après `_HELP_USAGE`) :
```python
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 dans le dossier de sortie.
"""
if summary is None or output_dir is None:
return None
if summary.failed == 0 and not getattr(summary, "stopped", False):
return None
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."
)
```
- [ ] **Step 5 : Mémoriser le dossier de sortie effectif + afficher hint/bouton**
Dans `_start`, après `run_runner, run_output_dir = self._build_run_runner()` :
```python
self._last_output_dir = run_output_dir or default_output_dir(self._input_path)
```
Dans `_finish`, à la fin (après `self._show_results(summary)`), ajouter l'appel :
```python
self._show_failure_hint(summary)
```
Puis ajouter la méthode (après `_show_results`) :
```python
def _show_failure_hint(self, summary) -> None:
hint = failure_hint(summary, getattr(self, "_last_output_dir", None))
if hint is None:
return
p = self._p
row = ctk.CTkFrame(self._rsec, fg_color="transparent")
row.pack(fill="x", padx=16, pady=(0, 12))
ctk.CTkLabel(
row, text=hint, text_color=p["text_dim"], font=ui_kit.font(11),
anchor="w", justify="left",
).pack(side="left", fill="x", expand=True)
ui_kit.secondary_button(
row, p, "📂 Ouvrir le dossier",
command=lambda: open_in_file_manager(self._last_output_dir),
).pack(side="right")
```
Ajouter en tête de `tab_usage.py` :
```python
from gui_v6.fsutil import open_in_file_manager
```
- [ ] **Step 6 : Lancer → vérifier le succès**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_result_hint.py tests/unit/test_gui_v6_processing_runner.py -q`
Expected: PASS.
- [ ] **Step 7 : Commit**
```bash
git add gui_v6/fsutil.py gui_v6/tabs/tab_usage.py tests/unit/test_gui_v6_result_hint.py
git commit -m "feat(gui): localiser les documents livrés + bouton ouvrir le dossier (P1-5)"
```
---
### Task 4 : P1-3 — Import / Export de configuration recâblés (workflow email V5)
**Problème :** Les boutons Partage sont des `_mockup_button` désactivés « (à venir) » (`tab_config.py:847,855`), alors que l'échange de config par email (export JSON → `merge_params` → renvoi YAML) est un workflow produit clé (CLAUDE.md). En V6, les listes whitelist/blacklist vivent dans le **profil actif** (`_pro_term_lists`, `tab_config.py:721-722,747-748`).
**Décision de modèle (à confirmer en revue) :** l'export produit le format V5 consommé par `merge_params` : `{"version", "date_export", "whitelist_phrases", "blacklist_force_mask_terms"}`, alimenté par les listes du **profil actuellement chargé dans l'éditeur** (`self._pro_term_lists`). L'import fusionne le JSON reçu dans le `dictionnaires.yml` utilisateur résolu (Task 1) via `merge_params`.
**Files:**
- Create: `gui_v6/config_share.py` (sérialisation pure export/import)
- Create: `tests/unit/test_gui_v6_config_share.py`
- Modify: `gui_v6/tabs/tab_config.py` (`_build_partage` : vrais boutons + handlers ; passer `config_path`)
- Modify: `gui_v6/app.py` (passer `config_path=self._user_config_path` à `ConfigTab`)
- [ ] **Step 1 : Écrire les tests (échouent)**
`tests/unit/test_gui_v6_config_share.py` :
```python
"""Export / import de configuration (P1-3) — format compatible merge_params.
Pur : sérialisation/désérialisation et fusion, sans display ni filedialog.
"""
from __future__ import annotations
import json
from pathlib import Path
from gui_v6.config_share import build_export_payload, import_config_file
def test_export_payload_has_v5_schema():
payload = build_export_payload(
whitelist=["Dr Métier", "Service ORL"],
blacklist=["DUPONT"],
version="2026.06.29",
)
assert payload["version"] == "2026.06.29"
assert "date_export" in payload
assert payload["whitelist_phrases"] == ["Dr Métier", "Service ORL"]
assert payload["blacklist_force_mask_terms"] == ["DUPONT"]
def test_export_payload_is_json_serializable():
payload = build_export_payload(whitelist=["A"], blacklist=["B"], version="1")
json.dumps(payload) # ne doit pas lever
def test_import_merges_into_user_config(tmp_path, monkeypatch):
# Config utilisateur YAML minimale.
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Existant]\n", encoding="utf-8")
# JSON reçu d'un établissement.
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({
"version": "1", "date_export": "2026-06-29",
"whitelist_phrases": ["Nouveau"],
"blacklist_force_mask_terms": ["MASQUERMOI"],
}),
encoding="utf-8",
)
added = import_config_file(incoming, cfg)
assert added is True
import yaml
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
assert "Existant" in merged["whitelist_phrases"]
assert "Nouveau" in merged["whitelist_phrases"]
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]
def test_import_returns_false_when_nothing_new(tmp_path):
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Deja]\n", encoding="utf-8")
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({"whitelist_phrases": ["Deja"], "blacklist_force_mask_terms": []}),
encoding="utf-8",
)
assert import_config_file(incoming, cfg) is False
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_share.py -q`
Expected: FAIL (`ModuleNotFoundError: gui_v6.config_share`).
- [ ] **Step 3 : Implémenter `gui_v6/config_share.py`**
```python
"""Échange de configuration par fichier JSON (workflow email V5, P1-3).
- ``build_export_payload`` : produit le dict V5 (consommé par
``scripts/merge_params.merge_params``) à partir des listes du profil courant ;
- ``import_config_file`` : fusionne un JSON reçu dans le ``dictionnaires.yml``
utilisateur, sans écraser l'existant (réutilise ``merge_params``).
Aucune dépendance à un widget : testable en pur Python.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
EXPORT_VERSION = "1"
def build_export_payload(
whitelist: Iterable[str], blacklist: Iterable[str], version: str = EXPORT_VERSION
) -> dict:
"""Construit la charge utile d'export au format consommé par merge_params."""
return {
"version": version,
"date_export": datetime.now(timezone.utc).isoformat(),
"whitelist_phrases": [str(t) for t in whitelist],
"blacklist_force_mask_terms": [str(t) for t in blacklist],
}
def _yaml_lists(config_path: Path) -> tuple[set, set]:
import yaml
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
wl = set(cfg.get("whitelist_phrases", []) or [])
bl = set((cfg.get("blacklist", {}) or {}).get("force_mask_terms", []) or [])
return wl, bl
def import_config_file(json_path, config_path) -> bool:
"""Fusionne ``json_path`` dans ``config_path`` (YAML). Retourne True si la
config a changé, False si rien de nouveau.
Fusion autonome (union des listes, jamais d'écrasement) — volontairement
SANS dépendance à ``scripts/merge_params`` (non bundlé en frozen). Même
sémantique : ``whitelist_phrases`` et ``blacklist.force_mask_terms``.
"""
import json
import yaml
json_path = Path(json_path)
config_path = Path(config_path)
before_wl, before_bl = _yaml_lists(config_path)
data = json.loads(json_path.read_text(encoding="utf-8"))
incoming_wl = {str(t).strip() for t in data.get("whitelist_phrases", []) if str(t).strip()}
incoming_bl = {str(t).strip() for t in data.get("blacklist_force_mask_terms", []) if str(t).strip()}
after_wl = before_wl | incoming_wl
after_bl = before_bl | incoming_bl
if after_wl == before_wl and after_bl == before_bl:
return False
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
cfg["whitelist_phrases"] = sorted(after_wl)
cfg.setdefault("blacklist", {})
cfg["blacklist"]["force_mask_terms"] = sorted(after_bl)
config_path.write_text(
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
return True
```
> Note implémentation : fusion autonome (pas d'import de `scripts/`), donc robuste en frozen. Le format JSON (`whitelist_phrases` / `blacklist_force_mask_terms`) reste celui consommé par `scripts/merge_params.py:52-58` → compatibilité email V5 préservée.
- [ ] **Step 4 : Recâbler `_build_partage` (vrais boutons)**
Remplacer les deux `self._mockup_button(...)` de `_build_partage` (`tab_config.py:847,855`) par de vrais boutons :
```python
ui_kit.secondary_button(
export, p, "⬇ Exporter (.json)", command=self._on_export_config
).pack(anchor="w", padx=12, pady=(0, 12))
...
ui_kit.secondary_button(
import_card, p, "⬆ Importer (.json)", command=self._on_import_config
).pack(anchor="w", padx=12, pady=(0, 12))
```
Ajouter les handlers (section « callbacks réglages », après `_on_manual_mask_template`) :
```python
def _on_export_config(self) -> None:
from gui_v6.config_share import build_export_payload
import json
lists = getattr(self, "_pro_term_lists", {})
wl = lists["whitelist"].terms() if "whitelist" in lists else []
bl = lists["blacklist"].terms() if "blacklist" in lists else []
path = filedialog.asksaveasfilename(
title="Exporter la configuration", defaultextension=".json",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
payload = build_export_payload(wl, bl)
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
messagebox.showinfo("Export", f"Configuration exportée :\n{path}")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Export", f"Échec de l'export : {exc}")
def _on_import_config(self) -> None:
from gui_v6.config_share import import_config_file
if self._config_path is None:
messagebox.showerror("Import", "Aucune configuration cible résolue.")
return
path = filedialog.askopenfilename(
title="Importer une configuration",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
changed = import_config_file(path, self._config_path)
if changed:
messagebox.showinfo("Import", "Configuration fusionnée. Redémarrez pour appliquer.")
else:
messagebox.showinfo("Import", "Rien de nouveau à fusionner.")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Import", f"Échec de l'import : {exc}")
```
- [ ] **Step 5 : Donner à `ConfigTab` le `config_path`**
Dans `ConfigTab.__init__`, accepter et stocker `config_path` (signature : ajouter `config_path: Path | None = None`, et `self._config_path = config_path`). Dans `gui_v6/app.py:_create_tab`, branche `"cfg"` :
```python
if key == "cfg":
return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
```
- [ ] **Step 6 : Lancer → vérifier le succès + non-régression**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_share.py tests/unit/test_gui_v6_config_mockup_sections.py tests/unit/test_gui_v6_app_shell.py -q`
Expected: PASS (le test `test_config_mockup_sections` reste vert : ne pas changer `CONFIG_MOCKUP_SECTIONS`, seulement le câblage des boutons Partage).
- [ ] **Step 7 : Commit**
```bash
git add gui_v6/config_share.py gui_v6/tabs/tab_config.py gui_v6/app.py tests/unit/test_gui_v6_config_share.py
git commit -m "feat(gui): recâbler import/export de configuration par email (P1-3)"
```
---
### Task 5 : P1-1 — Dropzone honnête (cliquable)
**Problème :** La zone « Choisissez vos fichiers » (`tab_usage.py:103-115`) suggère un glisser-déposer inexistant. Le DnD natif (`tkinterdnd2`) impose la lib native tkdnd à bundler dans le frozen → **différé** (ne pas alourdir le build torch-free du Plan 3). Fix bêta : rendre la zone **cliquable** (ouvre le sélecteur de fichier) + libellé honnête. Les boutons Fichier/Dossier existants restent.
**Files:**
- Modify: `gui_v6/tabs/tab_usage.py` (rendre la dropzone cliquable + libellé)
- [ ] **Step 1 : Rendre la dropzone cliquable + libellé honnête**
Dans `_build`, remplacer le label « Choisissez vos fichiers » et lier le clic. Après la création de `dz` (`tab_usage.py:103-106`), lier le clic de la zone et de ses labels à `_pick_file` :
```python
dz.bind("<Button-1>", lambda _e: self._pick_file())
ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
zone_lbl = ctk.CTkLabel(dz, text="Cliquez pour choisir un fichier", text_color=p["text"], font=ui_kit.font(14))
zone_lbl.pack()
zone_lbl.bind("<Button-1>", lambda _e: self._pick_file())
ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))
```
(la ligne `ctk.CTkLabel(dz, text="⬆️"...)` d'origine est remplacée par le bloc ci-dessus ; les boutons `acts` restent inchangés).
- [ ] **Step 2 : Smoke import (pas de régression d'import)**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py -q && .venv/bin/python Pseudonymisation_Gui_V6.py --self-test`
Expected: PASS + `GUI V6 self-test OK`.
- [ ] **Step 3 : Commit**
```bash
git add gui_v6/tabs/tab_usage.py
git commit -m "feat(gui): dropzone cliquable + libellé honnête (P1-1, DnD natif différé)"
```
---
### Task 6 (optionnel) : P1-6 — Valider l'inscriptibilité du dossier de sortie
**Problème :** Un dossier de sortie en lecture seule fait échouer **chaque** document avec un message cryptique (`processing_runner.py:215` `mkdir` puis échec par doc). Tester l'inscriptibilité en amont donne un message clair unique.
**Files:**
- Modify: `gui_v6/processing_runner.py` (`_run_impl` : test d'écriture amont)
- Create/Modify: `tests/unit/test_gui_v6_processing_runner.py` (nouveau test)
- [ ] **Step 1 : Test (échoue)**
Ajouter à `tests/unit/test_gui_v6_processing_runner.py` :
```python
def test_run_fails_fast_when_output_not_writable(tmp_path, monkeypatch):
from gui_v6.processing_runner import ProcessingRunner, OutputNotWritableError
src = tmp_path / "in"
src.mkdir()
(src / "a.txt").write_text("x", encoding="utf-8")
out = tmp_path / "ro"
out.mkdir()
def boom(*a, **k):
raise PermissionError("read-only")
monkeypatch.setattr("gui_v6.processing_runner.Path.mkdir", boom)
runner = ProcessingRunner(process_fn=lambda d, o: {})
import pytest
with pytest.raises(OutputNotWritableError):
runner.run(src, out)
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py::test_run_fails_fast_when_output_not_writable -q`
Expected: FAIL (`OutputNotWritableError` absent).
- [ ] **Step 3 : Implémenter le garde-fou**
Dans `gui_v6/processing_runner.py`, ajouter la classe d'erreur (près des dataclasses) :
```python
class OutputNotWritableError(RuntimeError):
"""Le dossier de sortie n'est pas inscriptible (échec amont, message clair)."""
```
Dans `_run_impl`, après le calcul de `out_root` (`:186`) et **avant** la boucle, vérifier une fois :
```python
try:
out_root.mkdir(parents=True, exist_ok=True)
probe = out_root / ".anon_write_test"
probe.write_text("", encoding="utf-8")
probe.unlink()
except Exception as exc:
raise OutputNotWritableError(
f"Dossier de sortie non inscriptible : {out_root} ({exc})"
) from exc
```
- [ ] **Step 4 : Lancer → vérifier le succès + non-régression runner**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py -q`
Expected: PASS (nouveau + existants).
> Note UI : `UsageTab._handle_event`/`work()` capture déjà `Exception` → le message d'`OutputNotWritableError` s'affiche dans le journal. Vérifier qu'il remonte lisible (pas de doublon par document).
- [ ] **Step 5 : Commit**
```bash
git add gui_v6/processing_runner.py tests/unit/test_gui_v6_processing_runner.py
git commit -m "feat(gui): échec amont clair si dossier de sortie non inscriptible (P1-6)"
```
---
### Task 7 (optionnel) : P1-11 — Provenance audit `docTR` → `OnnxTR`
**Problème :** L'audit JSONL trace `original="docTR"` (`anonymizer_core_refactored_onnx.py:5356`) alors que l'OCR est OnnxTR depuis la migration. Trace périmée visible dans le livrable client.
**Files:**
- Modify: `anonymizer_core_refactored_onnx.py:5356`
- Test: gate `synthetic_regression` (pas de nouveau test dédié — provenance cosmétique).
- [ ] **Step 1 : Localiser et corriger**
Run: `.venv/bin/python - <<'PY'\nimport re,io\np="anonymizer_core_refactored_onnx.py"\ns=open(p,encoding="utf-8").read()\nprint([l for l in s.splitlines() if 'docTR' in l and 'original' in l][:3])\nPY`
Remplacer la valeur de provenance `"docTR"` par `"OnnxTR"` à la ligne d'audit identifiée (uniquement la chaîne de provenance OCR, ne pas toucher aux commentaires/docstrings cosmétiques P2-3).
- [ ] **Step 2 : Non-régression**
Run: `.venv/bin/python -m pytest tests/unit -q -k "synthetic_regression or audit"`
Expected: PASS (sortie d'anonymisation inchangée ; seule la provenance change).
- [ ] **Step 3 : Commit**
```bash
git add anonymizer_core_refactored_onnx.py
git commit -m "fix(core): corriger la provenance OCR de l'audit (docTR → OnnxTR, P1-11)"
```
---
## Clôture du Plan 1c
- [ ] **Suite complète + gate**
Run: `.venv/bin/python -m pytest tests/unit -q` (attendu : 436 + nouveaux tests, 0 régression) puis le gate `synthetic_regression` du dépôt.
- [ ] **Self-test GUI**
Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test`
Expected: `GUI V6 self-test OK`.
- [ ] **Revue + push**
Revue Qwen **courte** recommandée (1c = honnêteté UI, hors cœur sécurité masquage — pas de revue bloquante comme 1b, mais signaler la décision « confirmation NER » et le modèle export profil-actif). Puis push `gitea/feature/q1-quarantine-mvp`. Mettre à jour la mémoire projet `gui_v6_beta_prod_chantier.md`.
---
## Self-Review (auteur du plan)
**Couverture spec (chantier D) :** P1-2 = déjà livré (Plan 1b, hors périmètre ici) ✓ ; P1-1 = Task 5 (cliquable, DnD différé — **déviation assumée** vs spec « tkinterdnd2 », à valider Dom) ; P1-3 = Task 4 ; P1-4 = Task 1 ; P1-5 = Task 3 ; confirmation NER = Task 2 (décision Dom 2026-06-29) ; P1-6 = Task 6 (optionnel) ; P1-11 = Task 7 (optionnel). P2-1/P2-2 non traités (best-effort, non bloquants) — **signalé**.
**Placeholders :** aucun « TODO / à compléter » ; chaque step porte le code réel.
**Cohérence des types/symboles :** `resolve_user_config_path` (Task 1) réutilisé en Task 4 (Step 5) ; `failure_hint`/`open_in_file_manager` (Task 3) cohérents avec leur test ; `build_export_payload`/`import_config_file` (Task 4) ⇄ schéma `merge_params` (`whitelist_phrases` / `blacklist_force_mask_terms`) vérifié contre `scripts/merge_params.py:52-58`. `self._tog_ner.var` confirmé exposé par `_mini_toggle` (`tab_config.py:1107`).
**Hypothèses à confirmer en revue :**
1. **Task 4** exporte les listes du **profil chargé dans l'éditeur** (`_pro_term_lists`). Si l'attendu est « toutes les listes globales » ou « le profil par défaut », ajuster la source des listes.
2. **Task 5** dévie de la spec (DnD natif → cliquable). Justification : éviter la lib native tkdnd dans le build torch-free. À confirmer.
*(Hypothèse « scripts importable » levée : la fusion d'import est désormais autonome, frozen-safe — vérifié `scripts/__init__.py` absent et `scripts/` non bundlé.)*

View File

@@ -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.config_paths import resolve_user_config_path
from gui_v6.config_state import ConfigState
from gui_v6.license_client import LicenseClient, LicenseStatus
from gui_v6.machine_id import default_machine_id
@@ -70,6 +71,7 @@ class AnonymisationApp(ctk.CTk):
self._theme_name = theme_name
self._license_client = license_client or LicenseClient(resolve_portal_url())
self._config = ConfigState()
self._user_config_path = resolve_user_config_path()
self._active = "use"
self._tab_buttons: dict = {}
self._tab_frames: dict = {}
@@ -187,12 +189,13 @@ class AnonymisationApp(ctk.CTk):
self._content,
palette=p,
config_provider=lambda: self._config,
config_path=self._user_config_path,
on_theme_change=self.set_theme,
current_theme=self._theme_name,
usage_reporter=self._report_usage,
)
if key == "cfg":
return ConfigTab(self._content, palette=p, state=self._config)
return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
return AboutTab(
self._content,
palette=p,

62
gui_v6/config_paths.py Normal file
View File

@@ -0,0 +1,62 @@
"""Résolution du fichier de configuration externe éditable (dictionnaires.yml).
En frozen (PyInstaller), la config doit vivre À CÔTÉ de l'exécutable pour que
l'établissement puisse l'éditer sans recompiler ; on copie la version embarquée
au premier lancement si elle est absente. En développement, on pointe directement
la config du dépôt. Aligné sur le pattern V5
(``Pseudonymisation_Gui_V5._resolve_config``), best-effort (jamais de crash).
"""
from __future__ import annotations
import logging
import shutil
import sys
from pathlib import Path
from typing import Optional
log = logging.getLogger(__name__)
_CONFIG_RELATIVE = Path("config") / "dictionnaires.yml"
def _frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def _bundled_config() -> Path:
"""Config embarquée : ``_MEIPASS`` en frozen, racine du dépôt en dev."""
if _frozen():
base = Path(getattr(sys, "_MEIPASS"))
else:
base = Path(__file__).resolve().parent.parent
return base / _CONFIG_RELATIVE
def resolve_user_config_path() -> Optional[Path]:
"""Chemin du ``dictionnaires.yml`` éditable par l'utilisateur.
- dev : la config du dépôt (éditable en place) ; on ne crée jamais le fichier
(contrairement à la V5) : si absent, on renvoie ``None`` (le moteur retombe
sur sa config par défaut) ;
- frozen : ``<dossier de l'exe>/config/dictionnaires.yml`` ; copie la version
embarquée au premier lancement si absente, sans jamais écraser une config
existante (perso établissement).
Renvoie ``None`` si rien n'est résoluble (le moteur retombe alors sur sa
config runtime par défaut).
"""
if not _frozen():
bundled = _bundled_config()
return bundled if bundled.exists() else None
user_cfg = Path(sys.executable).resolve().parent / _CONFIG_RELATIVE
if user_cfg.exists():
return user_cfg
try:
user_cfg.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(_bundled_config(), user_cfg)
return user_cfg
except Exception as exc:
log.warning("copie de la configuration externe échouée (%s) : %s", user_cfg, exc)
bundled = _bundled_config()
return bundled if bundled.exists() else None

72
gui_v6/config_share.py Normal file
View File

@@ -0,0 +1,72 @@
"""Échange de configuration par fichier JSON (workflow email V5, P1-3).
- ``build_export_payload`` : produit le dict V5 (format consommé par
``scripts/merge_params.merge_params``) à partir des listes du profil courant ;
- ``import_config_file`` : fusionne un JSON reçu dans le ``dictionnaires.yml``
utilisateur, sans écraser l'existant.
Aucune dépendance à un widget : testable en pur Python.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
EXPORT_VERSION = "1"
def build_export_payload(
whitelist: Iterable[str], blacklist: Iterable[str], version: str = EXPORT_VERSION
) -> dict:
"""Construit la charge utile d'export au format consommé par merge_params."""
return {
"version": version,
"date_export": datetime.now(timezone.utc).isoformat(),
"whitelist_phrases": [str(t) for t in whitelist],
"blacklist_force_mask_terms": [str(t) for t in blacklist],
}
def _yaml_lists(config_path: Path) -> tuple[set, set]:
import yaml
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
wl = set(cfg.get("whitelist_phrases", []) or [])
bl = set((cfg.get("blacklist", {}) or {}).get("force_mask_terms", []) or [])
return wl, bl
def import_config_file(json_path, config_path) -> bool:
"""Fusionne ``json_path`` dans ``config_path`` (YAML). Retourne True si la
config a changé, False si rien de nouveau.
Fusion autonome (union des listes, jamais d'écrasement) — volontairement
SANS dépendance à ``scripts/merge_params`` (non bundlé en frozen). Même
sémantique : ``whitelist_phrases`` et ``blacklist.force_mask_terms``.
"""
import json
import yaml
json_path = Path(json_path)
config_path = Path(config_path)
before_wl, before_bl = _yaml_lists(config_path)
data = json.loads(json_path.read_text(encoding="utf-8"))
incoming_wl = {str(t).strip() for t in data.get("whitelist_phrases", []) if str(t).strip()}
incoming_bl = {str(t).strip() for t in data.get("blacklist_force_mask_terms", []) if str(t).strip()}
after_wl = before_wl | incoming_wl
after_bl = before_bl | incoming_bl
if after_wl == before_wl and after_bl == before_bl:
return False
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
cfg["whitelist_phrases"] = sorted(after_wl)
cfg.setdefault("blacklist", {})
cfg["blacklist"]["force_mask_terms"] = sorted(after_bl)
config_path.write_text(
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
return True

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

@@ -99,6 +99,10 @@ def discover_documents(input_path, extensions: Optional[Sequence[str]] = None) -
return []
class OutputNotWritableError(RuntimeError):
"""Le dossier de sortie n'est pas inscriptible (échec amont, message clair)."""
@dataclass
class DocResult:
"""Détail anonymisé d'un document traité (pour la télémétrie d'usage).
@@ -195,6 +199,19 @@ class ProcessingRunner:
log("Aucun document supporté détecté.")
return summary
# Sonde amont : on vérifie une seule fois que le dossier de sortie est
# inscriptible AVANT la boucle, pour un échec clair et unique (P1-6)
# plutôt qu'une erreur cryptique répétée à chaque document.
try:
out_root.mkdir(parents=True, exist_ok=True)
probe = out_root / ".anon_write_test"
probe.write_text("", encoding="utf-8")
probe.unlink()
except Exception as exc:
raise OutputNotWritableError(
f"Dossier de sortie non inscriptible : {out_root} ({exc})"
) from exc
for index, doc in enumerate(docs, start=1):
if stop_event is not None and stop_event.is_set():
summary.stopped = True

View File

@@ -20,6 +20,31 @@ from gui_v6 import ui_kit
from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys
from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_template_label
NER_DISABLE_WARNING = (
"Vous allez désactiver le moteur d'intelligence artificielle "
"(CamemBERT-bio).\n\n"
"Sans lui, la détection des NOMS de personnes repose uniquement sur des "
"règles (expressions régulières) : des noms peuvent rester EN CLAIR dans "
"les documents.\n\n"
"Pour un usage médical, garder ce moteur activé est fortement recommandé.\n\n"
"Confirmer la désactivation ?"
)
def confirm_ner_disable(asker) -> bool:
"""Décision de désactivation du NER.
``asker`` est une fonction ``() -> bool`` (ex. ``messagebox.askyesno``),
injectée pour rester testable sans display. Retourne True si l'utilisateur
CONFIRME la désactivation (regex-only). Toute erreur de l'asker est traitée
comme un refus (sens sûr : le NER reste actif).
"""
try:
return bool(asker())
except Exception:
return False
_SUBTABS = [
("reg", "⚙️ Réglages"),
("pro", "👤 Profils"),
@@ -145,12 +170,15 @@ _HELP_PROFIL_MOTS = (
"Pour de longues listes, utilisez le tableau des termes afin de rechercher et vérifier plus facilement."
)
_HELP_EXPORT_CONFIG = (
"Exporte uniquement les réglages de l'application : profils, listes locales, règles et style de masque.\n\n"
"Exporte vos deux listes locales — termes à toujours conserver et termes à toujours "
"masquer — dans un fichier .json à envoyer à un autre poste ou à l'administrateur.\n\n"
"Les documents patients, résultats d'anonymisation et audits ne sont pas exportés."
)
_HELP_IMPORT_CONFIG = (
"Importe des réglages reçus d'un administrateur ou d'un autre poste.\n\n"
"L'import ne lit pas de documents patients. Vérifiez toujours le profil actif après import."
"Importe les deux listes locales (termes à conserver, termes à masquer) reçues d'un "
"autre poste ou de l'administrateur. La fusion ajoute ces termes à votre configuration "
"sans rien écraser.\n\n"
"L'import ne lit pas de documents patients. Redémarrez l'application pour appliquer."
)
CONFIG_MOCKUP_SECTIONS = {
@@ -198,10 +226,11 @@ def _app_base_dir() -> Path:
class ConfigTab(ctk.CTkFrame):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, config_path: Path | None = None, **kwargs):
self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME)
super().__init__(master, fg_color=self._p["bg"], **kwargs)
self._state = state if state is not None else ConfigState()
self._config_path = config_path
self._sub = "reg"
self._sub_buttons: dict[str, ctk.CTkButton] = {}
self._panels: dict[str, ctk.CTkFrame] = {}
@@ -843,16 +872,20 @@ class ConfigTab(ctk.CTkFrame):
help_text=_HELP_EXPORT_CONFIG, help_title="Exporter la configuration",
)
export.pack(fill="both", expand=True)
self._note(export, "Listes locales, règles admin, style de masquage et template actif.")
self._mockup_button(export, "⬇ Exporter (.json)").pack(anchor="w", padx=12, pady=(0, 12))
self._note(export, "Vos listes locales : termes à toujours conserver et termes à toujours masquer.")
ui_kit.secondary_button(
export, p, "⬇ Exporter (.json)", command=self._on_export_config
).pack(anchor="w", padx=12, pady=(0, 12))
import_card = ui_kit.Card(
cols[1], p, title="📥 Importer une configuration",
help_text=_HELP_IMPORT_CONFIG, help_title="Importer une configuration",
)
import_card.pack(fill="both", expand=True)
self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.")
self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12))
self._note(import_card, "Fusionne les listes reçues (à conserver / à masquer) avec vos listes locales.")
ui_kit.secondary_button(
import_card, p, "⬆ Importer (.json)", command=self._on_import_config
).pack(anchor="w", padx=12, pady=(0, 12))
# -- helpers aide / maquette -----------------------------------------
@@ -891,7 +924,19 @@ class ConfigTab(ctk.CTkFrame):
setattr(self._state, field_name, bool(toggle.get()))
def _on_ner(self) -> None:
self._state.use_local_ner = self._tog_ner.get()
new_value = self._tog_ner.get()
if not new_value:
confirmed = confirm_ner_disable(
lambda: messagebox.askyesno(
"Moteur de détection", NER_DISABLE_WARNING, icon="warning"
)
)
if not confirmed:
# Refus : rétablir l'affichage du switch et garder le NER actif.
self._tog_ner.var.set(True)
self._state.use_local_ner = True
return
self._state.use_local_ner = new_value
def _on_eds(self) -> None:
self._state.enable_eds = self._tog_eds.get()
@@ -905,6 +950,47 @@ class ConfigTab(ctk.CTkFrame):
def _on_manual_mask_template(self, label: str) -> None:
self._state.manual_mask_template = self._manual_mask_templates.get(label)
def _on_export_config(self) -> None:
from gui_v6.config_share import build_export_payload
import json
lists = getattr(self, "_pro_term_lists", {})
wl = lists["whitelist"].terms() if "whitelist" in lists else []
bl = lists["blacklist"].terms() if "blacklist" in lists else []
path = filedialog.asksaveasfilename(
title="Exporter la configuration", defaultextension=".json",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
payload = build_export_payload(wl, bl)
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
messagebox.showinfo("Export", f"Configuration exportée :\n{path}")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Export", f"Échec de l'export : {exc}")
def _on_import_config(self) -> None:
from gui_v6.config_share import import_config_file
if self._config_path is None:
messagebox.showerror("Import", "Aucune configuration cible résolue.")
return
path = filedialog.askopenfilename(
title="Importer une configuration",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
changed = import_config_file(path, self._config_path)
if changed:
messagebox.showinfo("Import", "Configuration fusionnée. Redémarrez pour appliquer.")
else:
messagebox.showinfo("Import", "Rien de nouveau à fusionner.")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Import", f"Échec de l'import : {exc}")
def _pick_output(self) -> None:
path = filedialog.askdirectory(title="Dossier de sortie")
if path:

View File

@@ -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()
@@ -104,9 +129,19 @@ class UsageTab(ctk.CTkFrame):
docs, fg_color=p["divider"], border_color=p["card_border"], border_width=2, corner_radius=8
)
dz.pack(fill="x", padx=16, pady=(0, 8))
ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
ctk.CTkLabel(dz, text="Choisissez vos fichiers", text_color=p["text"], font=ui_kit.font(14)).pack()
ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))
# Zone réellement cliquable (ouvre le sélecteur de fichier). Le DnD natif
# est différé ; on ne laisse pas une métaphore « déposer » trompeuse.
dz.configure(cursor="hand2")
dz.bind("<Button-1>", lambda _e: self._pick_file())
for _txt, _color, _size, _pady in (
("⬆️", p["text"], 30, (20, 4)),
("Cliquez pour choisir un fichier", p["text"], 14, 0),
("PDF · Word · Images · Texte", p["text_muted"], 12, (2, 10)),
):
_lbl = ctk.CTkLabel(dz, text=_txt, text_color=_color, font=ui_kit.font(_size))
_lbl.configure(cursor="hand2")
_lbl.bind("<Button-1>", lambda _e: self._pick_file())
_lbl.pack(pady=_pady)
acts = ctk.CTkFrame(dz, fg_color="transparent")
acts.pack(pady=(0, 20))
ui_kit.secondary_button(acts, p, "📄 Fichier", command=self._pick_file).pack(side="left", padx=4)
@@ -158,6 +193,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 +247,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 +318,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 +349,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:

View File

@@ -0,0 +1,82 @@
"""Résolution du dictionnaires.yml externe éditable (P1-4).
Pur : on simule frozen via monkeypatch (sys.frozen / sys.executable / _MEIPASS),
aucun display, aucun modèle.
"""
from __future__ import annotations
from pathlib import Path
import gui_v6.config_paths as cp
def _make_bundle(tmp_path: Path) -> Path:
bundle = tmp_path / "bundle"
(bundle / "config").mkdir(parents=True)
(bundle / "config" / "dictionnaires.yml").write_text("whitelist_phrases: []\n", encoding="utf-8")
return bundle
def test_dev_returns_repo_config_when_present(monkeypatch):
# En dev (non frozen) : pointe la config embarquée si elle existe.
monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
path = cp.resolve_user_config_path()
assert path is not None
assert path.name == "dictionnaires.yml"
assert path.exists()
def test_frozen_copies_bundle_on_first_launch(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
exe_dir.mkdir()
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
expected = exe_dir / "config" / "dictionnaires.yml"
assert out == expected
assert expected.exists() # copié depuis le bundle au 1er lancement
assert expected.read_text(encoding="utf-8") == "whitelist_phrases: []\n"
def test_frozen_keeps_existing_user_config(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
(exe_dir / "config").mkdir(parents=True)
user_cfg = exe_dir / "config" / "dictionnaires.yml"
user_cfg.write_text("whitelist_phrases: [HOPITAL_LOCAL]\n", encoding="utf-8")
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
assert out == user_cfg
# Ne JAMAIS écraser la perso établissement existante.
assert "HOPITAL_LOCAL" in out.read_text(encoding="utf-8")
def test_dev_returns_none_when_config_missing(tmp_path, monkeypatch):
# En dev, si la config embarquée est absente : on renvoie None (pas de création).
monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
monkeypatch.setattr(cp, "_bundled_config", lambda: tmp_path / "absent" / "dictionnaires.yml")
assert cp.resolve_user_config_path() is None
def test_frozen_copy_failure_falls_back(tmp_path, monkeypatch):
# En frozen, si la copie échoue (ex. droits) : fallback sur la config embarquée, sans crash.
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
exe_dir.mkdir()
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
def _boom(*_args, **_kwargs):
raise PermissionError("accès refusé")
monkeypatch.setattr(cp.shutil, "copyfile", _boom)
out = cp.resolve_user_config_path()
assert out == cp._bundled_config()

View File

@@ -0,0 +1,95 @@
"""Export / import de configuration (P1-3) — format compatible merge_params.
Pur : sérialisation/désérialisation et fusion, sans display ni filedialog.
"""
from __future__ import annotations
import json
from pathlib import Path
from gui_v6.config_share import build_export_payload, import_config_file
def test_export_payload_has_v5_schema():
payload = build_export_payload(
whitelist=["Dr Métier", "Service ORL"],
blacklist=["DUPONT"],
version="2026.06.29",
)
assert payload["version"] == "2026.06.29"
assert "date_export" in payload
assert payload["whitelist_phrases"] == ["Dr Métier", "Service ORL"]
assert payload["blacklist_force_mask_terms"] == ["DUPONT"]
def test_export_payload_is_json_serializable():
payload = build_export_payload(whitelist=["A"], blacklist=["B"], version="1")
json.dumps(payload) # ne doit pas lever
def test_import_merges_into_user_config(tmp_path, monkeypatch):
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Existant]\n", encoding="utf-8")
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({
"version": "1", "date_export": "2026-06-29",
"whitelist_phrases": ["Nouveau"],
"blacklist_force_mask_terms": ["MASQUERMOI"],
}),
encoding="utf-8",
)
added = import_config_file(incoming, cfg)
assert added is True
import yaml
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
assert "Existant" in merged["whitelist_phrases"]
assert "Nouveau" in merged["whitelist_phrases"]
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]
def test_import_returns_false_when_nothing_new(tmp_path):
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Deja]\n", encoding="utf-8")
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({"whitelist_phrases": ["Deja"], "blacklist_force_mask_terms": []}),
encoding="utf-8",
)
assert import_config_file(incoming, cfg) is False
def test_import_preserves_unmanaged_yaml_keys(tmp_path):
# Une config riche : l'import ne doit toucher QUE whitelist/blacklist,
# et préserver toutes les autres sections (anti-perte de données).
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text(
"version: 3\n"
"whitelist_phrases: [Existant]\n"
"blacklist:\n"
" force_mask_terms: [DEJA]\n"
" autre_sous_cle: [GARDER]\n"
"regex_overrides:\n"
" - rule_a\n"
"flags:\n"
" strict: true\n",
encoding="utf-8",
)
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({"whitelist_phrases": ["Nouveau"], "blacklist_force_mask_terms": ["MASQUERMOI"]}),
encoding="utf-8",
)
assert import_config_file(incoming, cfg) is True
import yaml
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
# Clés non gérées intactes
assert merged["version"] == 3
assert merged["regex_overrides"] == ["rule_a"]
assert merged["flags"] == {"strict": True}
assert merged["blacklist"]["autre_sous_cle"] == ["GARDER"]
# Listes gérées fusionnées
assert "Existant" in merged["whitelist_phrases"]
assert "Nouveau" in merged["whitelist_phrases"]
assert "DEJA" in merged["blacklist"]["force_mask_terms"]
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]

View File

@@ -0,0 +1,31 @@
"""Confirmation avant de désactiver le NER (regex-only) — outil médical.
Pur : la décision est isolée dans ``confirm_ner_disable(asker)`` ; ``asker`` est
injecté (pas de messagebox réel, pas de display).
"""
from __future__ import annotations
from gui_v6.tabs.tab_config import NER_DISABLE_WARNING, confirm_ner_disable
def test_confirm_true_when_user_accepts():
assert confirm_ner_disable(lambda: True) is True
def test_confirm_false_when_user_declines():
assert confirm_ner_disable(lambda: False) is False
def test_confirm_false_when_asker_raises():
def boom():
raise RuntimeError("Tk indisponible")
# Sens sûr : une erreur de dialogue ne désactive jamais le NER.
assert confirm_ner_disable(boom) is False
def test_warning_text_is_explicit_for_medical_use():
txt = NER_DISABLE_WARNING.lower()
# L'avertissement DOIT nommer la dégradation : règles/regex + risque noms.
assert "règles" in txt or "regex" in txt
assert "nom" in txt
assert "recommand" in txt # « fortement recommandé »

View File

@@ -197,6 +197,23 @@ def test_progress_callbacks(tmp_path):
assert (2, 2) in events # progression finale atteinte
def test_run_fails_fast_when_output_not_writable(tmp_path, monkeypatch):
from gui_v6.processing_runner import ProcessingRunner, OutputNotWritableError
src = tmp_path / "in"
src.mkdir()
(src / "a.txt").write_text("x", encoding="utf-8")
out = tmp_path / "ro"
out.mkdir()
def boom(*a, **k):
raise PermissionError("read-only")
monkeypatch.setattr("gui_v6.processing_runner.Path.mkdir", boom)
runner = ProcessingRunner(process_fn=lambda d, o: {})
with pytest.raises(OutputNotWritableError):
runner.run(src, out)
def test_no_double_run(tmp_path):
_touch(tmp_path / "a.pdf")
started = threading.Event()

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