feat: Léa UX — messages français naturels + feedback temps réel

Aspect 1/4 de Léa (agent Windows) : rendre Léa humaine.

Nouveaux modules :
- agent_v1/ui/messages.py : 11 formatters (cible non trouvée, mauvaise fenêtre,
  écran inchangé, connexion, workflow, retry, ralentissement, erreur générique)
- agent_v1/ui/activity_panel.py : panneau tkinter lazy avec état courant,
  action, progression X/Y, temps écoulé, 7 états (OBSERVE/CHERCHE/AGIT/VERIFIE...)

Hiérarchie de notifications :
- INFO (4s, vert) — début workflow, étape en cours
- ATTENTION (7s, orange) — retry, ralentissement
- BLOCAGE (15s, rouge, persistent, bypass rate-limit) — cible introuvable, mauvaise fenêtre

Transformations de messages :
  AVANT : "target_not_found: dans *bonjour, – Bloc-notes"
  APRÈS : "Léa a besoin d'aide"
          "Je ne trouve pas « bonjour » dans Bloc-notes.
           Peux-tu cliquer dessus toi-même ? Je reprends ensuite."

Robustesse :
- Détection fenêtre Léa via regex word-boundaries (évite cléa.txt, leapfrog.exe)
- Centralisée dans messages.est_fenetre_lea() — source unique de vérité
- Noop stub universel via __getattr__ (plus besoin de lister les méthodes)
- Thread-safe (RLock + snapshots immutables)
- Fallback silencieux si tkinter/plyer absent

101 nouveaux tests, aucune régression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-10 08:42:01 +02:00
parent f6ad5ff2b2
commit a6eb4c168f
6 changed files with 1864 additions and 72 deletions

View File

@@ -0,0 +1,727 @@
"""Tests unitaires pour l'UX de Léa (notifications, messages, activity panel).
Couvre :
- Formatage des messages techniques → français naturel (module messages.py)
- Hiérarchie info/attention/blocage
- Détection de la fenêtre Léa
- NotificationManager avec plyer mocké
- ActivityPanel sans tkinter (fallback silencieux)
Ces tests ne nécessitent ni tkinter ni plyer : tout est mocké ou géré en
fallback silencieux. Ils doivent passer sur toutes les plateformes.
Auteur: Dom, avril 2026
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Assurer que la racine du projet est dans le path (comme conftest)
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from agent_v0.agent_v1.ui import activity_panel, messages, notifications
from agent_v0.agent_v1.ui.activity_panel import ActivityPanel, EtatLea, reset_activity_panel
from agent_v0.agent_v1.ui.messages import (
MessageUtilisateur,
NiveauMessage,
_extraire_nom_application,
_nettoyer_description_cible,
est_fenetre_lea,
formatter_cible_non_trouvee,
formatter_connexion_perdue,
formatter_connexion_retablie,
formatter_debut_workflow,
formatter_ecran_inchange,
formatter_erreur_generique,
formatter_etape_workflow,
formatter_fenetre_incorrecte,
formatter_fin_workflow,
formatter_ralentissement,
formatter_retry,
)
from agent_v0.agent_v1.ui.notifications import NotificationManager
# ============================================================================
# Tests : helpers d'extraction
# ============================================================================
class TestExtraction:
"""Tests des helpers _extraire_nom_application et _nettoyer_description_cible."""
def test_extraire_app_avec_em_dash(self):
assert _extraire_nom_application("Document.txt Bloc-notes") == "Bloc-notes"
def test_extraire_app_avec_em_dash_long(self):
assert _extraire_nom_application("Ma Page — Google Chrome") == "Google Chrome"
def test_extraire_app_avec_dash_simple(self):
assert _extraire_nom_application("Session 1 - Firefox") == "Firefox"
def test_extraire_app_sans_separateur(self):
assert _extraire_nom_application("Bloc-notes") == "Bloc-notes"
def test_extraire_app_vide(self):
assert _extraire_nom_application("") == ""
assert _extraire_nom_application(None) == ""
def test_extraire_app_garde_dernier_separateur(self):
# Cas multi-séparateurs : on garde la dernière partie
assert _extraire_nom_application("A - B - C") == "C"
def test_nettoyer_description_retire_guillemets(self):
assert _nettoyer_description_cible("'bonjour'") == "bonjour"
assert _nettoyer_description_cible('"bonjour"') == "bonjour"
assert _nettoyer_description_cible("`code`") == "code"
def test_nettoyer_description_vide(self):
assert _nettoyer_description_cible("") == ""
assert _nettoyer_description_cible(None) == ""
def test_nettoyer_description_tronque(self):
longue = "x" * 200
resultat = _nettoyer_description_cible(longue)
assert len(resultat) <= 80
assert resultat.endswith("...")
# ============================================================================
# Tests : détection fenêtre Léa
# ============================================================================
class TestFenetreLea:
"""Tests de est_fenetre_lea — crucial pour la robustesse."""
@pytest.mark.parametrize("titre", [
"Léa",
"Léa — Assistante IA",
"Lea - Assistante",
"Léa — Activité",
"Lea : Explorateur de fichiers",
"LÉA — ASSISTANTE IA", # casse mixte
"Léa assistante",
"Assistante IA",
])
def test_detecte_fenetres_lea(self, titre):
assert est_fenetre_lea(titre), f"Devrait détecter : {titre!r}"
@pytest.mark.parametrize("titre", [
"Bloc-notes",
"Google Chrome",
"Program Manager",
"Microsoft Word - Document1",
"Sans titre - Paint",
"",
"cléa.txt", # contient "léa" mais c'est un fichier
"replay.log", # contient "lea"
"leapfrog.exe", # contient "lea"
"nucleaire.pdf", # contient "lea"
])
def test_ignore_fenetres_non_lea(self, titre):
"""Les faux positifs sur des noms contenant 'lea' doivent être évités
grâce aux word boundaries regex."""
assert not est_fenetre_lea(titre), f"Ne devrait pas détecter : {titre!r}"
def test_titre_none(self):
assert est_fenetre_lea(None) is False
def test_espaces_en_trop(self):
assert est_fenetre_lea(" Léa — Assistante IA ") is True
# ============================================================================
# Tests : formatage des messages techniques → humains
# ============================================================================
class TestFormatterCibleNonTrouvee:
"""Tests du formatage quand un élément n'est pas trouvé."""
def test_message_blocage(self):
msg = formatter_cible_non_trouvee("bonjour", "Document Bloc-notes")
assert msg.niveau == NiveauMessage.BLOCAGE
assert msg.persistent is True
assert "besoin d'aide" in msg.titre.lower()
def test_message_contient_nom_element(self):
msg = formatter_cible_non_trouvee("Rechercher", "Chrome")
assert "rechercher" in msg.corps.lower()
def test_message_contient_nom_application(self):
msg = formatter_cible_non_trouvee("bonjour", "Doc Bloc-notes")
assert "bloc-notes" in msg.corps.lower()
def test_message_action_orientee(self):
"""Le message doit proposer une action à l'utilisateur."""
msg = formatter_cible_non_trouvee("bouton", "App")
corps_lower = msg.corps.lower()
# Doit contenir un verbe d'action type "cliquer", "faire"
assert any(verb in corps_lower for verb in ["cliqu", "faire", "peux-tu"])
def test_sans_fenetre(self):
msg = formatter_cible_non_trouvee("Submit", None)
assert msg.niveau == NiveauMessage.BLOCAGE
assert "submit" in msg.corps.lower()
def test_description_vide(self):
msg = formatter_cible_non_trouvee("", "App")
# Doit quand même produire un message utilisable
assert msg.corps
assert msg.niveau == NiveauMessage.BLOCAGE
def test_message_techniques_nettoyes(self):
"""Pas de '__target_not_found__' ni code technique visible."""
msg = formatter_cible_non_trouvee("'bonjour'", "Bloc-notes")
assert "target_not_found" not in msg.corps
# Les guillemets techniques sont nettoyés, mais on en ajoute des français
assert "bonjour" in msg.corps
class TestFormatterFenetreIncorrecte:
"""Tests du formatage quand la mauvaise fenêtre est active."""
def test_message_blocage_persistent(self):
msg = formatter_fenetre_incorrecte(
"Program Manager",
"Lea : Explorateur de fichiers",
)
assert msg.niveau == NiveauMessage.BLOCAGE
assert msg.persistent is True
def test_mentionne_fenetre_attendue(self):
msg = formatter_fenetre_incorrecte("Program Manager", "Chrome")
assert "chrome" in msg.corps.lower()
def test_mentionne_fenetre_actuelle(self):
msg = formatter_fenetre_incorrecte("Program Manager", "Chrome")
assert "program manager" in msg.corps.lower()
def test_suggere_action(self):
msg = formatter_fenetre_incorrecte("A", "B")
# Propose d'ouvrir la bonne fenêtre
assert "ouvr" in msg.corps.lower() or "fenêtre" in msg.corps.lower()
class TestFormatterEcranInchange:
"""Tests du formatage quand l'écran ne change pas après une action."""
def test_niveau_attention(self):
"""L'écran inchangé est de niveau ATTENTION, pas BLOCAGE."""
msg = formatter_ecran_inchange("click")
assert msg.niveau == NiveauMessage.ATTENTION
def test_message_pour_click(self):
msg = formatter_ecran_inchange("click")
assert "clic" in msg.corps.lower()
def test_message_pour_type(self):
msg = formatter_ecran_inchange("type")
assert "saisie" in msg.corps.lower()
def test_message_pour_key_combo(self):
msg = formatter_ecran_inchange("key_combo")
assert "raccourci" in msg.corps.lower()
def test_sans_type_action(self):
msg = formatter_ecran_inchange("")
assert msg.corps # Doit quand même produire quelque chose
def test_pas_persistent(self):
msg = formatter_ecran_inchange("click")
assert msg.persistent is False
class TestFormatterConnexion:
"""Tests des messages de connexion serveur."""
def test_connexion_perdue_attention(self):
msg = formatter_connexion_perdue("localhost")
assert msg.niveau == NiveauMessage.ATTENTION
def test_connexion_perdue_rassurante(self):
"""Le message doit rassurer (reconnexion automatique)."""
msg = formatter_connexion_perdue()
assert "automatique" in msg.corps.lower() or "retent" in msg.corps.lower()
def test_connexion_retablie_info(self):
msg = formatter_connexion_retablie()
assert msg.niveau == NiveauMessage.INFO
def test_connexion_retablie_positive(self):
msg = formatter_connexion_retablie()
assert "bon" in msg.corps.lower() or "revenue" in msg.corps.lower()
class TestFormatterWorkflow:
"""Tests des messages de workflow (début, étape, fin)."""
def test_debut_avec_etapes(self):
msg = formatter_debut_workflow("Saisie patient", 15)
assert msg.niveau == NiveauMessage.INFO
assert "saisie patient" in msg.corps.lower()
assert "15" in msg.corps
def test_debut_sans_etapes(self):
msg = formatter_debut_workflow("Backup")
assert msg.niveau == NiveauMessage.INFO
assert "backup" in msg.corps.lower()
def test_etape_progression(self):
msg = formatter_etape_workflow(3, 15, "Clic sur Valider")
assert "3" in msg.corps
assert "15" in msg.corps
assert "valider" in msg.corps.lower()
def test_etape_sans_description(self):
msg = formatter_etape_workflow(5, 20)
assert "5" in msg.corps
assert "20" in msg.corps
def test_fin_succes(self):
msg = formatter_fin_workflow(True, "Ma tâche", 10, 45.0)
assert msg.niveau == NiveauMessage.INFO
assert "terminé" in msg.corps.lower() or "fait" in msg.corps.lower()
def test_fin_echec_blocage(self):
msg = formatter_fin_workflow(False, "Ma tâche")
assert msg.niveau == NiveauMessage.BLOCAGE
assert msg.persistent is True
class TestFormatterRetryRalentissement:
"""Tests des messages de retry et ralentissement."""
def test_retry_attention(self):
msg = formatter_retry("click", 2)
assert msg.niveau == NiveauMessage.ATTENTION
assert "2" in msg.corps # numéro de tentative
def test_ralentissement_attention(self):
msg = formatter_ralentissement()
assert msg.niveau == NiveauMessage.ATTENTION
assert "lent" in msg.corps.lower()
class TestFormatterErreurGenerique:
"""Tests du router formatter_erreur_generique → spécialisé."""
def test_detecte_target_not_found(self):
msg = formatter_erreur_generique("target_not_found: 'bouton'")
assert msg.niveau == NiveauMessage.BLOCAGE
assert "bouton" in msg.corps.lower()
def test_detecte_fenetre_incorrecte(self):
msg = formatter_erreur_generique(
"Fenêtre incorrecte: 'Program Manager' (attendu: 'Chrome')"
)
assert msg.niveau == NiveauMessage.BLOCAGE
assert "chrome" in msg.corps.lower() or "program manager" in msg.corps.lower()
def test_detecte_ecran_inchange(self):
msg = formatter_erreur_generique("Ecran inchange apres l'action")
assert msg.niveau == NiveauMessage.ATTENTION
def test_detecte_no_screen_change(self):
msg = formatter_erreur_generique("no_screen_change after click")
assert msg.niveau == NiveauMessage.ATTENTION
def test_detecte_policy_abort(self):
msg = formatter_erreur_generique("policy_abort:target_desc_x")
assert msg.niveau == NiveauMessage.BLOCAGE
def test_message_vide(self):
msg = formatter_erreur_generique("")
assert msg.corps
assert msg.niveau == NiveauMessage.ATTENTION
def test_message_inconnu_tronque(self):
long_msg = "erreur très longue " * 20
msg = formatter_erreur_generique(long_msg)
assert len(msg.corps) <= 200 # tronqué avec "..."
def test_pas_de_code_technique_dans_message_utilisateur(self):
"""Les messages présentés à l'utilisateur ne doivent pas contenir de
noms de variables, de fonctions, ou de types Python."""
msg = formatter_erreur_generique("target_not_found: 'bouton'")
# Le code technique ne doit pas apparaître tel quel dans le corps
assert "target_not_found" not in msg.corps
# ============================================================================
# Tests : hiérarchie NiveauMessage
# ============================================================================
class TestHierarchieNiveau:
"""Tests de la hiérarchie info/attention/blocage."""
def test_niveau_info_duree_courte(self):
msg = formatter_connexion_retablie()
assert msg.niveau == NiveauMessage.INFO
assert msg.duree_s <= 6
def test_niveau_attention_duree_moyenne(self):
msg = formatter_ecran_inchange("click")
assert msg.niveau == NiveauMessage.ATTENTION
assert 5 <= msg.duree_s <= 10
def test_niveau_blocage_duree_longue_persistent(self):
msg = formatter_cible_non_trouvee("x", "y")
assert msg.niveau == NiveauMessage.BLOCAGE
assert msg.duree_s >= 10
assert msg.persistent is True
def test_niveau_info_non_persistent(self):
msg = formatter_debut_workflow("test")
assert msg.persistent is False
def test_to_dict_serialisation(self):
msg = MessageUtilisateur(
niveau=NiveauMessage.INFO,
titre="Test",
corps="Corps",
duree_s=5,
)
d = msg.to_dict()
assert d["niveau"] == "info"
assert d["titre"] == "Test"
assert d["corps"] == "Corps"
assert d["duree_s"] == 5
assert d["persistent"] is False
# ============================================================================
# Tests : NotificationManager (avec plyer mocké)
# ============================================================================
class TestNotificationManager:
"""Tests du NotificationManager avec plyer mocké.
Ces tests ne dépendent pas de l'environnement : plyer est patché pour
qu'on puisse vérifier les appels sans afficher de vraies notifications.
"""
def test_instanciation(self):
mgr = NotificationManager()
assert mgr is not None
def test_notify_sans_plyer(self, monkeypatch):
"""Si plyer n'est pas dispo, notify() retourne False sans crasher."""
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", False)
mgr = NotificationManager()
assert mgr.notify("titre", "message") is False
def test_notify_avec_plyer_mocke(self, monkeypatch):
mock_plyer = MagicMock()
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", True)
monkeypatch.setattr(notifications, "_plyer_notification", mock_plyer)
mgr = NotificationManager()
result = mgr.notify("Titre", "Message", timeout=5)
assert result is True
# L'envoi est asynchrone, laissons le thread démarrer
time.sleep(0.1)
mock_plyer.notify.assert_called_once()
def test_rate_limit(self, monkeypatch):
"""Le rate limit bloque les notifications trop rapprochées."""
mock_plyer = MagicMock()
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", True)
monkeypatch.setattr(notifications, "_plyer_notification", mock_plyer)
mgr = NotificationManager()
assert mgr.notify("T1", "M1") is True
# Immédiatement après → bloqué
assert mgr.notify("T2", "M2") is False
def test_bypass_rate_limit_pour_blocage(self, monkeypatch):
"""Les messages BLOCAGE bypass le rate limit."""
mock_plyer = MagicMock()
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", True)
monkeypatch.setattr(notifications, "_plyer_notification", mock_plyer)
mgr = NotificationManager()
assert mgr.notify("T1", "M1") is True
# Sans bypass → bloqué
assert mgr.notify("T2", "M2") is False
# Avec bypass → passe
assert mgr.notify("T3", "M3", bypass_rate_limit=True) is True
def test_notify_message_niveau_blocage_bypass(self, monkeypatch):
mock_plyer = MagicMock()
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", True)
monkeypatch.setattr(notifications, "_plyer_notification", mock_plyer)
mgr = NotificationManager()
# Occuper le rate limit
mgr.notify("T0", "M0")
# Message BLOCAGE doit passer même pendant le rate limit
msg_blocage = formatter_cible_non_trouvee("x", "y")
assert mgr.notify_message(msg_blocage) is True
def test_replay_target_not_found_avec_titre(self, monkeypatch):
"""L'API spécialisée produit un message contenant le nom d'app."""
mock_plyer = MagicMock()
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", True)
monkeypatch.setattr(notifications, "_plyer_notification", mock_plyer)
mgr = NotificationManager()
mgr.replay_target_not_found("Rechercher", "Document Bloc-notes")
time.sleep(0.1)
# Vérifier qu'on a bien envoyé un message qui mentionne l'app
args, kwargs = mock_plyer.notify.call_args
message_envoye = kwargs.get("message", "")
assert "bloc-notes" in message_envoye.lower()
assert "rechercher" in message_envoye.lower()
def test_replay_wrong_window(self, monkeypatch):
mock_plyer = MagicMock()
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", True)
monkeypatch.setattr(notifications, "_plyer_notification", mock_plyer)
mgr = NotificationManager()
mgr.replay_wrong_window("Program Manager", "Chrome")
time.sleep(0.1)
args, kwargs = mock_plyer.notify.call_args
titre = kwargs.get("title", "")
# Le titre doit indiquer l'attente d'une fenêtre
assert "fenêtre" in titre.lower() or "attend" in titre.lower()
def test_error_route_vers_formatter_specialise(self, monkeypatch):
"""error() détecte 'target_not_found' et produit un message de blocage."""
mock_plyer = MagicMock()
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", True)
monkeypatch.setattr(notifications, "_plyer_notification", mock_plyer)
mgr = NotificationManager()
mgr.error("target_not_found: 'bonjour'")
time.sleep(0.1)
mock_plyer.notify.assert_called_once()
args, kwargs = mock_plyer.notify.call_args
# Le message envoyé doit être en français naturel, pas le code brut
message_envoye = kwargs.get("message", "")
assert "target_not_found" not in message_envoye
def test_backward_compat_connection_changed(self, monkeypatch):
"""L'API existante connection_changed reste fonctionnelle."""
mock_plyer = MagicMock()
monkeypatch.setattr(notifications, "_PLYER_AVAILABLE", True)
monkeypatch.setattr(notifications, "_plyer_notification", mock_plyer)
mgr = NotificationManager()
# Déconnexion
mgr.connection_changed(False, "localhost")
time.sleep(0.1)
assert mock_plyer.notify.called
# ============================================================================
# Tests : ActivityPanel (sans tkinter)
# ============================================================================
class TestActivityPanelFallback:
"""Tests du panel d'activité en mode fallback (sans tkinter)."""
def setup_method(self):
reset_activity_panel()
def teardown_method(self):
reset_activity_panel()
def test_creation_sans_ui(self):
"""Le panel peut être créé sans UI (activer_ui=False)."""
panel = ActivityPanel(activer_ui=False)
assert panel is not None
def test_snapshot_initial(self):
panel = ActivityPanel(activer_ui=False)
snap = panel.snapshot()
assert snap.etat == EtatLea.INACTIVE
assert snap.nom_workflow == ""
assert snap.etape == 0
def test_definir_workflow(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", nb_etapes=10)
snap = panel.snapshot()
assert snap.nom_workflow == "Test"
assert snap.nb_etapes == 10
assert snap.etat == EtatLea.OBSERVE
assert snap.debut_timestamp > 0
def test_mettre_a_jour_etape(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", 10)
panel.mettre_a_jour(etat=EtatLea.AGIT, action="Clic", etape=3)
snap = panel.snapshot()
assert snap.etat == EtatLea.AGIT
assert snap.action_courante == "Clic"
assert snap.etape == 3
def test_mettre_a_jour_partiel(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", 10)
panel.mettre_a_jour(etape=5)
snap = panel.snapshot()
assert snap.etape == 5
# L'état reste OBSERVE (non modifié)
assert snap.etat == EtatLea.OBSERVE
def test_progression_texte(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", 10)
panel.mettre_a_jour(etape=3)
snap = panel.snapshot()
assert snap.progression_texte() == "3/10"
def test_progression_texte_sans_nb_etapes(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", nb_etapes=0)
snap = panel.snapshot()
assert snap.progression_texte() == ""
def test_temps_ecoule(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", 10)
time.sleep(0.05)
snap = panel.snapshot()
assert snap.temps_ecoule_s() >= 0.05
def test_temps_ecoule_texte_secondes(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", 10)
snap = panel.snapshot()
# Format "Xs" pour < 60s
texte = snap.temps_ecoule_texte()
assert texte.endswith("s")
def test_terminer_succes(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", 10)
panel.terminer(succes=True)
snap = panel.snapshot()
assert snap.etat == EtatLea.TERMINE
def test_terminer_echec(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", 10)
panel.terminer(succes=False)
snap = panel.snapshot()
assert snap.etat == EtatLea.BLOQUEE
assert snap.dernier_message # Un message par défaut est mis
def test_reinitialiser(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Test", 10)
panel.reinitialiser()
snap = panel.snapshot()
assert snap.etat == EtatLea.INACTIVE
assert snap.nom_workflow == ""
def test_listener_appele_sur_changement(self):
panel = ActivityPanel(activer_ui=False)
calls = []
panel.on_change(lambda snap: calls.append(snap.etat))
panel.definir_workflow("Test", 5)
panel.mettre_a_jour(etat=EtatLea.AGIT)
assert EtatLea.OBSERVE in calls
assert EtatLea.AGIT in calls
def test_listener_erreur_nintervient_pas(self):
"""Un listener qui crash ne doit pas casser le panel."""
panel = ActivityPanel(activer_ui=False)
def listener_casse(snap):
raise RuntimeError("boom")
panel.on_change(listener_casse)
# Ne doit pas crasher
panel.definir_workflow("Test", 5)
snap = panel.snapshot()
assert snap.nom_workflow == "Test"
def test_to_dict_serialisation(self):
panel = ActivityPanel(activer_ui=False)
panel.definir_workflow("Ma tâche", 10)
panel.mettre_a_jour(
etat=EtatLea.AGIT,
action="Clic sur Valider",
etape=3,
)
d = panel.snapshot().to_dict()
assert d["nom_workflow"] == "Ma tâche"
assert d["etat"] == "agit"
assert d["etat_libelle"] == "Agit"
assert d["progression"] == "3/10"
assert d["action_courante"] == "Clic sur Valider"
def test_masquer_sans_ui_ne_crash_pas(self):
panel = ActivityPanel(activer_ui=False)
# Doit être no-op sans crasher
panel.masquer()
panel.afficher()
def test_etats_ont_couleurs_et_libelles(self):
"""Vérifier que tous les états ont bien une couleur et un libellé."""
for etat in EtatLea:
assert etat.libelle
assert etat.couleur.startswith("#")
assert etat.code
def test_singleton_global(self):
p1 = activity_panel.get_activity_panel(activer_ui=False)
p2 = activity_panel.get_activity_panel(activer_ui=False)
assert p1 is p2
def test_reset_singleton(self):
p1 = activity_panel.get_activity_panel(activer_ui=False)
activity_panel.reset_activity_panel()
p2 = activity_panel.get_activity_panel(activer_ui=False)
assert p1 is not p2
# ============================================================================
# Tests : intégration executor ↔ notifier
# ============================================================================
class TestExecutorNotifierFallback:
"""Vérifier que le Noop fallback de l'executor couvre toutes les méthodes."""
def test_executor_noop_supporte_toutes_methodes(self):
"""Le fallback _Noop doit répondre à n'importe quelle méthode."""
# Simuler le cas où NotificationManager lève une exception
with patch(
"agent_v0.agent_v1.ui.notifications.NotificationManager",
side_effect=RuntimeError("UI indisponible"),
):
from agent_v0.agent_v1.core.executor import ActionExecutorV1
# Ne pas vraiment instancier (dépendances mss/pynput) — on teste
# la logique du stub en recréant la classe noop inline.
# Test direct du pattern noop utilisé dans executor
class _Noop:
def __getattr__(self, name):
return lambda *a, **kw: False
noop = _Noop()
# Toutes ces méthodes doivent retourner False sans crasher
assert noop.replay_target_not_found("x") is False
assert noop.replay_wrong_window("x", "y") is False
assert noop.replay_no_screen_change("click") is False
assert noop.notify_message(None) is False
assert noop.nimporte_quelle_methode() is False