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>
This commit is contained in:
101
gui_v6/editable_list.py
Normal file
101
gui_v6/editable_list.py
Normal file
@@ -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("<Return>", 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)")
|
||||
120
gui_v6/profile_editor.py
Normal file
120
gui_v6/profile_editor.py
Normal file
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
79
tests/unit/test_gui_v6_profile_editor.py
Normal file
79
tests/unit/test_gui_v6_profile_editor.py
Normal file
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user