"""Tests FeedbackBusClient (J3.2). On mock python-socketio pour ne pas ouvrir de vraie connexion réseau. Le test E2E réel (vraie connexion bus 5004) est différé à J4.3. """ from unittest.mock import patch import pytest from agent_v0.agent_v1.network.feedback_bus import FeedbackBusClient, LEA_EVENTS def test_init_creates_socketio_client(): bus = FeedbackBusClient("http://localhost:5004") assert bus._sio is not None assert bus.connected is False def test_init_strips_trailing_slash(): bus = FeedbackBusClient("http://localhost:5004/") assert bus._url == "http://localhost:5004" def test_lea_events_registered(): bus = FeedbackBusClient("http://localhost:5004") handlers = bus._sio.handlers.get('/', {}) for ev in LEA_EVENTS: assert ev in handlers, f"Handler {ev!r} non enregistré sur le client" def test_dispatch_calls_callback(): received = [] bus = FeedbackBusClient( "http://localhost:5004", on_event=lambda e, p: received.append((e, p)), ) bus._dispatch('lea:paused', {'workflow': 'demo', 'reason': 'incertain'}) assert received == [('lea:paused', {'workflow': 'demo', 'reason': 'incertain'})] def test_dispatch_handles_none_payload(): received = [] bus = FeedbackBusClient( "http://localhost:5004", on_event=lambda e, p: received.append((e, p)), ) bus._dispatch('lea:done', None) assert received == [('lea:done', {})] def test_dispatch_silenced_on_callback_error(): """Une exception dans le callback consommateur ne doit jamais remonter.""" def boom(event, payload): raise RuntimeError("callback fail") bus = FeedbackBusClient("http://localhost:5004", on_event=boom) bus._dispatch('lea:paused', {}) # ne doit pas raise def test_default_callback_is_silent(): """Sans callback fourni, le dispatch ne casse pas.""" bus = FeedbackBusClient("http://localhost:5004") bus._dispatch('lea:paused', {'x': 1}) # ne doit pas raise def test_token_in_authorization_header(): bus = FeedbackBusClient("http://localhost:5004", token="abc123") captured = {} def fake_connect(url, headers=None, **kwargs): captured['headers'] = headers raise RuntimeError("stop here") with patch.object(bus._sio, 'connect', side_effect=fake_connect): bus._run() assert captured['headers']['Authorization'] == 'Bearer abc123' def test_no_token_means_no_auth_header(): bus = FeedbackBusClient("http://localhost:5004") captured = {} def fake_connect(url, headers=None, **kwargs): captured['headers'] = headers raise RuntimeError("stop here") with patch.object(bus._sio, 'connect', side_effect=fake_connect): bus._run() assert 'Authorization' not in captured['headers'] def test_run_silenced_on_connect_error(): """connect() qui raise ne doit pas faire crasher le thread.""" bus = FeedbackBusClient("http://localhost:5004") with patch.object(bus._sio, 'connect', side_effect=ConnectionError("boom")): bus._run() # ne doit pas raise def test_start_is_idempotent(): """Un second start() pendant que le thread tourne ne doit pas en créer un autre.""" import threading bus = FeedbackBusClient("http://localhost:5004") block = threading.Event() with patch.object(bus, '_run', side_effect=lambda: block.wait(timeout=2)): bus.start() first_thread = bus._thread bus.start() second_thread = bus._thread block.set() assert first_thread is second_thread, "start() doit être idempotent quand un thread tourne" def test_stop_when_not_connected_is_silent(): bus = FeedbackBusClient("http://localhost:5004") bus.stop() # ne doit pas raise même si jamais connecté def test_stop_silenced_on_disconnect_error(): bus = FeedbackBusClient("http://localhost:5004") # Forcer connected=True sur l'instance et faire raise disconnect() with patch.object(bus._sio, 'disconnect', side_effect=RuntimeError("boom")): bus._sio.connected = True bus.stop() # ne doit pas raise # ---------------------------------------------------------------------- # J3.5 — Actions utilisateur (resume_replay / abort_replay) # ---------------------------------------------------------------------- def test_resume_replay_emits_when_connected(): bus = FeedbackBusClient("http://localhost:5004") bus._sio.connected = True with patch.object(bus._sio, 'emit') as mock_emit: ok = bus.resume_replay("rep_abc") assert ok is True mock_emit.assert_called_once_with("lea:replay_resume", {"replay_id": "rep_abc"}) def test_resume_replay_returns_false_when_disconnected(): bus = FeedbackBusClient("http://localhost:5004") # _sio.connected reste False par défaut with patch.object(bus._sio, 'emit') as mock_emit: ok = bus.resume_replay("rep_abc") assert ok is False mock_emit.assert_not_called() def test_abort_replay_emits_when_connected(): bus = FeedbackBusClient("http://localhost:5004") bus._sio.connected = True with patch.object(bus._sio, 'emit') as mock_emit: ok = bus.abort_replay("rep_xyz") assert ok is True mock_emit.assert_called_once_with("lea:replay_abort", {"replay_id": "rep_xyz"}) def test_safe_emit_silenced_on_error(): bus = FeedbackBusClient("http://localhost:5004") bus._sio.connected = True with patch.object(bus._sio, 'emit', side_effect=RuntimeError("boom")): ok = bus.resume_replay("rep_abc") assert ok is False # erreur avalée silencieusement