fix(gui): retours Dom GUI V6 — thème, Administration, Règles, aide
Cinq retours utilisateur sur l'exécutable Windows GUI V6. - Thème : `_render()` vidait les widgets mais conservait le cache `_tab_frames`/`_visible_tab` → l'onglet Utilisation se vidait (TclError sur widget détruit) au changement de thème. Reset du cache dans `_render()` → onglet actif recréé proprement. - Onglet principal « Configuration » → « Administration » (clé interne inchangée). - Sous-onglet « Règles 2 » → « Règles » (le « 2 » était un badge non câblé). - Actions de maquette non câblées (Partage Export/Import, Règles Nouvelle règle/Recharger/Tester/Fermer) désactivées + suffixe « (à venir) » via `_mockup_button` : plus aucune action morte qui semble fonctionner. - Aide « ? » restaurée (façon V5) : `ui_kit.HelpButton`/`help_button` réutilisable ouvrant une fenêtre d'aide en français simple, posée sur Utilisation, Administration (Réglages/Masquage/Partage/Règles) et À propos. Partage : phrase visible + aide expliquant qu'on partage les réglages, jamais les documents patients. `tests/unit/test_gui_v6_app_shell.py` : régression thème, libellés, présence d'aide, navigation. 228 tests unit OK (0 régression), self-test GUI V6 OK. V5/moteur/app_aivanov non touchés, aucune dépendance ajoutée. Verdict Qwen requis avant push/build/diffusion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ from gui_v6.tabs.tab_usage import UsageTab
|
||||
|
||||
_TABS = [
|
||||
("use", "📄 Utilisation"),
|
||||
("cfg", "⚙️ Configuration"),
|
||||
("cfg", "⚙️ Administration"),
|
||||
("about", "ℹ️ À propos"),
|
||||
]
|
||||
|
||||
@@ -65,6 +65,11 @@ class AnonymisationApp(ctk.CTk):
|
||||
pass
|
||||
for child in self.winfo_children():
|
||||
child.destroy()
|
||||
# Les frames d'onglets mis en cache étaient des enfants détruits ci-dessus :
|
||||
# on vide le cache pour que ``_show`` recrée proprement l'onglet actif
|
||||
# (sinon on re-packe un widget mort → onglet vide / TclError au changement de thème).
|
||||
self._tab_frames = {}
|
||||
self._visible_tab = None
|
||||
self._build_header(p)
|
||||
self._build_tabsbar(p)
|
||||
self._content = ctk.CTkScrollableFrame(self, fg_color=p["bg"])
|
||||
|
||||
@@ -17,6 +17,14 @@ from gui_v6 import ui_kit
|
||||
from gui_v6.license_client import LicenseClient, LicenseStatus
|
||||
from gui_v6.machine_id import default_machine_id
|
||||
|
||||
_HELP_ABOUT = (
|
||||
"Cet écran affiche la version de l'application, les moteurs utilisés et "
|
||||
"l'identifiant de ce poste.\n\n"
|
||||
"La licence s'active avec une clef fournie par votre administrateur. "
|
||||
"L'activation se fait sans envoyer aucun document : seule la clef est vérifiée.\n\n"
|
||||
"Le traitement des documents reste 100 % local sur ce poste."
|
||||
)
|
||||
|
||||
_STATUS_LABELS = {
|
||||
"active": "Licence active",
|
||||
"grace": "Licence en période de grâce",
|
||||
@@ -58,6 +66,18 @@ class AboutTab(ctk.CTkFrame):
|
||||
def _build(self) -> None:
|
||||
p = self._p
|
||||
|
||||
# Bandeau d'introduction + aide « ? »
|
||||
intro = ctk.CTkFrame(self, fg_color="transparent")
|
||||
intro.pack(fill="x", padx=14, pady=(12, 0))
|
||||
ctk.CTkLabel(
|
||||
intro,
|
||||
text="Informations sur l'application et activation de votre licence.",
|
||||
text_color=p["text_dim"],
|
||||
font=ui_kit.font(12),
|
||||
anchor="w",
|
||||
).pack(side="left", padx=(2, 6))
|
||||
ui_kit.help_button(intro, p, _HELP_ABOUT, title="À propos / Licence").pack(side="right", padx=2)
|
||||
|
||||
# Grille d'informations
|
||||
info = ui_kit.Card(self, p, title="ℹ️ Informations")
|
||||
info.pack(fill="x", padx=14, pady=(14, 7))
|
||||
|
||||
@@ -24,7 +24,7 @@ _SUBTABS = [
|
||||
("reg", "⚙️ Réglages"),
|
||||
("msk", "🎭 Masquage"),
|
||||
("shr", "🔄 Partage"),
|
||||
("rul", "🛡️ Règles 2"),
|
||||
("rul", "🛡️ Règles"),
|
||||
]
|
||||
|
||||
_DETECTION_OPTIONS = [
|
||||
@@ -62,6 +62,40 @@ _STOPWORDS = ["hospitalisation", "contrôle", "prescription"]
|
||||
|
||||
MANUAL_MASK_NONE_LABEL = "Aucun masque manuel"
|
||||
|
||||
# Textes d'aide « ? » (français simple, pour utilisateurs non informaticiens).
|
||||
_HELP_REGLAGES = (
|
||||
"Réglages de l'anonymisation.\n\n"
|
||||
"• Profil métier : choisit un jeu de réglages adapté à votre service.\n"
|
||||
"• Moteurs NER : les modèles qui détectent les noms et données personnelles.\n"
|
||||
"• Données à détecter : ce qui sera masqué (noms, dates de naissance, etc.).\n"
|
||||
"• Listes locales : vos termes à toujours masquer ou toujours conserver.\n\n"
|
||||
"Tout fonctionne 100 % en local sur ce poste. Aucun document patient n'est envoyé sur Internet."
|
||||
)
|
||||
_HELP_MASQUAGE = (
|
||||
"Masquage des documents.\n\n"
|
||||
"• Couleur, style et marges du masque appliqué sur les PDF.\n"
|
||||
"• Codes de remplacement affichés à la place des données ([NOM], [DATE_NAISSANCE]…).\n"
|
||||
"• Masques de zones fixes : ouvrez l'éditeur pour dessiner les zones à masquer "
|
||||
"(en-têtes, blocs identité) directement sur un PDF modèle, puis enregistrez un modèle réutilisable.\n\n"
|
||||
"Le traitement reste local ; rien n'est envoyé sur Internet."
|
||||
)
|
||||
_HELP_PARTAGE = (
|
||||
"À quoi sert le Partage ?\n\n"
|
||||
"Il permet d'échanger les RÉGLAGES de l'application (listes de termes, règles, "
|
||||
"style de masquage, modèle de masque) entre plusieurs postes, ou avec votre administrateur.\n\n"
|
||||
"• Exporter : enregistre vos réglages dans un fichier .json à transmettre.\n"
|
||||
"• Importer : fusionne des réglages reçus avec les vôtres.\n\n"
|
||||
"IMPORTANT : seuls les réglages sont partagés. Vos documents patients ne sont JAMAIS "
|
||||
"partagés ni envoyés sur Internet."
|
||||
)
|
||||
_HELP_REGLES = (
|
||||
"Les Règles adaptent le moteur à votre établissement (ex. : toujours masquer un sigle, "
|
||||
"toujours conserver un terme métier).\n\n"
|
||||
"Chaque règle est validée avant d'être activée.\n\n"
|
||||
"Cette section est en cours de finalisation : les actions marquées « à venir » "
|
||||
"ne sont pas encore disponibles."
|
||||
)
|
||||
|
||||
CONFIG_MOCKUP_SECTIONS = {
|
||||
"reglages": [
|
||||
"Profil métier",
|
||||
@@ -198,6 +232,12 @@ class ConfigTab(ctk.CTkFrame):
|
||||
|
||||
def _build_reglages(self, parent) -> None:
|
||||
p = self._p
|
||||
self._section_intro(
|
||||
parent,
|
||||
"Choisissez ce que l'application doit détecter et masquer. Tout reste local.",
|
||||
_HELP_REGLAGES,
|
||||
"Les Réglages",
|
||||
)
|
||||
top = ctk.CTkFrame(
|
||||
parent,
|
||||
fg_color=p["card"],
|
||||
@@ -308,6 +348,12 @@ class ConfigTab(ctk.CTkFrame):
|
||||
|
||||
def _build_masquage(self, parent) -> None:
|
||||
p = self._p
|
||||
self._section_intro(
|
||||
parent,
|
||||
"Apparence des masques et éditeur de zones fixes à masquer sur vos PDF.",
|
||||
_HELP_MASQUAGE,
|
||||
"Le Masquage",
|
||||
)
|
||||
top_cols = self._columns(parent, 3, gap=8, height=300)
|
||||
|
||||
pdf_opts = ui_kit.Card(top_cols[0], p, title="⬛ PDF")
|
||||
@@ -423,19 +469,31 @@ class ConfigTab(ctk.CTkFrame):
|
||||
|
||||
def _build_partage(self, parent) -> None:
|
||||
p = self._p
|
||||
self._section_intro(
|
||||
parent,
|
||||
"Partagez vos réglages (jamais vos documents) entre postes ou avec l'administrateur.",
|
||||
_HELP_PARTAGE,
|
||||
"À 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.pack(fill="both", expand=True)
|
||||
self._note(export, "Listes locales, règles admin, style de masquage et template actif.")
|
||||
ui_kit.secondary_button(export, p, "⬇ Exporter (.json)", command=lambda: None).pack(anchor="w", padx=12, pady=(0, 12))
|
||||
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.pack(fill="both", expand=True)
|
||||
self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.")
|
||||
ui_kit.secondary_button(import_card, p, "⬆ Importer (.json)", command=lambda: None).pack(anchor="w", padx=12, pady=(0, 12))
|
||||
self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12))
|
||||
|
||||
def _build_regles(self, parent) -> None:
|
||||
p = self._p
|
||||
self._section_intro(
|
||||
parent,
|
||||
"Règles d'adaptation du moteur à votre établissement.",
|
||||
_HELP_REGLES,
|
||||
"Les Règles",
|
||||
)
|
||||
card = ui_kit.Card(parent, p, title="🛡️ Règles actives")
|
||||
card.pack(fill="x", pady=(0, 8))
|
||||
self._note(card, "Ces règles adaptent le moteur à votre établissement. Chaque règle est validée avant activation.")
|
||||
@@ -451,8 +509,8 @@ class ConfigTab(ctk.CTkFrame):
|
||||
self._rule_row(card, row)
|
||||
actions = ctk.CTkFrame(card, fg_color="transparent")
|
||||
actions.pack(fill="x", padx=12, pady=(8, 12))
|
||||
ui_kit.primary_button(actions, p, "+ Nouvelle règle", command=lambda: None).pack(side="left", padx=(0, 8))
|
||||
ui_kit.secondary_button(actions, p, "🔄 Recharger", command=lambda: None).pack(side="left")
|
||||
self._mockup_button(actions, "+ Nouvelle règle", primary=True).pack(side="left", padx=(0, 8))
|
||||
self._mockup_button(actions, "🔄 Recharger").pack(side="left")
|
||||
|
||||
sim = ui_kit.Card(parent, p, title="🧪 Testeur de règle")
|
||||
sim.pack(fill="x")
|
||||
@@ -462,8 +520,29 @@ class ConfigTab(ctk.CTkFrame):
|
||||
txt.insert("1.0", "Compte rendu CHUXX, patient N° 1234567.")
|
||||
btns = ctk.CTkFrame(sim, fg_color="transparent")
|
||||
btns.pack(fill="x", padx=12, pady=(0, 12))
|
||||
ui_kit.primary_button(btns, p, "▶ Tester", command=lambda: None).pack(side="left", padx=(0, 8))
|
||||
ui_kit.secondary_button(btns, p, "✖ Fermer", command=lambda: None).pack(side="left")
|
||||
self._mockup_button(btns, "▶ Tester", primary=True).pack(side="left", padx=(0, 8))
|
||||
self._mockup_button(btns, "✖ Fermer").pack(side="left")
|
||||
|
||||
# -- helpers aide / maquette -----------------------------------------
|
||||
|
||||
def _section_intro(self, parent, sentence: str, help_text: str, help_title: str) -> None:
|
||||
"""Ligne d'introduction d'une sous-section : phrase courte + bouton d'aide « ? »."""
|
||||
p = self._p
|
||||
intro = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
intro.pack(fill="x", pady=(0, 6))
|
||||
ctk.CTkLabel(
|
||||
intro, text=sentence, text_color=p["text_dim"], font=ui_kit.font(12), anchor="w", justify="left"
|
||||
).pack(side="left", padx=(2, 6))
|
||||
ui_kit.help_button(intro, p, help_text, title=help_title).pack(side="right", padx=2)
|
||||
|
||||
def _mockup_button(self, parent, text: str, primary: bool = False):
|
||||
"""Bouton de maquette non câblé : désactivé + suffixe « (à venir) » pour
|
||||
ne pas laisser croire qu'il fonctionne."""
|
||||
p = self._p
|
||||
factory = ui_kit.primary_button if primary else ui_kit.secondary_button
|
||||
btn = factory(parent, p, f"{text} (à venir)", command=lambda: None)
|
||||
btn.configure(state="disabled")
|
||||
return btn
|
||||
|
||||
# -- callbacks réglages ----------------------------------------------
|
||||
|
||||
@@ -714,4 +793,4 @@ class ConfigTab(ctk.CTkFrame):
|
||||
ctk.CTkLabel(row, text=target, width=210, anchor="w", text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="left")
|
||||
color = p["success"] if status == "Actif" else p["warning"]
|
||||
ctk.CTkLabel(row, text=status, width=70, anchor="w", text_color=color, font=ui_kit.font(11, "bold")).pack(side="left")
|
||||
ui_kit.secondary_button(row, p, "▶ Tester", command=lambda: None).pack(side="left")
|
||||
self._mockup_button(row, "▶ Tester").pack(side="left")
|
||||
|
||||
@@ -22,6 +22,16 @@ from gui_v6.processing_runner import ProcessingRunner, default_output_dir
|
||||
|
||||
_STEPS = ["📖 Extraction", "🧠 Détection", "🔒 Masquage", "📄 PDF final"]
|
||||
|
||||
_HELP_USAGE = (
|
||||
"Anonymiser vos documents.\n\n"
|
||||
"1) Choisissez un fichier ou un dossier de documents.\n"
|
||||
"2) Vérifiez le format de sortie.\n"
|
||||
"3) Cliquez sur « Lancer » : l'application détecte et masque les données "
|
||||
"personnelles, puis écrit les documents anonymisés dans un dossier de sortie.\n\n"
|
||||
"Tout le traitement se fait 100 % en local sur ce poste. Aucun document "
|
||||
"n'est envoyé sur Internet."
|
||||
)
|
||||
|
||||
|
||||
class UsageTab(ctk.CTkFrame):
|
||||
def __init__(
|
||||
@@ -57,6 +67,18 @@ class UsageTab(ctk.CTkFrame):
|
||||
def _build(self) -> None:
|
||||
p = self._p
|
||||
|
||||
# Bandeau d'introduction + aide « ? »
|
||||
intro = ctk.CTkFrame(self, fg_color="transparent")
|
||||
intro.pack(fill="x", padx=14, pady=(12, 0))
|
||||
ctk.CTkLabel(
|
||||
intro,
|
||||
text="Sélectionnez vos documents puis lancez l'anonymisation (100 % local).",
|
||||
text_color=p["text_dim"],
|
||||
font=ui_kit.font(12),
|
||||
anchor="w",
|
||||
).pack(side="left", padx=(2, 6))
|
||||
ui_kit.help_button(intro, p, _HELP_USAGE, title="Comment ça marche ?").pack(side="right", padx=2)
|
||||
|
||||
# Carte Apparence (sélecteur de thème)
|
||||
appearance = ui_kit.Card(self, p, title="🎨 Apparence")
|
||||
appearance.pack(fill="x", padx=14, pady=(14, 7))
|
||||
|
||||
@@ -146,3 +146,88 @@ class ToggleRow(ctk.CTkFrame):
|
||||
|
||||
def get(self) -> bool:
|
||||
return bool(self.var.get())
|
||||
|
||||
|
||||
class HelpButton(ctk.CTkButton):
|
||||
"""Petit bouton « ? » ouvrant une fenêtre d'aide en français simple.
|
||||
|
||||
Restaure l'affordance d'aide de la V5 (``ToolTip`` / « Comment ça marche ? »)
|
||||
pour les utilisateurs non informaticiens.
|
||||
"""
|
||||
|
||||
def __init__(self, master, palette: dict, text: str, *, title: str = "Aide", **kwargs):
|
||||
self._palette = palette
|
||||
self._help_text = text
|
||||
self._help_title = title
|
||||
self._window = None
|
||||
super().__init__(
|
||||
master,
|
||||
text="?",
|
||||
command=self.open_help,
|
||||
width=26,
|
||||
height=26,
|
||||
corner_radius=13,
|
||||
fg_color=palette["btn_sec_bg"],
|
||||
hover_color=palette["card_border"],
|
||||
text_color=palette["text_dim"],
|
||||
border_color=palette["btn_sec_border"],
|
||||
border_width=1,
|
||||
font=font(13, "bold"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def open_help(self):
|
||||
if self._window is not None:
|
||||
try:
|
||||
if self._window.winfo_exists():
|
||||
self._window.lift()
|
||||
self._window.focus_force()
|
||||
return self._window
|
||||
except Exception:
|
||||
pass
|
||||
p = self._palette
|
||||
win = ctk.CTkToplevel(self)
|
||||
win.title(self._help_title)
|
||||
win.geometry("480x380")
|
||||
win.minsize(360, 240)
|
||||
try:
|
||||
win.configure(fg_color=p["bg"])
|
||||
except Exception:
|
||||
pass
|
||||
ctk.CTkLabel(
|
||||
win, text=self._help_title, text_color=p["text"], font=font(15, "bold"), anchor="w"
|
||||
).pack(fill="x", padx=16, pady=(14, 4))
|
||||
box = ctk.CTkScrollableFrame(win, fg_color=p["card"])
|
||||
box.pack(fill="both", expand=True, padx=12, pady=(0, 8))
|
||||
ctk.CTkLabel(
|
||||
box,
|
||||
text=self._help_text,
|
||||
text_color=p["text_dim"],
|
||||
font=font(12),
|
||||
justify="left",
|
||||
wraplength=420,
|
||||
anchor="w",
|
||||
).pack(fill="x", padx=10, pady=10)
|
||||
ctk.CTkButton(
|
||||
win,
|
||||
text="Fermer",
|
||||
command=win.destroy,
|
||||
fg_color=p["btn_sec_bg"],
|
||||
hover_color=p["card_border"],
|
||||
text_color=p["text"],
|
||||
border_color=p["btn_sec_border"],
|
||||
border_width=1,
|
||||
corner_radius=CARD_RADIUS,
|
||||
height=30,
|
||||
).pack(padx=12, pady=(0, 12))
|
||||
try:
|
||||
win.transient(self.winfo_toplevel())
|
||||
win.after(120, lambda: (win.lift(), win.focus_force()))
|
||||
except Exception:
|
||||
pass
|
||||
self._window = win
|
||||
return win
|
||||
|
||||
|
||||
def help_button(master, palette: dict, text: str, title: str = "Aide") -> "HelpButton":
|
||||
return HelpButton(master, palette, text, title=title)
|
||||
|
||||
108
tests/unit/test_gui_v6_app_shell.py
Normal file
108
tests/unit/test_gui_v6_app_shell.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Shell GUI V6 : robustesse du changement de thème, libellés d'onglets, aide.
|
||||
|
||||
Smokes headless (Xvfb) — skip propre si pas de display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
pytest.importorskip("customtkinter")
|
||||
try:
|
||||
from gui_v6.app import AnonymisationApp
|
||||
|
||||
a = AnonymisationApp()
|
||||
except Exception as exc: # pas de display
|
||||
pytest.skip(f"display Tk indisponible: {exc}")
|
||||
a.withdraw()
|
||||
try:
|
||||
yield a
|
||||
finally:
|
||||
try:
|
||||
a.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_usage_tab_survives_theme_change(app):
|
||||
"""Retour Dom #1 : l'onglet Utilisation ne doit pas se vider au changement
|
||||
de thème (le cache d'onglets ne doit pas conserver de widgets détruits)."""
|
||||
app._show("use")
|
||||
app.update_idletasks()
|
||||
assert app._active == "use"
|
||||
|
||||
other = "clair" if app._theme_name != "clair" else "sombre"
|
||||
app.set_theme(other)
|
||||
app.update_idletasks()
|
||||
|
||||
assert app._active == "use"
|
||||
assert "use" in app._tab_frames
|
||||
frame = app._tab_frames["use"]
|
||||
assert frame.winfo_exists() # onglet recréé et vivant, pas un widget mort
|
||||
|
||||
|
||||
def test_main_tab_renamed_to_administration():
|
||||
"""Retour Dom #2 : l'onglet principal Configuration devient Administration."""
|
||||
from gui_v6.app import _TABS
|
||||
|
||||
labels = [label for _, label in _TABS]
|
||||
assert any("Administration" in lbl for lbl in labels)
|
||||
assert not any("Configuration" in lbl for lbl in labels)
|
||||
|
||||
|
||||
def test_rules_subtab_has_no_unexplained_2():
|
||||
"""Retour Dom #3 : « Règles 2 » incompréhensible → simple « Règles »."""
|
||||
from gui_v6.tabs.tab_config import _SUBTABS
|
||||
|
||||
labels = [label for _, label in _SUBTABS]
|
||||
assert any(lbl.strip() == "🛡️ Règles" for lbl in labels)
|
||||
assert not any("Règles 2" in lbl or "Règles 2" in lbl for lbl in labels)
|
||||
|
||||
|
||||
def test_help_button_opens_help_window(app):
|
||||
"""Retours Dom #4/#5 : affordance d'aide « ? » réutilisable qui ouvre une
|
||||
fenêtre d'aide en français."""
|
||||
from gui_v6 import theme as theme_mod
|
||||
from gui_v6 import ui_kit
|
||||
|
||||
p = theme_mod.get_palette(theme_mod.DEFAULT_THEME)
|
||||
btn = ui_kit.help_button(app, p, "Cette section reste 100 % locale.", title="Aide test")
|
||||
assert btn.cget("text") == "?"
|
||||
win = btn.open_help()
|
||||
app.update_idletasks()
|
||||
assert win.winfo_exists()
|
||||
win.destroy()
|
||||
|
||||
|
||||
def _count_help_buttons(widget) -> int:
|
||||
from gui_v6.ui_kit import HelpButton
|
||||
|
||||
total = 1 if isinstance(widget, HelpButton) else 0
|
||||
for child in widget.winfo_children():
|
||||
total += _count_help_buttons(child)
|
||||
return total
|
||||
|
||||
|
||||
def test_each_tab_exposes_help(app):
|
||||
"""Retour Dom #5 : une affordance d'aide « ? » est présente sur chaque onglet."""
|
||||
for key in ("use", "cfg", "about"):
|
||||
app._show(key)
|
||||
app.update_idletasks()
|
||||
assert _count_help_buttons(app._tab_frames[key]) >= 1, key
|
||||
|
||||
|
||||
def test_navigation_and_theme_change_keep_tabs_alive(app):
|
||||
"""Navigation + changement de thème : aucun onglet vide/mort."""
|
||||
for key in ("use", "cfg", "about"):
|
||||
app._show(key)
|
||||
app.update_idletasks()
|
||||
assert app._tab_frames[key].winfo_exists()
|
||||
app.set_theme("medical")
|
||||
app.update_idletasks()
|
||||
for key in ("use", "cfg", "about"):
|
||||
app._show(key)
|
||||
app.update_idletasks()
|
||||
assert app._tab_frames[key].winfo_exists()
|
||||
Reference in New Issue
Block a user