fix: stabilize test suite and repair frontend/backend wiring

- meshcore pin >=2.3.0 (EventType.STATS_CORE floor); setup.sh derives
  optional packages from requirements.txt; Python 3.10 warning
- agent-mode wifi clients proxy route + bare-array response handling
- remove dead AIS/ACARS/VDL2 SPA wiring and orphaned partials/CSS
- agent TLE download to data/tle/ (was littering repo root as gp.php)
- gate deferred background init off under pytest (mock-pollution race)
- complete Popen mocks (context manager protocol, communicate tuples)
- real pipe fds in weather-sat decoder tests (fd 10/11 collision caused
  10s SQLite stalls); satellite tests no longer rewrite data/satellites.py
- register 'live' pytest marker, excluded by default
- update stale test assertions to current APIs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-06-11 16:42:33 +01:00
parent 0a6ad837d8
commit 746d195d76
27 changed files with 3128 additions and 4029 deletions
+5
View File
@@ -7,6 +7,11 @@ from unittest.mock import MagicMock, patch
import pytest
# Must be set before importing app: stops the deferred background-init
# thread, whose subprocess/DB cleanup fires mid-session and races with
# test mocks (e.g. a patched subprocess.Popen catching its pkill call)
os.environ.setdefault("INTERCEPT_SKIP_DEFERRED_INIT", "1")
from app import app as flask_app
from routes import register_blueprints
+142 -132
View File
@@ -25,10 +25,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Fixtures
# =============================================================================
@pytest.fixture
def mode_manager():
"""Create a fresh ModeManager instance for testing."""
from intercept_agent import ModeManager
manager = ModeManager()
yield manager
# Cleanup: stop all modes
@@ -40,15 +42,21 @@ def mode_manager():
@pytest.fixture
def mock_subprocess():
"""Mock subprocess.Popen for controlled testing."""
with patch('subprocess.Popen') as mock_popen:
with patch("subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.poll.return_value = None # Process is running
mock_proc.stdout = MagicMock()
mock_proc.stderr = MagicMock()
mock_proc.stderr.read.return_value = b''
mock_proc.stderr.read.return_value = b""
mock_proc.stdin = MagicMock()
mock_proc.pid = 12345
mock_proc.wait.return_value = 0
# subprocess.run() is built on Popen: it enters the context manager
# and unpacks communicate(); detect_capabilities() calls it with
# text=True, so return strings
mock_proc.communicate.return_value = ("", "")
mock_proc.returncode = 0
mock_proc.__enter__.return_value = mock_proc
mock_popen.return_value = mock_proc
yield mock_popen, mock_proc
@@ -57,19 +65,19 @@ def mock_subprocess():
def mock_tools():
"""Mock tool availability checks."""
tools = {
'rtl_433': '/usr/bin/rtl_433',
'rtl_fm': '/usr/bin/rtl_fm',
'dump1090': '/usr/bin/dump1090',
'multimon-ng': '/usr/bin/multimon-ng',
'airodump-ng': '/usr/sbin/airodump-ng',
'acarsdec': '/usr/bin/acarsdec',
'AIS-catcher': '/usr/bin/AIS-catcher',
'direwolf': '/usr/bin/direwolf',
'rtlamr': '/usr/bin/rtlamr',
'rtl_tcp': '/usr/bin/rtl_tcp',
'bluetoothctl': '/usr/bin/bluetoothctl',
"rtl_433": "/usr/bin/rtl_433",
"rtl_fm": "/usr/bin/rtl_fm",
"dump1090": "/usr/bin/dump1090",
"multimon-ng": "/usr/bin/multimon-ng",
"airodump-ng": "/usr/sbin/airodump-ng",
"acarsdec": "/usr/bin/acarsdec",
"AIS-catcher": "/usr/bin/AIS-catcher",
"direwolf": "/usr/bin/direwolf",
"rtlamr": "/usr/bin/rtlamr",
"rtl_tcp": "/usr/bin/rtl_tcp",
"bluetoothctl": "/usr/bin/bluetoothctl",
}
with patch('shutil.which', side_effect=lambda x: tools.get(x)):
with patch("shutil.which", side_effect=lambda x: tools.get(x)):
yield tools
@@ -77,8 +85,8 @@ def mock_tools():
# SDR Mode List
# =============================================================================
SDR_MODES = ['sensor', 'adsb', 'pager', 'ais', 'acars', 'aprs', 'rtlamr', 'dsc', 'listening_post']
NON_SDR_MODES = ['wifi', 'bluetooth', 'tscm', 'satellite']
SDR_MODES = ["sensor", "adsb", "pager", "ais", "acars", "aprs", "rtlamr", "dsc", "listening_post"]
NON_SDR_MODES = ["wifi", "bluetooth", "tscm", "satellite"]
ALL_MODES = SDR_MODES + NON_SDR_MODES
@@ -86,6 +94,7 @@ ALL_MODES = SDR_MODES + NON_SDR_MODES
# Mode Lifecycle Tests
# =============================================================================
class TestModeLifecycle:
"""Test start/stop lifecycle for all modes."""
@@ -94,99 +103,88 @@ class TestModeLifecycle:
mock_popen, mock_proc = mock_subprocess
# Start
result = mode_manager.start_mode('sensor', {'frequency': '433.92', 'device': '0'})
assert result['status'] == 'started'
assert 'sensor' in mode_manager.running_modes
result = mode_manager.start_mode("sensor", {"frequency": "433.92", "device": "0"})
assert result["status"] == "started"
assert "sensor" in mode_manager.running_modes
# Stop
result = mode_manager.stop_mode('sensor')
assert result['status'] == 'stopped'
assert 'sensor' not in mode_manager.running_modes
result = mode_manager.stop_mode("sensor")
assert result["status"] == "stopped"
assert "sensor" not in mode_manager.running_modes
def test_adsb_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""ADS-B mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
# Mock socket for SBS connection check
with patch('socket.socket') as mock_socket:
with patch("socket.socket") as mock_socket:
mock_sock = MagicMock()
mock_sock.connect_ex.return_value = 1 # Port not in use
mock_socket.return_value = mock_sock
result = mode_manager.start_mode('adsb', {'device': '0', 'gain': '40'})
result = mode_manager.start_mode("adsb", {"device": "0", "gain": "40"})
# May fail due to SBS port check, but shouldn't crash
assert result['status'] in ['started', 'error']
assert result["status"] in ["started", "error"]
def test_pager_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""Pager mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
result = mode_manager.start_mode('pager', {
'frequency': '929.6125',
'protocols': ['POCSAG512', 'POCSAG1200']
})
assert result['status'] == 'started'
assert 'pager' in mode_manager.running_modes
result = mode_manager.start_mode("pager", {"frequency": "929.6125", "protocols": ["POCSAG512", "POCSAG1200"]})
assert result["status"] == "started"
assert "pager" in mode_manager.running_modes
result = mode_manager.stop_mode('pager')
assert result['status'] == 'stopped'
result = mode_manager.stop_mode("pager")
assert result["status"] == "stopped"
def test_wifi_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""WiFi mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
# Mock glob for CSV file detection
with patch('glob.glob', return_value=[]), patch('tempfile.mkdtemp', return_value='/tmp/test'):
result = mode_manager.start_mode('wifi', {
'interface': 'wlan0',
'scan_type': 'quick'
})
with patch("glob.glob", return_value=[]), patch("tempfile.mkdtemp", return_value="/tmp/test"):
result = mode_manager.start_mode("wifi", {"interface": "wlan0", "scan_type": "quick"})
# Quick scan returns data directly
assert result['status'] in ['started', 'error', 'success']
assert result["status"] in ["started", "error", "success"]
def test_bluetooth_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""Bluetooth mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
result = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
assert result['status'] == 'started'
assert 'bluetooth' in mode_manager.running_modes
result = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
assert result["status"] == "started"
assert "bluetooth" in mode_manager.running_modes
# Give thread time to start
time.sleep(0.1)
result = mode_manager.stop_mode('bluetooth')
assert result['status'] == 'stopped'
result = mode_manager.stop_mode("bluetooth")
assert result["status"] == "stopped"
def test_satellite_mode_lifecycle(self, mode_manager):
"""Satellite mode should work without SDR."""
# Satellite mode is computational only
result = mode_manager.start_mode('satellite', {
'lat': 33.5,
'lon': -82.1,
'min_elevation': 10
})
assert result['status'] in ['started', 'error'] # May fail if skyfield not installed
# Patch the predictor loop — the real one downloads TLEs from
# CelesTrak and keeps computing passes after the test finishes
with patch.object(type(mode_manager), "_satellite_predictor", MagicMock()):
result = mode_manager.start_mode("satellite", {"lat": 33.5, "lon": -82.1, "min_elevation": 10})
assert result["status"] in ["started", "error"] # May fail if skyfield not installed
def test_tscm_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""TSCM mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
result = mode_manager.start_mode('tscm', {
'wifi': True,
'bluetooth': True,
'rf': False
})
assert result['status'] == 'started'
result = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": True, "rf": False})
assert result["status"] == "started"
result = mode_manager.stop_mode('tscm')
assert result['status'] == 'stopped'
result = mode_manager.stop_mode("tscm")
assert result["status"] == "stopped"
# =============================================================================
# SDR Conflict Detection Tests
# =============================================================================
class TestSDRConflictDetection:
"""Test SDR device conflict detection."""
@@ -195,25 +193,25 @@ class TestSDRConflictDetection:
mock_popen, mock_proc = mock_subprocess
# Start sensor on device 0
result1 = mode_manager.start_mode('sensor', {'device': '0'})
assert result1['status'] == 'started'
result1 = mode_manager.start_mode("sensor", {"device": "0"})
assert result1["status"] == "started"
# Try to start pager on device 0 - should fail
result2 = mode_manager.start_mode('pager', {'device': '0'})
assert result2['status'] == 'error'
assert 'in use' in result2['message'].lower()
result2 = mode_manager.start_mode("pager", {"device": "0"})
assert result2["status"] == "error"
assert "in use" in result2["message"].lower()
def test_different_device_no_conflict(self, mode_manager, mock_subprocess, mock_tools):
"""Starting SDR modes on different devices should work."""
mock_popen, mock_proc = mock_subprocess
# Start sensor on device 0
result1 = mode_manager.start_mode('sensor', {'device': '0'})
assert result1['status'] == 'started'
result1 = mode_manager.start_mode("sensor", {"device": "0"})
assert result1["status"] == "started"
# Start pager on device 1 - should work
result2 = mode_manager.start_mode('pager', {'device': '1'})
assert result2['status'] == 'started'
result2 = mode_manager.start_mode("pager", {"device": "1"})
assert result2["status"] == "started"
assert len(mode_manager.running_modes) == 2
@@ -222,12 +220,12 @@ class TestSDRConflictDetection:
mock_popen, mock_proc = mock_subprocess
# Start sensor (SDR)
result1 = mode_manager.start_mode('sensor', {'device': '0'})
assert result1['status'] == 'started'
result1 = mode_manager.start_mode("sensor", {"device": "0"})
assert result1["status"] == "started"
# Start bluetooth (non-SDR) - should work
result2 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
assert result2['status'] == 'started'
result2 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
assert result2["status"] == "started"
assert len(mode_manager.running_modes) == 2
@@ -239,10 +237,10 @@ class TestSDRConflictDetection:
assert mode_manager.get_sdr_in_use(0) is None
# Start sensor
mode_manager.start_mode('sensor', {'device': '0'})
mode_manager.start_mode("sensor", {"device": "0"})
# Device 0 now in use by sensor
assert mode_manager.get_sdr_in_use(0) == 'sensor'
assert mode_manager.get_sdr_in_use(0) == "sensor"
assert mode_manager.get_sdr_in_use(1) is None
@@ -250,67 +248,73 @@ class TestSDRConflictDetection:
# Process Verification Tests
# =============================================================================
class TestProcessVerification:
"""Test process startup verification."""
def test_immediate_process_exit_detected(self, mode_manager, mock_tools):
"""Process that exits immediately should return error."""
with patch('subprocess.Popen') as mock_popen:
with patch("subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.poll.return_value = 1 # Process exited
mock_proc.stderr.read.return_value = b'device busy'
mock_proc.stderr.read.return_value = b"device busy"
mock_proc.communicate.return_value = ("", "")
mock_proc.__enter__.return_value = mock_proc
mock_popen.return_value = mock_proc
result = mode_manager.start_mode('sensor', {'device': '0'})
assert result['status'] == 'error'
assert 'sensor' not in mode_manager.running_modes
result = mode_manager.start_mode("sensor", {"device": "0"})
assert result["status"] == "error"
assert "sensor" not in mode_manager.running_modes
def test_running_process_accepted(self, mode_manager, mock_subprocess, mock_tools):
"""Process that stays running should be accepted."""
mock_popen, mock_proc = mock_subprocess
mock_proc.poll.return_value = None # Still running
result = mode_manager.start_mode('sensor', {'device': '0'})
assert result['status'] == 'started'
assert 'sensor' in mode_manager.running_modes
result = mode_manager.start_mode("sensor", {"device": "0"})
assert result["status"] == "started"
assert "sensor" in mode_manager.running_modes
def test_error_message_from_stderr(self, mode_manager, mock_tools):
"""Error message should include stderr output."""
with patch('subprocess.Popen') as mock_popen:
with patch("subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.poll.return_value = 1
mock_proc.stderr.read.return_value = b'usb_claim_interface error -6'
mock_proc.stderr.read.return_value = b"usb_claim_interface error -6"
mock_proc.communicate.return_value = ("", "")
mock_proc.__enter__.return_value = mock_proc
mock_popen.return_value = mock_proc
result = mode_manager.start_mode('sensor', {'device': '0'})
assert result['status'] == 'error'
assert 'usb_claim_interface' in result['message'] or 'failed' in result['message'].lower()
result = mode_manager.start_mode("sensor", {"device": "0"})
assert result["status"] == "error"
assert "usb_claim_interface" in result["message"] or "failed" in result["message"].lower()
# =============================================================================
# Data Snapshot Tests
# =============================================================================
class TestDataSnapshots:
"""Test data snapshot operations."""
def test_get_mode_data_empty(self, mode_manager):
"""get_mode_data for non-running mode should return empty."""
result = mode_manager.get_mode_data('sensor')
assert result['mode'] == 'sensor'
result = mode_manager.get_mode_data("sensor")
assert result["mode"] == "sensor"
# Mode not running - should have empty data or 'running' field
assert result.get('running') is False or result.get('data') == [] or 'status' in result
assert result.get("running") is False or result.get("data") == [] or "status" in result
def test_get_mode_data_running(self, mode_manager, mock_subprocess, mock_tools):
"""get_mode_data for running mode should return status."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
result = mode_manager.get_mode_data('sensor')
mode_manager.start_mode("sensor", {"device": "0"})
result = mode_manager.get_mode_data("sensor")
assert result['mode'] == 'sensor'
assert result["mode"] == "sensor"
# Mode is running - should indicate running status
assert result.get('running') is True or 'data' in result or 'status' in result
assert result.get("running") is True or "data" in result or "status" in result
def test_data_queue_limit(self, mode_manager):
"""Data queues should respect max size limits."""
@@ -321,7 +325,7 @@ class TestDataSnapshots:
for i in range(150):
if test_queue.full():
test_queue.get_nowait() # Remove old item
test_queue.put_nowait({'index': i})
test_queue.put_nowait({"index": i})
assert test_queue.qsize() <= 100
@@ -330,68 +334,73 @@ class TestDataSnapshots:
# Mode Status Tests
# =============================================================================
class TestModeStatus:
"""Test mode status reporting."""
def test_status_includes_all_modes(self, mode_manager):
"""Status should include all running modes."""
status = mode_manager.get_status()
assert 'running_modes' in status
assert 'running_modes_detail' in status
assert isinstance(status['running_modes'], list)
assert "running_modes" in status
assert "running_modes_detail" in status
assert isinstance(status["running_modes"], list)
def test_running_modes_detail_includes_device(self, mode_manager, mock_subprocess, mock_tools):
"""Running modes detail should include device info."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
mode_manager.start_mode("sensor", {"device": "0"})
status = mode_manager.get_status()
assert 'sensor' in status['running_modes_detail']
detail = status['running_modes_detail']['sensor']
assert 'device' in detail or 'params' in detail
assert "sensor" in status["running_modes_detail"]
detail = status["running_modes_detail"]["sensor"]
assert "device" in detail or "params" in detail
# =============================================================================
# Error Handling Tests
# =============================================================================
class TestErrorHandling:
"""Test error handling scenarios."""
def test_missing_tool_returns_error(self, mode_manager):
"""Mode should fail gracefully if required tool is missing."""
with patch('shutil.which', return_value=None):
result = mode_manager.start_mode('sensor', {'device': '0'})
assert result['status'] == 'error'
# get_tool_path checks Homebrew paths via os.path.isfile before
# shutil.which, so patch it too or installed tools are still found
with patch("utils.dependencies.get_tool_path", return_value=None), patch("shutil.which", return_value=None):
result = mode_manager.start_mode("sensor", {"device": "0"})
assert result["status"] == "error"
# Error message may vary - check for common patterns
msg = result['message'].lower()
assert 'not found' in msg or 'not available' in msg or 'missing' in msg
msg = result["message"].lower()
assert "not found" in msg or "not available" in msg or "missing" in msg
def test_invalid_mode_returns_error(self, mode_manager):
"""Invalid mode name should return error."""
result = mode_manager.start_mode('invalid_mode', {})
assert result['status'] == 'error'
result = mode_manager.start_mode("invalid_mode", {})
assert result["status"] == "error"
def test_double_start_returns_already_running(self, mode_manager, mock_subprocess, mock_tools):
"""Starting already-running mode should return appropriate status."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
result = mode_manager.start_mode('sensor', {'device': '0'})
mode_manager.start_mode("sensor", {"device": "0"})
result = mode_manager.start_mode("sensor", {"device": "0"})
assert result['status'] in ['already_running', 'error']
assert result["status"] in ["already_running", "error"]
def test_stop_non_running_mode(self, mode_manager):
"""Stopping non-running mode should handle gracefully."""
result = mode_manager.stop_mode('sensor')
assert result['status'] in ['stopped', 'not_running']
result = mode_manager.stop_mode("sensor")
assert result["status"] in ["stopped", "not_running"]
# =============================================================================
# Cleanup Tests
# =============================================================================
class TestCleanup:
"""Test mode cleanup on stop."""
@@ -399,8 +408,8 @@ class TestCleanup:
"""Processes should be terminated when mode is stopped."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
mode_manager.stop_mode('sensor')
mode_manager.start_mode("sensor", {"device": "0"})
mode_manager.stop_mode("sensor")
# Verify terminate was called
mock_proc.terminate.assert_called()
@@ -409,20 +418,20 @@ class TestCleanup:
"""Output threads should be stopped when mode is stopped."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
time.sleep(0.1) # Let thread start
mode_manager.stop_mode('bluetooth')
mode_manager.stop_mode("bluetooth")
# Thread should no longer be in output_threads or should be stopped
assert 'bluetooth' not in mode_manager.output_threads or \
not mode_manager.output_threads['bluetooth'].is_alive()
assert "bluetooth" not in mode_manager.output_threads or not mode_manager.output_threads["bluetooth"].is_alive()
# =============================================================================
# Multi-Mode Tests
# =============================================================================
class TestMultiMode:
"""Test multiple modes running simultaneously."""
@@ -430,19 +439,19 @@ class TestMultiMode:
"""Multiple non-SDR modes should run simultaneously."""
mock_popen, mock_proc = mock_subprocess
result1 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
result2 = mode_manager.start_mode('tscm', {'wifi': True, 'bluetooth': False})
result1 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
result2 = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": False})
assert result1['status'] == 'started'
assert result2['status'] == 'started'
assert result1["status"] == "started"
assert result2["status"] == "started"
assert len(mode_manager.running_modes) == 2
def test_stop_all_modes(self, mode_manager, mock_subprocess, mock_tools):
"""All modes should stop cleanly."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
mode_manager.start_mode("sensor", {"device": "0"})
mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
# Stop all
for mode in list(mode_manager.running_modes.keys()):
@@ -455,26 +464,27 @@ class TestMultiMode:
# GPS Integration Tests
# =============================================================================
class TestGPSIntegration:
"""Test GPS coordinate integration."""
def test_status_includes_gps_flag(self, mode_manager):
"""Status should indicate GPS availability."""
status = mode_manager.get_status()
assert 'gps' in status
assert "gps" in status
def test_mode_start_includes_gps_flag(self, mode_manager, mock_subprocess, mock_tools):
"""Mode start response should include GPS status."""
mock_popen, mock_proc = mock_subprocess
result = mode_manager.start_mode('sensor', {'device': '0'})
if result['status'] == 'started':
assert 'gps_enabled' in result
result = mode_manager.start_mode("sensor", {"device": "0"})
if result["status"] == "started":
assert "gps_enabled" in result
# =============================================================================
# Run Tests
# =============================================================================
if __name__ == '__main__':
pytest.main([__file__, '-v'])
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+177 -194
View File
@@ -23,18 +23,19 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Fixtures
# =============================================================================
@pytest.fixture
def setup_db(tmp_path):
"""Set up a temporary database."""
import utils.database as db_module
from utils.database import init_db
test_db_path = tmp_path / 'test.db'
test_db_path = tmp_path / "test.db"
original_db_path = db_module.DB_PATH
db_module.DB_PATH = test_db_path
db_module.DB_DIR = tmp_path
if hasattr(db_module._local, 'connection') and db_module._local.connection:
if hasattr(db_module._local, "connection") and db_module._local.connection:
db_module._local.connection.close()
db_module._local.connection = None
@@ -42,7 +43,7 @@ def setup_db(tmp_path):
yield
if hasattr(db_module._local, 'connection') and db_module._local.connection:
if hasattr(db_module._local, "connection") and db_module._local.connection:
db_module._local.connection.close()
db_module._local.connection = None
db_module.DB_PATH = original_db_path
@@ -56,7 +57,7 @@ def app(setup_db):
from routes.controller import controller_bp
app = Flask(__name__)
app.config['TESTING'] = True
app.config["TESTING"] = True
app.register_blueprint(controller_bp)
return app
@@ -72,13 +73,14 @@ def client(app):
def sample_agent(setup_db):
"""Create a sample agent in database."""
from utils.database import create_agent
agent_id = create_agent(
name='test-sensor',
base_url='http://192.168.1.50:8020',
api_key='test-key',
description='Test sensor node',
capabilities={'adsb': True, 'wifi': True},
gps_coords={'lat': 40.7128, 'lon': -74.0060}
name="test-sensor",
base_url="http://192.168.1.50:8020",
api_key="test-key",
description="Test sensor node",
capabilities={"adsb": True, "wifi": True},
gps_coords={"lat": 40.7128, "lon": -74.0060},
)
return agent_id
@@ -87,125 +89,125 @@ def sample_agent(setup_db):
# Agent CRUD Tests
# =============================================================================
class TestAgentCRUD:
"""Tests for agent CRUD operations."""
def test_list_agents_empty(self, client):
"""GET /controller/agents should return empty list initially."""
response = client.get('/controller/agents')
response = client.get("/controller/agents")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['agents'] == []
assert data['count'] == 0
assert data["status"] == "success"
assert data["agents"] == []
assert data["count"] == 0
def test_register_agent_success(self, client):
"""POST /controller/agents should register new agent."""
with patch('routes.controller.AgentClient') as MockClient:
with patch("routes.controller.AgentClient") as MockClient:
# Mock successful capability fetch
mock_instance = Mock()
mock_instance.get_capabilities.return_value = {
'modes': {'adsb': True, 'wifi': True},
'devices': [{'name': 'RTL-SDR'}]
"modes": {"adsb": True, "wifi": True},
"devices": [{"name": "RTL-SDR"}],
}
MockClient.return_value = mock_instance
response = client.post('/controller/agents',
response = client.post(
"/controller/agents",
json={
'name': 'new-sensor',
'base_url': 'http://192.168.1.51:8020',
'api_key': 'secret123',
'description': 'New sensor node'
"name": "new-sensor",
"base_url": "http://192.168.1.51:8020",
"api_key": "secret123",
"description": "New sensor node",
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 201
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['agent']['name'] == 'new-sensor'
assert data["status"] == "success"
assert data["agent"]["name"] == "new-sensor"
def test_register_agent_missing_name(self, client):
"""POST /controller/agents should reject missing name."""
response = client.post('/controller/agents',
json={'base_url': 'http://localhost:8020'},
content_type='application/json'
response = client.post(
"/controller/agents", json={"base_url": "http://localhost:8020"}, content_type="application/json"
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'name is required' in data['message']
assert "name is required" in data["message"]
def test_register_agent_missing_url(self, client):
"""POST /controller/agents should reject missing URL."""
response = client.post('/controller/agents',
json={'name': 'test-sensor'},
content_type='application/json'
)
response = client.post("/controller/agents", json={"name": "test-sensor"}, content_type="application/json")
assert response.status_code == 400
data = json.loads(response.data)
assert 'Base URL is required' in data['message']
assert "Base URL is required" in data["message"]
def test_register_agent_duplicate_name(self, client, sample_agent):
"""POST /controller/agents should reject duplicate name."""
response = client.post('/controller/agents',
response = client.post(
"/controller/agents",
json={
'name': 'test-sensor', # Same as sample_agent
'base_url': 'http://192.168.1.60:8020'
"name": "test-sensor", # Same as sample_agent
"base_url": "http://192.168.1.60:8020",
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 409
data = json.loads(response.data)
assert 'already exists' in data['message']
assert "already exists" in data["message"]
def test_list_agents_with_agents(self, client, sample_agent):
"""GET /controller/agents should return registered agents."""
response = client.get('/controller/agents')
response = client.get("/controller/agents")
assert response.status_code == 200
data = json.loads(response.data)
assert data['count'] >= 1
assert data["count"] >= 1
names = [a['name'] for a in data['agents']]
assert 'test-sensor' in names
names = [a["name"] for a in data["agents"]]
assert "test-sensor" in names
def test_get_agent_detail(self, client, sample_agent):
"""GET /controller/agents/<id> should return agent details."""
response = client.get(f'/controller/agents/{sample_agent}')
response = client.get(f"/controller/agents/{sample_agent}")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['agent']['name'] == 'test-sensor'
assert data['agent']['capabilities']['adsb'] is True
assert data["status"] == "success"
assert data["agent"]["name"] == "test-sensor"
assert data["agent"]["capabilities"]["adsb"] is True
def test_get_agent_not_found(self, client):
"""GET /controller/agents/<id> should return 404 for missing agent."""
response = client.get('/controller/agents/99999')
response = client.get("/controller/agents/99999")
assert response.status_code == 404
def test_update_agent(self, client, sample_agent):
"""PATCH /controller/agents/<id> should update agent."""
response = client.patch(f'/controller/agents/{sample_agent}',
json={'description': 'Updated description'},
content_type='application/json'
response = client.patch(
f"/controller/agents/{sample_agent}",
json={"description": "Updated description"},
content_type="application/json",
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['agent']['description'] == 'Updated description'
assert data["agent"]["description"] == "Updated description"
def test_delete_agent(self, client, sample_agent):
"""DELETE /controller/agents/<id> should remove agent."""
response = client.delete(f'/controller/agents/{sample_agent}')
response = client.delete(f"/controller/agents/{sample_agent}")
assert response.status_code == 200
# Verify deleted
response = client.get(f'/controller/agents/{sample_agent}')
response = client.get(f"/controller/agents/{sample_agent}")
assert response.status_code == 404
@@ -213,345 +215,325 @@ class TestAgentCRUD:
# Proxy Operation Tests
# =============================================================================
class TestProxyOperations:
"""Tests for proxying operations to agents."""
def test_proxy_start_mode(self, client, sample_agent):
"""POST /controller/agents/<id>/<mode>/start should proxy to agent."""
with patch('routes.controller.create_client_from_agent') as mock_create:
with patch("routes.controller.create_client_from_agent") as mock_create:
mock_client = Mock()
mock_client.start_mode.return_value = {'status': 'started', 'mode': 'adsb'}
mock_client.start_mode.return_value = {"status": "started", "mode": "adsb"}
mock_create.return_value = mock_client
response = client.post(
f'/controller/agents/{sample_agent}/adsb/start',
json={'device_index': 0},
content_type='application/json'
f"/controller/agents/{sample_agent}/adsb/start",
json={"device_index": 0},
content_type="application/json",
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['mode'] == 'adsb'
assert data["status"] == "success"
assert data["mode"] == "adsb"
mock_client.start_mode.assert_called_once_with('adsb', {'device_index': 0})
mock_client.start_mode.assert_called_once_with("adsb", {"device_index": 0})
def test_proxy_stop_mode(self, client, sample_agent):
"""POST /controller/agents/<id>/<mode>/stop should proxy to agent."""
with patch('routes.controller.create_client_from_agent') as mock_create:
with patch("routes.controller.create_client_from_agent") as mock_create:
mock_client = Mock()
mock_client.stop_mode.return_value = {'status': 'stopped'}
mock_client.stop_mode.return_value = {"status": "stopped"}
mock_create.return_value = mock_client
response = client.post(
f'/controller/agents/{sample_agent}/wifi/stop',
content_type='application/json'
)
response = client.post(f"/controller/agents/{sample_agent}/wifi/stop", content_type="application/json")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data["status"] == "success"
def test_proxy_get_mode_data(self, client, sample_agent):
"""GET /controller/agents/<id>/<mode>/data should return data."""
with patch('routes.controller.create_client_from_agent') as mock_create:
with patch("routes.controller.create_client_from_agent") as mock_create:
mock_client = Mock()
mock_client.get_mode_data.return_value = {
'mode': 'adsb',
'data': [{'icao': 'ABC123'}]
}
mock_client.get_mode_data.return_value = {"mode": "adsb", "data": [{"icao": "ABC123"}]}
mock_create.return_value = mock_client
response = client.get(f'/controller/agents/{sample_agent}/adsb/data')
response = client.get(f"/controller/agents/{sample_agent}/adsb/data")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'agent_name' in data
assert data['agent_name'] == 'test-sensor'
assert data["status"] == "success"
assert "agent_name" in data
assert data["agent_name"] == "test-sensor"
def test_proxy_agent_not_found(self, client):
"""Proxy operations should return 404 for missing agent."""
response = client.post('/controller/agents/99999/adsb/start')
response = client.post("/controller/agents/99999/adsb/start")
assert response.status_code == 404
def test_proxy_connection_error(self, client, sample_agent):
"""Proxy should return 503 when agent unreachable."""
from utils.agent_client import AgentConnectionError
with patch('routes.controller.create_client_from_agent') as mock_create:
with patch("routes.controller.create_client_from_agent") as mock_create:
mock_client = Mock()
mock_client.start_mode.side_effect = AgentConnectionError("Connection refused")
mock_create.return_value = mock_client
response = client.post(
f'/controller/agents/{sample_agent}/adsb/start',
json={},
content_type='application/json'
f"/controller/agents/{sample_agent}/adsb/start", json={}, content_type="application/json"
)
assert response.status_code == 503
data = json.loads(response.data)
assert 'Cannot connect' in data['message']
assert "Cannot connect" in data["message"]
# =============================================================================
# Push Data Ingestion Tests
# =============================================================================
class TestPushIngestion:
"""Tests for push data ingestion endpoint."""
def test_ingest_success(self, client, sample_agent):
"""POST /controller/api/ingest should store payload."""
payload = {
'agent_name': 'test-sensor',
'scan_type': 'adsb',
'interface': 'rtlsdr0',
'payload': {
'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]
}
"agent_name": "test-sensor",
"scan_type": "adsb",
"interface": "rtlsdr0",
"payload": {"aircraft": [{"icao": "ABC123", "altitude": 35000}]},
}
response = client.post('/controller/api/ingest',
json=payload,
headers={'X-API-Key': 'test-key'},
content_type='application/json'
response = client.post(
"/controller/api/ingest", json=payload, headers={"X-API-Key": "test-key"}, content_type="application/json"
)
assert response.status_code == 202
data = json.loads(response.data)
assert data['status'] == 'accepted'
assert 'payload_id' in data
assert data["status"] == "accepted"
assert "payload_id" in data
def test_ingest_unknown_agent(self, client):
"""POST /controller/api/ingest should reject unknown agent."""
payload = {
'agent_name': 'nonexistent-sensor',
'scan_type': 'adsb',
'payload': {}
}
payload = {"agent_name": "nonexistent-sensor", "scan_type": "adsb", "payload": {}}
response = client.post('/controller/api/ingest',
json=payload,
content_type='application/json'
)
response = client.post("/controller/api/ingest", json=payload, content_type="application/json")
assert response.status_code == 401
data = json.loads(response.data)
assert 'Unknown agent' in data['message']
assert "Unknown agent" in data["message"]
def test_ingest_invalid_api_key(self, client, sample_agent):
"""POST /controller/api/ingest should reject invalid API key."""
payload = {
'agent_name': 'test-sensor',
'scan_type': 'adsb',
'payload': {}
}
payload = {"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}}
response = client.post('/controller/api/ingest',
json=payload,
headers={'X-API-Key': 'wrong-key'},
content_type='application/json'
response = client.post(
"/controller/api/ingest", json=payload, headers={"X-API-Key": "wrong-key"}, content_type="application/json"
)
assert response.status_code == 401
data = json.loads(response.data)
assert 'Invalid API key' in data['message']
assert "Invalid API key" in data["message"]
def test_ingest_missing_agent_name(self, client):
"""POST /controller/api/ingest should require agent_name."""
response = client.post('/controller/api/ingest',
json={'scan_type': 'adsb', 'payload': {}},
content_type='application/json'
response = client.post(
"/controller/api/ingest", json={"scan_type": "adsb", "payload": {}}, content_type="application/json"
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'agent_name required' in data['message']
assert "agent_name required" in data["message"]
def test_get_payloads(self, client, sample_agent):
"""GET /controller/api/payloads should return stored payloads."""
# First ingest some data
for i in range(3):
client.post('/controller/api/ingest',
client.post(
"/controller/api/ingest",
json={
'agent_name': 'test-sensor',
'scan_type': 'adsb',
'payload': {'aircraft': [{'icao': f'TEST{i}'}]}
"agent_name": "test-sensor",
"scan_type": "adsb",
"payload": {"aircraft": [{"icao": f"TEST{i}"}]},
},
headers={'X-API-Key': 'test-key'},
content_type='application/json'
headers={"X-API-Key": "test-key"},
content_type="application/json",
)
response = client.get(f'/controller/api/payloads?agent_id={sample_agent}')
response = client.get(f"/controller/api/payloads?agent_id={sample_agent}")
assert response.status_code == 200
data = json.loads(response.data)
assert data['count'] == 3
assert data["count"] == 3
def test_get_payloads_filter_by_type(self, client, sample_agent):
"""GET /controller/api/payloads should filter by scan_type."""
# Ingest mixed data
client.post('/controller/api/ingest',
json={'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {}},
headers={'X-API-Key': 'test-key'},
content_type='application/json'
client.post(
"/controller/api/ingest",
json={"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}},
headers={"X-API-Key": "test-key"},
content_type="application/json",
)
client.post('/controller/api/ingest',
json={'agent_name': 'test-sensor', 'scan_type': 'wifi', 'payload': {}},
headers={'X-API-Key': 'test-key'},
content_type='application/json'
client.post(
"/controller/api/ingest",
json={"agent_name": "test-sensor", "scan_type": "wifi", "payload": {}},
headers={"X-API-Key": "test-key"},
content_type="application/json",
)
response = client.get('/controller/api/payloads?scan_type=adsb')
response = client.get("/controller/api/payloads?scan_type=adsb")
data = json.loads(response.data)
assert all(p['scan_type'] == 'adsb' for p in data['payloads'])
assert all(p["scan_type"] == "adsb" for p in data["payloads"])
# =============================================================================
# Location Estimation Tests
# =============================================================================
class TestLocationEstimation:
"""Tests for device location estimation (trilateration)."""
def test_add_observation(self, client):
"""POST /controller/api/location/observe should accept observation."""
response = client.post('/controller/api/location/observe',
response = client.post(
"/controller/api/location/observe",
json={
'device_id': 'AA:BB:CC:DD:EE:FF',
'agent_name': 'sensor-1',
'agent_lat': 40.7128,
'agent_lon': -74.0060,
'rssi': -55
"device_id": "AA:BB:CC:DD:EE:FF",
"agent_name": "sensor-1",
"agent_lat": 40.7128,
"agent_lon": -74.0060,
"rssi": -55,
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['device_id'] == 'AA:BB:CC:DD:EE:FF'
assert data["status"] == "success"
assert data["device_id"] == "AA:BB:CC:DD:EE:FF"
def test_add_observation_missing_fields(self, client):
"""POST /controller/api/location/observe should require all fields."""
response = client.post('/controller/api/location/observe',
response = client.post(
"/controller/api/location/observe",
json={
'device_id': 'AA:BB:CC:DD:EE:FF',
'rssi': -55
"device_id": "AA:BB:CC:DD:EE:FF",
"rssi": -55,
# Missing agent_name, agent_lat, agent_lon
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 400
def test_estimate_location(self, client):
"""POST /controller/api/location/estimate should compute location."""
response = client.post('/controller/api/location/estimate',
response = client.post(
"/controller/api/location/estimate",
json={
'observations': [
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'},
{'agent_lat': 40.7135, 'agent_lon': -74.0055, 'rssi': -70, 'agent_name': 'node-2'},
{'agent_lat': 40.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'}
"observations": [
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"},
],
'environment': 'outdoor'
"environment": "outdoor",
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 200
data = json.loads(response.data)
# Should have computed a location
if data['location']:
assert 'lat' in data['location']
assert 'lon' in data['location']
if data["location"]:
assert "latitude" in data["location"]
assert "longitude" in data["location"]
def test_estimate_location_insufficient_data(self, client):
"""Estimation should require at least 2 observations."""
response = client.post('/controller/api/location/estimate',
json={
'observations': [
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'}
]
},
content_type='application/json'
response = client.post(
"/controller/api/location/estimate",
json={"observations": [{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"}]},
content_type="application/json",
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'At least 2' in data['message']
assert "At least 2" in data["message"]
def test_get_device_location_not_found(self, client):
"""GET /controller/api/location/<device_id> returns not_found for unknown device."""
response = client.get('/controller/api/location/unknown-device')
response = client.get("/controller/api/location/unknown-device")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'not_found'
assert data['location'] is None
assert data["status"] == "not_found"
assert data["location"] is None
def test_get_all_locations(self, client):
"""GET /controller/api/location/all should return all estimates."""
response = client.get('/controller/api/location/all')
response = client.get("/controller/api/location/all")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'devices' in data
assert data["status"] == "success"
assert "devices" in data
def test_get_devices_near(self, client):
"""GET /controller/api/location/near should find nearby devices."""
response = client.get(
'/controller/api/location/near',
query_string={'lat': 40.7128, 'lon': -74.0060, 'radius': 100}
"/controller/api/location/near", query_string={"lat": 40.7128, "lon": -74.0060, "radius": 100}
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['center']['lat'] == 40.7128
assert data["status"] == "success"
assert data["center"]["lat"] == 40.7128
# =============================================================================
# Agent Refresh Tests
# =============================================================================
class TestAgentRefresh:
"""Tests for agent refresh operations."""
def test_refresh_agent_success(self, client, sample_agent):
"""POST /controller/agents/<id>/refresh should update metadata."""
with patch('routes.controller.create_client_from_agent') as mock_create:
with patch("routes.controller.create_client_from_agent") as mock_create:
mock_client = Mock()
mock_client.refresh_metadata.return_value = {
'healthy': True,
'capabilities': {
'modes': {'adsb': True, 'wifi': True, 'bluetooth': True},
'devices': [{'name': 'RTL-SDR V3'}]
"healthy": True,
"capabilities": {
"modes": {"adsb": True, "wifi": True, "bluetooth": True},
"devices": [{"name": "RTL-SDR V3"}],
},
'status': {'running_modes': ['adsb']},
'config': {}
"status": {"running_modes": ["adsb"]},
"config": {},
}
mock_create.return_value = mock_client
response = client.post(f'/controller/agents/{sample_agent}/refresh')
response = client.post(f"/controller/agents/{sample_agent}/refresh")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['metadata']['healthy'] is True
assert data["status"] == "success"
assert data["metadata"]["healthy"] is True
def test_refresh_agent_unreachable(self, client, sample_agent):
"""POST /controller/agents/<id>/refresh should return 503 if unreachable."""
with patch('routes.controller.create_client_from_agent') as mock_create:
with patch("routes.controller.create_client_from_agent") as mock_create:
mock_client = Mock()
mock_client.refresh_metadata.return_value = {'healthy': False}
mock_client.refresh_metadata.return_value = {"healthy": False}
mock_create.return_value = mock_client
response = client.post(f'/controller/agents/{sample_agent}/refresh')
response = client.post(f"/controller/agents/{sample_agent}/refresh")
assert response.status_code == 503
@@ -560,6 +542,7 @@ class TestAgentRefresh:
# SSE Stream Tests
# =============================================================================
class TestSSEStream:
"""Tests for SSE streaming endpoint."""
@@ -567,5 +550,5 @@ class TestSSEStream:
"""GET /controller/stream/all should exist and return SSE."""
# Just verify the endpoint is accessible
# Full SSE testing requires more complex setup
response = client.get('/controller/stream/all')
assert response.content_type == 'text/event-stream'
response = client.get("/controller/stream/all")
assert response.mimetype == "text/event-stream"
+1 -1
View File
@@ -126,7 +126,7 @@ class TestMeshcoreClientStateMachine:
client.get_queue().get_nowait()
# Call on_connected directly (simulating what AsyncWorker would call)
client.on_connected(transport="serial", device="/dev/ttyUSB0")
assert client.get_state() == ConnectionState.CONNECTED
assert client.get_state()[0] == ConnectionState.CONNECTED
event = client.get_queue().get_nowait()
assert event["type"] == "status"
assert event["data"]["state"] == "connected"
+2 -2
View File
@@ -164,7 +164,7 @@ class TestConnectionStateTransitions:
client = MeshcoreClient()
client.on_connected(transport="serial", device="/dev/ttyUSB0")
assert client.get_state() == ConnectionState.CONNECTED
assert client.get_state()[0] == ConnectionState.CONNECTED
event = client.get_queue().get_nowait()
assert event["type"] == "status"
assert event["data"]["state"] == "connected"
@@ -175,7 +175,7 @@ class TestConnectionStateTransitions:
client = MeshcoreClient()
client.on_error("timeout")
assert client.get_state() == ConnectionState.ERROR
assert client.get_state()[0] == ConnectionState.ERROR
event = client.get_queue().get_nowait()
assert event["data"]["state"] == "error"
assert event["data"].get("message") == "timeout"
+1 -1
View File
@@ -25,7 +25,7 @@ def client(app):
@pytest.fixture(autouse=True)
def mock_meshcore_client():
mc = MagicMock()
mc.get_state.return_value = MagicMock(value="disconnected")
mc.get_state.return_value = (MagicMock(value="disconnected"), None)
mc.get_messages.return_value = []
mc.get_nodes.return_value = []
mc.get_repeaters.return_value = []
+17
View File
@@ -8,6 +8,23 @@ from flask import Flask
from routes.satellite import satellite_bp
@pytest.fixture(autouse=True)
def _isolate_tle_state(monkeypatch):
"""Keep TLE updates off the real data/satellites.py and reset the cache.
Without this, the update-tle test rewrites the tracked data file on
every run and leaks 'ISS' into the module-global cache, breaking
later tests that depend on cache contents.
"""
import routes.satellite as sat
monkeypatch.setattr(sat, "_persist_tle_cache", lambda: None)
saved = dict(sat._tle_cache)
yield
sat._tle_cache.clear()
sat._tle_cache.update(saved)
@pytest.fixture
def app():
app = Flask(__name__)
+65 -44
View File
@@ -7,94 +7,115 @@ import pytest
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess["logged_in"] = True
return client
def test_signal_guess_fm_broadcast(auth_client):
"""FM broadcast frequency should return a known signal type."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 98.1,
'modulation': 'wfm',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 98.1,
"modulation": "wfm",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
assert data["status"] == "ok"
assert data["primary_label"]
assert data["confidence"] in ("HIGH", "MEDIUM", "LOW")
def test_signal_guess_airband(auth_client):
"""Airband frequency should be identified."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 121.5,
'modulation': 'am',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 121.5,
"modulation": "am",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
assert data["status"] == "ok"
assert data["primary_label"]
def test_signal_guess_ism_band(auth_client):
"""ISM band frequency (433.92 MHz) should be identified."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 433.92,
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 433.92,
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
assert data["status"] == "ok"
assert data["primary_label"]
assert data["confidence"] in ("HIGH", "MEDIUM", "LOW")
def test_signal_guess_missing_frequency(auth_client):
"""Missing frequency should return 400."""
resp = auth_client.post('/listening/signal/guess', json={})
resp = auth_client.post("/receiver/signal/guess", json={})
assert resp.status_code == 400
data = resp.get_json()
assert data['status'] == 'error'
assert data["status"] == "error"
def test_signal_guess_invalid_frequency(auth_client):
"""Invalid frequency value should return 400."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 'abc',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": "abc",
},
)
assert resp.status_code == 400
def test_signal_guess_negative_frequency(auth_client):
"""Negative frequency should return 400."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': -5.0,
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": -5.0,
},
)
assert resp.status_code == 400
def test_signal_guess_with_region(auth_client):
"""Specifying region should work."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 462.5625,
'region': 'US',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 462.5625,
"region": "US",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data["status"] == "ok"
def test_signal_guess_response_structure(auth_client):
"""Response should have all expected fields."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 146.52,
'modulation': 'fm',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 146.52,
"modulation": "fm",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert 'primary_label' in data
assert 'confidence' in data
assert 'alternatives' in data
assert 'explanation' in data
assert 'tags' in data
assert isinstance(data['alternatives'], list)
assert isinstance(data['tags'], list)
assert "primary_label" in data
assert "confidence" in data
assert "alternatives" in data
assert "explanation" in data
assert "tags" in data
assert isinstance(data["alternatives"], list)
assert isinstance(data["tags"], list)
+262 -220
View File
@@ -6,11 +6,14 @@ and image handling.
from __future__ import annotations
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from utils.weather_sat import (
WEATHER_SATELLITES,
CaptureProgress,
@@ -21,34 +24,57 @@ from utils.weather_sat import (
)
@pytest.fixture(autouse=True)
def _stop_decoder_threads():
"""Stop watcher/reader threads leaked by tests that call start().
Leaked threads keep scanning the output dir and contend for the SQLite
lock, slowing every later test in the session. Full stop() is unsafe
here: it would os.close() the mocked pty fds (10, 11), which are real
fds of the pytest process.
"""
created: list[WeatherSatDecoder] = []
orig_init = WeatherSatDecoder.__init__
def tracking_init(self, *args, **kwargs):
orig_init(self, *args, **kwargs)
created.append(self)
with patch.object(WeatherSatDecoder, "__init__", tracking_init):
yield
for decoder in created:
decoder._running = False
decoder._stop_event.set()
class TestWeatherSatDecoder:
"""Tests for WeatherSatDecoder class."""
def test_decoder_initialization(self):
"""Decoder should initialize with default output directory."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
assert decoder.is_running is False
assert decoder.decoder_available == 'satdump'
assert decoder.current_satellite == ''
assert decoder.decoder_available == "satdump"
assert decoder.current_satellite == ""
assert decoder.current_frequency == 0.0
def test_decoder_initialization_no_satdump(self):
"""Decoder should detect when SatDump is unavailable."""
with patch('shutil.which', return_value=None):
with patch("shutil.which", return_value=None):
decoder = WeatherSatDecoder()
assert decoder.decoder_available is None
def test_decoder_custom_output_dir(self):
"""Decoder should accept custom output directory."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
custom_dir = '/tmp/custom_output'
with patch("shutil.which", return_value="/usr/bin/satdump"):
custom_dir = "/tmp/custom_output"
decoder = WeatherSatDecoder(output_dir=custom_dir)
assert decoder._output_dir == Path(custom_dir)
def test_set_callback(self):
"""Decoder should accept progress callback."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
@@ -56,7 +82,7 @@ class TestWeatherSatDecoder:
def test_set_on_complete(self):
"""Decoder should accept on_complete callback."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_on_complete(callback)
@@ -64,44 +90,47 @@ class TestWeatherSatDecoder:
def test_start_no_decoder(self):
"""start() should fail when no decoder available."""
with patch('shutil.which', return_value=None):
with patch("shutil.which", return_value=None):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0)
assert success is False
assert error_msg is not None
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
assert 'SatDump' in progress.message
assert progress.status == "error"
assert "SatDump" in progress.message
def test_start_invalid_satellite(self):
"""start() should fail with invalid satellite."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success, error_msg = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
success, error_msg = decoder.start(satellite="FAKE-SAT", device_index=0, gain=40.0)
assert success is False
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
assert 'Unknown satellite' in progress.message
assert progress.status == "error"
assert "Unknown satellite" in progress.message
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('utils.weather_sat.register_process')
@patch("subprocess.Popen")
@patch("pty.openpty")
@patch("utils.weather_sat.register_process")
def test_start_success(self, mock_register, mock_pty, mock_popen):
"""start() should successfully start SatDump."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
mock_pty.return_value = (10, 11)
with (
patch("shutil.which", return_value="/usr/bin/satdump"),
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"),
):
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
# the test process actually has open (DB, log files, capture)
mock_pty.return_value = os.pipe()
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_popen.return_value = mock_process
@@ -111,7 +140,7 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start(
satellite='NOAA-18',
satellite="NOAA-18",
device_index=0,
gain=40.0,
bias_t=True,
@@ -120,25 +149,27 @@ class TestWeatherSatDecoder:
assert success is True
assert error_msg is None
assert decoder.is_running is True
assert decoder.current_satellite == 'NOAA-18'
assert decoder.current_satellite == "NOAA-18"
assert decoder.current_frequency == 137.9125
assert decoder.current_mode == 'APT'
assert decoder.current_mode == "APT"
assert decoder.device_index == 0
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == 'satdump'
assert 'live' in cmd
assert 'noaa_apt' in cmd
assert '--bias' in cmd
assert cmd[0] == "satdump"
assert "live" in cmd
assert "noaa_apt" in cmd
assert "--bias" in cmd
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('utils.weather_sat.register_process')
@patch("subprocess.Popen")
@patch("pty.openpty")
@patch("utils.weather_sat.register_process")
def test_start_rtl_tcp_uses_rtltcp_source(self, mock_register, mock_pty, mock_popen):
"""start() with rtl_tcp should use --source rtltcp instead of rtlsdr."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
mock_pty.return_value = (10, 11)
with patch("shutil.which", return_value="/usr/bin/satdump"):
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
# the test process actually has open (DB, log files, capture)
mock_pty.return_value = os.pipe()
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_popen.return_value = mock_process
@@ -148,10 +179,10 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start(
satellite='NOAA-18',
satellite="NOAA-18",
device_index=0,
gain=40.0,
rtl_tcp_host='192.168.1.100',
rtl_tcp_host="192.168.1.100",
rtl_tcp_port=1234,
)
@@ -160,24 +191,28 @@ class TestWeatherSatDecoder:
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert '--source' in cmd
source_idx = cmd.index('--source')
assert cmd[source_idx + 1] == 'rtltcp'
assert '--ip_address' in cmd
assert '192.168.1.100' in cmd
assert '--port' in cmd
assert '1234' in cmd
assert "--source" in cmd
source_idx = cmd.index("--source")
assert cmd[source_idx + 1] == "rtltcp"
assert "--ip_address" in cmd
assert "192.168.1.100" in cmd
assert "--port" in cmd
assert "1234" in cmd
# Should NOT have --source_id for remote
assert '--source_id' not in cmd
assert "--source_id" not in cmd
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('utils.weather_sat.register_process')
@patch("subprocess.Popen")
@patch("pty.openpty")
@patch("utils.weather_sat.register_process")
def test_start_rtl_tcp_skips_device_resolve(self, mock_register, mock_pty, mock_popen):
"""start() with rtl_tcp should skip _resolve_device_id."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id') as mock_resolve:
mock_pty.return_value = (10, 11)
with (
patch("shutil.which", return_value="/usr/bin/satdump"),
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id") as mock_resolve,
):
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
# the test process actually has open (DB, log files, capture)
mock_pty.return_value = os.pipe()
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_popen.return_value = mock_process
@@ -185,83 +220,87 @@ class TestWeatherSatDecoder:
decoder = WeatherSatDecoder()
success, _ = decoder.start(
satellite='NOAA-18',
satellite="NOAA-18",
device_index=0,
gain=40.0,
rtl_tcp_host='10.0.0.1',
rtl_tcp_host="10.0.0.1",
)
assert success is True
mock_resolve.assert_not_called()
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch("subprocess.Popen")
@patch("pty.openpty")
def test_start_already_running(self, mock_pty, mock_popen):
"""start() should return True when already running."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
with (
patch("shutil.which", return_value="/usr/bin/satdump"),
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"),
):
decoder = WeatherSatDecoder()
decoder._running = True
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0)
assert success is True
assert error_msg is None
mock_popen.assert_not_called()
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch("subprocess.Popen")
@patch("pty.openpty")
def test_start_exception_handling(self, mock_pty, mock_popen):
"""start() should handle exceptions gracefully."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
mock_pty.return_value = (10, 11)
mock_popen.side_effect = OSError('Device not found')
with patch("shutil.which", return_value="/usr/bin/satdump"):
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
# the test process actually has open (DB, log files, capture)
mock_pty.return_value = os.pipe()
mock_popen.side_effect = OSError("Device not found")
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0)
assert success is False
assert error_msg is not None
assert decoder.is_running is False
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
assert progress.status == "error"
def test_start_from_file_no_decoder(self):
"""start_from_file() should fail when no decoder available."""
with patch('shutil.which', return_value=None):
with patch("shutil.which", return_value=None):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/test.wav',
satellite="NOAA-18",
input_file="data/test.wav",
)
assert success is False
assert error_msg is not None
callback.assert_called()
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('pathlib.Path.is_file', return_value=True)
@patch('pathlib.Path.resolve')
@patch("subprocess.Popen")
@patch("pty.openpty")
@patch("pathlib.Path.is_file", return_value=True)
@patch("pathlib.Path.resolve")
def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen):
"""start_from_file() should successfully decode from file."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.register_process'):
with patch("shutil.which", return_value="/usr/bin/satdump"), patch("utils.weather_sat.register_process"):
# Mock path resolution
mock_path = MagicMock()
mock_path.is_relative_to.return_value = True
mock_path.suffix = '.wav'
mock_path.suffix = ".wav"
mock_resolve.return_value = mock_path
mock_pty.return_value = (10, 11)
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
# the test process actually has open (DB, log files, capture)
mock_pty.return_value = os.pipe()
mock_process = MagicMock()
mock_process.poll.return_value = None # Process still running
mock_popen.return_value = mock_process
@@ -271,27 +310,27 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/test.wav',
satellite="NOAA-18",
input_file="data/test.wav",
sample_rate=1000000,
)
assert success is True
assert error_msg is None
assert decoder.is_running is True
assert decoder.current_satellite == 'NOAA-18'
assert decoder.current_satellite == "NOAA-18"
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == 'satdump'
assert 'noaa_apt' in cmd
assert 'audio_wav' in cmd
assert '--samplerate' in cmd
assert cmd[0] == "satdump"
assert "noaa_apt" in cmd
assert "audio_wav" in cmd
assert "--samplerate" in cmd
@patch('pathlib.Path.resolve')
@patch("pathlib.Path.resolve")
def test_start_from_file_path_traversal(self, mock_resolve):
"""start_from_file() should block path traversal."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
# Mock path outside allowed directory
mock_path = MagicMock()
mock_path.is_relative_to.return_value = False
@@ -302,20 +341,20 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='/etc/passwd',
satellite="NOAA-18",
input_file="/etc/passwd",
)
assert success is False
callback.assert_called()
progress = callback.call_args[0][0]
assert 'data/ directory' in progress.message
assert "must be under INTERCEPT data" in progress.message
@patch('pathlib.Path.is_file', return_value=False)
@patch('pathlib.Path.resolve')
@patch("pathlib.Path.is_file", return_value=False)
@patch("pathlib.Path.resolve")
def test_start_from_file_not_found(self, mock_resolve, mock_is_file):
"""start_from_file() should fail when file not found."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
mock_path = MagicMock()
mock_path.is_relative_to.return_value = True
mock_resolve.return_value = mock_path
@@ -325,32 +364,32 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/missing.wav',
satellite="NOAA-18",
input_file="data/missing.wav",
)
assert success is False
callback.assert_called()
progress = callback.call_args[0][0]
assert 'not found' in progress.message.lower()
assert "not found" in progress.message.lower()
def test_stop_not_running(self):
"""stop() should be safe when not running."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
decoder.stop() # Should not raise
@patch('utils.weather_sat.safe_terminate')
@patch("utils.weather_sat.safe_terminate")
def test_stop_running(self, mock_terminate):
"""stop() should terminate process."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
mock_process = MagicMock()
decoder._process = mock_process
decoder._running = True
decoder._pty_master_fd = 10
with patch('os.close') as mock_close:
with patch("os.close") as mock_close:
decoder.stop()
assert decoder._running is False
@@ -359,21 +398,21 @@ class TestWeatherSatDecoder:
def test_get_images_empty(self):
"""get_images() should return empty list initially."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
images = decoder.get_images()
assert images == []
@patch('pathlib.Path.glob')
@patch('pathlib.Path.stat')
@patch("pathlib.Path.glob")
@patch("pathlib.Path.stat")
def test_get_images_scans_directory(self, mock_stat, mock_glob):
"""get_images() should scan output directory."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
# Mock image files
mock_file = MagicMock()
mock_file.name = 'NOAA-18_test.png'
mock_file.name = "NOAA-18_test.png"
mock_file.stat.return_value.st_size = 10000
mock_file.stat.return_value.st_mtime = time.time()
mock_glob.return_value = [mock_file]
@@ -381,39 +420,39 @@ class TestWeatherSatDecoder:
images = decoder.get_images()
assert len(images) == 1
assert images[0].filename == 'NOAA-18_test.png'
assert images[0].satellite == 'NOAA-18'
assert images[0].filename == "NOAA-18_test.png"
assert images[0].satellite == "NOAA-18"
def test_delete_image_success(self):
"""delete_image() should delete file."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
with patch('pathlib.Path.exists', return_value=True), \
patch('pathlib.Path.unlink') as mock_unlink:
result = decoder.delete_image('test.png')
with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink") as mock_unlink:
result = decoder.delete_image("test.png")
assert result is True
mock_unlink.assert_called_once()
def test_delete_image_not_found(self):
"""delete_image() should return False for non-existent file."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
with patch('pathlib.Path.exists', return_value=False):
result = decoder.delete_image('missing.png')
with patch("pathlib.Path.exists", return_value=False):
result = decoder.delete_image("missing.png")
assert result is False
def test_delete_all_images(self):
"""delete_all_images() should delete all images."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
mock_files = [MagicMock() for _ in range(3)]
with patch('pathlib.Path.glob', return_value=mock_files):
# delete_all_images globs three extensions; return files for the
# first pattern only so each mock is deleted exactly once
with patch("pathlib.Path.glob", side_effect=[mock_files, [], []]):
count = decoder.delete_all_images()
assert count == 3
@@ -422,74 +461,74 @@ class TestWeatherSatDecoder:
def test_get_status_idle(self):
"""get_status() should return idle status."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
status = decoder.get_status()
assert status['available'] is True
assert status['decoder'] == 'satdump'
assert status['running'] is False
assert status['satellite'] == ''
assert status["available"] is True
assert status["decoder"] == "satdump"
assert status["running"] is False
assert status["satellite"] == ""
def test_get_status_running(self):
"""get_status() should return running status."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
decoder._running = True
decoder._current_satellite = 'NOAA-18'
decoder._current_satellite = "NOAA-18"
decoder._current_frequency = 137.9125
decoder._current_mode = 'APT'
decoder._current_mode = "APT"
decoder._capture_start_time = time.time() - 60
status = decoder.get_status()
assert status['running'] is True
assert status['satellite'] == 'NOAA-18'
assert status['frequency'] == 137.9125
assert status['mode'] == 'APT'
assert status['elapsed_seconds'] >= 60
assert status["running"] is True
assert status["satellite"] == "NOAA-18"
assert status["frequency"] == 137.9125
assert status["mode"] == "APT"
assert status["elapsed_seconds"] >= 60
def test_classify_log_type_error(self):
"""_classify_log_type() should detect errors."""
assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error'
assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error'
assert WeatherSatDecoder._classify_log_type("(E) Error occurred") == "error"
assert WeatherSatDecoder._classify_log_type("Failed to open device") == "error"
def test_classify_log_type_progress(self):
"""_classify_log_type() should detect progress."""
assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress'
assert WeatherSatDecoder._classify_log_type("Progress: 50%") == "progress"
def test_classify_log_type_save(self):
"""_classify_log_type() should detect save events."""
assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save'
assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save'
assert WeatherSatDecoder._classify_log_type("Saved image: test.png") == "save"
assert WeatherSatDecoder._classify_log_type("Writing output file") == "save"
def test_classify_log_type_signal(self):
"""_classify_log_type() should detect signal events."""
assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal'
assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal'
assert WeatherSatDecoder._classify_log_type("Signal detected") == "signal"
assert WeatherSatDecoder._classify_log_type("Lock acquired") == "signal"
def test_classify_log_type_warning(self):
"""_classify_log_type() should detect warnings."""
assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning'
assert WeatherSatDecoder._classify_log_type("(W) Low signal quality") == "warning"
def test_classify_log_type_debug(self):
"""_classify_log_type() should detect debug messages."""
assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug'
assert WeatherSatDecoder._classify_log_type("(D) Debug info") == "debug"
@patch('subprocess.run')
@patch("subprocess.run")
def test_resolve_device_id_success(self, mock_run):
"""_resolve_device_id() should extract serial from rtl_test."""
mock_result = MagicMock()
mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000'
mock_result.stderr = ''
mock_result.stdout = "Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000"
mock_result.stderr = ""
mock_run.return_value = mock_result
serial = WeatherSatDecoder._resolve_device_id(0)
assert serial == '00004000'
assert serial == "00004000"
mock_run.assert_called_once()
@patch('subprocess.run')
@patch("subprocess.run")
def test_resolve_device_id_fallback(self, mock_run):
"""_resolve_device_id() should return None when no serial found."""
mock_run.side_effect = FileNotFoundError
@@ -500,59 +539,59 @@ class TestWeatherSatDecoder:
def test_parse_product_name_rgb(self):
"""_parse_product_name() should identify RGB composite."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png'))
assert product == 'RGB Composite'
product = decoder._parse_product_name(Path("/tmp/output/rgb_composite.png"))
assert product == "RGB Composite"
def test_parse_product_name_thermal(self):
"""_parse_product_name() should identify thermal imagery."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png'))
assert product == 'Thermal'
product = decoder._parse_product_name(Path("/tmp/output/thermal_image.png"))
assert product == "Thermal"
def test_parse_product_name_channel(self):
"""_parse_product_name() should identify channel images."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
product = decoder._parse_product_name(Path('/tmp/output/channel_3.png'))
assert product == 'Channel 3'
product = decoder._parse_product_name(Path("/tmp/output/channel_3.png"))
assert product == "Channel 3"
def test_parse_product_name_unknown(self):
"""_parse_product_name() should return stem for unknown products."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png'))
assert product == 'unknown_image'
product = decoder._parse_product_name(Path("/tmp/output/unknown_image.png"))
assert product == "unknown_image"
def test_emit_progress(self):
"""_emit_progress() should call callback."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
progress = CaptureProgress(status='capturing', message='Test')
progress = CaptureProgress(status="capturing", message="Test")
decoder._emit_progress(progress)
callback.assert_called_once_with(progress)
def test_emit_progress_no_callback(self):
"""_emit_progress() should handle missing callback."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
progress = CaptureProgress(status='capturing', message='Test')
progress = CaptureProgress(status="capturing", message="Test")
decoder._emit_progress(progress) # Should not raise
def test_emit_progress_callback_exception(self):
"""_emit_progress() should handle callback exceptions."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
callback = MagicMock(side_effect=Exception('Callback error'))
callback = MagicMock(side_effect=Exception("Callback error"))
decoder.set_callback(callback)
progress = CaptureProgress(status='capturing', message='Test')
progress = CaptureProgress(status="capturing", message="Test")
decoder._emit_progress(progress) # Should not raise
@@ -562,26 +601,26 @@ class TestWeatherSatImage:
def test_to_dict(self):
"""WeatherSatImage.to_dict() should serialize correctly."""
image = WeatherSatImage(
filename='test.png',
path=Path('/tmp/test.png'),
satellite='NOAA-18',
mode='APT',
filename="test.png",
path=Path("/tmp/test.png"),
satellite="NOAA-18",
mode="APT",
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
frequency=137.9125,
size_bytes=12345,
product='RGB Composite',
product="RGB Composite",
)
data = image.to_dict()
assert data['filename'] == 'test.png'
assert data['satellite'] == 'NOAA-18'
assert data['mode'] == 'APT'
assert data['timestamp'] == '2024-01-01T12:00:00+00:00'
assert data['frequency'] == 137.9125
assert data['size_bytes'] == 12345
assert data['product'] == 'RGB Composite'
assert data['url'] == '/weather-sat/images/test.png'
assert data["filename"] == "test.png"
assert data["satellite"] == "NOAA-18"
assert data["mode"] == "APT"
assert data["timestamp"] == "2024-01-01T12:00:00+00:00"
assert data["frequency"] == 137.9125
assert data["size_bytes"] == 12345
assert data["product"] == "RGB Composite"
assert data["url"] == "/weather-sat/images/test.png"
class TestCaptureProgress:
@@ -589,51 +628,51 @@ class TestCaptureProgress:
def test_to_dict_minimal(self):
"""CaptureProgress.to_dict() with minimal fields."""
progress = CaptureProgress(status='idle')
progress = CaptureProgress(status="idle")
data = progress.to_dict()
assert data['type'] == 'weather_sat_progress'
assert data['status'] == 'idle'
assert data['satellite'] == ''
assert data['message'] == ''
assert data['progress'] == 0
assert data["type"] == "weather_sat_progress"
assert data["status"] == "idle"
assert data["satellite"] == ""
assert data["message"] == ""
assert data["progress"] == 0
def test_to_dict_complete(self):
"""CaptureProgress.to_dict() with all fields."""
image = WeatherSatImage(
filename='test.png',
path=Path('/tmp/test.png'),
satellite='NOAA-18',
mode='APT',
filename="test.png",
path=Path("/tmp/test.png"),
satellite="NOAA-18",
mode="APT",
timestamp=datetime.now(timezone.utc),
frequency=137.9125,
)
progress = CaptureProgress(
status='complete',
satellite='NOAA-18',
status="complete",
satellite="NOAA-18",
frequency=137.9125,
mode='APT',
message='Capture complete',
mode="APT",
message="Capture complete",
progress_percent=100,
elapsed_seconds=600,
image=image,
log_type='info',
capture_phase='complete',
log_type="info",
capture_phase="complete",
)
data = progress.to_dict()
assert data['status'] == 'complete'
assert data['satellite'] == 'NOAA-18'
assert data['frequency'] == 137.9125
assert data['mode'] == 'APT'
assert data['message'] == 'Capture complete'
assert data['progress'] == 100
assert data['elapsed_seconds'] == 600
assert 'image' in data
assert data['log_type'] == 'info'
assert data['capture_phase'] == 'complete'
assert data["status"] == "complete"
assert data["satellite"] == "NOAA-18"
assert data["frequency"] == 137.9125
assert data["mode"] == "APT"
assert data["message"] == "Capture complete"
assert data["progress"] == 100
assert data["elapsed_seconds"] == 600
assert "image" in data
assert data["log_type"] == "info"
assert data["capture_phase"] == "complete"
class TestGlobalFunctions:
@@ -641,8 +680,9 @@ class TestGlobalFunctions:
def test_get_weather_sat_decoder_singleton(self):
"""get_weather_sat_decoder() should return singleton."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
import utils.weather_sat as mod
old = mod._decoder
mod._decoder = None
@@ -656,8 +696,9 @@ class TestGlobalFunctions:
def test_is_weather_sat_available_true(self):
"""is_weather_sat_available() should return True when available."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
import utils.weather_sat as mod
old = mod._decoder
mod._decoder = None
@@ -668,8 +709,9 @@ class TestGlobalFunctions:
def test_is_weather_sat_available_false(self):
"""is_weather_sat_available() should return False when unavailable."""
with patch('shutil.which', return_value=None):
with patch("shutil.which", return_value=None):
import utils.weather_sat as mod
old = mod._decoder
mod._decoder = None
@@ -684,26 +726,26 @@ class TestWeatherSatellitesConstant:
def test_weather_satellites_structure(self):
"""WEATHER_SATELLITES should have correct structure."""
assert 'NOAA-18' in WEATHER_SATELLITES
sat = WEATHER_SATELLITES['NOAA-18']
assert "NOAA-18" in WEATHER_SATELLITES
sat = WEATHER_SATELLITES["NOAA-18"]
assert 'name' in sat
assert 'frequency' in sat
assert 'mode' in sat
assert 'pipeline' in sat
assert 'tle_key' in sat
assert 'description' in sat
assert 'active' in sat
assert "name" in sat
assert "frequency" in sat
assert "mode" in sat
assert "pipeline" in sat
assert "tle_key" in sat
assert "description" in sat
assert "active" in sat
def test_noaa_satellites(self):
"""NOAA satellites should have correct frequencies."""
assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620
assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125
assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100
assert WEATHER_SATELLITES["NOAA-15"]["frequency"] == 137.620
assert WEATHER_SATELLITES["NOAA-18"]["frequency"] == 137.9125
assert WEATHER_SATELLITES["NOAA-19"]["frequency"] == 137.100
def test_meteor_satellite(self):
"""Meteor satellite should use LRPT mode."""
meteor = WEATHER_SATELLITES['METEOR-M2-3']
assert meteor['mode'] == 'LRPT'
assert meteor['frequency'] == 137.900
assert meteor['pipeline'] == 'meteor_m2-x_lrpt'
meteor = WEATHER_SATELLITES["METEOR-M2-3"]
assert meteor["mode"] == "LRPT"
assert meteor["frequency"] == 137.900
assert meteor["pipeline"] == "meteor_m2-x_lrpt"
File diff suppressed because it is too large Load Diff