From 72841ed7b37ce894e78a3d96e28fd46f04b9e8e8 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Mon, 15 Jun 2026 23:09:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20onglet=20Profils=20=C3=A9ditable?= =?UTF-8?q?=20(cr=C3=A9ation/modification/persistance)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gui_v6/editable_list.py | 101 +++++++++ gui_v6/profile_editor.py | 120 +++++++++++ gui_v6/tabs/tab_config.py | 255 +++++++++++++++++++++++ tests/unit/test_gui_v6_profile_editor.py | 79 +++++++ tests/unit/test_gui_v6_profiles.py | 72 ++++++- 5 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 gui_v6/editable_list.py create mode 100644 gui_v6/profile_editor.py create mode 100644 tests/unit/test_gui_v6_profile_editor.py diff --git a/gui_v6/editable_list.py b/gui_v6/editable_list.py new file mode 100644 index 0000000..02b21c3 --- /dev/null +++ b/gui_v6/editable_list.py @@ -0,0 +1,101 @@ +"""Liste de termes éditable et scrollable (pas de pastilles). + +Utilisée dans l'éditeur de profils pour « mots à masquer / à conserver / à +ignorer ». Reste lisible avec 50+ termes (zone scrollable + ajout/suppression). +""" + +from __future__ import annotations + +import customtkinter as ctk + +from gui_v6 import ui_kit + + +class EditableTermList(ctk.CTkFrame): + def __init__(self, master, palette: dict, *, title: str, initial=None, height: int = 150, **kwargs): + super().__init__(master, fg_color="transparent", **kwargs) + self._palette = palette + self._title = title + self._terms: list[str] = [str(t) for t in (initial or [])] + self._editable = True + self._build(height) + self._render() + + # -- API testable -------------------------------------------------------- + def terms(self) -> list[str]: + return list(self._terms) + + def set_terms(self, terms) -> None: + self._terms = [str(t) for t in (terms or [])] + self._render() + + def set_editable(self, editable: bool) -> None: + self._editable = bool(editable) + state = "normal" if self._editable else "disabled" + self._entry.configure(state=state) + self._add_btn.configure(state=state) + self._render() + + def add_term(self, term: str) -> bool: + term = str(term).strip() + if not term or term in self._terms: + return False + self._terms.append(term) + self._render() + return True + + def remove_term(self, term: str) -> None: + if term in self._terms: + self._terms.remove(term) + self._render() + + # -- UI ------------------------------------------------------------------ + def _build(self, height: int) -> None: + p = self._palette + ctk.CTkLabel(self, text=self._title, text_color=p["text"], font=ui_kit.font(12, "bold"), anchor="w").pack( + fill="x", pady=(0, 2) + ) + row = ctk.CTkFrame(self, fg_color="transparent") + row.pack(fill="x", pady=(0, 4)) + self._entry = ctk.CTkEntry( + row, placeholder_text="Ajouter un terme…", fg_color=p["btn_sec_bg"], + border_color=p["btn_sec_border"], text_color=p["text"], height=28, + ) + self._entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) + self._entry.bind("", lambda _e: self._on_add()) + self._add_btn = ui_kit.secondary_button(row, p, "+ Ajouter", command=self._on_add) + self._add_btn.pack(side="right") + self._list = ctk.CTkScrollableFrame(self, fg_color=p["divider"], height=height) + self._list.pack(fill="both", expand=True) + self._count = ctk.CTkLabel(self, text="", text_color=p["text_muted"], font=ui_kit.font(10), anchor="w") + self._count.pack(fill="x", pady=(2, 0)) + + def _on_add(self) -> None: + if not self._editable: + return + if self.add_term(self._entry.get()): + self._entry.delete(0, "end") + + def _render(self) -> None: + p = self._palette + for child in self._list.winfo_children(): + child.destroy() + if not self._terms: + ctk.CTkLabel(self._list, text="Aucun terme.", text_color=p["text_muted"], font=ui_kit.font(11)).pack( + anchor="w", padx=8, pady=6 + ) + for term in self._terms: + line = ctk.CTkFrame(self._list, fg_color="transparent") + line.pack(fill="x", pady=1) + ctk.CTkLabel(line, text=term, text_color=p["text"], font=ui_kit.font(12), anchor="w").pack( + side="left", fill="x", expand=True, padx=(6, 4) + ) + btn = ctk.CTkButton( + line, text="×", width=26, height=24, corner_radius=6, + fg_color=p["btn_sec_bg"], hover_color=p["card_border"], text_color=p["text"], + command=lambda t=term: self.remove_term(t), + ) + if not self._editable: + btn.configure(state="disabled") + btn.pack(side="right", padx=(0, 4)) + self._count.configure(text=f"{len(self._terms)} terme(s)") diff --git a/gui_v6/profile_editor.py b/gui_v6/profile_editor.py new file mode 100644 index 0000000..b840784 --- /dev/null +++ b/gui_v6/profile_editor.py @@ -0,0 +1,120 @@ +"""Couche logique de l'éditeur de profils (persistance via profile_defaults). + +Wrappers testables sans display : assemblage de la spec, détection +runtime/éditable vs defaut/lecture-seule, sauvegarde dans config/profiles.yml. +Aucun profil par défaut (profiles.default.yml) n'est jamais modifié ici. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Iterable, Optional + +from profile_defaults import ( + delete_runtime_profile, + get_default_profile_key, + list_effective_profiles, + load_runtime_profiles_overlay_dict, + save_runtime_profile, + set_runtime_default_profile, +) + + +def _clean_list(values: Optional[Iterable]) -> list[str]: + return [str(v).strip() for v in (values or []) if str(v).strip()] + + +def build_profile_spec( + *, + label: str, + description: str = "", + require_manual_mask: bool = False, + force_disable_vlm: bool = False, + preferred_manual_mask_template: str = "", + whitelist: Optional[Iterable] = None, + blacklist: Optional[Iterable] = None, + stopwords: Optional[Iterable] = None, +) -> dict[str, Any]: + """Assemble une spec de profil persistable (3 listes normalisées).""" + return { + "label": str(label or "").strip(), + "description": str(description or ""), + "require_manual_mask": bool(require_manual_mask), + "force_disable_vlm": bool(force_disable_vlm), + "preferred_manual_mask_template": str(preferred_manual_mask_template or "").strip(), + "has_preferred_manual_mask_template": True, + "has_param_lists": True, + "param_lists": { + "whitelist_phrases": _clean_list(whitelist), + "blacklist_force_mask_terms": _clean_list(blacklist), + "additional_stopwords": _clean_list(stopwords), + }, + } + + +def runtime_profile_keys(path: Path | None = None) -> set[str]: + """Clés des profils définis dans l'overlay runtime (config/profiles.yml).""" + try: + data = load_runtime_profiles_overlay_dict(path) or {} + except Exception: + return set() + profiles = data.get("profiles") if isinstance(data, dict) else None + return set(profiles) if isinstance(profiles, dict) else set() + + +def profile_is_editable(key: str, path: Path | None = None) -> bool: + """Un profil est éditable s'il est dans l'overlay runtime (pas un defaut pur).""" + return key in runtime_profile_keys(path) + + +def _default_key(path: Path | None = None) -> Optional[str]: + try: + data = load_runtime_profiles_overlay_dict(path) or {} + if isinstance(data, dict) and data.get("default_profile"): + return str(data["default_profile"]) + except Exception: + pass + try: + return get_default_profile_key() + except Exception: + return None + + +def list_profile_choices(path: Path | None = None) -> list[dict]: + """Liste triée des profils avec méta : ``key``, ``label``, ``editable``, ``is_default``.""" + profiles = list_effective_profiles(path) + runtime = runtime_profile_keys(path) + default = _default_key(path) + return [ + { + "key": key, + "label": str(profiles[key].get("label") or key), + "editable": key in runtime, + "is_default": key == default, + } + for key in sorted(profiles) + ] + + +def slug_for_copy(key: str, existing: Iterable[str]) -> str: + """Clé de copie unique : ``{key}_copie`` puis ``_2``, ``_3``…""" + existing = set(existing) + base = f"{key}_copie" + if base not in existing: + return base + index = 2 + while f"{base}_{index}" in existing: + index += 1 + return f"{base}_{index}" + + +def save_profile(key: str, spec: dict, path: Path | None = None, *, set_default: bool = False) -> Path: + return save_runtime_profile(key, spec, path, set_default=set_default) + + +def set_default_profile(key: str, path: Path | None = None) -> Path: + return set_runtime_default_profile(key, path) + + +def delete_profile(key: str, path: Path | None = None) -> Path: + return delete_runtime_profile(key, path) diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index 59b8f62..1d60911 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"), @@ -184,6 +185,12 @@ class ConfigTab(ctk.CTkFrame): # ouverte pour éviter d'en empiler plusieurs. self._mask_editor_window = None + # Éditeur de profils : chemin overlay runtime (None = config/profiles.yml standard, + # surchargeable en test), clé en cours d'édition + widgets. + self._profiles_path = None + self._pro_edit_key: str | None = None + self._pro_term_lists: dict = {} + self._build() @property @@ -221,6 +228,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, @@ -288,6 +296,9 @@ class ConfigTab(ctk.CTkFrame): 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, "✏️ Modifier le profil…", command=self._open_profile_editor).pack( + side="left", padx=(10, 4), 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) @@ -426,6 +437,250 @@ class ConfigTab(ctk.CTkFrame): rows = profile_term_rows(self._active_profile_dict()) TermsTableWindow(self.winfo_toplevel(), self._p, rows, profile_label=summary.label) + # -- Profils (éditeur) ------------------------------------------------ + + def _build_profils(self, parent) -> None: + p = self._p + from gui_v6.editable_list import EditableTermList + + self._section_intro( + parent, + "Un profil regroupe les moteurs, les masques, les règles et les mots à conserver ou masquer.", + _HELP_PROFIL, + "Profils d'anonymisation", + ) + + bar = ui_kit.Card(parent, p, title="👤 Profil à modifier") + bar.pack(fill="x", pady=(0, 8)) + top = ctk.CTkFrame(bar, fg_color="transparent") + top.pack(fill="x", padx=12, pady=(0, 4)) + self._pro_menu_var = ctk.StringVar(value="") + self._pro_menu = ctk.CTkOptionMenu( + top, values=["—"], variable=self._pro_menu_var, command=self._pro_on_select, + fg_color=p["btn_sec_bg"], button_color=p["primary"], button_hover_color=p["primary_dim"], + text_color=p["text"], width=260, height=30, + ) + self._pro_menu.pack(side="left") + self._pro_status = ctk.CTkLabel(top, text="", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w") + self._pro_status.pack(side="left", padx=(10, 0)) + + actions = ctk.CTkFrame(bar, fg_color="transparent") + actions.pack(fill="x", padx=12, pady=(0, 12)) + ui_kit.secondary_button(actions, p, "+ Nouveau", command=self._pro_new).pack(side="left", padx=(0, 6)) + ui_kit.secondary_button(actions, p, "⧉ Dupliquer", command=self._pro_duplicate).pack(side="left", padx=(0, 6)) + self._pro_save_btn = ui_kit.primary_button(actions, p, "💾 Enregistrer", command=self._pro_save) + self._pro_save_btn.pack(side="left", padx=(6, 6)) + ui_kit.secondary_button(actions, p, "↩ Annuler", command=self._pro_cancel).pack(side="left") + self._pro_default_btn = ui_kit.secondary_button(actions, p, "⭐ Définir par défaut", command=self._pro_set_default) + self._pro_default_btn.pack(side="right") + + cols = self._columns(parent, 2, gap=8) + left, right = cols[0], cols[1] + + ident = ui_kit.Card(left, p, title="🏷️ Identité") + ident.pack(fill="x", pady=(0, 8)) + self._pro_label_var = ctk.StringVar() + self._pro_label_entry = ctk.CTkEntry(ident, textvariable=self._pro_label_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30) + ctk.CTkLabel(ident, text="Nom du profil", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2)) + self._pro_label_entry.pack(fill="x", padx=12, pady=(0, 6)) + self._pro_desc_var = ctk.StringVar() + self._pro_desc_entry = ctk.CTkEntry(ident, textvariable=self._pro_desc_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30) + ctk.CTkLabel(ident, text="Description", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2)) + self._pro_desc_entry.pack(fill="x", padx=12, pady=(0, 12)) + + mask = ui_kit.Card(left, p, title="⬛ Masquage") + mask.pack(fill="x", pady=(0, 8)) + self._pro_require_mask_var = ctk.BooleanVar(value=False) + self._pro_require_switch = ctk.CTkSwitch(mask, text="Masque manuel obligatoire", variable=self._pro_require_mask_var, progress_color=p["primary"], text_color=p["text"], font=ui_kit.font(12)) + self._pro_require_switch.pack(anchor="w", padx=12, pady=(0, 6)) + ctk.CTkLabel(mask, text="Template de masque préféré", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2)) + self._pro_template_var = ctk.StringVar() + self._pro_template_entry = ctk.CTkEntry(mask, textvariable=self._pro_template_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30) + self._pro_template_entry.pack(fill="x", padx=12, pady=(0, 12)) + + eng = ui_kit.Card(left, p, title="🧠 Moteurs") + eng.pack(fill="x") + self._pro_disable_vlm_var = ctk.BooleanVar(value=False) + self._pro_vlm_switch = ctk.CTkSwitch(eng, text="Désactiver le moteur VLM (images)", variable=self._pro_disable_vlm_var, progress_color=p["primary"], text_color=p["text"], font=ui_kit.font(12)) + self._pro_vlm_switch.pack(anchor="w", padx=12, pady=(0, 6)) + ctk.CTkLabel(eng, text="CamemBERT-bio (standard) toujours actif ; EDS-Pseudo / GLiNER optionnels.", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w", wraplength=300, justify="left").pack(fill="x", padx=12, pady=(0, 12)) + + words = ui_kit.Card(right, p, title="📝 Mots du profil") + words.pack(fill="both", expand=True) + self._pro_term_lists = { + "blacklist": EditableTermList(words, p, title="À masquer", height=104), + "whitelist": EditableTermList(words, p, title="À conserver", height=104), + "stopwords": EditableTermList(words, p, title="À ignorer", height=88), + } + for tl in self._pro_term_lists.values(): + tl.pack(fill="x", padx=12, pady=(0, 8)) + + rules = ui_kit.Card(parent, p, title="🛡️ Règles du profil") + rules.pack(fill="x", pady=(8, 0)) + self._note(rules, "Les règles embarquées par profil seront éditables prochainement.") + self._mockup_button(rules, "+ Ajouter une règle").pack(anchor="w", padx=12, pady=(0, 12)) + + self._pro_refresh_and_load() + + # -- Profils : logique ----------------------------------------------- + + def _pro_choices(self) -> list: + from gui_v6.profile_editor import list_profile_choices + + return list_profile_choices(self._profiles_path) + + @staticmethod + def _pro_label_for(choice: dict) -> str: + if choice["is_default"]: + return f"{choice['label']} (défaut)" + if not choice["editable"]: + return f"{choice['label']} (lecture seule)" + return choice["label"] + + def _pro_refresh_and_load(self, select_key: str | None = None) -> None: + choices = self._pro_choices() + self._pro_choice_by_label = {self._pro_label_for(c): c for c in choices} + labels = list(self._pro_choice_by_label) or ["—"] + self._pro_menu.configure(values=labels) + target = None + if select_key is not None: + target = next((lbl for lbl, c in self._pro_choice_by_label.items() if c["key"] == select_key), None) + if target is None: + target = self._pro_menu_var.get() if self._pro_menu_var.get() in self._pro_choice_by_label else labels[0] + self._pro_menu_var.set(target) + self._pro_on_select(target) + + def _pro_on_select(self, label: str) -> None: + choice = getattr(self, "_pro_choice_by_label", {}).get(label) + if choice is None: + return + self._pro_load(choice["key"]) + + def _profile_dict_for(self, key: str) -> dict: + from profile_defaults import list_effective_profiles + + try: + return list_effective_profiles(self._profiles_path).get(key, {}) or {} + except Exception: + return {} + + def _pro_load(self, key: str) -> None: + from gui_v6.profile_editor import profile_is_editable + + profile = self._profile_dict_for(key) + self._pro_edit_key = key + self._pro_label_var.set(str(profile.get("label") or key)) + self._pro_desc_var.set(str(profile.get("description") or "")) + self._pro_require_mask_var.set(bool(profile.get("require_manual_mask"))) + self._pro_disable_vlm_var.set(bool(profile.get("force_disable_vlm"))) + self._pro_template_var.set(str(profile.get("preferred_manual_mask_template") or "")) + param_lists = profile.get("param_lists") or {} + self._pro_term_lists["blacklist"].set_terms(param_lists.get("blacklist_force_mask_terms") or []) + self._pro_term_lists["whitelist"].set_terms(param_lists.get("whitelist_phrases") or []) + self._pro_term_lists["stopwords"].set_terms(param_lists.get("additional_stopwords") or []) + editable = profile_is_editable(key, self._profiles_path) + self._pro_set_editable(editable) + self._pro_status.configure( + text="Éditable" if editable else "Profil par défaut — lecture seule (dupliquez pour modifier)" + ) + + def _pro_set_editable(self, editable: bool) -> None: + state = "normal" if editable else "disabled" + for widget in (self._pro_label_entry, self._pro_desc_entry, self._pro_template_entry, + self._pro_require_switch, self._pro_vlm_switch, self._pro_save_btn): + widget.configure(state=state) + for term_list in self._pro_term_lists.values(): + term_list.set_editable(editable) + + def _pro_collect_spec(self) -> dict: + from gui_v6.profile_editor import build_profile_spec + + return build_profile_spec( + label=self._pro_label_var.get(), + description=self._pro_desc_var.get(), + require_manual_mask=bool(self._pro_require_mask_var.get()), + force_disable_vlm=bool(self._pro_disable_vlm_var.get()), + preferred_manual_mask_template=self._pro_template_var.get(), + whitelist=self._pro_term_lists["whitelist"].terms(), + blacklist=self._pro_term_lists["blacklist"].terms(), + stopwords=self._pro_term_lists["stopwords"].terms(), + ) + + def _pro_unique_key(self, base: str) -> str: + from gui_v6.profile_editor import slug_for_copy + + existing = {c["key"] for c in self._pro_choices()} + if base not in existing: + return base + return slug_for_copy(base, existing) + + def _pro_new(self) -> None: + from gui_v6.profile_editor import build_profile_spec, save_profile + + key = self._pro_unique_key("nouveau_profil") + spec = build_profile_spec(label="Nouveau profil") + try: + save_profile(key, spec, path=self._profiles_path) + except Exception as exc: # pragma: no cover + messagebox.showerror("Profils", f"Impossible de créer le profil : {exc}") + return + self._pro_refresh_and_load(select_key=key) + + def _pro_duplicate(self) -> None: + from gui_v6.profile_editor import save_profile, slug_for_copy + + if not self._pro_edit_key: + return + existing = {c["key"] for c in self._pro_choices()} + new_key = slug_for_copy(self._pro_edit_key, existing) + spec = self._pro_collect_spec() + spec["label"] = f"{spec['label']} (copie)" + try: + save_profile(new_key, spec, path=self._profiles_path) + except Exception as exc: # pragma: no cover + messagebox.showerror("Profils", f"Impossible de dupliquer : {exc}") + return + self._pro_refresh_and_load(select_key=new_key) + + def _pro_save(self) -> None: + from gui_v6.profile_editor import save_profile + + key = self._pro_edit_key + if not key: + return + spec = self._pro_collect_spec() + try: + save_profile(key, spec, path=self._profiles_path) + except Exception as exc: # pragma: no cover + messagebox.showerror("Profils", f"Impossible d'enregistrer le profil : {exc}") + return + self._pro_refresh_and_load(select_key=key) + # Confirmation non bloquante (pas de modale qui fige l'app). + self._pro_status.configure(text=f"✓ Profil « {spec['label']} » enregistré.") + + def _pro_cancel(self) -> None: + if self._pro_edit_key: + self._pro_load(self._pro_edit_key) + + def _pro_set_default(self) -> None: + from gui_v6.profile_editor import set_default_profile + + if not self._pro_edit_key: + return + try: + set_default_profile(self._pro_edit_key, path=self._profiles_path) + except Exception as exc: # pragma: no cover + messagebox.showerror("Profils", f"Impossible de définir par défaut : {exc}") + return + self._pro_refresh_and_load(select_key=self._pro_edit_key) + + def _open_profile_editor(self) -> None: + """Ouvre le sous-onglet Profils sur le profil actif (depuis Réglages).""" + self._show_sub("pro") + active = self._state.profile + if active: + self._pro_refresh_and_load(select_key=active) + # -- Masquage --------------------------------------------------------- def _build_masquage(self, parent) -> None: diff --git a/tests/unit/test_gui_v6_profile_editor.py b/tests/unit/test_gui_v6_profile_editor.py new file mode 100644 index 0000000..ab84d37 --- /dev/null +++ b/tests/unit/test_gui_v6_profile_editor.py @@ -0,0 +1,79 @@ +"""Couche logique de l'éditeur de profils (persistance via profile_defaults). + +Tests sans display, avec un fichier profiles.yml temporaire. +""" + +from __future__ import annotations + +from gui_v6.profile_editor import ( + build_profile_spec, + list_profile_choices, + profile_is_editable, + save_profile, + slug_for_copy, +) + + +def test_build_profile_spec_structure_and_strip(): + spec = build_profile_spec( + label=" Mon profil ", + description="desc", + require_manual_mask=True, + force_disable_vlm=False, + preferred_manual_mask_template="config/mask_templates/x.json", + whitelist=[" garder ", "", "garder2"], + blacklist=["CHUXX"], + stopwords=[], + ) + assert spec["label"] == "Mon profil" + assert spec["require_manual_mask"] is True + assert spec["force_disable_vlm"] is False + assert spec["preferred_manual_mask_template"] == "config/mask_templates/x.json" + assert spec["param_lists"]["whitelist_phrases"] == ["garder", "garder2"] # strip + vides retirés + assert spec["param_lists"]["blacklist_force_mask_terms"] == ["CHUXX"] + assert spec["param_lists"]["additional_stopwords"] == [] + + +def test_save_and_reload_roundtrip(tmp_path): + profiles = tmp_path / "profiles.yml" + spec = build_profile_spec( + label="Test runtime", description="d", require_manual_mask=True, + force_disable_vlm=True, preferred_manual_mask_template="", + whitelist=["a"], blacklist=["b", "c"], stopwords=["d"], + ) + save_profile("mon_profil", spec, path=profiles) + + from profile_defaults import list_effective_profiles + effective = list_effective_profiles(profiles) + assert "mon_profil" in effective + saved = effective["mon_profil"] + assert saved["label"] == "Test runtime" + assert saved["require_manual_mask"] is True + assert saved["force_disable_vlm"] is True + assert saved["param_lists"]["blacklist_force_mask_terms"] == ["b", "c"] + + +def test_profile_is_editable_runtime_vs_default(tmp_path): + profiles = tmp_path / "profiles.yml" + save_profile("runtime_one", build_profile_spec(label="R1"), path=profiles) + assert profile_is_editable("runtime_one", path=profiles) is True + # un profil par défaut (non présent dans l'overlay runtime) n'est pas éditable + assert profile_is_editable("standard_local", path=profiles) is False + + +def test_list_profile_choices_marks_editable(tmp_path): + profiles = tmp_path / "profiles.yml" + save_profile("runtime_one", build_profile_spec(label="R1"), path=profiles) + choices = list_profile_choices(path=profiles) + by_key = {c["key"]: c for c in choices} + assert by_key["runtime_one"]["editable"] is True + assert by_key["runtime_one"]["label"] == "R1" + # un profil défaut présent et non éditable + assert "standard_local" in by_key + assert by_key["standard_local"]["editable"] is False + + +def test_slug_for_copy_avoids_collision(): + assert slug_for_copy("std", set()) == "std_copie" + assert slug_for_copy("std", {"std_copie"}) == "std_copie_2" + assert slug_for_copy("std", {"std_copie", "std_copie_2"}) == "std_copie_3" diff --git a/tests/unit/test_gui_v6_profiles.py b/tests/unit/test_gui_v6_profiles.py index 10982da..ece7329 100644 --- a/tests/unit/test_gui_v6_profiles.py +++ b/tests/unit/test_gui_v6_profiles.py @@ -118,14 +118,78 @@ def test_attach_tooltip_does_not_break_widget(ctk_root): assert lbl.winfo_exists() -def test_subtabs_no_profils_subtab(): - """Retour Dom : le sous-onglet Profils (doublon non câblé) est retiré.""" +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" not in keys - assert not any("Profils" in lbl for lbl in labels) + 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):