"""Tests for OOK signal decoder utilities and route handlers.""" from __future__ import annotations import io import json import queue import threading import unittest.mock import pytest from utils.ook import decode_ook_frame, ook_parser_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" # --------------------------------------------------------------------------- # decode_ook_frame # --------------------------------------------------------------------------- class TestDecodeOokFrame: def test_valid_hex_returns_bits_and_hex(self): result = decode_ook_frame("aa55") assert result is not None assert result["hex"] == "aa55" assert result["bits"] == "1010101001010101" assert result["byte_count"] == 2 assert result["bit_count"] == 16 def test_strips_0x_prefix(self): result = decode_ook_frame("0xaa55") assert result is not None assert result["hex"] == "aa55" def test_strips_0X_uppercase_prefix(self): result = decode_ook_frame("0Xff") assert result is not None assert result["hex"] == "ff" assert result["bits"] == "11111111" def test_strips_spaces(self): result = decode_ook_frame("aa 55") assert result is not None assert result["hex"] == "aa55" def test_invalid_hex_returns_none(self): assert decode_ook_frame("zzzz") is None def test_empty_string_returns_none(self): assert decode_ook_frame("") is None def test_just_0x_prefix_returns_none(self): assert decode_ook_frame("0x") is None def test_single_byte(self): result = decode_ook_frame("48") assert result is not None assert result["bits"] == "01001000" assert result["byte_count"] == 1 def test_hello_ascii(self): """'Hello' in hex is 48656c6c6f.""" result = decode_ook_frame("48656c6c6f") assert result is not None assert result["hex"] == "48656c6c6f" assert result["byte_count"] == 5 assert result["bit_count"] == 40 # --------------------------------------------------------------------------- # ook_parser_thread # --------------------------------------------------------------------------- class TestOokParserThread: def _run_parser(self, json_lines, encoding="pwm", deduplicate=False): """Feed JSON lines to parser thread and collect output events.""" raw = "\n".join(json.dumps(line) for line in json_lines) + "\n" stdout = io.BytesIO(raw.encode("utf-8")) output_queue = queue.Queue() stop_event = threading.Event() t = threading.Thread( target=ook_parser_thread, args=(stdout, output_queue, stop_event, encoding, deduplicate), ) t.start() t.join(timeout=2) events = [] while not output_queue.empty(): events.append(output_queue.get_nowait()) return events def test_parses_codes_field_list(self): events = self._run_parser([{"codes": ["aa55"]}]) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 assert frames[0]["hex"] == "aa55" assert frames[0]["bits"] == "1010101001010101" assert frames[0]["inverted"] is False def test_parses_codes_field_string(self): events = self._run_parser([{"codes": "ff00"}]) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 assert frames[0]["hex"] == "ff00" def test_parses_code_field(self): events = self._run_parser([{"code": "abcd"}]) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 assert frames[0]["hex"] == "abcd" def test_parses_data_field(self): events = self._run_parser([{"data": "1234"}]) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 assert frames[0]["hex"] == "1234" def test_strips_brace_bit_count_prefix(self): """rtl_433 sometimes prefixes with {N} bit count.""" events = self._run_parser([{"codes": ["{16}aa55"]}]) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 assert frames[0]["hex"] == "aa55" def test_deduplication_suppresses_consecutive_identical(self): events = self._run_parser( [{"codes": ["aa55"]}, {"codes": ["aa55"]}, {"codes": ["aa55"]}], deduplicate=True, ) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 def test_deduplication_allows_different_frames(self): events = self._run_parser( [{"codes": ["aa55"]}, {"codes": ["ff00"]}, {"codes": ["aa55"]}], deduplicate=True, ) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 3 def test_no_code_field_emits_ook_raw(self): events = self._run_parser([{"model": "unknown", "id": 42}]) raw_events = [e for e in events if e.get("type") == "ook_raw"] assert len(raw_events) == 1 def test_rssi_extracted_from_snr(self): events = self._run_parser([{"codes": ["aa55"], "snr": 12.3}]) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 assert frames[0]["rssi"] == 12.3 def test_encoding_passed_through(self): events = self._run_parser([{"codes": ["aa55"]}], encoding="manchester") frames = [e for e in events if e.get("type") == "ook_frame"] assert frames[0]["encoding"] == "manchester" def test_timestamp_present(self): events = self._run_parser([{"codes": ["aa55"]}]) frames = [e for e in events if e.get("type") == "ook_frame"] assert "timestamp" in frames[0] assert len(frames[0]["timestamp"]) > 0 def test_invalid_json_skipped(self): """Non-JSON lines should be silently skipped.""" raw = b'not json\n{"codes": ["aa55"]}\n' stdout = io.BytesIO(raw) output_queue = queue.Queue() stop_event = threading.Event() t = threading.Thread( target=ook_parser_thread, args=(stdout, output_queue, stop_event), ) t.start() t.join(timeout=2) events = [] while not output_queue.empty(): events.append(output_queue.get_nowait()) frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 # --------------------------------------------------------------------------- # Route handlers # --------------------------------------------------------------------------- class TestOokRoutes: @pytest.fixture def client(self): import app as app_module from routes import register_blueprints app_module.app.config["TESTING"] = True if "ook" not in app_module.app.blueprints: register_blueprints(app_module.app) with app_module.app.test_client() as c: yield c def test_status_returns_not_running(self, client): _login_session(client) resp = client.get("/ook/status") assert resp.status_code == 200 data = resp.get_json() assert data["running"] is False def test_stop_when_not_running(self, client): _login_session(client) resp = client.post("/ook/stop") assert resp.status_code == 200 data = resp.get_json() assert data["status"] == "not_running" def test_start_validates_frequency(self, client): _login_session(client) resp = client.post("/ook/start", json={"frequency": "invalid"}, content_type="application/json") assert resp.status_code == 400 def test_start_validates_encoding(self, client): _login_session(client) resp = client.post("/ook/start", json={"encoding": "invalid_enc"}, content_type="application/json") assert resp.status_code == 400 def test_start_validates_timing_params(self, client): _login_session(client) resp = client.post("/ook/start", json={"short_pulse": "not_a_number"}, content_type="application/json") assert resp.status_code == 400 def test_start_rejects_negative_frequency(self, client): _login_session(client) resp = client.post("/ook/start", json={"frequency": "-5"}, content_type="application/json") assert resp.status_code == 400 def test_start_rejects_out_of_range_timing(self, client): """Timing params that exceed server-side max should be rejected.""" _login_session(client) resp = client.post("/ook/start", json={"short_pulse": 999999}, content_type="application/json") assert resp.status_code == 400 def test_start_rejects_negative_timing(self, client): _login_session(client) resp = client.post("/ook/start", json={"min_bits": -1}, content_type="application/json") assert resp.status_code == 400 def test_start_success_mocked(self, client, monkeypatch): """start_ook with mocked Popen should return 'started'.""" import subprocess import app as app_module _login_session(client) mock_proc = unittest.mock.MagicMock() mock_proc.poll.return_value = None mock_proc.stdout = io.BytesIO(b"") mock_proc.stderr = io.BytesIO(b"") mock_proc.pid = 12345 monkeypatch.setattr(subprocess, "Popen", lambda *a, **kw: mock_proc) monkeypatch.setattr(app_module, "claim_sdr_device", lambda *a, **kw: None) monkeypatch.setattr(app_module, "release_sdr_device", lambda *a, **kw: None) resp = client.post("/ook/start", json={"frequency": "433.920"}, content_type="application/json") data = resp.get_json() assert resp.status_code == 200 assert data["status"] == "started" assert "command" in data # Cleanup with app_module.ook_lock: app_module.ook_process = None def test_stop_with_running_process(self, client, monkeypatch): """stop_ook should clean up a running process.""" import app as app_module _login_session(client) mock_proc = unittest.mock.MagicMock() mock_proc.poll.return_value = None mock_proc.stdout = None mock_proc.stderr = None mock_proc.pid = 12345 # Inject a fake running process import routes.ook as ook_module app_module.ook_process = mock_proc ook_module._ook_stop_event = threading.Event() ook_module._ook_parser_thread = None ook_module.ook_active_device = 0 ook_module.ook_active_sdr_type = "rtlsdr" monkeypatch.setattr(app_module, "release_sdr_device", lambda *a, **kw: None) monkeypatch.setattr("utils.process.safe_terminate", lambda p: None) monkeypatch.setattr("utils.process.unregister_process", lambda p: None) resp = client.post("/ook/stop") data = resp.get_json() assert resp.status_code == 200 assert data["status"] == "stopped" assert app_module.ook_process is None assert ook_module.ook_active_device is None def test_stream_endpoint(self, client): """SSE stream endpoint should return text/event-stream.""" _login_session(client) resp = client.get("/ook/stream") assert resp.content_type.startswith("text/event-stream") assert resp.headers.get("Cache-Control") == "no-cache" # --------------------------------------------------------------------------- # Parser thread — stopped status on exit # --------------------------------------------------------------------------- class TestOokParserStoppedEvent: def test_emits_stopped_on_normal_exit(self): """Parser thread should emit a status: stopped event when stream ends.""" stdout = io.BytesIO(b"") output_queue = queue.Queue() stop_event = threading.Event() t = threading.Thread( target=ook_parser_thread, args=(stdout, output_queue, stop_event), ) t.start() t.join(timeout=2) events = [] while not output_queue.empty(): events.append(output_queue.get_nowait()) status_events = [e for e in events if e.get("type") == "status"] assert len(status_events) == 1 assert status_events[0]["text"] == "stopped"