Skip to main content

flutmax_codegen/
maxpat.rs

1/// .maxpat JSON generation
2///
3/// Generate a `.maxpat` JSON string that Max/MSP can load from a `PatchGraph`.
4/// Conforms to the schema defined in experiment E01.
5use std::collections::HashMap;
6
7use flutmax_sema::graph::{PatchGraph, PatchNode};
8use serde_json::{json, Map, Value};
9
10use crate::layout::sugiyama_layout;
11
12/// UI layout and decorative attribute data from .uiflutmax sidecar file.
13pub struct UiData {
14    /// Patcher-level settings (window rect, etc.)
15    pub patcher: HashMap<String, Value>,
16    /// Per-wire UI data: wire_name -> { "rect": [...], "background": 0, ... }
17    pub entries: HashMap<String, Value>,
18    /// Comment boxes with text and position for .maxpat reconstruction.
19    pub comments: Vec<Value>,
20    /// Visual-only panel boxes for .maxpat reconstruction.
21    pub panels: Vec<Value>,
22    /// Visual-only image boxes (fpic) for .maxpat reconstruction.
23    pub images: Vec<Value>,
24}
25
26impl UiData {
27    /// Parse a .uiflutmax JSON string into UiData.
28    /// Returns None if the JSON is invalid or not an object.
29    pub fn from_json(json_str: &str) -> Option<Self> {
30        let root: Value = serde_json::from_str(json_str).ok()?;
31        let obj = root.as_object()?;
32
33        let mut patcher = HashMap::new();
34        let mut entries = HashMap::new();
35
36        let comments = obj
37            .get("_comments")
38            .and_then(|v| v.as_array())
39            .cloned()
40            .unwrap_or_default();
41        let panels = obj
42            .get("_panels")
43            .and_then(|v| v.as_array())
44            .cloned()
45            .unwrap_or_default();
46        let images = obj
47            .get("_images")
48            .and_then(|v| v.as_array())
49            .cloned()
50            .unwrap_or_default();
51
52        for (key, value) in obj {
53            if key == "_patcher" {
54                if let Some(inner) = value.as_object() {
55                    for (k, v) in inner {
56                        patcher.insert(k.clone(), v.clone());
57                    }
58                }
59            } else if key == "_comments" || key == "_panels" || key == "_images" {
60                // Already parsed above
61            } else {
62                entries.insert(key.clone(), value.clone());
63            }
64        }
65
66        Some(UiData {
67            patcher,
68            entries,
69            comments,
70            panels,
71            images,
72        })
73    }
74}
75
76/// Code generation error
77#[derive(Debug)]
78pub enum CodegenError {
79    /// JSON serialization failed
80    Serialization(String),
81}
82
83impl std::fmt::Display for CodegenError {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            CodegenError::Serialization(msg) => write!(f, "codegen error: {}", msg),
87        }
88    }
89}
90
91impl std::error::Error for CodegenError {}
92
93// ─── Layout constants ───
94
95const LAYOUT_X: f64 = 100.0;
96const LAYOUT_Y_START: f64 = 50.0;
97const LAYOUT_Y_STEP: f64 = 70.0;
98
99const BOX_WIDTH_INLET_OUTLET: f64 = 30.0;
100const BOX_HEIGHT_INLET_OUTLET: f64 = 30.0;
101const BOX_WIDTH_NEWOBJ: f64 = 80.0;
102const BOX_HEIGHT_NEWOBJ: f64 = 22.0;
103const BOX_WIDTH_EZDAC: f64 = 45.0;
104const BOX_HEIGHT_EZDAC: f64 = 45.0;
105
106/// Options for .maxpat generation.
107pub struct GenerateOptions {
108    /// Patcher classnamespace: "box" (standard Max) or "rnbo" (RNBO subset).
109    pub classnamespace: String,
110}
111
112impl Default for GenerateOptions {
113    fn default() -> Self {
114        Self {
115            classnamespace: "box".to_string(),
116        }
117    }
118}
119
120/// Generate a .maxpat JSON string from a PatchGraph.
121pub fn generate(graph: &PatchGraph) -> Result<String, CodegenError> {
122    generate_with_options(graph, &GenerateOptions::default())
123}
124
125/// Generate a .maxpat JSON string from a PatchGraph (with options).
126pub fn generate_with_options(
127    graph: &PatchGraph,
128    opts: &GenerateOptions,
129) -> Result<String, CodegenError> {
130    generate_with_ui(graph, opts, None)
131}
132
133/// Generate a .maxpat JSON string from a PatchGraph (with UiData).
134///
135/// When `ui_data` is provided, position and decoration attributes loaded from .uiflutmax
136/// are reflected in the generated .maxpat. When None, automatic layout is used.
137pub fn generate_with_ui(
138    graph: &PatchGraph,
139    opts: &GenerateOptions,
140    ui_data: Option<&UiData>,
141) -> Result<String, CodegenError> {
142    let patcher = build_patcher(graph, opts, ui_data)?;
143    let root = json!({ "patcher": patcher });
144    serde_json::to_string_pretty(&root).map_err(|e| CodegenError::Serialization(e.to_string()))
145}
146
147/// Build the patcher object.
148fn build_patcher(
149    graph: &PatchGraph,
150    opts: &GenerateOptions,
151    ui_data: Option<&UiData>,
152) -> Result<Value, CodegenError> {
153    let is_rnbo = opts.classnamespace == "rnbo";
154    let is_gen = opts.classnamespace == "dsp.gen";
155    let needs_port_indices = is_rnbo || is_gen;
156    let ordered_nodes = topological_order(graph);
157
158    // RNBO/gen~ mode: pre-calculate inlet/outlet port indices
159    let inlet_indices: HashMap<String, usize> = if needs_port_indices {
160        let mut control_idx = 0usize;
161        let mut signal_idx = 0usize;
162        let mut map = HashMap::new();
163        for node in &ordered_nodes {
164            match node.object_name.as_str() {
165                "inlet" => {
166                    map.insert(node.id.clone(), control_idx);
167                    control_idx += 1;
168                }
169                "inlet~" => {
170                    map.insert(node.id.clone(), signal_idx);
171                    signal_idx += 1;
172                }
173                _ => {}
174            }
175        }
176        map
177    } else {
178        HashMap::new()
179    };
180
181    let outlet_indices: HashMap<String, usize> = if needs_port_indices {
182        let mut control_idx = 0usize;
183        let mut signal_idx = 0usize;
184        let mut map = HashMap::new();
185        for node in &ordered_nodes {
186            match node.object_name.as_str() {
187                "outlet" => {
188                    map.insert(node.id.clone(), control_idx);
189                    control_idx += 1;
190                }
191                "outlet~" => {
192                    map.insert(node.id.clone(), signal_idx);
193                    signal_idx += 1;
194                }
195                _ => {}
196            }
197        }
198        map
199    } else {
200        HashMap::new()
201    };
202
203    // Node ID -> sequential ID mapping ("obj-1", "obj-2", ...)
204    let mut id_map: HashMap<String, String> = HashMap::new();
205    for (i, node) in ordered_nodes.iter().enumerate() {
206        id_map.insert(node.id.clone(), format!("obj-{}", i + 1));
207    }
208
209    // Sugiyama auto-layout
210    let layout = sugiyama_layout(graph);
211
212    // Box generation
213    let classnamespace = opts.classnamespace.as_str();
214    let mut boxes: Vec<Value> = ordered_nodes
215        .iter()
216        .enumerate()
217        .map(|(i, node)| {
218            let mapped_id = format!("obj-{}", i + 1);
219            let (x, y) = layout
220                .positions
221                .get(&node.id)
222                .copied()
223                .unwrap_or((LAYOUT_X, LAYOUT_Y_START + (i as f64) * LAYOUT_Y_STEP));
224            let serial = i + 1; // rnbo_serial: 1-based monotonically increasing
225            let port_index = inlet_indices
226                .get(&node.id)
227                .or_else(|| outlet_indices.get(&node.id))
228                .copied();
229            build_box(
230                node,
231                &BoxContext {
232                    id: &mapped_id,
233                    x,
234                    y,
235                    classnamespace,
236                    serial,
237                    port_index,
238                    ui_data,
239                },
240            )
241        })
242        .collect();
243
244    // Append visual-only boxes from UI data (comments, panels, images)
245    if let Some(ui) = ui_data {
246        let mut visual_counter = ordered_nodes.len() + 1;
247
248        // Restore comment boxes
249        for comment in &ui.comments {
250            let rect = comment
251                .get("rect")
252                .cloned()
253                .unwrap_or(json!([50, 50, 200, 20]));
254            let text = comment.get("text").and_then(|t| t.as_str()).unwrap_or("");
255            let id = format!("obj-{}", visual_counter);
256            visual_counter += 1;
257            boxes.push(json!({
258                "box": {
259                    "id": id,
260                    "maxclass": "comment",
261                    "text": text,
262                    "numinlets": 1,
263                    "numoutlets": 0,
264                    "outlettype": [],
265                    "patching_rect": rect,
266                }
267            }));
268        }
269
270        // Restore panel boxes
271        for panel in &ui.panels {
272            let rect = panel
273                .get("rect")
274                .cloned()
275                .unwrap_or(json!([50, 50, 200, 200]));
276            let id = format!("obj-{}", visual_counter);
277            visual_counter += 1;
278            let mut box_obj = serde_json::Map::new();
279            box_obj.insert("id".into(), json!(id));
280            box_obj.insert("maxclass".into(), json!("panel"));
281            box_obj.insert("numinlets".into(), json!(1));
282            box_obj.insert("numoutlets".into(), json!(0));
283            box_obj.insert("outlettype".into(), json!([]));
284            box_obj.insert("patching_rect".into(), rect);
285            // Restore panel attributes
286            if let Some(obj) = panel.as_object() {
287                for (k, v) in obj {
288                    if k != "rect" {
289                        box_obj.insert(k.clone(), v.clone());
290                    }
291                }
292            }
293            boxes.push(json!({ "box": Value::Object(box_obj) }));
294        }
295
296        // Restore image boxes (fpic)
297        for image in &ui.images {
298            let rect = image
299                .get("rect")
300                .cloned()
301                .unwrap_or(json!([50, 50, 200, 200]));
302            let pic = image.get("pic").and_then(|p| p.as_str()).unwrap_or("");
303            let id = format!("obj-{}", visual_counter);
304            visual_counter += 1;
305            let mut box_obj = serde_json::Map::new();
306            box_obj.insert("id".into(), json!(id));
307            box_obj.insert("maxclass".into(), json!("fpic"));
308            box_obj.insert("numinlets".into(), json!(1));
309            box_obj.insert("numoutlets".into(), json!(1));
310            box_obj.insert("outlettype".into(), json!(["jit_matrix"]));
311            box_obj.insert("patching_rect".into(), rect);
312            if !pic.is_empty() {
313                box_obj.insert("pic".into(), json!(pic));
314            }
315            boxes.push(json!({ "box": Value::Object(box_obj) }));
316        }
317
318        // Suppress unused variable warning
319        let _ = visual_counter;
320    }
321
322    // Line generation
323    let lines: Vec<Value> = graph
324        .edges
325        .iter()
326        .map(|edge| {
327            let source_id = id_map
328                .get(&edge.source_id)
329                .cloned()
330                .unwrap_or_else(|| edge.source_id.clone());
331            let dest_id = id_map
332                .get(&edge.dest_id)
333                .cloned()
334                .unwrap_or_else(|| edge.dest_id.clone());
335            let mut patchline = serde_json::Map::new();
336            patchline.insert("source".into(), json!([source_id, edge.source_outlet]));
337            patchline.insert("destination".into(), json!([dest_id, edge.dest_inlet]));
338            if let Some(order) = edge.order {
339                patchline.insert("order".into(), json!(order));
340            }
341            json!({ "patchline": Value::Object(patchline) })
342        })
343        .collect();
344
345    // Combine fixed template + dynamic fields
346    let mut patcher = Map::new();
347    patcher.insert("fileversion".into(), json!(1));
348    patcher.insert(
349        "appversion".into(),
350        json!({
351            "major": 8,
352            "minor": 6,
353            "revision": 0,
354            "architecture": "x64",
355            "modernui": 1
356        }),
357    );
358    patcher.insert("classnamespace".into(), json!(&opts.classnamespace));
359    // Use patcher rect from UI data if available, otherwise derive from Sugiyama layout
360    let patcher_rect = ui_data
361        .and_then(|ui| ui.patcher.get("rect"))
362        .cloned()
363        .unwrap_or_else(|| {
364            json!([
365                100.0,
366                100.0,
367                layout.patcher_size.0.max(640.0),
368                layout.patcher_size.1.max(480.0)
369            ])
370        });
371    patcher.insert("rect".into(), patcher_rect);
372    patcher.insert("bglocked".into(), json!(0));
373    patcher.insert("openinpresentation".into(), json!(0));
374    patcher.insert("default_fontsize".into(), json!(12.0));
375    patcher.insert("default_fontface".into(), json!(0));
376    patcher.insert("default_fontname".into(), json!("Arial"));
377    patcher.insert("gridonopen".into(), json!(1));
378    patcher.insert("gridsize".into(), json!([15.0, 15.0]));
379    patcher.insert("gridsnaponopen".into(), json!(1));
380    patcher.insert("objectsnaponopen".into(), json!(1));
381    patcher.insert("statusbarvisible".into(), json!(2));
382    patcher.insert("toolbarvisible".into(), json!(1));
383    patcher.insert("lefttoolbarpinned".into(), json!(0));
384    patcher.insert("toptoolbarpinned".into(), json!(0));
385    patcher.insert("righttoolbarpinned".into(), json!(0));
386    patcher.insert("bottomtoolbarpinned".into(), json!(0));
387    patcher.insert("toolbars_unpinned_last_save".into(), json!(0));
388    patcher.insert("tallnewobj".into(), json!(0));
389    patcher.insert("boxanimatetime".into(), json!(200));
390    patcher.insert("enablehscroll".into(), json!(1));
391    patcher.insert("enablevscroll".into(), json!(1));
392    patcher.insert("devicewidth".into(), json!(0.0));
393    patcher.insert("description".into(), json!(""));
394    patcher.insert("digest".into(), json!(""));
395    patcher.insert("tags".into(), json!(""));
396    patcher.insert("style".into(), json!(""));
397    patcher.insert("subpatcher_template".into(), json!(""));
398    patcher.insert("assistshowspatchername".into(), json!(0));
399    patcher.insert("boxes".into(), Value::Array(boxes));
400    patcher.insert("lines".into(), Value::Array(lines));
401    patcher.insert("dependency_cache".into(), json!([]));
402    patcher.insert("autosave".into(), json!(0));
403
404    Ok(Value::Object(patcher))
405}
406
407/// Layout and rendering context for generating a box.
408struct BoxContext<'a> {
409    id: &'a str,
410    x: f64,
411    y: f64,
412    classnamespace: &'a str,
413    serial: usize,
414    port_index: Option<usize>,
415    ui_data: Option<&'a UiData>,
416}
417
418/// Generate box JSON from a PatchNode.
419fn build_box(node: &PatchNode, ctx: &BoxContext) -> Value {
420    let is_rnbo = ctx.classnamespace == "rnbo";
421    let is_gen = ctx.classnamespace == "dsp.gen";
422    let (maxclass, width, height) = classify_maxclass(node, ctx.classnamespace);
423    let outlettype = compute_outlettype(node, is_rnbo, is_gen);
424
425    // RNBO mode: outlet/outport has numoutlets=0 (sink)
426    // gen~ mode: `out N` has numoutlets=0 (sink)
427    // RNBO inlet/inlet~ boxes expose a single outlet on the host side.
428    let effective_num_outlets =
429        if (is_rnbo || is_gen) && matches!(node.object_name.as_str(), "outlet" | "outlet~") {
430            0
431        } else if is_rnbo && matches!(node.object_name.as_str(), "inlet" | "inlet~") {
432            1
433        } else {
434            node.num_outlets
435        };
436
437    // RNBO inlet/outlet boxes always present a single inlet on the host side.
438    let effective_num_inlets = if is_rnbo
439        && matches!(
440            node.object_name.as_str(),
441            "inlet" | "inlet~" | "outlet" | "outlet~"
442        ) {
443        1
444    } else {
445        node.num_inlets
446    };
447
448    let mut box_obj = Map::new();
449    box_obj.insert("id".into(), json!(ctx.id));
450    box_obj.insert("maxclass".into(), json!(maxclass));
451    box_obj.insert("numinlets".into(), json!(effective_num_inlets));
452    box_obj.insert("numoutlets".into(), json!(effective_num_outlets));
453
454    if !outlettype.is_empty() {
455        box_obj.insert("outlettype".into(), json!(outlettype));
456    }
457
458    box_obj.insert("patching_rect".into(), json!([ctx.x, ctx.y, width, height]));
459
460    // text field: for newobj and message
461    if maxclass == "newobj" {
462        let text = if is_rnbo {
463            // RNBO mode: inlet/outlet → inport/outport or in~/out~ text
464            match node.object_name.as_str() {
465                "inlet" => {
466                    let name = node
467                        .varname
468                        .clone()
469                        .unwrap_or_else(|| format!("port_{}", ctx.port_index.unwrap_or(0)));
470                    format!("inport {}", name)
471                }
472                "inlet~" => {
473                    let idx = ctx.port_index.unwrap_or(0) + 1; // RNBO uses 1-based
474                    format!("in~ {}", idx)
475                }
476                "outlet" => {
477                    let name = node
478                        .varname
479                        .clone()
480                        .unwrap_or_else(|| format!("port_{}", ctx.port_index.unwrap_or(0)));
481                    format!("outport {}", name)
482                }
483                "outlet~" => {
484                    let idx = ctx.port_index.unwrap_or(0) + 1; // RNBO uses 1-based
485                    format!("out~ {}", idx)
486                }
487                _ => {
488                    let mut t = build_object_text(node);
489                    if !node.attrs.is_empty() {
490                        let attr_str: String = node
491                            .attrs
492                            .iter()
493                            .map(|(k, v)| format!("@{} {}", k, v))
494                            .collect::<Vec<_>>()
495                            .join(" ");
496                        t = format!("{} {}", t, attr_str);
497                    }
498                    t
499                }
500            }
501        } else if is_gen {
502            // gen~ mode: inlet/outlet → `in N` / `out N` (1-based)
503            match node.object_name.as_str() {
504                "inlet" | "inlet~" => {
505                    let idx = ctx.port_index.unwrap_or(0) + 1; // gen~ uses 1-based
506                    format!("in {}", idx)
507                }
508                "outlet" | "outlet~" => {
509                    let idx = ctx.port_index.unwrap_or(0) + 1; // gen~ uses 1-based
510                    format!("out {}", idx)
511                }
512                "history" => {
513                    // gen~ history needs an explicit name. If the user didn't
514                    // pass one as a literal first arg, synthesize one from the
515                    // node id (or varname) so the resulting patch is valid.
516                    let first_arg_is_name = node
517                        .args
518                        .first()
519                        .map(|a| a.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_'))
520                        .unwrap_or(false);
521                    let mut t = if first_arg_is_name {
522                        build_object_text(node)
523                    } else {
524                        let name = node.varname.clone().unwrap_or_else(|| {
525                            let suffix = node.id.trim_start_matches(|c: char| !c.is_ascii_digit());
526                            if suffix.is_empty() {
527                                format!("h_{}", node.id.replace('-', "_"))
528                            } else {
529                                format!("h_{}", suffix)
530                            }
531                        });
532                        if node.args.is_empty() {
533                            format!("history {}", name)
534                        } else {
535                            format!("history {} {}", name, node.args.join(" "))
536                        }
537                    };
538                    if !node.attrs.is_empty() {
539                        let attr_str: String = node
540                            .attrs
541                            .iter()
542                            .map(|(k, v)| format!("@{} {}", k, v))
543                            .collect::<Vec<_>>()
544                            .join(" ");
545                        t = format!("{} {}", t, attr_str);
546                    }
547                    t
548                }
549                _ => {
550                    let mut t = build_object_text(node);
551                    if !node.attrs.is_empty() {
552                        let attr_str: String = node
553                            .attrs
554                            .iter()
555                            .map(|(k, v)| format!("@{} {}", k, v))
556                            .collect::<Vec<_>>()
557                            .join(" ");
558                        t = format!("{} {}", t, attr_str);
559                    }
560                    t
561                }
562            }
563        } else {
564            let mut t = build_object_text(node);
565            // newobj: append .attr() attributes as @key value to text
566            if !node.attrs.is_empty() {
567                let attr_str: String = node
568                    .attrs
569                    .iter()
570                    .map(|(k, v)| format!("@{} {}", k, v))
571                    .collect::<Vec<_>>()
572                    .join(" ");
573                t = format!("{} {}", t, attr_str);
574            }
575            t
576        };
577        box_obj.insert("text".into(), json!(text));
578    } else if maxclass == "message" {
579        // message box: text uses the content (args[0]) as-is
580        let text = if node.args.is_empty() {
581            String::new()
582        } else {
583            node.args.join(" ")
584        };
585        box_obj.insert("text".into(), json!(text));
586    }
587
588    // varname: output flutmax wire name as Max varname attribute
589    if let Some(ref vn) = node.varname {
590        box_obj.insert("varname".into(), json!(vn));
591    }
592
593    // UI objects (non-newobj): output .attr() attributes as top-level fields in box JSON
594    if maxclass != "newobj" && !node.attrs.is_empty() {
595        for (key, value) in &node.attrs {
596            // Output as number if parseable, otherwise as string
597            if let Ok(f) = value.parse::<f64>() {
598                box_obj.insert(key.clone(), json!(f));
599            } else {
600                box_obj.insert(key.clone(), json!(value));
601            }
602        }
603    }
604
605    // Codebox: emit code field and special attributes
606    if matches!(maxclass, "v8.codebox" | "codebox") {
607        if let Some(ref code) = node.code {
608            box_obj.insert("code".into(), json!(code));
609        }
610        if maxclass == "v8.codebox" {
611            box_obj.insert("filename".into(), json!("none"));
612            // v8.codebox uses empty text (code is in the code field)
613            if !box_obj.contains_key("text") {
614                box_obj.insert("text".into(), json!(""));
615            }
616        }
617    }
618
619    // .uiflutmax UI data: override position and add decorative attributes
620    if let Some(ui_entry) = ctx
621        .ui_data
622        .and_then(|ui| node.varname.as_ref().and_then(|vn| ui.entries.get(vn)))
623    {
624        // Override position from UI data
625        if let Some(rect) = ui_entry.get("rect") {
626            box_obj.insert("patching_rect".into(), rect.clone());
627        }
628        // Add decorative attributes (everything except "rect")
629        if let Some(obj) = ui_entry.as_object() {
630            for (k, v) in obj {
631                if k != "rect" {
632                    box_obj.insert(k.clone(), v.clone());
633                }
634            }
635        }
636    }
637
638    // RNBO mode: add rnbo_serial and rnbo_uniqueid
639    if is_rnbo {
640        box_obj.insert("rnbo_serial".into(), json!(ctx.serial));
641        box_obj.insert(
642            "rnbo_uniqueid".into(),
643            json!(format!(
644                "{}_{}",
645                node.object_name.replace('~', "_tilde"),
646                ctx.id
647            )),
648        );
649    }
650
651    json!({ "box": Value::Object(box_obj) })
652}
653
654/// Determine maxclass from PatchNode object_name.
655/// Returns: (maxclass, width, height)
656fn classify_maxclass(node: &PatchNode, classnamespace: &str) -> (&'static str, f64, f64) {
657    let is_rnbo = classnamespace == "rnbo";
658    let is_gen = classnamespace == "dsp.gen";
659    match node.object_name.as_str() {
660        "inlet" | "inlet~" if is_rnbo || is_gen => ("newobj", BOX_WIDTH_NEWOBJ, BOX_HEIGHT_NEWOBJ),
661        "outlet" | "outlet~" if is_rnbo || is_gen => {
662            ("newobj", BOX_WIDTH_NEWOBJ, BOX_HEIGHT_NEWOBJ)
663        }
664        "inlet" => ("inlet", BOX_WIDTH_INLET_OUTLET, BOX_HEIGHT_INLET_OUTLET),
665        "inlet~" => ("inlet", BOX_WIDTH_INLET_OUTLET, BOX_HEIGHT_INLET_OUTLET),
666        "outlet" => ("outlet", BOX_WIDTH_INLET_OUTLET, BOX_HEIGHT_INLET_OUTLET),
667        "outlet~" => ("outlet", BOX_WIDTH_INLET_OUTLET, BOX_HEIGHT_INLET_OUTLET),
668        "ezdac~" => ("ezdac~", BOX_WIDTH_EZDAC, BOX_HEIGHT_EZDAC),
669        "message" => ("message", 50.0, 22.0),
670        "button" => ("button", 50.0, 50.0),
671        "flonum" => ("flonum", 80.0, 22.0),
672        "number" => ("number", 50.0, 22.0),
673        "toggle" => ("toggle", 20.0, 20.0),
674        "umenu" => ("umenu", 100.0, 22.0),
675        "panel" => ("panel", 100.0, 50.0),
676        "jsui" => ("jsui", 64.0, 64.0),
677        // Additional UI objects
678        "textbutton" => ("textbutton", 100.0, 20.0),
679        "live.text" => ("live.text", 44.0, 15.0),
680        "live.dial" => ("live.dial", 47.0, 48.0),
681        "live.toggle" => ("live.toggle", 15.0, 15.0),
682        "live.menu" => ("live.menu", 100.0, 15.0),
683        "live.numbox" => ("live.numbox", 44.0, 15.0),
684        "live.tab" => ("live.tab", 100.0, 20.0),
685        "live.comment" => ("live.comment", 100.0, 18.0),
686        "slider" => ("slider", 20.0, 140.0),
687        "dial" => ("dial", 40.0, 40.0),
688        "multislider" => ("multislider", 120.0, 100.0),
689        "kslider" => ("kslider", 168.0, 53.0),
690        "tab" => ("tab", 200.0, 24.0),
691        "rslider" => ("rslider", 100.0, 22.0),
692        "filtergraph~" => ("filtergraph~", 256.0, 128.0),
693        "spectroscope~" => ("spectroscope~", 300.0, 100.0),
694        "scope~" => ("scope~", 130.0, 130.0),
695        "meter~" => ("meter~", 13.0, 80.0),
696        "gain~" => ("gain~", 22.0, 140.0),
697        "ezadc~" => ("ezadc~", BOX_WIDTH_EZDAC, BOX_HEIGHT_EZDAC),
698        "number~" => ("number~", 56.0, 22.0),
699        "bpatcher" => ("bpatcher", 128.0, 128.0),
700        "fpic" => ("fpic", 100.0, 100.0),
701        "textedit" => ("textedit", 100.0, 22.0),
702        "attrui" => ("attrui", 150.0, 22.0),
703        "nslider" => ("nslider", 50.0, 120.0),
704        "preset" => ("preset", 100.0, 40.0),
705        // Codebox objects
706        "v8.codebox" => ("v8.codebox", 200.0, 100.0),
707        "codebox" => ("codebox", 200.0, 100.0),
708        _ => ("newobj", BOX_WIDTH_NEWOBJ, BOX_HEIGHT_NEWOBJ),
709    }
710}
711
712/// Compute the outlettype array for an object.
713fn compute_outlettype(node: &PatchNode, is_rnbo: bool, is_gen: bool) -> Vec<&'static str> {
714    // RNBO mode: outlet/outport is a sink, so no outlettype
715    // gen~ mode: `out N` is a sink, so no outlettype
716    if (is_rnbo || is_gen) && matches!(node.object_name.as_str(), "outlet" | "outlet~") {
717        return vec![];
718    }
719
720    if node.num_outlets == 0 {
721        return vec![];
722    }
723
724    match node.object_name.as_str() {
725        // RNBO mode: inport (control inlet) → outlettype = [""]
726        "inlet" if is_rnbo => vec![""],
727        // RNBO mode: in~ (signal inlet) → outlettype = ["signal"]
728        "inlet~" if is_rnbo => vec!["signal"],
729
730        // gen~ mode: `in N` (all I/O is signal) → outlettype = [""]
731        "inlet" | "inlet~" if is_gen => vec![""],
732
733        // inlet/inlet~ has one outlettype
734        "inlet" => vec![""],
735        "inlet~" => vec!["signal"],
736
737        // message box
738        "message" => vec![""],
739
740        // UI objects
741        "button" => vec!["bang"],
742        "toggle" => vec!["int"],
743        "umenu" => vec!["int", "", ""],
744        "flonum" => vec!["", "bang"],
745        "number" => vec!["", "bang"],
746        "textbutton" => vec!["", "", "int"],
747        "live.text" => vec!["", ""],
748        "live.dial" => vec!["", ""],
749        "live.toggle" => vec![""],
750        "live.menu" => vec!["", "", ""],
751        "live.numbox" => vec!["", ""],
752        "live.tab" => vec!["", "", ""],
753        "live.comment" => vec![],
754        "slider" => vec![""],
755        "dial" => vec![""],
756        "multislider" => vec!["", ""],
757        "kslider" => vec!["", ""],
758        "tab" => vec!["", "", ""],
759        "rslider" => vec!["", ""],
760        "bpatcher" => {
761            // bpatcher outlet count depends on the patch. Use node.num_outlets
762            vec![""; node.num_outlets as usize]
763        }
764
765        // Signal objects: all outlets are "signal"
766        name if name.ends_with('~') => {
767            let mut types = vec!["signal"];
768            // For objects like line~ with 2+ outlets: the last may be "bang"
769            if name == "line~" && node.num_outlets >= 2 {
770                types = vec!["signal", "bang"];
771            }
772            // Keep as-is if already sufficient, otherwise pad with signal
773            while types.len() < node.num_outlets as usize {
774                types.push("signal");
775            }
776            types.truncate(node.num_outlets as usize);
777            types
778        }
779
780        // Codebox objects
781        "v8.codebox" | "codebox" => {
782            vec![""; node.num_outlets as usize]
783        }
784
785        // Control objects
786        "trigger" | "t" => {
787            // trigger outlet types depend on arg types; simplified to "" padding
788            vec![""; node.num_outlets as usize]
789        }
790
791        _ => {
792            // Use "signal" when is_signal is set (e.g., for Abstractions)
793            if node.is_signal {
794                vec!["signal"; node.num_outlets as usize]
795            } else {
796                // Default: set all outlets to "" (generic message)
797                vec![""; node.num_outlets as usize]
798            }
799        }
800    }
801}
802
803/// Generate object text from a PatchNode.
804/// e.g., object_name="cycle~", args=["440"] -> "cycle~ 440"
805fn build_object_text(node: &PatchNode) -> String {
806    if node.args.is_empty() {
807        node.object_name.clone()
808    } else {
809        format!("{} {}", node.object_name, node.args.join(" "))
810    }
811}
812
813/// Sort nodes in topological order.
814/// inlet -> processing objects -> outlet order.
815/// Not a full topological sort; a simplified classification-based reordering.
816fn topological_order(graph: &PatchGraph) -> Vec<&PatchNode> {
817    let mut inlets: Vec<&PatchNode> = Vec::new();
818    let mut outlets: Vec<&PatchNode> = Vec::new();
819    let mut others: Vec<&PatchNode> = Vec::new();
820
821    for node in &graph.nodes {
822        match node.object_name.as_str() {
823            "inlet" | "inlet~" => inlets.push(node),
824            "outlet" | "outlet~" => outlets.push(node),
825            _ => others.push(node),
826        }
827    }
828
829    // Maintain original order within each category
830    let mut result = Vec::with_capacity(graph.nodes.len());
831    result.extend(inlets);
832    result.extend(others);
833    result.extend(outlets);
834    result
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use flutmax_sema::graph::{NodePurity, PatchEdge, PatchNode};
841
842    /// Minimal graph: cycle~ 440 -> ezdac~
843    fn make_minimal_graph() -> PatchGraph {
844        let mut g = PatchGraph::new();
845        g.add_node(PatchNode {
846            id: "osc".into(),
847            object_name: "cycle~".into(),
848            args: vec!["440".into()],
849            num_inlets: 2,
850            num_outlets: 1,
851            is_signal: true,
852            varname: None,
853            hot_inlets: vec![],
854            purity: NodePurity::Unknown,
855            attrs: vec![],
856            code: None,
857        });
858        g.add_node(PatchNode {
859            id: "dac".into(),
860            object_name: "ezdac~".into(),
861            args: vec![],
862            num_inlets: 2,
863            num_outlets: 0,
864            is_signal: true,
865            varname: None,
866            hot_inlets: vec![],
867            purity: NodePurity::Unknown,
868            attrs: vec![],
869            code: None,
870        });
871        g.add_edge(PatchEdge {
872            source_id: "osc".into(),
873            source_outlet: 0,
874            dest_id: "dac".into(),
875            dest_inlet: 0,
876            is_feedback: false,
877            order: None,
878        });
879        g.add_edge(PatchEdge {
880            source_id: "osc".into(),
881            source_outlet: 0,
882            dest_id: "dac".into(),
883            dest_inlet: 1,
884            is_feedback: false,
885            order: None,
886        });
887        g
888    }
889
890    /// Graph: inlet -> cycle~ -> *~ -> outlet~
891    fn make_l2_graph() -> PatchGraph {
892        let mut g = PatchGraph::new();
893        g.add_node(PatchNode {
894            id: "in_freq".into(),
895            object_name: "inlet".into(),
896            args: vec![],
897            num_inlets: 0,
898            num_outlets: 1,
899            is_signal: false,
900            varname: None,
901            hot_inlets: vec![],
902            purity: NodePurity::Unknown,
903            attrs: vec![],
904            code: None,
905        });
906        g.add_node(PatchNode {
907            id: "cycle".into(),
908            object_name: "cycle~".into(),
909            args: vec![],
910            num_inlets: 2,
911            num_outlets: 1,
912            is_signal: true,
913            varname: None,
914            hot_inlets: vec![],
915            purity: NodePurity::Unknown,
916            attrs: vec![],
917            code: None,
918        });
919        g.add_node(PatchNode {
920            id: "mul".into(),
921            object_name: "*~".into(),
922            args: vec!["0.5".into()],
923            num_inlets: 2,
924            num_outlets: 1,
925            is_signal: true,
926            varname: None,
927            hot_inlets: vec![],
928            purity: NodePurity::Unknown,
929            attrs: vec![],
930            code: None,
931        });
932        g.add_node(PatchNode {
933            id: "out_audio".into(),
934            object_name: "outlet~".into(),
935            args: vec![],
936            num_inlets: 1,
937            num_outlets: 0,
938            is_signal: true,
939            varname: None,
940            hot_inlets: vec![],
941            purity: NodePurity::Unknown,
942            attrs: vec![],
943            code: None,
944        });
945        g.add_edge(PatchEdge {
946            source_id: "in_freq".into(),
947            source_outlet: 0,
948            dest_id: "cycle".into(),
949            dest_inlet: 0,
950            is_feedback: false,
951            order: None,
952        });
953        g.add_edge(PatchEdge {
954            source_id: "cycle".into(),
955            source_outlet: 0,
956            dest_id: "mul".into(),
957            dest_inlet: 0,
958            is_feedback: false,
959            order: None,
960        });
961        g.add_edge(PatchEdge {
962            source_id: "mul".into(),
963            source_outlet: 0,
964            dest_id: "out_audio".into(),
965            dest_inlet: 0,
966            is_feedback: false,
967            order: None,
968        });
969        g
970    }
971
972    #[test]
973    fn test_generate_valid_json() {
974        let graph = make_minimal_graph();
975        let json_str = generate(&graph).unwrap();
976
977        // Must be parseable JSON
978        let parsed: Value = serde_json::from_str(&json_str).unwrap();
979        assert!(parsed.is_object());
980        assert!(parsed.get("patcher").is_some());
981    }
982
983    #[test]
984    fn test_patcher_fixed_fields() {
985        let graph = make_minimal_graph();
986        let json_str = generate(&graph).unwrap();
987        let parsed: Value = serde_json::from_str(&json_str).unwrap();
988        let patcher = parsed.get("patcher").unwrap();
989
990        assert_eq!(patcher["fileversion"], 1);
991        assert_eq!(patcher["appversion"]["major"], 8);
992        assert_eq!(patcher["appversion"]["minor"], 6);
993        assert_eq!(patcher["classnamespace"], "box");
994        assert_eq!(patcher["default_fontname"], "Arial");
995        assert_eq!(patcher["autosave"], 0);
996    }
997
998    #[test]
999    fn test_boxes_count() {
1000        let graph = make_minimal_graph();
1001        let json_str = generate(&graph).unwrap();
1002        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1003        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1004        assert_eq!(boxes.len(), 2);
1005    }
1006
1007    #[test]
1008    fn test_box_structure() {
1009        let graph = make_minimal_graph();
1010        let json_str = generate(&graph).unwrap();
1011        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1012        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1013
1014        // cycle~ box
1015        let cycle_box = &boxes[0]["box"];
1016        assert_eq!(cycle_box["id"], "obj-1");
1017        assert_eq!(cycle_box["maxclass"], "newobj");
1018        assert_eq!(cycle_box["numinlets"], 2);
1019        assert_eq!(cycle_box["numoutlets"], 1);
1020        assert_eq!(cycle_box["text"], "cycle~ 440");
1021        let outlettype = cycle_box["outlettype"].as_array().unwrap();
1022        assert_eq!(outlettype.len(), 1);
1023        assert_eq!(outlettype[0], "signal");
1024
1025        // ezdac~ box
1026        let dac_box = &boxes[1]["box"];
1027        assert_eq!(dac_box["id"], "obj-2");
1028        assert_eq!(dac_box["maxclass"], "ezdac~");
1029        assert_eq!(dac_box["numinlets"], 2);
1030        assert_eq!(dac_box["numoutlets"], 0);
1031        // ezdac~ has no outlettype (0 outlets)
1032        assert!(dac_box.get("outlettype").is_none());
1033    }
1034
1035    #[test]
1036    fn test_lines_count() {
1037        let graph = make_minimal_graph();
1038        let json_str = generate(&graph).unwrap();
1039        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1040        let lines = parsed["patcher"]["lines"].as_array().unwrap();
1041        assert_eq!(lines.len(), 2);
1042    }
1043
1044    #[test]
1045    fn test_line_structure() {
1046        let graph = make_minimal_graph();
1047        let json_str = generate(&graph).unwrap();
1048        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1049        let lines = parsed["patcher"]["lines"].as_array().unwrap();
1050
1051        // Both have source "obj-1" (cycle~), dest "obj-2" (ezdac~)
1052        for line in lines {
1053            let patchline = &line["patchline"];
1054            let source = patchline["source"].as_array().unwrap();
1055            let dest = patchline["destination"].as_array().unwrap();
1056
1057            assert_eq!(source[0], "obj-1");
1058            assert_eq!(source[1], 0);
1059            assert_eq!(dest[0], "obj-2");
1060            // dest_inlet is 0 or 1
1061            let inlet = dest[1].as_u64().unwrap();
1062            assert!(inlet == 0 || inlet == 1);
1063        }
1064    }
1065
1066    #[test]
1067    fn test_patching_rect_layout() {
1068        let graph = make_minimal_graph();
1069        let json_str = generate(&graph).unwrap();
1070        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1071        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1072
1073        let rect0 = boxes[0]["box"]["patching_rect"].as_array().unwrap();
1074        let rect1 = boxes[1]["box"]["patching_rect"].as_array().unwrap();
1075
1076        // Sugiyama layout: linear chain → both at same x column
1077        let x0 = rect0[0].as_f64().unwrap();
1078        let x1 = rect1[0].as_f64().unwrap();
1079        assert_eq!(x0, x1, "linear chain nodes should share the same x");
1080
1081        // Y increases sequentially (osc in layer 0, dac in layer 1)
1082        let y0 = rect0[1].as_f64().unwrap();
1083        let y1 = rect1[1].as_f64().unwrap();
1084        assert!(y1 > y0, "downstream node should have larger y");
1085    }
1086
1087    #[test]
1088    fn test_l2_topological_order() {
1089        let graph = make_l2_graph();
1090        let json_str = generate(&graph).unwrap();
1091        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1092        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1093
1094        // Topological order: inlet -> cycle~ -> *~ -> outlet~
1095        assert_eq!(boxes[0]["box"]["maxclass"], "inlet");
1096        assert_eq!(boxes[1]["box"]["text"], "cycle~");
1097        assert_eq!(boxes[2]["box"]["text"], "*~ 0.5");
1098        assert_eq!(boxes[3]["box"]["maxclass"], "outlet");
1099    }
1100
1101    #[test]
1102    fn test_inlet_outlettype() {
1103        let graph = make_l2_graph();
1104        let json_str = generate(&graph).unwrap();
1105        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1106        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1107
1108        // inlet outlettype is [""]
1109        let inlet_box = &boxes[0]["box"];
1110        assert_eq!(inlet_box["maxclass"], "inlet");
1111        let outlettype = inlet_box["outlettype"].as_array().unwrap();
1112        assert_eq!(outlettype.len(), 1);
1113        assert_eq!(outlettype[0], "");
1114    }
1115
1116    #[test]
1117    fn test_outlet_tilde_maxclass() {
1118        let graph = make_l2_graph();
1119        let json_str = generate(&graph).unwrap();
1120        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1121        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1122
1123        // outlet~ maxclass is "outlet~"
1124        let outlet_box = &boxes[3]["box"];
1125        assert_eq!(outlet_box["maxclass"], "outlet");
1126        assert_eq!(outlet_box["numinlets"], 1);
1127        assert_eq!(outlet_box["numoutlets"], 0);
1128    }
1129
1130    #[test]
1131    fn test_empty_graph() {
1132        let graph = PatchGraph::new();
1133        let json_str = generate(&graph).unwrap();
1134        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1135
1136        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1137        let lines = parsed["patcher"]["lines"].as_array().unwrap();
1138        assert_eq!(boxes.len(), 0);
1139        assert_eq!(lines.len(), 0);
1140    }
1141
1142    #[test]
1143    fn test_dependency_cache_empty() {
1144        let graph = make_minimal_graph();
1145        let json_str = generate(&graph).unwrap();
1146        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1147
1148        let dep_cache = parsed["patcher"]["dependency_cache"].as_array().unwrap();
1149        assert_eq!(dep_cache.len(), 0);
1150    }
1151
1152    #[test]
1153    fn test_build_object_text_no_args() {
1154        let node = PatchNode {
1155            id: "test".into(),
1156            object_name: "cycle~".into(),
1157            args: vec![],
1158            num_inlets: 2,
1159            num_outlets: 1,
1160            is_signal: true,
1161            varname: None,
1162            hot_inlets: vec![],
1163            purity: NodePurity::Unknown,
1164            attrs: vec![],
1165            code: None,
1166        };
1167        assert_eq!(build_object_text(&node), "cycle~");
1168    }
1169
1170    #[test]
1171    fn test_build_object_text_with_args() {
1172        let node = PatchNode {
1173            id: "test".into(),
1174            object_name: "cycle~".into(),
1175            args: vec!["440".into()],
1176            num_inlets: 2,
1177            num_outlets: 1,
1178            is_signal: true,
1179            varname: None,
1180            hot_inlets: vec![],
1181            purity: NodePurity::Unknown,
1182            attrs: vec![],
1183            code: None,
1184        };
1185        assert_eq!(build_object_text(&node), "cycle~ 440");
1186    }
1187
1188    #[test]
1189    fn test_build_object_text_multiple_args() {
1190        let node = PatchNode {
1191            id: "test".into(),
1192            object_name: "trigger".into(),
1193            args: vec!["b".into(), "b".into(), "b".into()],
1194            num_inlets: 1,
1195            num_outlets: 3,
1196            is_signal: false,
1197            varname: None,
1198            hot_inlets: vec![],
1199            purity: NodePurity::Unknown,
1200            attrs: vec![],
1201            code: None,
1202        };
1203        assert_eq!(build_object_text(&node), "trigger b b b");
1204    }
1205
1206    #[test]
1207    fn test_classify_maxclass_inlet() {
1208        let node = PatchNode {
1209            id: "test".into(),
1210            object_name: "inlet".into(),
1211            args: vec![],
1212            num_inlets: 0,
1213            num_outlets: 1,
1214            is_signal: false,
1215            varname: None,
1216            hot_inlets: vec![],
1217            purity: NodePurity::Unknown,
1218            attrs: vec![],
1219            code: None,
1220        };
1221        let (maxclass, _, _) = classify_maxclass(&node, "box");
1222        assert_eq!(maxclass, "inlet");
1223    }
1224
1225    #[test]
1226    fn test_classify_maxclass_inlet_tilde() {
1227        // inlet~ uses the same maxclass "inlet" internally in Max,
1228        // In actual Max patches, signal inlets also use "inlet" maxclass.
1229        let node = PatchNode {
1230            id: "test".into(),
1231            object_name: "inlet~".into(),
1232            args: vec![],
1233            num_inlets: 1,
1234            num_outlets: 1,
1235            is_signal: true,
1236            varname: None,
1237            hot_inlets: vec![],
1238            purity: NodePurity::Unknown,
1239            attrs: vec![],
1240            code: None,
1241        };
1242        let (maxclass, _, _) = classify_maxclass(&node, "box");
1243        assert_eq!(maxclass, "inlet");
1244    }
1245
1246    #[test]
1247    fn test_classify_maxclass_newobj() {
1248        let node = PatchNode {
1249            id: "test".into(),
1250            object_name: "cycle~".into(),
1251            args: vec!["440".into()],
1252            num_inlets: 2,
1253            num_outlets: 1,
1254            is_signal: true,
1255            varname: None,
1256            hot_inlets: vec![],
1257            purity: NodePurity::Unknown,
1258            attrs: vec![],
1259            code: None,
1260        };
1261        let (maxclass, _, _) = classify_maxclass(&node, "box");
1262        assert_eq!(maxclass, "newobj");
1263    }
1264
1265    #[test]
1266    fn test_compute_outlettype_signal() {
1267        let node = PatchNode {
1268            id: "test".into(),
1269            object_name: "cycle~".into(),
1270            args: vec![],
1271            num_inlets: 2,
1272            num_outlets: 1,
1273            is_signal: true,
1274            varname: None,
1275            hot_inlets: vec![],
1276            purity: NodePurity::Unknown,
1277            attrs: vec![],
1278            code: None,
1279        };
1280        let types = compute_outlettype(&node, false, false);
1281        assert_eq!(types, vec!["signal"]);
1282    }
1283
1284    #[test]
1285    fn test_compute_outlettype_no_outlets() {
1286        let node = PatchNode {
1287            id: "test".into(),
1288            object_name: "ezdac~".into(),
1289            args: vec![],
1290            num_inlets: 2,
1291            num_outlets: 0,
1292            is_signal: true,
1293            varname: None,
1294            hot_inlets: vec![],
1295            purity: NodePurity::Unknown,
1296            attrs: vec![],
1297            code: None,
1298        };
1299        let types = compute_outlettype(&node, false, false);
1300        assert!(types.is_empty());
1301    }
1302
1303    #[test]
1304    fn test_roundtrip_l2() {
1305        // AST -> PatchGraph -> JSON -> parse -> structural verification
1306        use crate::builder::build_graph;
1307        use flutmax_ast::*;
1308
1309        let prog = Program {
1310            in_decls: vec![InDecl {
1311                index: 0,
1312                name: "freq".to_string(),
1313                port_type: PortType::Float,
1314            }],
1315            out_decls: vec![OutDecl {
1316                index: 0,
1317                name: "audio".to_string(),
1318                port_type: PortType::Signal,
1319                value: None,
1320            }],
1321            wires: vec![
1322                Wire {
1323                    name: "osc".to_string(),
1324                    value: Expr::Call {
1325                        object: "cycle~".to_string(),
1326                        args: vec![CallArg::positional(Expr::Ref("freq".to_string()))],
1327                    },
1328                    span: None,
1329                    attrs: vec![],
1330                },
1331                Wire {
1332                    name: "amp".to_string(),
1333                    value: Expr::Call {
1334                        object: "mul~".to_string(),
1335                        args: vec![
1336                            CallArg::positional(Expr::Ref("osc".to_string())),
1337                            CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
1338                        ],
1339                    },
1340                    span: None,
1341                    attrs: vec![],
1342                },
1343            ],
1344            destructuring_wires: vec![],
1345            msg_decls: vec![],
1346            out_assignments: vec![OutAssignment {
1347                index: 0,
1348                value: Expr::Ref("amp".to_string()),
1349                span: None,
1350            }],
1351            direct_connections: vec![],
1352            feedback_decls: vec![],
1353            feedback_assignments: vec![],
1354            state_decls: vec![],
1355            state_assignments: vec![],
1356        };
1357
1358        let graph = build_graph(&prog).unwrap();
1359        let json_str = generate(&graph).unwrap();
1360        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1361
1362        let patcher = &parsed["patcher"];
1363        let boxes = patcher["boxes"].as_array().unwrap();
1364        let lines = patcher["lines"].as_array().unwrap();
1365
1366        // 4 nodes: inlet, cycle~, *~, outlet~
1367        assert_eq!(boxes.len(), 4);
1368        // 3 edges: inlet->cycle~, cycle~->*~, *~->outlet~
1369        assert_eq!(lines.len(), 3);
1370
1371        // First box is inlet
1372        assert_eq!(boxes[0]["box"]["maxclass"], "inlet");
1373
1374        // Last box is outlet~
1375        assert_eq!(boxes[3]["box"]["maxclass"], "outlet");
1376    }
1377
1378    #[test]
1379    fn test_unique_ids() {
1380        let graph = make_l2_graph();
1381        let json_str = generate(&graph).unwrap();
1382        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1383        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1384
1385        let ids: Vec<&str> = boxes
1386            .iter()
1387            .map(|b| b["box"]["id"].as_str().unwrap())
1388            .collect();
1389
1390        // All IDs are unique
1391        let mut unique_ids = ids.clone();
1392        unique_ids.sort();
1393        unique_ids.dedup();
1394        assert_eq!(ids.len(), unique_ids.len());
1395    }
1396
1397    #[test]
1398    fn test_message_box_output() {
1399        let mut g = PatchGraph::new();
1400        g.add_node(PatchNode {
1401            id: "msg1".into(),
1402            object_name: "message".into(),
1403            args: vec!["bang".into()],
1404            num_inlets: 2,
1405            num_outlets: 1,
1406            is_signal: false,
1407            varname: Some("click".into()),
1408            hot_inlets: vec![true, false],
1409            purity: NodePurity::Stateful,
1410            attrs: vec![],
1411            code: None,
1412        });
1413
1414        let json_str = generate(&g).unwrap();
1415        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1416        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1417
1418        let msg_box = &boxes[0]["box"];
1419        assert_eq!(msg_box["maxclass"], "message");
1420        assert_eq!(msg_box["text"], "bang");
1421        assert_eq!(msg_box["numinlets"], 2);
1422        assert_eq!(msg_box["numoutlets"], 1);
1423        assert_eq!(msg_box["varname"], "click");
1424
1425        let outlettype = msg_box["outlettype"].as_array().unwrap();
1426        assert_eq!(outlettype.len(), 1);
1427        assert_eq!(outlettype[0], "");
1428    }
1429
1430    #[test]
1431    fn test_fanout_patchline_has_order() {
1432        // cycle~ -> ezdac~ (inlet 0 and inlet 1) fanout
1433        let mut graph = make_minimal_graph();
1434        // Set order on fanout edges
1435        graph.edges[0].order = Some(0);
1436        graph.edges[1].order = Some(1);
1437
1438        let json_str = generate(&graph).unwrap();
1439        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1440        let lines = parsed["patcher"]["lines"].as_array().unwrap();
1441
1442        // Both lines have an order field
1443        for (i, line) in lines.iter().enumerate() {
1444            let patchline = &line["patchline"];
1445            let order = patchline.get("order");
1446            assert!(order.is_some(), "patchline {} should have order field", i);
1447            assert_eq!(order.unwrap().as_u64().unwrap(), i as u64);
1448        }
1449    }
1450
1451    #[test]
1452    fn test_non_fanout_patchline_no_order() {
1453        // Single-connection edges have no order
1454        let graph = make_l2_graph();
1455        let json_str = generate(&graph).unwrap();
1456        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1457        let lines = parsed["patcher"]["lines"].as_array().unwrap();
1458
1459        for (i, line) in lines.iter().enumerate() {
1460            let patchline = &line["patchline"];
1461            assert!(
1462                patchline.get("order").is_none(),
1463                "patchline {} should not have order field",
1464                i
1465            );
1466        }
1467    }
1468
1469    // ================================================
1470    // .attr() chain codegen tests
1471    // ================================================
1472
1473    #[test]
1474    fn test_newobj_attrs_in_text() {
1475        // newobj: attrs should be appended as @key value in text field
1476        let mut g = PatchGraph::new();
1477        g.add_node(PatchNode {
1478            id: "osc".into(),
1479            object_name: "cycle~".into(),
1480            args: vec!["440".into()],
1481            num_inlets: 2,
1482            num_outlets: 1,
1483            is_signal: true,
1484            varname: Some("osc".into()),
1485            hot_inlets: vec![],
1486            purity: NodePurity::Unknown,
1487            attrs: vec![("phase".into(), "0.5".into())],
1488            code: None,
1489        });
1490
1491        let json_str = generate(&g).unwrap();
1492        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1493        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1494
1495        let text = boxes[0]["box"]["text"].as_str().unwrap();
1496        assert_eq!(text, "cycle~ 440 @phase 0.5");
1497    }
1498
1499    #[test]
1500    fn test_newobj_multiple_attrs_in_text() {
1501        let mut g = PatchGraph::new();
1502        g.add_node(PatchNode {
1503            id: "osc".into(),
1504            object_name: "cycle~".into(),
1505            args: vec![],
1506            num_inlets: 2,
1507            num_outlets: 1,
1508            is_signal: true,
1509            varname: None,
1510            hot_inlets: vec![],
1511            purity: NodePurity::Unknown,
1512            attrs: vec![
1513                ("frequency".into(), "440.".into()),
1514                ("phase".into(), "0.5".into()),
1515            ],
1516            code: None,
1517        });
1518
1519        let json_str = generate(&g).unwrap();
1520        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1521        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1522
1523        let text = boxes[0]["box"]["text"].as_str().unwrap();
1524        assert_eq!(text, "cycle~ @frequency 440. @phase 0.5");
1525    }
1526
1527    #[test]
1528    fn test_ui_object_attrs_as_fields() {
1529        // UI object (flonum): attrs should be top-level box JSON fields
1530        let mut g = PatchGraph::new();
1531        g.add_node(PatchNode {
1532            id: "fnum".into(),
1533            object_name: "flonum".into(),
1534            args: vec![],
1535            num_inlets: 1,
1536            num_outlets: 2,
1537            is_signal: false,
1538            varname: Some("w".into()),
1539            hot_inlets: vec![],
1540            purity: NodePurity::Unknown,
1541            attrs: vec![
1542                ("minimum".into(), "0.".into()),
1543                ("maximum".into(), "100.".into()),
1544            ],
1545            code: None,
1546        });
1547
1548        let json_str = generate(&g).unwrap();
1549        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1550        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1551        let box_obj = &boxes[0]["box"];
1552
1553        assert_eq!(box_obj["maxclass"], "flonum");
1554        assert_eq!(box_obj["minimum"], 0.0);
1555        assert_eq!(box_obj["maximum"], 100.0);
1556        // UI objects should NOT have attrs in text (no text field for flonum)
1557        assert!(box_obj.get("text").is_none());
1558    }
1559
1560    #[test]
1561    fn test_ui_object_string_attr() {
1562        let mut g = PatchGraph::new();
1563        g.add_node(PatchNode {
1564            id: "dial".into(),
1565            object_name: "live.dial".into(),
1566            args: vec![],
1567            num_inlets: 1,
1568            num_outlets: 2,
1569            is_signal: false,
1570            varname: None,
1571            hot_inlets: vec![],
1572            purity: NodePurity::Unknown,
1573            attrs: vec![("parameter_longname".into(), "Cutoff".into())],
1574            code: None,
1575        });
1576
1577        let json_str = generate(&g).unwrap();
1578        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1579        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1580        let box_obj = &boxes[0]["box"];
1581
1582        assert_eq!(box_obj["maxclass"], "live.dial");
1583        assert_eq!(box_obj["parameter_longname"], "Cutoff");
1584    }
1585
1586    #[test]
1587    fn test_no_attrs_unchanged() {
1588        // When no attrs, output should be unchanged
1589        let mut g = PatchGraph::new();
1590        g.add_node(PatchNode {
1591            id: "osc".into(),
1592            object_name: "cycle~".into(),
1593            args: vec!["440".into()],
1594            num_inlets: 2,
1595            num_outlets: 1,
1596            is_signal: true,
1597            varname: None,
1598            hot_inlets: vec![],
1599            purity: NodePurity::Unknown,
1600            attrs: vec![],
1601            code: None,
1602        });
1603
1604        let json_str = generate(&g).unwrap();
1605        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1606        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1607
1608        let text = boxes[0]["box"]["text"].as_str().unwrap();
1609        assert_eq!(text, "cycle~ 440");
1610    }
1611
1612    // ================================================
1613    // RNBO codegen tests
1614    // ================================================
1615
1616    /// Graph: inlet -> cycle~ -> outlet~ (for RNBO tests)
1617    fn make_rnbo_graph() -> PatchGraph {
1618        let mut g = PatchGraph::new();
1619        g.add_node(PatchNode {
1620            id: "in_freq".into(),
1621            object_name: "inlet".into(),
1622            args: vec![],
1623            num_inlets: 0,
1624            num_outlets: 1,
1625            is_signal: false,
1626            varname: Some("freq".into()),
1627            hot_inlets: vec![],
1628            purity: NodePurity::Unknown,
1629            attrs: vec![],
1630            code: None,
1631        });
1632        g.add_node(PatchNode {
1633            id: "osc".into(),
1634            object_name: "cycle~".into(),
1635            args: vec!["440".into()],
1636            num_inlets: 2,
1637            num_outlets: 1,
1638            is_signal: true,
1639            varname: Some("osc".into()),
1640            hot_inlets: vec![],
1641            purity: NodePurity::Unknown,
1642            attrs: vec![],
1643            code: None,
1644        });
1645        g.add_node(PatchNode {
1646            id: "out_audio".into(),
1647            object_name: "outlet~".into(),
1648            args: vec![],
1649            num_inlets: 1,
1650            num_outlets: 0,
1651            is_signal: true,
1652            varname: None,
1653            hot_inlets: vec![],
1654            purity: NodePurity::Unknown,
1655            attrs: vec![],
1656            code: None,
1657        });
1658        g.add_edge(PatchEdge {
1659            source_id: "in_freq".into(),
1660            source_outlet: 0,
1661            dest_id: "osc".into(),
1662            dest_inlet: 0,
1663            is_feedback: false,
1664            order: None,
1665        });
1666        g.add_edge(PatchEdge {
1667            source_id: "osc".into(),
1668            source_outlet: 0,
1669            dest_id: "out_audio".into(),
1670            dest_inlet: 0,
1671            is_feedback: false,
1672            order: None,
1673        });
1674        g
1675    }
1676
1677    fn rnbo_opts() -> GenerateOptions {
1678        GenerateOptions {
1679            classnamespace: "rnbo".to_string(),
1680        }
1681    }
1682
1683    #[test]
1684    fn test_generate_rnbo_classnamespace() {
1685        let graph = make_rnbo_graph();
1686        let json_str = generate_with_options(&graph, &rnbo_opts()).unwrap();
1687        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1688        let patcher = parsed.get("patcher").unwrap();
1689
1690        assert_eq!(patcher["classnamespace"], "rnbo");
1691    }
1692
1693    #[test]
1694    fn test_rnbo_inport_outport() {
1695        let graph = make_rnbo_graph();
1696        let json_str = generate_with_options(&graph, &rnbo_opts()).unwrap();
1697        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1698        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1699
1700        // inlet (control) → "inport freq" (uses varname)
1701        let inlet_box = &boxes[0]["box"];
1702        assert_eq!(inlet_box["maxclass"], "newobj");
1703        assert_eq!(inlet_box["text"], "inport freq");
1704
1705        // outlet~ (signal) → "out~ 1" (1-based index)
1706        let outlet_box = &boxes[2]["box"];
1707        assert_eq!(outlet_box["maxclass"], "newobj");
1708        assert_eq!(outlet_box["text"], "out~ 1");
1709    }
1710
1711    #[test]
1712    fn test_rnbo_signal_io() {
1713        // Graph with signal inlet~ and signal outlet~
1714        let mut g = PatchGraph::new();
1715        g.add_node(PatchNode {
1716            id: "in_sig".into(),
1717            object_name: "inlet~".into(),
1718            args: vec![],
1719            num_inlets: 1,
1720            num_outlets: 1,
1721            is_signal: true,
1722            varname: None,
1723            hot_inlets: vec![],
1724            purity: NodePurity::Unknown,
1725            attrs: vec![],
1726            code: None,
1727        });
1728        g.add_node(PatchNode {
1729            id: "out_sig".into(),
1730            object_name: "outlet~".into(),
1731            args: vec![],
1732            num_inlets: 1,
1733            num_outlets: 0,
1734            is_signal: true,
1735            varname: None,
1736            hot_inlets: vec![],
1737            purity: NodePurity::Unknown,
1738            attrs: vec![],
1739            code: None,
1740        });
1741        g.add_edge(PatchEdge {
1742            source_id: "in_sig".into(),
1743            source_outlet: 0,
1744            dest_id: "out_sig".into(),
1745            dest_inlet: 0,
1746            is_feedback: false,
1747            order: None,
1748        });
1749
1750        let json_str = generate_with_options(&g, &rnbo_opts()).unwrap();
1751        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1752        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1753
1754        // inlet~ → "in~ 1"
1755        let inlet_box = &boxes[0]["box"];
1756        assert_eq!(inlet_box["maxclass"], "newobj");
1757        assert_eq!(inlet_box["text"], "in~ 1");
1758        let outlettype = inlet_box["outlettype"].as_array().unwrap();
1759        assert_eq!(outlettype, &[json!("signal")]);
1760
1761        // outlet~ → "out~ 1"
1762        let outlet_box = &boxes[1]["box"];
1763        assert_eq!(outlet_box["maxclass"], "newobj");
1764        assert_eq!(outlet_box["text"], "out~ 1");
1765        // outlet is sink: numoutlets = 0, no outlettype
1766        assert_eq!(outlet_box["numoutlets"], 0);
1767        assert!(outlet_box.get("outlettype").is_none());
1768    }
1769
1770    #[test]
1771    fn test_rnbo_serial() {
1772        let graph = make_rnbo_graph();
1773        let json_str = generate_with_options(&graph, &rnbo_opts()).unwrap();
1774        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1775        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1776
1777        // Each box should have rnbo_serial (1-based) and rnbo_uniqueid
1778        for (i, boxval) in boxes.iter().enumerate() {
1779            let b = &boxval["box"];
1780            let serial = b["rnbo_serial"].as_u64().unwrap();
1781            assert_eq!(serial, (i + 1) as u64, "rnbo_serial for box {}", i);
1782
1783            let uniqueid = b["rnbo_uniqueid"].as_str().unwrap();
1784            assert!(!uniqueid.is_empty(), "rnbo_uniqueid should not be empty");
1785        }
1786
1787        // Verify specific uniqueid format: "object_name_obj-N"
1788        let inlet_uid = boxes[0]["box"]["rnbo_uniqueid"].as_str().unwrap();
1789        assert_eq!(inlet_uid, "inlet_obj-1");
1790
1791        let cycle_uid = boxes[1]["box"]["rnbo_uniqueid"].as_str().unwrap();
1792        assert_eq!(cycle_uid, "cycle_tilde_obj-2");
1793
1794        let outlet_uid = boxes[2]["box"]["rnbo_uniqueid"].as_str().unwrap();
1795        assert_eq!(outlet_uid, "outlet_tilde_obj-3");
1796    }
1797
1798    #[test]
1799    fn test_standard_unchanged() {
1800        // Verify generate() (default options) produces standard Max output
1801        let graph = make_rnbo_graph();
1802        let json_str = generate(&graph).unwrap();
1803        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1804        let patcher = parsed.get("patcher").unwrap();
1805
1806        // classnamespace should be "box"
1807        assert_eq!(patcher["classnamespace"], "box");
1808
1809        let boxes = patcher["boxes"].as_array().unwrap();
1810
1811        // inlet should use "inlet" maxclass, not "newobj"
1812        let inlet_box = &boxes[0]["box"];
1813        assert_eq!(inlet_box["maxclass"], "inlet");
1814        // No text field for standard inlet
1815        assert!(inlet_box.get("text").is_none());
1816
1817        // outlet~ should use "outlet" maxclass
1818        let outlet_box = &boxes[2]["box"];
1819        assert_eq!(outlet_box["maxclass"], "outlet");
1820
1821        // No rnbo_serial or rnbo_uniqueid in standard mode
1822        for boxval in boxes {
1823            let b = &boxval["box"];
1824            assert!(
1825                b.get("rnbo_serial").is_none(),
1826                "standard mode should not have rnbo_serial"
1827            );
1828            assert!(
1829                b.get("rnbo_uniqueid").is_none(),
1830                "standard mode should not have rnbo_uniqueid"
1831            );
1832        }
1833    }
1834
1835    #[test]
1836    fn test_rnbo_control_outlet() {
1837        // Test control outlet → "outport name"
1838        let mut g = PatchGraph::new();
1839        g.add_node(PatchNode {
1840            id: "out_ctrl".into(),
1841            object_name: "outlet".into(),
1842            args: vec![],
1843            num_inlets: 1,
1844            num_outlets: 1,
1845            is_signal: false,
1846            varname: Some("result".into()),
1847            hot_inlets: vec![],
1848            purity: NodePurity::Unknown,
1849            attrs: vec![],
1850            code: None,
1851        });
1852
1853        let json_str = generate_with_options(&g, &rnbo_opts()).unwrap();
1854        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1855        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1856
1857        let outlet_box = &boxes[0]["box"];
1858        assert_eq!(outlet_box["maxclass"], "newobj");
1859        assert_eq!(outlet_box["text"], "outport result");
1860        // Control outlet in RNBO is sink: numoutlets = 0
1861        assert_eq!(outlet_box["numoutlets"], 0);
1862    }
1863
1864    #[test]
1865    fn test_rnbo_inport_fallback_name() {
1866        // When no varname, use port_N as fallback
1867        let mut g = PatchGraph::new();
1868        g.add_node(PatchNode {
1869            id: "in_unnamed".into(),
1870            object_name: "inlet".into(),
1871            args: vec![],
1872            num_inlets: 0,
1873            num_outlets: 1,
1874            is_signal: false,
1875            varname: None,
1876            hot_inlets: vec![],
1877            purity: NodePurity::Unknown,
1878            attrs: vec![],
1879            code: None,
1880        });
1881
1882        let json_str = generate_with_options(&g, &rnbo_opts()).unwrap();
1883        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1884        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1885
1886        let inlet_box = &boxes[0]["box"];
1887        assert_eq!(inlet_box["text"], "inport port_0");
1888    }
1889
1890    // ================================================
1891    // Codebox tests
1892    // ================================================
1893
1894    #[test]
1895    fn test_classify_maxclass_codebox() {
1896        // v8.codebox should return "v8.codebox" maxclass
1897        let node = PatchNode {
1898            id: "cb1".into(),
1899            object_name: "v8.codebox".into(),
1900            args: vec![],
1901            num_inlets: 1,
1902            num_outlets: 1,
1903            is_signal: false,
1904            varname: None,
1905            hot_inlets: vec![],
1906            purity: NodePurity::Unknown,
1907            attrs: vec![],
1908            code: None,
1909        };
1910        let (maxclass, width, height) = classify_maxclass(&node, "box");
1911        assert_eq!(maxclass, "v8.codebox");
1912        assert_eq!(width, 200.0);
1913        assert_eq!(height, 100.0);
1914
1915        // codebox (gen~) should return "codebox" maxclass
1916        let node2 = PatchNode {
1917            id: "cb2".into(),
1918            object_name: "codebox".into(),
1919            args: vec![],
1920            num_inlets: 1,
1921            num_outlets: 1,
1922            is_signal: false,
1923            varname: None,
1924            hot_inlets: vec![],
1925            purity: NodePurity::Unknown,
1926            attrs: vec![],
1927            code: None,
1928        };
1929        let (maxclass2, _, _) = classify_maxclass(&node2, "box");
1930        assert_eq!(maxclass2, "codebox");
1931    }
1932
1933    #[test]
1934    fn test_build_box_codebox_with_code() {
1935        // v8.codebox with code field should emit code, filename, and text in JSON
1936        let node = PatchNode {
1937            id: "cb1".into(),
1938            object_name: "v8.codebox".into(),
1939            args: vec![],
1940            num_inlets: 1,
1941            num_outlets: 1,
1942            is_signal: false,
1943            varname: None,
1944            hot_inlets: vec![],
1945            purity: NodePurity::Unknown,
1946            attrs: vec![],
1947            code: Some("function bang() { outlet(0, 42); }".into()),
1948        };
1949
1950        let box_json = build_box(
1951            &node,
1952            &BoxContext {
1953                id: "obj-1",
1954                x: 100.0,
1955                y: 50.0,
1956                classnamespace: "box",
1957                serial: 1,
1958                port_index: None,
1959                ui_data: None,
1960            },
1961        );
1962        let box_obj = &box_json["box"];
1963
1964        assert_eq!(box_obj["maxclass"], "v8.codebox");
1965        assert_eq!(box_obj["code"], "function bang() { outlet(0, 42); }");
1966        assert_eq!(box_obj["filename"], "none");
1967        assert_eq!(box_obj["text"], "");
1968    }
1969
1970    #[test]
1971    fn test_build_box_codebox_without_code() {
1972        // codebox (gen~) without code field should not emit code/filename
1973        let node = PatchNode {
1974            id: "cb1".into(),
1975            object_name: "codebox".into(),
1976            args: vec![],
1977            num_inlets: 1,
1978            num_outlets: 1,
1979            is_signal: false,
1980            varname: None,
1981            hot_inlets: vec![],
1982            purity: NodePurity::Unknown,
1983            attrs: vec![],
1984            code: None,
1985        };
1986
1987        let box_json = build_box(
1988            &node,
1989            &BoxContext {
1990                id: "obj-1",
1991                x: 100.0,
1992                y: 50.0,
1993                classnamespace: "box",
1994                serial: 1,
1995                port_index: None,
1996                ui_data: None,
1997            },
1998        );
1999        let box_obj = &box_json["box"];
2000
2001        assert_eq!(box_obj["maxclass"], "codebox");
2002        assert!(box_obj.get("code").is_none());
2003        assert!(box_obj.get("filename").is_none());
2004    }
2005
2006    #[test]
2007    fn test_standard_codegen_unchanged() {
2008        // Standard generate() still works with existing PatchGraph
2009        let mut g = PatchGraph::new();
2010        g.add_node(PatchNode {
2011            id: "osc".into(),
2012            object_name: "cycle~".into(),
2013            args: vec!["440".into()],
2014            num_inlets: 2,
2015            num_outlets: 1,
2016            is_signal: true,
2017            varname: Some("osc".into()),
2018            hot_inlets: vec![],
2019            purity: NodePurity::Unknown,
2020            attrs: vec![],
2021            code: None,
2022        });
2023
2024        let json_str = generate(&g).unwrap();
2025        let parsed: Value = serde_json::from_str(&json_str).unwrap();
2026        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
2027        assert_eq!(boxes.len(), 1);
2028        assert_eq!(boxes[0]["box"]["maxclass"], "newobj");
2029        assert_eq!(boxes[0]["box"]["text"], "cycle~ 440");
2030        // No code field for regular objects
2031        assert!(boxes[0]["box"].get("code").is_none());
2032    }
2033
2034    #[test]
2035    fn test_gen_mode_classify_inlet_outlet() {
2036        // In gen~ mode, inlet/outlet should become "newobj"
2037        let inlet_node = PatchNode {
2038            id: "in".into(),
2039            object_name: "inlet~".into(),
2040            args: vec![],
2041            num_inlets: 0,
2042            num_outlets: 1,
2043            is_signal: true,
2044            varname: None,
2045            hot_inlets: vec![],
2046            purity: NodePurity::Unknown,
2047            attrs: vec![],
2048            code: None,
2049        };
2050        let (maxclass, _, _) = classify_maxclass(&inlet_node, "dsp.gen");
2051        assert_eq!(maxclass, "newobj");
2052
2053        let outlet_node = PatchNode {
2054            id: "out".into(),
2055            object_name: "outlet~".into(),
2056            args: vec![],
2057            num_inlets: 1,
2058            num_outlets: 0,
2059            is_signal: true,
2060            varname: None,
2061            hot_inlets: vec![],
2062            purity: NodePurity::Unknown,
2063            attrs: vec![],
2064            code: None,
2065        };
2066        let (maxclass, _, _) = classify_maxclass(&outlet_node, "dsp.gen");
2067        assert_eq!(maxclass, "newobj");
2068    }
2069
2070    #[test]
2071    fn test_gen_mode_build_box_text() {
2072        // gen~ mode should generate "in N" / "out N" text
2073        let inlet_node = PatchNode {
2074            id: "in".into(),
2075            object_name: "inlet~".into(),
2076            args: vec![],
2077            num_inlets: 0,
2078            num_outlets: 1,
2079            is_signal: true,
2080            varname: None,
2081            hot_inlets: vec![],
2082            purity: NodePurity::Unknown,
2083            attrs: vec![],
2084            code: None,
2085        };
2086        let box_json = build_box(
2087            &inlet_node,
2088            &BoxContext {
2089                id: "obj-1",
2090                x: 100.0,
2091                y: 50.0,
2092                classnamespace: "dsp.gen",
2093                serial: 1,
2094                port_index: Some(0),
2095                ui_data: None,
2096            },
2097        );
2098        let box_obj = &box_json["box"];
2099        assert_eq!(box_obj["maxclass"], "newobj");
2100        assert_eq!(box_obj["text"], "in 1");
2101        // gen~ should NOT have rnbo_serial/rnbo_uniqueid
2102        assert!(box_obj.get("rnbo_serial").is_none());
2103
2104        let outlet_node = PatchNode {
2105            id: "out".into(),
2106            object_name: "outlet~".into(),
2107            args: vec![],
2108            num_inlets: 1,
2109            num_outlets: 0,
2110            is_signal: true,
2111            varname: None,
2112            hot_inlets: vec![],
2113            purity: NodePurity::Unknown,
2114            attrs: vec![],
2115            code: None,
2116        };
2117        let box_json = build_box(
2118            &outlet_node,
2119            &BoxContext {
2120                id: "obj-2",
2121                x: 100.0,
2122                y: 120.0,
2123                classnamespace: "dsp.gen",
2124                serial: 2,
2125                port_index: Some(0),
2126                ui_data: None,
2127            },
2128        );
2129        let box_obj = &box_json["box"];
2130        assert_eq!(box_obj["maxclass"], "newobj");
2131        assert_eq!(box_obj["text"], "out 1");
2132        assert_eq!(box_obj["numoutlets"], 0); // sink
2133    }
2134
2135    #[test]
2136    fn test_gen_mode_codegen() {
2137        // Full gen~ codegen roundtrip
2138        let mut g = PatchGraph::new();
2139        g.add_node(PatchNode {
2140            id: "in1".into(),
2141            object_name: "inlet~".into(),
2142            args: vec![],
2143            num_inlets: 0,
2144            num_outlets: 1,
2145            is_signal: true,
2146            varname: None,
2147            hot_inlets: vec![],
2148            purity: NodePurity::Unknown,
2149            attrs: vec![],
2150            code: None,
2151        });
2152        g.add_node(PatchNode {
2153            id: "mul".into(),
2154            object_name: "*".into(),
2155            args: vec!["0.5".into()],
2156            num_inlets: 2,
2157            num_outlets: 1,
2158            is_signal: false,
2159            varname: None,
2160            hot_inlets: vec![],
2161            purity: NodePurity::Unknown,
2162            attrs: vec![],
2163            code: None,
2164        });
2165        g.add_node(PatchNode {
2166            id: "out1".into(),
2167            object_name: "outlet~".into(),
2168            args: vec![],
2169            num_inlets: 1,
2170            num_outlets: 0,
2171            is_signal: true,
2172            varname: None,
2173            hot_inlets: vec![],
2174            purity: NodePurity::Unknown,
2175            attrs: vec![],
2176            code: None,
2177        });
2178        g.add_edge(PatchEdge {
2179            source_id: "in1".into(),
2180            source_outlet: 0,
2181            dest_id: "mul".into(),
2182            dest_inlet: 0,
2183            is_feedback: false,
2184            order: None,
2185        });
2186        g.add_edge(PatchEdge {
2187            source_id: "mul".into(),
2188            source_outlet: 0,
2189            dest_id: "out1".into(),
2190            dest_inlet: 0,
2191            is_feedback: false,
2192            order: None,
2193        });
2194
2195        let opts = GenerateOptions {
2196            classnamespace: "dsp.gen".to_string(),
2197        };
2198        let json_str = generate_with_options(&g, &opts).unwrap();
2199        let parsed: Value = serde_json::from_str(&json_str).unwrap();
2200
2201        assert_eq!(parsed["patcher"]["classnamespace"], "dsp.gen");
2202
2203        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
2204        assert_eq!(boxes.len(), 3);
2205
2206        // First box: inlet~ → "in 1"
2207        assert_eq!(boxes[0]["box"]["maxclass"], "newobj");
2208        assert_eq!(boxes[0]["box"]["text"], "in 1");
2209
2210        // Second box: * 0.5
2211        assert_eq!(boxes[1]["box"]["maxclass"], "newobj");
2212        assert_eq!(boxes[1]["box"]["text"], "* 0.5");
2213
2214        // Third box: outlet~ → "out 1"
2215        assert_eq!(boxes[2]["box"]["maxclass"], "newobj");
2216        assert_eq!(boxes[2]["box"]["text"], "out 1");
2217        assert_eq!(boxes[2]["box"]["numoutlets"], 0);
2218    }
2219
2220    // ─── UiData tests ───
2221
2222    #[test]
2223    fn test_ui_data_from_json_basic() {
2224        let json_str = r#"{
2225            "_patcher": { "rect": [50, 50, 800, 600] },
2226            "osc": { "rect": [100, 200, 80, 22] },
2227            "dac": { "rect": [100, 400, 45, 45], "background": 0 }
2228        }"#;
2229        let ui = UiData::from_json(json_str).unwrap();
2230
2231        // Patcher-level settings
2232        assert_eq!(ui.patcher["rect"], json!([50, 50, 800, 600]));
2233
2234        // Per-wire entries
2235        assert!(ui.entries.contains_key("osc"));
2236        assert!(ui.entries.contains_key("dac"));
2237        assert!(!ui.entries.contains_key("_patcher"));
2238        assert_eq!(ui.entries["osc"]["rect"], json!([100, 200, 80, 22]));
2239        assert_eq!(ui.entries["dac"]["background"], json!(0));
2240    }
2241
2242    #[test]
2243    fn test_ui_data_from_json_empty() {
2244        let ui = UiData::from_json("{}").unwrap();
2245        assert!(ui.patcher.is_empty());
2246        assert!(ui.entries.is_empty());
2247    }
2248
2249    #[test]
2250    fn test_ui_data_from_json_invalid() {
2251        assert!(UiData::from_json("not json").is_none());
2252        assert!(UiData::from_json("42").is_none());
2253        assert!(UiData::from_json("[]").is_none());
2254    }
2255
2256    #[test]
2257    fn test_ui_data_from_json_no_patcher() {
2258        let json_str = r#"{ "osc": { "rect": [10, 20, 80, 22] } }"#;
2259        let ui = UiData::from_json(json_str).unwrap();
2260        assert!(ui.patcher.is_empty());
2261        assert_eq!(ui.entries.len(), 1);
2262    }
2263
2264    #[test]
2265    fn test_build_box_with_ui_data_rect_override() {
2266        let node = PatchNode {
2267            id: "osc".into(),
2268            object_name: "cycle~".into(),
2269            args: vec!["440".into()],
2270            num_inlets: 2,
2271            num_outlets: 1,
2272            is_signal: true,
2273            varname: Some("osc".into()),
2274            hot_inlets: vec![],
2275            purity: NodePurity::Unknown,
2276            attrs: vec![],
2277            code: None,
2278        };
2279        let ui = UiData::from_json(r#"{ "osc": { "rect": [250, 350, 90, 24] } }"#).unwrap();
2280
2281        let box_json = build_box(
2282            &node,
2283            &BoxContext {
2284                id: "obj-1",
2285                x: 100.0,
2286                y: 50.0,
2287                classnamespace: "box",
2288                serial: 1,
2289                port_index: None,
2290                ui_data: Some(&ui),
2291            },
2292        );
2293        let rect = box_json["box"]["patching_rect"].as_array().unwrap();
2294
2295        // Should use UI data rect, not auto-layout position
2296        assert_eq!(rect[0], json!(250));
2297        assert_eq!(rect[1], json!(350));
2298        assert_eq!(rect[2], json!(90));
2299        assert_eq!(rect[3], json!(24));
2300    }
2301
2302    #[test]
2303    fn test_build_box_with_ui_data_decorative_attrs() {
2304        let node = PatchNode {
2305            id: "osc".into(),
2306            object_name: "cycle~".into(),
2307            args: vec!["440".into()],
2308            num_inlets: 2,
2309            num_outlets: 1,
2310            is_signal: true,
2311            varname: Some("osc".into()),
2312            hot_inlets: vec![],
2313            purity: NodePurity::Unknown,
2314            attrs: vec![],
2315            code: None,
2316        };
2317        let ui = UiData::from_json(
2318            r#"{
2319            "osc": {
2320                "rect": [250, 350, 90, 24],
2321                "background": 0,
2322                "fontsize": 14
2323            }
2324        }"#,
2325        )
2326        .unwrap();
2327
2328        let box_json = build_box(
2329            &node,
2330            &BoxContext {
2331                id: "obj-1",
2332                x: 100.0,
2333                y: 50.0,
2334                classnamespace: "box",
2335                serial: 1,
2336                port_index: None,
2337                ui_data: Some(&ui),
2338            },
2339        );
2340        let box_obj = &box_json["box"];
2341
2342        // Decorative attributes should be present
2343        assert_eq!(box_obj["background"], json!(0));
2344        assert_eq!(box_obj["fontsize"], json!(14));
2345    }
2346
2347    #[test]
2348    fn test_build_box_without_varname_ignores_ui_data() {
2349        let node = PatchNode {
2350            id: "osc".into(),
2351            object_name: "cycle~".into(),
2352            args: vec!["440".into()],
2353            num_inlets: 2,
2354            num_outlets: 1,
2355            is_signal: true,
2356            varname: None, // no varname
2357            hot_inlets: vec![],
2358            purity: NodePurity::Unknown,
2359            attrs: vec![],
2360            code: None,
2361        };
2362        let ui = UiData::from_json(r#"{ "osc": { "rect": [250, 350, 90, 24] } }"#).unwrap();
2363
2364        let box_json = build_box(
2365            &node,
2366            &BoxContext {
2367                id: "obj-1",
2368                x: 100.0,
2369                y: 50.0,
2370                classnamespace: "box",
2371                serial: 1,
2372                port_index: None,
2373                ui_data: Some(&ui),
2374            },
2375        );
2376        let rect = box_json["box"]["patching_rect"].as_array().unwrap();
2377
2378        // Should use auto-layout position since there's no varname to match
2379        assert_eq!(rect[0], json!(100.0));
2380        assert_eq!(rect[1], json!(50.0));
2381    }
2382
2383    #[test]
2384    fn test_build_patcher_with_ui_data_patcher_rect() {
2385        let mut g = PatchGraph::new();
2386        g.add_node(PatchNode {
2387            id: "osc".into(),
2388            object_name: "cycle~".into(),
2389            args: vec!["440".into()],
2390            num_inlets: 2,
2391            num_outlets: 1,
2392            is_signal: true,
2393            varname: Some("osc".into()),
2394            hot_inlets: vec![],
2395            purity: NodePurity::Unknown,
2396            attrs: vec![],
2397            code: None,
2398        });
2399
2400        let ui = UiData::from_json(
2401            r#"{
2402            "_patcher": { "rect": [50, 50, 800, 600] },
2403            "osc": { "rect": [200, 300, 80, 22] }
2404        }"#,
2405        )
2406        .unwrap();
2407
2408        let json_str = generate_with_ui(&g, &GenerateOptions::default(), Some(&ui)).unwrap();
2409        let parsed: Value = serde_json::from_str(&json_str).unwrap();
2410
2411        // Patcher rect should come from UI data
2412        assert_eq!(parsed["patcher"]["rect"], json!([50, 50, 800, 600]));
2413
2414        // Box rect should come from UI data
2415        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
2416        assert_eq!(boxes[0]["box"]["patching_rect"], json!([200, 300, 80, 22]));
2417    }
2418
2419    #[test]
2420    fn test_generate_with_ui_none_is_same_as_generate() {
2421        let graph = make_minimal_graph();
2422
2423        let json_without = generate(&graph).unwrap();
2424        let json_with_none = generate_with_ui(&graph, &GenerateOptions::default(), None).unwrap();
2425
2426        // Both should produce identical output
2427        assert_eq!(json_without, json_with_none);
2428    }
2429}