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