flutmax-cli 0.1.2

CLI entry point for the flutmax transpiler
Documentation
//! Compile-and-validate integration tests.
//!
//! These tests compile .flutmax source files through the full pipeline
//! and validate the resulting .maxpat JSON using flutmax-validate.

use std::path::PathBuf;

/// Return the workspace root directory.
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()
}

/// Read a fixture .flutmax file from the e2e fixtures directory.
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))
}

/// Read a pattern .flutmax file from the patterns subdirectory.
fn read_pattern(name: &str) -> String {
    let path = workspace_root()
        .join("tests/e2e/fixtures/patterns")
        .join(name);
    std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("failed to read pattern {}: {}", path.display(), e))
}

/// Compile a .flutmax source string and validate the output.
///
/// The validator has a known false-positive: signal objects connected to
/// outlet/inlet boxes (maxclass "outlet"/"inlet") are flagged as
/// signal-to-control mismatches because these boxes don't have a
/// text-based object_name and default to is_signal=false. We filter
/// these out since flutmax-generated outlet connections are correct.
fn compile_and_validate(source: &str, label: &str) {
    // Step 1: Compile
    let json = flutmax_cli::compile(source)
        .unwrap_or_else(|e| panic!("compilation failed for {}: {}", label, e));

    // Step 2: Validate JSON structure
    let report = flutmax_validate::validate_str(&json, &format!("{}.maxpat", label));

    // Filter out known false positives: signal→outlet connections.
    // These are errors where the dest box ID starts with "obj-" and the dest
    // name in the message also starts with "obj-" (i.e., it's a non-newobj box
    // like outlet/inlet that lacks a text-based object name).
    let real_errors: Vec<_> = report
        .errors
        .iter()
        .filter(|e| e.severity == flutmax_validate::Severity::Error)
        .filter(|e| {
            // Keep errors that are NOT the known signal→outlet false positive.
            // The false positive pattern: "Signal outlet from 'X' connected to control inlet of 'obj-N'"
            // where 'obj-N' is an outlet/inlet box.
            if e.layer == "static" && e.message.contains("Signal outlet") {
                // If the destination is an outlet/inlet box (identified by obj-N pattern
                // in the "control inlet of '...'" part), skip it.
                if let Some(dest_start) = e.message.rfind("of '") {
                    let dest_name = &e.message[dest_start + 4..e.message.len() - 1];
                    if dest_name.starts_with("obj-") {
                        return false; // Known false positive
                    }
                }
            }
            true
        })
        .collect();

    assert!(
        real_errors.is_empty(),
        "Validation errors for {}:\n{}",
        label,
        report
    );

    // Step 3: Additional structural checks
    let parsed: serde_json::Value =
        serde_json::from_str(&json).expect("output should be valid JSON");
    assert!(
        parsed.get("patcher").is_some(),
        "missing 'patcher' key for {}",
        label
    );
    let boxes = parsed["patcher"]["boxes"]
        .as_array()
        .expect("missing boxes array");
    assert!(!boxes.is_empty(), "boxes should not be empty for {}", label);
}

// ─── Existing fixtures ───

#[test]
fn compile_validate_l1_sine() {
    compile_and_validate(&read_fixture("L1_sine.flutmax"), "L1_sine");
}

#[test]
fn compile_validate_l2_simple_synth() {
    compile_and_validate(&read_fixture("L2_simple_synth.flutmax"), "L2_simple_synth");
}

#[test]
fn compile_validate_l3_trigger_fanout() {
    compile_and_validate(
        &read_fixture("L3_trigger_fanout.flutmax"),
        "L3_trigger_fanout",
    );
}

#[test]
fn compile_validate_l3b_control_fanout() {
    compile_and_validate(
        &read_fixture("L3b_control_fanout.flutmax"),
        "L3b_control_fanout",
    );
}

// ─── Pattern fixtures ───

#[test]
fn compile_validate_pattern_fm_synth() {
    compile_and_validate(
        &read_pattern("pattern_fm_synth.flutmax"),
        "pattern_fm_synth",
    );
}

#[test]
fn compile_validate_pattern_stereo_mixer() {
    compile_and_validate(
        &read_pattern("pattern_stereo_mixer.flutmax"),
        "pattern_stereo_mixer",
    );
}

#[test]
fn compile_validate_pattern_subtractive_synth() {
    compile_and_validate(
        &read_pattern("pattern_subtractive_synth.flutmax"),
        "pattern_subtractive_synth",
    );
}

#[test]
fn compile_validate_pattern_multi_osc() {
    compile_and_validate(
        &read_pattern("pattern_multi_osc.flutmax"),
        "pattern_multi_osc",
    );
}

// ─── Inline source tests ───

#[test]
fn compile_validate_minimal_sine() {
    let source = "out 0 (audio): signal;\nwire osc = cycle~(440);\nout[0] = osc;";
    compile_and_validate(source, "minimal_sine");
}

#[test]
fn compile_validate_control_only() {
    let source = r#"
in 0 (val): float;
out 0 (result): float;
wire doubled = mul(val, 2);
out[0] = doubled;
"#;
    compile_and_validate(source, "control_only");
}

#[test]
fn compile_validate_signal_chain() {
    let source = r#"
in 0 (freq): float;
out 0 (audio): signal;
wire osc = cycle~(freq);
wire filtered = biquad~(osc, 1.0, 0.0, 0.0, 0.0, 0.0);
wire amp = mul~(filtered, 0.3);
out[0] = amp;
"#;
    compile_and_validate(source, "signal_chain");
}

#[test]
fn compile_validate_multi_outlet() {
    let source = r#"
in 0 (freq): float;
out 0 (left): signal;
out 1 (right): signal;
wire osc = cycle~(freq);
out[0] = osc;
out[1] = osc;
"#;
    compile_and_validate(source, "multi_outlet");
}

#[test]
fn compile_validate_many_wires() {
    let mut source = String::new();
    source.push_str("out 0 (audio): signal;\n");
    source.push_str("wire w0 = cycle~(440);\n");
    for i in 1..50 {
        source.push_str(&format!("wire w{} = mul~(w{}, 0.99);\n", i, i - 1));
    }
    source.push_str("out[0] = w49;\n");
    compile_and_validate(&source, "many_wires");
}

#[test]
fn compile_validate_all_arithmetic_operators() {
    let source = r#"
in 0 (a): float;
in 1 (b): float;
out 0 (result): float;
wire sum = add(a, b);
wire diff = sub(a, b);
wire product = mul(a, b);
wire quotient = dvd(a, b);
wire remainder = mod(a, b);
out[0] = sum;
"#;
    compile_and_validate(source, "all_arithmetic");
}

#[test]
fn compile_validate_all_signal_operators() {
    let source = r#"
in 0 (a): signal;
in 1 (b): signal;
out 0 (result): signal;
wire sum = add~(a, b);
wire diff = sub~(a, b);
wire product = mul~(a, b);
wire quotient = dvd~(a, b);
out[0] = sum;
"#;
    compile_and_validate(source, "all_signal_operators");
}

#[test]
fn compile_validate_nested_calls() {
    let source = r#"
out 0 (audio): signal;
wire sig = biquad~(cycle~(440), 1000, 0.7);
out[0] = sig;
"#;
    compile_and_validate(source, "nested_calls");
}

#[test]
fn compile_validate_phasor_synth() {
    let source = r#"
in 0 (freq): float;
in 1 (phase): float;
out 0 (audio): signal;
wire saw = phasor~(freq, phase);
wire amp = mul~(saw, 0.5);
out[0] = amp;
"#;
    compile_and_validate(source, "phasor_synth");
}

#[test]
fn compile_validate_noise_source() {
    let source = r#"
out 0 (audio): signal;
wire n = noise~();
wire amp = mul~(n, 0.1);
out[0] = amp;
"#;
    compile_and_validate(source, "noise_source");
}

// ─── Box count verification ───

#[test]
fn verify_l1_sine_box_count() {
    let source = read_fixture("L1_sine.flutmax");
    let json = flutmax_cli::compile(&source).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
    let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
    // L1: cycle~ + outlet~ = 2 boxes
    assert_eq!(boxes.len(), 2, "L1_sine should have 2 boxes");
}

#[test]
fn verify_l2_simple_synth_box_count() {
    let source = read_fixture("L2_simple_synth.flutmax");
    let json = flutmax_cli::compile(&source).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
    let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
    // L2: inlet + cycle~ + *~ + outlet~ = 4 boxes
    assert_eq!(boxes.len(), 4, "L2_simple_synth should have 4 boxes");
}

// ─── Error handling ───

#[test]
fn compile_invalid_source_returns_error() {
    let source = "wire a = ;";
    let result = flutmax_cli::compile(source);
    assert!(result.is_err(), "invalid source should produce an error");
}

#[test]
fn compile_undefined_ref_returns_error() {
    let source = r#"
out 0 (audio): signal;
wire osc = cycle~(undefined_var);
out[0] = osc;
"#;
    let result = flutmax_cli::compile(source);
    assert!(
        result.is_err(),
        "undefined reference should produce an error"
    );
}

#[test]
fn compile_empty_source_succeeds() {
    // Empty source should parse OK and produce a minimal .maxpat
    let result = flutmax_cli::compile("");
    assert!(result.is_ok(), "empty source should compile");
    let json = result.unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
    assert!(parsed.get("patcher").is_some());
}

#[test]
fn compile_signal_to_control_type_error() {
    let source = r#"
out 0 (result): float;
wire osc = cycle~(440);
wire val = print(osc);
out[0] = val;
"#;
    let result = flutmax_cli::compile(source);
    assert!(result.is_err());
    let err = result.unwrap_err().to_string();
    assert!(err.contains("E001"));
}