use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::PathBuf;
#[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();
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();
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;
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 workspace_root() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent()
.expect("no parent of crates/flutmax-cli")
.parent()
.expect("no grandparent")
.to_path_buf()
}
fn read_fixture(name: &str) -> String {
let path = workspace_root().join("tests/e2e/fixtures").join(name);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read fixture {}: {}", path.display(), e))
}
fn read_expected(name: &str) -> String {
let path = workspace_root().join("tests/e2e/expected").join(name);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read expected {}: {}", path.display(), e))
}
fn assert_graph_equivalence(fixture_name: &str, expected_name: &str) {
let source = read_fixture(fixture_name);
let generated_json = flutmax_cli::compile(&source)
.unwrap_or_else(|e| panic!("compilation of {} failed: {}", fixture_name, e));
let generated_graph = extract_logical_graph(&generated_json);
let expected_json = read_expected(expected_name);
let expected_graph = extract_logical_graph(&expected_json);
assert_eq!(
generated_graph.nodes, expected_graph.nodes,
"\nNode mismatch for {}.\n\nGenerated nodes:\n{:#?}\n\nExpected nodes:\n{:#?}",
fixture_name, generated_graph.nodes, expected_graph.nodes
);
assert_eq!(
generated_graph.edges, expected_graph.edges,
"\nEdge mismatch for {}.\n\nGenerated edges:\n{:#?}\n\nExpected edges:\n{:#?}",
fixture_name, generated_graph.edges, expected_graph.edges
);
}
#[test]
fn test_l1_sine_graph_equivalence() {
assert_graph_equivalence("L1_sine.flutmax", "L1_sine.maxpat");
}
#[test]
fn test_l2_simple_synth_graph_equivalence() {
assert_graph_equivalence("L2_simple_synth.flutmax", "L2_simple_synth.maxpat");
}
#[test]
fn test_l3_trigger_fanout_graph_equivalence() {
assert_graph_equivalence("L3_trigger_fanout.flutmax", "L3_trigger_fanout.maxpat");
}
#[test]
fn test_l3b_control_fanout_graph_equivalence() {
assert_graph_equivalence("L3b_control_fanout.flutmax", "L3b_control_fanout.maxpat");
}
fn read_abstraction_fixture(name: &str) -> String {
let path = workspace_root()
.join("tests/e2e/fixtures/abstraction")
.join(name);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read fixture {}: {}", path.display(), e))
}
#[test]
fn test_abstraction_directory_compile() {
use flutmax_sema::registry::AbstractionRegistry;
let osc_source = read_abstraction_fixture("oscillator.flutmax");
let fm_source = read_abstraction_fixture("fm_synth.flutmax");
let osc_ast = flutmax_parser::parse(&osc_source).expect("oscillator.flutmax should parse");
let fm_ast = flutmax_parser::parse(&fm_source).expect("fm_synth.flutmax should parse");
let mut registry = AbstractionRegistry::new();
registry.register("oscillator", &osc_ast);
registry.register("fm_synth", &fm_ast);
let osc_json = flutmax_cli::compile_with_registry(&osc_source, Some(®istry))
.expect("oscillator should compile");
let osc_parsed: serde_json::Value =
serde_json::from_str(&osc_json).expect("oscillator output should be valid JSON");
let osc_boxes = osc_parsed["patcher"]["boxes"].as_array().unwrap();
assert_eq!(osc_boxes.len(), 3, "oscillator should have 3 boxes");
let fm_json = flutmax_cli::compile_with_registry(&fm_source, Some(®istry))
.expect("fm_synth should compile");
let fm_parsed: serde_json::Value =
serde_json::from_str(&fm_json).expect("fm_synth output should be valid JSON");
let fm_boxes = fm_parsed["patcher"]["boxes"].as_array().unwrap();
assert_eq!(fm_boxes.len(), 4, "fm_synth should have 4 boxes");
let osc_box = fm_boxes
.iter()
.find(|b| {
b["box"]["text"]
.as_str()
.map(|t| t.starts_with("oscillator"))
.unwrap_or(false)
})
.expect("fm_synth should contain an oscillator box");
assert_eq!(
osc_box["box"]["numinlets"].as_u64().unwrap(),
1,
"oscillator box should have 1 inlet"
);
assert_eq!(
osc_box["box"]["numoutlets"].as_u64().unwrap(),
1,
"oscillator box should have 1 outlet"
);
let text = osc_box["box"]["text"].as_str().unwrap();
assert!(
text == "oscillator",
"oscillator box text should be 'oscillator', got '{}'",
text
);
}
#[test]
fn test_abstraction_signal_type_propagation() {
use flutmax_sema::registry::AbstractionRegistry;
let osc_source = read_abstraction_fixture("oscillator.flutmax");
let fm_source = read_abstraction_fixture("fm_synth.flutmax");
let osc_ast = flutmax_parser::parse(&osc_source).unwrap();
let fm_ast = flutmax_parser::parse(&fm_source).unwrap();
let mut registry = AbstractionRegistry::new();
registry.register("oscillator", &osc_ast);
registry.register("fm_synth", &fm_ast);
let fm_json = flutmax_cli::compile_with_registry(&fm_source, Some(®istry)).unwrap();
let fm_parsed: serde_json::Value = serde_json::from_str(&fm_json).unwrap();
let fm_boxes = fm_parsed["patcher"]["boxes"].as_array().unwrap();
let osc_box = fm_boxes
.iter()
.find(|b| {
b["box"]["text"]
.as_str()
.map(|t| t.starts_with("oscillator"))
.unwrap_or(false)
})
.expect("fm_synth should contain an oscillator box");
let outlettype = osc_box["box"]["outlettype"].as_array().unwrap();
assert_eq!(outlettype.len(), 1);
assert_eq!(
outlettype[0].as_str().unwrap(),
"signal",
"oscillator outlet should be signal type"
);
}
#[test]
fn test_compile_with_none_registry_same_as_compile() {
let source = read_fixture("L2_simple_synth.flutmax");
let result1 = flutmax_cli::compile(&source).unwrap();
let result2 = flutmax_cli::compile_with_registry(&source, None).unwrap();
let parsed1: serde_json::Value = serde_json::from_str(&result1).unwrap();
let parsed2: serde_json::Value = serde_json::from_str(&result2).unwrap();
assert_eq!(parsed1, parsed2);
}