mirror of
https://github.com/smittix/intercept.git
synced 2026-07-08 17:48:13 -07:00
Merge upstream/main and resolve weather-satellite.js conflict
Keep allPasses assignment for satellite filtering support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+114
-13
@@ -515,18 +515,16 @@ class TestDSCDecoder:
|
||||
assert result == '002320001'
|
||||
|
||||
def test_decode_mmsi_short_symbols(self, decoder):
|
||||
"""Test MMSI decoding handles short symbol list."""
|
||||
"""Test MMSI decoding returns None for short symbol list."""
|
||||
result = decoder._decode_mmsi([1, 2, 3])
|
||||
assert result == '000000000'
|
||||
assert result is None
|
||||
|
||||
def test_decode_mmsi_invalid_symbols(self, decoder):
|
||||
"""Test MMSI decoding handles invalid symbol values."""
|
||||
# Symbols > 99 should be treated as 0
|
||||
"""Test MMSI decoding returns None for out-of-range symbols."""
|
||||
# Symbols > 99 should cause decode to fail
|
||||
symbols = [100, 32, 12, 34, 56]
|
||||
result = decoder._decode_mmsi(symbols)
|
||||
# First symbol (100) becomes 00, padded result "0032123456",
|
||||
# trim leading pad digit -> "032123456"
|
||||
assert result == '032123456'
|
||||
assert result is None
|
||||
|
||||
def test_decode_position_northeast(self, decoder):
|
||||
"""Test position decoding for NE quadrant."""
|
||||
@@ -577,8 +575,9 @@ class TestDSCDecoder:
|
||||
def test_bits_to_symbol(self, decoder):
|
||||
"""Test bit to symbol conversion."""
|
||||
# Symbol value is first 7 bits (LSB first)
|
||||
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x]
|
||||
bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
|
||||
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1] -> 3 ones
|
||||
# Check bits must make total even -> need 1 more one -> [1,0,0]
|
||||
bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
|
||||
result = decoder._bits_to_symbol(bits)
|
||||
assert result == 100
|
||||
|
||||
@@ -588,14 +587,14 @@ class TestDSCDecoder:
|
||||
assert result == -1
|
||||
|
||||
def test_detect_dot_pattern(self, decoder):
|
||||
"""Test dot pattern detection."""
|
||||
# Dot pattern is alternating 1010101...
|
||||
decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits
|
||||
"""Test dot pattern detection with 200+ alternating bits."""
|
||||
# Dot pattern requires at least 200 bits / 100 alternations
|
||||
decoder.bit_buffer = [1, 0] * 110 # 220 alternating bits
|
||||
assert decoder._detect_dot_pattern() is True
|
||||
|
||||
def test_detect_dot_pattern_insufficient(self, decoder):
|
||||
"""Test dot pattern not detected with insufficient alternations."""
|
||||
decoder.bit_buffer = [1, 0] * 5 # Only 10 bits
|
||||
decoder.bit_buffer = [1, 0] * 40 # Only 80 bits, below 200 threshold
|
||||
assert decoder._detect_dot_pattern() is False
|
||||
|
||||
def test_detect_dot_pattern_not_alternating(self, decoder):
|
||||
@@ -603,6 +602,84 @@ class TestDSCDecoder:
|
||||
decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5
|
||||
assert decoder._detect_dot_pattern() is False
|
||||
|
||||
def test_bounded_phasing_strip(self, decoder):
|
||||
"""Test that >7 phasing symbols causes decode to return None."""
|
||||
# Build message bits: 10 phasing symbols (120) + format + data
|
||||
# Each symbol is 10 bits. Phasing symbol 120 = 0b1111000 LSB first
|
||||
# 120 in 7 bits LSB-first: 0,0,0,1,1,1,1 + 3 check bits
|
||||
# 120 = 0b1111000 -> LSB first: 0,0,0,1,1,1,1 -> ones=4 (even) -> check [0,0,0]
|
||||
phasing_bits = [0, 0, 0, 1, 1, 1, 1, 0, 0, 0] # symbol 120
|
||||
# 10 phasing symbols (>7 max)
|
||||
decoder.message_bits = phasing_bits * 10
|
||||
# Add some non-phasing symbols after (enough for a message)
|
||||
# Symbol 112 (INDIVIDUAL) = 0b1110000 LSB-first: 0,0,0,0,1,1,1 -> ones=3 (odd) -> need odd check
|
||||
# For simplicity, just add enough bits for the decoder to attempt
|
||||
for _ in range(20):
|
||||
decoder.message_bits.extend([0, 0, 0, 0, 1, 1, 1, 1, 0, 0])
|
||||
result = decoder._try_decode_message()
|
||||
assert result is None
|
||||
|
||||
def test_eos_minimum_length(self, decoder):
|
||||
"""Test that EOS found too early in the symbol stream is skipped."""
|
||||
# Build a message where EOS appears at position 5 (< MIN_SYMBOLS_FOR_FORMAT=12)
|
||||
# This should not be accepted as a valid message end
|
||||
# Symbol 127 (EOS) = 0b1111111 LSB-first: 1,1,1,1,1,1,1 -> ones=7 (odd) -> check needs 1 one
|
||||
# Use a simple approach: create symbols directly via _try_decode_message
|
||||
# Create 5 normal symbols + EOS at position 5 — should be skipped
|
||||
# Followed by more symbols and a real EOS at position 15
|
||||
from utils.dsc.decoder import DSCDecoder
|
||||
d = DSCDecoder()
|
||||
|
||||
# Build symbols manually: we need _try_decode_message to find EOS too early
|
||||
# Symbol 112 = format code. We'll build 10 bits per symbol.
|
||||
# Since check bit validation is now active, we need valid check bits.
|
||||
# Symbol value 10 = 0b0001010 LSB-first: 0,1,0,1,0,0,0, ones=2 (even) -> check [0,0,0]
|
||||
sym_10 = [0, 1, 0, 1, 0, 0, 0, 0, 0, 0]
|
||||
# Symbol 127 (EOS) = 0b1111111, ones=7 (odd) -> check needs odd total -> [1,0,0]
|
||||
sym_eos = [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
|
||||
|
||||
# 5 normal symbols + early EOS (should be skipped) + 8 more normal + real EOS
|
||||
d.message_bits = sym_10 * 5 + sym_eos + sym_10 * 8 + sym_eos
|
||||
result = d._try_decode_message()
|
||||
# The early EOS at index 5 should be skipped; the one at index 14
|
||||
# is past MIN_SYMBOLS_FOR_FORMAT so it can be accepted.
|
||||
# But the message content is garbage, so _decode_symbols will likely
|
||||
# return None for other reasons. The key test: it doesn't return a
|
||||
# message truncated at position 5.
|
||||
# Just verify no crash and either None or a valid longer message
|
||||
# (not truncated at the early EOS)
|
||||
assert result is None or len(result.get('raw', '')) > 18
|
||||
|
||||
def test_bits_to_symbol_check_bit_validation(self, decoder):
|
||||
"""Test that _bits_to_symbol rejects symbols with invalid check bits."""
|
||||
# Symbol 100 = 0b1100100 LSB-first: 0,0,1,0,0,1,1
|
||||
# ones in data = 3, need total even -> check bits need 1 one
|
||||
# Valid: [0,0,1,0,0,1,1, 1,0,0] -> total ones = 4 (even) -> valid
|
||||
valid_bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
|
||||
assert decoder._bits_to_symbol(valid_bits) == 100
|
||||
|
||||
# Invalid: flip one check bit -> total ones = 5 (odd) -> invalid
|
||||
invalid_bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
|
||||
assert decoder._bits_to_symbol(invalid_bits) == -1
|
||||
|
||||
def test_safety_is_critical(self):
|
||||
"""Test that SAFETY category is marked as critical."""
|
||||
import json
|
||||
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 123,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'SAFETY',
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '123232123456100122',
|
||||
})
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None
|
||||
assert msg['is_critical'] is True
|
||||
|
||||
|
||||
class TestDSCConstants:
|
||||
"""Tests for DSC constants."""
|
||||
@@ -670,3 +747,27 @@ class TestDSCConstants:
|
||||
assert DSC_BAUD_RATE == 1200
|
||||
assert DSC_MARK_FREQ == 2100
|
||||
assert DSC_SPACE_FREQ == 1300
|
||||
|
||||
def test_telecommand_codes_full(self):
|
||||
"""Test TELECOMMAND_CODES_FULL covers 0-127 range."""
|
||||
from utils.dsc.constants import TELECOMMAND_CODES_FULL
|
||||
|
||||
assert len(TELECOMMAND_CODES_FULL) == 128
|
||||
# Known codes map correctly
|
||||
assert TELECOMMAND_CODES_FULL[100] == 'F3E_G3E_ALL'
|
||||
assert TELECOMMAND_CODES_FULL[107] == 'DISTRESS_ACK'
|
||||
# Unknown codes map to "UNKNOWN"
|
||||
assert TELECOMMAND_CODES_FULL[0] == 'UNKNOWN'
|
||||
assert TELECOMMAND_CODES_FULL[99] == 'UNKNOWN'
|
||||
|
||||
def test_telecommand_formats(self):
|
||||
"""Test TELECOMMAND_FORMATS contains correct format codes."""
|
||||
from utils.dsc.constants import TELECOMMAND_FORMATS
|
||||
|
||||
assert {112, 114, 116, 120, 123} == TELECOMMAND_FORMATS
|
||||
|
||||
def test_min_symbols_for_format(self):
|
||||
"""Test MIN_SYMBOLS_FOR_FORMAT constant."""
|
||||
from utils.dsc.constants import MIN_SYMBOLS_FOR_FORMAT
|
||||
|
||||
assert MIN_SYMBOLS_FOR_FORMAT == 12
|
||||
|
||||
+546
-393
@@ -1,393 +1,546 @@
|
||||
"""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')
|
||||
"""Tests for Morse code decoder pipeline and lifecycle routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
import app as app_module
|
||||
import routes.morse as morse_routes
|
||||
from utils.morse import (
|
||||
CHAR_TO_MORSE,
|
||||
MORSE_TABLE,
|
||||
GoertzelFilter,
|
||||
MorseDecoder,
|
||||
decode_morse_wav_file,
|
||||
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 synthetic CW PCM for the given text."""
|
||||
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)
|
||||
|
||||
# Leading/trailing silence for threshold settling.
|
||||
return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate)
|
||||
|
||||
|
||||
def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None:
|
||||
"""Write mono 16-bit PCM bytes to a WAV file."""
|
||||
with wave.open(str(path), 'wb') as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(pcm_bytes)
|
||||
|
||||
|
||||
def decode_text_from_events(events) -> str:
|
||||
out = []
|
||||
for ev in events:
|
||||
if ev.get('type') == 'morse_char':
|
||||
out.append(str(ev.get('char', '')))
|
||||
elif ev.get('type') == 'morse_space':
|
||||
out.append(' ')
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseTable:
|
||||
def test_morse_table_contains_letters_and_digits(self):
|
||||
chars = set(MORSE_TABLE.values())
|
||||
for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789':
|
||||
assert ch in chars
|
||||
|
||||
def test_round_trip_morse_lookup(self):
|
||||
for morse, char in MORSE_TABLE.items():
|
||||
if char in CHAR_TO_MORSE:
|
||||
assert CHAR_TO_MORSE[char] == morse
|
||||
|
||||
|
||||
class TestToneDetector:
|
||||
def test_goertzel_prefers_target_frequency(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)]
|
||||
off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)]
|
||||
assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
|
||||
|
||||
|
||||
class TestTimingAndWpmEstimator:
|
||||
def test_timing_classifier_distinguishes_dit_and_dah(self):
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
dit = 1.2 / 15.0
|
||||
dah = dit * 3.0
|
||||
|
||||
audio = (
|
||||
generate_silence(0.35)
|
||||
+ generate_tone(700.0, dit)
|
||||
+ generate_silence(dit * 1.5)
|
||||
+ generate_tone(700.0, dah)
|
||||
+ generate_silence(0.35)
|
||||
)
|
||||
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
elements = [e['element'] for e in events if e.get('type') == 'morse_element']
|
||||
|
||||
assert '.' in elements
|
||||
assert '-' in elements
|
||||
|
||||
def test_wpm_estimator_sanity(self):
|
||||
target_wpm = 18
|
||||
audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm)
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto')
|
||||
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
metrics = decoder.get_metrics()
|
||||
assert metrics['wpm'] >= 10.0
|
||||
assert metrics['wpm'] <= 35.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoder thread tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseDecoderThread:
|
||||
def test_thread_emits_waiting_heartbeat_on_no_data(self):
|
||||
stop_event = threading.Event()
|
||||
output_queue = queue.Queue(maxsize=64)
|
||||
|
||||
read_fd, write_fd = os.pipe()
|
||||
read_file = os.fdopen(read_fd, 'rb', 0)
|
||||
|
||||
worker = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(read_file, output_queue, stop_event),
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
|
||||
got_waiting = False
|
||||
deadline = time.monotonic() + 3.5
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
msg = output_queue.get(timeout=0.3)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if msg.get('type') == 'scope' and msg.get('waiting'):
|
||||
got_waiting = True
|
||||
break
|
||||
|
||||
stop_event.set()
|
||||
os.close(write_fd)
|
||||
read_file.close()
|
||||
worker.join(timeout=2.0)
|
||||
|
||||
assert got_waiting is True
|
||||
assert not worker.is_alive()
|
||||
|
||||
def test_thread_produces_character_events(self):
|
||||
stop_event = threading.Event()
|
||||
output_queue = queue.Queue(maxsize=512)
|
||||
audio = generate_morse_audio('SOS', wpm=15)
|
||||
|
||||
worker = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(io.BytesIO(audio), output_queue, stop_event),
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
worker.join(timeout=4.0)
|
||||
|
||||
events = []
|
||||
while not output_queue.empty():
|
||||
events.append(output_queue.get_nowait())
|
||||
|
||||
chars = [e for e in events if e.get('type') == 'morse_char']
|
||||
assert len(chars) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route lifecycle regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseLifecycleRoutes:
|
||||
def _reset_route_state(self):
|
||||
with app_module.morse_lock:
|
||||
app_module.morse_process = None
|
||||
while not app_module.morse_queue.empty():
|
||||
try:
|
||||
app_module.morse_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
morse_routes.morse_active_device = None
|
||||
morse_routes.morse_decoder_worker = None
|
||||
morse_routes.morse_stderr_worker = None
|
||||
morse_routes.morse_relay_worker = None
|
||||
morse_routes.morse_stop_event = None
|
||||
morse_routes.morse_control_queue = None
|
||||
morse_routes.morse_runtime_config = {}
|
||||
morse_routes.morse_last_error = ''
|
||||
morse_routes.morse_state = morse_routes.MORSE_IDLE
|
||||
morse_routes.morse_state_message = 'Idle'
|
||||
|
||||
def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch):
|
||||
_login_session(client)
|
||||
self._reset_route_state()
|
||||
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
|
||||
class DummyBuilder:
|
||||
def build_fm_demod_command(self, **kwargs):
|
||||
return ['rtl_fm', '-f', '14060000', '-']
|
||||
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
|
||||
|
||||
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||
|
||||
class FakeRtlProc:
|
||||
def __init__(self, payload: bytes):
|
||||
self.stdout = io.BytesIO(payload)
|
||||
self.stderr = io.BytesIO(b'')
|
||||
self.returncode = None
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = 0
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.returncode = 0
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -9
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
return FakeRtlProc(pcm)
|
||||
|
||||
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||
monkeypatch.setattr(
|
||||
morse_routes,
|
||||
'safe_terminate',
|
||||
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||
)
|
||||
|
||||
start_resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'gain': '20',
|
||||
'ppm': '0',
|
||||
'device': '0',
|
||||
'tone_freq': '700',
|
||||
'wpm': '15',
|
||||
})
|
||||
assert start_resp.status_code == 200
|
||||
assert start_resp.get_json()['status'] == 'started'
|
||||
|
||||
status_resp = client.get('/morse/status')
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'}
|
||||
|
||||
stop_resp = client.post('/morse/stop')
|
||||
assert stop_resp.status_code == 200
|
||||
stop_data = stop_resp.get_json()
|
||||
assert stop_data['status'] == 'stopped'
|
||||
assert stop_data['state'] == 'idle'
|
||||
assert stop_data['alive'] == []
|
||||
|
||||
final_status = client.get('/morse/status').get_json()
|
||||
assert final_status['running'] is False
|
||||
assert final_status['state'] == 'idle'
|
||||
assert 0 in released_devices
|
||||
|
||||
def test_start_retries_after_early_process_exit(self, client, monkeypatch):
|
||||
_login_session(client)
|
||||
self._reset_route_state()
|
||||
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
|
||||
class DummyBuilder:
|
||||
def build_fm_demod_command(self, **kwargs):
|
||||
cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050']
|
||||
if kwargs.get('direct_sampling') is not None:
|
||||
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
|
||||
cmd.append('-')
|
||||
return cmd
|
||||
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
|
||||
|
||||
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||
rtl_cmds = []
|
||||
|
||||
class FakeRtlProc:
|
||||
def __init__(self, stdout_bytes: bytes, returncode: int | None):
|
||||
self.stdout = io.BytesIO(stdout_bytes)
|
||||
self.stderr = io.BytesIO(b'')
|
||||
self.returncode = returncode
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = 0
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.returncode = 0
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -9
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
rtl_cmds.append(cmd)
|
||||
if len(rtl_cmds) == 1:
|
||||
return FakeRtlProc(b'', 1)
|
||||
return FakeRtlProc(pcm, None)
|
||||
|
||||
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||
monkeypatch.setattr(
|
||||
morse_routes,
|
||||
'safe_terminate',
|
||||
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||
)
|
||||
|
||||
start_resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'gain': '20',
|
||||
'ppm': '0',
|
||||
'device': '0',
|
||||
'tone_freq': '700',
|
||||
'wpm': '15',
|
||||
})
|
||||
assert start_resp.status_code == 200
|
||||
assert start_resp.get_json()['status'] == 'started'
|
||||
assert len(rtl_cmds) >= 2
|
||||
assert rtl_cmds[0][0] == 'rtl_fm'
|
||||
assert '--direct' in rtl_cmds[0]
|
||||
assert '2' in rtl_cmds[0]
|
||||
assert rtl_cmds[1][0] == 'rtl_fm'
|
||||
assert '--direct' in rtl_cmds[1]
|
||||
assert '1' in rtl_cmds[1]
|
||||
|
||||
stop_resp = client.post('/morse/stop')
|
||||
assert stop_resp.status_code == 200
|
||||
assert stop_resp.get_json()['status'] == 'stopped'
|
||||
assert 0 in released_devices
|
||||
|
||||
def test_start_falls_back_to_next_device_when_selected_device_has_no_pcm(self, client, monkeypatch):
|
||||
_login_session(client)
|
||||
self._reset_route_state()
|
||||
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
def __init__(self, index: int):
|
||||
self.sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
self.index = index
|
||||
|
||||
class DummyDetected:
|
||||
def __init__(self, index: int, serial: str):
|
||||
self.sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
self.index = index
|
||||
self.name = f'RTL {index}'
|
||||
self.serial = serial
|
||||
|
||||
class DummyBuilder:
|
||||
def build_fm_demod_command(self, **kwargs):
|
||||
cmd = ['rtl_fm', '-d', str(kwargs['device'].index), '-f', '14.060M', '-M', 'usb', '-s', '22050']
|
||||
if kwargs.get('direct_sampling') is not None:
|
||||
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
|
||||
cmd.append('-')
|
||||
return cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
morse_routes.SDRFactory,
|
||||
'create_default_device',
|
||||
staticmethod(lambda sdr_type, index: DummyDevice(int(index))),
|
||||
)
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
|
||||
monkeypatch.setattr(
|
||||
morse_routes.SDRFactory,
|
||||
'detect_devices',
|
||||
staticmethod(lambda: [DummyDetected(0, 'AAA00000'), DummyDetected(1, 'BBB11111')]),
|
||||
)
|
||||
|
||||
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||
|
||||
class FakeRtlProc:
|
||||
def __init__(self, stdout_bytes: bytes, returncode: int | None):
|
||||
self.stdout = io.BytesIO(stdout_bytes)
|
||||
self.stderr = io.BytesIO(b'')
|
||||
self.returncode = returncode
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = 0
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.returncode = 0
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -9
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
try:
|
||||
dev = int(cmd[cmd.index('-d') + 1])
|
||||
except Exception:
|
||||
dev = 0
|
||||
if dev == 0:
|
||||
return FakeRtlProc(b'', 1)
|
||||
return FakeRtlProc(pcm, None)
|
||||
|
||||
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||
monkeypatch.setattr(
|
||||
morse_routes,
|
||||
'safe_terminate',
|
||||
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||
)
|
||||
|
||||
start_resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'gain': '20',
|
||||
'ppm': '0',
|
||||
'device': '0',
|
||||
'tone_freq': '700',
|
||||
'wpm': '15',
|
||||
})
|
||||
assert start_resp.status_code == 200
|
||||
start_data = start_resp.get_json()
|
||||
assert start_data['status'] == 'started'
|
||||
assert start_data['config']['active_device'] == 1
|
||||
assert start_data['config']['device_serial'] == 'BBB11111'
|
||||
assert 0 in released_devices
|
||||
|
||||
stop_resp = client.post('/morse/stop')
|
||||
assert stop_resp.status_code == 200
|
||||
assert stop_resp.get_json()['status'] == 'stopped'
|
||||
assert 1 in released_devices
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: synthetic CW -> WAV decode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseIntegration:
|
||||
def test_decode_morse_wav_contains_expected_phrase(self, tmp_path):
|
||||
wav_path = tmp_path / 'cq_test_123.wav'
|
||||
pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0)
|
||||
write_wav(wav_path, pcm, sample_rate=8000)
|
||||
|
||||
result = decode_morse_wav_file(
|
||||
wav_path,
|
||||
sample_rate=8000,
|
||||
tone_freq=700.0,
|
||||
wpm=15,
|
||||
bandwidth_hz=200,
|
||||
auto_tone_track=True,
|
||||
threshold_mode='auto',
|
||||
wpm_mode='auto',
|
||||
min_signal_gate=0.0,
|
||||
)
|
||||
|
||||
decoded = ' '.join(str(result.get('text', '')).split())
|
||||
assert 'CQ TEST 123' in decoded
|
||||
|
||||
events = result.get('events', [])
|
||||
event_counts = Counter(e.get('type') for e in events)
|
||||
assert event_counts['morse_char'] >= len('CQTEST123')
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the System Health monitoring blueprint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _login(client):
|
||||
"""Mark the Flask test session as authenticated."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess['username'] = 'test'
|
||||
sess['role'] = 'admin'
|
||||
|
||||
|
||||
def test_metrics_returns_expected_keys(client):
|
||||
"""GET /system/metrics returns top-level metric keys."""
|
||||
_login(client)
|
||||
resp = client.get('/system/metrics')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'system' in data
|
||||
assert 'processes' in data
|
||||
assert 'cpu' in data
|
||||
assert 'memory' in data
|
||||
assert 'disk' in data
|
||||
assert data['system']['hostname']
|
||||
assert 'version' in data['system']
|
||||
assert 'uptime_seconds' in data['system']
|
||||
assert 'uptime_human' in data['system']
|
||||
|
||||
|
||||
def test_metrics_without_psutil(client):
|
||||
"""Metrics degrade gracefully when psutil is unavailable."""
|
||||
_login(client)
|
||||
import routes.system as mod
|
||||
|
||||
orig = mod._HAS_PSUTIL
|
||||
mod._HAS_PSUTIL = False
|
||||
try:
|
||||
resp = client.get('/system/metrics')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
# These fields should be None without psutil
|
||||
assert data['cpu'] is None
|
||||
assert data['memory'] is None
|
||||
assert data['disk'] is None
|
||||
finally:
|
||||
mod._HAS_PSUTIL = orig
|
||||
|
||||
|
||||
def test_sdr_devices_returns_list(client):
|
||||
"""GET /system/sdr_devices returns a devices list."""
|
||||
_login(client)
|
||||
mock_device = MagicMock()
|
||||
mock_device.sdr_type = MagicMock()
|
||||
mock_device.sdr_type.value = 'rtlsdr'
|
||||
mock_device.index = 0
|
||||
mock_device.name = 'Generic RTL2832U'
|
||||
mock_device.serial = '00000001'
|
||||
mock_device.driver = 'rtlsdr'
|
||||
|
||||
with patch('utils.sdr.detection.detect_all_devices', return_value=[mock_device]):
|
||||
resp = client.get('/system/sdr_devices')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'devices' in data
|
||||
assert len(data['devices']) == 1
|
||||
assert data['devices'][0]['type'] == 'rtlsdr'
|
||||
assert data['devices'][0]['name'] == 'Generic RTL2832U'
|
||||
|
||||
|
||||
def test_sdr_devices_handles_detection_failure(client):
|
||||
"""SDR detection failure returns empty list with error."""
|
||||
_login(client)
|
||||
with patch('utils.sdr.detection.detect_all_devices', side_effect=RuntimeError('no devices')):
|
||||
resp = client.get('/system/sdr_devices')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['devices'] == []
|
||||
assert 'error' in data
|
||||
|
||||
|
||||
def test_stream_returns_sse_content_type(client):
|
||||
"""GET /system/stream returns text/event-stream."""
|
||||
_login(client)
|
||||
resp = client.get('/system/stream')
|
||||
assert resp.status_code == 200
|
||||
assert 'text/event-stream' in resp.content_type
|
||||
Reference in New Issue
Block a user