[refactor](trx-server): de-ignore listener tests via duplex transport

Split handle_client into a generic handle_client_io<R, W> core plus a thin TcpStream wrapper, and make send_response generic over AsyncWrite. The 8 ignored integration tests now drive handle_client_io directly over a tokio::io::duplex pair, so the per-connection protocol state machine is exercised on every cargo test run instead of only when TCP loopback bind privileges are available.

All 24 listener tests run unconditionally; trx-server suite reports 142 passed, 0 ignored (was 134 passed, 8 ignored).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-05-03 21:05:31 +02:00
parent cc001287a2
commit bdf63fe81c
+130 -268
View File
@@ -234,8 +234,8 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
} }
} }
async fn send_response( async fn send_response<W: tokio::io::AsyncWrite + Unpin>(
writer: &mut tokio::net::tcp::OwnedWriteHalf, writer: &mut W,
response: &ClientResponse, response: &ClientResponse,
io_timeout: Duration, io_timeout: Duration,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
@@ -257,8 +257,31 @@ async fn handle_client(
socket: TcpStream, socket: TcpStream,
addr: SocketAddr, addr: SocketAddr,
ctx: ClientContext, ctx: ClientContext,
mut shutdown_rx: watch::Receiver<bool>, shutdown_rx: watch::Receiver<bool>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
// Disable Nagle so small frames (command responses, meter samples) ship
// immediately instead of sitting in the kernel's send buffer for up to
// ~40 ms waiting for more payload.
let _ = socket.set_nodelay(true);
let (reader, writer) = socket.into_split();
handle_client_io(BufReader::new(reader), writer, addr, ctx, shutdown_rx).await
}
/// Generic per-client request loop. Splitting this from `handle_client`
/// lets tests exercise the full protocol over a `tokio::io::duplex` pair
/// instead of needing a real TCP socket — the production wrapper just feeds
/// in the split halves of an accepted `TcpStream`.
async fn handle_client_io<R, W>(
mut reader: BufReader<R>,
mut writer: W,
addr: SocketAddr,
ctx: ClientContext,
mut shutdown_rx: watch::Receiver<bool>,
) -> std::io::Result<()>
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let ClientContext { let ClientContext {
rigs, rigs,
default_rig_id, default_rig_id,
@@ -267,12 +290,6 @@ async fn handle_client(
sat_pass_cache, sat_pass_cache,
timeouts, timeouts,
} = ctx; } = ctx;
// Disable Nagle so small frames (command responses, meter samples) ship
// immediately instead of sitting in the kernel's send buffer for up to
// ~40 ms waiting for more payload.
let _ = socket.set_nodelay(true);
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader);
loop { loop {
let line = tokio::select! { let line = tokio::select! {
@@ -664,10 +681,9 @@ async fn handle_client(
mod tests { mod tests {
use super::*; use super::*;
use std::collections::HashSet; use std::collections::HashSet;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::SocketAddr;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch};
use trx_core::radio::freq::Band; use trx_core::radio::freq::Band;
@@ -675,13 +691,6 @@ mod tests {
use trx_core::rig::state::RigState; use trx_core::rig::state::RigState;
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo}; use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
fn loopback_addr() -> SocketAddr {
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind");
let addr = listener.local_addr().expect("local_addr");
drop(listener);
addr
}
fn sample_state() -> RigState { fn sample_state() -> RigState {
let mut state = RigState::new_uninitialized(); let mut state = RigState::new_uninitialized();
state.initialized = true; state.initialized = true;
@@ -736,121 +745,115 @@ mod tests {
(Arc::new(map), "default".to_string()) (Arc::new(map), "default".to_string())
} }
#[tokio::test] /// Build a ClientContext directly so tests can drive `handle_client_io`
#[ignore = "requires TCP bind permissions"] /// without binding a real TCP listener.
async fn listener_rejects_missing_token() { fn make_ctx(
let addr = loopback_addr(); rigs: Arc<HashMap<String, RigHandle>>,
let (rigs, default_id) = make_rigs(sample_state()); default_rig_id: String,
let (shutdown_tx, shutdown_rx) = watch::channel(false); auth_tokens: HashSet<String>,
) -> ClientContext {
let mut auth = HashSet::new(); ClientContext {
auth.insert("secret".to_string());
let handle = tokio::spawn(run_listener(
addr,
rigs, rigs,
default_id, default_rig_id,
auth, validator: Arc::new(SimpleTokenValidator::new(auth_tokens)),
None, station_coords: None,
ListenerTimeouts::default(), sat_pass_cache: Arc::new(Mutex::new(None)),
timeouts: ListenerTimeouts::default(),
}
}
type ClientReader = BufReader<tokio::io::ReadHalf<tokio::io::DuplexStream>>;
type ClientWriter = tokio::io::WriteHalf<tokio::io::DuplexStream>;
type SpawnedClient = (
ClientReader,
ClientWriter,
tokio::task::JoinHandle<std::io::Result<()>>,
watch::Sender<bool>,
);
/// Spawn `handle_client_io` over a `tokio::io::duplex` pair. Returns the
/// client-side read+write halves, the spawned task handle, and a
/// shutdown sender that ends the loop when fired.
fn spawn_client_io(ctx: ClientContext) -> SpawnedClient {
let (server_io, client_io) = tokio::io::duplex(64 * 1024);
let (server_read, server_write) = tokio::io::split(server_io);
let (client_read, client_write) = tokio::io::split(client_io);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let addr: SocketAddr = "127.0.0.1:1234".parse().unwrap();
let handle = tokio::spawn(handle_client_io(
BufReader::new(server_read),
server_write,
addr,
ctx,
shutdown_rx, shutdown_rx,
)); ));
(
BufReader::new(client_read),
client_write,
handle,
shutdown_tx,
)
}
let stream = TcpStream::connect(addr).await.expect("connect"); /// Send one JSON line over the duplex and read one response line back.
let (reader, mut writer) = stream.into_split(); async fn duplex_round_trip<W: AsyncWriteExt + Unpin>(
let mut reader = BufReader::new(reader); writer: &mut W,
reader: &mut ClientReader,
writer json: &[u8],
.write_all(br#"{"cmd":"get_state"}"#) ) -> ClientResponse {
.await writer.write_all(json).await.expect("write");
.expect("write");
writer.write_all(b"\n").await.expect("newline"); writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush"); writer.flush().await.expect("flush");
let mut line = String::new(); let mut line = String::new();
reader.read_line(&mut line).await.expect("read"); reader.read_line(&mut line).await.expect("read");
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json"); serde_json::from_str(line.trim_end()).expect("response json")
}
#[tokio::test]
async fn listener_rejects_missing_token() {
let (rigs, default_id) = make_rigs(sample_state());
let mut auth = HashSet::new();
auth.insert("secret".to_string());
let ctx = make_ctx(rigs, default_id, auth);
let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
let resp = duplex_round_trip(&mut writer, &mut reader, br#"{"cmd":"get_state"}"#).await;
assert!(!resp.success); assert!(!resp.success);
assert_eq!(resp.error.as_deref(), Some("missing authorization token")); assert_eq!(resp.error.as_deref(), Some("missing authorization token"));
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await; let _ = handle.await;
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn listener_serves_get_state_snapshot() { async fn listener_serves_get_state_snapshot() {
let addr = loopback_addr();
let (rigs, default_id) = make_rigs(sample_state()); let (rigs, default_id) = make_rigs(sample_state());
let (shutdown_tx, shutdown_rx) = watch::channel(false); let ctx = make_ctx(rigs, default_id, HashSet::new());
let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
let handle = tokio::spawn(run_listener( let resp = duplex_round_trip(&mut writer, &mut reader, br#"{"cmd":"get_state"}"#).await;
addr,
rigs,
default_id,
HashSet::new(),
None,
ListenerTimeouts::default(),
shutdown_rx,
));
let stream = TcpStream::connect(addr).await.expect("connect");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
writer
.write_all(br#"{"cmd":"get_state"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read");
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
assert!(resp.success); assert!(resp.success);
let snapshot = resp.state.expect("snapshot"); let snapshot = resp.state.expect("snapshot");
assert_eq!(snapshot.info.model, "Dummy"); assert_eq!(snapshot.info.model, "Dummy");
assert_eq!(snapshot.status.freq.hz, 144_300_000); assert_eq!(snapshot.status.freq.hz, 144_300_000);
// rig_id should be set in the response
assert_eq!(resp.rig_id.as_deref(), Some("default")); assert_eq!(resp.rig_id.as_deref(), Some("default"));
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await; let _ = handle.await;
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn listener_routes_unknown_rig_id() { async fn listener_routes_unknown_rig_id() {
let addr = loopback_addr();
let (rigs, default_id) = make_rigs(sample_state()); let (rigs, default_id) = make_rigs(sample_state());
let (shutdown_tx, shutdown_rx) = watch::channel(false); let ctx = make_ctx(rigs, default_id, HashSet::new());
let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
let handle = tokio::spawn(run_listener( let resp = duplex_round_trip(
addr, &mut writer,
rigs, &mut reader,
default_id, br#"{"rig_id":"nonexistent","cmd":"get_state"}"#,
HashSet::new(), )
None, .await;
ListenerTimeouts::default(),
shutdown_rx,
));
let stream = TcpStream::connect(addr).await.expect("connect");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
writer
.write_all(br#"{"rig_id":"nonexistent","cmd":"get_state"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read");
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
assert!(!resp.success); assert!(!resp.success);
assert!(resp assert!(resp
.error .error
@@ -859,7 +862,6 @@ mod tests {
.contains("Unknown rig_id")); .contains("Unknown rig_id"));
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await; let _ = handle.await;
} }
@@ -951,161 +953,76 @@ mod tests {
(Arc::new(map), "rig_hf".to_string(), rx_a, rx_b) (Arc::new(map), "rig_hf".to_string(), rx_a, rx_b)
} }
/// Helper: send a JSON line and read one response line from the stream.
async fn send_and_recv(
writer: &mut tokio::net::tcp::OwnedWriteHalf,
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
json: &[u8],
) -> ClientResponse {
writer.write_all(json).await.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read");
serde_json::from_str(line.trim_end()).expect("response json")
}
#[tokio::test] #[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn multi_rig_state_isolation() { async fn multi_rig_state_isolation() {
// Two rigs with different frequencies and modes.
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB); let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM); let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM);
let (rigs, default_id, _rx_a, _rx_b) = make_two_rigs(state_hf, state_vhf); let (rigs, default_id, _rx_a, _rx_b) = make_two_rigs(state_hf, state_vhf);
let addr = loopback_addr(); let ctx = make_ctx(rigs, default_id, HashSet::new());
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
let handle = tokio::spawn(run_listener( let resp = duplex_round_trip(
addr,
rigs,
default_id,
HashSet::new(),
None,
ListenerTimeouts::default(),
shutdown_rx,
));
// Allow listener to bind.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let stream = TcpStream::connect(addr).await.expect("connect");
let (read_half, mut writer) = stream.into_split();
let mut reader = BufReader::new(read_half);
// Query rig_hf — should return HF state.
let resp = send_and_recv(
&mut writer, &mut writer,
&mut reader, &mut reader,
br#"{"rig_id":"rig_hf","cmd":"get_state"}"#, br#"{"rig_id":"rig_hf","cmd":"get_state"}"#,
) )
.await; .await;
assert!(resp.success, "rig_hf get_state should succeed"); assert!(resp.success);
assert_eq!(resp.rig_id.as_deref(), Some("rig_hf")); assert_eq!(resp.rig_id.as_deref(), Some("rig_hf"));
let snap_hf = resp.state.expect("rig_hf snapshot"); let snap_hf = resp.state.expect("rig_hf snapshot");
assert_eq!(snap_hf.info.model, "HF-Dummy"); assert_eq!(snap_hf.info.model, "HF-Dummy");
assert_eq!(snap_hf.status.freq.hz, 14_200_000); assert_eq!(snap_hf.status.freq.hz, 14_200_000);
// Query rig_vhf — should return VHF state. let resp = duplex_round_trip(
let resp = send_and_recv(
&mut writer, &mut writer,
&mut reader, &mut reader,
br#"{"rig_id":"rig_vhf","cmd":"get_state"}"#, br#"{"rig_id":"rig_vhf","cmd":"get_state"}"#,
) )
.await; .await;
assert!(resp.success, "rig_vhf get_state should succeed"); assert!(resp.success);
assert_eq!(resp.rig_id.as_deref(), Some("rig_vhf")); assert_eq!(resp.rig_id.as_deref(), Some("rig_vhf"));
let snap_vhf = resp.state.expect("rig_vhf snapshot"); let snap_vhf = resp.state.expect("rig_vhf snapshot");
assert_eq!(snap_vhf.info.model, "VHF-Dummy"); assert_eq!(snap_vhf.info.model, "VHF-Dummy");
assert_eq!(snap_vhf.status.freq.hz, 145_500_000); assert_eq!(snap_vhf.status.freq.hz, 145_500_000);
assert_ne!(snap_hf.status.mode, snap_vhf.status.mode);
// Verify the two snapshots have different modes.
assert_ne!(
snap_hf.status.mode, snap_vhf.status.mode,
"Rig states should be independent"
);
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await; let _ = handle.await;
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn multi_rig_default_fallback() { async fn multi_rig_default_fallback() {
// When rig_id is omitted, the default rig (rig_hf) should be used.
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB); let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM); let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM);
let (rigs, default_id, _rx_a, _rx_b) = make_two_rigs(state_hf, state_vhf); let (rigs, default_id, _rx_a, _rx_b) = make_two_rigs(state_hf, state_vhf);
let addr = loopback_addr(); let ctx = make_ctx(rigs, default_id, HashSet::new());
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
let handle = tokio::spawn(run_listener( let resp = duplex_round_trip(&mut writer, &mut reader, br#"{"cmd":"get_state"}"#).await;
addr, assert!(resp.success);
rigs,
default_id,
HashSet::new(),
None,
ListenerTimeouts::default(),
shutdown_rx,
));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let stream = TcpStream::connect(addr).await.expect("connect");
let (read_half, mut writer) = stream.into_split();
let mut reader = BufReader::new(read_half);
// No rig_id — should resolve to default (rig_hf).
let resp = send_and_recv(&mut writer, &mut reader, br#"{"cmd":"get_state"}"#).await;
assert!(resp.success, "default get_state should succeed");
assert_eq!(resp.rig_id.as_deref(), Some("rig_hf")); assert_eq!(resp.rig_id.as_deref(), Some("rig_hf"));
let snap = resp.state.expect("default snapshot"); let snap = resp.state.expect("default snapshot");
assert_eq!(snap.info.model, "HF-Dummy"); assert_eq!(snap.info.model, "HF-Dummy");
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await; let _ = handle.await;
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn multi_rig_get_rigs_returns_all() { async fn multi_rig_get_rigs_returns_all() {
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB); let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM); let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM);
let (rigs, default_id, _rx_a, _rx_b) = make_two_rigs(state_hf, state_vhf); let (rigs, default_id, _rx_a, _rx_b) = make_two_rigs(state_hf, state_vhf);
let addr = loopback_addr(); let ctx = make_ctx(rigs, default_id, HashSet::new());
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
let handle = tokio::spawn(run_listener( let resp = duplex_round_trip(&mut writer, &mut reader, br#"{"cmd":"get_rigs"}"#).await;
addr, assert!(resp.success);
rigs,
default_id,
HashSet::new(),
None,
ListenerTimeouts::default(),
shutdown_rx,
));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let stream = TcpStream::connect(addr).await.expect("connect");
let (read_half, mut writer) = stream.into_split();
let mut reader = BufReader::new(read_half);
let resp = send_and_recv(&mut writer, &mut reader, br#"{"cmd":"get_rigs"}"#).await;
assert!(resp.success, "get_rigs should succeed");
let entries = resp.rigs.expect("rigs list"); let entries = resp.rigs.expect("rigs list");
assert_eq!(entries.len(), 2, "should return both rigs"); assert_eq!(entries.len(), 2);
// Collect rig_ids from the entries.
let ids: HashSet<String> = entries.iter().map(|e| e.rig_id.clone()).collect(); let ids: HashSet<String> = entries.iter().map(|e| e.rig_id.clone()).collect();
assert!(ids.contains("rig_hf"), "should contain rig_hf"); assert!(ids.contains("rig_hf"));
assert!(ids.contains("rig_vhf"), "should contain rig_vhf"); assert!(ids.contains("rig_vhf"));
// Verify each entry has the correct frequency.
for entry in &entries { for entry in &entries {
match entry.rig_id.as_str() { match entry.rig_id.as_str() {
"rig_hf" => { "rig_hf" => {
@@ -1118,44 +1035,22 @@ mod tests {
assert_eq!(entry.state.info.model, "VHF-Dummy"); assert_eq!(entry.state.info.model, "VHF-Dummy");
assert_eq!(entry.audio_port, Some(4532)); assert_eq!(entry.audio_port, Some(4532));
} }
other => panic!("Unexpected rig_id: {}", other), other => panic!("unexpected rig_id: {}", other),
} }
} }
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await; let _ = handle.await;
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn multi_rig_command_routing() { async fn multi_rig_command_routing() {
// Verify that a set_freq command targeting rig_vhf is delivered to the
// VHF rig's mpsc channel and not to the HF rig's channel.
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB); let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM); let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM);
let (rigs, default_id, mut rx_hf, mut rx_vhf) = make_two_rigs(state_hf, state_vhf); let (rigs, default_id, mut rx_hf, mut rx_vhf) = make_two_rigs(state_hf, state_vhf);
let addr = loopback_addr(); let ctx = make_ctx(rigs, default_id, HashSet::new());
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (_reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
let handle = tokio::spawn(run_listener(
addr,
rigs,
default_id,
HashSet::new(),
None,
ListenerTimeouts::default(),
shutdown_rx,
));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let stream = TcpStream::connect(addr).await.expect("connect");
let (_read_half, mut writer) = stream.into_split();
// Send set_freq targeting rig_vhf. The listener will forward the
// command to the VHF rig's mpsc channel.
writer writer
.write_all(br#"{"rig_id":"rig_vhf","cmd":"set_freq","freq_hz":146000000}"#) .write_all(br#"{"rig_id":"rig_vhf","cmd":"set_freq","freq_hz":146000000}"#)
.await .await
@@ -1163,8 +1058,7 @@ mod tests {
writer.write_all(b"\n").await.expect("newline"); writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush"); writer.flush().await.expect("flush");
// The VHF channel should receive the command. let req = tokio::time::timeout(Duration::from_secs(2), rx_vhf.recv())
let req = tokio::time::timeout(std::time::Duration::from_secs(2), rx_vhf.recv())
.await .await
.expect("timeout waiting for VHF command") .expect("timeout waiting for VHF command")
.expect("VHF channel closed"); .expect("VHF channel closed");
@@ -1173,45 +1067,20 @@ mod tests {
"VHF rig should receive SetFreq(146 MHz), got {:?}", "VHF rig should receive SetFreq(146 MHz), got {:?}",
req.cmd req.cmd
); );
assert!(rx_hf.try_recv().is_err());
// The HF channel should NOT have received anything.
assert!(
rx_hf.try_recv().is_err(),
"HF rig should not receive commands targeting VHF"
);
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await; let _ = handle.await;
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn multi_rig_command_routing_to_default() { async fn multi_rig_command_routing_to_default() {
// When rig_id is omitted, commands should go to the default rig (HF).
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB); let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM); let state_vhf = sample_state_custom("VHF-Dummy", 145_500_000, trx_core::RigMode::FM);
let (rigs, default_id, mut rx_hf, mut rx_vhf) = make_two_rigs(state_hf, state_vhf); let (rigs, default_id, mut rx_hf, mut rx_vhf) = make_two_rigs(state_hf, state_vhf);
let addr = loopback_addr(); let ctx = make_ctx(rigs, default_id, HashSet::new());
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (_reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
let handle = tokio::spawn(run_listener(
addr,
rigs,
default_id,
HashSet::new(),
None,
ListenerTimeouts::default(),
shutdown_rx,
));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let stream = TcpStream::connect(addr).await.expect("connect");
let (_read_half, mut writer) = stream.into_split();
// No rig_id — should route to default (rig_hf).
writer writer
.write_all(br#"{"cmd":"set_freq","freq_hz":7100000}"#) .write_all(br#"{"cmd":"set_freq","freq_hz":7100000}"#)
.await .await
@@ -1219,8 +1088,7 @@ mod tests {
writer.write_all(b"\n").await.expect("newline"); writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush"); writer.flush().await.expect("flush");
// The HF channel should receive the command. let req = tokio::time::timeout(Duration::from_secs(2), rx_hf.recv())
let req = tokio::time::timeout(std::time::Duration::from_secs(2), rx_hf.recv())
.await .await
.expect("timeout waiting for HF command") .expect("timeout waiting for HF command")
.expect("HF channel closed"); .expect("HF channel closed");
@@ -1229,15 +1097,9 @@ mod tests {
"HF rig should receive SetFreq(7.1 MHz), got {:?}", "HF rig should receive SetFreq(7.1 MHz), got {:?}",
req.cmd req.cmd
); );
assert!(rx_vhf.try_recv().is_err());
// VHF should not receive anything.
assert!(
rx_vhf.try_recv().is_err(),
"VHF rig should not receive commands with no rig_id"
);
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await; let _ = handle.await;
} }