From a9e8b2c2e694a96059fa3c2412f33b6ef41d4a91 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Mon, 15 Jun 2026 17:02:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20addenda=20Dom=20GUI=20V6=20?= =?UTF-8?q?=E2=80=94=20sous-onglet=20Profils,=20libell=C3=A9s,=20aide,=20b?= =?UTF-8?q?=C3=AAta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite des retours Dom sur la GUI V6 (par-dessus 6a0a581). Addendum Profils / Réglages : - Nouveau sous-onglet Administration « 👤 Profils » : le profil actif devient un objet lisible (nom, description, masque requis, template, listes locales avec compteurs) — données réelles lues depuis profile_defaults. - Fenêtre « Tableau des termes » (terms_table_window.py) : table scrollable avec recherche/filtre, colonnes Type/Terme/Source ; reste lisible à 50+ termes. Ajouter/éditer/supprimer désactivés « (à venir) » (écriture par profil non câblée). - Réglages : « Profil métier » → « Profil d'anonymisation », « Sortie… » → « Dossier de sortie… » (+ infobulle), hints moteurs (standard/optionnel/ plus lent), bouton « Voir le profil », « Ouvrir le tableau des termes ». - Aide « ? » + infobulles (ui_kit.attach_tooltip) près des éléments ambigus. - profile_view.py : logique pure (résumé profil + lignes du tableau), testable sans display. Addendum bêta : en-tête « aivanonym » + badge « bêta », titre fenêtre « … — bêta ». Détail version conservé dans À propos. tests/unit/test_gui_v6_profiles.py + ajouts shell. 237 tests unit OK (228 → 237, 0 régression), self-test GUI V6 OK, navigation des 5 sous-onglets + thème OK. V5/moteur/app_aivanov/profile_defaults non touchés, 0 dépendance. Aucun build/push sans GO Dom — validation visuelle Dom attendue. Co-Authored-By: Claude Opus 4.8 (1M context) --- gui_v6/app.py | 16 +- gui_v6/profile_view.py | 69 ++++++++ gui_v6/tabs/tab_config.py | 160 +++++++++++++++++- gui_v6/terms_table_window.py | 114 +++++++++++++ gui_v6/ui_kit.py | 68 ++++++++ tests/unit/test_gui_v6_app_shell.py | 20 +++ .../test_gui_v6_config_mockup_sections.py | 2 +- tests/unit/test_gui_v6_profiles.py | 160 ++++++++++++++++++ 8 files changed, 597 insertions(+), 12 deletions(-) create mode 100644 gui_v6/profile_view.py create mode 100644 gui_v6/terms_table_window.py create mode 100644 tests/unit/test_gui_v6_profiles.py diff --git a/gui_v6/app.py b/gui_v6/app.py index 886797f..cf88750 100644 --- a/gui_v6/app.py +++ b/gui_v6/app.py @@ -45,7 +45,7 @@ class AnonymisationApp(ctk.CTk): self._tab_frames: dict = {} self._visible_tab = None - self.title("Pseudonymisation de vos documents") + self.title("Pseudonymisation de vos documents — bêta") self.geometry("820x880") self.minsize(720, 680) self._render() @@ -87,9 +87,19 @@ class AnonymisationApp(ctk.CTk): def _build_header(self, p: dict) -> None: header = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=0) header.pack(fill="x") + identity = ctk.CTkFrame(header, fg_color="transparent") + identity.pack(side="left", padx=16, pady=10) ctk.CTkLabel( - header, text="🛡️ aivanonym", text_color=p["text"], font=ui_kit.font(18, "bold") - ).pack(side="left", padx=16, pady=10) + identity, text="🛡️ aivanonym", text_color=p["text"], font=ui_kit.font(18, "bold") + ).pack(side="left") + ctk.CTkLabel( + identity, + text="bêta", + text_color="#ffffff", + fg_color=p["primary"], + corner_radius=8, + font=ui_kit.font(10, "bold"), + ).pack(side="left", padx=(8, 0), ipadx=6, ipady=1) status = self._safe_local_status() ctk.CTkLabel( diff --git a/gui_v6/profile_view.py b/gui_v6/profile_view.py new file mode 100644 index 0000000..ce58b18 --- /dev/null +++ b/gui_v6/profile_view.py @@ -0,0 +1,69 @@ +"""Vue lisible d'un profil d'anonymisation (logique pure, testable sans display). + +Un profil de ``profile_defaults`` est un dict riche (label, description, +require_manual_mask, force_disable_vlm, preferred_manual_mask_template, +param_lists). Ce module en extrait un résumé affichable et les lignes du +« tableau des termes » pour les utilisateurs non informaticiens. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + +# Ordre + libellés lisibles des listes locales d'un profil. +LIST_LABELS = { + "whitelist_phrases": "À conserver", + "blacklist_force_mask_terms": "À masquer", + "additional_stopwords": "À ignorer", +} + + +@dataclass +class ProfileSummary: + key: str + label: str + description: str + require_manual_mask: bool + mask_template: str # "" si aucun + disable_vlm: bool + list_counts: dict[str, int] + + +def summarize_profile(key: str, profile: Optional[dict[str, Any]]) -> ProfileSummary: + profile = profile or {} + param_lists = profile.get("param_lists") or {} + counts = { + label: len(param_lists.get(raw) or []) + for raw, label in LIST_LABELS.items() + } + return ProfileSummary( + key=key, + label=str(profile.get("label") or key or "—"), + description=str(profile.get("description") or ""), + require_manual_mask=bool(profile.get("require_manual_mask")), + mask_template=str(profile.get("preferred_manual_mask_template") or ""), + disable_vlm=bool(profile.get("force_disable_vlm")), + list_counts=counts, + ) + + +def profile_term_rows(profile: Optional[dict[str, Any]]) -> list[tuple[str, str, str]]: + """Lignes ``(type, terme, source)`` pour le tableau des termes du profil.""" + profile = profile or {} + source = str(profile.get("label") or "") + param_lists = profile.get("param_lists") or {} + rows: list[tuple[str, str, str]] = [] + for raw, type_label in LIST_LABELS.items(): + for term in (param_lists.get(raw) or []): + rows.append((type_label, str(term), source)) + return rows + + +def filter_term_rows( + rows: list[tuple[str, str, str]], query: str +) -> list[tuple[str, str, str]]: + q = (query or "").strip().lower() + if not q: + return list(rows) + return [r for r in rows if q in r[1].lower() or q in r[0].lower()] diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index a4c7ff4..15b4c12 100644 --- a/gui_v6/tabs/tab_config.py +++ b/gui_v6/tabs/tab_config.py @@ -22,6 +22,7 @@ from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_ _SUBTABS = [ ("reg", "⚙️ Réglages"), + ("pro", "👤 Profils"), ("msk", "🎭 Masquage"), ("shr", "🔄 Partage"), ("rul", "🛡️ Règles"), @@ -65,7 +66,7 @@ MANUAL_MASK_NONE_LABEL = "Aucun masque manuel" # Textes d'aide « ? » (français simple, pour utilisateurs non informaticiens). _HELP_REGLAGES = ( "Réglages de l'anonymisation.\n\n" - "• Profil métier : choisit un jeu de réglages adapté à votre service.\n" + "• Profil d'anonymisation : choisit un jeu de réglages adapté à votre usage.\n" "• Moteurs NER : les modèles qui détectent les noms et données personnelles.\n" "• Données à détecter : ce qui sera masqué (noms, dates de naissance, etc.).\n" "• Listes locales : vos termes à toujours masquer ou toujours conserver.\n\n" @@ -95,10 +96,33 @@ _HELP_REGLES = ( "Cette section est en cours de finalisation : les actions marquées « à venir » " "ne sont pas encore disponibles." ) +_HELP_PROFIL = ( + "Un profil d'anonymisation regroupe tous les réglages adaptés à un usage " + "(ex. : interne standard, diffusion prudente, recherche…).\n\n" + "Il définit les moteurs utilisés, les données détectées, les termes à conserver " + "ou à masquer, et si un masque manuel est requis.\n\n" + "Choisissez un profil ici, et consultez son détail dans l'onglet « Profils »." +) +_HELP_MOTEURS = ( + "Les moteurs détectent les données personnelles.\n\n" + "• CamemBERT-bio : moteur standard, rapide et fiable — activé par défaut.\n" + "• EDS-Pseudo et GLiNER : optionnels. Ils renforcent la détection mais sont " + "plus lents et ne sont pas toujours installés sur le poste.\n\n" + "Si tout n'est pas coché, c'est que les moteurs optionnels ne sont pas requis " + "par le profil ou pas disponibles." +) +_HELP_LISTES = ( + "Les listes locales personnalisent la détection pour votre établissement :\n\n" + "• À conserver : termes à ne jamais masquer (vocabulaire métier).\n" + "• À masquer : termes à toujours masquer (sigles, en-têtes…).\n" + "• À ignorer : mots à ne pas considérer.\n\n" + "Pour une liste longue, ouvrez le tableau des termes (onglet « Profils ») : " + "il reste lisible et permet la recherche." +) CONFIG_MOCKUP_SECTIONS = { "reglages": [ - "Profil métier", + "Profil d'anonymisation", "Moteurs NER", "Données à détecter", "Termes à toujours conserver", @@ -202,6 +226,7 @@ class ConfigTab(ctk.CTkFrame): builders = { "reg": self._build_reglages, + "pro": self._build_profils, "msk": self._build_masquage, "shr": self._build_partage, "rul": self._build_regles, @@ -251,7 +276,7 @@ class ConfigTab(ctk.CTkFrame): current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "") self._state.profile = current or None - ctk.CTkLabel(top, text="Profil métier", text_color=p["text_dim"], font=ui_kit.font(11, "bold")).pack( + ctk.CTkLabel(top, text="Profil d'anonymisation", text_color=p["text_dim"], font=ui_kit.font(11, "bold")).pack( side="left", padx=(12, 8), pady=10 ) self._profile_menu = ctk.CTkOptionMenu( @@ -268,9 +293,15 @@ class ConfigTab(ctk.CTkFrame): if current: self._profile_menu.set(current) self._profile_menu.pack(side="left", pady=10) + ui_kit.help_button(top, p, _HELP_PROFIL, title="Profil d'anonymisation").pack(side="left", padx=(6, 0), pady=10) + ui_kit.secondary_button(top, p, "👤 Voir le profil", command=lambda: self._show_sub("pro")).pack( + side="left", padx=(10, 4), pady=10 + ) - ui_kit.secondary_button(top, p, "📁 Sortie…", command=self._pick_output).pack( - side="left", padx=(12, 6), pady=10 + sortie = ui_kit.secondary_button(top, p, "📁 Dossier de sortie…", command=self._pick_output) + sortie.pack(side="left", padx=(6, 6), pady=10) + ui_kit.attach_tooltip( + sortie, "Dossier où seront écrits les documents anonymisés.\nRéglage local de traitement (pas une règle du profil)." ) self._out_label = ctk.CTkLabel( top, @@ -289,16 +320,22 @@ class ConfigTab(ctk.CTkFrame): ner = ui_kit.Card(cols[1], p, title="🧠 Moteurs et masques") ner.pack(fill="both", expand=True) + hint_row = ctk.CTkFrame(ner, fg_color="transparent") + hint_row.pack(fill="x", padx=12, pady=(0, 2)) + ctk.CTkLabel( + hint_row, text="Pourquoi pas tout coché ?", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w" + ).pack(side="left") + ui_kit.help_button(hint_row, p, _HELP_MOTEURS, title="Moteurs de détection").pack(side="right") self._tog_ner = self._mini_toggle( - ner, "CamemBERT-bio", "rapide · F1 0.963", value=self._state.use_local_ner, command=self._on_ner + ner, "CamemBERT-bio", "standard · rapide · F1 0.963", value=self._state.use_local_ner, command=self._on_ner ) self._tog_ner.pack(fill="x", padx=12, pady=1) self._tog_eds = self._mini_toggle( - ner, "EDS-Pseudo", "médical français", value=self._state.enable_eds, command=self._on_eds + ner, "EDS-Pseudo", "optionnel · médical français · plus lent", value=self._state.enable_eds, command=self._on_eds ) self._tog_eds.pack(fill="x", padx=12, pady=1) self._tog_gli = self._mini_toggle( - ner, "GLiNER", "vote croisé", value=self._state.enable_gliner, command=self._on_gliner + ner, "GLiNER", "optionnel · vote croisé · plus lent", value=self._state.enable_gliner, command=self._on_gliner ) self._tog_gli.pack(fill="x", padx=12, pady=1) self._mini_toggle( @@ -340,9 +377,115 @@ class ConfigTab(ctk.CTkFrame): terms = ui_kit.Card(cols[2], p, title="✅ Listes locales") terms.pack(fill="both", expand=True) + terms_help = ctk.CTkFrame(terms, fg_color="transparent") + terms_help.pack(fill="x", padx=12, pady=(0, 2)) + ctk.CTkLabel( + terms_help, text="Termes propres à votre établissement", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w" + ).pack(side="left") + ui_kit.help_button(terms_help, p, _HELP_LISTES, title="Listes locales").pack(side="right") self._compact_tag_editor(terms, "Termes à conserver", "Ex : FUROSEMIDE", _PRESERVE_TERMS, "keep") self._compact_tag_editor(terms, "Termes à masquer", "Ex : CHUXX", _MASK_TERMS, "mask") self._compact_tag_editor(terms, "Mots à ignorer", "Ex : prescription", _STOPWORDS, "stop") + ctk.CTkButton( + terms, + text="📋 Ouvrir le tableau des termes", + command=lambda: self._show_sub("pro"), + fg_color=p["btn_sec_bg"], + hover_color=p["card_border"], + text_color=p["text"], + border_color=p["btn_sec_border"], + border_width=1, + corner_radius=8, + height=30, + font=ui_kit.font(12), + ).pack(fill="x", padx=12, pady=(6, 12)) + + # -- Profils ---------------------------------------------------------- + + def _active_profile_dict(self) -> dict: + try: + from profile_defaults import list_effective_profiles + + key = self._state.profile or default_profile_key() + if not key: + return {} + return list_effective_profiles().get(key, {}) or {} + except Exception: + return {} + + def _active_profile_summary(self): + from gui_v6.profile_view import summarize_profile + + key = self._state.profile or default_profile_key() or "" + return summarize_profile(key, self._active_profile_dict()) + + def _open_terms_table(self) -> None: + from gui_v6.profile_view import profile_term_rows + from gui_v6.terms_table_window import TermsTableWindow + + summary = self._active_profile_summary() + rows = profile_term_rows(self._active_profile_dict()) + TermsTableWindow(self.winfo_toplevel(), self._p, rows, profile_label=summary.label) + + def _rebuild_profils(self) -> None: + panel = self._panels.get("pro") + if panel is None: + return + for child in panel.winfo_children(): + child.destroy() + self._build_profils(panel) + + def _build_profils(self, parent) -> None: + p = self._p + self._section_intro( + parent, + "Un profil regroupe tous les réglages d'anonymisation. Voici le profil actif.", + _HELP_PROFIL, + "Création / modification d'un profil d'anonymisation", + ) + summary = self._active_profile_summary() + + card = ui_kit.Card(parent, p, title=f"👤 {summary.label}") + card.pack(fill="x", pady=(0, 8)) + if summary.description: + self._note(card, summary.description) + grid = ctk.CTkFrame(card, fg_color="transparent") + grid.pack(fill="x", padx=12, pady=(0, 10)) + infos = [ + ("Masque manuel requis", "Oui" if summary.require_manual_mask else "Non"), + ("Template de masque", summary.mask_template or "—"), + ("Moteur VLM (images)", "désactivé" if summary.disable_vlm else "selon réglages"), + ] + for idx, (key, val) in enumerate(infos): + ctk.CTkLabel(grid, text=key, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").grid( + row=idx, column=0, sticky="w", pady=1 + ) + ctk.CTkLabel(grid, text=val, text_color=p["text"], font=ui_kit.font(11, "bold"), anchor="w").grid( + row=idx, column=1, sticky="w", padx=(12, 0), pady=1 + ) + grid.grid_columnconfigure(1, weight=1) + + lists_card = ui_kit.Card(parent, p, title="✅ Listes locales du profil") + lists_card.pack(fill="x", pady=(0, 8)) + chips = ctk.CTkFrame(lists_card, fg_color="transparent") + chips.pack(fill="x", padx=12, pady=(0, 8)) + for label, count in summary.list_counts.items(): + ctk.CTkLabel( + chips, + text=f"{label} : {count}", + text_color=p["text"], + fg_color=p["divider"], + corner_radius=8, + font=ui_kit.font(11, "bold"), + ).pack(side="left", padx=(0, 8), ipadx=8, ipady=3) + ui_kit.primary_button(lists_card, p, "📋 Ouvrir le tableau des termes", command=self._open_terms_table).pack( + anchor="w", padx=12, pady=(0, 12) + ) + + create = ui_kit.Card(parent, p, title="🧩 Créer / modifier un profil") + create.pack(fill="x") + self._note(create, "La création et la modification de profils seront disponibles prochainement.") + self._mockup_button(create, "+ Nouveau profil").pack(anchor="w", padx=12, pady=(0, 12)) # -- Masquage --------------------------------------------------------- @@ -548,6 +691,7 @@ class ConfigTab(ctk.CTkFrame): def _on_profile(self, value: str) -> None: self._state.profile = value + self._rebuild_profils() def _on_ner(self) -> None: self._state.use_local_ner = self._tog_ner.get() diff --git a/gui_v6/terms_table_window.py b/gui_v6/terms_table_window.py new file mode 100644 index 0000000..2a0b69d --- /dev/null +++ b/gui_v6/terms_table_window.py @@ -0,0 +1,114 @@ +"""Fenêtre « Tableau des termes » d'un profil (lisible même avec 50+ termes). + +Table scrollable avec recherche/filtre — colonnes Type / Terme / Source (profil). +Lecture seule pour l'instant : ajouter/supprimer/éditer sont désactivés et +marqués « (à venir) » (l'écriture par profil n'est pas encore câblée). +""" + +from __future__ import annotations + +import tkinter as tk +from typing import Optional + +import customtkinter as ctk + +from gui_v6 import ui_kit +from gui_v6.profile_view import filter_term_rows + +_TYPE_COLORS = { + "À conserver": "success", + "À masquer": "primary", + "À ignorer": "text_muted", +} + + +class TermsTableWindow(ctk.CTkToplevel): + def __init__( + self, + master, + palette: dict, + rows, + *, + title: str = "Tableau des termes", + profile_label: str = "", + ) -> None: + super().__init__(master) + self._palette = palette + self._rows = list(rows) + self._profile_label = profile_label + self._visible = list(self._rows) + + self.title(title) + self.geometry("740x560") + self.minsize(520, 360) + try: + self.configure(fg_color=palette["bg"]) + except Exception: + pass + + self._query = tk.StringVar() + self._count_text = tk.StringVar(value="") + self._build() + self._refresh() + try: + self.transient(master) + self.after(120, lambda: (self.lift(), self.focus_force())) + except Exception: + pass + + # -- coutures testables -------------------------------------------------- + def set_query(self, query: str) -> None: + self._query.set(query) + self._refresh() + + def visible_count(self) -> int: + return len(self._visible) + + def add_is_disabled(self) -> bool: + return str(self._add_btn.cget("state")) == "disabled" + + # -- UI ------------------------------------------------------------------ + def _build(self) -> None: + p = self._palette + head = ctk.CTkFrame(self, fg_color="transparent") + head.pack(fill="x", padx=12, pady=(12, 4)) + title = "Termes du profil" + if self._profile_label: + title += f" « {self._profile_label} »" + ctk.CTkLabel(head, text=title, text_color=p["text"], font=ui_kit.font(15, "bold")).pack(side="left") + + bar = ctk.CTkFrame(self, fg_color="transparent") + bar.pack(fill="x", padx=12, pady=(0, 6)) + ctk.CTkLabel(bar, text="🔎 Rechercher :", text_color=p["text_dim"], font=ui_kit.font(12)).pack(side="left") + entry = ctk.CTkEntry(bar, textvariable=self._query, width=240) + entry.pack(side="left", padx=6) + self._query.trace_add("write", lambda *_: self._refresh()) + self._add_btn = ui_kit.secondary_button(bar, p, "+ Ajouter (à venir)", command=lambda: None) + self._add_btn.configure(state="disabled") + self._add_btn.pack(side="right") + + header = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=6) + header.pack(fill="x", padx=12) + for text, width in [("TYPE", 130), ("TERME", 360), ("SOURCE (PROFIL)", 180)]: + ctk.CTkLabel(header, text=text, width=width, anchor="w", text_color=p["text_muted"], font=ui_kit.font(10, "bold")).pack(side="left", padx=8, pady=4) + + self._table = ctk.CTkScrollableFrame(self, fg_color=p["bg"]) + self._table.pack(fill="both", expand=True, padx=12, pady=(2, 4)) + + ctk.CTkLabel(self, textvariable=self._count_text, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=14, pady=(0, 10)) + + def _refresh(self) -> None: + p = self._palette + self._visible = filter_term_rows(self._rows, self._query.get()) + for child in self._table.winfo_children(): + child.destroy() + if not self._visible: + ctk.CTkLabel(self._table, text="Aucun terme.", text_color=p["text_muted"], font=ui_kit.font(12)).pack(anchor="w", padx=8, pady=8) + for type_label, term, source in self._visible: + row = ctk.CTkFrame(self._table, fg_color="transparent") + row.pack(fill="x", pady=1) + color = p[_TYPE_COLORS.get(type_label, "text")] + ctk.CTkLabel(row, text=type_label, width=130, anchor="w", text_color=color, font=ui_kit.font(11, "bold")).pack(side="left", padx=8) + ctk.CTkLabel(row, text=term, width=360, anchor="w", text_color=p["text"], font=ui_kit.font(12)).pack(side="left", padx=8) + ctk.CTkLabel(row, text=source, width=180, anchor="w", text_color=p["text_muted"], font=ui_kit.font(11)).pack(side="left", padx=8) + self._count_text.set(f"{len(self._visible)} terme(s) affiché(s) sur {len(self._rows)}.") diff --git a/gui_v6/ui_kit.py b/gui_v6/ui_kit.py index cdd2cc0..9b8ee39 100644 --- a/gui_v6/ui_kit.py +++ b/gui_v6/ui_kit.py @@ -7,6 +7,7 @@ Les widgets ne sont créés qu'à l'appel (import sûr pour ``--self-test``). from __future__ import annotations +import tkinter as tk from typing import Optional import customtkinter as ctk @@ -231,3 +232,70 @@ class HelpButton(ctk.CTkButton): def help_button(master, palette: dict, text: str, title: str = "Aide") -> "HelpButton": return HelpButton(master, palette, text, title=title) + + +class Tooltip: + """Infobulle au survol (façon V5 ``ToolTip``), pour les éléments ambigus.""" + + def __init__(self, widget, text: str, delay: int = 450): + self.widget = widget + self.text = text + self.delay = delay + self._tip = None + self._after = None + widget.bind("", self._schedule, add="+") + widget.bind("", self.hide, add="+") + widget.bind("", self.hide, add="+") + + def _schedule(self, *_): + self._cancel() + try: + self._after = self.widget.after(self.delay, self.show) + except Exception: + pass + + def _cancel(self): + if self._after is not None: + try: + self.widget.after_cancel(self._after) + except Exception: + pass + self._after = None + + def show(self, *_): + if self._tip is not None or not self.text: + return self._tip + try: + x = self.widget.winfo_rootx() + 16 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 + except Exception: + return None + self._tip = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + tk.Label( + tw, + text=self.text, + justify="left", + background="#1f2937", + foreground="#f9fafb", + relief="solid", + borderwidth=1, + wraplength=320, + padx=8, + pady=5, + ).pack() + return tw + + def hide(self, *_): + self._cancel() + if self._tip is not None: + try: + self._tip.destroy() + except Exception: + pass + self._tip = None + + +def attach_tooltip(widget, text: str, delay: int = 450) -> "Tooltip": + return Tooltip(widget, text, delay) diff --git a/tests/unit/test_gui_v6_app_shell.py b/tests/unit/test_gui_v6_app_shell.py index c505f79..5fb922d 100644 --- a/tests/unit/test_gui_v6_app_shell.py +++ b/tests/unit/test_gui_v6_app_shell.py @@ -77,6 +77,26 @@ def test_help_button_opens_help_window(app): win.destroy() +def _all_texts(widget) -> list: + 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_beta_label_in_product_identity(app): + """Addendum Dom : indiquer « bêta » à côté du nom produit (en-tête + titre).""" + app.update_idletasks() + assert "bêta" in app.title().lower() or "beta" in app.title().lower() + texts = [t.lower() for t in _all_texts(app)] + assert any("aivanonym" in t for t in texts) + assert any("bêta" in t or "beta" in t for t in texts) + + def _count_help_buttons(widget) -> int: from gui_v6.ui_kit import HelpButton diff --git a/tests/unit/test_gui_v6_config_mockup_sections.py b/tests/unit/test_gui_v6_config_mockup_sections.py index a136ab1..21e3cc0 100644 --- a/tests/unit/test_gui_v6_config_mockup_sections.py +++ b/tests/unit/test_gui_v6_config_mockup_sections.py @@ -8,7 +8,7 @@ from gui_v6.tabs.tab_config import CONFIG_INTERACTION_CONTRACT, CONFIG_MOCKUP_SE def test_config_mockup_sections_cover_admin_surface(): assert CONFIG_MOCKUP_SECTIONS == { "reglages": [ - "Profil métier", + "Profil d'anonymisation", "Moteurs NER", "Données à détecter", "Termes à toujours conserver", diff --git a/tests/unit/test_gui_v6_profiles.py b/tests/unit/test_gui_v6_profiles.py new file mode 100644 index 0000000..44a9a89 --- /dev/null +++ b/tests/unit/test_gui_v6_profiles.py @@ -0,0 +1,160 @@ +"""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_profils(): + 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 _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é + + # 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 sans erreur + tab._open_terms_table() + tab.update_idletasks() + tab.destroy()