Retour Dom : « les règles du profil doivent être dans le menu profil, pas à
part ! ». Même logique que le Masquage — les règles qui influencent
l'anonymisation appartiennent au profil ; un sous-onglet séparé crée la
même confusion.
- Retrait du sous-onglet « Administration > Règles » (_SUBTABS, builder,
méthode _build_regles supprimée). Sous-onglets restants : Réglages /
Profils / Partage.
- Section « Profils > Règles du profil » enrichie : wording clair (règles
d'anonymisation portées par le profil), aperçu illustratif de la table
des règles (réutilise _rule_row + _HELP_REGLES), édition fine annoncée
« à venir ».
- Abandon du « Testeur de règle » (écran outil global) pour ne pas
réintroduire un second réglage métier.
Cible UX : Réglages / Profils (Général・Masquage・Mots・Moteurs・Règles du
profil) / Partage. Test obsolète test_rules_subtab_has_no_unexplained_2
remplacé par test_no_separate_rules_subtab.
262 tests unit OK (0 régression), self-test OK, nav 3 sous-onglets + section
Règles dans Profils + thème OK. Préserve d8bc0cd + GO Qwen. Aucun build/push
sans GO Dom.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
298 lines
9.9 KiB
Python
298 lines
9.9 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()
|
|
|
|
|
|
def test_masquage_moved_into_profils(ctk_root, tmp_path, monkeypatch):
|
|
"""Le sous-onglet Masquage est retiré ; son contenu utile est dans Profils."""
|
|
from gui_v6.tabs import tab_config
|
|
|
|
keys = [k for k, _ in tab_config._SUBTABS]
|
|
assert "msk" not in keys # plus de sous-onglet Masquage séparé
|
|
|
|
monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path)
|
|
tab = tab_config.ConfigTab(ctk_root)
|
|
tab._show_sub("pro")
|
|
tab.update_idletasks()
|
|
|
|
# apparence du masque relocalisée dans la section Profils > Masquage
|
|
assert getattr(tab, "_swatch_buttons", None)
|
|
# un template enregistré depuis l'éditeur remplit le champ Template du profil
|
|
saved = tmp_path / "config" / "mask_templates" / "depuis_editeur.json"
|
|
saved.parent.mkdir(parents=True, exist_ok=True)
|
|
tab._on_mask_template_saved(saved)
|
|
assert tab._pro_template_var.get().endswith("depuis_editeur.json")
|
|
tab.destroy()
|
|
|
|
|
|
def test_regles_moved_into_profils(ctk_root, tmp_path, monkeypatch):
|
|
"""Retour Dom : le sous-onglet Règles séparé est retiré ; les règles du
|
|
profil sont une section de Profils."""
|
|
from gui_v6.tabs import tab_config
|
|
|
|
keys = [k for k, _ in tab_config._SUBTABS]
|
|
assert "rul" not in keys # plus de sous-onglet Règles séparé
|
|
|
|
monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path)
|
|
tab = tab_config.ConfigTab(ctk_root)
|
|
tab._show_sub("pro")
|
|
tab.update_idletasks()
|
|
|
|
# la section des règles du profil est dans le panneau Profils
|
|
texts = " | ".join(_all_texts(tab._panels["pro"]))
|
|
assert "Règles d'anonymisation portées par ce profil" in texts
|
|
assert "Masquer le sigle CHUXX" in texts # table de règles relocalisée dans Profils
|
|
# le builder du sous-onglet séparé n'existe plus
|
|
assert "rul" not in tab._panels
|
|
tab.destroy()
|