Implemented DebugService with TCPLogger injection
This commit is contained in:
+508
-21
@@ -155,12 +155,38 @@ struct PlotInstance {
|
||||
auto_bounds: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum MsgStatus {
|
||||
Unknown,
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MessageHistoryEntry {
|
||||
time: String,
|
||||
destination: String,
|
||||
function: String,
|
||||
payload: String,
|
||||
wait_reply: bool,
|
||||
raw_cmd: String,
|
||||
response: String,
|
||||
status: MsgStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum MainTab {
|
||||
Plots,
|
||||
Config,
|
||||
}
|
||||
|
||||
enum InternalEvent {
|
||||
Log(LogEntry),
|
||||
Discovery(Vec<Signal>),
|
||||
Tree(TreeItem),
|
||||
CommandResponse(String),
|
||||
NodeInfo(String),
|
||||
ConfigResponse(String),
|
||||
Connected,
|
||||
Disconnected,
|
||||
InternalLog(String),
|
||||
@@ -170,7 +196,7 @@ enum InternalEvent {
|
||||
UdpDropped(u32),
|
||||
RecordPathChosen(String, String), // SignalName, FilePath
|
||||
RecordingError(String, String), // SignalName, ErrorMessage
|
||||
TelemMatched(u32), // Signal ID
|
||||
TelemMatched(u32),
|
||||
ServiceConfig { udp_port: String, log_port: String },
|
||||
}
|
||||
|
||||
@@ -236,7 +262,6 @@ struct MarteDebugApp {
|
||||
node_info: String,
|
||||
udp_packets: u64,
|
||||
udp_dropped: u64,
|
||||
telem_match_count: HashMap<u32, u64>,
|
||||
forcing_dialog: Option<ForcingDialog>,
|
||||
monitoring_dialog: Option<MonitorDialog>,
|
||||
message_dialog: Option<MessageDialog>,
|
||||
@@ -246,6 +271,11 @@ struct MarteDebugApp {
|
||||
internal_tx: Sender<InternalEvent>,
|
||||
shared_x_range: Option<[f64; 2]>,
|
||||
scope: ScopeSettings,
|
||||
active_main_tab: MainTab,
|
||||
app_config_text: String,
|
||||
message_history: Vec<MessageHistoryEntry>,
|
||||
show_message_history: bool,
|
||||
pending_msg_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl MarteDebugApp {
|
||||
@@ -317,7 +347,6 @@ impl MarteDebugApp {
|
||||
node_info: "".to_string(),
|
||||
udp_packets: 0,
|
||||
udp_dropped: 0,
|
||||
telem_match_count: HashMap::new(),
|
||||
forcing_dialog: None,
|
||||
monitoring_dialog: None,
|
||||
message_dialog: None,
|
||||
@@ -340,6 +369,11 @@ impl MarteDebugApp {
|
||||
last_trigger_time: 0.0,
|
||||
is_armed: true,
|
||||
},
|
||||
active_main_tab: MainTab::Plots,
|
||||
app_config_text: String::new(),
|
||||
message_history: Vec::new(),
|
||||
show_message_history: true,
|
||||
pending_msg_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +579,76 @@ impl MarteDebugApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit the body of a JSON object as MARTe2 config lines at the given indent level.
|
||||
/// `Class` is always emitted first; child objects with a `Class` key get `+` prefix.
|
||||
fn json_node_to_marte(map: &serde_json::Map<String, serde_json::Value>, indent: usize) -> String {
|
||||
let pad = " ".repeat(indent);
|
||||
let mut out = String::new();
|
||||
|
||||
// Class first
|
||||
if let Some(serde_json::Value::String(cls)) = map.get("Class") {
|
||||
out.push_str(&format!("{}Class = {}\n", pad, cls));
|
||||
}
|
||||
|
||||
// Collect and sort remaining keys so output is deterministic
|
||||
let mut keys: Vec<&String> = map.keys().filter(|k| k.as_str() != "Class").collect();
|
||||
keys.sort();
|
||||
|
||||
for key in keys {
|
||||
let val = &map[key];
|
||||
match val {
|
||||
serde_json::Value::Object(child_map) => {
|
||||
let prefix = if child_map.contains_key("Class") { "+" } else { "" };
|
||||
out.push_str(&format!("{}{}{} = {{\n", pad, prefix, key));
|
||||
out.push_str(&json_node_to_marte(child_map, indent + 1));
|
||||
out.push_str(&format!("{}}}\n", pad));
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
out.push_str(&format!("{}{} = {}\n", pad, key, s));
|
||||
}
|
||||
serde_json::Value::Null => {
|
||||
out.push_str(&format!("{}{} =\n", pad, key));
|
||||
}
|
||||
other => {
|
||||
out.push_str(&format!("{}{} = {}\n", pad, key, other));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn convert_config_json(json_text: &str) -> String {
|
||||
let root = match serde_json::from_str::<serde_json::Value>(json_text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return json_text.to_string(),
|
||||
};
|
||||
let map = match root.as_object() {
|
||||
Some(m) => m,
|
||||
None => return json_text.to_string(),
|
||||
};
|
||||
let mut out = String::new();
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
let val = &map[key];
|
||||
match val {
|
||||
serde_json::Value::Object(child_map) => {
|
||||
// Top-level nodes always get + (they are named MARTe2 objects)
|
||||
out.push_str(&format!("+{} = {{\n", key));
|
||||
out.push_str(&json_node_to_marte(child_map, 1));
|
||||
out.push_str("}\n\n");
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
out.push_str(&format!("{} = {}\n", key, s));
|
||||
}
|
||||
other => {
|
||||
out.push_str(&format!("{} = {}\n", key, other));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tcp_command_worker(
|
||||
shared_config: Arc<Mutex<ConnectionConfig>>,
|
||||
rx_cmd: Receiver<String>,
|
||||
@@ -630,6 +734,12 @@ fn tcp_command_worker(
|
||||
let _ = tx_events_inner
|
||||
.send(InternalEvent::NodeInfo(json_clean.to_string()));
|
||||
json_acc.clear();
|
||||
} else if trimmed.contains("OK CONFIG") {
|
||||
in_json = false;
|
||||
let text = json_acc.split("OK CONFIG").next().unwrap_or("").trim();
|
||||
let _ = tx_events_inner
|
||||
.send(InternalEvent::ConfigResponse(text.to_string()));
|
||||
json_acc.clear();
|
||||
}
|
||||
} else {
|
||||
if trimmed.starts_with("OK SERVICE_INFO") {
|
||||
@@ -882,6 +992,23 @@ fn udp_worker(
|
||||
last_warning_time = std::time::Instant::now();
|
||||
}
|
||||
|
||||
// Resolve the base timestamp once per packet, not per signal.
|
||||
// BASE_TELEM_TS is a global lazy Mutex; locking it inside the
|
||||
// inner loop at 1 kHz × N_signals/packet was a major bottleneck.
|
||||
let packet_base_ts: Option<u64> = {
|
||||
let mut guard = BASE_TELEM_TS.lock().unwrap();
|
||||
if guard.is_none() {
|
||||
// Peek at the first signal's timestamp to initialise base
|
||||
if n >= 20 + 12 {
|
||||
let first_ts = u64::from_le_bytes(
|
||||
buf[20 + 4..20 + 12].try_into().unwrap(),
|
||||
);
|
||||
*guard = Some(first_ts);
|
||||
}
|
||||
}
|
||||
*guard
|
||||
};
|
||||
|
||||
for _ in 0..count {
|
||||
if offset + 16 > n {
|
||||
break;
|
||||
@@ -898,21 +1025,15 @@ fn udp_worker(
|
||||
}
|
||||
let data_slice = &buf[offset..offset + size as usize];
|
||||
|
||||
let mut base_ts_guard = BASE_TELEM_TS.lock().unwrap();
|
||||
if base_ts_guard.is_none() {
|
||||
*base_ts_guard = Some(ts_raw);
|
||||
}
|
||||
|
||||
let base = base_ts_guard.unwrap();
|
||||
let ts_s = if ts_raw >= base {
|
||||
(ts_raw - base) as f64 / 1000000.0
|
||||
let ts_s = if let Some(base) = packet_base_ts {
|
||||
if ts_raw >= base { (ts_raw - base) as f64 / 1_000_000.0 } else { 0.0 }
|
||||
} else {
|
||||
0.0 // Avoid huge jitter wrap-around
|
||||
0.0
|
||||
};
|
||||
drop(base_ts_guard);
|
||||
|
||||
if let Some(meta) = metas.get(&id) {
|
||||
let _ = tx_events.send(InternalEvent::TelemMatched(id));
|
||||
// Do NOT send a channel event per signal — at 1kHz×N this
|
||||
// floods the event queue and causes the UI spiral slowdown.
|
||||
let t = meta.sig_type.as_str();
|
||||
let type_size = if meta.elements > 0 { size / meta.elements } else { size };
|
||||
|
||||
@@ -1110,15 +1231,30 @@ impl eframe::App for MarteDebugApp {
|
||||
});
|
||||
}
|
||||
InternalEvent::CommandResponse(resp) => {
|
||||
// Resolve pending MSG history entry
|
||||
if let Some(idx) = self.pending_msg_idx {
|
||||
if resp.contains("MSG") {
|
||||
if let Some(entry) = self.message_history.get_mut(idx) {
|
||||
entry.response = resp.clone();
|
||||
entry.status = if resp.starts_with("OK") {
|
||||
MsgStatus::Success
|
||||
} else {
|
||||
MsgStatus::Failure
|
||||
};
|
||||
}
|
||||
self.pending_msg_idx = None;
|
||||
}
|
||||
}
|
||||
self.logs.push_back(LogEntry {
|
||||
time: Local::now().format("%H:%M:%S").to_string(),
|
||||
level: "CMD_RESP".to_string(),
|
||||
message: resp,
|
||||
});
|
||||
}
|
||||
InternalEvent::TelemMatched(id) => {
|
||||
*self.telem_match_count.entry(id).or_insert(0) += 1;
|
||||
InternalEvent::ConfigResponse(text) => {
|
||||
self.app_config_text = convert_config_json(&text);
|
||||
}
|
||||
InternalEvent::TelemMatched(_) => {}
|
||||
InternalEvent::RecordPathChosen(name, path) => {
|
||||
let mut data_map = self.traced_signals.lock().unwrap();
|
||||
if let Some(entry) = data_map.get_mut(&name) {
|
||||
@@ -1275,16 +1411,23 @@ impl eframe::App for MarteDebugApp {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("🚀 Send").clicked() {
|
||||
let wait = if dialog.expect_reply { "1" } else { "0" };
|
||||
// Replace actual newlines with literal '\n' for the server-side tokenizer if needed,
|
||||
// or ensure the server handles the raw multi-line stream if the protocol allows it.
|
||||
// Given HandleCommand reads line-by-line, we must send it carefully.
|
||||
// Actually, our Server loop reads up to \n.
|
||||
// So we should encode newlines in payload if we want to send them in one go.
|
||||
let encoded_payload = dialog.payload.replace('\n', "\\n");
|
||||
let cmd = format!(
|
||||
"MSG {} {} {} {}",
|
||||
dialog.destination, dialog.function, wait, encoded_payload
|
||||
);
|
||||
let idx = self.message_history.len();
|
||||
self.message_history.push(MessageHistoryEntry {
|
||||
time: Local::now().format("%H:%M:%S").to_string(),
|
||||
destination: dialog.destination.clone(),
|
||||
function: dialog.function.clone(),
|
||||
payload: dialog.payload.clone(),
|
||||
wait_reply: dialog.expect_reply,
|
||||
raw_cmd: cmd.clone(),
|
||||
response: String::new(),
|
||||
status: MsgStatus::Unknown,
|
||||
});
|
||||
self.pending_msg_idx = Some(idx);
|
||||
let _ = self.tx_cmd.send(cmd);
|
||||
close = true;
|
||||
}
|
||||
@@ -1346,6 +1489,7 @@ impl eframe::App for MarteDebugApp {
|
||||
ui.horizontal(|ui| {
|
||||
ui.toggle_value(&mut self.show_left_panel, "🗂 Tree");
|
||||
ui.toggle_value(&mut self.show_right_panel, "📊 Signals");
|
||||
ui.toggle_value(&mut self.show_message_history, "💬 Msgs");
|
||||
ui.toggle_value(&mut self.show_bottom_panel, "📜 Logs");
|
||||
ui.separator();
|
||||
if ui.button("➕ Plot").clicked() {
|
||||
@@ -1629,6 +1773,85 @@ impl eframe::App for MarteDebugApp {
|
||||
for key in to_delete.iter() {
|
||||
self.forced_signals.remove(key);
|
||||
}
|
||||
|
||||
if self.show_message_history {
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Message History");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("🗑 Clear").clicked() {
|
||||
self.message_history.clear();
|
||||
self.pending_msg_idx = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
if self.message_history.is_empty() {
|
||||
ui.label(egui::RichText::new("No messages sent yet").italics().color(egui::Color32::GRAY));
|
||||
} else {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("msg_history_scroll")
|
||||
.max_height(300.0)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
let mut resend_cmd: Option<String> = None;
|
||||
let mut edit_dialog: Option<MessageDialog> = None;
|
||||
for entry in self.message_history.iter().rev() {
|
||||
let (status_icon, status_color) = match entry.status {
|
||||
MsgStatus::Success => ("✔", egui::Color32::from_rgb(100, 220, 100)),
|
||||
MsgStatus::Failure => ("✘", egui::Color32::from_rgb(255, 100, 100)),
|
||||
MsgStatus::Unknown => ("…", egui::Color32::from_rgb(255, 220, 50)),
|
||||
};
|
||||
ui.group(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(status_icon).color(status_color).strong());
|
||||
ui.label(egui::RichText::new(&entry.time).color(egui::Color32::GRAY).monospace().small());
|
||||
});
|
||||
ui.label(format!("→ {}.{}", entry.destination, entry.function));
|
||||
if !entry.payload.is_empty() {
|
||||
ui.label(egui::RichText::new(&entry.payload).monospace().small().color(egui::Color32::from_rgb(180, 180, 255)));
|
||||
}
|
||||
if !entry.response.is_empty() {
|
||||
ui.label(egui::RichText::new(&entry.response).small().color(status_color));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("↩ Resend").clicked() {
|
||||
resend_cmd = Some(entry.raw_cmd.clone());
|
||||
}
|
||||
if ui.small_button("✏ Edit").clicked() {
|
||||
edit_dialog = Some(MessageDialog {
|
||||
destination: entry.destination.clone(),
|
||||
function: entry.function.clone(),
|
||||
payload: entry.payload.clone(),
|
||||
expect_reply: entry.wait_reply,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(cmd) = resend_cmd {
|
||||
let idx = self.message_history.len();
|
||||
// find the matching history entry to clone metadata
|
||||
if let Some(orig) = self.message_history.iter().find(|e| e.raw_cmd == cmd) {
|
||||
self.message_history.push(MessageHistoryEntry {
|
||||
time: Local::now().format("%H:%M:%S").to_string(),
|
||||
destination: orig.destination.clone(),
|
||||
function: orig.function.clone(),
|
||||
payload: orig.payload.clone(),
|
||||
wait_reply: orig.wait_reply,
|
||||
raw_cmd: cmd.clone(),
|
||||
response: String::new(),
|
||||
status: MsgStatus::Unknown,
|
||||
});
|
||||
}
|
||||
self.pending_msg_idx = Some(idx);
|
||||
let _ = self.tx_cmd.send(cmd);
|
||||
}
|
||||
if let Some(dialog) = edit_dialog {
|
||||
self.message_dialog = Some(dialog);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1712,6 +1935,35 @@ impl eframe::App for MarteDebugApp {
|
||||
}
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut self.active_main_tab, MainTab::Plots, "📈 Plots");
|
||||
ui.selectable_value(&mut self.active_main_tab, MainTab::Config, "⚙ Config");
|
||||
});
|
||||
ui.separator();
|
||||
if self.active_main_tab == MainTab::Config {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("🔄 Refresh").clicked() {
|
||||
let _ = self.tx_cmd.send("CONFIG".to_string());
|
||||
}
|
||||
ui.label(egui::RichText::new("Application Configuration").color(egui::Color32::GRAY));
|
||||
});
|
||||
ui.separator();
|
||||
egui::ScrollArea::both()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if self.app_config_text.is_empty() {
|
||||
ui.label(egui::RichText::new("Press Refresh to load the configuration").italics().color(egui::Color32::GRAY));
|
||||
} else {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&self.app_config_text).monospace(),
|
||||
)
|
||||
.selectable(true),
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let n_plots = self.plots.len();
|
||||
if n_plots > 0 {
|
||||
let plot_height = ui.available_height() / n_plots as f32;
|
||||
@@ -1909,6 +2161,241 @@ impl eframe::App for MarteDebugApp {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Parse MARTe2 config text into a serde_json Value tree so tests can do
|
||||
/// structural comparisons without caring about whitespace or key ordering.
|
||||
///
|
||||
/// Rules:
|
||||
/// `[+]Name = {` → start of a named block
|
||||
/// `}` → close current block
|
||||
/// `Name = Value` → leaf key-value (value stripped of surrounding quotes)
|
||||
/// `Name = {value}` (e.g. `Functions = {GAM1}`) → leaf, not a block
|
||||
fn parse_marte_config(text: &str) -> serde_json::Value {
|
||||
let mut stack: Vec<serde_json::Map<String, serde_json::Value>> =
|
||||
vec![serde_json::Map::new()];
|
||||
let mut name_stack: Vec<String> = vec![];
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("/*") {
|
||||
continue;
|
||||
}
|
||||
if trimmed == "}" {
|
||||
if stack.len() > 1 {
|
||||
let child = stack.pop().unwrap();
|
||||
let name = name_stack.pop().unwrap();
|
||||
stack.last_mut().unwrap().insert(name, serde_json::Value::Object(child));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let clean = trimmed.trim_start_matches('+');
|
||||
if let Some(eq_pos) = clean.find('=') {
|
||||
let key = clean[..eq_pos].trim().to_string();
|
||||
let val = clean[eq_pos + 1..].trim();
|
||||
// A block opens only if the value is exactly `{`
|
||||
if val == "{" {
|
||||
stack.push(serde_json::Map::new());
|
||||
name_stack.push(key);
|
||||
} else {
|
||||
let val_clean = val.trim_matches('"').to_string();
|
||||
stack.last_mut().unwrap().insert(key, serde_json::Value::String(val_clean));
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(stack.remove(0))
|
||||
}
|
||||
|
||||
/// Recursively assert that every object key with a `Class` value in `expected`
|
||||
/// also exists with the same `Class` in `actual`. Missing non-Class keys are
|
||||
/// tolerated because `ExportData()` does not re-emit config-file-only fields
|
||||
/// (e.g. signal configurations, Frequency, Samples).
|
||||
fn assert_classes_match(
|
||||
expected: &serde_json::Value,
|
||||
actual: &serde_json::Value,
|
||||
path: &str,
|
||||
) {
|
||||
use serde_json::Value;
|
||||
if let (Value::Object(exp_map), Value::Object(act_map)) = (expected, actual) {
|
||||
if let Some(Value::String(exp_class)) = exp_map.get("Class") {
|
||||
let act_class = act_map
|
||||
.get("Class")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<missing>");
|
||||
assert_eq!(
|
||||
exp_class.as_str(),
|
||||
act_class,
|
||||
"Class mismatch at '{}': expected '{}', got '{}'",
|
||||
path,
|
||||
exp_class,
|
||||
act_class
|
||||
);
|
||||
}
|
||||
for (key, exp_child) in exp_map {
|
||||
if key == "Class" {
|
||||
continue;
|
||||
}
|
||||
if let Some(act_child) = act_map.get(key) {
|
||||
let child_path = if path.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}.{}", path, key)
|
||||
};
|
||||
assert_classes_match(exp_child, act_child, &child_path);
|
||||
}
|
||||
// Extra keys added by MARTe2 runtime are allowed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Unit tests for the JSON → MARTe2 converter -----
|
||||
|
||||
#[test]
|
||||
fn test_convert_basic_structure() {
|
||||
let json = r#"{"App":{"Class":"RealTimeApplication","Functions":{"Class":"ReferenceContainer","GAM1":{"Class":"IOGAM"}}}}"#;
|
||||
let out = convert_config_json(json);
|
||||
let parsed = parse_marte_config(&out);
|
||||
|
||||
assert_eq!(
|
||||
parsed["App"]["Class"],
|
||||
serde_json::Value::String("RealTimeApplication".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["App"]["Functions"]["Class"],
|
||||
serde_json::Value::String("ReferenceContainer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["App"]["Functions"]["GAM1"]["Class"],
|
||||
serde_json::Value::String("IOGAM".into())
|
||||
);
|
||||
// Objects with Class must get + prefix
|
||||
assert!(out.contains("+App = {"), "top-level object missing +");
|
||||
assert!(out.contains(" +Functions = {"), "nested object with Class missing +");
|
||||
assert!(out.contains(" +GAM1 = {"), "leaf object with Class missing +");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_plus_for_blocks_without_class() {
|
||||
let json = r#"{"App":{"Class":"RealTimeApplication","Signals":{"Counter":{"Type":"uint32"}}}}"#;
|
||||
let out = convert_config_json(json);
|
||||
// Signals block has no Class → no + prefix
|
||||
assert!(out.contains(" Signals = {"), "plain block should not have +");
|
||||
assert!(!out.contains(" +Signals"), "plain block must not have + prefix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_class_emitted_first() {
|
||||
let json = r#"{"App":{"ZZZKey":"last","Class":"RealTimeApplication","AAA":"first_alpha"}}"#;
|
||||
let out = convert_config_json(json);
|
||||
let class_pos = out.find("Class = RealTimeApplication").unwrap();
|
||||
let zzz_pos = out.find("ZZZKey = last").unwrap();
|
||||
assert!(
|
||||
class_pos < zzz_pos,
|
||||
"Class must appear before other keys in the block"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leaf_values_preserved() {
|
||||
let json = r#"{"App":{"Class":"RealTimeApplication","ControlPort":"8080","StreamIP":"127.0.0.1"}}"#;
|
||||
let out = convert_config_json(json);
|
||||
assert!(out.contains("ControlPort = 8080"));
|
||||
assert!(out.contains("StreamIP = 127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_parse() {
|
||||
// Build a JSON that mirrors what the server produces for debug_test.cfg
|
||||
let json = r#"{
|
||||
"App": {
|
||||
"Class": "RealTimeApplication",
|
||||
"Data": {"Class": "ReferenceContainer",
|
||||
"DDB": {"Class": "GAMDataSource"},
|
||||
"Timer": {"Class": "LinuxTimer"}
|
||||
},
|
||||
"Functions": {"Class": "ReferenceContainer",
|
||||
"GAM1": {"Class": "IOGAM"},
|
||||
"GAM2": {"Class": "IOGAM"}
|
||||
},
|
||||
"Scheduler": {"Class": "GAMScheduler", "TimingDataSource": "DAMS"},
|
||||
"States": {"Class": "ReferenceContainer",
|
||||
"State1": {"Class": "RealTimeState"}
|
||||
}
|
||||
},
|
||||
"DebugService": {
|
||||
"Class": "DebugService",
|
||||
"ControlPort": "8080",
|
||||
"UdpPort": "8081"
|
||||
}
|
||||
}"#;
|
||||
let out = convert_config_json(json);
|
||||
let cfg_path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../Test/Configurations/debug_test.cfg"
|
||||
);
|
||||
let original = std::fs::read_to_string(cfg_path).expect("debug_test.cfg not found");
|
||||
|
||||
// Parse both into trees and check that all Class values from the
|
||||
// converted output are consistent with the original config
|
||||
let converted_tree = parse_marte_config(&out);
|
||||
let original_tree = parse_marte_config(&original);
|
||||
assert_classes_match(&converted_tree, &original_tree, "");
|
||||
// And vice-versa for the subset present in the JSON fixture
|
||||
assert_classes_match(&original_tree, &converted_tree, "");
|
||||
}
|
||||
|
||||
// ----- Integration test (requires running MARTe2 app) -----
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires running MARTe2 debug app — start with ./run_debug_app.sh first"]
|
||||
fn test_live_config_matches_debug_test_cfg() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
let mut stream = TcpStream::connect("127.0.0.1:8080")
|
||||
.expect("Could not connect to DebugService on 127.0.0.1:8080");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(15)))
|
||||
.unwrap();
|
||||
stream.write_all(b"CONFIG\n").unwrap();
|
||||
|
||||
// Accumulate lines until the "OK CONFIG" sentinel
|
||||
let reader = BufReader::new(stream);
|
||||
let mut json_lines: Vec<String> = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line = line.expect("read error while receiving CONFIG response");
|
||||
if line.trim() == "OK CONFIG" {
|
||||
break;
|
||||
}
|
||||
json_lines.push(line);
|
||||
}
|
||||
let json_text = json_lines.join("\n");
|
||||
assert!(!json_text.is_empty(), "Received empty CONFIG response from server");
|
||||
|
||||
// Convert server JSON to MARTe2 syntax
|
||||
let converted = convert_config_json(&json_text);
|
||||
assert!(!converted.is_empty(), "convert_config_json produced empty output");
|
||||
|
||||
// Load the reference config
|
||||
let cfg_path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../Test/Configurations/debug_test.cfg"
|
||||
);
|
||||
let original = std::fs::read_to_string(cfg_path).expect("debug_test.cfg not found");
|
||||
|
||||
let expected_tree = parse_marte_config(&original);
|
||||
let actual_tree = parse_marte_config(&converted);
|
||||
|
||||
// Every named object present in the original config must appear in the live
|
||||
// config with the same Class. Config-file-only fields (InputSignals,
|
||||
// OutputSignals, Frequency, etc.) are intentionally not checked because
|
||||
// ExportData() does not re-emit them after ConfigureApplication().
|
||||
assert_classes_match(&expected_tree, &actual_tree, "");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), eframe::Error> {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]),
|
||||
|
||||
Reference in New Issue
Block a user