From 5aa4c9add5d265445a08d8051213ac7005ec3c85 Mon Sep 17 00:00:00 2001 From: Matt James Date: Sun, 18 Oct 2026 12:14:32 -0400 Subject: [PATCH] refactor(app): collapse the mode-launch recipes --- backend/app.py | 271 +++++++++++---------------- backend/launch.py | 200 ++++++++++++++++++++ docs/architecture.md | 6 +- tests/test_mode_launch.py | 374 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 686 insertions(+), 165 deletions(-) create mode 100644 backend/launch.py create mode 100644 tests/test_mode_launch.py diff --git a/backend/app.py b/backend/app.py index 7779bd0..ba819ae 100644 --- a/backend/app.py +++ b/backend/app.py @@ -42,6 +42,7 @@ from .scan import AutoScanner, ScanConfig from .sdr import SweepConfig, SweepStreamer from .jobs import JobBoard, mode_spec +from .launch import Launcher, LaunchSpec, done_finish, exit_finish, scan_finish, toast_on_completed from .store import get_store from . import telemetry @@ -386,10 +387,6 @@ def _emit_adsb(aircraft: list[dict], meta: dict) -> None: socketio.emit("adsb", {"aircraft": aircraft, "meta": meta}) -def _emit_scan(name: str, payload: dict) -> None: - socketio.emit(name, payload) - - # ------------------------------------------------------------------ # Job lifecycle. One board, one publish path. # ------------------------------------------------------------------ @@ -422,19 +419,38 @@ def _finish_job(identity: int) -> bool: return job is not None -def _exit_handler(identity: int, kind: str): - death_label = mode_spec(kind).death_label +class _AppLaunchHost: + """LaunchHost adapter: app globals behind the launch module's seam.""" - def on_exit(reason: str) -> None: - if reason == "stopped": - return - if not _finish_job(identity): - return - telemetry.bump("subprocess_deaths") - if _device["info"] is not None: - _emit_toast("error", f"{death_label} stopped unexpectedly.") + def begin_job(self, kind, make): + return _begin_job(kind, make) + + def finish_job(self, identity): + return _finish_job(identity) + + def emit(self, name, payload): + socketio.emit(name, payload) + + def toast(self, level, message): + _emit_toast(level, message) + + def status(self): _emit_status() - return on_exit + + def captures_list(self): + _emit_captures_list() + + def device_present(self): + return _device["info"] is not None + + def bump_deaths(self): + telemetry.bump("subprocess_deaths") + + def death_label(self, kind): + return mode_spec(kind).death_label + + +_launcher = Launcher(_AppLaunchHost()) def _begin_job(kind: str, make): @@ -477,93 +493,65 @@ def _begin_job(kind: str, make): def _start_sweep(cfg: SweepConfig) -> bool: - def make(identity: int): + def build(ctx): _state["sweep_config"] = cfg - return SweepStreamer( - cfg, - on_sweep=_emit_sweep, - on_exit=_exit_handler(identity, "sweep"), - ) - ok, _value = _begin_job("sweep", make) - return ok + return SweepStreamer(cfg, on_sweep=_emit_sweep, on_exit=ctx.on_exit) + return _launcher.launch(LaunchSpec("sweep", build, exit_finish())) def _start_decode(cfg: DecodeConfig) -> bool: - def make(identity: int): + def build(ctx): _state["decode_config"] = cfg - return Rtl433Decoder( - cfg, - on_event=_emit_event, - on_exit=_exit_handler(identity, "decode"), - ) - ok, _value = _begin_job("decode", make) - return ok + return Rtl433Decoder(cfg, on_event=_emit_event, on_exit=ctx.on_exit) + return _launcher.launch(LaunchSpec("decode", build, exit_finish())) + + +def _capture_toast(reason, extra, device_present): + record = extra[0] + if reason == "completed": + return ("info", f"Capture done: {record.name} ({record.file_size / (1024 * 1024):.1f} MB)") + if reason == "cancelled": + return ("warn", f"Capture cancelled: {record.name}") + if device_present: + return ("error", f"Capture failed: {record.name}") + return None def _start_capture(cfg: CaptureConfig) -> bool: geo = _receiver_geotag() - def on_done(identity: int): - def _on_done(record, reason: str) -> None: - cleared = _finish_job(identity) - if reason == "completed": - size_mb = record.file_size / (1024 * 1024) - _emit_toast("info", f"Capture done: {record.name} ({size_mb:.1f} MB)") - elif reason == "cancelled": - _emit_toast("warn", f"Capture cancelled: {record.name}") - elif _device["info"] is not None: - _emit_toast("error", f"Capture failed: {record.name}") - socketio.emit("capture_done", {"record": asdict(record), "reason": reason}) - _emit_captures_list() - if cleared: - _emit_status() - return _on_done - - def make(identity: int): + def build(ctx): _state["capture_config"] = cfg - return IqCapture( - cfg, - on_progress=_emit_capture_progress, - on_done=on_done(identity), - geolocation=geo, - ) - - ok, worker = _begin_job("capture", make) - if not ok: - return False - socketio.emit("capture_started", {"record": asdict(worker.record)}) - return True + return IqCapture(cfg, on_progress=_emit_capture_progress, + on_done=ctx.on_done, geolocation=geo) + return _launcher.launch(LaunchSpec( + "capture", build, + done_finish("capture_done", + lambda extra, reason: {"record": asdict(extra[0]), "reason": reason}, + toast_for=_capture_toast, toast_before_event=True, + refresh_captures=True), + started=lambda worker: [("emit", "capture_started", {"record": asdict(worker.record)})], + )) def _start_adsb(cfg: AdsbConfig) -> bool: - def make(identity: int): + def build(ctx): _state["adsb_config"] = cfg - return AdsbReceiver( - cfg, - on_update=_emit_adsb, - on_exit=_exit_handler(identity, "adsb"), - ) - ok, _value = _begin_job("adsb", make) - return ok + return AdsbReceiver(cfg, on_update=_emit_adsb, on_exit=ctx.on_exit) + return _launcher.launch(LaunchSpec("adsb", build, exit_finish())) def _start_radio(cfg: RadioConfig) -> bool: - def make(identity: int): + def build(ctx): _state["radio_config"] = cfg return RadioReceiver( - cfg, - on_audio=_on_radio_audio, - on_exit=_exit_handler(identity, "radio"), - on_signal=lambda db: socketio.emit("radio_signal", {"dbfs": db}), - ) - ok, _value = _begin_job("radio", make) - if not ok: - return False - socketio.emit( - "radio_started", - {"sample_rate": AUDIO_RATE, "freq_mhz": cfg.freq_mhz, "demod": cfg.demod}, - ) - return True + cfg, on_audio=_on_radio_audio, on_exit=ctx.on_exit, + on_signal=lambda db: socketio.emit("radio_signal", {"dbfs": db})) + return _launcher.launch(LaunchSpec( + "radio", build, exit_finish(), + started=lambda worker: [("emit", "radio_started", + {"sample_rate": AUDIO_RATE, "freq_mhz": cfg.freq_mhz, "demod": cfg.demod})], + )) def _start_scan_radio(cfg: ScanRadioConfig) -> bool: @@ -571,21 +559,17 @@ def _start_scan_radio(cfg: ScanRadioConfig) -> bool: _emit_toast("error", "No marked frequencies to scan.") return False - def make(identity: int): + def build(ctx): return RadioScanner( cfg, on_audio=lambda pcm: socketio.emit("radio_audio", pcm), on_event=lambda name, payload: socketio.emit(name, payload), - on_exit=_exit_handler(identity, "scan_radio"), - ) - ok, _value = _begin_job("scan_radio", make) - if not ok: - return False - socketio.emit( - "scan_radio_started", - {"count": len(cfg.freqs_mhz), "sample_rate": AUDIO_RATE, "demod": cfg.demod}, - ) - return True + on_exit=ctx.on_exit) + return _launcher.launch(LaunchSpec( + "scan_radio", build, exit_finish(), + started=lambda worker: [("emit", "scan_radio_started", + {"count": len(cfg.freqs_mhz), "sample_rate": AUDIO_RATE, "demod": cfg.demod})], + )) def _start_replay(name: str) -> bool: @@ -599,26 +583,17 @@ def _start_replay(name: str) -> bool: center = entry.freq_hz sr = getattr(entry, "sample_rate", 2_000_000) # audio shape has no rate - def on_done(identity: int): - def _on_done(reason: str) -> None: - cleared = _finish_job(identity) - socketio.emit("replay_done", {"reason": reason, "name": name}) - if cleared: - _emit_status() - return _on_done - - def make(identity: int): + def build(ctx): return IqReplay( entry.path, center, sr, on_frame=lambda f0, f1, powers: socketio.emit( "sweep", {"f0": f0, "f1": f1, "powers": powers, "rate_hz": 20.0}), - on_done=on_done(identity), - ) - ok, _value = _begin_job("replay", make) - if not ok: - return False - socketio.emit("replay_started", {"name": name, "freq_hz": center, "sample_rate": sr}) - return True + on_done=ctx.on_done) + return _launcher.launch(LaunchSpec( + "replay", build, + done_finish("replay_done", lambda extra, reason: {"reason": reason, "name": name}), + started=lambda worker: [("emit", "replay_started", {"name": name, "freq_hz": center, "sample_rate": sr})], + )) def _start_iq_play(name: str, demod: str, offset_hz: float) -> bool: @@ -634,27 +609,19 @@ def _start_iq_play(name: str, demod: str, offset_hz: float) -> bool: sr = getattr(entry, "sample_rate", 2_000_000) center = entry.freq_hz - def on_done(identity: int): - def _on_done(reason: str) -> None: - cleared = _finish_job(identity) - socketio.emit("iq_play_done", {"reason": reason, "name": name}) - if cleared: - _emit_status() - return _on_done - - def make(identity: int): + def build(ctx): return IqAudioPlayer( entry.path, sr, demod, offset_hz, on_audio=lambda pcm: socketio.emit("radio_audio", pcm), - on_done=on_done(identity), - ) - ok, _value = _begin_job("iq_play", make) - if not ok: - return False - socketio.emit("radio_started", {"sample_rate": AUDIO_RATE, "freq_mhz": center / 1e6, "demod": demod}) - socketio.emit("iq_play_started", {"name": name, "demod": demod, "offset_hz": offset_hz, - "center_hz": center, "sample_rate": sr}) - return True + on_done=ctx.on_done) + return _launcher.launch(LaunchSpec( + "iq_play", build, + done_finish("iq_play_done", lambda extra, reason: {"reason": reason, "name": name}), + started=lambda worker: [ + ("emit", "radio_started", {"sample_rate": AUDIO_RATE, "freq_mhz": center / 1e6, "demod": demod}), + ("emit", "iq_play_started", {"name": name, "demod": demod, "offset_hz": offset_hz, + "center_hz": center, "sample_rate": sr})], + )) def _start_decode_file(name: str) -> bool: @@ -668,47 +635,23 @@ def _start_decode_file(name: str) -> bool: sr = getattr(entry, "sample_rate", 2_000_000) center = entry.freq_hz - def on_done(identity: int): - def _on_done(reason: str) -> None: - cleared = _finish_job(identity) - socketio.emit("decode_file_done", {"reason": reason, "name": name}) - if reason == "completed": - _emit_toast("info", f"Decode finished: {name}") - if cleared: - _emit_status() - return _on_done - - def make(identity: int): - return IqFileDecoder( - entry.path, sr, center, - on_event=_emit_event, - on_done=on_done(identity), - ) - ok, _value = _begin_job("decode_file", make) - if not ok: - return False - socketio.emit("decode_file_started", {"name": name, "freq_hz": center, "sample_rate": sr}) - _emit_toast("info", f"Decoding {name} (this can take a moment)") - return True + def build(ctx): + return IqFileDecoder(entry.path, sr, center, on_event=_emit_event, on_done=ctx.on_done) + return _launcher.launch(LaunchSpec( + "decode_file", build, + done_finish("decode_file_done", lambda extra, reason: {"reason": reason, "name": name}, + toast_for=toast_on_completed(f"Decode finished: {name}")), + started=lambda worker: [ + ("emit", "decode_file_started", {"name": name, "freq_hz": center, "sample_rate": sr}), + ("toast", "info", f"Decoding {name} (this can take a moment)")], + )) def _start_scan(cfg: ScanConfig) -> bool: - def on_scan_event(identity: int): - def _on_scan_event(name: str, payload: dict) -> None: - if name in ("scan_completed", "scan_failed"): - cleared = _finish_job(identity) - _emit_scan(name, payload) - if cleared: - _emit_status() - else: - _emit_scan(name, payload) - return _on_scan_event - - def make(identity: int): + def build(ctx): _state["scan_config"] = cfg - return AutoScanner(cfg, on_event=on_scan_event(identity)) - ok, _value = _begin_job("scan", make) - return ok + return AutoScanner(cfg, on_event=ctx.on_event) + return _launcher.launch(LaunchSpec("scan", build, scan_finish())) def _stop_all_external() -> None: diff --git a/backend/launch.py b/backend/launch.py new file mode 100644 index 0000000..203a58a --- /dev/null +++ b/backend/launch.py @@ -0,0 +1,200 @@ +"""Mode-launch module: one recipe for starting a mode and finishing it. + +Deep module (see docs/architecture.md). Ten per-mode launch recipes in +app.py used to re-derive the same consequences behind a small army of +closures: five ``on_exit`` handlers, four hand-built ``on_done`` +closures, and one scan-event closure. This module owns that recipe +behind a small interface: + + LaunchSpec one mode as data + small callbacks: kind, how to build + its worker (given the finish callbacks to wire in), + how its finish maps to toast/socket/status, and what + to emit once it has started. + Launcher consumes one spec through one launch() path: ask the + host to begin the job, then emit the started actions. + Adding a mode is adding a spec, not a new recipe. + +The seam is the ``LaunchHost`` the app supplies. JobBoard remains the +owner of job state (the host's begin/finish are its publish/finish via +app.py's ``_begin_job`` / ``_finish_job``) and WorkerLifecycle remains +the owner of start/stop/outcome() in each adapter; this module owns +only the launch recipe and the finish/toast/status derivation, in one +place. Per-mode differences that are real stay explicit in the spec's +finish mapping (capture's record payload and captures refresh, +decode_file's completed-only toast, scan's terminal events) rather +than being flattened into one finish shape. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Protocol + +# One started/finish consequence: ("emit", name, payload) or +# ("toast", level, message). A tuple keeps the ordering byte-identical +# to the hand-built recipes this replaces. +Action = tuple + +# Finish callbacks see the worker callback's own arguments: exit modes +# get (reason,), capture gets (record, reason), file modes get +# (reason,), scan gets (name, payload). In every done call the reason +# is the last argument, and any preceding arguments are `extra`. + + +def toast_on_completed(message: str): + """toast_for helper: an info toast only when the run completed.""" + return lambda reason, extra, device_present: ( + ("info", message) if reason == "completed" else None) + + +@dataclass(frozen=True) +class FinishSpec: + """How one mode's worker callbacks derive finish consequences. + + style is the recipe shape, one of: + "exit": on_exit(reason) — "stopped" is silent and leaves the job + published; any other reason finishes the job, counts a + subprocess death, toasts " stopped + unexpectedly." when a device is present, then emits status. + "done": on_done(*extra, reason) — always tries to finish the job, + emits done_event and/or the mapped toast (order set by + toast_before_event), optionally refreshes the captures + list, then emits status only when the job was cleared. + "scan": on_event(name, payload) — forwards every event; only the + terminal_events finish the job, then emit status when + the job was cleared. + """ + + style: str + done_event: str | None = None + done_payload: Callable | None = None # (extra, reason) -> dict + toast_for: Callable | None = None # (reason, extra, device_present) -> (level, msg) | None + toast_before_event: bool = False + refresh_captures: bool = False + terminal_events: tuple = () + + +def exit_finish() -> FinishSpec: + return FinishSpec(style="exit") + + +def done_finish(done_event: str | None = None, done_payload: Callable | None = None, + toast_for: Callable | None = None, toast_before_event: bool = False, + refresh_captures: bool = False) -> FinishSpec: + return FinishSpec(style="done", done_event=done_event, done_payload=done_payload, + toast_for=toast_for, toast_before_event=toast_before_event, + refresh_captures=refresh_captures) + + +def scan_finish() -> FinishSpec: + return FinishSpec(style="scan", terminal_events=("scan_completed", "scan_failed")) + + +@dataclass(frozen=True) +class LaunchContext: + """Finish callbacks a build() wires into its worker, by mode style.""" + + identity: int + on_exit: Callable + on_done: Callable + on_event: Callable + + +@dataclass(frozen=True) +class LaunchSpec: + kind: str + build: Callable # (LaunchContext) -> worker (not started) + finish: FinishSpec + started: Callable | None = None # (worker) -> list[Action] + + +class LaunchHost(Protocol): + """The app-side seam: job board, socket, toast, status, telemetry.""" + + def begin_job(self, kind: str, make: Callable): ... + def finish_job(self, identity: int) -> bool: ... + def emit(self, name: str, payload: dict) -> None: ... + def toast(self, level: str, message: str) -> None: ... + def status(self) -> None: ... + def captures_list(self) -> None: ... + def device_present(self) -> bool: ... + def bump_deaths(self) -> None: ... + def death_label(self, kind: str) -> str: ... + + +class Launcher: + """The single launch path. Tests cross this same seam with a fake host.""" + + def __init__(self, host: LaunchHost) -> None: + self._host = host + + def launch(self, spec: LaunchSpec) -> bool: + def make(identity: int): + ctx = LaunchContext( + identity=identity, + on_exit=lambda reason: self._finish_exit(spec, identity, reason), + on_done=lambda *args: self._finish_done(spec, identity, args), + on_event=lambda name, payload: self._finish_scan(spec, identity, name, payload), + ) + return spec.build(ctx) + + ok, worker = self._host.begin_job(spec.kind, make) + if not ok: + return False + for action in (spec.started(worker) if spec.started else []): + self._do(action) + return True + + # -- finish derivation: the one place ------------------------------ + def _finish_exit(self, spec: LaunchSpec, identity: int, reason: str) -> None: + if reason == "stopped": + return + if not self._host.finish_job(identity): + return + self._host.bump_deaths() + if self._host.device_present(): + self._host.toast("error", f"{self._host.death_label(spec.kind)} stopped unexpectedly.") + self._host.status() + + def _finish_done(self, spec: LaunchSpec, identity: int, args: tuple) -> None: + reason = args[-1] + extra = args[:-1] + finish = spec.finish + cleared = self._host.finish_job(identity) + toast = (finish.toast_for(reason, extra, self._host.device_present()) + if finish.toast_for else None) + + def do_toast() -> None: + if toast is not None: + self._host.toast(toast[0], toast[1]) + + def do_event() -> None: + if finish.done_event is not None: + payload = finish.done_payload(extra, reason) if finish.done_payload else {} + self._host.emit(finish.done_event, payload) + + if finish.toast_before_event: + do_toast(); do_event() + else: + do_event(); do_toast() + if finish.refresh_captures: + self._host.captures_list() + if cleared: + self._host.status() + + def _finish_scan(self, spec: LaunchSpec, identity: int, name: str, payload: dict) -> None: + if name not in spec.finish.terminal_events: + self._host.emit(name, payload) + return + cleared = self._host.finish_job(identity) + self._host.emit(name, payload) + if cleared: + self._host.status() + + def _do(self, action: Action) -> None: + if action[0] == "emit": + self._host.emit(action[1], action[2]) + elif action[0] == "toast": + self._host.toast(action[1], action[2]) + else: # pragma: no cover - a spec bug, not a runtime mode + raise ValueError(f"unknown launch action {action[0]!r}") diff --git a/docs/architecture.md b/docs/architecture.md index 592c5c3..d3a86ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,11 @@ Content-Security-Policy, so the app shell works fully offline. - `backend/`: Flask app (`app.py`), the single job board (`jobs.py`), the one worker lifecycle (`worker.py`: start/stop/is_running, terminate->wait->kill, - and completed-vs-died outcome mapping behind one interface), per-mode subprocess wrappers + and completed-vs-died outcome mapping behind one interface), the mode-launch + module (`launch.py`: one declarative `LaunchSpec` per mode consumed by a + single `Launcher`, owning the started/finish toast/status derivation in one + place — JobBoard still owns job state and WorkerLifecycle still owns each + worker's lifecycle), per-mode subprocess wrappers (`sdr.py`, `radio.py`, `decoders.py`, `adsb.py`, `capture.py`, `scan.py`), the capture catalogue (`catalogue.py`), offline `replay.py`, snap-to-peak `tuning.py`, SQLite `store.py`, diff --git a/tests/test_mode_launch.py b/tests/test_mode_launch.py new file mode 100644 index 0000000..fa3878a --- /dev/null +++ b/tests/test_mode_launch.py @@ -0,0 +1,374 @@ +"""Mode-launch recipes: characterization of all ten _start_* in app.py. + +Fake workers + a socket recorder pin, per mode: the JobBoard kind it +publishes, the started socket events (names + payloads byte-identical), +the finish derivation (status/toast/socket order), and the guard/error +toasts. No hardware, no real subprocesses, no store: _emit_captures_list +is replaced by a marker so its ordering against capture_done/status is +still pinned. + +Inventory pinned here (backend/app.py on origin/main f8f80cd, lines +479-711): 5 _exit_handler modes (sweep, decode, adsb, radio, +scan_radio), 4 hand-built on_done modes (capture, replay, iq_play, +decode_file), 1 scan-event mode (scan). That is 10 _start_* / 10 finish +derivations; the review's "6 _exit_handler uses" is 5 on this tree. +""" + +import os +import sys +import traceback +from contextlib import ExitStack +from dataclasses import dataclass +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend import app # noqa: E402 +from backend.adsb import AdsbConfig # noqa: E402 +from backend.capture import CaptureConfig # noqa: E402 +from backend.decoders import DecodeConfig # noqa: E402 +from backend.radio import RadioConfig, ScanRadioConfig # noqa: E402 +from backend.scan import ScanConfig # noqa: E402 +from backend.sdr import SweepConfig # noqa: E402 + +WORKER_NAMES = ["SweepStreamer", "Rtl433Decoder", "IqCapture", "AdsbReceiver", + "RadioReceiver", "RadioScanner", "IqReplay", "IqAudioPlayer", + "IqFileDecoder", "AutoScanner"] + + +@dataclass +class FakeRecord: + name: str = "cap.iq" + file_size: int = 1024 * 1024 + + +class FakeWorker: + instances: list = [] + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.record = FakeRecord() + self.stopped = False + FakeWorker.instances.append(self) + + def start(self): + return True + + def stop(self): + self.stopped = True + + +class Harness: + def __enter__(self): + self.events = [] + self.stack = ExitStack() + FakeWorker.instances = [] + emit = lambda name, payload=None: self.events.append((name, payload)) # noqa: E731 + self.stack.enter_context(mock.patch.object(app.socketio, "emit", emit)) + for name in WORKER_NAMES: + self.stack.enter_context(mock.patch.object(app, name, FakeWorker)) + self.entry = SimpleNamespace(path="/tmp/cap.iq", freq_hz=100_000_000, + sample_rate=2_000_000, exists=True) + self.stack.enter_context(mock.patch.object( + app, "find_capture", lambda name, captures_dir: self.entry)) + self.stack.enter_context(mock.patch.object(app, "_receiver_geotag", lambda: None)) + self.stack.enter_context(mock.patch.object( + app, "_emit_captures_list", lambda: self.events.append(("captures_list", {})))) + with app._state_lock: + app._jobs.clear() + app._device["info"] = {"serial": "test"} + app._audio_rec["recorder"] = None + app._audio_rec["record"] = None + return self + + def __exit__(self, *exc): + with app._state_lock: + app._jobs.clear() + self.stack.close() + return False + + def names(self): + return [n for n, _ in self.events] + + def clear(self): + self.events.clear() + + +def _start_all(h): + """(kind, start-callable, callback-kwarg) for every mode.""" + return [ + ("sweep", lambda: app._start_sweep(SweepConfig()), "on_exit"), + ("decode", lambda: app._start_decode(DecodeConfig()), "on_exit"), + ("capture", lambda: app._start_capture(CaptureConfig()), "on_done"), + ("adsb", lambda: app._start_adsb(AdsbConfig()), "on_exit"), + ("radio", lambda: app._start_radio(RadioConfig()), "on_exit"), + ("scan_radio", lambda: app._start_scan_radio(ScanRadioConfig(freqs_mhz=[101.1])), "on_exit"), + ("replay", lambda: app._start_replay("cap.iq"), "on_done"), + ("iq_play", lambda: app._start_iq_play("cap.iq", "fm", 0.0), "on_done"), + ("decode_file", lambda: app._start_decode_file("cap.iq"), "on_done"), + ("scan", lambda: app._start_scan(ScanConfig()), "on_event"), + ] + + +EXPECTED_STARTED = { + "sweep": ["status", "status"], + "decode": ["status", "status"], + "capture": ["status", "status", "capture_started"], + "adsb": ["status", "status"], + "radio": ["status", "status", "radio_started"], + "scan_radio": ["status", "status", "scan_radio_started"], + "replay": ["status", "status", "replay_started"], + "iq_play": ["status", "status", "radio_started", "iq_play_started"], + "decode_file": ["status", "status", "decode_file_started", "toast"], + "scan": ["status", "status"], +} + + +def test_all_ten_modes_publish_and_emit_started(): + with Harness() as h: + cases = _start_all(h) + assert len(cases) == 10, [c[0] for c in cases] + for kind, start, cb in cases: + h.clear(); FakeWorker.instances = [] + assert start() is True, kind + assert app._jobs.mode() == kind, (kind, app._jobs.mode()) + assert h.names() == EXPECTED_STARTED[kind], (kind, h.events) + assert len(FakeWorker.instances) == 1, kind + worker = FakeWorker.instances[0] + assert callable(worker.kwargs.get(cb)), (kind, cb, worker.kwargs) + # status payload carries the board's mode, byte-identical labels + statuses = [p for n, p in h.events if n == "status"] + assert statuses[-1]["mode"] == kind, (kind, statuses[-1]) + + +def test_started_payloads_are_byte_identical(): + with Harness() as h: + app._start_capture(CaptureConfig()) + assert ("capture_started", {"record": {"name": "cap.iq", "file_size": 1048576}}) in h.events + h.clear(); app._start_radio(RadioConfig(freq_mhz=101.1, demod="fm")) + assert h.events[-1] == ("radio_started", {"sample_rate": app.AUDIO_RATE, "freq_mhz": 101.1, "demod": "fm"}) + h.clear(); app._start_scan_radio(ScanRadioConfig(freqs_mhz=[101.1, 102.2], demod="nfm")) + assert h.events[-1] == ("scan_radio_started", {"count": 2, "sample_rate": app.AUDIO_RATE, "demod": "nfm"}) + h.clear(); app._start_replay("cap.iq") + assert h.events[-1] == ("replay_started", {"name": "cap.iq", "freq_hz": 100_000_000, "sample_rate": 2_000_000}) + h.clear(); app._start_iq_play("cap.iq", "bogus", 500.0) + # demod coercion + both started events, in order + assert h.events[-2] == ("radio_started", {"sample_rate": app.AUDIO_RATE, "freq_mhz": 100.0, "demod": "fm"}) + assert h.events[-1] == ("iq_play_started", {"name": "cap.iq", "demod": "fm", "offset_hz": 500.0, "center_hz": 100_000_000, "sample_rate": 2_000_000}) + h.clear(); app._start_decode_file("cap.iq") + assert h.events[-2] == ("decode_file_started", {"name": "cap.iq", "freq_hz": 100_000_000, "sample_rate": 2_000_000}) + assert h.events[-1] == ("toast", {"level": "info", "message": "Decoding cap.iq (this can take a moment)"}) + + +def test_exit_modes_finish_derivation(): + death = {"sweep": "Sweep", "decode": "Decoder", "adsb": "ADS-B", + "radio": "Radio", "scan_radio": "Scanner"} + with Harness() as h: + for kind, start, _cb in _start_all(h): + if kind not in death: + continue + assert start() is True, kind + on_exit = FakeWorker.instances[-1].kwargs["on_exit"] + h.clear(); on_exit("stopped") + assert h.events == [] and app._jobs.mode() == kind, (kind, h.events) + on_exit("died") + assert h.names() == ["toast", "status"], (kind, h.events) + assert h.events[0] == ("toast", {"level": "error", "message": f"{death[kind]} stopped unexpectedly."}) + assert h.events[1][1]["mode"] == "idle" + assert app._jobs.mode() == "idle" + + +def test_done_modes_finish_derivation(): + with Harness() as h: + # capture: toast first, then capture_done, captures list, status + app._start_capture(CaptureConfig()); rec = FakeRecord() + on_done = FakeWorker.instances[-1].kwargs["on_done"]; h.clear() + on_done(rec, "completed") + assert h.events == [ + ("toast", {"level": "info", "message": "Capture done: cap.iq (1.0 MB)"}), + ("capture_done", {"record": {"name": "cap.iq", "file_size": 1048576}, "reason": "completed"}), + ("captures_list", {}), ("status", h.events[-1][1])], h.events + assert h.events[-1][1]["mode"] == "idle" + app._start_capture(CaptureConfig()); on_done = FakeWorker.instances[-1].kwargs["on_done"]; h.clear() + on_done(rec, "cancelled") + assert h.events[0] == ("toast", {"level": "warn", "message": "Capture cancelled: cap.iq"}) + app._start_capture(CaptureConfig()); on_done = FakeWorker.instances[-1].kwargs["on_done"]; h.clear() + on_done(rec, "died") + assert h.events[0] == ("toast", {"level": "error", "message": "Capture failed: cap.iq"}) + # replay / iq_play: done event then status, no toast + for kind, start, event in [("replay", lambda: app._start_replay("cap.iq"), "replay_done"), + ("iq_play", lambda: app._start_iq_play("cap.iq", "fm", 0.0), "iq_play_done")]: + assert start() is True + cb = FakeWorker.instances[-1].kwargs["on_done"]; h.clear(); cb("completed") + assert h.names() == [event, "status"], (kind, h.events) + assert h.events[0] == (event, {"reason": "completed", "name": "cap.iq"}) + # decode_file: done, completed-only toast, status + app._start_decode_file("cap.iq"); cb = FakeWorker.instances[-1].kwargs["on_done"]; h.clear() + cb("completed") + assert h.names() == ["decode_file_done", "toast", "status"], h.events + assert h.events[1] == ("toast", {"level": "info", "message": "Decode finished: cap.iq"}) + app._start_decode_file("cap.iq"); cb = FakeWorker.instances[-1].kwargs["on_done"]; h.clear() + cb("died") + assert h.names() == ["decode_file_done", "status"], h.events + + +def test_scan_finish_derivation(): + with Harness() as h: + app._start_scan(ScanConfig()) + on_event = FakeWorker.instances[-1].kwargs["on_event"]; h.clear() + on_event("scan_progress", {"pct": 1}) + assert h.events == [("scan_progress", {"pct": 1})] and app._jobs.mode() == "scan" + on_event("scan_completed", {"summary": {}}) + assert h.names() == ["scan_progress", "scan_completed", "status"], h.events + assert app._jobs.mode() == "idle" + app._start_scan(ScanConfig()); on_event = FakeWorker.instances[-1].kwargs["on_event"]; h.clear() + on_event("scan_failed", {"reason": "exception"}) + assert h.names() == ["scan_failed", "status"] and app._jobs.mode() == "idle" + + +def test_guards_and_error_paths(): + with Harness() as h: + # device missing: device modes refused, file modes allowed + app._device["info"] = None; h.clear() + assert app._start_sweep(SweepConfig()) is False + assert h.events == [("toast", {"level": "error", "message": "Cannot start: HackRF not detected."})] + assert app._start_replay("cap.iq") is True + app._device["info"] = {"serial": "test"} + # scan_radio pre-guard runs before the board is touched + h.clear(); assert app._start_scan_radio(ScanRadioConfig(freqs_mhz=[])) is False + assert h.events == [("toast", {"level": "error", "message": "No marked frequencies to scan."})] + # invalid / missing captures + for call, msg in [(lambda: app._start_replay("../cap.iq"), "Invalid capture name"), + (lambda: app._start_iq_play("cap.wav", "fm", 0.0), "Invalid capture"), + (lambda: app._start_decode_file("cap.wav"), "Invalid capture")]: + h.clear(); assert call() is False + assert h.events == [("toast", {"level": "error", "message": msg})], h.events + h.entry = None + for call in [lambda: app._start_replay("cap.iq"), lambda: app._start_iq_play("cap.iq", "fm", 0.0), + lambda: app._start_decode_file("cap.iq")]: + h.clear(); assert call() is False + assert h.events == [("toast", {"level": "error", "message": "Capture not found"})], h.events + + + + +# --- the single-recipe property (backend/launch.py) --------------------- + +class _SpecWorker: + def __init__(self, ctx): + self.ctx = ctx + + def start(self): + return True + + def stop(self): + pass + + +class _FakeHost: + def __init__(self): + self.events = [] + self.deaths = 0 + self.current = None + self._next = 0 + + def begin_job(self, kind, make): + self._next += 1 + worker = make(self._next) + if worker.start() is not True: + return False, None + self.current = (kind, self._next, worker) + self.status() + return True, worker + + def finish_job(self, identity): + if self.current is None or self.current[1] != identity: + return False + self.current = None + return True + + def emit(self, name, payload): + self.events.append((name, payload)) + + def toast(self, level, message): + self.events.append(("toast", {"level": level, "message": message})) + + def status(self): + mode = self.current[0] if self.current else "idle" + self.events.append(("status", {"mode": mode})) + + def captures_list(self): + self.events.append(("captures_list", {})) + + def device_present(self): + return True + + def bump_deaths(self): + self.deaths += 1 + + def death_label(self, kind): + return {"test_mode": "Test", "a": "A", "b": "B"}.get(kind, kind) + + +def test_test_only_mode_spec_launches_through_same_path(): + from backend.launch import LaunchSpec, Launcher, exit_finish + host = _FakeHost() + launcher = Launcher(host) + spec = LaunchSpec( + kind="test_mode", + build=lambda ctx: _SpecWorker(ctx), + finish=exit_finish(), + started=lambda worker: [("emit", "test_started", {"ok": True})], + ) + assert launcher.launch(spec) is True + assert host.current[0] == "test_mode" + assert ("test_started", {"ok": True}) in host.events + worker = host.current[2] + host.events.clear() + worker.ctx.on_exit("stopped") + assert host.events == [] and host.current is not None + worker.ctx.on_exit("died") + assert host.events == [("toast", {"level": "error", "message": "Test stopped unexpectedly."}), + ("status", {"mode": "idle"})], host.events + assert host.deaths == 1 and host.current is None + + +def test_shared_done_finish_derivation_across_modes(): + from backend.launch import LaunchSpec, Launcher, done_finish + for event in ("a_done", "b_done"): + host = _FakeHost(); launcher = Launcher(host) + spec = LaunchSpec( + kind="a" if event == "a_done" else "b", + build=lambda ctx: _SpecWorker(ctx), + finish=done_finish(event, lambda extra, reason: {"reason": reason, "name": "x"}, + toast_for=lambda reason, extra, device: ("info", "fin") if reason == "completed" else None, + toast_before_event=False), + ) + assert launcher.launch(spec) is True + worker = host.current[2]; host.events.clear() + worker.ctx.on_done("completed") + assert [n for n, _ in host.events] == [event, "toast", "status"], host.events + + +def test_app_has_one_launch_path_and_no_handbuilt_finish_closures(): + from pathlib import Path + source = (Path(__file__).resolve().parent.parent / "backend" / "app.py").read_text() + assert source.count("_launcher.launch(") == 10, source.count("_launcher.launch(") + assert "_exit_handler" not in source + assert "def on_done" not in source + assert "def on_scan_event" not in source + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) + if k.startswith("test_") and callable(v)] + failed = 0 + for t in tests: + try: + t(); print(f"PASS {t.__name__}") + except Exception: + failed += 1; print(f"FAIL {t.__name__}"); traceback.print_exc() + print(f"\n{len(tests) - failed}/{len(tests)} passed") + raise SystemExit(1 if failed else 0)