mirror of
https://github.com/smittix/intercept.git
synced 2026-07-05 16:18:12 -07:00
Merge upstream/main and resolve weather-satellite.js conflict
Resolved conflict in static/js/modes/weather-satellite.js: - Kept allPasses state variable and applyPassFilter() for satellite pass filtering - Kept satellite select dropdown listener for filter feature - Adopted upstream's optimistic stop() UI pattern for better responsiveness - Kept optional chaining (pass?.trajectory) since drawPolarPlot can receive null Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""APRS packet parser regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from routes.aprs import parse_aprs_packet
|
||||
|
||||
|
||||
_BASE_PACKET = "N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
_BASE_PACKET,
|
||||
f"[0.4] {_BASE_PACKET}",
|
||||
f"[0L] {_BASE_PACKET}",
|
||||
f"AFSK1200: {_BASE_PACKET}",
|
||||
f"AFSK1200: [0L] {_BASE_PACKET}",
|
||||
],
|
||||
)
|
||||
def test_parse_aprs_packet_accepts_decoder_prefix_variants(line: str) -> None:
|
||||
packet = parse_aprs_packet(line)
|
||||
assert packet is not None
|
||||
assert packet["callsign"] == "N0CALL-9"
|
||||
assert packet["type"] == "aprs"
|
||||
|
||||
|
||||
def test_parse_aprs_packet_accepts_callsign_with_tactical_suffix() -> None:
|
||||
packet = parse_aprs_packet("CALL/1>APRS:!4903.50N/07201.75W-Test")
|
||||
assert packet is not None
|
||||
assert packet["callsign"] == "CALL/1"
|
||||
assert packet["lat"] == pytest.approx(49.058333, rel=0, abs=1e-6)
|
||||
assert packet["lon"] == pytest.approx(-72.029167, rel=0, abs=1e-6)
|
||||
|
||||
|
||||
def test_parse_aprs_packet_handles_ambiguous_uncompressed_position() -> None:
|
||||
packet = parse_aprs_packet("KJ7ABC-7>APRS,WIDE1-1:!4903. N/07201. W-Test")
|
||||
assert packet is not None
|
||||
assert packet["packet_type"] == "position"
|
||||
assert packet["lat"] == pytest.approx(49.05, rel=0, abs=1e-6)
|
||||
assert packet["lon"] == pytest.approx(-72.016667, rel=0, abs=1e-6)
|
||||
|
||||
|
||||
def test_parse_aprs_packet_handles_no_decimal_position_variant() -> None:
|
||||
packet = parse_aprs_packet("KJ7ABC-7>APRS,WIDE1-1:!4903N/07201W-Test")
|
||||
assert packet is not None
|
||||
assert packet["packet_type"] == "position"
|
||||
assert packet["lat"] == pytest.approx(49.05, rel=0, abs=1e-6)
|
||||
assert packet["lon"] == pytest.approx(-72.016667, rel=0, abs=1e-6)
|
||||
+239
-37
@@ -1,8 +1,8 @@
|
||||
"""Tests for DSC (Digital Selective Calling) utilities."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TestDSCParser:
|
||||
@@ -88,17 +88,15 @@ class TestDSCParser:
|
||||
assert get_distress_nature_text('invalid') == 'invalid'
|
||||
|
||||
def test_get_format_text(self):
|
||||
"""Test format code to text conversion."""
|
||||
"""Test format code to text conversion per ITU-R M.493."""
|
||||
from utils.dsc.parser import get_format_text
|
||||
|
||||
assert get_format_text(100) == 'DISTRESS'
|
||||
assert get_format_text(102) == 'ALL_SHIPS'
|
||||
assert get_format_text(106) == 'DISTRESS_ACK'
|
||||
assert get_format_text(108) == 'DISTRESS_RELAY'
|
||||
assert get_format_text(112) == 'INDIVIDUAL'
|
||||
assert get_format_text(116) == 'ROUTINE'
|
||||
assert get_format_text(118) == 'SAFETY'
|
||||
assert get_format_text(120) == 'URGENCY'
|
||||
assert get_format_text(114) == 'INDIVIDUAL_ACK'
|
||||
assert get_format_text(116) == 'GROUP'
|
||||
assert get_format_text(120) == 'DISTRESS'
|
||||
assert get_format_text(123) == 'ALL_SHIPS_URGENCY_SAFETY'
|
||||
|
||||
def test_get_format_text_unknown(self):
|
||||
"""Test format code returns unknown for invalid codes."""
|
||||
@@ -107,6 +105,15 @@ class TestDSCParser:
|
||||
result = get_format_text(999)
|
||||
assert 'UNKNOWN' in result
|
||||
|
||||
def test_get_format_text_removed_codes(self):
|
||||
"""Test that non-ITU format codes are no longer recognized."""
|
||||
from utils.dsc.parser import get_format_text
|
||||
|
||||
# These were previously defined but are not ITU-R M.493 specifiers
|
||||
for code in [100, 104, 106, 108, 110, 118]:
|
||||
result = get_format_text(code)
|
||||
assert 'UNKNOWN' in result
|
||||
|
||||
def test_get_telecommand_text(self):
|
||||
"""Test telecommand code to text conversion."""
|
||||
from utils.dsc.parser import get_telecommand_text
|
||||
@@ -124,14 +131,13 @@ class TestDSCParser:
|
||||
assert get_category_priority('DISTRESS') == 0
|
||||
assert get_category_priority('distress') == 0
|
||||
|
||||
# Urgency is lower
|
||||
assert get_category_priority('URGENCY') == 3
|
||||
# Urgency/safety
|
||||
assert get_category_priority('ALL_SHIPS_URGENCY_SAFETY') == 2
|
||||
|
||||
# Safety is lower still
|
||||
assert get_category_priority('SAFETY') == 4
|
||||
|
||||
# Routine is lowest
|
||||
assert get_category_priority('ROUTINE') == 5
|
||||
# Routine-level
|
||||
assert get_category_priority('ALL_SHIPS') == 5
|
||||
assert get_category_priority('GROUP') == 5
|
||||
assert get_category_priority('INDIVIDUAL') == 5
|
||||
|
||||
# Unknown gets default high number
|
||||
assert get_category_priority('UNKNOWN') == 10
|
||||
@@ -182,19 +188,20 @@ class TestDSCParser:
|
||||
assert classify_mmsi('812345678') == 'unknown'
|
||||
|
||||
def test_parse_dsc_message_distress(self):
|
||||
"""Test parsing a distress message."""
|
||||
"""Test parsing a distress message with ITU format 120."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 100,
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '000000000',
|
||||
'dest_mmsi': '002320001',
|
||||
'category': 'DISTRESS',
|
||||
'nature': 101,
|
||||
'position': {'lat': 51.5, 'lon': -0.1},
|
||||
'telecommand1': 100,
|
||||
'timestamp': '2025-01-15T12:00:00Z'
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '120002032123456101100127',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
@@ -210,26 +217,49 @@ class TestDSCParser:
|
||||
assert msg['is_critical'] is True
|
||||
assert msg['priority'] == 0
|
||||
|
||||
def test_parse_dsc_message_routine(self):
|
||||
"""Test parsing a routine message."""
|
||||
def test_parse_dsc_message_group(self):
|
||||
"""Test parsing a group call message."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 116,
|
||||
'source_mmsi': '366000001',
|
||||
'category': 'ROUTINE',
|
||||
'timestamp': '2025-01-15T12:00:00Z'
|
||||
'dest_mmsi': '023200001',
|
||||
'category': 'GROUP',
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '116023200001366000001117',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
|
||||
assert msg is not None
|
||||
assert msg['category'] == 'ROUTINE'
|
||||
assert msg['category'] == 'GROUP'
|
||||
assert msg['source_country'] == 'USA'
|
||||
assert msg['is_critical'] is False
|
||||
assert msg['priority'] == 5
|
||||
|
||||
def test_parse_dsc_message_individual(self):
|
||||
"""Test parsing an individual call message."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 112,
|
||||
'source_mmsi': '366000001',
|
||||
'dest_mmsi': '232123456',
|
||||
'category': 'INDIVIDUAL',
|
||||
'telecommand1': 100,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '112232123456366000001100122',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
|
||||
assert msg is not None
|
||||
assert msg['category'] == 'INDIVIDUAL'
|
||||
assert msg['is_critical'] is False
|
||||
|
||||
def test_parse_dsc_message_invalid_json(self):
|
||||
"""Test parsing rejects invalid JSON."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
@@ -262,6 +292,171 @@ class TestDSCParser:
|
||||
assert parse_dsc_message(None) is None
|
||||
assert parse_dsc_message(' ') is None
|
||||
|
||||
def test_parse_dsc_message_rejects_non_itu_format(self):
|
||||
"""Test parser rejects records with non-ITU format specifier."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
for bad_format in [100, 104, 106, 108, 110, 118, 999]:
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': bad_format,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'ROUTINE',
|
||||
'raw': '120232123456100127',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None, f"Format {bad_format} should be rejected"
|
||||
|
||||
def test_parse_dsc_message_rejects_telecommand_out_of_range(self):
|
||||
"""Test parser rejects records with telecommand out of 100-127 range."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '002320001',
|
||||
'category': 'DISTRESS',
|
||||
'telecommand1': 200,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '120002032123456200127',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
def test_parse_dsc_message_accepts_zero_telecommand(self):
|
||||
"""Test parser does not drop telecommand with value 100 (truthiness fix)."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 112,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '366000001',
|
||||
'category': 'INDIVIDUAL',
|
||||
'telecommand1': 100,
|
||||
'telecommand2': 100,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '112366000001232123456100100122',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None
|
||||
assert msg['telecommand1'] == 100
|
||||
assert msg['telecommand2'] == 100
|
||||
|
||||
def test_parse_dsc_message_validates_raw_field(self):
|
||||
"""Test parser validates raw field structure."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
# Non-digit raw field
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '12abc',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
# Raw field length not divisible by 3
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '1201',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
# Raw field with non-EOS last token
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '120100',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
def test_parse_dsc_message_accepts_valid_eos_in_raw(self):
|
||||
"""Test parser accepts all three valid EOS values in raw field."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
for eos in [117, 122, 127]:
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '002320001',
|
||||
'category': 'DISTRESS',
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': f'120002032123456{eos:03d}',
|
||||
})
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None, f"EOS {eos} should be accepted"
|
||||
|
||||
def test_parse_dsc_message_rejects_invalid_mmsi(self):
|
||||
"""Test parser rejects invalid MMSI values."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
# All-zeros MMSI
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '000000000',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '120000000000127',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
# Short MMSI
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '12345',
|
||||
'category': 'DISTRESS',
|
||||
'raw': '120127',
|
||||
})
|
||||
assert parse_dsc_message(raw) is None
|
||||
|
||||
def test_parse_dsc_message_nature_zero_not_dropped(self):
|
||||
"""Test that nature code 0 is not dropped by truthiness check."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 120,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '002320001',
|
||||
'category': 'DISTRESS',
|
||||
'nature': 0,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '120002032123456000127',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None
|
||||
assert msg['nature_code'] == 0
|
||||
|
||||
def test_parse_dsc_message_channel_zero_not_dropped(self):
|
||||
"""Test that channel value 0 is not dropped by truthiness check."""
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 112,
|
||||
'source_mmsi': '232123456',
|
||||
'dest_mmsi': '366000001',
|
||||
'category': 'INDIVIDUAL',
|
||||
'channel': 0,
|
||||
'telecommand1': 100,
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '112366000001232123456100122',
|
||||
})
|
||||
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None
|
||||
assert msg['channel'] == 0
|
||||
|
||||
def test_format_dsc_for_display(self):
|
||||
"""Test message formatting for display."""
|
||||
from utils.dsc.parser import format_dsc_for_display
|
||||
@@ -413,17 +608,24 @@ class TestDSCConstants:
|
||||
"""Tests for DSC constants."""
|
||||
|
||||
def test_format_codes_completeness(self):
|
||||
"""Test that all standard format codes are defined."""
|
||||
"""Test that all ITU-R M.493 format specifiers are defined."""
|
||||
from utils.dsc.constants import FORMAT_CODES
|
||||
|
||||
# ITU-R M.493 format codes
|
||||
assert 100 in FORMAT_CODES # DISTRESS
|
||||
assert 102 in FORMAT_CODES # ALL_SHIPS
|
||||
assert 106 in FORMAT_CODES # DISTRESS_ACK
|
||||
assert 112 in FORMAT_CODES # INDIVIDUAL
|
||||
assert 116 in FORMAT_CODES # ROUTINE
|
||||
assert 118 in FORMAT_CODES # SAFETY
|
||||
assert 120 in FORMAT_CODES # URGENCY
|
||||
# ITU-R M.493 format specifiers (and only these)
|
||||
expected_keys = {102, 112, 114, 116, 120, 123}
|
||||
assert set(FORMAT_CODES.keys()) == expected_keys
|
||||
|
||||
def test_valid_format_specifiers_set(self):
|
||||
"""Test VALID_FORMAT_SPECIFIERS matches FORMAT_CODES keys."""
|
||||
from utils.dsc.constants import FORMAT_CODES, VALID_FORMAT_SPECIFIERS
|
||||
|
||||
assert set(FORMAT_CODES.keys()) == VALID_FORMAT_SPECIFIERS
|
||||
|
||||
def test_valid_eos_symbols(self):
|
||||
"""Test VALID_EOS contains the three ITU-defined EOS symbols."""
|
||||
from utils.dsc.constants import VALID_EOS
|
||||
|
||||
assert {117, 122, 127} == VALID_EOS
|
||||
|
||||
def test_distress_nature_codes_completeness(self):
|
||||
"""Test that all distress nature codes are defined."""
|
||||
@@ -458,13 +660,13 @@ class TestDSCConstants:
|
||||
assert VHF_CHANNELS[70] == 156.525
|
||||
|
||||
def test_dsc_modulation_parameters(self):
|
||||
"""Test DSC modulation constants."""
|
||||
"""Test DSC modulation constants per ITU-R M.493."""
|
||||
from utils.dsc.constants import (
|
||||
DSC_BAUD_RATE,
|
||||
DSC_MARK_FREQ,
|
||||
DSC_SPACE_FREQ,
|
||||
)
|
||||
|
||||
assert DSC_BAUD_RATE == 100
|
||||
assert DSC_MARK_FREQ == 1800
|
||||
assert DSC_SPACE_FREQ == 1200
|
||||
assert DSC_BAUD_RATE == 1200
|
||||
assert DSC_MARK_FREQ == 2100
|
||||
assert DSC_SPACE_FREQ == 1300
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for GPS route behavior and gps client callback management."""
|
||||
|
||||
from routes import gps as gps_routes
|
||||
from utils.gps import GPSDClient
|
||||
|
||||
|
||||
def test_gpsd_client_add_callback_deduplicates():
|
||||
"""Adding the same position callback twice should only register once."""
|
||||
client = GPSDClient()
|
||||
|
||||
def callback(_position):
|
||||
return None
|
||||
|
||||
client.add_callback(callback)
|
||||
client.add_callback(callback)
|
||||
|
||||
assert client._callbacks.count(callback) == 1
|
||||
|
||||
|
||||
def test_gpsd_client_add_sky_callback_deduplicates():
|
||||
"""Adding the same sky callback twice should only register once."""
|
||||
client = GPSDClient()
|
||||
|
||||
def callback(_sky):
|
||||
return None
|
||||
|
||||
client.add_sky_callback(callback)
|
||||
client.add_sky_callback(callback)
|
||||
|
||||
assert client._sky_callbacks.count(callback) == 1
|
||||
|
||||
|
||||
def test_auto_connect_attaches_callbacks_when_reader_already_running(client, monkeypatch):
|
||||
"""Auto-connect should re-attach stream callbacks for an already-running reader."""
|
||||
|
||||
class FakeReader:
|
||||
is_running = True
|
||||
position = None
|
||||
sky = None
|
||||
|
||||
def __init__(self):
|
||||
self.position_callbacks = []
|
||||
self.sky_callbacks = []
|
||||
|
||||
def add_callback(self, callback):
|
||||
self.position_callbacks.append(callback)
|
||||
|
||||
def add_sky_callback(self, callback):
|
||||
self.sky_callbacks.append(callback)
|
||||
|
||||
reader = FakeReader()
|
||||
monkeypatch.setattr(gps_routes, 'get_gps_reader', lambda: reader)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
|
||||
response = client.post('/gps/auto-connect')
|
||||
payload = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload['status'] == 'connected'
|
||||
assert reader.position_callbacks == [gps_routes._position_callback]
|
||||
assert reader.sky_callbacks == [gps_routes._sky_callback]
|
||||
|
||||
|
||||
def test_satellites_returns_waiting_when_reader_not_running(client, monkeypatch):
|
||||
"""Satellite endpoint should return a non-error waiting state when reader is down."""
|
||||
monkeypatch.setattr(gps_routes, 'get_gps_reader', lambda: None)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
|
||||
response = client.get('/gps/satellites')
|
||||
payload = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload['status'] == 'waiting'
|
||||
assert payload['running'] is False
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Tests for Morse code decoder (utils/morse.py) and routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.morse import (
|
||||
CHAR_TO_MORSE,
|
||||
MORSE_TABLE,
|
||||
GoertzelFilter,
|
||||
MorseDecoder,
|
||||
morse_decoder_thread,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _login_session(client) -> None:
|
||||
"""Mark the Flask test session as authenticated."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess['username'] = 'test'
|
||||
sess['role'] = 'admin'
|
||||
|
||||
|
||||
def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes:
|
||||
"""Generate a pure sine wave as 16-bit LE PCM bytes."""
|
||||
n_samples = int(sample_rate * duration)
|
||||
samples = []
|
||||
for i in range(n_samples):
|
||||
t = i / sample_rate
|
||||
val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t))
|
||||
samples.append(max(-32768, min(32767, val)))
|
||||
return struct.pack(f'<{len(samples)}h', *samples)
|
||||
|
||||
|
||||
def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
|
||||
"""Generate silence as 16-bit LE PCM bytes."""
|
||||
n_samples = int(sample_rate * duration)
|
||||
return b'\x00\x00' * n_samples
|
||||
|
||||
|
||||
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
|
||||
"""Generate PCM audio for a Morse-encoded string."""
|
||||
dit_dur = 1.2 / wpm
|
||||
dah_dur = 3 * dit_dur
|
||||
element_gap = dit_dur
|
||||
char_gap = 3 * dit_dur
|
||||
word_gap = 7 * dit_dur
|
||||
|
||||
audio = b''
|
||||
words = text.upper().split()
|
||||
for wi, word in enumerate(words):
|
||||
for ci, char in enumerate(word):
|
||||
morse = CHAR_TO_MORSE.get(char)
|
||||
if morse is None:
|
||||
continue
|
||||
for ei, element in enumerate(morse):
|
||||
if element == '.':
|
||||
audio += generate_tone(tone_freq, dit_dur, sample_rate)
|
||||
elif element == '-':
|
||||
audio += generate_tone(tone_freq, dah_dur, sample_rate)
|
||||
if ei < len(morse) - 1:
|
||||
audio += generate_silence(element_gap, sample_rate)
|
||||
if ci < len(word) - 1:
|
||||
audio += generate_silence(char_gap, sample_rate)
|
||||
if wi < len(words) - 1:
|
||||
audio += generate_silence(word_gap, sample_rate)
|
||||
|
||||
# Add some leading/trailing silence for threshold settling
|
||||
silence = generate_silence(0.3, sample_rate)
|
||||
return silence + audio + silence
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MORSE_TABLE tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseTable:
|
||||
def test_all_26_letters_present(self):
|
||||
chars = set(MORSE_TABLE.values())
|
||||
for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
|
||||
assert letter in chars, f"Missing letter: {letter}"
|
||||
|
||||
def test_all_10_digits_present(self):
|
||||
chars = set(MORSE_TABLE.values())
|
||||
for digit in '0123456789':
|
||||
assert digit in chars, f"Missing digit: {digit}"
|
||||
|
||||
def test_reverse_lookup_consistent(self):
|
||||
for morse, char in MORSE_TABLE.items():
|
||||
if char in CHAR_TO_MORSE:
|
||||
assert CHAR_TO_MORSE[char] == morse
|
||||
|
||||
def test_no_duplicate_morse_codes(self):
|
||||
"""Each morse pattern should map to exactly one character."""
|
||||
assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GoertzelFilter tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGoertzelFilter:
|
||||
def test_detects_target_frequency(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
# Generate 700 Hz tone
|
||||
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag > 10.0, f"Expected high magnitude for target freq, got {mag}"
|
||||
|
||||
def test_rejects_off_frequency(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
# Generate 1500 Hz tone (well off target)
|
||||
samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)]
|
||||
mag_off = gf.magnitude(samples)
|
||||
|
||||
# Compare with on-target
|
||||
samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
|
||||
mag_on = gf.magnitude(samples_on)
|
||||
|
||||
assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq"
|
||||
|
||||
def test_silence_returns_near_zero(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
samples = [0.0] * 160
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag < 0.01, f"Expected near-zero for silence, got {mag}"
|
||||
|
||||
def test_different_block_sizes(self):
|
||||
for block_size in [80, 160, 320]:
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size)
|
||||
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)]
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag > 5.0, f"Should detect tone with block_size={block_size}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MorseDecoder tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseDecoder:
|
||||
def _make_decoder(self, wpm=15):
|
||||
"""Create decoder with pre-warmed threshold for testing."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm)
|
||||
# Warm up noise floor with silence
|
||||
silence = generate_silence(0.5)
|
||||
decoder.process_block(silence)
|
||||
# Warm up signal peak with tone
|
||||
tone = generate_tone(700.0, 0.3)
|
||||
decoder.process_block(tone)
|
||||
# More silence to settle
|
||||
silence2 = generate_silence(0.5)
|
||||
decoder.process_block(silence2)
|
||||
# Reset state after warm-up
|
||||
decoder._tone_on = False
|
||||
decoder._current_symbol = ''
|
||||
decoder._tone_blocks = 0
|
||||
decoder._silence_blocks = 0
|
||||
return decoder
|
||||
|
||||
def test_dit_detection(self):
|
||||
"""A single dit should produce a '.' in the symbol buffer."""
|
||||
decoder = self._make_decoder()
|
||||
dit_dur = 1.2 / 15
|
||||
|
||||
# Send a tone burst (dit)
|
||||
tone = generate_tone(700.0, dit_dur)
|
||||
decoder.process_block(tone)
|
||||
|
||||
# Send silence to trigger end of tone
|
||||
silence = generate_silence(dit_dur * 2)
|
||||
decoder.process_block(silence)
|
||||
|
||||
# Symbol buffer should have a dot
|
||||
assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'"
|
||||
|
||||
def test_dah_detection(self):
|
||||
"""A longer tone should produce a '-' in the symbol buffer."""
|
||||
decoder = self._make_decoder()
|
||||
dah_dur = 3 * 1.2 / 15
|
||||
|
||||
tone = generate_tone(700.0, dah_dur)
|
||||
decoder.process_block(tone)
|
||||
|
||||
silence = generate_silence(dah_dur)
|
||||
decoder.process_block(silence)
|
||||
|
||||
assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'"
|
||||
|
||||
def test_decode_letter_e(self):
|
||||
"""E is a single dit - the simplest character."""
|
||||
decoder = self._make_decoder()
|
||||
audio = generate_morse_audio('E', wpm=15)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
chars = [e for e in events if e['type'] == 'morse_char']
|
||||
decoded = ''.join(e['char'] for e in chars)
|
||||
assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'"
|
||||
|
||||
def test_decode_letter_t(self):
|
||||
"""T is a single dah."""
|
||||
decoder = self._make_decoder()
|
||||
audio = generate_morse_audio('T', wpm=15)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
chars = [e for e in events if e['type'] == 'morse_char']
|
||||
decoded = ''.join(e['char'] for e in chars)
|
||||
assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'"
|
||||
|
||||
def test_word_space_detection(self):
|
||||
"""A long silence between words should produce decoded chars with a space."""
|
||||
decoder = self._make_decoder()
|
||||
dit_dur = 1.2 / 15
|
||||
# E = dit
|
||||
audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5)
|
||||
# T = dah
|
||||
audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
spaces = [e for e in events if e['type'] == 'morse_space']
|
||||
assert len(spaces) >= 1, "Expected at least one word space"
|
||||
|
||||
def test_scope_events_generated(self):
|
||||
"""Decoder should produce scope events for visualization."""
|
||||
audio = generate_morse_audio('SOS', wpm=15)
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
|
||||
events = decoder.process_block(audio)
|
||||
|
||||
scope_events = [e for e in events if e['type'] == 'scope']
|
||||
assert len(scope_events) > 0, "Expected scope events"
|
||||
# Check scope event structure
|
||||
se = scope_events[0]
|
||||
assert 'amplitudes' in se
|
||||
assert 'threshold' in se
|
||||
assert 'tone_on' in se
|
||||
|
||||
def test_adaptive_threshold_adjusts(self):
|
||||
"""After processing audio, threshold should be non-zero."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
|
||||
# Process some tone + silence
|
||||
audio = generate_tone(700.0, 0.3) + generate_silence(0.3)
|
||||
decoder.process_block(audio)
|
||||
|
||||
assert decoder._threshold > 0, "Threshold should adapt above zero"
|
||||
|
||||
def test_flush_emits_pending_char(self):
|
||||
"""flush() should emit any accumulated but not-yet-decoded symbol."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
decoder._current_symbol = '.' # Manually set pending dit
|
||||
events = decoder.flush()
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'morse_char'
|
||||
assert events[0]['char'] == 'E'
|
||||
|
||||
def test_flush_empty_returns_nothing(self):
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
events = decoder.flush()
|
||||
assert events == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# morse_decoder_thread tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseDecoderThread:
|
||||
def test_thread_stops_on_event(self):
|
||||
"""Thread should exit when stop_event is set."""
|
||||
import io
|
||||
# Create a fake stdout that blocks until stop
|
||||
stop = threading.Event()
|
||||
q = queue.Queue(maxsize=100)
|
||||
|
||||
# Feed some audio then close
|
||||
audio = generate_morse_audio('E', wpm=15)
|
||||
fake_stdout = io.BytesIO(audio)
|
||||
|
||||
t = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(fake_stdout, q, stop),
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
t.join(timeout=5)
|
||||
assert not t.is_alive(), "Thread should finish after reading all data"
|
||||
|
||||
def test_thread_produces_events(self):
|
||||
"""Thread should push character events to the queue."""
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
stop = threading.Event()
|
||||
q = queue.Queue(maxsize=1000)
|
||||
|
||||
# Generate audio with pre-warmed decoder in mind
|
||||
# The thread creates a fresh decoder, so generate lots of audio
|
||||
audio = generate_silence(0.5) + generate_morse_audio('SOS', wpm=10) + generate_silence(1.0)
|
||||
fake_stdout = io.BytesIO(audio)
|
||||
|
||||
# Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads
|
||||
with patch('utils.morse.time') as mock_time:
|
||||
# Make monotonic() always return increasing values
|
||||
counter = [0.0]
|
||||
def fake_monotonic():
|
||||
counter[0] += 0.15 # each call advances 150ms
|
||||
return counter[0]
|
||||
mock_time.monotonic = fake_monotonic
|
||||
|
||||
t = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(fake_stdout, q, stop),
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
t.join(timeout=10)
|
||||
|
||||
events = []
|
||||
while not q.empty():
|
||||
events.append(q.get_nowait())
|
||||
|
||||
# Should have at least some events (scope or char)
|
||||
assert len(events) > 0, "Expected events from thread"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseRoutes:
|
||||
def test_start_missing_required_fields(self, client):
|
||||
"""Start should succeed with defaults."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
# Should fail because rtl_fm won't be found in test env
|
||||
resp = client.post('/morse/start', json={'frequency': '14.060'})
|
||||
assert resp.status_code in (200, 400, 409, 500)
|
||||
|
||||
def test_stop_when_not_running(self, client):
|
||||
"""Stop when nothing is running should return not_running."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/stop')
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'not_running'
|
||||
|
||||
def test_status_when_not_running(self, client):
|
||||
"""Status should report not running."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.get('/morse/status')
|
||||
data = resp.get_json()
|
||||
assert data['running'] is False
|
||||
|
||||
def test_invalid_tone_freq(self, client):
|
||||
"""Tone frequency outside range should be rejected."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'tone_freq': '50', # too low
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_invalid_wpm(self, client):
|
||||
"""WPM outside range should be rejected."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'wpm': '100', # too high
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_stream_endpoint_exists(self, client):
|
||||
"""Stream endpoint should return SSE content type."""
|
||||
_login_session(client)
|
||||
resp = client.get('/morse/stream')
|
||||
assert resp.content_type.startswith('text/event-stream')
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for SSE fanout queue behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.sse import subscribe_fanout_queue
|
||||
|
||||
|
||||
def _channel_key(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4()}"
|
||||
|
||||
|
||||
def test_fanout_drains_source_queue_without_subscribers() -> None:
|
||||
"""Queued messages should be dropped while no SSE clients are connected."""
|
||||
source = queue.Queue()
|
||||
channel_key = _channel_key("sse-idle")
|
||||
|
||||
# Start fanout distributor, then remove the only subscriber.
|
||||
_, unsubscribe = subscribe_fanout_queue(source, channel_key=channel_key, source_timeout=0.01)
|
||||
unsubscribe()
|
||||
|
||||
source.put({"type": "aprs", "callsign": "N0CALL"})
|
||||
time.sleep(0.05)
|
||||
|
||||
assert source.qsize() == 0
|
||||
|
||||
|
||||
def test_fanout_does_not_replay_stale_message_after_re_subscribe() -> None:
|
||||
"""A message queued while disconnected should not be replayed on reconnect."""
|
||||
source = queue.Queue()
|
||||
channel_key = _channel_key("sse-resub")
|
||||
|
||||
_, unsubscribe = subscribe_fanout_queue(source, channel_key=channel_key, source_timeout=0.01)
|
||||
unsubscribe()
|
||||
|
||||
source.put({"type": "aprs", "callsign": "K1ABC"})
|
||||
|
||||
subscriber, unsubscribe2 = subscribe_fanout_queue(
|
||||
source,
|
||||
channel_key=channel_key,
|
||||
source_timeout=0.01,
|
||||
)
|
||||
try:
|
||||
with pytest.raises(queue.Empty):
|
||||
subscriber.get(timeout=0.1)
|
||||
live = {"type": "aprs", "callsign": "LIVE01"}
|
||||
source.put(live)
|
||||
got = subscriber.get(timeout=0.25)
|
||||
finally:
|
||||
unsubscribe2()
|
||||
|
||||
assert got == live
|
||||
+214
-188
@@ -76,12 +76,12 @@ class TestReceive:
|
||||
mock_proc.stderr = MagicMock()
|
||||
mock_proc.stderr.readline = MagicMock(return_value=b'')
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True), \
|
||||
patch('utils.subghz.register_process'):
|
||||
manager._hackrf_available = None
|
||||
result = manager.start_receive(
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True), \
|
||||
patch('utils.subghz.register_process'):
|
||||
manager._hackrf_available = None
|
||||
result = manager.start_receive(
|
||||
frequency_hz=433920000,
|
||||
sample_rate=2000000,
|
||||
lna_gain=32,
|
||||
@@ -92,9 +92,14 @@ class TestReceive:
|
||||
assert manager.active_mode == 'rx'
|
||||
|
||||
def test_start_receive_already_running(self, manager):
|
||||
import time as _time
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
manager._rx_process = mock_proc
|
||||
# Pre-lock device checks now run before active_mode guard
|
||||
manager._hackrf_available = True
|
||||
manager._hackrf_device_cache = True
|
||||
manager._hackrf_device_cache_ts = _time.time()
|
||||
|
||||
result = manager.start_receive(frequency_hz=433920000)
|
||||
assert result['status'] == 'error'
|
||||
@@ -104,10 +109,10 @@ class TestReceive:
|
||||
result = manager.stop_receive()
|
||||
assert result['status'] == 'not_running'
|
||||
|
||||
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
|
||||
# Create a fake IQ file
|
||||
iq_file = tmp_data_dir / 'captures' / 'test.iq'
|
||||
iq_file.write_bytes(b'\x00' * 1024)
|
||||
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
|
||||
# Create a fake IQ file
|
||||
iq_file = tmp_data_dir / 'captures' / 'test.iq'
|
||||
iq_file.write_bytes(b'\x00' * 1024)
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
@@ -115,10 +120,10 @@ class TestReceive:
|
||||
manager._rx_file = iq_file
|
||||
manager._rx_frequency_hz = 433920000
|
||||
manager._rx_sample_rate = 2000000
|
||||
manager._rx_lna_gain = 32
|
||||
manager._rx_vga_gain = 20
|
||||
manager._rx_start_time = 1000.0
|
||||
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
|
||||
manager._rx_lna_gain = 32
|
||||
manager._rx_vga_gain = 20
|
||||
manager._rx_start_time = 1000.0
|
||||
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
|
||||
|
||||
with patch('utils.subghz.safe_terminate'), \
|
||||
patch('time.time', return_value=1005.0):
|
||||
@@ -131,10 +136,10 @@ class TestReceive:
|
||||
# Verify JSON sidecar was written
|
||||
meta_path = iq_file.with_suffix('.json')
|
||||
assert meta_path.exists()
|
||||
meta = json.loads(meta_path.read_text())
|
||||
assert meta['frequency_hz'] == 433920000
|
||||
assert isinstance(meta.get('bursts'), list)
|
||||
assert meta['bursts'][0]['peak_level'] == 42
|
||||
meta = json.loads(meta_path.read_text())
|
||||
assert meta['frequency_hz'] == 433920000
|
||||
assert isinstance(meta.get('bursts'), list)
|
||||
assert meta['bursts'][0]['peak_level'] == 42
|
||||
|
||||
|
||||
class TestTxSafety:
|
||||
@@ -165,13 +170,13 @@ class TestTxSafety:
|
||||
result = manager.transmit(capture_id='abc123')
|
||||
assert result['status'] == 'error'
|
||||
|
||||
def test_transmit_capture_not_found(self, manager):
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True):
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(capture_id='nonexistent')
|
||||
assert result['status'] == 'error'
|
||||
assert 'not found' in result['message']
|
||||
def test_transmit_capture_not_found(self, manager):
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True):
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(capture_id='nonexistent')
|
||||
assert result['status'] == 'error'
|
||||
assert 'not found' in result['message']
|
||||
|
||||
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
|
||||
# Create a capture with out-of-band frequency
|
||||
@@ -188,64 +193,79 @@ class TestTxSafety:
|
||||
meta_path.write_text(json.dumps(meta))
|
||||
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100)
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True):
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(capture_id='test123')
|
||||
assert result['status'] == 'error'
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True):
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(capture_id='test123')
|
||||
assert result['status'] == 'error'
|
||||
assert 'outside allowed TX bands' in result['message']
|
||||
|
||||
def test_transmit_already_running(self, manager):
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
manager._rx_process = mock_proc
|
||||
|
||||
result = manager.transmit(capture_id='test123')
|
||||
assert result['status'] == 'error'
|
||||
assert 'Already running' in result['message']
|
||||
|
||||
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
|
||||
meta = {
|
||||
'id': 'seg001',
|
||||
'filename': 'seg.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 1.0,
|
||||
'size_bytes': 2000,
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
|
||||
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_timer = MagicMock()
|
||||
mock_timer.start = MagicMock()
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True), \
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch('utils.subghz.register_process'), \
|
||||
patch('threading.Timer', return_value=mock_timer), \
|
||||
patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread.start = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(
|
||||
capture_id='seg001',
|
||||
start_seconds=0.2,
|
||||
duration_seconds=0.3,
|
||||
)
|
||||
|
||||
assert result['status'] == 'transmitting'
|
||||
assert result['segment'] is not None
|
||||
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
|
||||
assert manager._tx_temp_file is not None
|
||||
assert manager._tx_temp_file.exists()
|
||||
def test_transmit_already_running(self, manager, tmp_data_dir):
|
||||
import time as _time
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
manager._rx_process = mock_proc
|
||||
# Pre-lock device checks now run before active_mode guard
|
||||
manager._hackrf_available = True
|
||||
manager._hackrf_device_cache = True
|
||||
manager._hackrf_device_cache_ts = _time.time()
|
||||
# Capture lookup also runs pre-lock now; provide a valid capture + IQ file
|
||||
meta = {
|
||||
'id': 'test123',
|
||||
'filename': 'test.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2025-01-01T00:00:00',
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta))
|
||||
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 64)
|
||||
|
||||
result = manager.transmit(capture_id='test123')
|
||||
assert result['status'] == 'error'
|
||||
assert 'Already running' in result['message']
|
||||
|
||||
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
|
||||
meta = {
|
||||
'id': 'seg001',
|
||||
'filename': 'seg.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 1.0,
|
||||
'size_bytes': 2000,
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
|
||||
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_timer = MagicMock()
|
||||
mock_timer.start = MagicMock()
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
|
||||
patch.object(manager, 'check_hackrf_device', return_value=True), \
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch('utils.subghz.register_process'), \
|
||||
patch('threading.Timer', return_value=mock_timer), \
|
||||
patch('threading.Thread') as mock_thread_cls:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread.start = MagicMock()
|
||||
mock_thread_cls.return_value = mock_thread
|
||||
|
||||
manager._hackrf_available = None
|
||||
result = manager.transmit(
|
||||
capture_id='seg001',
|
||||
start_seconds=0.2,
|
||||
duration_seconds=0.3,
|
||||
)
|
||||
|
||||
assert result['status'] == 'transmitting'
|
||||
assert result['segment'] is not None
|
||||
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
|
||||
assert manager._tx_temp_file is not None
|
||||
assert manager._tx_temp_file.exists()
|
||||
|
||||
|
||||
class TestCaptureLibrary:
|
||||
@@ -311,11 +331,11 @@ class TestCaptureLibrary:
|
||||
def test_delete_capture_not_found(self, manager):
|
||||
assert manager.delete_capture('nonexistent') is False
|
||||
|
||||
def test_update_label(self, manager, tmp_data_dir):
|
||||
meta = {
|
||||
'id': 'lbl001',
|
||||
'filename': 'label_test.iq',
|
||||
'frequency_hz': 433920000,
|
||||
def test_update_label(self, manager, tmp_data_dir):
|
||||
meta = {
|
||||
'id': 'lbl001',
|
||||
'filename': 'label_test.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'label': '',
|
||||
@@ -324,10 +344,10 @@ class TestCaptureLibrary:
|
||||
meta_path.write_text(json.dumps(meta))
|
||||
|
||||
assert manager.update_capture_label('lbl001', 'Garage Remote') is True
|
||||
|
||||
updated = json.loads(meta_path.read_text())
|
||||
assert updated['label'] == 'Garage Remote'
|
||||
assert updated['label_source'] == 'manual'
|
||||
|
||||
updated = json.loads(meta_path.read_text())
|
||||
assert updated['label'] == 'Garage Remote'
|
||||
assert updated['label_source'] == 'manual'
|
||||
|
||||
def test_update_label_not_found(self, manager):
|
||||
assert manager.update_capture_label('nonexistent', 'test') is False
|
||||
@@ -348,100 +368,100 @@ class TestCaptureLibrary:
|
||||
assert path is not None
|
||||
assert path.name == 'path_test.iq'
|
||||
|
||||
def test_get_capture_path_not_found(self, manager):
|
||||
assert manager.get_capture_path('nonexistent') is None
|
||||
|
||||
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
|
||||
captures_dir = tmp_data_dir / 'captures'
|
||||
iq_path = captures_dir / 'trim_src.iq'
|
||||
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
|
||||
(captures_dir / 'trim_src.json').write_text(json.dumps({
|
||||
'id': 'trim001',
|
||||
'filename': 'trim_src.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 2.0,
|
||||
'size_bytes': 4000,
|
||||
'label': 'Weather Burst',
|
||||
'bursts': [
|
||||
{
|
||||
'start_seconds': 0.55,
|
||||
'duration_seconds': 0.2,
|
||||
'peak_level': 67,
|
||||
'fingerprint': 'abc123',
|
||||
'modulation_hint': 'OOK/ASK',
|
||||
'modulation_confidence': 0.9,
|
||||
}
|
||||
],
|
||||
}))
|
||||
|
||||
result = manager.trim_capture(
|
||||
capture_id='trim001',
|
||||
start_seconds=0.5,
|
||||
duration_seconds=0.4,
|
||||
)
|
||||
|
||||
assert result['status'] == 'ok'
|
||||
assert result['capture']['id'] != 'trim001'
|
||||
assert result['capture']['size_bytes'] == 800
|
||||
assert result['capture']['label'].endswith('(Trim)')
|
||||
trimmed_iq = captures_dir / result['capture']['filename']
|
||||
assert trimmed_iq.exists()
|
||||
trimmed_meta = trimmed_iq.with_suffix('.json')
|
||||
assert trimmed_meta.exists()
|
||||
|
||||
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
|
||||
captures_dir = tmp_data_dir / 'captures'
|
||||
iq_path = captures_dir / 'auto_src.iq'
|
||||
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
|
||||
(captures_dir / 'auto_src.json').write_text(json.dumps({
|
||||
'id': 'trim002',
|
||||
'filename': 'auto_src.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 2.0,
|
||||
'size_bytes': 4000,
|
||||
'bursts': [
|
||||
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
|
||||
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
|
||||
],
|
||||
}))
|
||||
|
||||
result = manager.trim_capture(capture_id='trim002')
|
||||
assert result['status'] == 'ok'
|
||||
assert result['segment']['auto_selected'] is True
|
||||
assert result['capture']['duration_seconds'] > 0.25
|
||||
|
||||
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
|
||||
cap_a = {
|
||||
'id': 'grp001',
|
||||
'filename': 'a.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'dominant_fingerprint': 'deadbeefcafebabe',
|
||||
}
|
||||
cap_b = {
|
||||
'id': 'grp002',
|
||||
'filename': 'b.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:01:00Z',
|
||||
'dominant_fingerprint': 'deadbeefcafebabe',
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
|
||||
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
|
||||
|
||||
captures = manager.list_captures()
|
||||
assert len(captures) == 2
|
||||
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
|
||||
assert all(c.fingerprint_group_size == 2 for c in captures)
|
||||
def test_get_capture_path_not_found(self, manager):
|
||||
assert manager.get_capture_path('nonexistent') is None
|
||||
|
||||
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
|
||||
captures_dir = tmp_data_dir / 'captures'
|
||||
iq_path = captures_dir / 'trim_src.iq'
|
||||
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
|
||||
(captures_dir / 'trim_src.json').write_text(json.dumps({
|
||||
'id': 'trim001',
|
||||
'filename': 'trim_src.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 2.0,
|
||||
'size_bytes': 4000,
|
||||
'label': 'Weather Burst',
|
||||
'bursts': [
|
||||
{
|
||||
'start_seconds': 0.55,
|
||||
'duration_seconds': 0.2,
|
||||
'peak_level': 67,
|
||||
'fingerprint': 'abc123',
|
||||
'modulation_hint': 'OOK/ASK',
|
||||
'modulation_confidence': 0.9,
|
||||
}
|
||||
],
|
||||
}))
|
||||
|
||||
result = manager.trim_capture(
|
||||
capture_id='trim001',
|
||||
start_seconds=0.5,
|
||||
duration_seconds=0.4,
|
||||
)
|
||||
|
||||
assert result['status'] == 'ok'
|
||||
assert result['capture']['id'] != 'trim001'
|
||||
assert result['capture']['size_bytes'] == 800
|
||||
assert result['capture']['label'].endswith('(Trim)')
|
||||
trimmed_iq = captures_dir / result['capture']['filename']
|
||||
assert trimmed_iq.exists()
|
||||
trimmed_meta = trimmed_iq.with_suffix('.json')
|
||||
assert trimmed_meta.exists()
|
||||
|
||||
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
|
||||
captures_dir = tmp_data_dir / 'captures'
|
||||
iq_path = captures_dir / 'auto_src.iq'
|
||||
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
|
||||
(captures_dir / 'auto_src.json').write_text(json.dumps({
|
||||
'id': 'trim002',
|
||||
'filename': 'auto_src.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 1000,
|
||||
'lna_gain': 24,
|
||||
'vga_gain': 20,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'duration_seconds': 2.0,
|
||||
'size_bytes': 4000,
|
||||
'bursts': [
|
||||
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
|
||||
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
|
||||
],
|
||||
}))
|
||||
|
||||
result = manager.trim_capture(capture_id='trim002')
|
||||
assert result['status'] == 'ok'
|
||||
assert result['segment']['auto_selected'] is True
|
||||
assert result['capture']['duration_seconds'] > 0.25
|
||||
|
||||
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
|
||||
cap_a = {
|
||||
'id': 'grp001',
|
||||
'filename': 'a.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:00:00Z',
|
||||
'dominant_fingerprint': 'deadbeefcafebabe',
|
||||
}
|
||||
cap_b = {
|
||||
'id': 'grp002',
|
||||
'filename': 'b.iq',
|
||||
'frequency_hz': 433920000,
|
||||
'sample_rate': 2000000,
|
||||
'timestamp': '2026-01-01T00:01:00Z',
|
||||
'dominant_fingerprint': 'deadbeefcafebabe',
|
||||
}
|
||||
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
|
||||
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
|
||||
|
||||
captures = manager.list_captures()
|
||||
assert len(captures) == 2
|
||||
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
|
||||
assert all(c.fingerprint_group_size == 2 for c in captures)
|
||||
|
||||
|
||||
class TestSweep:
|
||||
@@ -452,6 +472,7 @@ class TestSweep:
|
||||
assert result['status'] == 'error'
|
||||
|
||||
def test_start_sweep_success(self, manager):
|
||||
import time as _time
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.stdout = MagicMock()
|
||||
@@ -460,6 +481,8 @@ class TestSweep:
|
||||
patch('subprocess.Popen', return_value=mock_proc), \
|
||||
patch('utils.subghz.register_process'):
|
||||
manager._sweep_available = None
|
||||
manager._hackrf_device_cache = True
|
||||
manager._hackrf_device_cache_ts = _time.time()
|
||||
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
|
||||
assert result['status'] == 'started'
|
||||
|
||||
@@ -517,8 +540,11 @@ class TestDecode:
|
||||
with patch('shutil.which', return_value='/usr/bin/tool'), \
|
||||
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
|
||||
patch('utils.subghz.register_process'):
|
||||
import time as _time
|
||||
manager._hackrf_available = None
|
||||
manager._rtl433_available = None
|
||||
manager._hackrf_device_cache = True
|
||||
manager._hackrf_device_cache_ts = _time.time()
|
||||
result = manager.start_decode(
|
||||
frequency_hz=433920000,
|
||||
sample_rate=2000000,
|
||||
@@ -536,10 +562,10 @@ class TestDecode:
|
||||
assert '-r' in hackrf_cmd
|
||||
|
||||
# Verify rtl_433 command
|
||||
rtl433_cmd = mock_popen.call_args_list[1][0][0]
|
||||
assert rtl433_cmd[0] == 'rtl_433'
|
||||
assert '-r' in rtl433_cmd
|
||||
assert 'cs8:-' in rtl433_cmd
|
||||
rtl433_cmd = mock_popen.call_args_list[1][0][0]
|
||||
assert rtl433_cmd[0] == 'rtl_433'
|
||||
assert '-r' in rtl433_cmd
|
||||
assert 'cs8:-' in rtl433_cmd
|
||||
|
||||
# Both processes tracked
|
||||
assert manager._decode_hackrf_process is mock_hackrf_proc
|
||||
|
||||
@@ -73,9 +73,10 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = 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'
|
||||
@@ -88,7 +89,7 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = 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()
|
||||
@@ -113,7 +114,7 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start(
|
||||
success, error_msg = decoder.start(
|
||||
satellite='NOAA-18',
|
||||
device_index=0,
|
||||
gain=40.0,
|
||||
@@ -121,6 +122,7 @@ 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_frequency == 137.9125
|
||||
@@ -138,13 +140,15 @@ class TestWeatherSatDecoder:
|
||||
@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'):
|
||||
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 = 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')
|
||||
@@ -159,9 +163,10 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = 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]
|
||||
@@ -174,12 +179,13 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='data/test.wav',
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert error_msg is not None
|
||||
callback.assert_called()
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
@@ -199,19 +205,21 @@ class TestWeatherSatDecoder:
|
||||
|
||||
mock_pty.return_value = (10, 11)
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None # Process still running
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
decoder = WeatherSatDecoder()
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
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'
|
||||
|
||||
@@ -235,7 +243,7 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='/etc/passwd',
|
||||
)
|
||||
@@ -258,7 +266,7 @@ class TestWeatherSatDecoder:
|
||||
callback = MagicMock()
|
||||
decoder.set_callback(callback)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='NOAA-18',
|
||||
input_file='data/missing.wav',
|
||||
)
|
||||
@@ -425,12 +433,12 @@ class TestWeatherSatDecoder:
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_resolve_device_id_fallback(self, mock_run):
|
||||
"""_resolve_device_id() should fall back to index string."""
|
||||
"""_resolve_device_id() should return None when no serial found."""
|
||||
mock_run.side_effect = FileNotFoundError
|
||||
|
||||
serial = WeatherSatDecoder._resolve_device_id(0)
|
||||
|
||||
assert serial == '0'
|
||||
assert serial is None
|
||||
|
||||
def test_parse_product_name_rgb(self):
|
||||
"""_parse_product_name() should identify RGB composite."""
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Targeted regression tests for recent weather-satellite hardening fixes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.weather_sat import WeatherSatDecoder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_client(client):
|
||||
"""Return a logged-in test client for authenticated weather-sat routes."""
|
||||
with client.session_transaction() as session:
|
||||
session['logged_in'] = True
|
||||
return client
|
||||
|
||||
|
||||
class TestWeatherSatRouteReleaseGuards:
|
||||
"""Regression tests for safe SDR release behavior in weather-sat routes."""
|
||||
|
||||
def test_stop_does_not_release_device_owned_by_other_mode(self, authed_client):
|
||||
"""POST /weather-sat/stop should not release a foreign-owned SDR device."""
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.device_index = 2
|
||||
|
||||
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
|
||||
patch('app.get_sdr_device_status', return_value={2: 'wifi'}), \
|
||||
patch('app.release_sdr_device') as mock_release:
|
||||
response = authed_client.post('/weather-sat/stop')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'stopped'
|
||||
mock_decoder.stop.assert_called_once()
|
||||
mock_release.assert_not_called()
|
||||
|
||||
def test_stop_releases_device_owned_by_weather_sat(self, authed_client):
|
||||
"""POST /weather-sat/stop should release SDR when weather-sat owns it."""
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.device_index = 2
|
||||
|
||||
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
|
||||
patch('app.get_sdr_device_status', return_value={2: 'weather_sat'}), \
|
||||
patch('app.release_sdr_device') as mock_release:
|
||||
response = authed_client.post('/weather-sat/stop')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'stopped'
|
||||
mock_decoder.stop.assert_called_once()
|
||||
mock_release.assert_called_once_with(2)
|
||||
|
||||
def test_stop_skips_release_for_offline_decode_index(self, authed_client):
|
||||
"""POST /weather-sat/stop should not release when decoder index is -1."""
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.device_index = -1
|
||||
|
||||
with patch('routes.weather_sat.get_weather_sat_decoder', return_value=mock_decoder), \
|
||||
patch('app.release_sdr_device') as mock_release:
|
||||
response = authed_client.post('/weather-sat/stop')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'stopped'
|
||||
mock_decoder.stop.assert_called_once()
|
||||
mock_release.assert_not_called()
|
||||
|
||||
|
||||
class TestWeatherSatDecoderRegressions:
|
||||
"""Regression tests for decoder filename and offline-device handling."""
|
||||
|
||||
def test_scan_output_dir_preserves_extension_and_sanitizes_filename(self, tmp_path):
|
||||
"""Copied image names should stay safe and preserve JPG/JPEG extensions."""
|
||||
output_dir = tmp_path / 'weather_sat_out'
|
||||
capture_dir = tmp_path / 'capture'
|
||||
capture_dir.mkdir(parents=True)
|
||||
|
||||
source_image = capture_dir / 'channel 3 (raw).jpeg'
|
||||
source_image.write_bytes(b'\xff\xd8\xff' + b'\x00' * 2048)
|
||||
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||
decoder = WeatherSatDecoder(output_dir=output_dir)
|
||||
|
||||
decoder._capture_output_dir = capture_dir
|
||||
decoder._current_satellite = 'METEOR-M2-4'
|
||||
decoder._current_mode = 'LRPT'
|
||||
decoder._current_frequency = 137.9
|
||||
|
||||
decoder._scan_output_dir(set())
|
||||
|
||||
assert len(decoder._images) == 1
|
||||
image = decoder._images[0]
|
||||
assert image.filename.endswith('.jpeg')
|
||||
assert re.fullmatch(r'[A-Za-z0-9_.-]+', image.filename)
|
||||
assert (output_dir / image.filename).is_file()
|
||||
|
||||
def test_start_from_file_keeps_device_index_unclaimed(self, tmp_path):
|
||||
"""Offline file decode should not claim or persist an SDR device index."""
|
||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
||||
patch('pathlib.Path.is_file', return_value=True), \
|
||||
patch('pathlib.Path.resolve') as mock_resolve, \
|
||||
patch.object(WeatherSatDecoder, '_start_satdump_offline') as mock_start:
|
||||
|
||||
resolved = MagicMock()
|
||||
resolved.is_relative_to.return_value = True
|
||||
mock_resolve.return_value = resolved
|
||||
|
||||
decoder = WeatherSatDecoder(output_dir=tmp_path / 'weather_sat_out')
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite='METEOR-M2-3',
|
||||
input_file='data/weather_sat/samples/sample.wav',
|
||||
sample_rate=1_000_000,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error_msg is None
|
||||
assert decoder.device_index == -1
|
||||
mock_start.assert_called_once()
|
||||
|
||||
decoder.stop()
|
||||
assert decoder.device_index == -1
|
||||
@@ -73,7 +73,7 @@ class TestWeatherSatRoutes:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
mock_decoder.start.return_value = (True, None)
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
payload = {
|
||||
@@ -233,7 +233,7 @@ class TestWeatherSatRoutes:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = False
|
||||
mock_decoder.start.return_value = (False, 'SatDump exited immediately (code 1)')
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
payload = {'satellite': 'NOAA-18'}
|
||||
@@ -246,7 +246,7 @@ class TestWeatherSatRoutes:
|
||||
assert response.status_code == 500
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
assert 'Failed to start capture' in data['message']
|
||||
assert 'SatDump exited immediately' in data['message']
|
||||
|
||||
def test_test_decode_success(self, client):
|
||||
"""POST /weather-sat/test-decode successfully starts file decode."""
|
||||
@@ -262,7 +262,7 @@ class TestWeatherSatRoutes:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start_from_file.return_value = True
|
||||
mock_decoder.start_from_file.return_value = (True, None)
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -546,7 +546,7 @@ class TestWeatherSatScheduler:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
mock_decoder.start.return_value = (True, None)
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
mock_timer_instance = MagicMock()
|
||||
@@ -590,7 +590,7 @@ class TestWeatherSatScheduler:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = False
|
||||
mock_decoder.start.return_value = (False, 'Start failed')
|
||||
mock_get.return_value = mock_decoder
|
||||
|
||||
pass_data = {
|
||||
@@ -798,7 +798,7 @@ class TestSchedulerIntegration:
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
mock_decoder.start.return_value = (True, None)
|
||||
mock_get_decoder.return_value = mock_decoder
|
||||
|
||||
scheduler = WeatherSatScheduler()
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Tests for WeFax (Weather Fax) routes, decoder, and station loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _login_session(client) -> None:
|
||||
"""Mark the Flask test session as authenticated."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess['username'] = 'test'
|
||||
sess['role'] = 'admin'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Station database tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWeFaxStations:
|
||||
"""WeFax station database tests."""
|
||||
|
||||
def test_load_stations_returns_list(self):
|
||||
"""load_stations() should return a non-empty list."""
|
||||
from utils.wefax_stations import load_stations
|
||||
stations = load_stations()
|
||||
assert isinstance(stations, list)
|
||||
assert len(stations) >= 10
|
||||
|
||||
def test_station_has_required_fields(self):
|
||||
"""Each station must have required fields."""
|
||||
from utils.wefax_stations import load_stations
|
||||
required = {'name', 'callsign', 'country', 'city', 'coordinates',
|
||||
'frequencies', 'ioc', 'lpm', 'schedule'}
|
||||
for station in load_stations():
|
||||
missing = required - set(station.keys())
|
||||
assert not missing, f"Station {station.get('callsign', '?')} missing: {missing}"
|
||||
|
||||
def test_get_station_by_callsign(self):
|
||||
"""get_station() should return correct station."""
|
||||
from utils.wefax_stations import get_station
|
||||
station = get_station('NOJ')
|
||||
assert station is not None
|
||||
assert station['callsign'] == 'NOJ'
|
||||
assert station['country'] == 'US'
|
||||
|
||||
def test_get_station_case_insensitive(self):
|
||||
"""get_station() should be case-insensitive."""
|
||||
from utils.wefax_stations import get_station
|
||||
assert get_station('noj') is not None
|
||||
|
||||
def test_get_station_not_found(self):
|
||||
"""get_station() should return None for unknown callsign."""
|
||||
from utils.wefax_stations import get_station
|
||||
assert get_station('XXXXX') is None
|
||||
|
||||
def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self):
|
||||
"""Known station frequencies default to carrier-list behavior in auto mode."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='auto',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4296.1, abs_tol=1e-6)
|
||||
assert reference == 'carrier'
|
||||
assert offset_applied is True
|
||||
|
||||
def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self):
|
||||
"""Ad-hoc frequencies (no station metadata) should be treated as dial."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='',
|
||||
frequency_reference='auto',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||
assert reference == 'dial'
|
||||
assert offset_applied is False
|
||||
|
||||
def test_resolve_tuning_frequency_dial_override(self):
|
||||
"""Explicit dial reference must bypass USB alignment."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='dial',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||
assert reference == 'dial'
|
||||
assert offset_applied is False
|
||||
|
||||
def test_resolve_tuning_frequency_rejects_invalid_reference(self):
|
||||
"""Invalid frequency reference values should raise a validation error."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
try:
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='invalid',
|
||||
)
|
||||
assert False, "Expected ValueError for invalid frequency_reference"
|
||||
except ValueError as exc:
|
||||
assert 'frequency_reference' in str(exc)
|
||||
|
||||
def test_station_frequencies_have_khz(self):
|
||||
"""Each frequency entry must have 'khz' and 'description'."""
|
||||
from utils.wefax_stations import load_stations
|
||||
for station in load_stations():
|
||||
for freq in station['frequencies']:
|
||||
assert 'khz' in freq, f"{station['callsign']} missing khz"
|
||||
assert 'description' in freq, f"{station['callsign']} missing description"
|
||||
assert isinstance(freq['khz'], (int, float))
|
||||
assert freq['khz'] > 0
|
||||
|
||||
def test_schedule_format(self):
|
||||
"""Schedule entries must have utc, duration_min, content."""
|
||||
from utils.wefax_stations import load_stations
|
||||
for station in load_stations():
|
||||
for entry in station['schedule']:
|
||||
assert 'utc' in entry
|
||||
assert 'duration_min' in entry
|
||||
assert 'content' in entry
|
||||
# UTC format: HH:MM
|
||||
parts = entry['utc'].split(':')
|
||||
assert len(parts) == 2
|
||||
assert 0 <= int(parts[0]) <= 23
|
||||
assert 0 <= int(parts[1]) <= 59
|
||||
|
||||
def test_get_current_broadcasts(self):
|
||||
"""get_current_broadcasts() should return up to 3 entries."""
|
||||
from utils.wefax_stations import get_current_broadcasts
|
||||
broadcasts = get_current_broadcasts('NOJ')
|
||||
assert isinstance(broadcasts, list)
|
||||
assert len(broadcasts) <= 3
|
||||
for b in broadcasts:
|
||||
assert 'utc' in b
|
||||
assert 'content' in b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoder unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWeFaxDecoder:
|
||||
"""WeFax decoder DSP and data class tests."""
|
||||
|
||||
def test_freq_to_pixel_black(self):
|
||||
"""1500 Hz should map to 0 (black)."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
assert _freq_to_pixel(1500.0) == 0
|
||||
|
||||
def test_freq_to_pixel_white(self):
|
||||
"""2300 Hz should map to 255 (white)."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
assert _freq_to_pixel(2300.0) == 255
|
||||
|
||||
def test_freq_to_pixel_mid(self):
|
||||
"""1900 Hz (carrier) should map to ~128."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
val = _freq_to_pixel(1900.0)
|
||||
assert 120 <= val <= 135
|
||||
|
||||
def test_freq_to_pixel_clamp_low(self):
|
||||
"""Below 1500 Hz should clamp to 0."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
assert _freq_to_pixel(1000.0) == 0
|
||||
|
||||
def test_freq_to_pixel_clamp_high(self):
|
||||
"""Above 2300 Hz should clamp to 255."""
|
||||
from utils.wefax import _freq_to_pixel
|
||||
assert _freq_to_pixel(3000.0) == 255
|
||||
|
||||
def test_ioc_576_pixel_count(self):
|
||||
"""IOC 576 should give pi*576 ≈ 1809 pixels per line."""
|
||||
pixels = int(math.pi * 576)
|
||||
assert pixels == 1809
|
||||
|
||||
def test_ioc_288_pixel_count(self):
|
||||
"""IOC 288 should give pi*288 ≈ 904 pixels per line."""
|
||||
pixels = int(math.pi * 288)
|
||||
assert pixels == 904
|
||||
|
||||
def test_goertzel_mag_detects_tone(self):
|
||||
"""Goertzel should detect a pure tone."""
|
||||
from utils.wefax import _goertzel_mag
|
||||
sr = 22050
|
||||
freq = 1900.0
|
||||
t = np.arange(sr) / sr
|
||||
samples = np.sin(2 * np.pi * freq * t)
|
||||
mag = _goertzel_mag(samples[:2205], freq, sr)
|
||||
# Should be significantly non-zero for a matching tone
|
||||
assert mag > 1.0
|
||||
|
||||
def test_goertzel_mag_rejects_wrong_freq(self):
|
||||
"""Goertzel should be much weaker for non-matching frequency."""
|
||||
from utils.wefax import _goertzel_mag
|
||||
sr = 22050
|
||||
t = np.arange(sr) / sr
|
||||
samples = np.sin(2 * np.pi * 1900.0 * t)
|
||||
mag_match = _goertzel_mag(samples[:2205], 1900.0, sr)
|
||||
mag_off = _goertzel_mag(samples[:2205], 300.0, sr)
|
||||
assert mag_match > mag_off * 5
|
||||
|
||||
def test_detect_tone_start(self):
|
||||
"""detect_tone should identify a 300 Hz start tone."""
|
||||
from utils.wefax import _detect_tone
|
||||
sr = 22050
|
||||
t = np.arange(sr) / sr
|
||||
samples = np.sin(2 * np.pi * 300.0 * t)
|
||||
assert _detect_tone(samples[:2205], 300.0, sr, threshold=2.0)
|
||||
|
||||
def test_wefax_image_to_dict(self):
|
||||
"""WeFaxImage.to_dict() should produce expected format."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.wefax import WeFaxImage
|
||||
img = WeFaxImage(
|
||||
filename='test.png',
|
||||
path=Path('/tmp/test.png'),
|
||||
station='NOJ',
|
||||
frequency_khz=4298,
|
||||
timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
ioc=576,
|
||||
lpm=120,
|
||||
size_bytes=1234,
|
||||
)
|
||||
d = img.to_dict()
|
||||
assert d['filename'] == 'test.png'
|
||||
assert d['station'] == 'NOJ'
|
||||
assert d['frequency_khz'] == 4298
|
||||
assert d['ioc'] == 576
|
||||
assert d['url'] == '/wefax/images/test.png'
|
||||
|
||||
def test_wefax_progress_to_dict(self):
|
||||
"""WeFaxProgress.to_dict() should produce expected format."""
|
||||
from utils.wefax import WeFaxProgress
|
||||
p = WeFaxProgress(
|
||||
status='receiving',
|
||||
station='NOJ',
|
||||
message='Receiving: 100 lines',
|
||||
progress_percent=50,
|
||||
line_count=100,
|
||||
)
|
||||
d = p.to_dict()
|
||||
assert d['type'] == 'wefax_progress'
|
||||
assert d['status'] == 'receiving'
|
||||
assert d['progress'] == 50
|
||||
assert d['station'] == 'NOJ'
|
||||
assert d['line_count'] == 100
|
||||
|
||||
def test_singleton_returns_same_instance(self, tmp_path):
|
||||
"""get_wefax_decoder() should return a singleton."""
|
||||
from utils.wefax import WeFaxDecoder
|
||||
# Use __new__ to avoid __init__ creating dirs
|
||||
d1 = WeFaxDecoder.__new__(WeFaxDecoder)
|
||||
# Test the module-level singleton pattern
|
||||
import utils.wefax as wefax_mod
|
||||
original = wefax_mod._decoder
|
||||
try:
|
||||
wefax_mod._decoder = d1
|
||||
assert wefax_mod.get_wefax_decoder() is d1
|
||||
assert wefax_mod.get_wefax_decoder() is d1
|
||||
finally:
|
||||
wefax_mod._decoder = original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWeFaxRoutes:
|
||||
"""WeFax route endpoint tests."""
|
||||
|
||||
def test_status(self, client):
|
||||
"""GET /wefax/status should return decoder status."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.get_images.return_value = []
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.get('/wefax/status')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['available'] is True
|
||||
assert data['running'] is False
|
||||
|
||||
def test_stations_list(self, client):
|
||||
"""GET /wefax/stations should return station list."""
|
||||
_login_session(client)
|
||||
response = client.get('/wefax/stations')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['count'] >= 10
|
||||
|
||||
def test_station_detail(self, client):
|
||||
"""GET /wefax/stations/NOJ should return station detail."""
|
||||
_login_session(client)
|
||||
response = client.get('/wefax/stations/NOJ')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['station']['callsign'] == 'NOJ'
|
||||
assert 'current_broadcasts' in data
|
||||
|
||||
def test_station_not_found(self, client):
|
||||
"""GET /wefax/stations/XXXXX should return 404."""
|
||||
_login_session(client)
|
||||
response = client.get('/wefax/stations/XXXXX')
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_start_requires_frequency(self, client):
|
||||
"""POST /wefax/start without frequency should fail."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
|
||||
def test_start_validates_frequency_range(self, client):
|
||||
"""POST /wefax/start with out-of-range frequency should fail."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({'frequency_khz': 100}), # 0.1 MHz - too low
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_start_validates_ioc(self, client):
|
||||
"""POST /wefax/start with invalid IOC should fail."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({'frequency_khz': 4298, 'ioc': 999}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'IOC' in data['message']
|
||||
|
||||
def test_start_validates_lpm(self, client):
|
||||
"""POST /wefax/start with invalid LPM should fail."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({'frequency_khz': 4298, 'lpm': 999}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'LPM' in data['message']
|
||||
|
||||
def test_start_success(self, client):
|
||||
"""POST /wefax/start with valid params should succeed."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({
|
||||
'frequency_khz': 4298,
|
||||
'station': 'NOJ',
|
||||
'device': 0,
|
||||
'ioc': 576,
|
||||
'lpm': 120,
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data['frequency_khz'] == 4298
|
||||
assert data['usb_offset_applied'] is True
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
assert data['frequency_reference'] == 'carrier'
|
||||
assert data['station'] == 'NOJ'
|
||||
mock_decoder.start.assert_called_once()
|
||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||
assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
|
||||
def test_start_respects_dial_reference_override(self, client):
|
||||
"""POST /wefax/start with dial reference should not apply USB offset."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({
|
||||
'frequency_khz': 4298,
|
||||
'station': 'NOJ',
|
||||
'device': 0,
|
||||
'frequency_reference': 'dial',
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data['usb_offset_applied'] is False
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||
assert data['frequency_reference'] == 'dial'
|
||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||
assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||
|
||||
def test_start_device_busy(self, client):
|
||||
"""POST /wefax/start should return 409 when device is busy."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('routes.wefax.app_module.claim_sdr_device',
|
||||
return_value='Device 0 in use by pager'):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({'frequency_khz': 4298}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
data = response.get_json()
|
||||
assert data['error_type'] == 'DEVICE_BUSY'
|
||||
|
||||
def test_stop(self, client):
|
||||
"""POST /wefax/stop should stop the decoder."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.post('/wefax/stop')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'stopped'
|
||||
mock_decoder.stop.assert_called_once()
|
||||
|
||||
def test_images_list(self, client):
|
||||
"""GET /wefax/images should return image list."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.get_images.return_value = []
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.get('/wefax/images')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['count'] == 0
|
||||
|
||||
def test_delete_image_invalid_filename(self, client):
|
||||
"""DELETE /wefax/images/<filename> should reject invalid filenames."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
# Use a filename with special chars that won't be split by Flask routing
|
||||
response = client.delete('/wefax/images/te$t!file.png')
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_delete_image_wrong_extension(self, client):
|
||||
"""DELETE /wefax/images/<filename> should reject non-PNG."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.delete('/wefax/images/test.jpg')
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_schedule_enable_applies_usb_alignment(self, client):
|
||||
"""Scheduler should receive tuned USB dial frequency in auto mode."""
|
||||
_login_session(client)
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.enable.return_value = {
|
||||
'enabled': True,
|
||||
'scheduled_count': 2,
|
||||
'total_broadcasts': 2,
|
||||
}
|
||||
|
||||
with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler):
|
||||
response = client.post(
|
||||
'/wefax/schedule/enable',
|
||||
data=json.dumps({
|
||||
'station': 'NOJ',
|
||||
'frequency_khz': 4298,
|
||||
'device': 0,
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['usb_offset_applied'] is True
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
enable_kwargs = mock_scheduler.enable.call_args.kwargs
|
||||
assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
|
||||
|
||||
class TestWeFaxProgressCallback:
|
||||
"""Regression tests for WeFax route-level progress callback behavior."""
|
||||
|
||||
def test_terminal_progress_releases_active_device(self):
|
||||
"""Terminal decoder events must release any manually claimed SDR."""
|
||||
import routes.wefax as wefax_routes
|
||||
|
||||
original_device = wefax_routes.wefax_active_device
|
||||
try:
|
||||
wefax_routes.wefax_active_device = 3
|
||||
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||
wefax_routes._progress_callback({
|
||||
'type': 'wefax_progress',
|
||||
'status': 'error',
|
||||
'message': 'decode failed',
|
||||
})
|
||||
|
||||
mock_release.assert_called_once_with(3)
|
||||
assert wefax_routes.wefax_active_device is None
|
||||
finally:
|
||||
wefax_routes.wefax_active_device = original_device
|
||||
|
||||
def test_non_terminal_progress_does_not_release_active_device(self):
|
||||
"""Non-terminal progress updates must not release SDR ownership."""
|
||||
import routes.wefax as wefax_routes
|
||||
|
||||
original_device = wefax_routes.wefax_active_device
|
||||
try:
|
||||
wefax_routes.wefax_active_device = 4
|
||||
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||
wefax_routes._progress_callback({
|
||||
'type': 'wefax_progress',
|
||||
'status': 'receiving',
|
||||
'line_count': 120,
|
||||
})
|
||||
|
||||
mock_release.assert_not_called()
|
||||
assert wefax_routes.wefax_active_device == 4
|
||||
finally:
|
||||
wefax_routes.wefax_active_device = original_device
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for WeFax auto-scheduler behavior and regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from utils.wefax_scheduler import ScheduledBroadcast, WeFaxScheduler
|
||||
|
||||
|
||||
class TestWeFaxScheduler:
|
||||
"""WeFaxScheduler regression tests."""
|
||||
|
||||
@patch('threading.Timer')
|
||||
def test_refresh_reschedules_same_utc_slot_next_day(self, mock_timer):
|
||||
"""Completed broadcasts must not block the next day's same UTC slot."""
|
||||
scheduler = WeFaxScheduler()
|
||||
scheduler._enabled = True
|
||||
scheduler._station = 'USCG Kodiak'
|
||||
scheduler._callsign = 'NOJ'
|
||||
scheduler._frequency_khz = 4298.0
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
utc_time = (now - timedelta(hours=2)).strftime('%H:%M')
|
||||
today = now.date().isoformat()
|
||||
|
||||
prior = ScheduledBroadcast(
|
||||
station='USCG Kodiak',
|
||||
callsign='NOJ',
|
||||
frequency_khz=4298.0,
|
||||
utc_time=utc_time,
|
||||
duration_min=20,
|
||||
content='Chart',
|
||||
occurrence_date=today,
|
||||
)
|
||||
prior.status = 'complete'
|
||||
scheduler._broadcasts = [prior]
|
||||
|
||||
mock_timer.return_value = MagicMock()
|
||||
|
||||
with patch('utils.wefax_scheduler.get_station', return_value={
|
||||
'name': 'USCG Kodiak',
|
||||
'schedule': [{
|
||||
'utc': utc_time,
|
||||
'duration_min': 20,
|
||||
'content': 'Chart',
|
||||
}],
|
||||
}):
|
||||
scheduler._refresh_schedule()
|
||||
|
||||
capture_calls = [
|
||||
c for c in mock_timer.call_args_list
|
||||
if len(c.args) >= 2 and getattr(c.args[1], '__name__', '') == '_execute_capture'
|
||||
]
|
||||
assert capture_calls, "Expected a capture timer for the next-day occurrence"
|
||||
|
||||
scheduled = [b for b in scheduler._broadcasts if b.status == 'scheduled']
|
||||
assert len(scheduled) == 1
|
||||
assert scheduled[0].occurrence_date != today
|
||||
|
||||
def test_execute_capture_stops_immediately_if_window_elapsed(self):
|
||||
"""If stop delay computes to <= 0, capture should close out immediately."""
|
||||
scheduler = WeFaxScheduler()
|
||||
scheduler._enabled = True
|
||||
scheduler._callsign = 'NOJ'
|
||||
scheduler._frequency_khz = 4298.0
|
||||
scheduler._device = 0
|
||||
scheduler._gain = 40.0
|
||||
scheduler._ioc = 576
|
||||
scheduler._lpm = 120
|
||||
scheduler._direct_sampling = True
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
sb = ScheduledBroadcast(
|
||||
station='USCG Kodiak',
|
||||
callsign='NOJ',
|
||||
frequency_khz=4298.0,
|
||||
utc_time=now.strftime('%H:%M'),
|
||||
duration_min=0,
|
||||
content='Late chart',
|
||||
occurrence_date=now.date().isoformat(),
|
||||
)
|
||||
sb.status = 'scheduled'
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
|
||||
with patch('utils.wefax_scheduler.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('utils.wefax_scheduler.WEFAX_CAPTURE_BUFFER_SECONDS', 0), \
|
||||
patch('app.claim_sdr_device', return_value=None), \
|
||||
patch.object(scheduler, '_stop_capture') as mock_stop_capture:
|
||||
scheduler._execute_capture_inner(sb)
|
||||
|
||||
mock_stop_capture.assert_called_once()
|
||||
|
||||
@patch('threading.Timer')
|
||||
def test_terminal_progress_releases_scheduler_device_early(self, mock_timer):
|
||||
"""Scheduler captures must release SDR as soon as terminal progress arrives."""
|
||||
scheduler = WeFaxScheduler()
|
||||
scheduler._enabled = True
|
||||
scheduler._callsign = 'NOJ'
|
||||
scheduler._frequency_khz = 4298.0
|
||||
scheduler._device = 0
|
||||
scheduler._gain = 40.0
|
||||
scheduler._ioc = 576
|
||||
scheduler._lpm = 120
|
||||
scheduler._direct_sampling = True
|
||||
|
||||
sb = ScheduledBroadcast(
|
||||
station='USCG Kodiak',
|
||||
callsign='NOJ',
|
||||
frequency_khz=4298.0,
|
||||
utc_time='12:00',
|
||||
duration_min=20,
|
||||
content='Chart',
|
||||
occurrence_date='2026-01-01',
|
||||
)
|
||||
sb.status = 'scheduled'
|
||||
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
mock_timer.return_value = MagicMock()
|
||||
|
||||
with patch('utils.wefax_scheduler.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('app.claim_sdr_device', return_value=None), \
|
||||
patch('app.release_sdr_device') as mock_release:
|
||||
scheduler._execute_capture_inner(sb)
|
||||
progress_cb = mock_decoder.set_callback.call_args[0][0]
|
||||
progress_cb({
|
||||
'type': 'wefax_progress',
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm failed',
|
||||
})
|
||||
|
||||
mock_release.assert_called_once_with(0)
|
||||
assert sb.status == 'skipped'
|
||||
|
||||
def test_stop_capture_non_capturing_only_releases(self):
|
||||
"""_stop_capture should be idempotent when capture already ended."""
|
||||
scheduler = WeFaxScheduler()
|
||||
sb = ScheduledBroadcast(
|
||||
station='USCG Kodiak',
|
||||
callsign='NOJ',
|
||||
frequency_khz=4298.0,
|
||||
utc_time='12:00',
|
||||
duration_min=20,
|
||||
content='Chart',
|
||||
occurrence_date='2026-01-01',
|
||||
)
|
||||
sb.status = 'complete'
|
||||
release_fn = MagicMock()
|
||||
|
||||
with patch('utils.wefax_scheduler.get_wefax_decoder') as mock_get_decoder:
|
||||
scheduler._stop_capture(sb, release_fn)
|
||||
|
||||
release_fn.assert_called_once()
|
||||
mock_get_decoder.assert_not_called()
|
||||
Reference in New Issue
Block a user