"""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() def test_unavailable_engines_disabled_in_reglages(ctk_root, tmp_path, monkeypatch): """Honnêteté moteurs : EDS-Pseudo / GLiNER non embarqués → switch désactivé et état forcé à False ; CamemBERT-bio reste actif.""" import engine_capabilities as ec from gui_v6.tabs import tab_config monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path) fake = { "camembert": ec.EngineCapability("camembert", "CamemBERT-bio (standard)", True, True, "ok"), "eds": ec.EngineCapability("eds", "EDS-Pseudo (optionnel)", False, False, "non embarqué dans cette version (manque : edsnlp, spacy)"), "gliner": ec.EngineCapability("gliner", "GLiNER (optionnel)", False, False, "non embarqué dans cette version (manque : gliner)"), } monkeypatch.setattr(tab_config.engine_capabilities, "capabilities_map", lambda probes=None: fake) tab = tab_config.ConfigTab(ctk_root) tab.update_idletasks() assert str(tab._tog_ner.switch.cget("state")) == "normal" # CamemBERT standard actif assert str(tab._tog_eds.switch.cget("state")) == "disabled" assert str(tab._tog_gli.switch.cget("state")) == "disabled" assert tab._state.enable_eds is False assert tab._state.enable_gliner is False tab.destroy() def test_available_engines_enabled_in_reglages(ctk_root, tmp_path, monkeypatch): """Si les moteurs optionnels sont embarqués, leurs switches restent actifs.""" import engine_capabilities as ec from gui_v6.tabs import tab_config monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path) fake = { "camembert": ec.EngineCapability("camembert", "CamemBERT-bio (standard)", True, True, "ok"), "eds": ec.EngineCapability("eds", "EDS-Pseudo (optionnel)", True, False, "edsnlp + spacy disponibles"), "gliner": ec.EngineCapability("gliner", "GLiNER (optionnel)", True, False, "gliner disponible"), } monkeypatch.setattr(tab_config.engine_capabilities, "capabilities_map", lambda probes=None: fake) tab = tab_config.ConfigTab(ctk_root) tab.update_idletasks() assert str(tab._tog_eds.switch.cget("state")) == "normal" assert str(tab._tog_gli.switch.cget("state")) == "normal" tab.destroy()