Files
rpa_vision_v3/tests/unit/test_lea_notifications.py
Dom a6eb4c168f 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>
2026-04-10 08:42:01 +02:00

728 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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