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>
728 lines
27 KiB
Python
728 lines
27 KiB
Python
"""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
|