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:
2026-06-15 23:09:01 +02:00
parent 1bbe70a911
commit 72841ed7b3
5 changed files with 623 additions and 4 deletions

101
gui_v6/editable_list.py Normal file
View 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
View 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)

View File

@@ -22,6 +22,7 @@ from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_
_SUBTABS = [ _SUBTABS = [
("reg", "⚙️ Réglages"), ("reg", "⚙️ Réglages"),
("pro", "👤 Profils"),
("msk", "🎭 Masquage"), ("msk", "🎭 Masquage"),
("shr", "🔄 Partage"), ("shr", "🔄 Partage"),
("rul", "🛡️ Règles"), ("rul", "🛡️ Règles"),
@@ -184,6 +185,12 @@ class ConfigTab(ctk.CTkFrame):
# ouverte pour éviter d'en empiler plusieurs. # ouverte pour éviter d'en empiler plusieurs.
self._mask_editor_window = None 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() self._build()
@property @property
@@ -221,6 +228,7 @@ class ConfigTab(ctk.CTkFrame):
builders = { builders = {
"reg": self._build_reglages, "reg": self._build_reglages,
"pro": self._build_profils,
"msk": self._build_masquage, "msk": self._build_masquage,
"shr": self._build_partage, "shr": self._build_partage,
"rul": self._build_regles, "rul": self._build_regles,
@@ -288,6 +296,9 @@ class ConfigTab(ctk.CTkFrame):
self._profile_menu.set(current) self._profile_menu.set(current)
self._profile_menu.pack(side="left", pady=10) 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.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 = ui_kit.secondary_button(top, p, "📁 Dossier de sortie…", command=self._pick_output)
sortie.pack(side="left", padx=(6, 6), pady=10) 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()) rows = profile_term_rows(self._active_profile_dict())
TermsTableWindow(self.winfo_toplevel(), self._p, rows, profile_label=summary.label) 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 --------------------------------------------------------- # -- Masquage ---------------------------------------------------------
def _build_masquage(self, parent) -> None: def _build_masquage(self, parent) -> None:

View 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"

View File

@@ -118,14 +118,78 @@ def test_attach_tooltip_does_not_break_widget(ctk_root):
assert lbl.winfo_exists() assert lbl.winfo_exists()
def test_subtabs_no_profils_subtab(): def test_subtabs_include_editable_profils():
"""Retour Dom : le sous-onglet Profils (doublon non câblé) est retiré.""" """Retour Dom : sous-onglet Profils réintroduit (éditeur)."""
from gui_v6.tabs.tab_config import _SUBTABS from gui_v6.tabs.tab_config import _SUBTABS
keys = [k for k, _ in _SUBTABS] keys = [k for k, _ in _SUBTABS]
labels = [lbl for _, lbl in _SUBTABS] labels = [lbl for _, lbl in _SUBTABS]
assert "pro" not in keys assert "pro" in keys
assert not any("Profils" in lbl for lbl in labels) 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): def _all_texts(widget):