# tests/unit/test_chat_interface.py """ Tests unitaires du module chat_interface (Léa conversationnelle). Vérifie : 1. Création de session (état initial, message d'accueil) 2. Envoi de message → appel TaskPlanner mocké 3. Historique (get_history) 4. Transitions d'états idle → planning → awaiting_confirmation → executing → done 5. Abandon (utilisateur répond "non") 6. Fallback gracieux quand gemma4/TaskPlanner indisponible 7. ChatManager (création, listing, cleanup) """ from __future__ import annotations import sys import time from pathlib import Path from unittest.mock import MagicMock import pytest _ROOT = str(Path(__file__).resolve().parents[2]) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) from agent_v0.server_v1.chat_interface import ( ChatSession, ChatManager, STATE_IDLE, STATE_PLANNING, STATE_AWAITING_CONFIRMATION, STATE_EXECUTING, STATE_DONE, STATE_ERROR, ROLE_USER, ROLE_LEA, ) from agent_v0.server_v1.task_planner import TaskPlan # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def sample_workflows(): return [ { "session_id": "sess_bloc_notes", "name": "Bloc-notes", "description": "Ouvrir Bloc-notes via Exécuter (Win+R) et écrire du texte", }, ] @pytest.fixture def understood_plan(): """TaskPlan qui a compris l'ordre et matche un workflow.""" return TaskPlan( instruction="ouvre le bloc-notes et écris bonjour", understood=True, workflow_match="sess_bloc_notes", workflow_name="Bloc-notes", match_confidence=0.9, parameters={"texte": "bonjour"}, is_loop=False, mode="replay", ) @pytest.fixture def unknown_plan(): """TaskPlan qui n'a pas compris.""" return TaskPlan( instruction="fais le café", understood=False, error="aucun workflow ne correspond", ) @pytest.fixture def mock_task_planner(understood_plan): planner = MagicMock() planner.understand.return_value = understood_plan return planner @pytest.fixture def mock_replay_callback(): return MagicMock(return_value="replay_abc123") @pytest.fixture def mock_status_provider(): """Retourne un dict par défaut 'running' — peut être modifié dans les tests.""" return MagicMock(return_value={ "status": "running", "completed_actions": 1, "total_actions": 5, }) @pytest.fixture def session(mock_task_planner, sample_workflows, mock_replay_callback, mock_status_provider): return ChatSession( task_planner=mock_task_planner, workflows_provider=lambda: sample_workflows, replay_callback=mock_replay_callback, status_provider=mock_status_provider, ) # ============================================================================= # Tests création session # ============================================================================= class TestSessionCreation: def test_session_id_generated(self): s = ChatSession() assert s.session_id.startswith("chat_") def test_initial_state_is_idle(self): s = ChatSession() assert s.state == STATE_IDLE def test_welcome_message_present(self): s = ChatSession() history = s.get_history() assert len(history) == 1 assert history[0]["role"] == ROLE_LEA assert "Bonjour" in history[0]["content"] or "Léa" in history[0]["content"] def test_session_id_custom(self): s = ChatSession(session_id="custom_42") assert s.session_id == "custom_42" # ============================================================================= # Tests send_message # ============================================================================= class TestSendMessage: def test_empty_message_rejected(self, session): result = session.send_message("") assert result["ok"] is False def test_send_message_calls_planner(self, session, mock_task_planner): session.send_message("ouvre le bloc-notes") mock_task_planner.understand.assert_called_once() call = mock_task_planner.understand.call_args assert call.kwargs["instruction"] == "ouvre le bloc-notes" # workflows_provider a été appelé et passé assert "available_workflows" in call.kwargs assert len(call.kwargs["available_workflows"]) == 1 def test_send_message_transitions_to_awaiting_confirmation(self, session): result = session.send_message("ouvre le bloc-notes") assert result["ok"] is True assert session.state == STATE_AWAITING_CONFIRMATION assert result["state"] == STATE_AWAITING_CONFIRMATION def test_user_message_added_to_history(self, session): session.send_message("ouvre le bloc-notes") history = session.get_history() user_msgs = [m for m in history if m["role"] == ROLE_USER] assert len(user_msgs) == 1 assert user_msgs[0]["content"] == "ouvre le bloc-notes" def test_lea_proposal_added_to_history(self, session): session.send_message("ouvre le bloc-notes") history = session.get_history() lea_msgs = [m for m in history if m["role"] == ROLE_LEA] # Bienvenue + proposition assert len(lea_msgs) == 2 proposal = lea_msgs[-1]["content"] assert "Bloc-notes" in proposal assert "oui" in proposal.lower() or "y aller" in proposal.lower() def test_proposal_contains_confidence(self, session): session.send_message("ouvre le bloc-notes") history = session.get_history() proposal = history[-1]["content"] # 0.9 → 90% assert "90" in proposal def test_proposal_contains_parameters(self, session): session.send_message("ouvre le bloc-notes") history = session.get_history() proposal = history[-1]["content"] assert "texte" in proposal assert "bonjour" in proposal # ============================================================================= # Tests get_history # ============================================================================= class TestGetHistory: def test_history_returns_list_of_dicts(self, session): history = session.get_history() assert isinstance(history, list) assert all(isinstance(m, dict) for m in history) def test_history_message_structure(self, session): history = session.get_history() msg = history[0] assert "role" in msg assert "content" in msg assert "timestamp" in msg assert "meta" in msg def test_history_grows_with_messages(self, session): initial = len(session.get_history()) session.send_message("ouvre le bloc-notes") assert len(session.get_history()) > initial # ============================================================================= # Tests transitions d'états # ============================================================================= class TestStateTransitions: def test_full_happy_path(self, session, mock_task_planner, mock_replay_callback): """idle → planning → awaiting_confirmation → executing → done.""" # Départ : idle assert session.state == STATE_IDLE # Envoi message → planning → awaiting_confirmation session.send_message("ouvre le bloc-notes") assert session.state == STATE_AWAITING_CONFIRMATION # Confirmation → executing result = session.confirm(confirmed=True) assert result["ok"] is True assert session.state == STATE_EXECUTING mock_replay_callback.assert_called_once() call = mock_replay_callback.call_args assert call.kwargs["session_id"] == "sess_bloc_notes" # Simulation : replay terminé → done session._status_provider.return_value = { "status": "done", "completed_actions": 5, "total_actions": 5, } session.refresh_progress() assert session.state == STATE_DONE def test_confirm_via_message_oui(self, session, mock_replay_callback): """Le TIM peut répondre 'oui' en message au lieu d'un bouton.""" session.send_message("ouvre le bloc-notes") assert session.state == STATE_AWAITING_CONFIRMATION session.send_message("oui") assert session.state == STATE_EXECUTING mock_replay_callback.assert_called_once() def test_refusal_via_confirm_false(self, session, mock_replay_callback): """confirm(False) → retour à idle, pas d'exécution.""" session.send_message("ouvre le bloc-notes") result = session.confirm(confirmed=False) assert result["ok"] is True assert result["confirmed"] is False assert session.state == STATE_IDLE mock_replay_callback.assert_not_called() def test_refusal_via_message_non(self, session, mock_replay_callback): """Le TIM répond 'non' → annulation.""" session.send_message("ouvre le bloc-notes") session.send_message("non") assert session.state == STATE_IDLE mock_replay_callback.assert_not_called() # Le message d'annulation doit être dans l'historique history = session.get_history() assert any("annule" in m["content"].lower() for m in history) def test_ambiguous_confirmation_reply(self, session): """Réponse ambiguë pendant awaiting_confirmation → demande de clarification.""" session.send_message("ouvre le bloc-notes") result = session.send_message("peut-être") assert session.state == STATE_AWAITING_CONFIRMATION assert result.get("needs_clarification") is True def test_failed_replay_transitions_to_error(self, session): """replay_callback lève une exception → état error.""" session._replay_callback = MagicMock(side_effect=RuntimeError("boom")) session.send_message("ouvre le bloc-notes") result = session.confirm(confirmed=True) assert result["ok"] is False assert session.state == STATE_ERROR def test_replay_failure_from_status(self, session): """Le replay rapporte 'failed' → état error.""" session.send_message("ouvre le bloc-notes") session.confirm(confirmed=True) assert session.state == STATE_EXECUTING session._status_provider.return_value = { "status": "failed", "error": "element introuvable", } session.refresh_progress() assert session.state == STATE_ERROR # ============================================================================= # Tests fallback / résilience # ============================================================================= class TestResilience: def test_no_task_planner_graceful(self): """Sans TaskPlanner, on reste gracieux.""" s = ChatSession(task_planner=None) result = s.send_message("test") assert result["ok"] is False assert s.state == STATE_ERROR # Message d'erreur présent dans l'historique history = s.get_history() assert any("désolée" in m["content"].lower() or "indisponible" in m["content"].lower() for m in history) def test_task_planner_exception_graceful(self, mock_replay_callback): """TaskPlanner lève une exception (gemma4 down) → état error propre.""" planner = MagicMock() planner.understand.side_effect = RuntimeError("gemma4 offline") s = ChatSession( task_planner=planner, workflows_provider=lambda: [], replay_callback=mock_replay_callback, ) result = s.send_message("test") assert result["ok"] is False assert s.state == STATE_ERROR def test_instruction_not_understood(self, unknown_plan, mock_replay_callback): """Plan.understood = False → message d'erreur explicite.""" planner = MagicMock() planner.understand.return_value = unknown_plan s = ChatSession( task_planner=planner, workflows_provider=lambda: [], replay_callback=mock_replay_callback, ) result = s.send_message("fais le café") assert result["ok"] is False assert s.state == STATE_ERROR history = s.get_history() assert any("reformuler" in m["content"].lower() for m in history) def test_no_replay_callback(self, mock_task_planner, sample_workflows): """Sans replay_callback, on refuse l'exécution proprement.""" s = ChatSession( task_planner=mock_task_planner, workflows_provider=lambda: sample_workflows, replay_callback=None, ) s.send_message("ouvre le bloc-notes") result = s.confirm(confirmed=True) assert result["ok"] is False assert s.state == STATE_ERROR # ============================================================================= # Tests snapshot # ============================================================================= class TestSnapshot: def test_snapshot_structure(self, session): snap = session.get_snapshot() assert "session_id" in snap assert "state" in snap assert "messages" in snap assert "pending_plan" in snap assert "active_replay_id" in snap assert "progress" in snap def test_snapshot_includes_pending_plan_when_awaiting(self, session): session.send_message("ouvre le bloc-notes") snap = session.get_snapshot() assert snap["state"] == STATE_AWAITING_CONFIRMATION assert snap["pending_plan"] is not None assert snap["pending_plan"]["workflow_name"] == "Bloc-notes" def test_snapshot_no_pending_plan_in_idle(self, session): snap = session.get_snapshot() assert snap["pending_plan"] is None # ============================================================================= # Tests ChatManager # ============================================================================= class TestChatManager: def test_create_session(self, mock_task_planner, sample_workflows): mgr = ChatManager( task_planner=mock_task_planner, workflows_provider=lambda: sample_workflows, ) s = mgr.create_session() assert s is not None assert s.session_id in [x["session_id"] for x in mgr.list_sessions()] def test_get_session(self, mock_task_planner): mgr = ChatManager(task_planner=mock_task_planner) s = mgr.create_session() retrieved = mgr.get_session(s.session_id) assert retrieved is s def test_get_session_not_found(self): mgr = ChatManager() assert mgr.get_session("unknown") is None def test_delete_session(self, mock_task_planner): mgr = ChatManager(task_planner=mock_task_planner) s = mgr.create_session() assert mgr.delete_session(s.session_id) is True assert mgr.get_session(s.session_id) is None def test_cleanup_old_sessions(self, mock_task_planner): mgr = ChatManager(task_planner=mock_task_planner) s = mgr.create_session() # Simuler une session très ancienne s.updated_at = time.time() - 100000 removed = mgr.cleanup_old(max_age_s=3600) assert removed == 1 assert mgr.get_session(s.session_id) is None def test_list_sessions_structure(self, mock_task_planner): mgr = ChatManager(task_planner=mock_task_planner) mgr.create_session(machine_id="pc-01") sessions = mgr.list_sessions() assert len(sessions) == 1 s = sessions[0] assert "session_id" in s assert "state" in s assert "machine_id" in s assert s["machine_id"] == "pc-01"