Files
columba/python/test_wrapper_config.py
torlando-tech 7091fbe67f test: Add 30 tests to improve TCP RNode patch coverage to 80%+
Add comprehensive test coverage for TCP RNode functionality:

RNodeWizardViewModelTest.kt (+16 tests):
- getPopularPresetsForRegion tests for all region types (US, AU, EU sub-bands)
- Tests for regions with no presets (Brazil, Russia, Japan)
- Asia-Pacific region filtering tests
- Frequency band exclusion tests

ServiceReticulumProtocolTest.kt (+2 tests):
- buildConfigJson includes TCP host/port for RNode
- buildConfigJson omits tcp_host when null (Bluetooth mode)

InterfaceRepositoryTest.kt (+1 test):
- Validates TCP RNode with invalid hostname format is skipped

test_wrapper_config.py (+6 tests):
- TCP RNode config generation with all LoRa parameters
- Airtime limits (st_alock, lt_alock) handling
- Interface mode handling (gateway, boundary, full)

test_wrapper_ble.py (+5 tests):
- get_paired_rnodes success/error/exception handling
- Empty device list and null response handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 01:58:19 -05:00

1295 lines
44 KiB
Python

"""
Test suite for ReticulumWrapper config manipulation methods.
Tests _remove_autointerface_from_config() and _setup_interface() methods
for handling RNS config file modifications and interface setup.
"""
import sys
import os
import unittest
from unittest.mock import Mock, MagicMock, patch
import tempfile
import shutil
# Add parent directory to path to import reticulum_wrapper
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Mock RNS and LXMF before importing reticulum_wrapper
sys.modules['RNS'] = MagicMock()
sys.modules['RNS.vendor'] = MagicMock()
sys.modules['RNS.vendor.platformutils'] = MagicMock()
sys.modules['LXMF'] = MagicMock()
# Now import after mocking
import reticulum_wrapper
class TestRemoveAutoInterfaceFromConfig(unittest.TestCase):
"""Test _remove_autointerface_from_config method"""
def setUp(self):
"""Set up test fixtures"""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
self.config_path = os.path.join(self.temp_dir, "config")
def tearDown(self):
"""Clean up test fixtures"""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_removes_autointerface_section_from_valid_config(self):
"""Test that AutoInterface section is removed from valid config file"""
# Create a config file with AutoInterface section
config_content = """[reticulum]
enable_transport = False
share_instance = No
[[Auto Discovery]]
type = AutoInterface
enabled = True
[[TCP Interface]]
type = TCPClientInterface
enabled = True
target_host = 127.0.0.1
target_port = 4242
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
# Remove AutoInterface section
self.wrapper._remove_autointerface_from_config()
# Verify AutoInterface section is removed
with open(self.config_path, 'r') as f:
new_content = f.read()
self.assertNotIn("[[Auto Discovery]]", new_content)
self.assertNotIn("type = AutoInterface", new_content)
# Verify other sections remain
self.assertIn("[[TCP Interface]]", new_content)
self.assertIn("type = TCPClientInterface", new_content)
self.assertIn("[reticulum]", new_content)
def test_handles_missing_config_file(self):
"""Test that method handles missing config file gracefully"""
# Ensure config file doesn't exist
if os.path.exists(self.config_path):
os.remove(self.config_path)
# Should not raise exception
try:
self.wrapper._remove_autointerface_from_config()
except Exception as e:
self.fail(f"Method raised unexpected exception: {e}")
def test_handles_config_with_no_autointerface_section(self):
"""Test that method handles config file with no AutoInterface section"""
# Create config without AutoInterface
config_content = """[reticulum]
enable_transport = False
share_instance = No
[[TCP Interface]]
type = TCPClientInterface
enabled = True
target_host = 127.0.0.1
target_port = 4242
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
original_content = config_content
# Remove AutoInterface section (should do nothing)
self.wrapper._remove_autointerface_from_config()
# Verify content unchanged
with open(self.config_path, 'r') as f:
new_content = f.read()
self.assertEqual(new_content, original_content)
def test_handles_permission_error(self):
"""Test that method handles permission errors appropriately"""
# Create config file
config_content = """[[Auto Discovery]]
type = AutoInterface
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
# Make file read-only
os.chmod(self.config_path, 0o444)
try:
# Should raise exception due to permission error
with self.assertRaises(Exception):
self.wrapper._remove_autointerface_from_config()
finally:
# Restore permissions for cleanup
os.chmod(self.config_path, 0o644)
def test_removes_only_autointerface_section_not_content(self):
"""Test that only AutoInterface section is removed, not individual lines"""
# Create config with AutoInterface section and other interfaces
config_content = """[reticulum]
enable_transport = False
[[Auto Discovery]]
type = AutoInterface
enabled = True
some_setting = value
[[UDP Interface]]
type = UDPInterface
enabled = True
port = 4242
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
# Remove AutoInterface section
self.wrapper._remove_autointerface_from_config()
# Verify only AutoInterface section is removed
with open(self.config_path, 'r') as f:
lines = f.readlines()
# AutoInterface section and its contents should be gone
for line in lines:
self.assertNotIn("Auto Discovery", line)
self.assertNotIn("type = AutoInterface", line)
self.assertNotIn("some_setting = value", line)
# Other sections should remain
content = ''.join(lines)
self.assertIn("[[UDP Interface]]", content)
self.assertIn("type = UDPInterface", content)
def test_handles_multiple_sections_after_autointerface(self):
"""Test that sections after AutoInterface are preserved"""
config_content = """[[Auto Discovery]]
type = AutoInterface
enabled = True
[[TCP Interface]]
type = TCPClientInterface
enabled = True
[[UDP Interface]]
type = UDPInterface
enabled = True
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
# Remove AutoInterface section
self.wrapper._remove_autointerface_from_config()
# Verify both subsequent sections remain
with open(self.config_path, 'r') as f:
content = f.read()
self.assertNotIn("[[Auto Discovery]]", content)
self.assertIn("[[TCP Interface]]", content)
self.assertIn("[[UDP Interface]]", content)
def test_preserves_section_order(self):
"""Test that the order of remaining sections is preserved"""
config_content = """[reticulum]
setting1 = value1
[[Auto Discovery]]
type = AutoInterface
[[First Interface]]
type = First
[[Second Interface]]
type = Second
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
# Remove AutoInterface section
self.wrapper._remove_autointerface_from_config()
# Verify order is preserved
with open(self.config_path, 'r') as f:
lines = f.readlines()
# Find indices of sections
reticulum_idx = next(i for i, line in enumerate(lines) if '[reticulum]' in line)
first_idx = next(i for i, line in enumerate(lines) if '[[First Interface]]' in line)
second_idx = next(i for i, line in enumerate(lines) if '[[Second Interface]]' in line)
# Verify order
self.assertLess(reticulum_idx, first_idx)
self.assertLess(first_idx, second_idx)
def test_handles_empty_config_file(self):
"""Test that method handles empty config file"""
# Create empty config file
with open(self.config_path, 'w') as f:
f.write("")
# Should not raise exception
try:
self.wrapper._remove_autointerface_from_config()
except Exception as e:
self.fail(f"Method raised unexpected exception: {e}")
def test_handles_malformed_section_headers(self):
"""Test that method handles malformed section headers gracefully"""
config_content = """[reticulum]
setting = value
[[Auto Discovery]
type = AutoInterface
missing_closing_bracket = yes
[[Normal Section]]
type = Normal
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
# Should not raise exception
try:
self.wrapper._remove_autointerface_from_config()
except Exception as e:
self.fail(f"Method raised unexpected exception: {e}")
def test_removes_autointerface_with_different_name_variations(self):
"""Test that AutoInterface is detected with name variations"""
# Test with exact match
config_content = """[[Auto Discovery]]
type = AutoInterface
"""
with open(self.config_path, 'w') as f:
f.write(config_content)
self.wrapper._remove_autointerface_from_config()
with open(self.config_path, 'r') as f:
content = f.read()
self.assertNotIn("Auto Discovery", content)
class TestSetupInterface(unittest.TestCase):
"""Test _setup_interface method"""
def setUp(self):
"""Set up test fixtures"""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
def tearDown(self):
"""Clean up test fixtures"""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
@patch('reticulum_wrapper.RNS')
def test_setup_autointerface(self, mock_rns):
"""Test setting up AutoInterface"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
mock_auto_iface = MagicMock()
mock_rns.Interfaces.AutoInterface.AutoInterface.return_value = mock_auto_iface
# Test config
iface_config = {
"type": "AutoInterface"
}
# Setup interface
self.wrapper._setup_interface(iface_config)
# Verify AutoInterface was created
mock_rns.Interfaces.AutoInterface.AutoInterface.assert_called_once_with(
mock_transport,
"AutoInterface"
)
# Verify interface was configured and added
self.assertTrue(mock_auto_iface.OUT)
self.assertIn(mock_auto_iface, mock_transport.interfaces)
@patch('reticulum_wrapper.RNS')
def test_setup_tcpclientinterface_with_defaults(self, mock_rns):
"""Test setting up TCPClientInterface with default host/port"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
mock_tcp_iface = MagicMock()
mock_rns.Interfaces.TCPInterface.TCPClientInterface.return_value = mock_tcp_iface
# Test config
iface_config = {
"type": "TCPClientInterface"
}
# Setup interface
self.wrapper._setup_interface(iface_config)
# Verify TCPClientInterface was created with defaults
mock_rns.Interfaces.TCPInterface.TCPClientInterface.assert_called_once_with(
mock_transport,
"TCPClientInterface",
"127.0.0.1", # default host
4242 # default port
)
# Verify interface was configured and added
self.assertTrue(mock_tcp_iface.OUT)
self.assertIn(mock_tcp_iface, mock_transport.interfaces)
@patch('reticulum_wrapper.RNS')
def test_setup_tcpclientinterface_with_custom_host_port(self, mock_rns):
"""Test setting up TCPClientInterface with custom host and port"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
mock_tcp_iface = MagicMock()
mock_rns.Interfaces.TCPInterface.TCPClientInterface.return_value = mock_tcp_iface
# Test config
iface_config = {
"type": "TCPClientInterface",
"host": "192.168.1.100",
"port": 8888
}
# Setup interface
self.wrapper._setup_interface(iface_config)
# Verify TCPClientInterface was created with custom values
mock_rns.Interfaces.TCPInterface.TCPClientInterface.assert_called_once_with(
mock_transport,
"TCPClientInterface",
"192.168.1.100",
8888
)
# Verify interface was configured and added
self.assertTrue(mock_tcp_iface.OUT)
self.assertIn(mock_tcp_iface, mock_transport.interfaces)
@patch('reticulum_wrapper.RNS')
def test_setup_udpinterface_with_default_port(self, mock_rns):
"""Test setting up UDPInterface with default port"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
mock_udp_iface = MagicMock()
mock_rns.Interfaces.UDPInterface.UDPInterface.return_value = mock_udp_iface
# Test config
iface_config = {
"type": "UDPInterface"
}
# Setup interface
self.wrapper._setup_interface(iface_config)
# Verify UDPInterface was created with default port
mock_rns.Interfaces.UDPInterface.UDPInterface.assert_called_once_with(
mock_transport,
"UDPInterface",
4242 # default port
)
# Verify interface was configured and added
self.assertTrue(mock_udp_iface.OUT)
self.assertIn(mock_udp_iface, mock_transport.interfaces)
@patch('reticulum_wrapper.RNS')
def test_setup_udpinterface_with_custom_port(self, mock_rns):
"""Test setting up UDPInterface with custom port"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
mock_udp_iface = MagicMock()
mock_rns.Interfaces.UDPInterface.UDPInterface.return_value = mock_udp_iface
# Test config
iface_config = {
"type": "UDPInterface",
"port": 9999
}
# Setup interface
self.wrapper._setup_interface(iface_config)
# Verify UDPInterface was created with custom port
mock_rns.Interfaces.UDPInterface.UDPInterface.assert_called_once_with(
mock_transport,
"UDPInterface",
9999
)
# Verify interface was configured and added
self.assertTrue(mock_udp_iface.OUT)
self.assertIn(mock_udp_iface, mock_transport.interfaces)
@patch('reticulum_wrapper.RNS')
def test_handles_unknown_interface_type(self, mock_rns):
"""Test that unknown interface type is handled gracefully"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
# Test config
iface_config = {
"type": "UnknownInterface"
}
# Should not raise exception
try:
self.wrapper._setup_interface(iface_config)
except Exception as e:
self.fail(f"Method raised unexpected exception: {e}")
# Verify no interface was created
self.assertFalse(mock_rns.Interfaces.AutoInterface.AutoInterface.called)
self.assertFalse(mock_rns.Interfaces.TCPInterface.TCPClientInterface.called)
self.assertFalse(mock_rns.Interfaces.UDPInterface.UDPInterface.called)
@patch('reticulum_wrapper.RNS')
def test_handles_missing_type_field(self, mock_rns):
"""Test that missing type field is handled gracefully"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
# Test config
iface_config = {
"host": "localhost",
"port": 4242
}
# Should not raise exception
try:
self.wrapper._setup_interface(iface_config)
except Exception as e:
self.fail(f"Method raised unexpected exception: {e}")
# Verify no interface was created
self.assertFalse(mock_rns.Interfaces.AutoInterface.AutoInterface.called)
self.assertFalse(mock_rns.Interfaces.TCPInterface.TCPClientInterface.called)
self.assertFalse(mock_rns.Interfaces.UDPInterface.UDPInterface.called)
@patch('reticulum_wrapper.RNS')
def test_handles_empty_config(self, mock_rns):
"""Test that empty config dict is handled gracefully"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
# Test config
iface_config = {}
# Should not raise exception
try:
self.wrapper._setup_interface(iface_config)
except Exception as e:
self.fail(f"Method raised unexpected exception: {e}")
# Verify no interface was created
self.assertFalse(mock_rns.Interfaces.AutoInterface.AutoInterface.called)
self.assertFalse(mock_rns.Interfaces.TCPInterface.TCPClientInterface.called)
self.assertFalse(mock_rns.Interfaces.UDPInterface.UDPInterface.called)
@patch('reticulum_wrapper.RNS')
def test_tcpclientinterface_with_only_host(self, mock_rns):
"""Test TCPClientInterface uses default port when only host provided"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
mock_tcp_iface = MagicMock()
mock_rns.Interfaces.TCPInterface.TCPClientInterface.return_value = mock_tcp_iface
# Test config
iface_config = {
"type": "TCPClientInterface",
"host": "example.com"
}
# Setup interface
self.wrapper._setup_interface(iface_config)
# Verify default port is used
mock_rns.Interfaces.TCPInterface.TCPClientInterface.assert_called_once_with(
mock_transport,
"TCPClientInterface",
"example.com",
4242 # default port
)
@patch('reticulum_wrapper.RNS')
def test_tcpclientinterface_with_only_port(self, mock_rns):
"""Test TCPClientInterface uses default host when only port provided"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
mock_tcp_iface = MagicMock()
mock_rns.Interfaces.TCPInterface.TCPClientInterface.return_value = mock_tcp_iface
# Test config
iface_config = {
"type": "TCPClientInterface",
"port": 5555
}
# Setup interface
self.wrapper._setup_interface(iface_config)
# Verify default host is used
mock_rns.Interfaces.TCPInterface.TCPClientInterface.assert_called_once_with(
mock_transport,
"TCPClientInterface",
"127.0.0.1", # default host
5555
)
@patch('reticulum_wrapper.RNS')
def test_multiple_interfaces_can_be_setup(self, mock_rns):
"""Test that multiple interfaces can be set up sequentially"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
# Mock all interface constructors
mock_auto_iface = MagicMock()
mock_tcp_iface = MagicMock()
mock_udp_iface = MagicMock()
mock_rns.Interfaces.AutoInterface.AutoInterface.return_value = mock_auto_iface
mock_rns.Interfaces.TCPInterface.TCPClientInterface.return_value = mock_tcp_iface
mock_rns.Interfaces.UDPInterface.UDPInterface.return_value = mock_udp_iface
# Test configs
configs = [
{"type": "AutoInterface"},
{"type": "TCPClientInterface", "host": "server1.com", "port": 4242},
{"type": "UDPInterface", "port": 5555}
]
# Setup all interfaces
for config in configs:
self.wrapper._setup_interface(config)
# Verify all interfaces were created
mock_rns.Interfaces.AutoInterface.AutoInterface.assert_called_once()
mock_rns.Interfaces.TCPInterface.TCPClientInterface.assert_called_once()
mock_rns.Interfaces.UDPInterface.UDPInterface.assert_called_once()
# Verify all interfaces were added to transport
self.assertEqual(len(mock_transport.interfaces), 3)
@patch('reticulum_wrapper.RNS')
def test_interface_out_flag_set_correctly(self, mock_rns):
"""Test that OUT flag is set to True for all interface types"""
# Setup mocks
mock_transport = MagicMock()
mock_rns.Transport = mock_transport
# Test configs
configs = [
{"type": "AutoInterface"},
{"type": "TCPClientInterface"},
{"type": "UDPInterface"}
]
for config in configs:
# Reset transport interfaces
mock_transport.interfaces = []
# Create a fresh mock for each interface
mock_iface = MagicMock()
if config["type"] == "AutoInterface":
mock_rns.Interfaces.AutoInterface.AutoInterface.return_value = mock_iface
elif config["type"] == "TCPClientInterface":
mock_rns.Interfaces.TCPInterface.TCPClientInterface.return_value = mock_iface
elif config["type"] == "UDPInterface":
mock_rns.Interfaces.UDPInterface.UDPInterface.return_value = mock_iface
# Setup interface
self.wrapper._setup_interface(config)
# Verify OUT flag is set
created_iface = mock_transport.interfaces[0]
self.assertTrue(created_iface.OUT, f"OUT flag not set for {config['type']}")
@patch('reticulum_wrapper.RNS')
def test_case_sensitive_interface_type(self, mock_rns):
"""Test that interface type matching is case-sensitive"""
# Setup mocks
mock_transport = MagicMock()
mock_transport.interfaces = []
mock_rns.Transport = mock_transport
# Test config with lowercase type
iface_config = {
"type": "autointerface" # lowercase
}
# Setup interface
self.wrapper._setup_interface(iface_config)
# Should not match AutoInterface (case-sensitive)
self.assertFalse(mock_rns.Interfaces.AutoInterface.AutoInterface.called)
class TestTransportNodeConfig(unittest.TestCase):
"""Test _create_config_file transport node setting"""
def setUp(self):
"""Set up test fixtures"""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
self.config_path = os.path.join(self.temp_dir, "config")
def tearDown(self):
"""Clean up test fixtures"""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_standalone_mode_transport_enabled_by_default(self):
"""Test that standalone mode has transport enabled by default"""
interfaces = []
result = self.wrapper._create_config_file(interfaces)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = yes", content)
def test_standalone_mode_transport_enabled_explicit(self):
"""Test that standalone mode with enable_transport=True generates yes"""
interfaces = []
result = self.wrapper._create_config_file(interfaces, enable_transport=True)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = yes", content)
self.assertNotIn("enable_transport = no", content)
def test_standalone_mode_transport_disabled(self):
"""Test that standalone mode with enable_transport=False generates no"""
interfaces = []
result = self.wrapper._create_config_file(interfaces, enable_transport=False)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = no", content)
self.assertNotIn("enable_transport = yes", content)
def test_shared_instance_mode_transport_enabled_by_default(self):
"""Test that shared instance mode has transport enabled by default"""
interfaces = []
result = self.wrapper._create_config_file(
interfaces,
use_shared_instance=True
)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = yes", content)
def test_shared_instance_mode_transport_enabled_explicit(self):
"""Test that shared instance mode with enable_transport=True generates yes"""
interfaces = []
result = self.wrapper._create_config_file(
interfaces,
use_shared_instance=True,
enable_transport=True
)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = yes", content)
self.assertNotIn("enable_transport = no", content)
def test_shared_instance_mode_transport_disabled(self):
"""Test that shared instance mode with enable_transport=False generates no"""
interfaces = []
result = self.wrapper._create_config_file(
interfaces,
use_shared_instance=True,
enable_transport=False
)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = no", content)
self.assertNotIn("enable_transport = yes", content)
def test_standalone_with_interfaces_transport_enabled(self):
"""Test standalone mode with interfaces and transport enabled"""
interfaces = [
{"type": "AutoInterface", "name": "Auto Discovery"}
]
result = self.wrapper._create_config_file(interfaces, enable_transport=True)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = yes", content)
self.assertIn("[[Auto Discovery]]", content)
def test_standalone_with_interfaces_transport_disabled(self):
"""Test standalone mode with interfaces and transport disabled"""
interfaces = [
{"type": "AutoInterface", "name": "Auto Discovery"}
]
result = self.wrapper._create_config_file(interfaces, enable_transport=False)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = no", content)
self.assertIn("[[Auto Discovery]]", content)
def test_shared_instance_with_rpc_key_transport_disabled(self):
"""Test shared instance with RPC key and transport disabled"""
interfaces = []
result = self.wrapper._create_config_file(
interfaces,
use_shared_instance=True,
rpc_key="abc123def456",
enable_transport=False
)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
self.assertIn("enable_transport = no", content)
self.assertIn("rpc_key = abc123def456", content)
def test_transport_setting_appears_in_reticulum_section(self):
"""Test that enable_transport appears in [reticulum] section"""
interfaces = []
result = self.wrapper._create_config_file(interfaces, enable_transport=False)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
# Verify enable_transport comes after [reticulum] and before [interfaces]
reticulum_pos = content.find("[reticulum]")
transport_pos = content.find("enable_transport = no")
interfaces_pos = content.find("[interfaces]")
self.assertNotEqual(reticulum_pos, -1)
self.assertNotEqual(transport_pos, -1)
self.assertNotEqual(interfaces_pos, -1)
self.assertGreater(transport_pos, reticulum_pos)
self.assertLess(transport_pos, interfaces_pos)
class TestGetDebugInfoTransport(unittest.TestCase):
"""Test get_debug_info transport_enabled field"""
def setUp(self):
"""Set up test fixtures"""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
def tearDown(self):
"""Clean up test fixtures"""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.RETICULUM_AVAILABLE', True)
def test_get_debug_info_transport_enabled_true(self, mock_rns):
"""Test that get_debug_info returns transport_enabled=True when enabled"""
# Setup mocks
mock_rns.Transport.interfaces = []
mock_rns.Transport.identity = MagicMock()
mock_rns.Reticulum.transport_enabled.return_value = True
# Make wrapper appear initialized
self.wrapper.initialized = True
self.wrapper.reticulum = MagicMock()
# Call get_debug_info
info = self.wrapper.get_debug_info()
# Verify transport_enabled is True
self.assertTrue(info['transport_enabled'])
mock_rns.Reticulum.transport_enabled.assert_called_once()
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.RETICULUM_AVAILABLE', True)
def test_get_debug_info_transport_enabled_false(self, mock_rns):
"""Test that get_debug_info returns transport_enabled=False when disabled"""
# Setup mocks
mock_rns.Transport.interfaces = []
mock_rns.Transport.identity = None
mock_rns.Reticulum.transport_enabled.return_value = False
# Make wrapper appear initialized
self.wrapper.initialized = True
self.wrapper.reticulum = MagicMock()
# Call get_debug_info
info = self.wrapper.get_debug_info()
# Verify transport_enabled is False
self.assertFalse(info['transport_enabled'])
mock_rns.Reticulum.transport_enabled.assert_called_once()
def test_get_debug_info_transport_enabled_false_when_not_initialized(self):
"""Test that get_debug_info returns transport_enabled=False when not initialized"""
# Wrapper not initialized
self.wrapper.initialized = False
# Call get_debug_info
info = self.wrapper.get_debug_info()
# Verify transport_enabled is False when not initialized
self.assertFalse(info['transport_enabled'])
class TestErrorHandling(unittest.TestCase):
"""Test error handling in config creation and shared instance checks"""
def setUp(self):
"""Set up test fixtures"""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
self.config_path = os.path.join(self.temp_dir, "config")
def tearDown(self):
"""Clean up test fixtures"""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_create_config_skips_rnode_with_empty_tcp_host(self):
"""Test that _create_config_file skips RNode with empty tcp_host"""
interfaces = [
{
"type": "RNode",
"name": "Bad RNode",
"connection_mode": "tcp",
"tcp_host": "", # Empty tcp_host
"frequency": 915000000
},
{
"type": "AutoInterface",
"name": "Auto Discovery"
}
]
result = self.wrapper._create_config_file(interfaces)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
# RNode section header may exist, but no type/config should be present
# The important thing is no RNodeInterface type is added
self.assertNotIn("type = RNodeInterface", content)
self.assertNotIn("tcp_host =", content)
# AutoInterface should still be present
self.assertIn("Auto Discovery", content)
self.assertIn("type = AutoInterface", content)
@patch('reticulum_wrapper.log_error')
def test_create_config_logs_error_for_empty_tcp_host(self, mock_log_error):
"""Test that empty tcp_host logs an error message"""
interfaces = [
{
"type": "RNode",
"name": "Invalid RNode",
"connection_mode": "tcp",
"tcp_host": " ", # Whitespace only
}
]
self.wrapper._create_config_file(interfaces)
# Verify error was logged
mock_log_error.assert_called()
call_args = str(mock_log_error.call_args)
self.assertIn("tcp_host is empty", call_args)
def test_check_shared_instance_handles_socket_timeout(self):
"""Test that check_shared_instance_available handles socket timeout"""
# Test with a very short timeout to a non-existent host
# Use a non-routable IP (RFC 5737 documentation range)
result = self.wrapper.check_shared_instance_available(
host="192.0.2.1", # Non-routable test IP
port=37428,
timeout=0.001 # Very short timeout
)
# Should return False on timeout or connection failure
self.assertFalse(result)
def test_check_shared_instance_handles_connection_refused(self):
"""Test that check_shared_instance_available handles connection refused"""
# Test with localhost on a port that's very unlikely to be listening
# Port 1 requires root privileges, so it should be refused
result = self.wrapper.check_shared_instance_available(
host="127.0.0.1",
port=1, # Privileged port unlikely to be open
timeout=0.1
)
# Should return False when connection is refused
self.assertFalse(result)
class TestCallbackRegistration(unittest.TestCase):
"""Test callback registration methods"""
def setUp(self):
"""Set up test fixtures"""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
def tearDown(self):
"""Clean up test fixtures"""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_set_alternative_relay_callback_stores_callback(self):
"""Test that set_kotlin_request_alternative_relay_callback stores the callback"""
mock_callback = MagicMock()
self.wrapper.set_kotlin_request_alternative_relay_callback(mock_callback)
# Verify callback is stored
self.assertEqual(self.wrapper.kotlin_request_alternative_relay_callback, mock_callback)
def test_set_message_received_callback_stores_callback(self):
"""Test that set_message_received_callback stores the callback"""
mock_callback = MagicMock()
self.wrapper.set_message_received_callback(mock_callback)
# Verify callback is stored
self.assertEqual(self.wrapper.kotlin_message_received_callback, mock_callback)
def test_set_delivery_status_callback_stores_callback(self):
"""Test that set_delivery_status_callback stores the callback"""
mock_callback = MagicMock()
self.wrapper.set_delivery_status_callback(mock_callback)
# Verify callback is stored
self.assertEqual(self.wrapper.kotlin_delivery_status_callback, mock_callback)
class TestStateTransitions(unittest.TestCase):
"""Test state transition and edge case handling"""
def setUp(self):
"""Set up test fixtures"""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
def tearDown(self):
"""Clean up test fixtures"""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
@patch('reticulum_wrapper.RNS')
def test_clear_stale_ble_paths_handles_empty_path_table(self, mock_rns):
"""Test that _clear_stale_ble_paths handles empty path table"""
# Mock empty path table
mock_rns.Transport.path_table = {}
# Should not raise exception
try:
self.wrapper._clear_stale_ble_paths()
except Exception as e:
self.fail(f"Method raised unexpected exception: {e}")
@patch('reticulum_wrapper.RNS')
def test_clear_stale_ble_paths_handles_malformed_entries(self, mock_rns):
"""Test that _clear_stale_ble_paths handles malformed path table entries"""
# Mock path table with malformed entry (tuple too short, missing elements)
# Path table entries are tuples: [timestamp, hops, expires, random_blobs, interface_hash, interface]
mock_rns.Transport.path_table = {
b'test_dest_hash_1': [100] # Too short, will cause IndexError when accessing entry[5]
}
# Should not raise exception - malformed entries should be caught and skipped
try:
self.wrapper._clear_stale_ble_paths()
# If we got here, the method handled the malformed entry gracefully
except Exception as e:
self.fail(f"Method raised unexpected exception: {e}")
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.time')
def test_clear_stale_ble_paths_removes_timestamp_zero_paths(self, mock_time, mock_rns):
"""Test that _clear_stale_ble_paths removes paths with timestamp=0"""
# Mock current time
mock_time.time.return_value = 1000.0
# Mock BLE interface
mock_interface = MagicMock()
mock_interface.__class__.__name__ = 'AndroidBLEInterface'
# Mock path table with timestamp=0 entry
# Path table format: [timestamp, hops, expires, random_blobs, interface_hash, interface]
dest_hash = b'test_dest_hash_1'
mock_rns.Transport.path_table = {
dest_hash: [0, 1, 2000, b'random', b'hash', mock_interface]
}
# Clear stale paths
self.wrapper._clear_stale_ble_paths()
# Verify path was removed
self.assertNotIn(dest_hash, mock_rns.Transport.path_table)
def test_cache_device_type_skips_unknown_type(self):
"""Test that cache operations handle unknown device types gracefully"""
# This test validates that the wrapper doesn't crash with unexpected data
# Device type caching is handled internally by RNS, this tests robustness
try:
# Create a wrapper and verify it initializes without device type cache errors
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
self.assertIsNotNone(wrapper)
except Exception as e:
self.fail(f"Wrapper initialization failed: {e}")
def test_get_cached_device_type_handles_exception(self):
"""Test that device type retrieval handles exceptions gracefully"""
# This test validates exception handling for device type operations
# The wrapper should handle missing or corrupted cache data gracefully
try:
# Wrapper should initialize even if device type cache is unavailable
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Verify wrapper is usable
self.assertFalse(wrapper.initialized)
self.assertEqual(wrapper.storage_path, self.temp_dir)
except Exception as e:
self.fail(f"Wrapper failed to handle device type cache exception: {e}")
class TestTcpRNodeConfigGeneration(unittest.TestCase):
"""Test TCP RNode configuration generation in _create_config_file"""
def setUp(self):
"""Set up test fixtures"""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
self.config_path = os.path.join(self.temp_dir, "config")
def tearDown(self):
"""Clean up test fixtures"""
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_create_config_tcp_rnode_writes_complete_config(self):
"""Test TCP RNode with all LoRa parameters writes complete config file."""
interfaces = [{
"type": "RNode",
"name": "Test TCP RNode",
"connection_mode": "tcp",
"tcp_host": "192.168.1.100",
"frequency": 915000000,
"bandwidth": 125000,
"tx_power": 17,
"spreading_factor": 8,
"coding_rate": 5
}]
result = self.wrapper._create_config_file(interfaces)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
# Verify all required parameters are present
self.assertIn("type = RNodeInterface", content)
self.assertIn("enabled = yes", content)
self.assertIn("tcp_host = 192.168.1.100", content)
self.assertIn("frequency = 915000000", content)
self.assertIn("bandwidth = 125000", content)
self.assertIn("txpower = 17", content)
self.assertIn("spreadingfactor = 8", content)
self.assertIn("codingrate = 5", content)
def test_create_config_tcp_rnode_includes_airtime_limits(self):
"""Test st_alock and lt_alock are written when present."""
interfaces = [{
"type": "RNode",
"name": "RNode with Limits",
"connection_mode": "tcp",
"tcp_host": "192.168.1.100",
"frequency": 869525000,
"bandwidth": 250000,
"tx_power": 14,
"spreading_factor": 10,
"coding_rate": 5,
"st_alock": 15,
"lt_alock": 5
}]
result = self.wrapper._create_config_file(interfaces)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
# Verify airtime limits are present
self.assertIn("airtime_limit_short = 15", content)
self.assertIn("airtime_limit_long = 5", content)
def test_create_config_tcp_rnode_omits_airtime_limits_when_none(self):
"""Test airtime limits are omitted when not specified."""
interfaces = [{
"type": "RNode",
"name": "RNode No Limits",
"connection_mode": "tcp",
"tcp_host": "192.168.1.100",
"frequency": 915000000,
"bandwidth": 125000,
"tx_power": 17,
"spreading_factor": 8,
"coding_rate": 5
# No st_alock or lt_alock specified
}]
result = self.wrapper._create_config_file(interfaces)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
# Verify airtime limits are not present
self.assertNotIn("airtime_limit_short", content)
self.assertNotIn("airtime_limit_long", content)
def test_create_config_tcp_rnode_handles_gateway_mode(self):
"""Test interface_mode is written for non-full modes."""
interfaces = [{
"type": "RNode",
"name": "Gateway RNode",
"connection_mode": "tcp",
"tcp_host": "192.168.1.100",
"frequency": 915000000,
"bandwidth": 125000,
"tx_power": 17,
"spreading_factor": 8,
"coding_rate": 5,
"mode": "gateway"
}]
result = self.wrapper._create_config_file(interfaces)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
# Verify interface_mode is present for non-full mode
self.assertIn("interface_mode = gateway", content)
def test_create_config_tcp_rnode_omits_mode_when_full(self):
"""Test interface_mode is omitted when mode is 'full' (default)."""
interfaces = [{
"type": "RNode",
"name": "Full Mode RNode",
"connection_mode": "tcp",
"tcp_host": "192.168.1.100",
"frequency": 915000000,
"bandwidth": 125000,
"tx_power": 17,
"spreading_factor": 8,
"coding_rate": 5,
"mode": "full"
}]
result = self.wrapper._create_config_file(interfaces)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
# Verify interface_mode is not present for full mode
self.assertNotIn("interface_mode", content)
def test_create_config_tcp_rnode_with_boundary_mode(self):
"""Test interface_mode is written for boundary mode."""
interfaces = [{
"type": "RNode",
"name": "Boundary RNode",
"connection_mode": "tcp",
"tcp_host": "192.168.1.100",
"frequency": 915000000,
"bandwidth": 125000,
"tx_power": 17,
"spreading_factor": 8,
"coding_rate": 5,
"mode": "boundary"
}]
result = self.wrapper._create_config_file(interfaces)
self.assertTrue(result)
with open(self.config_path, 'r') as f:
content = f.read()
# Verify interface_mode is present for boundary mode
self.assertIn("interface_mode = boundary", content)
if __name__ == '__main__':
unittest.main()