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    let effective_num_outlets =
428        if (is_rnbo || is_gen) && matches!(node.object_name.as_str(), "outlet" | "outlet~") {
429            0
430        } else {
431            node.num_outlets
432        };
433
434    let mut box_obj = Map::new();
435    box_obj.insert("id".into(), json!(ctx.id));
436    box_obj.insert("maxclass".into(), json!(maxclass));
437    box_obj.insert("numinlets".into(), json!(node.num_inlets));
438    box_obj.insert("numoutlets".into(), json!(effective_num_outlets));
439
440    if !outlettype.is_empty() {
441        box_obj.insert("outlettype".into(), json!(outlettype));
442    }
443
444    box_obj.insert("patching_rect".into(), json!([ctx.x, ctx.y, width, height]));
445
446    // text field: for newobj and message
447    if maxclass == "newobj" {
448        let text = if is_rnbo {
449            // RNBO mode: inlet/outlet → inport/outport or in~/out~ text
450            match node.object_name.as_str() {
451                "inlet" => {
452                    let name = node
453                        .varname
454                        .clone()
455                        .unwrap_or_else(|| format!("port_{}", ctx.port_index.unwrap_or(0)));
456                    format!("inport {}", name)
457                }
458                "inlet~" => {
459                    let idx = ctx.port_index.unwrap_or(0) + 1; // RNBO uses 1-based
460                    format!("in~ {}", idx)
461                }
462                "outlet" => {
463                    let name = node
464                        .varname
465                        .clone()
466                        .unwrap_or_else(|| format!("port_{}", ctx.port_index.unwrap_or(0)));
467                    format!("outport {}", name)
468                }
469                "outlet~" => {
470                    let idx = ctx.port_index.unwrap_or(0) + 1; // RNBO uses 1-based
471                    format!("out~ {}", idx)
472                }
473                _ => {
474                    let mut t = build_object_text(node);
475                    if !node.attrs.is_empty() {
476                        let attr_str: String = node
477                            .attrs
478                            .iter()
479                            .map(|(k, v)| format!("@{} {}", k, v))
480                            .collect::<Vec<_>>()
481                            .join(" ");
482                        t = format!("{} {}", t, attr_str);
483                    }
484                    t
485                }
486            }
487        } else if is_gen {
488            // gen~ mode: inlet/outlet → `in N` / `out N` (1-based)
489            match node.object_name.as_str() {
490                "inlet" | "inlet~" => {
491                    let idx = ctx.port_index.unwrap_or(0) + 1; // gen~ uses 1-based
492                    format!("in {}", idx)
493                }
494                "outlet" | "outlet~" => {
495                    let idx = ctx.port_index.unwrap_or(0) + 1; // gen~ uses 1-based
496                    format!("out {}", idx)
497                }
498                _ => {
499                    let mut t = build_object_text(node);
500                    if !node.attrs.is_empty() {
501                        let attr_str: String = node
502                            .attrs
503                            .iter()
504                            .map(|(k, v)| format!("@{} {}", k, v))
505                            .collect::<Vec<_>>()
506                            .join(" ");
507                        t = format!("{} {}", t, attr_str);
508                    }
509                    t
510                }
511            }
512        } else {
513            let mut t = build_object_text(node);
514            // newobj: append .attr() attributes as @key value to text
515            if !node.attrs.is_empty() {
516                let attr_str: String = node
517                    .attrs
518                    .iter()
519                    .map(|(k, v)| format!("@{} {}", k, v))
520                    .collect::<Vec<_>>()
521                    .join(" ");
522                t = format!("{} {}", t, attr_str);
523            }
524            t
525        };
526        box_obj.insert("text".into(), json!(text));
527    } else if maxclass == "message" {
528        // message box: text uses the content (args[0]) as-is
529        let text = if node.args.is_empty() {
530            String::new()
531        } else {
532            node.args.join(" ")
533        };
534        box_obj.insert("text".into(), json!(text));
535    }
536
537    // varname: output flutmax wire name as Max varname attribute
538    if let Some(ref vn) = node.varname {
539        box_obj.insert("varname".into(), json!(vn));
540    }
541
542    // UI objects (non-newobj): output .attr() attributes as top-level fields in box JSON
543    if maxclass != "newobj" && !node.attrs.is_empty() {
544        for (key, value) in &node.attrs {
545            // Output as number if parseable, otherwise as string
546            if let Ok(f) = value.parse::<f64>() {
547                box_obj.insert(key.clone(), json!(f));
548            } else {
549                box_obj.insert(key.clone(), json!(value));
550            }
551        }
552    }
553
554    // Codebox: emit code field and special attributes
555    if matches!(maxclass, "v8.codebox" | "codebox") {
556        if let Some(ref code) = node.code {
557            box_obj.insert("code".into(), json!(code));
558        }
559        if maxclass == "v8.codebox" {
560            box_obj.insert("filename".into(), json!("none"));
561            // v8.codebox uses empty text (code is in the code field)
562            if !box_obj.contains_key("text") {
563                box_obj.insert("text".into(), json!(""));
564            }
565        }
566    }
567
568    // .uiflutmax UI data: override position and add decorative attributes
569    if let Some(ui_entry) = ctx
570        .ui_data
571        .and_then(|ui| node.varname.as_ref().and_then(|vn| ui.entries.get(vn)))
572    {
573        // Override position from UI data
574        if let Some(rect) = ui_entry.get("rect") {
575            box_obj.insert("patching_rect".into(), rect.clone());
576        }
577        // Add decorative attributes (everything except "rect")
578        if let Some(obj) = ui_entry.as_object() {
579            for (k, v) in obj {
580                if k != "rect" {
581                    box_obj.insert(k.clone(), v.clone());
582                }
583            }
584        }
585    }
586
587    // RNBO mode: add rnbo_serial and rnbo_uniqueid
588    if is_rnbo {
589        box_obj.insert("rnbo_serial".into(), json!(ctx.serial));
590        box_obj.insert(
591            "rnbo_uniqueid".into(),
592            json!(format!(
593                "{}_{}",
594                node.object_name.replace('~', "_tilde"),
595                ctx.id
596            )),
597        );
598    }
599
600    json!({ "box": Value::Object(box_obj) })
601}
602
603/// Determine maxclass from PatchNode object_name.
604/// Returns: (maxclass, width, height)
605fn classify_maxclass(node: &PatchNode, classnamespace: &str) -> (&'static str, f64, f64) {
606    let is_rnbo = classnamespace == "rnbo";
607    let is_gen = classnamespace == "dsp.gen";
608    match node.object_name.as_str() {
609        "inlet" | "inlet~" if is_rnbo || is_gen => ("newobj", BOX_WIDTH_NEWOBJ, BOX_HEIGHT_NEWOBJ),
610        "outlet" | "outlet~" if is_rnbo || is_gen => {
611            ("newobj", BOX_WIDTH_NEWOBJ, BOX_HEIGHT_NEWOBJ)
612        }
613        "inlet" => ("inlet", BOX_WIDTH_INLET_OUTLET, BOX_HEIGHT_INLET_OUTLET),
614        "inlet~" => ("inlet", BOX_WIDTH_INLET_OUTLET, BOX_HEIGHT_INLET_OUTLET),
615        "outlet" => ("outlet", BOX_WIDTH_INLET_OUTLET, BOX_HEIGHT_INLET_OUTLET),
616        "outlet~" => ("outlet", BOX_WIDTH_INLET_OUTLET, BOX_HEIGHT_INLET_OUTLET),
617        "ezdac~" => ("ezdac~", BOX_WIDTH_EZDAC, BOX_HEIGHT_EZDAC),
618        "message" => ("message", 50.0, 22.0),
619        "button" => ("button", 50.0, 50.0),
620        "flonum" => ("flonum", 80.0, 22.0),
621        "number" => ("number", 50.0, 22.0),
622        "toggle" => ("toggle", 20.0, 20.0),
623        "umenu" => ("umenu", 100.0, 22.0),
624        "panel" => ("panel", 100.0, 50.0),
625        "jsui" => ("jsui", 64.0, 64.0),
626        // Additional UI objects
627        "textbutton" => ("textbutton", 100.0, 20.0),
628        "live.text" => ("live.text", 44.0, 15.0),
629        "live.dial" => ("live.dial", 47.0, 48.0),
630        "live.toggle" => ("live.toggle", 15.0, 15.0),
631        "live.menu" => ("live.menu", 100.0, 15.0),
632        "live.numbox" => ("live.numbox", 44.0, 15.0),
633        "live.tab" => ("live.tab", 100.0, 20.0),
634        "live.comment" => ("live.comment", 100.0, 18.0),
635        "slider" => ("slider", 20.0, 140.0),
636        "dial" => ("dial", 40.0, 40.0),
637        "multislider" => ("multislider", 120.0, 100.0),
638        "kslider" => ("kslider", 168.0, 53.0),
639        "tab" => ("tab", 200.0, 24.0),
640        "rslider" => ("rslider", 100.0, 22.0),
641        "filtergraph~" => ("filtergraph~", 256.0, 128.0),
642        "spectroscope~" => ("spectroscope~", 300.0, 100.0),
643        "scope~" => ("scope~", 130.0, 130.0),
644        "meter~" => ("meter~", 13.0, 80.0),
645        "gain~" => ("gain~", 22.0, 140.0),
646        "ezadc~" => ("ezadc~", BOX_WIDTH_EZDAC, BOX_HEIGHT_EZDAC),
647        "number~" => ("number~", 56.0, 22.0),
648        "bpatcher" => ("bpatcher", 128.0, 128.0),
649        "fpic" => ("fpic", 100.0, 100.0),
650        "textedit" => ("textedit", 100.0, 22.0),
651        "attrui" => ("attrui", 150.0, 22.0),
652        "nslider" => ("nslider", 50.0, 120.0),
653        "preset" => ("preset", 100.0, 40.0),
654        // Codebox objects
655        "v8.codebox" => ("v8.codebox", 200.0, 100.0),
656        "codebox" => ("codebox", 200.0, 100.0),
657        _ => ("newobj", BOX_WIDTH_NEWOBJ, BOX_HEIGHT_NEWOBJ),
658    }
659}
660
661/// Compute the outlettype array for an object.
662fn compute_outlettype(node: &PatchNode, is_rnbo: bool, is_gen: bool) -> Vec<&'static str> {
663    // RNBO mode: outlet/outport is a sink, so no outlettype
664    // gen~ mode: `out N` is a sink, so no outlettype
665    if (is_rnbo || is_gen) && matches!(node.object_name.as_str(), "outlet" | "outlet~") {
666        return vec![];
667    }
668
669    if node.num_outlets == 0 {
670        return vec![];
671    }
672
673    match node.object_name.as_str() {
674        // RNBO mode: inport (control inlet) → outlettype = [""]
675        "inlet" if is_rnbo => vec![""],
676        // RNBO mode: in~ (signal inlet) → outlettype = ["signal"]
677        "inlet~" if is_rnbo => vec!["signal"],
678
679        // gen~ mode: `in N` (all I/O is signal) → outlettype = [""]
680        "inlet" | "inlet~" if is_gen => vec![""],
681
682        // inlet/inlet~ has one outlettype
683        "inlet" => vec![""],
684        "inlet~" => vec!["signal"],
685
686        // message box
687        "message" => vec![""],
688
689        // UI objects
690        "button" => vec!["bang"],
691        "toggle" => vec!["int"],
692        "umenu" => vec!["int", "", ""],
693        "flonum" => vec!["", "bang"],
694        "number" => vec!["", "bang"],
695        "textbutton" => vec!["", "", "int"],
696        "live.text" => vec!["", ""],
697        "live.dial" => vec!["", ""],
698        "live.toggle" => vec![""],
699        "live.menu" => vec!["", "", ""],
700        "live.numbox" => vec!["", ""],
701        "live.tab" => vec!["", "", ""],
702        "live.comment" => vec![],
703        "slider" => vec![""],
704        "dial" => vec![""],
705        "multislider" => vec!["", ""],
706        "kslider" => vec!["", ""],
707        "tab" => vec!["", "", ""],
708        "rslider" => vec!["", ""],
709        "bpatcher" => {
710            // bpatcher outlet count depends on the patch. Use node.num_outlets
711            vec![""; node.num_outlets as usize]
712        }
713
714        // Signal objects: all outlets are "signal"
715        name if name.ends_with('~') => {
716            let mut types = vec!["signal"];
717            // For objects like line~ with 2+ outlets: the last may be "bang"
718            if name == "line~" && node.num_outlets >= 2 {
719                types = vec!["signal", "bang"];
720            }
721            // Keep as-is if already sufficient, otherwise pad with signal
722            while types.len() < node.num_outlets as usize {
723                types.push("signal");
724            }
725            types.truncate(node.num_outlets as usize);
726            types
727        }
728
729        // Codebox objects
730        "v8.codebox" | "codebox" => {
731            vec![""; node.num_outlets as usize]
732        }
733
734        // Control objects
735        "trigger" | "t" => {
736            // trigger outlet types depend on arg types; simplified to "" padding
737            vec![""; node.num_outlets as usize]
738        }
739
740        _ => {
741            // Use "signal" when is_signal is set (e.g., for Abstractions)
742            if node.is_signal {
743                vec!["signal"; node.num_outlets as usize]
744            } else {
745                // Default: set all outlets to "" (generic message)
746                vec![""; node.num_outlets as usize]
747            }
748        }
749    }
750}
751
752/// Generate object text from a PatchNode.
753/// e.g., object_name="cycle~", args=["440"] -> "cycle~ 440"
754fn build_object_text(node: &PatchNode) -> String {
755    if node.args.is_empty() {
756        node.object_name.clone()
757    } else {
758        format!("{} {}", node.object_name, node.args.join(" "))
759    }
760}
761
762/// Sort nodes in topological order.
763/// inlet -> processing objects -> outlet order.
764/// Not a full topological sort; a simplified classification-based reordering.
765fn topological_order(graph: &PatchGraph) -> Vec<&PatchNode> {
766    let mut inlets: Vec<&PatchNode> = Vec::new();
767    let mut outlets: Vec<&PatchNode> = Vec::new();
768    let mut others: Vec<&PatchNode> = Vec::new();
769
770    for node in &graph.nodes {
771        match node.object_name.as_str() {
772            "inlet" | "inlet~" => inlets.push(node),
773            "outlet" | "outlet~" => outlets.push(node),
774            _ => others.push(node),
775        }
776    }
777
778    // Maintain original order within each category
779    let mut result = Vec::with_capacity(graph.nodes.len());
780    result.extend(inlets);
781    result.extend(others);
782    result.extend(outlets);
783    result
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use flutmax_sema::graph::{NodePurity, PatchEdge, PatchNode};
790
791    /// Minimal graph: cycle~ 440 -> ezdac~
792    fn make_minimal_graph() -> PatchGraph {
793        let mut g = PatchGraph::new();
794        g.add_node(PatchNode {
795            id: "osc".into(),
796            object_name: "cycle~".into(),
797            args: vec!["440".into()],
798            num_inlets: 2,
799            num_outlets: 1,
800            is_signal: true,
801            varname: None,
802            hot_inlets: vec![],
803            purity: NodePurity::Unknown,
804            attrs: vec![],
805            code: None,
806        });
807        g.add_node(PatchNode {
808            id: "dac".into(),
809            object_name: "ezdac~".into(),
810            args: vec![],
811            num_inlets: 2,
812            num_outlets: 0,
813            is_signal: true,
814            varname: None,
815            hot_inlets: vec![],
816            purity: NodePurity::Unknown,
817            attrs: vec![],
818            code: None,
819        });
820        g.add_edge(PatchEdge {
821            source_id: "osc".into(),
822            source_outlet: 0,
823            dest_id: "dac".into(),
824            dest_inlet: 0,
825            is_feedback: false,
826            order: None,
827        });
828        g.add_edge(PatchEdge {
829            source_id: "osc".into(),
830            source_outlet: 0,
831            dest_id: "dac".into(),
832            dest_inlet: 1,
833            is_feedback: false,
834            order: None,
835        });
836        g
837    }
838
839    /// Graph: inlet -> cycle~ -> *~ -> outlet~
840    fn make_l2_graph() -> PatchGraph {
841        let mut g = PatchGraph::new();
842        g.add_node(PatchNode {
843            id: "in_freq".into(),
844            object_name: "inlet".into(),
845            args: vec![],
846            num_inlets: 0,
847            num_outlets: 1,
848            is_signal: false,
849            varname: None,
850            hot_inlets: vec![],
851            purity: NodePurity::Unknown,
852            attrs: vec![],
853            code: None,
854        });
855        g.add_node(PatchNode {
856            id: "cycle".into(),
857            object_name: "cycle~".into(),
858            args: vec![],
859            num_inlets: 2,
860            num_outlets: 1,
861            is_signal: true,
862            varname: None,
863            hot_inlets: vec![],
864            purity: NodePurity::Unknown,
865            attrs: vec![],
866            code: None,
867        });
868        g.add_node(PatchNode {
869            id: "mul".into(),
870            object_name: "*~".into(),
871            args: vec!["0.5".into()],
872            num_inlets: 2,
873            num_outlets: 1,
874            is_signal: true,
875            varname: None,
876            hot_inlets: vec![],
877            purity: NodePurity::Unknown,
878            attrs: vec![],
879            code: None,
880        });
881        g.add_node(PatchNode {
882            id: "out_audio".into(),
883            object_name: "outlet~".into(),
884            args: vec![],
885            num_inlets: 1,
886            num_outlets: 0,
887            is_signal: true,
888            varname: None,
889            hot_inlets: vec![],
890            purity: NodePurity::Unknown,
891            attrs: vec![],
892            code: None,
893        });
894        g.add_edge(PatchEdge {
895            source_id: "in_freq".into(),
896            source_outlet: 0,
897            dest_id: "cycle".into(),
898            dest_inlet: 0,
899            is_feedback: false,
900            order: None,
901        });
902        g.add_edge(PatchEdge {
903            source_id: "cycle".into(),
904            source_outlet: 0,
905            dest_id: "mul".into(),
906            dest_inlet: 0,
907            is_feedback: false,
908            order: None,
909        });
910        g.add_edge(PatchEdge {
911            source_id: "mul".into(),
912            source_outlet: 0,
913            dest_id: "out_audio".into(),
914            dest_inlet: 0,
915            is_feedback: false,
916            order: None,
917        });
918        g
919    }
920
921    #[test]
922    fn test_generate_valid_json() {
923        let graph = make_minimal_graph();
924        let json_str = generate(&graph).unwrap();
925
926        // Must be parseable JSON
927        let parsed: Value = serde_json::from_str(&json_str).unwrap();
928        assert!(parsed.is_object());
929        assert!(parsed.get("patcher").is_some());
930    }
931
932    #[test]
933    fn test_patcher_fixed_fields() {
934        let graph = make_minimal_graph();
935        let json_str = generate(&graph).unwrap();
936        let parsed: Value = serde_json::from_str(&json_str).unwrap();
937        let patcher = parsed.get("patcher").unwrap();
938
939        assert_eq!(patcher["fileversion"], 1);
940        assert_eq!(patcher["appversion"]["major"], 8);
941        assert_eq!(patcher["appversion"]["minor"], 6);
942        assert_eq!(patcher["classnamespace"], "box");
943        assert_eq!(patcher["default_fontname"], "Arial");
944        assert_eq!(patcher["autosave"], 0);
945    }
946
947    #[test]
948    fn test_boxes_count() {
949        let graph = make_minimal_graph();
950        let json_str = generate(&graph).unwrap();
951        let parsed: Value = serde_json::from_str(&json_str).unwrap();
952        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
953        assert_eq!(boxes.len(), 2);
954    }
955
956    #[test]
957    fn test_box_structure() {
958        let graph = make_minimal_graph();
959        let json_str = generate(&graph).unwrap();
960        let parsed: Value = serde_json::from_str(&json_str).unwrap();
961        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
962
963        // cycle~ box
964        let cycle_box = &boxes[0]["box"];
965        assert_eq!(cycle_box["id"], "obj-1");
966        assert_eq!(cycle_box["maxclass"], "newobj");
967        assert_eq!(cycle_box["numinlets"], 2);
968        assert_eq!(cycle_box["numoutlets"], 1);
969        assert_eq!(cycle_box["text"], "cycle~ 440");
970        let outlettype = cycle_box["outlettype"].as_array().unwrap();
971        assert_eq!(outlettype.len(), 1);
972        assert_eq!(outlettype[0], "signal");
973
974        // ezdac~ box
975        let dac_box = &boxes[1]["box"];
976        assert_eq!(dac_box["id"], "obj-2");
977        assert_eq!(dac_box["maxclass"], "ezdac~");
978        assert_eq!(dac_box["numinlets"], 2);
979        assert_eq!(dac_box["numoutlets"], 0);
980        // ezdac~ has no outlettype (0 outlets)
981        assert!(dac_box.get("outlettype").is_none());
982    }
983
984    #[test]
985    fn test_lines_count() {
986        let graph = make_minimal_graph();
987        let json_str = generate(&graph).unwrap();
988        let parsed: Value = serde_json::from_str(&json_str).unwrap();
989        let lines = parsed["patcher"]["lines"].as_array().unwrap();
990        assert_eq!(lines.len(), 2);
991    }
992
993    #[test]
994    fn test_line_structure() {
995        let graph = make_minimal_graph();
996        let json_str = generate(&graph).unwrap();
997        let parsed: Value = serde_json::from_str(&json_str).unwrap();
998        let lines = parsed["patcher"]["lines"].as_array().unwrap();
999
1000        // Both have source "obj-1" (cycle~), dest "obj-2" (ezdac~)
1001        for line in lines {
1002            let patchline = &line["patchline"];
1003            let source = patchline["source"].as_array().unwrap();
1004            let dest = patchline["destination"].as_array().unwrap();
1005
1006            assert_eq!(source[0], "obj-1");
1007            assert_eq!(source[1], 0);
1008            assert_eq!(dest[0], "obj-2");
1009            // dest_inlet is 0 or 1
1010            let inlet = dest[1].as_u64().unwrap();
1011            assert!(inlet == 0 || inlet == 1);
1012        }
1013    }
1014
1015    #[test]
1016    fn test_patching_rect_layout() {
1017        let graph = make_minimal_graph();
1018        let json_str = generate(&graph).unwrap();
1019        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1020        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1021
1022        let rect0 = boxes[0]["box"]["patching_rect"].as_array().unwrap();
1023        let rect1 = boxes[1]["box"]["patching_rect"].as_array().unwrap();
1024
1025        // Sugiyama layout: linear chain → both at same x column
1026        let x0 = rect0[0].as_f64().unwrap();
1027        let x1 = rect1[0].as_f64().unwrap();
1028        assert_eq!(x0, x1, "linear chain nodes should share the same x");
1029
1030        // Y increases sequentially (osc in layer 0, dac in layer 1)
1031        let y0 = rect0[1].as_f64().unwrap();
1032        let y1 = rect1[1].as_f64().unwrap();
1033        assert!(y1 > y0, "downstream node should have larger y");
1034    }
1035
1036    #[test]
1037    fn test_l2_topological_order() {
1038        let graph = make_l2_graph();
1039        let json_str = generate(&graph).unwrap();
1040        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1041        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1042
1043        // Topological order: inlet -> cycle~ -> *~ -> outlet~
1044        assert_eq!(boxes[0]["box"]["maxclass"], "inlet");
1045        assert_eq!(boxes[1]["box"]["text"], "cycle~");
1046        assert_eq!(boxes[2]["box"]["text"], "*~ 0.5");
1047        assert_eq!(boxes[3]["box"]["maxclass"], "outlet");
1048    }
1049
1050    #[test]
1051    fn test_inlet_outlettype() {
1052        let graph = make_l2_graph();
1053        let json_str = generate(&graph).unwrap();
1054        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1055        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1056
1057        // inlet outlettype is [""]
1058        let inlet_box = &boxes[0]["box"];
1059        assert_eq!(inlet_box["maxclass"], "inlet");
1060        let outlettype = inlet_box["outlettype"].as_array().unwrap();
1061        assert_eq!(outlettype.len(), 1);
1062        assert_eq!(outlettype[0], "");
1063    }
1064
1065    #[test]
1066    fn test_outlet_tilde_maxclass() {
1067        let graph = make_l2_graph();
1068        let json_str = generate(&graph).unwrap();
1069        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1070        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1071
1072        // outlet~ maxclass is "outlet~"
1073        let outlet_box = &boxes[3]["box"];
1074        assert_eq!(outlet_box["maxclass"], "outlet");
1075        assert_eq!(outlet_box["numinlets"], 1);
1076        assert_eq!(outlet_box["numoutlets"], 0);
1077    }
1078
1079    #[test]
1080    fn test_empty_graph() {
1081        let graph = PatchGraph::new();
1082        let json_str = generate(&graph).unwrap();
1083        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1084
1085        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1086        let lines = parsed["patcher"]["lines"].as_array().unwrap();
1087        assert_eq!(boxes.len(), 0);
1088        assert_eq!(lines.len(), 0);
1089    }
1090
1091    #[test]
1092    fn test_dependency_cache_empty() {
1093        let graph = make_minimal_graph();
1094        let json_str = generate(&graph).unwrap();
1095        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1096
1097        let dep_cache = parsed["patcher"]["dependency_cache"].as_array().unwrap();
1098        assert_eq!(dep_cache.len(), 0);
1099    }
1100
1101    #[test]
1102    fn test_build_object_text_no_args() {
1103        let node = PatchNode {
1104            id: "test".into(),
1105            object_name: "cycle~".into(),
1106            args: vec![],
1107            num_inlets: 2,
1108            num_outlets: 1,
1109            is_signal: true,
1110            varname: None,
1111            hot_inlets: vec![],
1112            purity: NodePurity::Unknown,
1113            attrs: vec![],
1114            code: None,
1115        };
1116        assert_eq!(build_object_text(&node), "cycle~");
1117    }
1118
1119    #[test]
1120    fn test_build_object_text_with_args() {
1121        let node = PatchNode {
1122            id: "test".into(),
1123            object_name: "cycle~".into(),
1124            args: vec!["440".into()],
1125            num_inlets: 2,
1126            num_outlets: 1,
1127            is_signal: true,
1128            varname: None,
1129            hot_inlets: vec![],
1130            purity: NodePurity::Unknown,
1131            attrs: vec![],
1132            code: None,
1133        };
1134        assert_eq!(build_object_text(&node), "cycle~ 440");
1135    }
1136
1137    #[test]
1138    fn test_build_object_text_multiple_args() {
1139        let node = PatchNode {
1140            id: "test".into(),
1141            object_name: "trigger".into(),
1142            args: vec!["b".into(), "b".into(), "b".into()],
1143            num_inlets: 1,
1144            num_outlets: 3,
1145            is_signal: false,
1146            varname: None,
1147            hot_inlets: vec![],
1148            purity: NodePurity::Unknown,
1149            attrs: vec![],
1150            code: None,
1151        };
1152        assert_eq!(build_object_text(&node), "trigger b b b");
1153    }
1154
1155    #[test]
1156    fn test_classify_maxclass_inlet() {
1157        let node = PatchNode {
1158            id: "test".into(),
1159            object_name: "inlet".into(),
1160            args: vec![],
1161            num_inlets: 0,
1162            num_outlets: 1,
1163            is_signal: false,
1164            varname: None,
1165            hot_inlets: vec![],
1166            purity: NodePurity::Unknown,
1167            attrs: vec![],
1168            code: None,
1169        };
1170        let (maxclass, _, _) = classify_maxclass(&node, "box");
1171        assert_eq!(maxclass, "inlet");
1172    }
1173
1174    #[test]
1175    fn test_classify_maxclass_inlet_tilde() {
1176        // inlet~ uses the same maxclass "inlet" internally in Max,
1177        // In actual Max patches, signal inlets also use "inlet" maxclass.
1178        let node = PatchNode {
1179            id: "test".into(),
1180            object_name: "inlet~".into(),
1181            args: vec![],
1182            num_inlets: 1,
1183            num_outlets: 1,
1184            is_signal: true,
1185            varname: None,
1186            hot_inlets: vec![],
1187            purity: NodePurity::Unknown,
1188            attrs: vec![],
1189            code: None,
1190        };
1191        let (maxclass, _, _) = classify_maxclass(&node, "box");
1192        assert_eq!(maxclass, "inlet");
1193    }
1194
1195    #[test]
1196    fn test_classify_maxclass_newobj() {
1197        let node = PatchNode {
1198            id: "test".into(),
1199            object_name: "cycle~".into(),
1200            args: vec!["440".into()],
1201            num_inlets: 2,
1202            num_outlets: 1,
1203            is_signal: true,
1204            varname: None,
1205            hot_inlets: vec![],
1206            purity: NodePurity::Unknown,
1207            attrs: vec![],
1208            code: None,
1209        };
1210        let (maxclass, _, _) = classify_maxclass(&node, "box");
1211        assert_eq!(maxclass, "newobj");
1212    }
1213
1214    #[test]
1215    fn test_compute_outlettype_signal() {
1216        let node = PatchNode {
1217            id: "test".into(),
1218            object_name: "cycle~".into(),
1219            args: vec![],
1220            num_inlets: 2,
1221            num_outlets: 1,
1222            is_signal: true,
1223            varname: None,
1224            hot_inlets: vec![],
1225            purity: NodePurity::Unknown,
1226            attrs: vec![],
1227            code: None,
1228        };
1229        let types = compute_outlettype(&node, false, false);
1230        assert_eq!(types, vec!["signal"]);
1231    }
1232
1233    #[test]
1234    fn test_compute_outlettype_no_outlets() {
1235        let node = PatchNode {
1236            id: "test".into(),
1237            object_name: "ezdac~".into(),
1238            args: vec![],
1239            num_inlets: 2,
1240            num_outlets: 0,
1241            is_signal: true,
1242            varname: None,
1243            hot_inlets: vec![],
1244            purity: NodePurity::Unknown,
1245            attrs: vec![],
1246            code: None,
1247        };
1248        let types = compute_outlettype(&node, false, false);
1249        assert!(types.is_empty());
1250    }
1251
1252    #[test]
1253    fn test_roundtrip_l2() {
1254        // AST -> PatchGraph -> JSON -> parse -> structural verification
1255        use crate::builder::build_graph;
1256        use flutmax_ast::*;
1257
1258        let prog = Program {
1259            in_decls: vec![InDecl {
1260                index: 0,
1261                name: "freq".to_string(),
1262                port_type: PortType::Float,
1263            }],
1264            out_decls: vec![OutDecl {
1265                index: 0,
1266                name: "audio".to_string(),
1267                port_type: PortType::Signal,
1268                value: None,
1269            }],
1270            wires: vec![
1271                Wire {
1272                    name: "osc".to_string(),
1273                    value: Expr::Call {
1274                        object: "cycle~".to_string(),
1275                        args: vec![CallArg::positional(Expr::Ref("freq".to_string()))],
1276                    },
1277                    span: None,
1278                    attrs: vec![],
1279                },
1280                Wire {
1281                    name: "amp".to_string(),
1282                    value: Expr::Call {
1283                        object: "mul~".to_string(),
1284                        args: vec![
1285                            CallArg::positional(Expr::Ref("osc".to_string())),
1286                            CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
1287                        ],
1288                    },
1289                    span: None,
1290                    attrs: vec![],
1291                },
1292            ],
1293            destructuring_wires: vec![],
1294            msg_decls: vec![],
1295            out_assignments: vec![OutAssignment {
1296                index: 0,
1297                value: Expr::Ref("amp".to_string()),
1298                span: None,
1299            }],
1300            direct_connections: vec![],
1301            feedback_decls: vec![],
1302            feedback_assignments: vec![],
1303            state_decls: vec![],
1304            state_assignments: vec![],
1305        };
1306
1307        let graph = build_graph(&prog).unwrap();
1308        let json_str = generate(&graph).unwrap();
1309        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1310
1311        let patcher = &parsed["patcher"];
1312        let boxes = patcher["boxes"].as_array().unwrap();
1313        let lines = patcher["lines"].as_array().unwrap();
1314
1315        // 4 nodes: inlet, cycle~, *~, outlet~
1316        assert_eq!(boxes.len(), 4);
1317        // 3 edges: inlet->cycle~, cycle~->*~, *~->outlet~
1318        assert_eq!(lines.len(), 3);
1319
1320        // First box is inlet
1321        assert_eq!(boxes[0]["box"]["maxclass"], "inlet");
1322
1323        // Last box is outlet~
1324        assert_eq!(boxes[3]["box"]["maxclass"], "outlet");
1325    }
1326
1327    #[test]
1328    fn test_unique_ids() {
1329        let graph = make_l2_graph();
1330        let json_str = generate(&graph).unwrap();
1331        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1332        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1333
1334        let ids: Vec<&str> = boxes
1335            .iter()
1336            .map(|b| b["box"]["id"].as_str().unwrap())
1337            .collect();
1338
1339        // All IDs are unique
1340        let mut unique_ids = ids.clone();
1341        unique_ids.sort();
1342        unique_ids.dedup();
1343        assert_eq!(ids.len(), unique_ids.len());
1344    }
1345
1346    #[test]
1347    fn test_message_box_output() {
1348        let mut g = PatchGraph::new();
1349        g.add_node(PatchNode {
1350            id: "msg1".into(),
1351            object_name: "message".into(),
1352            args: vec!["bang".into()],
1353            num_inlets: 2,
1354            num_outlets: 1,
1355            is_signal: false,
1356            varname: Some("click".into()),
1357            hot_inlets: vec![true, false],
1358            purity: NodePurity::Stateful,
1359            attrs: vec![],
1360            code: None,
1361        });
1362
1363        let json_str = generate(&g).unwrap();
1364        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1365        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1366
1367        let msg_box = &boxes[0]["box"];
1368        assert_eq!(msg_box["maxclass"], "message");
1369        assert_eq!(msg_box["text"], "bang");
1370        assert_eq!(msg_box["numinlets"], 2);
1371        assert_eq!(msg_box["numoutlets"], 1);
1372        assert_eq!(msg_box["varname"], "click");
1373
1374        let outlettype = msg_box["outlettype"].as_array().unwrap();
1375        assert_eq!(outlettype.len(), 1);
1376        assert_eq!(outlettype[0], "");
1377    }
1378
1379    #[test]
1380    fn test_fanout_patchline_has_order() {
1381        // cycle~ -> ezdac~ (inlet 0 and inlet 1) fanout
1382        let mut graph = make_minimal_graph();
1383        // Set order on fanout edges
1384        graph.edges[0].order = Some(0);
1385        graph.edges[1].order = Some(1);
1386
1387        let json_str = generate(&graph).unwrap();
1388        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1389        let lines = parsed["patcher"]["lines"].as_array().unwrap();
1390
1391        // Both lines have an order field
1392        for (i, line) in lines.iter().enumerate() {
1393            let patchline = &line["patchline"];
1394            let order = patchline.get("order");
1395            assert!(order.is_some(), "patchline {} should have order field", i);
1396            assert_eq!(order.unwrap().as_u64().unwrap(), i as u64);
1397        }
1398    }
1399
1400    #[test]
1401    fn test_non_fanout_patchline_no_order() {
1402        // Single-connection edges have no order
1403        let graph = make_l2_graph();
1404        let json_str = generate(&graph).unwrap();
1405        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1406        let lines = parsed["patcher"]["lines"].as_array().unwrap();
1407
1408        for (i, line) in lines.iter().enumerate() {
1409            let patchline = &line["patchline"];
1410            assert!(
1411                patchline.get("order").is_none(),
1412                "patchline {} should not have order field",
1413                i
1414            );
1415        }
1416    }
1417
1418    // ================================================
1419    // .attr() chain codegen tests
1420    // ================================================
1421
1422    #[test]
1423    fn test_newobj_attrs_in_text() {
1424        // newobj: attrs should be appended as @key value in text field
1425        let mut g = PatchGraph::new();
1426        g.add_node(PatchNode {
1427            id: "osc".into(),
1428            object_name: "cycle~".into(),
1429            args: vec!["440".into()],
1430            num_inlets: 2,
1431            num_outlets: 1,
1432            is_signal: true,
1433            varname: Some("osc".into()),
1434            hot_inlets: vec![],
1435            purity: NodePurity::Unknown,
1436            attrs: vec![("phase".into(), "0.5".into())],
1437            code: None,
1438        });
1439
1440        let json_str = generate(&g).unwrap();
1441        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1442        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1443
1444        let text = boxes[0]["box"]["text"].as_str().unwrap();
1445        assert_eq!(text, "cycle~ 440 @phase 0.5");
1446    }
1447
1448    #[test]
1449    fn test_newobj_multiple_attrs_in_text() {
1450        let mut g = PatchGraph::new();
1451        g.add_node(PatchNode {
1452            id: "osc".into(),
1453            object_name: "cycle~".into(),
1454            args: vec![],
1455            num_inlets: 2,
1456            num_outlets: 1,
1457            is_signal: true,
1458            varname: None,
1459            hot_inlets: vec![],
1460            purity: NodePurity::Unknown,
1461            attrs: vec![
1462                ("frequency".into(), "440.".into()),
1463                ("phase".into(), "0.5".into()),
1464            ],
1465            code: None,
1466        });
1467
1468        let json_str = generate(&g).unwrap();
1469        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1470        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1471
1472        let text = boxes[0]["box"]["text"].as_str().unwrap();
1473        assert_eq!(text, "cycle~ @frequency 440. @phase 0.5");
1474    }
1475
1476    #[test]
1477    fn test_ui_object_attrs_as_fields() {
1478        // UI object (flonum): attrs should be top-level box JSON fields
1479        let mut g = PatchGraph::new();
1480        g.add_node(PatchNode {
1481            id: "fnum".into(),
1482            object_name: "flonum".into(),
1483            args: vec![],
1484            num_inlets: 1,
1485            num_outlets: 2,
1486            is_signal: false,
1487            varname: Some("w".into()),
1488            hot_inlets: vec![],
1489            purity: NodePurity::Unknown,
1490            attrs: vec![
1491                ("minimum".into(), "0.".into()),
1492                ("maximum".into(), "100.".into()),
1493            ],
1494            code: None,
1495        });
1496
1497        let json_str = generate(&g).unwrap();
1498        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1499        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1500        let box_obj = &boxes[0]["box"];
1501
1502        assert_eq!(box_obj["maxclass"], "flonum");
1503        assert_eq!(box_obj["minimum"], 0.0);
1504        assert_eq!(box_obj["maximum"], 100.0);
1505        // UI objects should NOT have attrs in text (no text field for flonum)
1506        assert!(box_obj.get("text").is_none());
1507    }
1508
1509    #[test]
1510    fn test_ui_object_string_attr() {
1511        let mut g = PatchGraph::new();
1512        g.add_node(PatchNode {
1513            id: "dial".into(),
1514            object_name: "live.dial".into(),
1515            args: vec![],
1516            num_inlets: 1,
1517            num_outlets: 2,
1518            is_signal: false,
1519            varname: None,
1520            hot_inlets: vec![],
1521            purity: NodePurity::Unknown,
1522            attrs: vec![("parameter_longname".into(), "Cutoff".into())],
1523            code: None,
1524        });
1525
1526        let json_str = generate(&g).unwrap();
1527        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1528        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1529        let box_obj = &boxes[0]["box"];
1530
1531        assert_eq!(box_obj["maxclass"], "live.dial");
1532        assert_eq!(box_obj["parameter_longname"], "Cutoff");
1533    }
1534
1535    #[test]
1536    fn test_no_attrs_unchanged() {
1537        // When no attrs, output should be unchanged
1538        let mut g = PatchGraph::new();
1539        g.add_node(PatchNode {
1540            id: "osc".into(),
1541            object_name: "cycle~".into(),
1542            args: vec!["440".into()],
1543            num_inlets: 2,
1544            num_outlets: 1,
1545            is_signal: true,
1546            varname: None,
1547            hot_inlets: vec![],
1548            purity: NodePurity::Unknown,
1549            attrs: vec![],
1550            code: None,
1551        });
1552
1553        let json_str = generate(&g).unwrap();
1554        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1555        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1556
1557        let text = boxes[0]["box"]["text"].as_str().unwrap();
1558        assert_eq!(text, "cycle~ 440");
1559    }
1560
1561    // ================================================
1562    // RNBO codegen tests
1563    // ================================================
1564
1565    /// Graph: inlet -> cycle~ -> outlet~ (for RNBO tests)
1566    fn make_rnbo_graph() -> PatchGraph {
1567        let mut g = PatchGraph::new();
1568        g.add_node(PatchNode {
1569            id: "in_freq".into(),
1570            object_name: "inlet".into(),
1571            args: vec![],
1572            num_inlets: 0,
1573            num_outlets: 1,
1574            is_signal: false,
1575            varname: Some("freq".into()),
1576            hot_inlets: vec![],
1577            purity: NodePurity::Unknown,
1578            attrs: vec![],
1579            code: None,
1580        });
1581        g.add_node(PatchNode {
1582            id: "osc".into(),
1583            object_name: "cycle~".into(),
1584            args: vec!["440".into()],
1585            num_inlets: 2,
1586            num_outlets: 1,
1587            is_signal: true,
1588            varname: Some("osc".into()),
1589            hot_inlets: vec![],
1590            purity: NodePurity::Unknown,
1591            attrs: vec![],
1592            code: None,
1593        });
1594        g.add_node(PatchNode {
1595            id: "out_audio".into(),
1596            object_name: "outlet~".into(),
1597            args: vec![],
1598            num_inlets: 1,
1599            num_outlets: 0,
1600            is_signal: true,
1601            varname: None,
1602            hot_inlets: vec![],
1603            purity: NodePurity::Unknown,
1604            attrs: vec![],
1605            code: None,
1606        });
1607        g.add_edge(PatchEdge {
1608            source_id: "in_freq".into(),
1609            source_outlet: 0,
1610            dest_id: "osc".into(),
1611            dest_inlet: 0,
1612            is_feedback: false,
1613            order: None,
1614        });
1615        g.add_edge(PatchEdge {
1616            source_id: "osc".into(),
1617            source_outlet: 0,
1618            dest_id: "out_audio".into(),
1619            dest_inlet: 0,
1620            is_feedback: false,
1621            order: None,
1622        });
1623        g
1624    }
1625
1626    fn rnbo_opts() -> GenerateOptions {
1627        GenerateOptions {
1628            classnamespace: "rnbo".to_string(),
1629        }
1630    }
1631
1632    #[test]
1633    fn test_generate_rnbo_classnamespace() {
1634        let graph = make_rnbo_graph();
1635        let json_str = generate_with_options(&graph, &rnbo_opts()).unwrap();
1636        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1637        let patcher = parsed.get("patcher").unwrap();
1638
1639        assert_eq!(patcher["classnamespace"], "rnbo");
1640    }
1641
1642    #[test]
1643    fn test_rnbo_inport_outport() {
1644        let graph = make_rnbo_graph();
1645        let json_str = generate_with_options(&graph, &rnbo_opts()).unwrap();
1646        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1647        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1648
1649        // inlet (control) → "inport freq" (uses varname)
1650        let inlet_box = &boxes[0]["box"];
1651        assert_eq!(inlet_box["maxclass"], "newobj");
1652        assert_eq!(inlet_box["text"], "inport freq");
1653
1654        // outlet~ (signal) → "out~ 1" (1-based index)
1655        let outlet_box = &boxes[2]["box"];
1656        assert_eq!(outlet_box["maxclass"], "newobj");
1657        assert_eq!(outlet_box["text"], "out~ 1");
1658    }
1659
1660    #[test]
1661    fn test_rnbo_signal_io() {
1662        // Graph with signal inlet~ and signal outlet~
1663        let mut g = PatchGraph::new();
1664        g.add_node(PatchNode {
1665            id: "in_sig".into(),
1666            object_name: "inlet~".into(),
1667            args: vec![],
1668            num_inlets: 1,
1669            num_outlets: 1,
1670            is_signal: true,
1671            varname: None,
1672            hot_inlets: vec![],
1673            purity: NodePurity::Unknown,
1674            attrs: vec![],
1675            code: None,
1676        });
1677        g.add_node(PatchNode {
1678            id: "out_sig".into(),
1679            object_name: "outlet~".into(),
1680            args: vec![],
1681            num_inlets: 1,
1682            num_outlets: 0,
1683            is_signal: true,
1684            varname: None,
1685            hot_inlets: vec![],
1686            purity: NodePurity::Unknown,
1687            attrs: vec![],
1688            code: None,
1689        });
1690        g.add_edge(PatchEdge {
1691            source_id: "in_sig".into(),
1692            source_outlet: 0,
1693            dest_id: "out_sig".into(),
1694            dest_inlet: 0,
1695            is_feedback: false,
1696            order: None,
1697        });
1698
1699        let json_str = generate_with_options(&g, &rnbo_opts()).unwrap();
1700        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1701        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1702
1703        // inlet~ → "in~ 1"
1704        let inlet_box = &boxes[0]["box"];
1705        assert_eq!(inlet_box["maxclass"], "newobj");
1706        assert_eq!(inlet_box["text"], "in~ 1");
1707        let outlettype = inlet_box["outlettype"].as_array().unwrap();
1708        assert_eq!(outlettype, &[json!("signal")]);
1709
1710        // outlet~ → "out~ 1"
1711        let outlet_box = &boxes[1]["box"];
1712        assert_eq!(outlet_box["maxclass"], "newobj");
1713        assert_eq!(outlet_box["text"], "out~ 1");
1714        // outlet is sink: numoutlets = 0, no outlettype
1715        assert_eq!(outlet_box["numoutlets"], 0);
1716        assert!(outlet_box.get("outlettype").is_none());
1717    }
1718
1719    #[test]
1720    fn test_rnbo_serial() {
1721        let graph = make_rnbo_graph();
1722        let json_str = generate_with_options(&graph, &rnbo_opts()).unwrap();
1723        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1724        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1725
1726        // Each box should have rnbo_serial (1-based) and rnbo_uniqueid
1727        for (i, boxval) in boxes.iter().enumerate() {
1728            let b = &boxval["box"];
1729            let serial = b["rnbo_serial"].as_u64().unwrap();
1730            assert_eq!(serial, (i + 1) as u64, "rnbo_serial for box {}", i);
1731
1732            let uniqueid = b["rnbo_uniqueid"].as_str().unwrap();
1733            assert!(!uniqueid.is_empty(), "rnbo_uniqueid should not be empty");
1734        }
1735
1736        // Verify specific uniqueid format: "object_name_obj-N"
1737        let inlet_uid = boxes[0]["box"]["rnbo_uniqueid"].as_str().unwrap();
1738        assert_eq!(inlet_uid, "inlet_obj-1");
1739
1740        let cycle_uid = boxes[1]["box"]["rnbo_uniqueid"].as_str().unwrap();
1741        assert_eq!(cycle_uid, "cycle_tilde_obj-2");
1742
1743        let outlet_uid = boxes[2]["box"]["rnbo_uniqueid"].as_str().unwrap();
1744        assert_eq!(outlet_uid, "outlet_tilde_obj-3");
1745    }
1746
1747    #[test]
1748    fn test_standard_unchanged() {
1749        // Verify generate() (default options) produces standard Max output
1750        let graph = make_rnbo_graph();
1751        let json_str = generate(&graph).unwrap();
1752        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1753        let patcher = parsed.get("patcher").unwrap();
1754
1755        // classnamespace should be "box"
1756        assert_eq!(patcher["classnamespace"], "box");
1757
1758        let boxes = patcher["boxes"].as_array().unwrap();
1759
1760        // inlet should use "inlet" maxclass, not "newobj"
1761        let inlet_box = &boxes[0]["box"];
1762        assert_eq!(inlet_box["maxclass"], "inlet");
1763        // No text field for standard inlet
1764        assert!(inlet_box.get("text").is_none());
1765
1766        // outlet~ should use "outlet" maxclass
1767        let outlet_box = &boxes[2]["box"];
1768        assert_eq!(outlet_box["maxclass"], "outlet");
1769
1770        // No rnbo_serial or rnbo_uniqueid in standard mode
1771        for boxval in boxes {
1772            let b = &boxval["box"];
1773            assert!(
1774                b.get("rnbo_serial").is_none(),
1775                "standard mode should not have rnbo_serial"
1776            );
1777            assert!(
1778                b.get("rnbo_uniqueid").is_none(),
1779                "standard mode should not have rnbo_uniqueid"
1780            );
1781        }
1782    }
1783
1784    #[test]
1785    fn test_rnbo_control_outlet() {
1786        // Test control outlet → "outport name"
1787        let mut g = PatchGraph::new();
1788        g.add_node(PatchNode {
1789            id: "out_ctrl".into(),
1790            object_name: "outlet".into(),
1791            args: vec![],
1792            num_inlets: 1,
1793            num_outlets: 1,
1794            is_signal: false,
1795            varname: Some("result".into()),
1796            hot_inlets: vec![],
1797            purity: NodePurity::Unknown,
1798            attrs: vec![],
1799            code: None,
1800        });
1801
1802        let json_str = generate_with_options(&g, &rnbo_opts()).unwrap();
1803        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1804        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1805
1806        let outlet_box = &boxes[0]["box"];
1807        assert_eq!(outlet_box["maxclass"], "newobj");
1808        assert_eq!(outlet_box["text"], "outport result");
1809        // Control outlet in RNBO is sink: numoutlets = 0
1810        assert_eq!(outlet_box["numoutlets"], 0);
1811    }
1812
1813    #[test]
1814    fn test_rnbo_inport_fallback_name() {
1815        // When no varname, use port_N as fallback
1816        let mut g = PatchGraph::new();
1817        g.add_node(PatchNode {
1818            id: "in_unnamed".into(),
1819            object_name: "inlet".into(),
1820            args: vec![],
1821            num_inlets: 0,
1822            num_outlets: 1,
1823            is_signal: false,
1824            varname: None,
1825            hot_inlets: vec![],
1826            purity: NodePurity::Unknown,
1827            attrs: vec![],
1828            code: None,
1829        });
1830
1831        let json_str = generate_with_options(&g, &rnbo_opts()).unwrap();
1832        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1833        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1834
1835        let inlet_box = &boxes[0]["box"];
1836        assert_eq!(inlet_box["text"], "inport port_0");
1837    }
1838
1839    // ================================================
1840    // Codebox tests
1841    // ================================================
1842
1843    #[test]
1844    fn test_classify_maxclass_codebox() {
1845        // v8.codebox should return "v8.codebox" maxclass
1846        let node = PatchNode {
1847            id: "cb1".into(),
1848            object_name: "v8.codebox".into(),
1849            args: vec![],
1850            num_inlets: 1,
1851            num_outlets: 1,
1852            is_signal: false,
1853            varname: None,
1854            hot_inlets: vec![],
1855            purity: NodePurity::Unknown,
1856            attrs: vec![],
1857            code: None,
1858        };
1859        let (maxclass, width, height) = classify_maxclass(&node, "box");
1860        assert_eq!(maxclass, "v8.codebox");
1861        assert_eq!(width, 200.0);
1862        assert_eq!(height, 100.0);
1863
1864        // codebox (gen~) should return "codebox" maxclass
1865        let node2 = PatchNode {
1866            id: "cb2".into(),
1867            object_name: "codebox".into(),
1868            args: vec![],
1869            num_inlets: 1,
1870            num_outlets: 1,
1871            is_signal: false,
1872            varname: None,
1873            hot_inlets: vec![],
1874            purity: NodePurity::Unknown,
1875            attrs: vec![],
1876            code: None,
1877        };
1878        let (maxclass2, _, _) = classify_maxclass(&node2, "box");
1879        assert_eq!(maxclass2, "codebox");
1880    }
1881
1882    #[test]
1883    fn test_build_box_codebox_with_code() {
1884        // v8.codebox with code field should emit code, filename, and text in JSON
1885        let node = PatchNode {
1886            id: "cb1".into(),
1887            object_name: "v8.codebox".into(),
1888            args: vec![],
1889            num_inlets: 1,
1890            num_outlets: 1,
1891            is_signal: false,
1892            varname: None,
1893            hot_inlets: vec![],
1894            purity: NodePurity::Unknown,
1895            attrs: vec![],
1896            code: Some("function bang() { outlet(0, 42); }".into()),
1897        };
1898
1899        let box_json = build_box(
1900            &node,
1901            &BoxContext {
1902                id: "obj-1",
1903                x: 100.0,
1904                y: 50.0,
1905                classnamespace: "box",
1906                serial: 1,
1907                port_index: None,
1908                ui_data: None,
1909            },
1910        );
1911        let box_obj = &box_json["box"];
1912
1913        assert_eq!(box_obj["maxclass"], "v8.codebox");
1914        assert_eq!(box_obj["code"], "function bang() { outlet(0, 42); }");
1915        assert_eq!(box_obj["filename"], "none");
1916        assert_eq!(box_obj["text"], "");
1917    }
1918
1919    #[test]
1920    fn test_build_box_codebox_without_code() {
1921        // codebox (gen~) without code field should not emit code/filename
1922        let node = PatchNode {
1923            id: "cb1".into(),
1924            object_name: "codebox".into(),
1925            args: vec![],
1926            num_inlets: 1,
1927            num_outlets: 1,
1928            is_signal: false,
1929            varname: None,
1930            hot_inlets: vec![],
1931            purity: NodePurity::Unknown,
1932            attrs: vec![],
1933            code: None,
1934        };
1935
1936        let box_json = build_box(
1937            &node,
1938            &BoxContext {
1939                id: "obj-1",
1940                x: 100.0,
1941                y: 50.0,
1942                classnamespace: "box",
1943                serial: 1,
1944                port_index: None,
1945                ui_data: None,
1946            },
1947        );
1948        let box_obj = &box_json["box"];
1949
1950        assert_eq!(box_obj["maxclass"], "codebox");
1951        assert!(box_obj.get("code").is_none());
1952        assert!(box_obj.get("filename").is_none());
1953    }
1954
1955    #[test]
1956    fn test_standard_codegen_unchanged() {
1957        // Standard generate() still works with existing PatchGraph
1958        let mut g = PatchGraph::new();
1959        g.add_node(PatchNode {
1960            id: "osc".into(),
1961            object_name: "cycle~".into(),
1962            args: vec!["440".into()],
1963            num_inlets: 2,
1964            num_outlets: 1,
1965            is_signal: true,
1966            varname: Some("osc".into()),
1967            hot_inlets: vec![],
1968            purity: NodePurity::Unknown,
1969            attrs: vec![],
1970            code: None,
1971        });
1972
1973        let json_str = generate(&g).unwrap();
1974        let parsed: Value = serde_json::from_str(&json_str).unwrap();
1975        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
1976        assert_eq!(boxes.len(), 1);
1977        assert_eq!(boxes[0]["box"]["maxclass"], "newobj");
1978        assert_eq!(boxes[0]["box"]["text"], "cycle~ 440");
1979        // No code field for regular objects
1980        assert!(boxes[0]["box"].get("code").is_none());
1981    }
1982
1983    #[test]
1984    fn test_gen_mode_classify_inlet_outlet() {
1985        // In gen~ mode, inlet/outlet should become "newobj"
1986        let inlet_node = PatchNode {
1987            id: "in".into(),
1988            object_name: "inlet~".into(),
1989            args: vec![],
1990            num_inlets: 0,
1991            num_outlets: 1,
1992            is_signal: true,
1993            varname: None,
1994            hot_inlets: vec![],
1995            purity: NodePurity::Unknown,
1996            attrs: vec![],
1997            code: None,
1998        };
1999        let (maxclass, _, _) = classify_maxclass(&inlet_node, "dsp.gen");
2000        assert_eq!(maxclass, "newobj");
2001
2002        let outlet_node = PatchNode {
2003            id: "out".into(),
2004            object_name: "outlet~".into(),
2005            args: vec![],
2006            num_inlets: 1,
2007            num_outlets: 0,
2008            is_signal: true,
2009            varname: None,
2010            hot_inlets: vec![],
2011            purity: NodePurity::Unknown,
2012            attrs: vec![],
2013            code: None,
2014        };
2015        let (maxclass, _, _) = classify_maxclass(&outlet_node, "dsp.gen");
2016        assert_eq!(maxclass, "newobj");
2017    }
2018
2019    #[test]
2020    fn test_gen_mode_build_box_text() {
2021        // gen~ mode should generate "in N" / "out N" text
2022        let inlet_node = PatchNode {
2023            id: "in".into(),
2024            object_name: "inlet~".into(),
2025            args: vec![],
2026            num_inlets: 0,
2027            num_outlets: 1,
2028            is_signal: true,
2029            varname: None,
2030            hot_inlets: vec![],
2031            purity: NodePurity::Unknown,
2032            attrs: vec![],
2033            code: None,
2034        };
2035        let box_json = build_box(
2036            &inlet_node,
2037            &BoxContext {
2038                id: "obj-1",
2039                x: 100.0,
2040                y: 50.0,
2041                classnamespace: "dsp.gen",
2042                serial: 1,
2043                port_index: Some(0),
2044                ui_data: None,
2045            },
2046        );
2047        let box_obj = &box_json["box"];
2048        assert_eq!(box_obj["maxclass"], "newobj");
2049        assert_eq!(box_obj["text"], "in 1");
2050        // gen~ should NOT have rnbo_serial/rnbo_uniqueid
2051        assert!(box_obj.get("rnbo_serial").is_none());
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 box_json = build_box(
2067            &outlet_node,
2068            &BoxContext {
2069                id: "obj-2",
2070                x: 100.0,
2071                y: 120.0,
2072                classnamespace: "dsp.gen",
2073                serial: 2,
2074                port_index: Some(0),
2075                ui_data: None,
2076            },
2077        );
2078        let box_obj = &box_json["box"];
2079        assert_eq!(box_obj["maxclass"], "newobj");
2080        assert_eq!(box_obj["text"], "out 1");
2081        assert_eq!(box_obj["numoutlets"], 0); // sink
2082    }
2083
2084    #[test]
2085    fn test_gen_mode_codegen() {
2086        // Full gen~ codegen roundtrip
2087        let mut g = PatchGraph::new();
2088        g.add_node(PatchNode {
2089            id: "in1".into(),
2090            object_name: "inlet~".into(),
2091            args: vec![],
2092            num_inlets: 0,
2093            num_outlets: 1,
2094            is_signal: true,
2095            varname: None,
2096            hot_inlets: vec![],
2097            purity: NodePurity::Unknown,
2098            attrs: vec![],
2099            code: None,
2100        });
2101        g.add_node(PatchNode {
2102            id: "mul".into(),
2103            object_name: "*".into(),
2104            args: vec!["0.5".into()],
2105            num_inlets: 2,
2106            num_outlets: 1,
2107            is_signal: false,
2108            varname: None,
2109            hot_inlets: vec![],
2110            purity: NodePurity::Unknown,
2111            attrs: vec![],
2112            code: None,
2113        });
2114        g.add_node(PatchNode {
2115            id: "out1".into(),
2116            object_name: "outlet~".into(),
2117            args: vec![],
2118            num_inlets: 1,
2119            num_outlets: 0,
2120            is_signal: true,
2121            varname: None,
2122            hot_inlets: vec![],
2123            purity: NodePurity::Unknown,
2124            attrs: vec![],
2125            code: None,
2126        });
2127        g.add_edge(PatchEdge {
2128            source_id: "in1".into(),
2129            source_outlet: 0,
2130            dest_id: "mul".into(),
2131            dest_inlet: 0,
2132            is_feedback: false,
2133            order: None,
2134        });
2135        g.add_edge(PatchEdge {
2136            source_id: "mul".into(),
2137            source_outlet: 0,
2138            dest_id: "out1".into(),
2139            dest_inlet: 0,
2140            is_feedback: false,
2141            order: None,
2142        });
2143
2144        let opts = GenerateOptions {
2145            classnamespace: "dsp.gen".to_string(),
2146        };
2147        let json_str = generate_with_options(&g, &opts).unwrap();
2148        let parsed: Value = serde_json::from_str(&json_str).unwrap();
2149
2150        assert_eq!(parsed["patcher"]["classnamespace"], "dsp.gen");
2151
2152        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
2153        assert_eq!(boxes.len(), 3);
2154
2155        // First box: inlet~ → "in 1"
2156        assert_eq!(boxes[0]["box"]["maxclass"], "newobj");
2157        assert_eq!(boxes[0]["box"]["text"], "in 1");
2158
2159        // Second box: * 0.5
2160        assert_eq!(boxes[1]["box"]["maxclass"], "newobj");
2161        assert_eq!(boxes[1]["box"]["text"], "* 0.5");
2162
2163        // Third box: outlet~ → "out 1"
2164        assert_eq!(boxes[2]["box"]["maxclass"], "newobj");
2165        assert_eq!(boxes[2]["box"]["text"], "out 1");
2166        assert_eq!(boxes[2]["box"]["numoutlets"], 0);
2167    }
2168
2169    // ─── UiData tests ───
2170
2171    #[test]
2172    fn test_ui_data_from_json_basic() {
2173        let json_str = r#"{
2174            "_patcher": { "rect": [50, 50, 800, 600] },
2175            "osc": { "rect": [100, 200, 80, 22] },
2176            "dac": { "rect": [100, 400, 45, 45], "background": 0 }
2177        }"#;
2178        let ui = UiData::from_json(json_str).unwrap();
2179
2180        // Patcher-level settings
2181        assert_eq!(ui.patcher["rect"], json!([50, 50, 800, 600]));
2182
2183        // Per-wire entries
2184        assert!(ui.entries.contains_key("osc"));
2185        assert!(ui.entries.contains_key("dac"));
2186        assert!(!ui.entries.contains_key("_patcher"));
2187        assert_eq!(ui.entries["osc"]["rect"], json!([100, 200, 80, 22]));
2188        assert_eq!(ui.entries["dac"]["background"], json!(0));
2189    }
2190
2191    #[test]
2192    fn test_ui_data_from_json_empty() {
2193        let ui = UiData::from_json("{}").unwrap();
2194        assert!(ui.patcher.is_empty());
2195        assert!(ui.entries.is_empty());
2196    }
2197
2198    #[test]
2199    fn test_ui_data_from_json_invalid() {
2200        assert!(UiData::from_json("not json").is_none());
2201        assert!(UiData::from_json("42").is_none());
2202        assert!(UiData::from_json("[]").is_none());
2203    }
2204
2205    #[test]
2206    fn test_ui_data_from_json_no_patcher() {
2207        let json_str = r#"{ "osc": { "rect": [10, 20, 80, 22] } }"#;
2208        let ui = UiData::from_json(json_str).unwrap();
2209        assert!(ui.patcher.is_empty());
2210        assert_eq!(ui.entries.len(), 1);
2211    }
2212
2213    #[test]
2214    fn test_build_box_with_ui_data_rect_override() {
2215        let node = PatchNode {
2216            id: "osc".into(),
2217            object_name: "cycle~".into(),
2218            args: vec!["440".into()],
2219            num_inlets: 2,
2220            num_outlets: 1,
2221            is_signal: true,
2222            varname: Some("osc".into()),
2223            hot_inlets: vec![],
2224            purity: NodePurity::Unknown,
2225            attrs: vec![],
2226            code: None,
2227        };
2228        let ui = UiData::from_json(r#"{ "osc": { "rect": [250, 350, 90, 24] } }"#).unwrap();
2229
2230        let box_json = build_box(
2231            &node,
2232            &BoxContext {
2233                id: "obj-1",
2234                x: 100.0,
2235                y: 50.0,
2236                classnamespace: "box",
2237                serial: 1,
2238                port_index: None,
2239                ui_data: Some(&ui),
2240            },
2241        );
2242        let rect = box_json["box"]["patching_rect"].as_array().unwrap();
2243
2244        // Should use UI data rect, not auto-layout position
2245        assert_eq!(rect[0], json!(250));
2246        assert_eq!(rect[1], json!(350));
2247        assert_eq!(rect[2], json!(90));
2248        assert_eq!(rect[3], json!(24));
2249    }
2250
2251    #[test]
2252    fn test_build_box_with_ui_data_decorative_attrs() {
2253        let node = PatchNode {
2254            id: "osc".into(),
2255            object_name: "cycle~".into(),
2256            args: vec!["440".into()],
2257            num_inlets: 2,
2258            num_outlets: 1,
2259            is_signal: true,
2260            varname: Some("osc".into()),
2261            hot_inlets: vec![],
2262            purity: NodePurity::Unknown,
2263            attrs: vec![],
2264            code: None,
2265        };
2266        let ui = UiData::from_json(
2267            r#"{
2268            "osc": {
2269                "rect": [250, 350, 90, 24],
2270                "background": 0,
2271                "fontsize": 14
2272            }
2273        }"#,
2274        )
2275        .unwrap();
2276
2277        let box_json = build_box(
2278            &node,
2279            &BoxContext {
2280                id: "obj-1",
2281                x: 100.0,
2282                y: 50.0,
2283                classnamespace: "box",
2284                serial: 1,
2285                port_index: None,
2286                ui_data: Some(&ui),
2287            },
2288        );
2289        let box_obj = &box_json["box"];
2290
2291        // Decorative attributes should be present
2292        assert_eq!(box_obj["background"], json!(0));
2293        assert_eq!(box_obj["fontsize"], json!(14));
2294    }
2295
2296    #[test]
2297    fn test_build_box_without_varname_ignores_ui_data() {
2298        let node = PatchNode {
2299            id: "osc".into(),
2300            object_name: "cycle~".into(),
2301            args: vec!["440".into()],
2302            num_inlets: 2,
2303            num_outlets: 1,
2304            is_signal: true,
2305            varname: None, // no varname
2306            hot_inlets: vec![],
2307            purity: NodePurity::Unknown,
2308            attrs: vec![],
2309            code: None,
2310        };
2311        let ui = UiData::from_json(r#"{ "osc": { "rect": [250, 350, 90, 24] } }"#).unwrap();
2312
2313        let box_json = build_box(
2314            &node,
2315            &BoxContext {
2316                id: "obj-1",
2317                x: 100.0,
2318                y: 50.0,
2319                classnamespace: "box",
2320                serial: 1,
2321                port_index: None,
2322                ui_data: Some(&ui),
2323            },
2324        );
2325        let rect = box_json["box"]["patching_rect"].as_array().unwrap();
2326
2327        // Should use auto-layout position since there's no varname to match
2328        assert_eq!(rect[0], json!(100.0));
2329        assert_eq!(rect[1], json!(50.0));
2330    }
2331
2332    #[test]
2333    fn test_build_patcher_with_ui_data_patcher_rect() {
2334        let mut g = PatchGraph::new();
2335        g.add_node(PatchNode {
2336            id: "osc".into(),
2337            object_name: "cycle~".into(),
2338            args: vec!["440".into()],
2339            num_inlets: 2,
2340            num_outlets: 1,
2341            is_signal: true,
2342            varname: Some("osc".into()),
2343            hot_inlets: vec![],
2344            purity: NodePurity::Unknown,
2345            attrs: vec![],
2346            code: None,
2347        });
2348
2349        let ui = UiData::from_json(
2350            r#"{
2351            "_patcher": { "rect": [50, 50, 800, 600] },
2352            "osc": { "rect": [200, 300, 80, 22] }
2353        }"#,
2354        )
2355        .unwrap();
2356
2357        let json_str = generate_with_ui(&g, &GenerateOptions::default(), Some(&ui)).unwrap();
2358        let parsed: Value = serde_json::from_str(&json_str).unwrap();
2359
2360        // Patcher rect should come from UI data
2361        assert_eq!(parsed["patcher"]["rect"], json!([50, 50, 800, 600]));
2362
2363        // Box rect should come from UI data
2364        let boxes = parsed["patcher"]["boxes"].as_array().unwrap();
2365        assert_eq!(boxes[0]["box"]["patching_rect"], json!([200, 300, 80, 22]));
2366    }
2367
2368    #[test]
2369    fn test_generate_with_ui_none_is_same_as_generate() {
2370        let graph = make_minimal_graph();
2371
2372        let json_without = generate(&graph).unwrap();
2373        let json_with_none = generate_with_ui(&graph, &GenerateOptions::default(), None).unwrap();
2374
2375        // Both should produce identical output
2376        assert_eq!(json_without, json_with_none);
2377    }
2378}