2026-05-07 21:27:41 -04:00
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from modjam.radio.udp import UDPRadio, _params_match, _psk_hash
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def radio(monkeypatch):
|
|
|
|
|
"""Construct a UDPRadio without binding sockets."""
|
|
|
|
|
monkeypatch.delenv("SIM_PACKET_LOSS", raising=False)
|
|
|
|
|
monkeypatch.delenv("SIM_SEED", raising=False)
|
|
|
|
|
return UDPRadio(station="A", peers=["B", "C"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _msg(**overrides):
|
|
|
|
|
base = {
|
|
|
|
|
"sender": "B",
|
|
|
|
|
"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "pow": 22,
|
|
|
|
|
"channel": "modjam", "psk_hash": "abc123",
|
|
|
|
|
"packet_id": 42,
|
2026-05-07 22:07:32 -04:00
|
|
|
"text": "1234567890.0,1.0,1,deadbeef|payload",
|
2026-05-07 21:27:41 -04:00
|
|
|
"ts": 0.0,
|
|
|
|
|
}
|
|
|
|
|
base.update(overrides)
|
|
|
|
|
return base
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tune(radio, **overrides):
|
|
|
|
|
radio._cur.update({
|
|
|
|
|
"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "pow": 22,
|
|
|
|
|
"channel": "modjam", "psk_hash": "abc123",
|
|
|
|
|
})
|
|
|
|
|
radio._cur.update(overrides)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_params_match_exact():
|
|
|
|
|
a = {"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "channel": "x", "psk_hash": "h"}
|
|
|
|
|
b = dict(a)
|
|
|
|
|
assert _params_match(a, b) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("field,bad", [
|
|
|
|
|
("freq", 916.0),
|
|
|
|
|
("bw", 250.0),
|
|
|
|
|
("sf", 8),
|
|
|
|
|
("cr", 6),
|
|
|
|
|
("channel", "other"),
|
|
|
|
|
("psk_hash", "different"),
|
|
|
|
|
])
|
|
|
|
|
def test_params_match_rejects_mismatch(field, bad):
|
|
|
|
|
a = {"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "channel": "x", "psk_hash": "h"}
|
|
|
|
|
b = dict(a)
|
|
|
|
|
b[field] = bad
|
|
|
|
|
assert _params_match(a, b) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_psk_hash_deterministic_and_short():
|
|
|
|
|
h1 = _psk_hash("hello")
|
|
|
|
|
h2 = _psk_hash("hello")
|
|
|
|
|
assert h1 == h2 and len(h1) == 16
|
|
|
|
|
assert _psk_hash("hello") != _psk_hash("world")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_handle_incoming_drops_self(radio):
|
|
|
|
|
_tune(radio)
|
|
|
|
|
received = []
|
|
|
|
|
radio.on_message(lambda p: received.append(p))
|
|
|
|
|
radio._handle_incoming(_msg(sender="A"))
|
|
|
|
|
assert received == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_handle_incoming_drops_param_mismatch(radio):
|
|
|
|
|
_tune(radio, sf=8)
|
|
|
|
|
received = []
|
|
|
|
|
radio.on_message(lambda p: received.append(p))
|
|
|
|
|
radio._handle_incoming(_msg(sf=7))
|
|
|
|
|
assert received == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_handle_incoming_delivers_match(radio):
|
|
|
|
|
_tune(radio)
|
|
|
|
|
received = []
|
|
|
|
|
radio.on_message(lambda p: received.append(p))
|
|
|
|
|
radio._handle_incoming(_msg())
|
|
|
|
|
assert len(received) == 1
|
|
|
|
|
assert received[0].sender == "B"
|
|
|
|
|
assert received[0].text.startswith("1234567890.0,")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_packet_loss_drops_test_packets(monkeypatch):
|
|
|
|
|
monkeypatch.setenv("SIM_PACKET_LOSS", "1.0") # drop everything
|
|
|
|
|
monkeypatch.setenv("SIM_SEED", "fixed")
|
|
|
|
|
r = UDPRadio(station="A", peers=["B"])
|
|
|
|
|
_tune(r)
|
|
|
|
|
received = []
|
|
|
|
|
r.on_message(lambda p: received.append(p))
|
|
|
|
|
for i in range(20):
|
2026-05-07 22:07:32 -04:00
|
|
|
r._handle_incoming(_msg(packet_id=i, text=f"1234.5,1.0,{i},aabbccdd|x"))
|
2026-05-07 21:27:41 -04:00
|
|
|
assert received == [] # 100% drop
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_packet_loss_does_not_drop_protocol_messages(monkeypatch):
|
|
|
|
|
monkeypatch.setenv("SIM_PACKET_LOSS", "1.0")
|
|
|
|
|
monkeypatch.setenv("SIM_SEED", "fixed")
|
|
|
|
|
r = UDPRadio(station="A", peers=["B"])
|
|
|
|
|
_tune(r)
|
|
|
|
|
received = []
|
|
|
|
|
r.on_message(lambda p: received.append(p))
|
|
|
|
|
# heartbeat + START + STOP should never drop, regardless of loss.
|
|
|
|
|
r._handle_incoming(_msg(text="1234567890|B|IDLE|-100"))
|
|
|
|
|
r._handle_incoming(_msg(text="START|name:foo"))
|
|
|
|
|
r._handle_incoming(_msg(text="STOP"))
|
|
|
|
|
r._handle_incoming(_msg(text="1234567890|B|next:915.1/500/7/5/22/40"))
|
|
|
|
|
assert len(received) == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_packet_loss_partial_with_seed_is_deterministic(monkeypatch):
|
|
|
|
|
monkeypatch.setenv("SIM_PACKET_LOSS", "0.5")
|
|
|
|
|
monkeypatch.setenv("SIM_SEED", "fixed-seed")
|
|
|
|
|
|
|
|
|
|
def run_once():
|
|
|
|
|
r = UDPRadio(station="A", peers=["B"])
|
|
|
|
|
_tune(r)
|
|
|
|
|
out = []
|
|
|
|
|
r.on_message(lambda p: out.append(p.packet_id))
|
|
|
|
|
for i in range(50):
|
2026-05-07 22:07:32 -04:00
|
|
|
r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i},11223344|x"))
|
2026-05-07 21:27:41 -04:00
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
a = run_once()
|
|
|
|
|
b = run_once()
|
|
|
|
|
assert a == b # deterministic
|
|
|
|
|
assert 0 < len(a) < 50 # some dropped, some kept
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_packet_loss_zero_keeps_all_test_packets(monkeypatch):
|
|
|
|
|
monkeypatch.setenv("SIM_PACKET_LOSS", "0")
|
|
|
|
|
r = UDPRadio(station="A", peers=["B"])
|
|
|
|
|
_tune(r)
|
|
|
|
|
received = []
|
|
|
|
|
r.on_message(lambda p: received.append(p))
|
|
|
|
|
for i in range(10):
|
2026-05-07 22:07:32 -04:00
|
|
|
r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i},55667788|y"))
|
2026-05-07 21:27:41 -04:00
|
|
|
assert len(received) == 10
|
2026-05-07 22:07:32 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_test_packet_regex_matches_legacy_and_token_formats():
|
|
|
|
|
from modjam.radio.udp import _TEST_PACKET_RE
|
|
|
|
|
assert _TEST_PACKET_RE.match("1234.5,1.0,42|abc") # legacy 3-field
|
|
|
|
|
assert _TEST_PACKET_RE.match("1234.5,1.0,42,deadbeef|abc") # with token
|
|
|
|
|
assert not _TEST_PACKET_RE.match("1234567890|A|IDLE|-100") # heartbeat
|
|
|
|
|
assert not _TEST_PACKET_RE.match("START|name:foo")
|