radicle-reticulum/tests/test_link.py

197 lines
6.3 KiB
Python

"""Tests for RadicleLink (pure logic — no RNS networking required)."""
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
from radicle_reticulum.link import RadicleLink, LinkState
def make_link(state: LinkState = LinkState.ACTIVE) -> tuple[RadicleLink, MagicMock]:
"""Create a RadicleLink with a mock RNS.Link."""
mock_rns_link = MagicMock()
mock_rns_link.rtt = None
link = RadicleLink(rns_link=mock_rns_link, state=state)
return link, mock_rns_link
class TestRadicleLinkState:
def test_active_link_is_active(self):
link, _ = make_link(LinkState.ACTIVE)
assert link.is_active
assert link.state == LinkState.ACTIVE
def test_pending_link_is_not_active(self):
link, _ = make_link(LinkState.PENDING)
assert not link.is_active
def test_on_established_sets_active(self):
link, _ = make_link(LinkState.PENDING)
link._on_established(MagicMock())
assert link.state == LinkState.ACTIVE
assert link.is_active
def test_on_closed_sets_closed(self):
link, _ = make_link(LinkState.ACTIVE)
link._on_closed(MagicMock())
assert link.state == LinkState.CLOSED
assert not link.is_active
def test_close_calls_teardown(self):
link, mock_rns = make_link(LinkState.ACTIVE)
link.close()
mock_rns.teardown.assert_called_once()
assert link.state == LinkState.CLOSED
def test_close_on_inactive_link_is_noop(self):
link, mock_rns = make_link(LinkState.CLOSED)
link.close()
mock_rns.teardown.assert_not_called()
def test_repr(self):
link, _ = make_link(LinkState.ACTIVE)
r = repr(link)
assert "active" in r
assert "RadicleLink" in r
class TestRadicleLinkSend:
def test_send_on_active_link_succeeds(self):
link, _ = make_link(LinkState.ACTIVE)
with patch("radicle_reticulum.link.RNS.Packet") as mock_packet_cls:
mock_packet = MagicMock()
mock_packet_cls.return_value = mock_packet
result = link.send(b"hello world")
assert result is True
mock_packet.send.assert_called_once()
def test_send_on_inactive_link_returns_false(self):
link, _ = make_link(LinkState.CLOSED)
result = link.send(b"data")
assert result is False
def test_send_on_pending_link_returns_false(self):
link, _ = make_link(LinkState.PENDING)
result = link.send(b"data")
assert result is False
def test_send_exception_returns_false(self):
link, _ = make_link(LinkState.ACTIVE)
with patch("radicle_reticulum.link.RNS.Packet") as mock_packet_cls:
mock_packet_cls.side_effect = RuntimeError("send failed")
result = link.send(b"data")
assert result is False
class TestRadicleLinkRecv:
def test_recv_returns_buffered_data(self):
link, _ = make_link()
link._on_packet(b"buffered", MagicMock())
result = link.recv(timeout=0.1)
assert result == b"buffered"
def test_recv_returns_in_order(self):
link, _ = make_link()
for i in range(3):
link._on_packet(f"msg{i}".encode(), MagicMock())
assert link.recv(timeout=0.1) == b"msg0"
assert link.recv(timeout=0.1) == b"msg1"
assert link.recv(timeout=0.1) == b"msg2"
def test_recv_timeout_returns_none(self):
link, _ = make_link()
start = time.time()
result = link.recv(timeout=0.05)
elapsed = time.time() - start
assert result is None
assert elapsed >= 0.04
def test_recv_woken_by_data(self):
link, _ = make_link()
results = []
def delayed_send():
time.sleep(0.05)
link._on_packet(b"delayed", MagicMock())
t = threading.Thread(target=delayed_send, daemon=True)
t.start()
result = link.recv(timeout=1.0)
t.join()
assert result == b"delayed"
def test_recv_returns_none_when_closed(self):
link, _ = make_link()
link._on_closed(MagicMock())
result = link.recv(timeout=0.1)
assert result is None
def test_recv_woken_by_close(self):
"""recv() unblocks when link closes while waiting."""
link, _ = make_link()
result_holder = []
def close_after_delay():
time.sleep(0.05)
link._on_closed(MagicMock())
t = threading.Thread(target=close_after_delay, daemon=True)
t.start()
result = link.recv(timeout=2.0)
t.join()
assert result is None
def test_on_packet_calls_on_data_callback(self):
link, _ = make_link()
received = []
link.on_data = received.append
link._on_packet(b"callback data", MagicMock())
assert received == [b"callback data"]
def test_on_closed_calls_on_close_callback(self):
link, _ = make_link()
called = []
link.on_close = lambda: called.append(True)
link._on_closed(MagicMock())
assert called == [True]
class TestRadicleLinkProperties:
def test_rtt_returns_none_when_unavailable(self):
link, mock_rns = make_link()
mock_rns.rtt = None
assert link.rtt is None
def test_rtt_returns_value_when_available(self):
link, mock_rns = make_link()
mock_rns.rtt = 0.15
assert link.rtt == pytest.approx(0.15)
def test_remote_identity_delegates_to_rns(self):
link, mock_rns = make_link()
fake_id = MagicMock()
mock_rns.get_remote_identity.return_value = fake_id
assert link.remote_identity is fake_id
class TestRadicleLinkFactories:
def test_from_incoming_is_active(self):
mock_rns = MagicMock()
link = RadicleLink.from_incoming(mock_rns)
assert link.state == LinkState.ACTIVE
def test_from_incoming_does_not_set_established_callback(self):
mock_rns = MagicMock()
RadicleLink.from_incoming(mock_rns)
mock_rns.set_link_established_callback.assert_not_called()
def test_create_outbound_is_pending(self):
mock_dest = MagicMock()
with patch("radicle_reticulum.link.RNS.Link") as mock_link_cls:
mock_rns = MagicMock()
mock_link_cls.return_value = mock_rns
link = RadicleLink.create_outbound(mock_dest)
assert link.state == LinkState.PENDING