From 60fb41c2e7de0c069a189307afe3c2780e43eca5 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Wed, 17 Jun 2026 18:01:25 +0200 Subject: [PATCH] fix(gui): clarifier aide et disponibilite moteurs Passe theme clair, libelles utilisateur, aides conteneurs, recherche de mise a jour et indication honnete des moteurs optionnels non embarques. Tests GUI unitaires: 126 passed. --- gui_v6/license_client.py | 18 +++ gui_v6/tabs/tab_about.py | 29 +++- gui_v6/tabs/tab_config.py | 146 ++++++++++++++---- gui_v6/theme.py | 2 +- gui_v6/ui_kit.py | 23 ++- tests/unit/test_gui_v6_app_shell.py | 21 +++ .../test_gui_v6_config_mockup_sections.py | 22 ++- tests/unit/test_gui_v6_engine_bridge.py | 18 ++- tests/unit/test_gui_v6_license_client.py | 33 +++- tests/unit/test_gui_v6_profiles.py | 16 ++ tests/unit/test_gui_v6_theme.py | 12 +- 11 files changed, 296 insertions(+), 44 deletions(-) diff --git a/gui_v6/license_client.py b/gui_v6/license_client.py index 594b4d2..1f2c4a8 100644 --- a/gui_v6/license_client.py +++ b/gui_v6/license_client.py @@ -4,6 +4,7 @@ Contrat final aligné sur le portail ``app_aivanov`` : - ``activate(token, machine_id)`` → ``POST /api/v1/activate`` - ``check(license_ref, machine_id)`` → ``POST /api/v1/check`` +- ``latest_version()`` → ``GET /api/v1/version`` Principes : @@ -44,6 +45,8 @@ class _HttpResponse(Protocol): class _HttpSession(Protocol): def post(self, url: str, json: dict, timeout: float) -> _HttpResponse: ... + def get(self, url: str, timeout: float) -> _HttpResponse: ... + @dataclass class LicenseStatus: @@ -138,6 +141,13 @@ class LicenseClient: # Réseau indisponible, DNS, timeout, requests absent… : pas de crash. return None + def _get(self, endpoint: str) -> Optional[_HttpResponse]: + try: + session = self._get_session() + return session.get(f"{self._base_url}{endpoint}", timeout=self._timeout) + except Exception: + return None + @staticmethod def _parse(response: Optional[_HttpResponse]) -> Optional[dict]: if response is None: @@ -187,6 +197,14 @@ class LicenseClient: self._store.save(payload) return status + def latest_version(self) -> Optional[dict[str, Any]]: + """Retourne les métadonnées de la version active publiée sur le portail.""" + response = self._get("/api/v1/version") + if response is None or getattr(response, "status_code", 500) >= 400: + return None + payload = self._parse(response) + return payload if payload is not None else None + def local_status(self) -> LicenseStatus: """État de licence depuis le stockage local, sans appel réseau.""" data = self._store.load() diff --git a/gui_v6/tabs/tab_about.py b/gui_v6/tabs/tab_about.py index f55fb35..f69f9f4 100644 --- a/gui_v6/tabs/tab_about.py +++ b/gui_v6/tabs/tab_about.py @@ -7,6 +7,7 @@ démarrage (seul ``local_status`` est lu). from __future__ import annotations +from tkinter import messagebox from typing import Optional import customtkinter as ctk @@ -35,6 +36,11 @@ _STATUS_LABELS = { "none": "Aucune licence", } +_UPDATE_CHECK_MESSAGE = ( + "Aucune version publiée n'a été trouvée sur le portail.\n\n" + "Vérifiez que le serveur web est joignable ou contactez votre administrateur." +) + def _build_info() -> str: try: @@ -86,9 +92,9 @@ class AboutTab(ctk.CTkFrame): items = [ ("🏷️", "Version", f"Interface V6 — {GUI_VERSION}"), ("📅", "Build", _build_info()), - ("🧠", "Moteurs NER", "CamemBERT · EDS-Pseudo · GLiNER"), + ("🧠", "Moteurs NER", "CamemBERT inclus · EDS/GLiNER optionnels non embarqués"), ("🔒", "Traitement", "100 % local — aucune donnée transmise"), - ("📚", "Gazetteers", "INSEE 219K · FINESS 108K · BDPM 7K"), + ("📚", "Bases de données", "INSEE 219K · FINESS 108K · BDPM 7K"), ("📁", "Formats", "PDF · DOCX · ODT · RTF · TXT · Images"), ("🖥️", "Poste", self._machine_id), ] @@ -102,6 +108,9 @@ class AboutTab(ctk.CTkFrame): txt.pack(side="left") ctk.CTkLabel(txt, text=key.upper(), text_color=p["text_muted"], font=ui_kit.font(10), anchor="w").pack(anchor="w") ctk.CTkLabel(txt, text=val, text_color=p["text"], font=ui_kit.font(12, "bold"), anchor="w").pack(anchor="w") + ui_kit.secondary_button(info, p, "🔄 Rechercher une mise à jour", command=self._check_updates).pack( + anchor="w", padx=16, pady=(0, 14) + ) # Bloc licence lic = ui_kit.Card(self, p, title="🔑 Licence") @@ -151,3 +160,19 @@ class AboutTab(ctk.CTkFrame): self.set_status(LicenseStatus.none("Aucune licence à vérifier")) return self.set_status(self._client.check(ref, self._machine_id)) + + def _check_updates(self) -> None: + if self._client is None: + messagebox.showinfo("Mise à jour", _UPDATE_CHECK_MESSAGE) + return + payload = self._client.latest_version() + if not payload: + messagebox.showinfo("Mise à jour", _UPDATE_CHECK_MESSAGE) + return + version = str(payload.get("version") or "version inconnue") + channel = str(payload.get("channel") or "canal non précisé") + filename = str(payload.get("filename") or "fichier non précisé") + messagebox.showinfo( + "Mise à jour", + f"Version publiée : {version}\nCanal : {channel}\nFichier : {filename}", + ) diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index a8d484d..cdec500 100644 --- a/gui_v6/tabs/tab_config.py +++ b/gui_v6/tabs/tab_config.py @@ -9,7 +9,6 @@ Partage/Règles. from __future__ import annotations import sys -import webbrowser from pathlib import Path from tkinter import filedialog, messagebox @@ -28,7 +27,7 @@ _SUBTABS = [ ] _DETECTION_OPTIONS = [ - ("Noms et prénoms", "Gazetteers + IA"), + ("Noms et prénoms", "Annuaire + IA"), ("Dates de naissance", "Contexte naissance"), ("Établissements", "FINESS + contexte"), ("Adresses / CP", "Voie, ville, code"), @@ -47,6 +46,10 @@ _MASK_COLORS = [ MANUAL_MASK_NONE_LABEL = "Aucun masque manuel" +MINI_TOGGLE_HEIGHT = 46 +MINI_TOGGLE_LABEL_FONT_SIZE = 12 +MINI_TOGGLE_HINT_FONT_SIZE = 11 + # Textes d'aide « ? » (français simple, pour utilisateurs non informaticiens). _HELP_REGLAGES = ( "Réglages de l'anonymisation.\n\n" @@ -95,6 +98,57 @@ _HELP_LISTES = ( "Pour une liste longue, ouvrez le tableau des termes : " "il reste lisible et permet la recherche." ) +_HELP_DONNEES_DETECTER = ( + "Cette zone indique les familles de données que le profil cherche à anonymiser : " + "noms, dates de naissance, établissements, adresses, identifiants, téléphones et e-mails.\n\n" + "Ces options décrivent le périmètre fonctionnel attendu. Les règles exactes restent " + "contrôlées par le moteur et par le profil actif." +) +_HELP_MOTEURS_MASQUES = ( + "Cette zone regroupe les moteurs de détection et les réglages de masque manuel.\n\n" + "Le masque manuel sert aux zones fixes d'un document que le texte ne suffit pas à détecter " + "correctement : logo, en-tête, coordonnées, bloc institutionnel ou tampon scanné.\n\n" + "Si « Masque manuel obligatoire » est actif, le profil impose cette étape de contrôle " + "avant de considérer le traitement complet." +) +_HELP_PROFIL_CHOIX = ( + "Choisissez ici le profil à modifier.\n\n" + "Les profils livrés par défaut sont en lecture seule pour éviter une modification accidentelle. " + "Dupliquez un profil pour créer une version adaptée à votre établissement." +) +_HELP_PROFIL_IDENTITE = ( + "Nom et description visibles dans l'interface.\n\n" + "Utilisez un nom simple que les utilisateurs comprendront, par exemple « Standard local », " + "« Recherche » ou « Diffusion externe prudente »." +) +_HELP_PROFIL_MASQUAGE = ( + "Cette zone règle les masques propres au profil.\n\n" + "Masquage manuel obligatoire : le profil impose une vérification avec un masque de zones fixes " + "avant le traitement. C'est utile pour les documents qui ont toujours les mêmes zones sensibles " + "au même endroit : logos, en-têtes, coordonnées, tampons ou blocs scannés.\n\n" + "Template de masque préféré : modèle proposé automatiquement par ce profil. " + "L'éditeur de masque permet de créer ou ajuster ces zones visuellement." +) +_HELP_PROFIL_MOTEURS = ( + "Cette zone précise les moteurs utilisés par le profil.\n\n" + "CamemBERT-bio est le moteur standard. Les moteurs optionnels ne sont proposés que s'ils sont " + "réellement embarqués dans cette version. Le moteur VLM concerne surtout les documents images." +) +_HELP_PROFIL_MOTS = ( + "Ces listes appartiennent au profil.\n\n" + "À masquer : termes à remplacer systématiquement.\n" + "À conserver : termes à ne jamais masquer, même s'ils ressemblent à des noms.\n" + "À ignorer : mots qui ne doivent pas déclencher de détection.\n\n" + "Pour de longues listes, utilisez le tableau des termes afin de rechercher et vérifier plus facilement." +) +_HELP_EXPORT_CONFIG = ( + "Exporte uniquement les réglages de l'application : profils, listes locales, règles et style de masque.\n\n" + "Les documents patients, résultats d'anonymisation et audits ne sont pas exportés." +) +_HELP_IMPORT_CONFIG = ( + "Importe des réglages reçus d'un administrateur ou d'un autre poste.\n\n" + "L'import ne lit pas de documents patients. Vérifiez toujours le profil actif après import." +) CONFIG_MOCKUP_SECTIONS = { "reglages": [ @@ -293,12 +347,18 @@ class ConfigTab(ctk.CTkFrame): cols = self._columns(parent, 3, gap=8, height=455) - det = ui_kit.Card(cols[0], p, title="🔍 Données à détecter") + det = ui_kit.Card( + cols[0], p, title="🔍 Données à détecter", + help_text=_HELP_DONNEES_DETECTER, help_title="Données à détecter", + ) det.pack(fill="both", expand=True) for label, hint in _DETECTION_OPTIONS: self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1) - ner = ui_kit.Card(cols[1], p, title="🧠 Moteurs et masques") + ner = ui_kit.Card( + cols[1], p, title="🧠 Moteurs et masques", + help_text=_HELP_MOTEURS_MASQUES, help_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)) @@ -359,13 +419,13 @@ class ConfigTab(ctk.CTkFrame): mask_actions = ctk.CTkFrame(ner, fg_color="transparent") mask_actions.pack(fill="x", padx=12, pady=(0, 12)) ui_kit.secondary_button(mask_actions, p, "🔄 Actualiser", command=self._refresh_manual_mask_templates).pack( - side="left", fill="x", expand=True, padx=(0, 4) - ) - ui_kit.secondary_button(mask_actions, p, "📁 Dossier", command=self._open_templates_dir).pack( - side="left", fill="x", expand=True, padx=(4, 0) + side="left", fill="x", expand=True ) - terms = ui_kit.Card(cols[2], p, title="✅ Listes locales") + terms = ui_kit.Card( + cols[2], p, title="✅ Listes locales", + help_text=_HELP_LISTES, help_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)) @@ -438,7 +498,10 @@ class ConfigTab(ctk.CTkFrame): "Profils d'anonymisation", ) - bar = ui_kit.Card(parent, p, title="👤 Profil à modifier") + bar = ui_kit.Card( + parent, p, title="👤 Profil à modifier", + help_text=_HELP_PROFIL_CHOIX, help_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)) @@ -465,7 +528,10 @@ class ConfigTab(ctk.CTkFrame): cols = self._columns(parent, 2, gap=8) left, right = cols[0], cols[1] - ident = ui_kit.Card(left, p, title="🏷️ Identité") + ident = ui_kit.Card( + left, p, title="🏷️ Identité", + help_text=_HELP_PROFIL_IDENTITE, help_title="Identité du profil", + ) 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) @@ -476,7 +542,10 @@ class ConfigTab(ctk.CTkFrame): 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 = ui_kit.Card( + left, p, title="⬛ Masquage", + help_text=_HELP_PROFIL_MASQUAGE, help_title="Masquage manuel", + ) 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)) @@ -488,7 +557,6 @@ class ConfigTab(ctk.CTkFrame): mask_actions = ctk.CTkFrame(mask, fg_color="transparent") mask_actions.pack(fill="x", padx=12, pady=(0, 6)) ui_kit.secondary_button(mask_actions, p, "🖊 Ouvrir l'éditeur de masque", command=self._open_full_mask_editor).pack(side="left") - ui_kit.secondary_button(mask_actions, p, "📁 Dossier", command=self._open_templates_dir).pack(side="left", padx=(6, 0)) # Apparence du masque (couleur / style / marges) — réglage global appliqué aux PDF. ctk.CTkLabel(mask, text="Apparence du masque", text_color=p["text_muted"], font=ui_kit.font(11, "bold"), anchor="w").pack(fill="x", padx=12, pady=(6, 2)) @@ -518,7 +586,10 @@ class ConfigTab(ctk.CTkFrame): variable=self._mask_rounded_var, command=self._on_rounded_corners, ).pack(fill="x", padx=12, pady=(2, 12)) - eng = ui_kit.Card(left, p, title="🧠 Moteurs") + eng = ui_kit.Card( + left, p, title="🧠 Moteurs", + help_text=_HELP_PROFIL_MOTEURS, help_title="Moteurs du profil", + ) 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)) @@ -532,7 +603,10 @@ class ConfigTab(ctk.CTkFrame): moteurs_note = "CamemBERT-bio (standard) toujours actif ; EDS-Pseudo / GLiNER non embarqués dans cette version." ctk.CTkLabel(eng, text=moteurs_note, 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 = ui_kit.Card( + right, p, title="📝 Mots du profil", + help_text=_HELP_PROFIL_MOTS, help_title="Mots du profil", + ) words.pack(fill="both", expand=True) self._pro_term_lists = { "blacklist": EditableTermList(words, p, title="À masquer", height=104), @@ -542,7 +616,10 @@ class ConfigTab(ctk.CTkFrame): 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 = ui_kit.Card( + parent, p, title="🛡️ Règles du profil", + help_text=_HELP_REGLES, help_title="Règles du profil", + ) rules.pack(fill="x", pady=(8, 0)) rules_intro = ctk.CTkFrame(rules, fg_color="transparent") rules_intro.pack(fill="x", padx=12, pady=(0, 2)) @@ -740,12 +817,18 @@ class ConfigTab(ctk.CTkFrame): "À quoi sert le Partage ?", ) cols = self._columns(parent, 2, gap=8, height=180) - export = ui_kit.Card(cols[0], p, title="📤 Exporter la configuration") + export = ui_kit.Card( + cols[0], p, title="📤 Exporter la configuration", + help_text=_HELP_EXPORT_CONFIG, help_title="Exporter la configuration", + ) export.pack(fill="both", expand=True) self._note(export, "Listes locales, règles admin, style de masquage et template actif.") self._mockup_button(export, "⬇ Exporter (.json)").pack(anchor="w", padx=12, pady=(0, 12)) - import_card = ui_kit.Card(cols[1], p, title="📥 Importer une configuration") + import_card = ui_kit.Card( + cols[1], p, title="📥 Importer une configuration", + help_text=_HELP_IMPORT_CONFIG, help_title="Importer une configuration", + ) import_card.pack(fill="both", expand=True) self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.") self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12)) @@ -820,10 +903,7 @@ class ConfigTab(ctk.CTkFrame): def _open_templates_dir(self) -> None: path = ensure_mask_templates_dir(_app_base_dir()) - try: - webbrowser.open(path.as_uri()) - except Exception: - messagebox.showinfo("Dossier modèles", str(path)) + messagebox.showinfo("Dossier modèles", str(path)) # -- callbacks masquage ---------------------------------------------- @@ -962,15 +1042,27 @@ class ConfigTab(ctk.CTkFrame): def _mini_toggle(self, parent, label: str, hint: str, value: bool = True, variable=None, command=None, disabled: bool = False, disabled_hint: str | None = None): p = self._p - row = ctk.CTkFrame(parent, fg_color="transparent", height=34) + row = ctk.CTkFrame(parent, fg_color="transparent", height=MINI_TOGGLE_HEIGHT) row.pack_propagate(False) left = ctk.CTkFrame(row, fg_color="transparent") - left.pack(side="left", fill="x", expand=True) + left.pack(side="left", fill="both", expand=True, pady=(3, 2)) lbl_color = p["text_muted"] if disabled else p["text"] - ctk.CTkLabel(left, text=label, text_color=lbl_color, font=ui_kit.font(12), anchor="w").pack(anchor="w") + ctk.CTkLabel( + left, + text=label, + text_color=lbl_color, + font=ui_kit.font(MINI_TOGGLE_LABEL_FONT_SIZE, "bold"), + anchor="w", + ).pack(anchor="w") shown_hint = disabled_hint if (disabled and disabled_hint) else hint if shown_hint: - ctk.CTkLabel(left, text=shown_hint, text_color=p["text_muted"], font=ui_kit.font(10), anchor="w").pack(anchor="w") + ctk.CTkLabel( + left, + text=shown_hint, + text_color=p["text_muted"] if disabled else p["text_dim"], + font=ui_kit.font(MINI_TOGGLE_HINT_FONT_SIZE), + anchor="w", + ).pack(anchor="w", pady=(1, 0)) # Moteur indisponible : on force l'état à False (jamais « coché mais absent »). if disabled and variable is None: value = False @@ -980,7 +1072,7 @@ class ConfigTab(ctk.CTkFrame): switch = ctk.CTkSwitch(row, text="", variable=var, command=command, progress_color=p["primary"], width=38) if disabled: switch.configure(state="disabled") - switch.pack(side="right", padx=(6, 0)) + switch.pack(side="right", padx=(8, 0), pady=(8, 0)) row.var = var # type: ignore[attr-defined] row.switch = switch # type: ignore[attr-defined] row.get = lambda: bool(var.get()) # type: ignore[attr-defined] diff --git a/gui_v6/theme.py b/gui_v6/theme.py index 1764fc0..bd17318 100644 --- a/gui_v6/theme.py +++ b/gui_v6/theme.py @@ -96,7 +96,7 @@ PALETTES: Dict[str, dict] = { }, } -DEFAULT_THEME = "sombre" +DEFAULT_THEME = "clair" THEME_LABELS = { "sombre": "🌙 Sombre", diff --git a/gui_v6/ui_kit.py b/gui_v6/ui_kit.py index 9b8ee39..708f603 100644 --- a/gui_v6/ui_kit.py +++ b/gui_v6/ui_kit.py @@ -22,7 +22,15 @@ def font(size: int = 13, weight: str = "normal") -> "ctk.CTkFont": class Card(ctk.CTkFrame): """Carte maquette : fond `card`, bordure `card_border`, titre uppercase optionnel.""" - def __init__(self, master, palette: dict, title: Optional[str] = None, **kwargs): + def __init__( + self, + master, + palette: dict, + title: Optional[str] = None, + help_text: Optional[str] = None, + help_title: Optional[str] = None, + **kwargs, + ): super().__init__( master, fg_color=palette["card"], @@ -34,13 +42,22 @@ class Card(ctk.CTkFrame): self._palette = palette self.body = self # alias pour clarté if title: + header = ctk.CTkFrame(self, fg_color="transparent") + header.pack(fill="x", padx=16, pady=(14, 8)) ctk.CTkLabel( - self, + header, text=title.upper(), text_color=palette["text_dim"], font=font(11, "bold"), anchor="w", - ).pack(anchor="w", padx=16, pady=(14, 8)) + ).pack(side="left", fill="x", expand=True) + if help_text: + HelpButton( + header, + palette, + help_text, + title=help_title or title.lstrip("⚙️👤🏷️⬛🧠📝🛡️🔍✅📤📥 ").strip() or "Aide", + ).pack(side="right", padx=(8, 0)) def primary_button(master, palette: dict, text: str, command=None, large: bool = False): diff --git a/tests/unit/test_gui_v6_app_shell.py b/tests/unit/test_gui_v6_app_shell.py index 4e0d59d..1ac1a83 100644 --- a/tests/unit/test_gui_v6_app_shell.py +++ b/tests/unit/test_gui_v6_app_shell.py @@ -46,6 +46,7 @@ def test_usage_tab_survives_theme_change(app): def test_main_tab_renamed_to_administration(): """Retour Dom #2 : l'onglet principal Configuration devient Administration.""" + pytest.importorskip("customtkinter") from gui_v6.app import _TABS labels = [label for _, label in _TABS] @@ -56,6 +57,7 @@ def test_main_tab_renamed_to_administration(): def test_no_separate_rules_subtab(): """Retour Dom : les règles appartiennent au profil → plus de sous-onglet « Règles » séparé (et donc plus de « Règles 2 » incompréhensible).""" + pytest.importorskip("customtkinter") from gui_v6.tabs.tab_config import _SUBTABS keys = [key for key, _ in _SUBTABS] @@ -99,6 +101,25 @@ def test_beta_label_in_product_identity(app): assert any("bêta" in t or "beta" in t for t in texts) +def test_default_theme_is_light(): + """Retour Dom : le thème clair est le thème par défaut de la GUI.""" + from gui_v6 import theme as theme_mod + + assert theme_mod.DEFAULT_THEME == "clair" + + +def test_about_uses_user_facing_database_label(app): + """Retour Dom : éviter le terme technique anglais « Gazetteers » dans À propos.""" + app._show("about") + app.update_idletasks() + + texts = _all_texts(app._tab_frames["about"]) + joined = " | ".join(texts) + assert "bases de données" in joined.lower() + assert "Gazetteers" not in joined + assert "Rechercher une mise à jour" in joined + + 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 21e3cc0..8a972c1 100644 --- a/tests/unit/test_gui_v6_config_mockup_sections.py +++ b/tests/unit/test_gui_v6_config_mockup_sections.py @@ -2,7 +2,18 @@ from __future__ import annotations -from gui_v6.tabs.tab_config import CONFIG_INTERACTION_CONTRACT, CONFIG_MOCKUP_SECTIONS +import pytest + +pytest.importorskip("customtkinter") + +from gui_v6.tabs.tab_config import ( + CONFIG_INTERACTION_CONTRACT, + CONFIG_MOCKUP_SECTIONS, + MINI_TOGGLE_HEIGHT, + MINI_TOGGLE_HINT_FONT_SIZE, + MINI_TOGGLE_LABEL_FONT_SIZE, + _DETECTION_OPTIONS, +) def test_config_mockup_sections_cover_admin_surface(): @@ -42,3 +53,12 @@ def test_config_interaction_contract_prebuilds_panels_and_mask_editor(): "clear_page", "apply_template_selection", ] + + +def test_detection_rows_are_readable_in_light_theme(): + """Retour Dom : les sous-labels de la colonne détection doivent rester lisibles.""" + assert ("Noms et prénoms", "Annuaire + IA") in _DETECTION_OPTIONS + assert ("Noms et prénoms", "Bases de données + IA") not in _DETECTION_OPTIONS + assert MINI_TOGGLE_HEIGHT >= 44 + assert MINI_TOGGLE_LABEL_FONT_SIZE >= 12 + assert MINI_TOGGLE_HINT_FONT_SIZE >= 11 diff --git a/tests/unit/test_gui_v6_engine_bridge.py b/tests/unit/test_gui_v6_engine_bridge.py index 0e84540..e279567 100644 --- a/tests/unit/test_gui_v6_engine_bridge.py +++ b/tests/unit/test_gui_v6_engine_bridge.py @@ -57,7 +57,11 @@ def test_kwargs_defaults_v5_like(): def test_kwargs_with_loaded_managers(): settings = EngineSettings(enable_eds=True, enable_gliner=True) counter = {"camembert": 0, "eds": 0, "gliner": 0} - managers = NerManagers(settings, factories=_counting_factories(counter)) + managers = NerManagers( + settings, + factories=_counting_factories(counter), + caps_provider=_caps_provider(eds_ok=True, gliner_ok=True), + ) managers.ensure_loaded() kwargs = build_engine_kwargs(settings, managers) assert kwargs["use_hf"] is True @@ -89,7 +93,11 @@ def test_managers_not_loaded_on_init(): def test_managers_load_once_and_state(): settings = EngineSettings(enable_eds=True) counter = {"camembert": 0, "eds": 0, "gliner": 0} - managers = NerManagers(settings, factories=_counting_factories(counter)) + managers = NerManagers( + settings, + factories=_counting_factories(counter), + caps_provider=_caps_provider(eds_ok=True, gliner_ok=True), + ) assert managers.state == ManagerState.NOT_LOADED assert managers.ensure_loaded() == ManagerState.READY assert managers.ensure_loaded() == ManagerState.READY # idempotent @@ -121,7 +129,11 @@ def test_optional_manager_failure_is_tolerated(): return {"camembert": camembert, "eds": eds, "gliner": gliner} - managers = NerManagers(settings, factories=factories()) + managers = NerManagers( + settings, + factories=factories(), + caps_provider=_caps_provider(eds_ok=True, gliner_ok=True), + ) assert managers.ensure_loaded() == ManagerState.READY # gliner ko ne bloque pas assert managers.use_hf is True diff --git a/tests/unit/test_gui_v6_license_client.py b/tests/unit/test_gui_v6_license_client.py index a37413c..b2892b7 100644 --- a/tests/unit/test_gui_v6_license_client.py +++ b/tests/unit/test_gui_v6_license_client.py @@ -28,9 +28,11 @@ class FakeResponse: class FakeSession: """Session HTTP mockable : enregistre les appels, renvoie des réponses scriptées.""" - def __init__(self, response=None, exc=None): + def __init__(self, response=None, exc=None, get_response=None, get_exc=None): self._response = response self._exc = exc + self._get_response = response if get_response is None else get_response + self._get_exc = exc if get_exc is None else get_exc self.calls = [] def post(self, url, json, timeout): @@ -39,6 +41,12 @@ class FakeSession: raise self._exc return self._response + def get(self, url, timeout): + self.calls.append({"url": url, "timeout": timeout}) + if self._get_exc is not None: + raise self._get_exc + return self._get_response + def _client(tmp_path, session): store = LicenseStore(tmp_path / "license.json") @@ -173,6 +181,29 @@ def test_local_status_reads_store(tmp_path): assert status.license_ref == "LIC-7" +def test_latest_version_reads_active_artifact(tmp_path): + payload = { + "version": "v11.0-beta", + "channel": "beta", + "filename": "Anonymisation-Setup.exe", + } + session = FakeSession(get_response=FakeResponse(200, payload)) + client, _ = _client(tmp_path, session) + + version = client.latest_version() + + assert version == payload + assert session.calls[0]["url"] == "https://portail.example/api/v1/version" + + +def test_latest_version_unavailable_on_404_or_network_error(tmp_path): + client_404, _ = _client(tmp_path, FakeSession(get_response=FakeResponse(404, {"detail": "No active version"}))) + assert client_404.latest_version() is None + + client_down, _ = _client(tmp_path, FakeSession(get_exc=TimeoutError("timeout"))) + assert client_down.latest_version() is None + + def test_status_never_exposes_token(): # Le statut ne porte pas de token : la repr ne peut pas le fuiter. status = LicenseStatus.from_payload({"status": "active", "license_ref": "LIC-1"}) diff --git a/tests/unit/test_gui_v6_profiles.py b/tests/unit/test_gui_v6_profiles.py index 6083942..b888d4b 100644 --- a/tests/unit/test_gui_v6_profiles.py +++ b/tests/unit/test_gui_v6_profiles.py @@ -120,6 +120,7 @@ def test_attach_tooltip_does_not_break_widget(ctk_root): def test_subtabs_include_editable_profils(): """Retour Dom : sous-onglet Profils réintroduit (éditeur).""" + pytest.importorskip("customtkinter") from gui_v6.tabs.tab_config import _SUBTABS keys = [k for k, _ in _SUBTABS] @@ -297,6 +298,21 @@ def test_regles_moved_into_profils(ctk_root, tmp_path, monkeypatch): tab.destroy() +def test_profile_masking_does_not_expose_templates_folder_button(ctk_root, tmp_path, monkeypatch): + """Retour Dom : le bouton Dossier ouvrait un navigateur et n'aide pas l'utilisateur.""" + from gui_v6.tabs import tab_config + + monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path) + tab = tab_config.ConfigTab(ctk_root) + tab._show_sub("pro") + tab.update_idletasks() + + texts = _all_texts(tab._panels["pro"]) + assert "🖊 Ouvrir l'éditeur de masque" in texts + assert "📁 Dossier" not in texts + tab.destroy() + + def test_unavailable_engines_disabled_in_reglages(ctk_root, tmp_path, monkeypatch): """Honnêteté moteurs : EDS-Pseudo / GLiNER non embarqués → switch désactivé et état forcé à False ; CamemBERT-bio reste actif.""" diff --git a/tests/unit/test_gui_v6_theme.py b/tests/unit/test_gui_v6_theme.py index 2aa5a93..bcb50d3 100644 --- a/tests/unit/test_gui_v6_theme.py +++ b/tests/unit/test_gui_v6_theme.py @@ -15,7 +15,7 @@ _REQUIRED_TOKENS = { def test_four_themes_present(): assert set(theme_mod.PALETTES) == {"sombre", "clair", "medical", "neutre"} - assert theme_mod.DEFAULT_THEME == "sombre" + assert theme_mod.DEFAULT_THEME == "clair" @pytest.mark.parametrize("name", ["sombre", "clair", "medical", "neutre"]) @@ -31,11 +31,11 @@ def test_palette_has_all_tokens(name): def test_default_palette_matches_mockup(): - p = theme_mod.get_palette("sombre") - assert p["bg"] == "#1a1a2e" - assert p["card"] == "#16213e" - assert p["primary"] == "#e94560" - assert p["accent"] == "#f5a623" + p = theme_mod.get_palette(theme_mod.DEFAULT_THEME) + assert p["bg"] == "#cdd2da" + assert p["card"] == "#ffffff" + assert p["primary"] == "#c93050" + assert p["accent"] == "#b45309" def test_get_palette_fallback():