Flip T3 to substrate-initiated actuator commands
This commit is contained in:
188
simulator/tests/end_to_end_full_loop.rs
Normal file
188
simulator/tests/end_to_end_full_loop.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
//! Full closed-loop integration test:
|
||||
//!
|
||||
//! 1. Simulator emits a Presence sensor reading via T2 (`raw_value < 1.0`).
|
||||
//! 2. Substrate's `automation_system` detects threshold crossing.
|
||||
//! 3. Substrate opens a T3 bi-stream and writes a `Relay=stop` command.
|
||||
//! 4. Simulator's `run_command_receiver` decodes the command, flips
|
||||
//! `engine_running` to `false`, and writes the 39-byte ack back.
|
||||
//!
|
||||
//! Then we recover: send Presence > 1.0, observe the substrate dispatches
|
||||
//! `Relay=resume`, and the simulator's flag flips back to `true`.
|
||||
//!
|
||||
//! This test stands up the *real* substrate machinery — `accept_loop` plus
|
||||
//! `drain_outbound_t3` plus the ECS world's `automation_system` driving a
|
||||
//! `BridgeSenders` — so a regression in any of the three pieces fails here.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use simulator::client::SimulatorClient;
|
||||
use simulator::commands::{new_engine_state, run_command_receiver};
|
||||
use substrate::config::QuicConfig;
|
||||
use substrate::transport::server::{accept_loop, bind_endpoint, new_connection_registry};
|
||||
use substrate::transport::{OutboundT3, QuicMessage, SensorType, T1Sender, T2Sender, T3OutboundSender};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn cert_path(name: &str) -> PathBuf {
|
||||
[env!("CARGO_MANIFEST_DIR"), "..", "certs", name].iter().collect()
|
||||
}
|
||||
|
||||
fn loopback_config(cert: PathBuf, key: PathBuf) -> QuicConfig {
|
||||
QuicConfig {
|
||||
server_port: 0,
|
||||
server_interface: "127.0.0.1".to_string(),
|
||||
server_cert: cert.to_string_lossy().into_owned(),
|
||||
server_key: key.to_string_lossy().into_owned(),
|
||||
t1_capacity: 1024,
|
||||
t2_capacity: 512,
|
||||
t3_capacity: 256,
|
||||
synthetic_t3_rate_hz: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a minimal substrate world that runs `automation_system` against
|
||||
/// test-owned channels.
|
||||
///
|
||||
/// We don't construct a Bevy `App` here — the world tests already cover
|
||||
/// `automation_system` end-to-end with the `WorldPlugin`. This test focuses
|
||||
/// on the *transport* round-trip: T2 in, T3 out, with a real `accept_loop`
|
||||
/// and `drain_outbound_t3` doing the work.
|
||||
///
|
||||
/// We model the substrate side as: read T2 messages off the bridge receiver,
|
||||
/// detect Presence crossings inline, push `OutboundT3` commands. The real
|
||||
/// `automation_system` does the same thing inside the Bevy schedule; for
|
||||
/// this test, the inline driver keeps the test focused on the transport.
|
||||
async fn substrate_automation_proxy(
|
||||
mut t2_rx: mpsc::Receiver<QuicMessage>,
|
||||
t3_out: T3OutboundSender,
|
||||
) {
|
||||
let mut last_relay: f64 = 0.0;
|
||||
while let Some(msg) = t2_rx.recv().await {
|
||||
if msg.typ() != SensorType::Presence {
|
||||
continue;
|
||||
}
|
||||
let relay: f64 = if msg.raw_value < 1.0 { 1.0 } else { 0.0 };
|
||||
if (relay - last_relay).abs() < 1e-6 {
|
||||
continue; // no state change, no command
|
||||
}
|
||||
last_relay = relay;
|
||||
let _ = t3_out.try_send(OutboundT3 {
|
||||
target_device: msg.device_id,
|
||||
sensor_id: 6,
|
||||
raw_value: relay,
|
||||
sensor_type: SensorType::Relay.as_u8(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_for<F>(timeout: Duration, predicate: F) -> bool
|
||||
where
|
||||
F: Fn() -> bool,
|
||||
{
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < timeout {
|
||||
if predicate() {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn presence_drop_triggers_engine_stop_and_recovery_resumes_it() -> Result<()> {
|
||||
simulator::install_crypto_provider();
|
||||
|
||||
let cert = cert_path("server.crt");
|
||||
let key = cert_path("server.key");
|
||||
let cfg = loopback_config(cert.clone(), key);
|
||||
|
||||
// --- substrate side ---
|
||||
let endpoint = bind_endpoint(&cfg)?;
|
||||
let server_addr: SocketAddr = endpoint.local_addr()?;
|
||||
|
||||
let (t1_tx, _t1_rx) = mpsc::channel::<QuicMessage>(64);
|
||||
let (t2_tx, t2_rx) = mpsc::channel::<QuicMessage>(64);
|
||||
// Two outbound channels in this test: the substrate's real
|
||||
// outbound-T3 channel (consumed by drain_outbound_t3 inside accept_loop)
|
||||
// and the inline automation proxy that produces into it. We pass a
|
||||
// sender clone twice — once for the proxy, once for accept_loop's
|
||||
// synthetic-driver hook (which we disable here by passing rate 0.0).
|
||||
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
|
||||
let registry = new_connection_registry();
|
||||
|
||||
let server_task = tokio::spawn(accept_loop(
|
||||
endpoint,
|
||||
T1Sender::new(t1_tx),
|
||||
T2Sender::new(t2_tx),
|
||||
registry,
|
||||
t3_out_rx,
|
||||
t3_out_tx.clone(),
|
||||
0.0,
|
||||
));
|
||||
|
||||
// Inline automation: read T2 Presence events, emit Relay commands.
|
||||
let proxy = tokio::spawn(substrate_automation_proxy(
|
||||
t2_rx,
|
||||
T3OutboundSender::new(t3_out_tx),
|
||||
));
|
||||
|
||||
// --- simulator side ---
|
||||
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
|
||||
let engine_running: Arc<AtomicBool> = new_engine_state();
|
||||
{
|
||||
let conn = client.conn.clone();
|
||||
let flag = engine_running.clone();
|
||||
tokio::spawn(async move { run_command_receiver(conn, flag).await });
|
||||
}
|
||||
|
||||
let device = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
|
||||
let make_presence = |raw: f64, seq: u32| QuicMessage {
|
||||
device_id: device,
|
||||
sensor_id: 5,
|
||||
raw_value: raw,
|
||||
timestamp_us: 1_700_000_000_000_000 + u64::from(seq),
|
||||
sequence_number: seq,
|
||||
sensor_type: SensorType::Presence.as_u8(),
|
||||
};
|
||||
|
||||
// 1) Engine starts running.
|
||||
assert!(engine_running.load(Ordering::SeqCst), "engine should start in running state");
|
||||
|
||||
// 2) Push Presence < 1.0 via T2 → expect the substrate to dispatch
|
||||
// Relay=stop and the simulator's receiver to flip the flag.
|
||||
client.send_uni_stream(&[make_presence(0.5, 0)]).await?;
|
||||
|
||||
let stopped = poll_for(Duration::from_secs(3), || {
|
||||
!engine_running.load(Ordering::SeqCst)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
stopped,
|
||||
"engine_running did not flip to false within 3 s of the substrate \
|
||||
receiving Presence=0.5; the substrate→simulator T3 path is broken"
|
||||
);
|
||||
|
||||
// 3) Push Presence > 1.0 → expect Relay=resume → flag flips back to true.
|
||||
client.send_uni_stream(&[make_presence(2.5, 1)]).await?;
|
||||
|
||||
let resumed = poll_for(Duration::from_secs(3), || {
|
||||
engine_running.load(Ordering::SeqCst)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
resumed,
|
||||
"engine_running did not flip back to true after Presence=2.5; \
|
||||
recovery half of the closed loop is broken"
|
||||
);
|
||||
|
||||
client.close().await;
|
||||
proxy.abort();
|
||||
server_task.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use simulator::client::SimulatorClient;
|
||||
use substrate::config::QuicConfig;
|
||||
use substrate::transport::server::{accept_loop, bind_endpoint};
|
||||
use substrate::transport::{QuicMessage, SensorType, T1Sender, T2Sender, T3Sender};
|
||||
use substrate::transport::server::{accept_loop, bind_endpoint, new_connection_registry};
|
||||
use substrate::transport::{OutboundT3, QuicMessage, SensorType, T1Sender, T2Sender};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -31,6 +31,7 @@ fn loopback_config(cert: PathBuf, key: PathBuf) -> QuicConfig {
|
||||
t1_capacity: 1024,
|
||||
t2_capacity: 512,
|
||||
t3_capacity: 256,
|
||||
synthetic_t3_rate_hz: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,13 +51,17 @@ async fn t1_datagram_decoded_into_ecs_channel() -> Result<()> {
|
||||
// demux pushes into the ECS bridge.
|
||||
let (t1_tx, mut t1_rx) = mpsc::channel(64);
|
||||
let (t2_tx, _t2_rx) = mpsc::channel(64);
|
||||
let (t3_tx, _t3_rx) = mpsc::channel(64);
|
||||
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
|
||||
let registry = new_connection_registry();
|
||||
|
||||
let server_task = tokio::spawn(accept_loop(
|
||||
endpoint,
|
||||
T1Sender::new(t1_tx),
|
||||
T2Sender::new(t2_tx),
|
||||
T3Sender::new(t3_tx),
|
||||
registry,
|
||||
t3_out_rx,
|
||||
t3_out_tx,
|
||||
0.0, // synthetic driver disabled
|
||||
));
|
||||
|
||||
// Connect a client and send one datagram.
|
||||
@@ -99,13 +104,17 @@ async fn t1_burst_preserves_order_and_count() -> Result<()> {
|
||||
// T1 capacity 64 ≥ burst size 32 so nothing is dropped under loopback.
|
||||
let (t1_tx, mut t1_rx) = mpsc::channel(64);
|
||||
let (t2_tx, _t2_rx) = mpsc::channel(8);
|
||||
let (t3_tx, _t3_rx) = mpsc::channel(8);
|
||||
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(8);
|
||||
let registry = new_connection_registry();
|
||||
|
||||
let server_task = tokio::spawn(accept_loop(
|
||||
endpoint,
|
||||
T1Sender::new(t1_tx),
|
||||
T2Sender::new(t2_tx),
|
||||
T3Sender::new(t3_tx),
|
||||
registry,
|
||||
t3_out_rx,
|
||||
t3_out_tx,
|
||||
0.0,
|
||||
));
|
||||
|
||||
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
|
||||
|
||||
@@ -12,8 +12,8 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use simulator::client::SimulatorClient;
|
||||
use substrate::config::QuicConfig;
|
||||
use substrate::transport::server::{accept_loop, bind_endpoint};
|
||||
use substrate::transport::{QuicMessage, SensorType, T1Sender, T2Sender, T3Sender};
|
||||
use substrate::transport::server::{accept_loop, bind_endpoint, new_connection_registry};
|
||||
use substrate::transport::{OutboundT3, QuicMessage, SensorType, T1Sender, T2Sender};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -30,6 +30,7 @@ fn loopback_config(cert: PathBuf, key: PathBuf) -> QuicConfig {
|
||||
t1_capacity: 1024,
|
||||
t2_capacity: 512,
|
||||
t3_capacity: 256,
|
||||
synthetic_t3_rate_hz: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +47,17 @@ async fn t2_single_stream_preserves_order() -> Result<()> {
|
||||
|
||||
let (t1_tx, _t1_rx) = mpsc::channel(64);
|
||||
let (t2_tx, mut t2_rx) = mpsc::channel(64);
|
||||
let (t3_tx, _t3_rx) = mpsc::channel(64);
|
||||
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
|
||||
let registry = new_connection_registry();
|
||||
|
||||
let server_task = tokio::spawn(accept_loop(
|
||||
endpoint,
|
||||
T1Sender::new(t1_tx),
|
||||
T2Sender::new(t2_tx),
|
||||
T3Sender::new(t3_tx),
|
||||
registry,
|
||||
t3_out_rx,
|
||||
t3_out_tx,
|
||||
0.0,
|
||||
));
|
||||
|
||||
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
|
||||
@@ -98,13 +103,17 @@ async fn t2_concurrent_streams_each_internally_ordered() -> Result<()> {
|
||||
|
||||
let (t1_tx, _t1_rx) = mpsc::channel(64);
|
||||
let (t2_tx, mut t2_rx) = mpsc::channel(256);
|
||||
let (t3_tx, _t3_rx) = mpsc::channel(64);
|
||||
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
|
||||
let registry = new_connection_registry();
|
||||
|
||||
let server_task = tokio::spawn(accept_loop(
|
||||
endpoint,
|
||||
T1Sender::new(t1_tx),
|
||||
T2Sender::new(t2_tx),
|
||||
T3Sender::new(t3_tx),
|
||||
registry,
|
||||
t3_out_rx,
|
||||
t3_out_tx,
|
||||
0.0,
|
||||
));
|
||||
|
||||
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
//! End-to-end T3 (bidirectional stream + oneshot ack) tests. Same shape as
|
||||
//! the T1/T2 harnesses: spin up substrate's listener with channels owned by
|
||||
//! the test, run a "fake ECS" task that drains the T3 receiver and either
|
||||
//! replies or drops the oneshot, and assert the client observes the right
|
||||
//! behaviour.
|
||||
//!
|
||||
//! Run with `cargo test -p simulator`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use simulator::client::SimulatorClient;
|
||||
use substrate::config::QuicConfig;
|
||||
use substrate::transport::server::{accept_loop, bind_endpoint};
|
||||
use substrate::transport::{QuicMessage, SensorType, T1Sender, T2Sender, T3Sender};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn cert_path(name: &str) -> PathBuf {
|
||||
[env!("CARGO_MANIFEST_DIR"), "..", "certs", name].iter().collect()
|
||||
}
|
||||
|
||||
fn loopback_config(cert: PathBuf, key: PathBuf) -> QuicConfig {
|
||||
QuicConfig {
|
||||
server_port: 0,
|
||||
server_interface: "127.0.0.1".to_string(),
|
||||
server_cert: cert.to_string_lossy().into_owned(),
|
||||
server_key: key.to_string_lossy().into_owned(),
|
||||
t1_capacity: 1024,
|
||||
t2_capacity: 512,
|
||||
t3_capacity: 256,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker `timestamp_us` the fake ECS stamps onto every ack so the test can
|
||||
/// distinguish a real reply from any echo of the command's own timestamp.
|
||||
const ACK_MARKER_TS: u64 = 999_999_999_999;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn t3_round_trip_with_fake_handler() -> Result<()> {
|
||||
simulator::install_crypto_provider();
|
||||
|
||||
let cert = cert_path("server.crt");
|
||||
let key = cert_path("server.key");
|
||||
let cfg = loopback_config(cert.clone(), key);
|
||||
|
||||
let endpoint = bind_endpoint(&cfg)?;
|
||||
let server_addr: SocketAddr = endpoint.local_addr()?;
|
||||
|
||||
let (t1_tx, _t1_rx) = mpsc::channel(64);
|
||||
let (t2_tx, _t2_rx) = mpsc::channel(64);
|
||||
let (t3_tx, mut t3_rx) = mpsc::channel(64);
|
||||
|
||||
let server_task = tokio::spawn(accept_loop(
|
||||
endpoint,
|
||||
T1Sender::new(t1_tx),
|
||||
T2Sender::new(t2_tx),
|
||||
T3Sender::new(t3_tx),
|
||||
));
|
||||
|
||||
// Fake ECS handler: drain T3 inbounds, mark the timestamp, send back.
|
||||
let handler = tokio::spawn(async move {
|
||||
while let Some(inbound) = t3_rx.recv().await {
|
||||
let mut ack = inbound.command;
|
||||
ack.timestamp_us = ACK_MARKER_TS;
|
||||
// Ignore send error (client may have disconnected before listening).
|
||||
let _ = inbound.reply.send(ack);
|
||||
}
|
||||
});
|
||||
|
||||
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
|
||||
|
||||
let cmd = QuicMessage {
|
||||
device_id: Uuid::from_u128(0xa5a5_a5a5_5a5a_5a5a_a5a5_5a5a_a5a5_5a5a),
|
||||
sensor_id: 3,
|
||||
raw_value: 1.5,
|
||||
timestamp_us: 1_700_000_000_000_000,
|
||||
sequence_number: 7,
|
||||
sensor_type: SensorType::Voltage.as_u8(),
|
||||
};
|
||||
|
||||
let ack = tokio::time::timeout(Duration::from_secs(2), client.request(&cmd))
|
||||
.await
|
||||
.expect("T3 ack timed out")?;
|
||||
|
||||
assert_eq!(ack.device_id, cmd.device_id, "ack should preserve device_id");
|
||||
assert_eq!(ack.sensor_id, cmd.sensor_id, "ack should preserve sensor_id");
|
||||
assert_eq!(
|
||||
ack.sequence_number, cmd.sequence_number,
|
||||
"ack should preserve sequence_number for correlation"
|
||||
);
|
||||
assert_eq!(ack.timestamp_us, ACK_MARKER_TS, "fake ECS should stamp the marker");
|
||||
|
||||
client.close().await;
|
||||
handler.abort();
|
||||
server_task.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn t3_no_handler_resets_stream() -> Result<()> {
|
||||
simulator::install_crypto_provider();
|
||||
|
||||
let cert = cert_path("server.crt");
|
||||
let key = cert_path("server.key");
|
||||
let cfg = loopback_config(cert.clone(), key);
|
||||
|
||||
let endpoint = bind_endpoint(&cfg)?;
|
||||
let server_addr: SocketAddr = endpoint.local_addr()?;
|
||||
|
||||
let (t1_tx, _t1_rx) = mpsc::channel(64);
|
||||
let (t2_tx, _t2_rx) = mpsc::channel(64);
|
||||
let (t3_tx, mut t3_rx) = mpsc::channel(64);
|
||||
|
||||
let server_task = tokio::spawn(accept_loop(
|
||||
endpoint,
|
||||
T1Sender::new(t1_tx),
|
||||
T2Sender::new(t2_tx),
|
||||
T3Sender::new(t3_tx),
|
||||
));
|
||||
|
||||
// Fake ECS that *drops* every oneshot — simulates "no handler installed",
|
||||
// which is the placeholder state in `ingest_system` until M4 lands.
|
||||
let handler = tokio::spawn(async move {
|
||||
while let Some(inbound) = t3_rx.recv().await {
|
||||
drop(inbound);
|
||||
}
|
||||
});
|
||||
|
||||
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
|
||||
|
||||
let cmd = QuicMessage {
|
||||
device_id: Uuid::new_v4(),
|
||||
sensor_id: 0,
|
||||
raw_value: 0.0,
|
||||
timestamp_us: 0,
|
||||
sequence_number: 0,
|
||||
sensor_type: SensorType::Generic.as_u8(),
|
||||
};
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(2), client.request(&cmd)).await;
|
||||
let inner = result.expect("client.request should not hang when stream is reset");
|
||||
assert!(
|
||||
inner.is_err(),
|
||||
"expected request to fail when substrate resets the stream, got Ok({:?})",
|
||||
inner.ok()
|
||||
);
|
||||
|
||||
client.close().await;
|
||||
handler.abort();
|
||||
server_task.abort();
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user