197 lines
6.3 KiB
Python
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
|