From 7d765476de6cb90c201d8da52935d58047262e3f Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 21:04:01 +0200 Subject: [PATCH] feat: define Channel Protocol for output channels (M10-U2) Add @runtime_checkable Channel Protocol with send(XmppMessage) -> bool as the structural contract for all output channels (XMPP, future CalDAV, etc). 6 unit tests covering protocol structure, conforming/non-conforming classes, method signature introspection, and bool return type. Co-authored-by: opencode/test-engineer Co-authored-by: opencode/coder --- pronote_sync/channels/protocol.py | 27 ++++ tests/unit/test_channel_protocol.py | 202 ++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 pronote_sync/channels/protocol.py create mode 100644 tests/unit/test_channel_protocol.py diff --git a/pronote_sync/channels/protocol.py b/pronote_sync/channels/protocol.py new file mode 100644 index 0000000..85c4842 --- /dev/null +++ b/pronote_sync/channels/protocol.py @@ -0,0 +1,27 @@ +"""Protocole abstrait définissant le contrat des canaux de sortie.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from pronote_sync.models.xmpp import XmppMessage + + +@runtime_checkable +class Channel(Protocol): + """Contrat structurel d'un canal de sortie du pipeline. + + Un canal de sortie reçoit un message final :class:`XmppMessage` et tente de + l'envoyer vers la destination qu'il représente (CalDAV, XMPP, etc.). + + :ivar send: Envoie un message sur le canal. + """ + + def send(self, message: XmppMessage) -> bool: + """Envoie un message sur le canal. + + :param message: Message final à transmettre. + :return: ``True`` si l'envoi a réussi, ``False`` sinon. + :rtype: bool + """ + ... diff --git a/tests/unit/test_channel_protocol.py b/tests/unit/test_channel_protocol.py new file mode 100644 index 0000000..296c7b3 --- /dev/null +++ b/tests/unit/test_channel_protocol.py @@ -0,0 +1,202 @@ +"""Tests unitaires pour le Protocol Channel. + +Ce module valide la spécification du Protocol ``Channel`` qui sera ajouté +à ``pronote_sync.channels.protocol``. Ces tests doivent être ROUGES tant que +le Protocol n'est pas implémenté. +""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from pronote_sync.models.xmpp import XmppMessage + +# Import du Protocol à valider (doit échouer tant qu'il n'existe pas) +try: + from pronote_sync.channels.protocol import Channel + + CHANNEL_MODULE_EXISTS = True +except ImportError: + CHANNEL_MODULE_EXISTS = False + + +class TestChannelProtocol: + """Tests pour le Protocol Channel.""" + + def test_channel_is_protocol(self) -> None: + """Vérifie que Channel est un Protocol. + + :return: None + :raises AssertionError: Si Channel n'est pas un Protocol. + """ + if not CHANNEL_MODULE_EXISTS: + pytest.fail( + "Le module pronote_sync.channels.protocol n'existe pas encore. " + "Ceci est attendu pour l'instant." + ) + + assert hasattr(Channel, "_is_protocol"), "Channel doit être un sous-type de typing.Protocol" + + def test_channel_has_send_method(self) -> None: + """Vérifie que le Protocol Channel définit une méthode send. + + :return: None + :raises AssertionError: Si la méthode send n'est pas dans l'interface. + """ + if not CHANNEL_MODULE_EXISTS: + pytest.fail( + "Le module pronote_sync.channels.protocol n'existe pas encore. " + "Ceci est attendu pour l'instant." + ) + + assert hasattr(Channel, "send"), "Channel doit définir une méthode 'send'" + + send_method = Channel.send + assert callable(send_method), "La méthode 'send' doit être callable" + + def test_conforming_class_satisfies_protocol(self) -> None: + """Vérifie qu'une classe conforme satisfait le Protocol Channel. + + :return: None + :raises AssertionError: Si la classe conforme n'est pas acceptée. + """ + if not CHANNEL_MODULE_EXISTS: + pytest.fail( + "Le module pronote_sync.channels.protocol n'existe pas encore. " + "Ceci est attendu pour l'instant." + ) + + # Classe minimale conforme au Protocol + class DummyChannel: + """Implémentation minimale conforme au Protocol Channel.""" + + def send(self, message: XmppMessage) -> bool: + """Envoie un message XMPP. + + :param message: Message à envoyer. + :return: True si l'envoi a réussi. + :rtype: bool + """ + return True + + # Création d'une instance de message pour le test + test_message = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) + + # Instanciation et vérification + dummy_instance = DummyChannel() + assert dummy_instance.send(test_message) is True, "La méthode send doit retourner True" + + # Vérification que l'instance satisfait le Protocol + if hasattr(Channel, "__protocol_attrs__"): + # Vérification runtime avec @runtime_checkable + assert isinstance(dummy_instance, Channel), ( + "Une classe conforme doit satisfaire le Protocol Channel" + ) + + def test_non_conforming_class_does_not_satisfy_protocol(self) -> None: + """Vérifie qu'une classe non conforme ne satisfait pas le Protocol Channel. + + :return: None + :raises AssertionError: Si la classe non conforme est acceptée. + """ + if not CHANNEL_MODULE_EXISTS: + pytest.fail( + "Le module pronote_sync.channels.protocol n'existe pas encore. " + "Ceci est attendu pour l'instant." + ) + + # Classe minimale non conforme (sans méthode send) + class NonConformingChannel: + """Implémentation minimale non conforme au Protocol Channel.""" + + pass + + # Vérification que la classe ne satisfait pas le Protocol + non_conforming_instance = NonConformingChannel() + if hasattr(Channel, "__protocol_attrs__"): + # Vérification runtime avec @runtime_checkable + assert not isinstance(non_conforming_instance, Channel), ( + "Une classe non conforme ne doit pas satisfaire le Protocol Channel" + ) + + def test_channel_send_returns_bool(self) -> None: + """Vérifie que la méthode send retourne un booléen. + + :return: None + :raises AssertionError: Si le retour n'est pas de type bool. + """ + if not CHANNEL_MODULE_EXISTS: + pytest.fail( + "Le module pronote_sync.channels.protocol n'existe pas encore. " + "Ceci est attendu pour l'instant." + ) + + # Implémentation minimale retournant True + class BoolReturningChannel: + """Implémentation minimale retournant un booléen.""" + + def send(self, message: XmppMessage) -> bool: + """Envoie un message XMPP. + + :param message: Message à envoyer. + :return: True + :rtype: bool + """ + return True + + # Création d'une instance de message pour le test + test_message = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) + + # Test du retour + channel = BoolReturningChannel() + result = channel.send(test_message) + assert isinstance(result, bool), "La méthode send doit retourner un booléen" + assert result is True, "La méthode send doit retourner True dans cette implémentation" + + def test_channel_send_signature(self) -> None: + """Vérifie la signature déclarée de la méthode Channel.send. + + :return: None + :raises AssertionError: Si la signature ne correspond pas aux attentes. + """ + if not CHANNEL_MODULE_EXISTS: + pytest.fail( + "Le module pronote_sync.channels.protocol n'existe pas encore. " + "Ceci est attendu pour l'instant." + ) + + import inspect + + # Vérification de l'existence et de la nature callable de la méthode + assert hasattr(Channel, "send"), "Channel doit définir une méthode 'send'" + send_method = Channel.send + assert callable(send_method), "La méthode 'send' doit être callable" + + # Introspection de la signature + sig = inspect.signature(send_method) + params = list(sig.parameters.values()) + + # Vérification du nombre de paramètres (1 paramètre + self) + # On exclut 'self' pour vérifier le paramètre 'message' + param_count = len(params) + assert param_count == 2, ( + f"La méthode send doit avoir exactement 2 paramètres (self + message), " + f"trouvé {param_count}" + ) + + # Vérification du nom du paramètre (on ignore 'self') + param_names = [p.name for p in params if p.name != "self"] + assert len(param_names) == 1, "Doit avoir exactement un paramètre autre que self" + param_name = param_names[0] + assert param_name == "message", ( + f"Le paramètre doit s'appeler 'message', trouvé '{param_name}'" + ) + + # Vérification du type de retour + return_annotation = sig.return_annotation + # Le type de retour peut être soit la chaîne 'bool' soit le type bool (forward reference) + assert return_annotation in (bool, "bool"), ( + f"Le type de retour doit être 'bool' ou bool, trouvé {return_annotation}" + )