198 lines
8.0 KiB
Python
198 lines
8.0 KiB
Python
"""Tests pour ChatWindow._dispatch_paused_action.
|
||
|
||
Couvre le routage bus SocketIO → fallback HTTP de la bulle paused.
|
||
Le bug d'origine ``paused_bubble: bus déconnecté, resume non émis``
|
||
était causé par l'absence de ce fallback (cf.
|
||
``docs/CR_AUDIT_PAUSED_RESUME_BUS_2026-05-22.md``).
|
||
|
||
Les tests appellent ``ChatWindow._dispatch_paused_action`` en tant
|
||
que fonction unbound avec un faux ``self`` (``SimpleNamespace``) pour
|
||
éviter de démarrer Tkinter pendant les tests unitaires.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock
|
||
|
||
ROOT = Path(__file__).parent.parent.parent
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from agent_v0.agent_v1.ui.chat_window import ChatWindow # noqa: E402
|
||
|
||
|
||
def _make_self(bus=None, server_client=None):
|
||
return SimpleNamespace(_bus=bus, _server_client=server_client)
|
||
|
||
|
||
def _call(mock_self, replay_id="replay_xyz",
|
||
bus_method="resume_replay", client_method="resume_replay"):
|
||
return ChatWindow._dispatch_paused_action(
|
||
mock_self, replay_id, bus_method=bus_method, client_method=client_method,
|
||
)
|
||
|
||
|
||
class TestDispatchPausedAction:
|
||
def test_bus_connected_and_emits_uses_bus(self):
|
||
bus = MagicMock(connected=True)
|
||
bus.resume_replay.return_value = True
|
||
client = MagicMock(resume_replay=MagicMock(return_value=True))
|
||
emitted, channel = _call(_make_self(bus=bus, server_client=client))
|
||
assert emitted is True
|
||
assert channel == "bus"
|
||
bus.resume_replay.assert_called_once_with("replay_xyz")
|
||
client.resume_replay.assert_not_called()
|
||
|
||
def test_bus_disconnected_falls_back_to_http(self):
|
||
bus = MagicMock(connected=False)
|
||
client = MagicMock(resume_replay=MagicMock(return_value=True))
|
||
emitted, channel = _call(_make_self(bus=bus, server_client=client))
|
||
assert emitted is True
|
||
assert channel == "http"
|
||
bus.resume_replay.assert_not_called()
|
||
client.resume_replay.assert_called_once_with("replay_xyz")
|
||
|
||
def test_bus_emit_returns_false_falls_back_to_http(self):
|
||
"""Bus marqué connecté mais l'emit retourne False (socket cassé
|
||
entre connect() et send) → bascule sur HTTP."""
|
||
bus = MagicMock(connected=True)
|
||
bus.resume_replay.return_value = False
|
||
client = MagicMock(resume_replay=MagicMock(return_value=True))
|
||
emitted, channel = _call(_make_self(bus=bus, server_client=client))
|
||
assert emitted is True
|
||
assert channel == "http"
|
||
|
||
def test_bus_emit_raises_falls_back_to_http(self):
|
||
bus = MagicMock(connected=True)
|
||
bus.resume_replay.side_effect = RuntimeError("socket broken")
|
||
client = MagicMock(resume_replay=MagicMock(return_value=True))
|
||
emitted, channel = _call(_make_self(bus=bus, server_client=client))
|
||
assert emitted is True
|
||
assert channel == "http"
|
||
|
||
def test_no_bus_uses_http_directly(self):
|
||
client = MagicMock(resume_replay=MagicMock(return_value=True))
|
||
emitted, channel = _call(_make_self(bus=None, server_client=client))
|
||
assert emitted is True
|
||
assert channel == "http"
|
||
|
||
def test_all_channels_fail_returns_false(self):
|
||
"""Cas critique : bus déconnecté ET HTTP injoignable → l'UI
|
||
doit ré-activer les boutons côté appelant. Ici on vérifie
|
||
juste que dispatch retourne (False, '')."""
|
||
bus = MagicMock(connected=False)
|
||
client = MagicMock(resume_replay=MagicMock(return_value=False))
|
||
emitted, channel = _call(_make_self(bus=bus, server_client=client))
|
||
assert emitted is False
|
||
assert channel == ""
|
||
|
||
def test_neither_bus_nor_client_returns_false(self):
|
||
emitted, channel = _call(_make_self(bus=None, server_client=None))
|
||
assert emitted is False
|
||
assert channel == ""
|
||
|
||
def test_client_method_missing_falls_through(self):
|
||
"""Si server_client est un vieux client sans resume_replay,
|
||
on ne plante pas — on retourne (False, '')."""
|
||
bus = MagicMock(connected=False)
|
||
legacy_client = SimpleNamespace() # pas de resume_replay
|
||
emitted, channel = _call(
|
||
_make_self(bus=bus, server_client=legacy_client),
|
||
)
|
||
assert emitted is False
|
||
assert channel == ""
|
||
|
||
def test_abort_routing_symmetric(self):
|
||
"""Le même mécanisme couvre l'abort — vérifie qu'on utilise
|
||
bien la méthode demandée par le caller."""
|
||
bus = MagicMock(connected=False)
|
||
client = MagicMock(abort_replay=MagicMock(return_value=True))
|
||
emitted, channel = _call(
|
||
_make_self(bus=bus, server_client=client),
|
||
bus_method="abort_replay",
|
||
client_method="abort_replay",
|
||
)
|
||
assert emitted is True
|
||
assert channel == "http"
|
||
client.abort_replay.assert_called_once_with("replay_xyz")
|
||
|
||
|
||
class TestPausedBubbleHeight:
|
||
"""Couvre _compute_paused_bubble_height — anti-troncature pause UI."""
|
||
|
||
def test_empty_message_uses_minimum_height(self):
|
||
h, scroll = ChatWindow._compute_paused_bubble_height("")
|
||
assert h == 2
|
||
assert scroll is False
|
||
|
||
def test_short_message_no_scrollbar(self):
|
||
h, scroll = ChatWindow._compute_paused_bubble_height("Court message.")
|
||
assert h == 2
|
||
assert scroll is False
|
||
|
||
def test_long_single_line_triggers_scrollbar(self):
|
||
msg = "x" * 600
|
||
h, scroll = ChatWindow._compute_paused_bubble_height(msg)
|
||
assert h == 12
|
||
assert scroll is True
|
||
|
||
def test_narrow_window_estimate_keeps_wrong_window_message_visible(self):
|
||
"""Cas observé sur Windows : fenêtre Léa ~380px, message wrong_window
|
||
coupé après "attendu". Avec ~34 caractères par ligne, il faut
|
||
prévoir assez de lignes pour afficher le détail."""
|
||
msg = (
|
||
"Je m'attendais à voir la bonne fenêtre mais je vois autre chose. "
|
||
"Peux-tu vérifier que l'application est au premier plan ? "
|
||
"(Fenêtre incorrecte : attendu "
|
||
"'http192.168.1.408765dossier.htmlid=.txt - Bloc-notes', "
|
||
"actuel 'Program Manager')"
|
||
)
|
||
h, scroll = ChatWindow._compute_paused_bubble_height(
|
||
msg,
|
||
chars_per_line=34,
|
||
)
|
||
assert h >= 7
|
||
assert scroll is True
|
||
|
||
def test_message_with_many_newlines_uses_explicit_count(self):
|
||
"""Cas du bug : reason serveur listant 6 candidats sur 6 lignes
|
||
courtes — wrapped_lines bas mais explicit_lines élevé."""
|
||
msg = "\n".join([f"option {i}" for i in range(6)])
|
||
h, scroll = ChatWindow._compute_paused_bubble_height(msg)
|
||
# 6 lignes explicites > 2 lignes wrappées → hauteur = 6
|
||
assert h == 6
|
||
# Pas encore au cap, contenu court → pas de scrollbar
|
||
assert scroll is False
|
||
|
||
def test_cap_reached_triggers_scrollbar_even_if_short(self):
|
||
"""Quand on dépasse le cap, la scrollbar DOIT
|
||
s'afficher quel que soit la longueur en caractères."""
|
||
msg = "\n".join([f"l{i}" for i in range(20)])
|
||
h, scroll = ChatWindow._compute_paused_bubble_height(msg)
|
||
assert h == 14 # plafond
|
||
assert scroll is True
|
||
|
||
def test_long_content_triggers_scrollbar_at_200_chars(self):
|
||
"""Seuil sécurité texte : ≥ 200 chars → scrollbar même si
|
||
peu de lignes (filet anti-troncature visuel)."""
|
||
msg = "x" * 220
|
||
h, scroll = ChatWindow._compute_paused_bubble_height(msg)
|
||
assert scroll is True
|
||
|
||
def test_dynamic_small_viewport_caps_rows_and_scrolls(self):
|
||
msg = (
|
||
"Je m'attendais à voir la bonne fenêtre mais je vois autre chose. "
|
||
"Peux-tu vérifier que l'application est au premier plan ? "
|
||
"(Post-vérif échouée : fenêtre '*test – Bloc-notes' au lieu de "
|
||
"'Enregistrer sous')"
|
||
)
|
||
h, scroll = ChatWindow._compute_paused_bubble_height(
|
||
msg,
|
||
chars_per_line=32,
|
||
max_rows=5,
|
||
)
|
||
assert h == 5
|
||
assert scroll is True
|