Files
anonymisation/tests/unit/test_gui_v6_profiles.py
Domi31tls 72841ed7b3 feat(gui): onglet Profils éditable (création/modification/persistance)
Retour Dom : remplacer la page vitrine par un vrai éditeur de profils.

- gui_v6/profile_editor.py : couche logique (build_profile_spec,
  profile_is_editable runtime vs defaut, list_profile_choices, slug_for_copy,
  save/set_default/delete) au-dessus de profile_defaults — persistance dans
  config/profiles.yml.
- gui_v6/editable_list.py : EditableTermList (tableau scrollable de termes,
  ajout/suppression, pas de pastilles) — reste lisible à 50+ termes.
- tab_config : sous-onglet « 👤 Profils » réintroduit comme éditeur — menu
  déroulant « Profil à modifier », boutons Nouveau / Dupliquer / Enregistrer /
  Annuler / Définir par défaut, sections Identité, Masquage (require_manual_mask,
  template), Moteurs (force_disable_vlm), Mots (à masquer/conserver/ignorer
  éditables), Règles « à venir ». Profils défaut = lecture seule (dupliquer
  pour modifier). Confirmation non bloquante (pas de modale).
- Réglages : bouton « ✏️ Modifier le profil… » → ouvre Profils sur le profil
  actif. Pas de pastilles inline.

Persiste : label, description, require_manual_mask, force_disable_vlm,
preferred_manual_mask_template, param_lists (3 listes). 260 tests unit OK
(0 régression), self-test OK, nav 5 sous-onglets + thème OK. Préserve
1bbe70a/d30f7b7. Aucun build/push sans GO Dom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:09:01 +02:00

254 lines
8.1 KiB
Python

"""Vue lisible d'un profil d'anonymisation (logique pure, sans display).
Sous-tend le sous-onglet « Profils » et la fenêtre « Tableau des termes ».
"""
from __future__ import annotations
import pytest
from gui_v6.profile_view import (
filter_term_rows,
profile_term_rows,
summarize_profile,
)
_PROFILE = {
"label": "Standard local",
"description": "Profil par défaut.",
"require_manual_mask": True,
"force_disable_vlm": True,
"preferred_manual_mask_template": "config/mask_templates/x.json",
"param_lists": {
"whitelist_phrases": ["classification internationale", "prise en charge"],
"blacklist_force_mask_terms": ["CHUXX"],
"additional_stopwords": [],
},
}
def test_summarize_profile_reads_real_fields():
s = summarize_profile("standard_local", _PROFILE)
assert s.key == "standard_local"
assert s.label == "Standard local"
assert s.description == "Profil par défaut."
assert s.require_manual_mask is True
assert s.disable_vlm is True
assert s.mask_template == "config/mask_templates/x.json"
assert s.list_counts == {"À conserver": 2, "À masquer": 1, "À ignorer": 0}
def test_summarize_profile_tolerates_empty():
s = summarize_profile("vide", {})
assert s.label == "vide"
assert s.description == ""
assert s.require_manual_mask is False
assert s.mask_template == ""
assert s.list_counts == {"À conserver": 0, "À masquer": 0, "À ignorer": 0}
s2 = summarize_profile("none", None)
assert s2.list_counts["À masquer"] == 0
def test_profile_term_rows_type_term_source():
rows = profile_term_rows(_PROFILE)
assert ("À conserver", "classification internationale", "Standard local") in rows
assert ("À masquer", "CHUXX", "Standard local") in rows
# 2 whitelist + 1 blacklist + 0 stopwords
assert len(rows) == 3
def test_filter_term_rows_by_query():
rows = profile_term_rows(_PROFILE)
assert len(filter_term_rows(rows, "")) == 3
assert filter_term_rows(rows, "chuxx") == [("À masquer", "CHUXX", "Standard local")]
assert filter_term_rows(rows, "conserver") == [
r for r in rows if r[0] == "À conserver"
]
assert filter_term_rows(rows, "zzz") == []
# --- Smokes headless (fenêtre tableau + infobulle) --------------------------
@pytest.fixture
def ctk_root():
ctk = pytest.importorskip("customtkinter")
try:
root = ctk.CTk()
except Exception as exc:
pytest.skip(f"display Tk indisponible: {exc}")
root.withdraw()
try:
yield root
finally:
try:
root.destroy()
except Exception:
pass
def test_terms_table_window_filters_and_disables_add(ctk_root):
from gui_v6 import theme as theme_mod
from gui_v6.terms_table_window import TermsTableWindow
p = theme_mod.get_palette(theme_mod.DEFAULT_THEME)
win = TermsTableWindow(ctk_root, p, profile_term_rows(_PROFILE), profile_label="Standard local")
ctk_root.update_idletasks()
assert win.visible_count() == 3
assert win.add_is_disabled() # action non câblée → désactivée
win.set_query("chuxx")
assert win.visible_count() == 1
win.set_query("")
assert win.visible_count() == 3
win.destroy()
def test_attach_tooltip_does_not_break_widget(ctk_root):
import customtkinter as ctk
from gui_v6 import ui_kit
lbl = ctk.CTkLabel(ctk_root, text="x")
lbl.pack()
ctk_root.update_idletasks()
tip = ui_kit.attach_tooltip(lbl, "aide contextuelle")
tip.show()
ctk_root.update_idletasks()
tip.hide()
assert lbl.winfo_exists()
def test_subtabs_include_editable_profils():
"""Retour Dom : sous-onglet Profils réintroduit (éditeur)."""
from gui_v6.tabs.tab_config import _SUBTABS
keys = [k for k, _ in _SUBTABS]
labels = [lbl for _, lbl in _SUBTABS]
assert "pro" in keys
assert any("Profils" in lbl for lbl in labels)
def test_profils_editor_creates_and_persists(ctk_root, tmp_path, monkeypatch):
"""L'éditeur crée un profil, le rend éditable, et persiste les modifications."""
from gui_v6.tabs import tab_config
monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path)
profiles = tmp_path / "profiles.yml"
tab = tab_config.ConfigTab(ctk_root)
tab._profiles_path = profiles
tab._show_sub("pro")
tab.update_idletasks()
# création d'un profil runtime
tab._pro_new()
tab.update_idletasks()
key = tab._pro_edit_key
assert key and key.startswith("nouveau_profil")
# éditer : nom + un terme à masquer, puis enregistrer
tab._pro_label_var.set("Profil cabinet")
tab._pro_require_mask_var.set(True)
tab._pro_term_lists["blacklist"].add_term("CHUXX")
tab._pro_save()
tab.update_idletasks()
from profile_defaults import list_effective_profiles
saved = list_effective_profiles(profiles)[key]
assert saved["label"] == "Profil cabinet"
assert saved["require_manual_mask"] is True
assert saved["param_lists"]["blacklist_force_mask_terms"] == ["CHUXX"]
tab.destroy()
def test_profils_default_profile_is_read_only(ctk_root, tmp_path, monkeypatch):
"""Un profil par défaut n'est pas éditable (bouton Enregistrer désactivé)."""
from gui_v6.tabs import tab_config
monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path)
tab = tab_config.ConfigTab(ctk_root)
tab._profiles_path = tmp_path / "profiles.yml"
tab._show_sub("pro")
tab._pro_load("standard_local") # profil défaut
tab.update_idletasks()
assert str(tab._pro_save_btn.cget("state")) == "disabled"
tab.destroy()
def test_editable_term_list_add_remove(ctk_root):
from gui_v6 import theme as theme_mod
from gui_v6.editable_list import EditableTermList
p = theme_mod.get_palette(theme_mod.DEFAULT_THEME)
lst = EditableTermList(ctk_root, p, title="À masquer", initial=["A", "B"])
ctk_root.update_idletasks()
assert lst.terms() == ["A", "B"]
assert lst.add_term("C") is True
assert lst.add_term("C") is False # pas de doublon
lst.remove_term("A")
assert lst.terms() == ["B", "C"]
lst.set_editable(False)
assert str(lst._add_btn.cget("state")) == "disabled"
lst.destroy()
def _all_texts(widget):
out = []
try:
out.append(str(widget.cget("text")))
except Exception:
pass
for child in widget.winfo_children():
out += _all_texts(child)
return out
def test_reglages_labels_renamed_and_profile_readable(ctk_root, tmp_path, monkeypatch):
from gui_v6.tabs import tab_config
monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path)
tab = tab_config.ConfigTab(ctk_root)
tab.update_idletasks()
texts = " | ".join(_all_texts(tab))
assert "Profil d'anonymisation" in texts # addendum : renommage
assert "Profil métier" not in texts
assert "Dossier de sortie" in texts # addendum : « Sortie… » clarifié
# retour Dom : accès direct au tableau depuis Réglages, plus de pastilles inline
assert "Ouvrir le tableau des termes" in texts
assert "Voir le profil" not in texts
assert "FUROSEMIDE" not in texts # plus de pastilles de termes exemple inline
# profil lisible : résumé avec les 3 listes
summary = tab._active_profile_summary()
assert set(summary.list_counts.keys()) == {"À conserver", "À masquer", "À ignorer"}
# tableau des termes ouvrable DIRECTEMENT depuis Réglages (sans onglet Profils)
tab._open_terms_table()
tab.update_idletasks()
tab.destroy()
def test_usage_tab_finish_calls_reporter(ctk_root):
"""Câblage : la fin de run appelle le reporter de télémétrie (non bloquant)."""
import threading
from gui_v6.processing_runner import RunSummary
from gui_v6.tabs.tab_usage import UsageTab
called = threading.Event()
captured = {}
def reporter(summary):
captured["summary"] = summary
called.set()
tab = UsageTab(ctk_root, usage_reporter=reporter)
ctk_root.update_idletasks()
summary = RunSummary(total=1, succeeded=1)
tab._finish(summary)
assert called.wait(timeout=3.0) # reporter appelé en thread daemon
assert captured["summary"] is summary
tab.destroy()