use std::collections::{BTreeMap, BTreeSet, HashMap};
#[derive(Debug, PartialEq, Eq)]
struct LogicalGraph {
nodes: BTreeSet<LogicalNode>,
edges: BTreeSet<LogicalEdge>,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
struct LogicalNode {
maxclass: String,
text: String,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct LogicalEdge {
source_text: String,
source_outlet: u32,
dest_text: String,
dest_inlet: u32,
}
fn extract_logical_graph(maxpat_json: &str) -> LogicalGraph {
let root: serde_json::Value =
serde_json::from_str(maxpat_json).expect("failed to parse .maxpat JSON");
let patcher = &root["patcher"];
let boxes = patcher["boxes"].as_array().expect("missing boxes array");
let lines = patcher["lines"].as_array().expect("missing lines array");
let mut raw_texts: Vec<(String, String, String)> = Vec::new(); let mut text_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut comment_ids: BTreeSet<String> = BTreeSet::new();
for box_wrapper in boxes {
let b = &box_wrapper["box"];
let id = b["id"].as_str().expect("box missing id").to_string();
let maxclass = b["maxclass"]
.as_str()
.expect("box missing maxclass")
.to_string();
if maxclass == "comment" {
comment_ids.insert(id);
continue;
}
let raw_text = if maxclass == "newobj" {
b["text"].as_str().expect("newobj missing text").to_string()
} else {
match b.get("comment").and_then(|c| c.as_str()) {
Some(comment) if !comment.is_empty() => {
format!("{}:{}", maxclass, comment)
}
_ => maxclass.clone(),
}
};
*text_counts.entry(raw_text.clone()).or_insert(0) += 1;
raw_texts.push((id, maxclass, raw_text));
}
let mut id_to_node: HashMap<String, LogicalNode> = HashMap::new();
let mut dup_counters: HashMap<String, usize> = HashMap::new();
for (id, maxclass, raw_text) in &raw_texts {
let text = if text_counts[raw_text] > 1 {
let idx = dup_counters.entry(raw_text.clone()).or_insert(0);
let disambiguated = format!("{}#{}", raw_text, idx);
*idx += 1;
disambiguated
} else {
raw_text.clone()
};
id_to_node.insert(
id.clone(),
LogicalNode {
maxclass: maxclass.clone(),
text,
},
);
}
let nodes: BTreeSet<LogicalNode> = id_to_node.values().cloned().collect();
let mut edges: BTreeSet<LogicalEdge> = BTreeSet::new();
for line_wrapper in lines {
let patchline = &line_wrapper["patchline"];
let source = patchline["source"]
.as_array()
.expect("patchline missing source");
let dest = patchline["destination"]
.as_array()
.expect("patchline missing destination");
let source_id = source[0].as_str().expect("source id not a string");
let source_outlet = source[1].as_u64().expect("source outlet not a number") as u32;
let dest_id = dest[0].as_str().expect("dest id not a string");
let dest_inlet = dest[1].as_u64().expect("dest inlet not a number") as u32;
if comment_ids.contains(source_id) || comment_ids.contains(dest_id) {
continue;
}
let source_node = id_to_node
.get(source_id)
.unwrap_or_else(|| panic!("unknown source id: {}", source_id));
let dest_node = id_to_node
.get(dest_id)
.unwrap_or_else(|| panic!("unknown dest id: {}", dest_id));
edges.insert(LogicalEdge {
source_text: source_node.text.clone(),
source_outlet,
dest_text: dest_node.text.clone(),
dest_inlet,
});
}
LogicalGraph { nodes, edges }
}
fn assert_roundtrip(original_maxpat: &str, label: &str) {
let flutmax_source = flutmax_decompile::decompile(original_maxpat)
.unwrap_or_else(|e| panic!("decompile failed for {}: {}", label, e));
assert!(
!flutmax_source.trim().is_empty(),
"decompiled .flutmax source is empty for {}",
label
);
let regenerated_maxpat = flutmax_cli::compile(&flutmax_source)
.unwrap_or_else(|e| panic!("compile failed for {} (roundtrip): {}", label, e));
let orig_graph = extract_logical_graph(original_maxpat);
let regen_graph = extract_logical_graph(®enerated_maxpat);
assert_eq!(
orig_graph.nodes, regen_graph.nodes,
"\nRoundtrip node mismatch for {}.\n\nOriginal nodes:\n{:#?}\n\nRegenerated nodes:\n{:#?}",
label, orig_graph.nodes, regen_graph.nodes
);
assert_eq!(
orig_graph.edges, regen_graph.edges,
"\nRoundtrip edge mismatch for {}.\n\nOriginal edges:\n{:#?}\n\nRegenerated edges:\n{:#?}",
label, orig_graph.edges, regen_graph.edges
);
}
#[test]
fn roundtrip_l1_sine() {
let original = include_str!("../../../tests/e2e/expected/L1_sine.maxpat");
assert_roundtrip(original, "L1_sine");
}
#[test]
fn roundtrip_l2_simple_synth() {
let original = include_str!("../../../tests/e2e/expected/L2_simple_synth.maxpat");
assert_roundtrip(original, "L2_simple_synth");
}
#[test]
fn roundtrip_l3_trigger_fanout() {
let original = include_str!("../../../tests/e2e/expected/L3_trigger_fanout.maxpat");
assert_roundtrip(original, "L3_trigger_fanout");
}
#[test]
fn roundtrip_l3b_control_fanout() {
let original = include_str!("../../../tests/e2e/expected/L3b_control_fanout.maxpat");
assert_roundtrip(original, "L3b_control_fanout");
}