From 34999c875485a9391a44c2690316334a7990654c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 08:20:49 -0700 Subject: [PATCH 01/31] engine: fix MDL writer lookup equations and sketch format Variables with both an equation and a graphical function now emit WITH LOOKUP(input, (table_data)) instead of bare lookup data. Standalone lookup definitions (no input equation) use the correct name(\n\tbody) syntax instead of name=\n\t(body). Sketch fixes: valve type-11 field 3 set to 0, cloud type-12 field 3 set to 48 (ASCII '0'), flow pipe connectors include direction flag (4=stock, 100=cloud) and pipe type (22), influence connectors include field 9=64. Flow pipe direction flags are now based on endpoint type (stock vs cloud) rather than point order. --- src/simlin-engine/src/mdl/writer.rs | 207 ++++++++++++++++++++-------- 1 file changed, 150 insertions(+), 57 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 117ac90a..24bb0f56 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -673,10 +673,10 @@ fn is_data_equation(xmile_eqn: &str) -> bool { || s.starts_with("GET_123") } -/// Write a graphical-function (lookup table) body into `buf`. +/// Write the inner body of a lookup table into `buf` (no outer parens). /// -/// Format: `([(xmin,ymin)-(xmax,ymax)],(x1,y1),(x2,y2),...)` -fn write_lookup(buf: &mut String, gf: &GraphicalFunction) { +/// Format: `[(xmin,ymin)-(xmax,ymax)],(x1,y1),(x2,y2),...` +fn write_lookup_body(buf: &mut String, gf: &GraphicalFunction) { let xs: Vec = match &gf.x_points { Some(pts) => pts.clone(), None => { @@ -692,7 +692,7 @@ fn write_lookup(buf: &mut String, gf: &GraphicalFunction) { write!( buf, - "([({},{})-({},{})]", + "[({},{})-({},{})]", format_f64(gf.x_scale.min), format_f64(gf.y_scale.min), format_f64(gf.x_scale.max), @@ -703,9 +703,24 @@ fn write_lookup(buf: &mut String, gf: &GraphicalFunction) { for (x, y) in xs.iter().zip(gf.y_points.iter()) { write!(buf, ",({},{})", format_f64(*x), format_f64(*y)).unwrap(); } +} + +/// Write a graphical-function (lookup table) wrapped in parens. +/// +/// Format: `([(xmin,ymin)-(xmax,ymax)],(x1,y1),(x2,y2),...)` +fn write_lookup(buf: &mut String, gf: &GraphicalFunction) { + buf.push('('); + write_lookup_body(buf, gf); buf.push(')'); } +/// Returns true when the equation text is a placeholder sentinel rather +/// than a real input expression (standalone lookup definition). +fn is_lookup_only_equation(eqn: &str) -> bool { + let trimmed = eqn.trim(); + trimmed.is_empty() || trimmed == "0+0" || trimmed == "0" +} + /// Format f64 for MDL output: omit trailing `.0` for whole numbers. fn format_f64(v: f64) -> String { if v.is_infinite() { @@ -985,20 +1000,34 @@ fn write_single_entry( gf: Option<&GraphicalFunction>, ) { let name = format_mdl_ident(ident); - let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; - - if dims.is_empty() { - write!(buf, "{name}{assign_op}").unwrap(); + let dim_suffix = if dims.is_empty() { + String::new() } else { let dim_strs: Vec = dims.iter().map(|d| format_mdl_ident(d)).collect(); - write!(buf, "{name}[{}]{assign_op}", dim_strs.join(",")).unwrap(); - } + format!("[{}]", dim_strs.join(",")) + }; if let Some(gf) = gf { - // Lookup table replaces the equation - buf.push_str("\n\t"); - write_lookup(buf, gf); + if is_lookup_only_equation(eqn) { + // Standalone lookup definition: name(\n\tbody) + write!(buf, "{name}{dim_suffix}(").unwrap(); + buf.push_str("\n\t"); + write_lookup_body(buf, gf); + buf.push(')'); + } else { + // Embedded lookup: name=\n\tWITH LOOKUP(input, (body)) + let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; + write!(buf, "{name}{dim_suffix}{assign_op}").unwrap(); + let mdl_eqn = equation_to_mdl(eqn); + buf.push_str("\n\tWITH LOOKUP("); + buf.push_str(&mdl_eqn); + buf.push_str(", "); + write_lookup(buf, gf); + buf.push(')'); + } } else { + let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; + write!(buf, "{name}{dim_suffix}{assign_op}").unwrap(); let mdl_eqn = equation_to_mdl(eqn); buf.push_str("\n\t"); buf.push_str(&mdl_eqn); @@ -1037,14 +1066,26 @@ fn write_arrayed_element_entries( let last_idx = elements.len().saturating_sub(1); for (i, (elem_name, eqn, _comment, elem_gf)) in elements.iter().enumerate() { let elem_display = format_mdl_element_key(elem_name); - let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; - - write!(buf, "{name}[{elem_display}]{assign_op}").unwrap(); if let Some(gf) = elem_gf { - buf.push_str("\n\t"); - write_lookup(buf, gf); + if is_lookup_only_equation(eqn) { + write!(buf, "{name}[{elem_display}](").unwrap(); + buf.push_str("\n\t"); + write_lookup_body(buf, gf); + buf.push(')'); + } else { + let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; + write!(buf, "{name}[{elem_display}]{assign_op}").unwrap(); + let mdl_eqn = equation_to_mdl(eqn); + buf.push_str("\n\tWITH LOOKUP("); + buf.push_str(&mdl_eqn); + buf.push_str(", "); + write_lookup(buf, gf); + buf.push(')'); + } } else { + let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; + write!(buf, "{name}[{elem_display}]{assign_op}").unwrap(); let mdl_eqn = equation_to_mdl(eqn); buf.push_str("\n\t"); buf.push_str(&mdl_eqn); @@ -1263,6 +1304,7 @@ fn write_flow_element( buf: &mut String, flow: &view_element::Flow, valve_uids: &HashMap, + cloud_uids: &HashSet, next_connector_uid: &mut i32, ) { // Sketch names: see `write_aux_element` for why `underbar_to_space` is @@ -1270,12 +1312,11 @@ fn write_flow_element( let name = underbar_to_space(&flow.name); let valve_uid = valve_uids.get(&flow.uid).copied().unwrap_or(flow.uid - 1); - // Type 11 (valve): the valve name in Vensim is typically a numeric - // placeholder (like "48" or "444"). We use the valve uid as the name. + // Type 11 (valve): field 3 is always 0 in Vensim-generated files. write!( buf, - "11,{},{},{},{},6,8,34,3,0,0,1,0,0,0", - valve_uid, valve_uid, flow.x as i32, flow.y as i32, + "11,{},0,{},{},6,8,34,3,0,0,1,0,0,0", + valve_uid, flow.x as i32, flow.y as i32, ) .unwrap(); @@ -1289,33 +1330,47 @@ fn write_flow_element( ) .unwrap(); - write_flow_pipe_connectors(buf, flow, valve_uid, next_connector_uid); + write_flow_pipe_connectors(buf, flow, valve_uid, cloud_uids, next_connector_uid); } fn write_flow_pipe_connectors( buf: &mut String, flow: &view_element::Flow, valve_uid: i32, + cloud_uids: &HashSet, next_connector_uid: &mut i32, ) { - let write_connector = - |buf: &mut String, connector_uid: i32, from_uid: i32, to_uid: i32, x: i32, y: i32| { - write!( - buf, - "\n1,{},{},{},0,0,0,0,0,0,0,-1--1--1,,1|({},{})|", - connector_uid, from_uid, to_uid, x, y, - ) - .unwrap(); - }; + // Flow pipe connectors use field 4 for endpoint type and field 7 = 22 (pipe type). + // Flag 4 = connects to a stock, flag 100 = connects to a cloud. + let write_pipe = |buf: &mut String, + connector_uid: i32, + from_uid: i32, + to_uid: i32, + direction: i32, + x: i32, + y: i32| { + write!( + buf, + "\n1,{},{},{},{},0,0,22,0,0,0,-1--1--1,,1|({},{})|", + connector_uid, from_uid, to_uid, direction, x, y, + ) + .unwrap(); + }; if let Some(first) = flow.points.first() && let Some(endpoint_uid) = first.attached_to_uid { - write_connector( + let flag = if cloud_uids.contains(&endpoint_uid) { + 100 + } else { + 4 + }; + write_pipe( buf, *next_connector_uid, valve_uid, endpoint_uid, + flag, first.x as i32, first.y as i32, ); @@ -1328,11 +1383,12 @@ fn write_flow_pipe_connectors( .skip(1) .take(flow.points.len().saturating_sub(2)) { - write_connector( + write_pipe( buf, *next_connector_uid, valve_uid, valve_uid, + 0, point.x as i32, point.y as i32, ); @@ -1343,11 +1399,17 @@ fn write_flow_pipe_connectors( && let Some(last) = flow.points.last() && let Some(endpoint_uid) = last.attached_to_uid { - write_connector( + let flag = if cloud_uids.contains(&endpoint_uid) { + 100 + } else { + 4 + }; + write_pipe( buf, *next_connector_uid, valve_uid, endpoint_uid, + flag, last.x as i32, last.y as i32, ); @@ -1357,10 +1419,10 @@ fn write_flow_pipe_connectors( /// Write a type 12 line for a Cloud element. fn write_cloud_element(buf: &mut String, cloud: &view_element::Cloud) { - // Clouds: text="0", shape=0, bits=3 (visible) + // Clouds: field 3 is 48 (ASCII '0') in Vensim-generated files, shape=0, bits=3 write!( buf, - "12,{},0,{},{},10,8,0,3,0,0,-1,0,0,0", + "12,{},48,{},{},10,8,0,3,0,0,-1,0,0,0", cloud.uid, cloud.x as i32, cloud.y as i32, ) .unwrap(); @@ -1410,11 +1472,12 @@ fn write_link_element( .unwrap_or((0, 0)); let to_pos = elem_positions.get(&link.to_uid).copied().unwrap_or((0, 0)); + // Field 9 = 64 marks influence (causal) connectors in Vensim sketches. match &link.shape { LinkShape::Straight => { write!( buf, - "1,{},{},{},0,0,{},0,0,0,0,-1--1--1,,1|(0,0)|", + "1,{},{},{},0,0,{},0,0,64,0,-1--1--1,,1|(0,0)|", link.uid, link.from_uid, link.to_uid, polarity_val, ) .unwrap(); @@ -1423,7 +1486,7 @@ fn write_link_element( let (ctrl_x, ctrl_y) = compute_control_point(from_pos, to_pos, *canvas_angle); write!( buf, - "1,{},{},{},0,0,{},0,0,0,0,-1--1--1,,1|({},{})|", + "1,{},{},{},0,0,{},0,0,64,0,-1--1--1,,1|({},{})|", link.uid, link.from_uid, link.to_uid, polarity_val, ctrl_x, ctrl_y, ) .unwrap(); @@ -1432,7 +1495,7 @@ fn write_link_element( let npoints = points.len(); write!( buf, - "1,{},{},{},0,0,{},0,0,0,0,-1--1--1,,{}|", + "1,{},{},{},0,0,{},0,0,64,0,-1--1--1,,{}|", link.uid, link.from_uid, link.to_uid, polarity_val, npoints, ) .unwrap(); @@ -1681,6 +1744,16 @@ impl MdlWriter { // Build name map for alias resolution let name_map = build_name_map(&sf.elements); + // Collect cloud UIDs so flow pipe connectors can set the right direction flag + let cloud_uids: HashSet = sf + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Cloud(c) => Some(c.uid), + _ => None, + }) + .collect(); + for elem in &sf.elements { match elem { ViewElement::Aux(aux) => { @@ -1692,7 +1765,13 @@ impl MdlWriter { self.buf.push('\n'); } ViewElement::Flow(flow) => { - write_flow_element(&mut self.buf, flow, &valve_uids, &mut next_connector_uid); + write_flow_element( + &mut self.buf, + flow, + &valve_uids, + &cloud_uids, + &mut next_connector_uid, + ); self.buf.push('\n'); } ViewElement::Link(link) => { @@ -2471,7 +2550,7 @@ mod tests { write_variable_entry(&mut buf, &var); assert_eq!( buf, - "effect of x=\n\t([(0,0)-(2,1)],(0,0),(1,0.5),(2,1))\n\t~\t\n\t~\tLookup effect\n\t|" + "effect of x=\n\tWITH LOOKUP(Time, ([(0,0)-(2,1)],(0,0),(1,0.5),(2,1)))\n\t~\t\n\t~\tLookup effect\n\t|" ); } @@ -2499,10 +2578,10 @@ mod tests { }); let mut buf = String::new(); write_variable_entry(&mut buf, &var); - // x_points auto-generated: 0, 5, 10 + // Standalone lookup: name(\n\tbody) assert_eq!( buf, - "tbl=\n\t([(0,0)-(10,1)],(0,0),(5,0.5),(10,1))\n\t~\t\n\t~\t\n\t|" + "tbl(\n\t[(0,0)-(10,1)],(0,0),(5,0.5),(10,1))\n\t~\t\n\t~\t\n\t|" ); } @@ -2677,7 +2756,8 @@ mod tests { }); let mut buf = String::new(); write_variable_entry(&mut buf, &var); - assert!(buf.contains("tbl[a]=\n\t([(0,0)-(2,1)]")); + // Element "a" has empty equation + gf → standalone lookup + assert!(buf.contains("tbl[a](\n\t[(0,0)-(2,1)]")); assert!(buf.contains("tbl[b]=\n\t5")); } @@ -2817,7 +2897,8 @@ mod tests { }); let mut buf = String::new(); write_variable_entry(&mut buf, &var); - assert!(buf.contains("flow rate=\n\t([(0,0)-(2,1)]")); + // Flow with equation "TIME" + gf → WITH LOOKUP + assert!(buf.contains("flow rate=\n\tWITH LOOKUP(Time, ([(0,0)-(2,1)]")); assert!(buf.contains("~\twidgets/year")); assert!(buf.contains("~\tA flow")); } @@ -3194,9 +3275,15 @@ mod tests { let mut buf = String::new(); let valve_uids = HashMap::from([(6, 100)]); let mut next_connector_uid = 200; - write_flow_element(&mut buf, &flow, &valve_uids, &mut next_connector_uid); - // valve line uses allocated UID, variable line uses flow's UID - assert!(buf.starts_with("11,100,100,295,191,6,8,34,3,0,0,1,0,0,0\n")); + write_flow_element( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + ); + // valve line uses allocated UID (field 3 = 0), variable line uses flow's UID + assert!(buf.starts_with("11,100,0,295,191,6,8,34,3,0,0,1,0,0,0\n")); assert!(buf.contains("10,6,Infection Rate,295,207,49,8,40,3,0,0,-1,0,0,0")); } @@ -3224,7 +3311,13 @@ mod tests { let mut buf = String::new(); let valve_uids = HashMap::from([(6, 100)]); let mut next_connector_uid = 200; - write_flow_element(&mut buf, &flow, &valve_uids, &mut next_connector_uid); + write_flow_element( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + ); let connector_lines: Vec<&str> = buf.lines().filter(|line| line.starts_with("1,")).collect(); @@ -3284,7 +3377,7 @@ mod tests { }; let mut buf = String::new(); write_cloud_element(&mut buf, &cloud); - assert_eq!(buf, "12,7,0,479,235,10,8,0,3,0,0,-1,0,0,0"); + assert_eq!(buf, "12,7,48,479,235,10,8,0,3,0,0,-1,0,0,0"); } #[test] @@ -3320,8 +3413,8 @@ mod tests { positions.insert(2, (200, 200)); let mut buf = String::new(); write_link_element(&mut buf, &link, &positions, false); - // Straight => control point (0,0) - assert_eq!(buf, "1,3,1,2,0,0,0,0,0,0,0,-1--1--1,,1|(0,0)|"); + // Straight => control point (0,0), field 9 = 64 (influence connector) + assert_eq!(buf, "1,3,1,2,0,0,0,0,0,64,0,-1--1--1,,1|(0,0)|"); } #[test] @@ -3336,8 +3429,8 @@ mod tests { let positions = HashMap::new(); let mut buf = String::new(); write_link_element(&mut buf, &link, &positions, false); - // polarity=43 ('+') - assert!(buf.contains(",0,0,43,0,0,0,0,")); + // polarity=43 ('+'), field 9 = 64 + assert!(buf.contains(",0,0,43,0,0,64,0,")); } #[test] @@ -3352,8 +3445,8 @@ mod tests { let positions = HashMap::new(); let mut buf = String::new(); write_link_element(&mut buf, &link, &positions, true); - // polarity=83 ('S' for lettered positive) - assert!(buf.contains(",0,0,83,0,0,0,0,")); + // polarity=83 ('S' for lettered positive), field 9 = 64 + assert!(buf.contains(",0,0,83,0,0,64,0,")); } #[test] From 1aab3bb403747e163283ead5ad1ce42af30ed17e Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 08:31:43 -0700 Subject: [PATCH 02/31] engine: fix MDL sketch element ordering for Vensim compatibility Vensim requires a specific element ordering within flow blocks: pipe connectors (type 1, flag 22) must precede the valve (type 11) and flow label (type 10). Cloud elements (type 12) must appear before the pipe connectors that reference them. The previous ordering (valve, label, pipes; clouds at end) caused Vensim to not display most sketch elements. Reorder write_flow_element to emit pipes first, then valve, then label. Move cloud emission from the main element loop to just before their associated flow, using the cloud's flow_uid field for grouping. Add mark2 sketch ordering test that validates pipe-before-valve ordering, cloud-before-pipe ordering, and correct direction/influence flags. Update mark2 test fixture with Vensim-reformatted version. Add water and lookup_ex models to the sketch roundtrip test suite. --- src/simlin-engine/src/mdl/writer.rs | 78 ++++++---- src/simlin-engine/tests/mdl_roundtrip.rs | 111 +++++++++++++++ test/bobby/vdf/econ/mark2.mdl | 173 +++++++++++------------ 3 files changed, 249 insertions(+), 113 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 24bb0f56..8449e531 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1294,12 +1294,12 @@ fn sanitize_view_title_for_mdl(title: &str) -> String { out } -/// Write a Flow element as type 11 (valve), type 10 (attached flow variable), -/// and type 1 pipe connectors derived from flow endpoints. +/// Write a Flow element as type 1 pipe connectors, type 11 (valve), and +/// type 10 (attached flow variable). /// -/// In MDL, flows are two elements: a valve (type 11) at the flow position -/// and an attached variable (type 10) below it. The valve UID is looked up -/// from the pre-allocated valve_uids map to avoid collisions. +/// Vensim requires this exact ordering: pipe connectors first, then valve, +/// then flow label. The valve UID is looked up from the pre-allocated +/// valve_uids map to avoid collisions. fn write_flow_element( buf: &mut String, flow: &view_element::Flow, @@ -1307,12 +1307,18 @@ fn write_flow_element( cloud_uids: &HashSet, next_connector_uid: &mut i32, ) { - // Sketch names: see `write_aux_element` for why `underbar_to_space` is - // used here instead of `format_mdl_ident`. let name = underbar_to_space(&flow.name); let valve_uid = valve_uids.get(&flow.uid).copied().unwrap_or(flow.uid - 1); + // Pipe connectors must come before the valve and flow label. + let had_pipes = + write_flow_pipe_connectors(buf, flow, valve_uid, cloud_uids, next_connector_uid); + // Type 11 (valve): field 3 is always 0 in Vensim-generated files. + // Prefix with \n only if pipes were emitted (otherwise caller handles separation). + if had_pipes { + buf.push('\n'); + } write!( buf, "11,{},0,{},{},6,8,34,3,0,0,1,0,0,0", @@ -1329,29 +1335,34 @@ fn write_flow_element( flow.uid, name, flow.x as i32, var_y, ) .unwrap(); - - write_flow_pipe_connectors(buf, flow, valve_uid, cloud_uids, next_connector_uid); } +/// Returns true if any pipe connectors were written. fn write_flow_pipe_connectors( buf: &mut String, flow: &view_element::Flow, valve_uid: i32, cloud_uids: &HashSet, next_connector_uid: &mut i32, -) { +) -> bool { + let mut wrote_any = false; + // Flow pipe connectors use field 4 for endpoint type and field 7 = 22 (pipe type). // Flag 4 = connects to a stock, flag 100 = connects to a cloud. let write_pipe = |buf: &mut String, + first: bool, connector_uid: i32, from_uid: i32, to_uid: i32, direction: i32, x: i32, y: i32| { + if !first { + buf.push('\n'); + } write!( buf, - "\n1,{},{},{},{},0,0,22,0,0,0,-1--1--1,,1|({},{})|", + "1,{},{},{},{},0,0,22,0,0,0,-1--1--1,,1|({},{})|", connector_uid, from_uid, to_uid, direction, x, y, ) .unwrap(); @@ -1367,6 +1378,7 @@ fn write_flow_pipe_connectors( }; write_pipe( buf, + !wrote_any, *next_connector_uid, valve_uid, endpoint_uid, @@ -1374,6 +1386,7 @@ fn write_flow_pipe_connectors( first.x as i32, first.y as i32, ); + wrote_any = true; *next_connector_uid += 1; } @@ -1385,6 +1398,7 @@ fn write_flow_pipe_connectors( { write_pipe( buf, + !wrote_any, *next_connector_uid, valve_uid, valve_uid, @@ -1392,6 +1406,7 @@ fn write_flow_pipe_connectors( point.x as i32, point.y as i32, ); + wrote_any = true; *next_connector_uid += 1; } @@ -1406,6 +1421,7 @@ fn write_flow_pipe_connectors( }; write_pipe( buf, + !wrote_any, *next_connector_uid, valve_uid, endpoint_uid, @@ -1413,8 +1429,11 @@ fn write_flow_pipe_connectors( last.x as i32, last.y as i32, ); + wrote_any = true; *next_connector_uid += 1; } + + wrote_any } /// Write a type 12 line for a Cloud element. @@ -1744,15 +1763,17 @@ impl MdlWriter { // Build name map for alias resolution let name_map = build_name_map(&sf.elements); - // Collect cloud UIDs so flow pipe connectors can set the right direction flag - let cloud_uids: HashSet = sf - .elements - .iter() - .filter_map(|e| match e { - ViewElement::Cloud(c) => Some(c.uid), - _ => None, - }) - .collect(); + // Collect cloud UIDs so flow pipe connectors can set the right direction flag. + // Also build a map from flow_uid -> clouds so we can emit each cloud + // just before its associated flow (Vensim requires this ordering). + let mut cloud_uids: HashSet = HashSet::new(); + let mut flow_clouds: HashMap> = HashMap::new(); + for elem in &sf.elements { + if let ViewElement::Cloud(c) = elem { + cloud_uids.insert(c.uid); + flow_clouds.entry(c.flow_uid).or_default().push(c); + } + } for elem in &sf.elements { match elem { @@ -1765,6 +1786,13 @@ impl MdlWriter { self.buf.push('\n'); } ViewElement::Flow(flow) => { + // Emit associated clouds before the flow pipes + if let Some(clouds) = flow_clouds.get(&flow.uid) { + for cloud in clouds { + write_cloud_element(&mut self.buf, cloud); + self.buf.push('\n'); + } + } write_flow_element( &mut self.buf, flow, @@ -1783,10 +1811,8 @@ impl MdlWriter { ); self.buf.push('\n'); } - ViewElement::Cloud(cloud) => { - write_cloud_element(&mut self.buf, cloud); - self.buf.push('\n'); - } + // Clouds are emitted with their associated flow above + ViewElement::Cloud(_) => {} ViewElement::Alias(alias) => { write_alias_element(&mut self.buf, alias, &name_map); self.buf.push('\n'); @@ -3282,8 +3308,8 @@ mod tests { &HashSet::new(), &mut next_connector_uid, ); - // valve line uses allocated UID (field 3 = 0), variable line uses flow's UID - assert!(buf.starts_with("11,100,0,295,191,6,8,34,3,0,0,1,0,0,0\n")); + // No flow points, so no pipe connectors; valve and label follow + assert!(buf.contains("11,100,0,295,191,6,8,34,3,0,0,1,0,0,0")); assert!(buf.contains("10,6,Infection Rate,295,207,49,8,40,3,0,0,-1,0,0,0")); } diff --git a/src/simlin-engine/tests/mdl_roundtrip.rs b/src/simlin-engine/tests/mdl_roundtrip.rs index 96cb903a..d4d05c48 100644 --- a/src/simlin-engine/tests/mdl_roundtrip.rs +++ b/src/simlin-engine/tests/mdl_roundtrip.rs @@ -111,6 +111,9 @@ static TEST_MDL_MODELS_WITH_SKETCH: &[&str] = &[ "test/test-models/tests/constant_expressions/test_constant_expressions.mdl", "test/test-models/tests/parentheses/test_parens.mdl", "test/test-models/tests/number_handling/test_number_handling.mdl", + "test/bobby/vdf/econ/mark2.mdl", + "test/bobby/vdf/water/water.mdl", + "test/bobby/vdf/lookups/lookup_ex.mdl", ]; fn resolve_path(relative: &str) -> String { @@ -588,3 +591,111 @@ fn write_mdl_for_vensim_validation() { output_dir.display() ); } + +// --------------------------------------------------------------------------- +// Task 4: mark2 sketch structure validation +// --------------------------------------------------------------------------- + +/// Verify that the mark2 model's sketch roundtrips with correct Vensim +/// element ordering: for each flow, pipe connectors (type 1 with flag 22) +/// must precede the valve (type 11) and flow label (type 10, shape=40). +/// Clouds (type 12) must precede the pipe connectors that reference them. +#[test] +fn mark2_sketch_ordering() { + let file_path = resolve_path("test/bobby/vdf/econ/mark2.mdl"); + let source = fs::read_to_string(&file_path).expect("read mark2.mdl"); + let project = mdl::parse_mdl(&source).expect("parse mark2.mdl"); + + let mdl_text = mdl::project_to_mdl(&project).expect("write mark2.mdl"); + + // The written MDL should be re-parseable + let project2 = mdl::parse_mdl(&mdl_text).expect("re-parse mark2.mdl"); + + // Both views should survive + assert_eq!( + project.models[0].views.len(), + project2.models[0].views.len() + ); + + // Extract the sketch section from the written text + let sketch_start = mdl_text + .find("\\\\\\---/// Sketch information") + .expect("should have sketch section"); + let sketch_text = &mdl_text[sketch_start..]; + + // For each view, verify Vensim ordering constraints + for line in sketch_text.lines() { + // Flow pipe connectors (type 1 with field 7 = 22) must have the + // direction flag (field 4) set to 4 or 100 + if line.starts_with("1,") { + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() > 7 && fields[7] == "22" { + let direction: i32 = fields[4].parse().unwrap_or(0); + assert!( + direction == 4 || direction == 100, + "pipe connector should have direction 4 or 100, got {direction}: {line}" + ); + } + } + + // Influence connectors (type 1 without flag 22) should have field 9 = 64 + if line.starts_with("1,") { + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() > 9 && fields[7] != "22" { + let influence_flag: i32 = fields[9].parse().unwrap_or(0); + assert_eq!( + influence_flag, 64, + "influence connector should have field 9 = 64: {line}" + ); + } + } + } + + // Verify that pipe connectors appear before their valve in each flow block. + // Collect all valve UIDs and verify their pipes appear earlier in the text. + let sketch_lines: Vec<&str> = sketch_text.lines().collect(); + for (i, line) in sketch_lines.iter().enumerate() { + if !line.starts_with("11,") { + continue; + } + let valve_fields: Vec<&str> = line.split(',').collect(); + let valve_uid: &str = valve_fields[1]; + + // Find pipe connectors that reference this valve (field 2 = valve_uid) + for (j, pipe_line) in sketch_lines.iter().enumerate() { + if !pipe_line.starts_with("1,") { + continue; + } + let pipe_fields: Vec<&str> = pipe_line.split(',').collect(); + if pipe_fields.len() > 7 && pipe_fields[7] == "22" && pipe_fields[2] == valve_uid { + assert!( + j < i, + "pipe connector for valve {valve_uid} at line {j} should precede valve at line {i}" + ); + } + } + } + + // Verify cloud elements appear before the pipe connectors that reference them + for (i, line) in sketch_lines.iter().enumerate() { + if !line.starts_with("12,") { + continue; + } + let cloud_fields: Vec<&str> = line.split(',').collect(); + let cloud_uid: &str = cloud_fields[1]; + + // Find pipe connectors that reference this cloud (field 3 = cloud_uid) + for (j, pipe_line) in sketch_lines.iter().enumerate() { + if !pipe_line.starts_with("1,") { + continue; + } + let pipe_fields: Vec<&str> = pipe_line.split(',').collect(); + if pipe_fields.len() > 7 && pipe_fields[7] == "22" && pipe_fields[3] == cloud_uid { + assert!( + i < j, + "cloud {cloud_uid} at line {i} should precede pipe connector at line {j}" + ); + } + } + } +} diff --git a/test/bobby/vdf/econ/mark2.mdl b/test/bobby/vdf/econ/mark2.mdl index 4031cbf0..3aad95e6 100755 --- a/test/bobby/vdf/econ/mark2.mdl +++ b/test/bobby/vdf/econ/mark2.mdl @@ -436,7 +436,7 @@ $192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0 10,9,new home orders,181,77,60,9,40,3,0,0,-1,0,0,0 1,10,12,1,4,0,0,22,0,0,0,-1--1--1,,1|(490,53)| 1,11,12,4,100,0,0,22,0,0,0,-1--1--1,,1|(398,53)| -11,12,924,444,53,6,8,34,3,0,0,1,0,0,0 +11,12,1052,444,53,6,8,34,3,0,0,1,0,0,0 10,13,homes completed,444,82,36,16,40,3,0,0,-1,0,0,0 12,14,48,780,49,10,8,0,3,0,0,-1,0,0,0 1,15,17,14,4,0,0,22,0,0,0,-1--1--1,,1|(747,52)| @@ -559,90 +559,90 @@ $192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0 10,20,real inflation rate,906,257,57,9,8,3,0,0,0,0,0,0 10,21,inflation rate,1043,156,42,17,8,131,0,0,0,0,0,0 10,22,historical federal funds rate,863,103,58,16,8,131,0,0,0,0,0,0 -1,24,21,20,0,0,0,0,0,64,0,-1--1--1,,1|(974,206)| -10,25,interest earned from mortgages,358,171,56,16,8,3,0,0,0,0,0,0 -1,26,1,25,1,0,0,0,0,64,0,-1--1--1,,1|(276,220)| -10,27,insolvency risk,71,130,69,11,8,130,0,3,-1,0,0,0,128-128-128,0-0-0,|12||128-128-128 -10,28,perceived mortgage balance,481,205,47,24,8,131,0,0,0,0,0,0 -1,29,25,28,1,0,0,0,0,64,0,-1--1--1,,1|(419,168)| -1,30,6,28,1,0,0,0,0,64,0,-1--1--1,,1|(454,247)| -1,31,1,6,1,0,0,0,0,64,0,-1--1--1,,1|(282,325)| -10,32,time delay in investment earnings,434,71,52,30,8,131,0,0,-1,0,0,0 -1,33,32,28,1,0,0,0,0,64,0,-1--1--1,,1|(471,126)| -10,34,perceived inflation rate,901,330,54,26,3,131,0,0,0,0,0,0 -1,35,20,34,0,0,0,0,0,64,0,-1--1--1,,1|(904,278)| -12,36,48,217,420,10,8,0,3,0,0,-1,0,0,0 -1,37,39,36,4,0,0,22,0,0,0,-1--1--1,,1|(217,390)| -1,38,39,1,100,0,0,22,0,0,0,-1--1--1,,1|(217,336)| -11,39,48,217,363,8,6,33,3,0,0,4,0,0,0 -10,40,amortization,261,363,38,9,40,3,0,0,-1,0,0,0 -1,41,1,39,1,0,0,0,0,64,0,-1--1--1,,1|(196,346)| -10,42,average repayment rate,307,428,46,24,8,131,0,0,0,0,0,0 -1,43,42,40,0,0,0,0,0,64,0,-1--1--1,,1|(283,393)| -1,44,12,11,1,0,0,0,0,64,0,-1--1--1,,1|(-4,343)| -1,45,13,11,1,0,0,0,0,64,0,-1--1--1,,1|(61,367)| -10,46,perceived risk of insolvency,246,115,50,25,3,131,0,0,0,0,0,0 -1,47,27,46,1,0,0,0,0,64,0,-1--1--1,,1|(158,117)| -1,48,46,25,1,0,0,0,0,64,0,-1--1--1,,1|(302,133)| -10,49,defaults,188,200,40,20,3,3,0,0,0,0,0,0 -1,50,27,49,1,0,0,0,0,64,0,-1--1--1,,1|(143,152)| -10,51,average time before default,56,221,47,22,8,131,0,0,0,0,0,0 -1,52,51,49,0,0,0,0,0,64,0,-1--1--1,,1|(118,211)| -1,53,49,5,1,0,0,0,0,64,0,-1--1--1,,1|(309,227)| -10,54,insolvency elasticity of mortgage interest,184,32,56,28,8,131,0,0,0,0,0,0 -1,55,54,25,1,0,0,0,0,64,0,-1--1--1,,1|(297,89)| -10,56,effect of negative inflation rate on risk taking behavior,749,374,60,24,8,3,0,0,0,0,0,0 -1,57,34,56,1,0,0,0,0,64,0,-1--1--1,,1|(796,327)| -10,58,inflation elasticity of risky behavior,662,455,58,22,8,131,0,0,0,0,0,0 -10,59,desired risk taking behavior,940,543,55,16,8,3,0,0,0,0,0,0 -1,60,14,59,1,0,0,0,0,64,0,-1--1--1,,1|(859,540)| -1,61,59,18,1,0,0,0,0,64,0,-1--1--1,,1|(924,576)| -1,62,14,19,1,0,0,0,0,64,0,-1--1--1,,1|(791,672)| -10,63,time to change risk taking behavior,1043,601,51,27,8,131,0,0,0,0,0,0 -1,64,63,19,1,0,0,0,0,64,0,-1--1--1,,1|(988,634)| -1,65,58,56,1,0,0,0,0,64,0,-1--1--1,,1|(722,432)| -10,66,homes being built,443,376,44,16,8,2,1,3,-1,0,0,0,128-128-128,0-0-0,|10||128-128-128 -10,67,initial new homes on market,457,430,58,16,8,2,1,3,-1,0,0,0,128-128-128,0-0-0,|10||128-128-128 -10,68,Time,1011,265,26,9,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|10||128-128-128 -10,69,hud policy,1056,346,37,9,8,131,0,0,0,0,0,0 -10,70,hud policy lookup,1098,286,45,17,8,131,0,0,0,0,0,0 -1,71,70,69,0,0,0,0,0,64,0,-1--1--1,,1|(1078,314)| -1,72,68,69,0,0,0,0,0,64,0,-1--1--1,,1|(1030,299)| -10,73,hud elasticity of risky behavior,1092,464,47,27,8,131,0,0,-1,0,0,0 -10,74,desired rate of change of risk taking,888,460,62,16,8,3,0,0,0,0,0,0 -1,75,56,74,1,0,0,0,0,64,0,-1--1--1,,1|(778,432)| -10,76,effect of hud policies on risk taking behavior,985,400,67,20,8,131,0,0,0,0,0,0 -1,77,73,76,0,0,0,0,0,64,0,-1--1--1,,1|(1038,432)| -1,78,69,76,0,0,0,0,0,64,0,-1--1--1,,1|(1033,363)| -1,79,76,74,0,0,0,0,0,64,0,-1--1--1,,1|(939,428)| -1,80,74,59,1,0,0,0,0,64,0,-1--1--1,,1|(895,492)| -10,81,max risk,1080,532,35,9,8,131,0,0,0,0,0,0 -1,82,81,59,0,0,0,0,0,64,0,-1--1--1,,1|(1026,535)| -10,83,federal funds rate lookup,768,48,39,25,8,131,0,0,0,0,0,0 -10,84,Time,961,54,26,9,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|10||128-128-128 -1,85,83,22,0,0,0,0,0,64,0,-1--1--1,,1|(815,74)| -1,86,84,22,0,0,0,0,0,64,0,-1--1--1,,1|(925,71)| -10,87,inflation rate lookup,1091,43,52,17,8,131,0,0,0,0,0,0 -1,88,87,21,0,0,0,0,0,64,0,-1--1--1,,1|(1069,93)| -1,89,84,21,0,0,0,0,0,64,0,-1--1--1,,1|(994,95)| -10,90,Endogenous Federal Funds Rate,657,222,45,24,3,131,0,0,0,0,0,0 -12,91,48,825,211,10,8,0,3,0,0,-1,0,0,0 -1,93,95,90,4,0,0,22,0,0,0,-1--1--1,,1|(731,211)| -1,94,95,91,100,0,0,22,0,0,0,-1--1--1,,1|(794,211)| -11,95,0,767,211,6,8,34,3,0,0,1,0,0,0 -10,96,change in federal funds rate,767,235,60,16,40,3,0,0,-1,0,0,0 -10,97,time to adjust federal funds rate,625,294,60,24,8,3,0,0,0,0,0,0 -10,98,target federal funds rate,781,296,60,16,8,3,0,0,0,0,0,0 -1,99,98,96,1,0,0,0,0,64,0,-1--1--1,,1|(788,271)| -1,100,21,96,1,0,0,0,0,64,0,-1--1--1,,1|(898,211)| -1,101,90,95,1,0,0,0,0,64,0,-1--1--1,,1|(707,184)| -1,102,97,96,1,0,0,0,0,64,0,-1--1--1,,1|(713,273)| -10,104,federal funds policy switch,665,107,57,16,8,3,0,0,0,0,0,0 -10,105,federal funds rate,861,172,38,17,8,131,0,0,0,0,0,0 -1,106,104,105,0,0,0,0,0,64,0,-1--1--1,,1|(761,138)| -1,107,22,105,0,0,0,0,0,64,0,-1--1--1,,1|(862,130)| -1,108,90,105,1,0,0,0,0,64,0,-1--1--1,,1|(727,163)| -1,109,105,20,0,0,0,0,0,64,0,-1--1--1,,1|(882,212)| +1,23,21,20,0,0,0,0,0,64,0,-1--1--1,,1|(974,206)| +10,24,interest earned from mortgages,358,171,56,16,8,3,0,0,0,0,0,0 +1,25,1,24,1,0,0,0,0,64,0,-1--1--1,,1|(276,220)| +10,26,insolvency risk,71,130,69,11,8,130,0,3,-1,0,0,0,128-128-128,0-0-0,|12||128-128-128 +10,27,perceived mortgage balance,481,205,47,24,8,131,0,0,0,0,0,0 +1,28,24,27,1,0,0,0,0,64,0,-1--1--1,,1|(419,168)| +1,29,6,27,1,0,0,0,0,64,0,-1--1--1,,1|(454,247)| +1,30,1,6,1,0,0,0,0,64,0,-1--1--1,,1|(282,325)| +10,31,time delay in investment earnings,434,71,52,30,8,131,0,0,-1,0,0,0 +1,32,31,27,1,0,0,0,0,64,0,-1--1--1,,1|(471,126)| +10,33,perceived inflation rate,901,330,54,26,3,131,0,0,0,0,0,0 +1,34,20,33,0,0,0,0,0,64,0,-1--1--1,,1|(904,278)| +12,35,48,217,420,10,8,0,3,0,0,-1,0,0,0 +1,36,38,35,4,0,0,22,0,0,0,-1--1--1,,1|(217,390)| +1,37,38,1,100,0,0,22,0,0,0,-1--1--1,,1|(217,336)| +11,38,48,217,363,8,6,33,3,0,0,4,0,0,0 +10,39,amortization,261,363,38,9,40,3,0,0,-1,0,0,0 +1,40,1,38,1,0,0,0,0,64,0,-1--1--1,,1|(196,346)| +10,41,average repayment rate,307,428,46,24,8,131,0,0,0,0,0,0 +1,42,41,39,0,0,0,0,0,64,0,-1--1--1,,1|(283,393)| +1,43,12,11,1,0,0,0,0,64,0,-1--1--1,,1|(-4,343)| +1,44,13,11,1,0,0,0,0,64,0,-1--1--1,,1|(61,367)| +10,45,perceived risk of insolvency,246,115,50,25,3,131,0,0,0,0,0,0 +1,46,26,45,1,0,0,0,0,64,0,-1--1--1,,1|(158,117)| +1,47,45,24,1,0,0,0,0,64,0,-1--1--1,,1|(302,133)| +10,48,defaults,188,200,40,20,3,3,0,0,0,0,0,0 +1,49,26,48,1,0,0,0,0,64,0,-1--1--1,,1|(143,152)| +10,50,average time before default,56,221,47,22,8,131,0,0,0,0,0,0 +1,51,50,48,0,0,0,0,0,64,0,-1--1--1,,1|(118,211)| +1,52,48,5,1,0,0,0,0,64,0,-1--1--1,,1|(309,227)| +10,53,insolvency elasticity of mortgage interest,184,32,56,28,8,131,0,0,0,0,0,0 +1,54,53,24,1,0,0,0,0,64,0,-1--1--1,,1|(297,89)| +10,55,effect of negative inflation rate on risk taking behavior,749,374,60,24,8,3,0,0,0,0,0,0 +1,56,33,55,1,0,0,0,0,64,0,-1--1--1,,1|(796,327)| +10,57,inflation elasticity of risky behavior,662,455,58,22,8,131,0,0,0,0,0,0 +10,58,desired risk taking behavior,940,543,55,16,8,3,0,0,0,0,0,0 +1,59,14,58,1,0,0,0,0,64,0,-1--1--1,,1|(859,540)| +1,60,58,18,1,0,0,0,0,64,0,-1--1--1,,1|(924,576)| +1,61,14,19,1,0,0,0,0,64,0,-1--1--1,,1|(791,672)| +10,62,time to change risk taking behavior,1043,601,51,27,8,131,0,0,0,0,0,0 +1,63,62,19,1,0,0,0,0,64,0,-1--1--1,,1|(988,634)| +1,64,57,55,1,0,0,0,0,64,0,-1--1--1,,1|(722,432)| +10,65,homes being built,443,376,44,16,8,2,1,3,-1,0,0,0,128-128-128,0-0-0,|10||128-128-128 +10,66,initial new homes on market,457,430,58,16,8,2,1,3,-1,0,0,0,128-128-128,0-0-0,|10||128-128-128 +10,67,Time,1011,265,26,9,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|10||128-128-128 +10,68,hud policy,1056,346,37,9,8,131,0,0,0,0,0,0 +10,69,hud policy lookup,1098,286,45,17,8,131,0,0,0,0,0,0 +1,70,69,68,0,0,0,0,0,64,0,-1--1--1,,1|(1078,314)| +1,71,67,68,0,0,0,0,0,64,0,-1--1--1,,1|(1030,299)| +10,72,hud elasticity of risky behavior,1092,464,47,27,8,131,0,0,-1,0,0,0 +10,73,desired rate of change of risk taking,888,460,62,16,8,3,0,0,0,0,0,0 +1,74,55,73,1,0,0,0,0,64,0,-1--1--1,,1|(778,432)| +10,75,effect of hud policies on risk taking behavior,985,400,67,20,8,131,0,0,0,0,0,0 +1,76,72,75,0,0,0,0,0,64,0,-1--1--1,,1|(1038,432)| +1,77,68,75,0,0,0,0,0,64,0,-1--1--1,,1|(1033,363)| +1,78,75,73,0,0,0,0,0,64,0,-1--1--1,,1|(939,428)| +1,79,73,58,1,0,0,0,0,64,0,-1--1--1,,1|(895,492)| +10,80,max risk,1080,532,35,9,8,131,0,0,0,0,0,0 +1,81,80,58,0,0,0,0,0,64,0,-1--1--1,,1|(1026,535)| +10,82,federal funds rate lookup,768,48,39,25,8,131,0,0,0,0,0,0 +10,83,Time,961,54,26,9,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|10||128-128-128 +1,84,82,22,0,0,0,0,0,64,0,-1--1--1,,1|(815,74)| +1,85,83,22,0,0,0,0,0,64,0,-1--1--1,,1|(925,71)| +10,86,inflation rate lookup,1091,43,52,17,8,131,0,0,0,0,0,0 +1,87,86,21,0,0,0,0,0,64,0,-1--1--1,,1|(1069,93)| +1,88,83,21,0,0,0,0,0,64,0,-1--1--1,,1|(994,95)| +10,89,Endogenous Federal Funds Rate,657,222,45,24,3,131,0,0,0,0,0,0 +12,90,48,825,211,10,8,0,3,0,0,-1,0,0,0 +1,91,93,89,4,0,0,22,0,0,0,-1--1--1,,1|(731,211)| +1,92,93,90,100,0,0,22,0,0,0,-1--1--1,,1|(794,211)| +11,93,48,767,211,6,8,34,3,0,0,1,0,0,0 +10,94,change in federal funds rate,767,235,60,16,40,3,0,0,-1,0,0,0 +10,95,time to adjust federal funds rate,625,294,60,24,8,3,0,0,0,0,0,0 +10,96,target federal funds rate,781,296,60,16,8,3,0,0,0,0,0,0 +1,97,96,94,1,0,0,0,0,64,0,-1--1--1,,1|(788,271)| +1,98,21,94,1,0,0,0,0,64,0,-1--1--1,,1|(898,211)| +1,99,89,93,1,0,0,0,0,64,0,-1--1--1,,1|(707,184)| +1,100,95,94,1,0,0,0,0,64,0,-1--1--1,,1|(713,273)| +10,101,federal funds policy switch,665,107,57,16,8,3,0,0,0,0,0,0 +10,102,federal funds rate,861,172,38,17,8,131,0,0,0,0,0,0 +1,103,101,102,0,0,0,0,0,64,0,-1--1--1,,1|(761,138)| +1,104,22,102,0,0,0,0,0,64,0,-1--1--1,,1|(862,130)| +1,105,89,102,1,0,0,0,0,64,0,-1--1--1,,1|(727,163)| +1,106,102,20,0,0,0,0,0,64,0,-1--1--1,,1|(882,212)| ///---\\\ :GRAPH inflation :TITLE inflation @@ -670,9 +670,8 @@ $192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0 :VAR federal funds rate :VAR endogenous federal funds rate :L<%^E!@ -1:policy.vdf -1:base.vdf 1:data.vdf +1:policy.vdf 9:policy 22:$,Dollar,Dollars,$s 22:Hour,Hours From 0b16016e55c1857a456920da38475a1a9457257f Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 09:17:32 -0700 Subject: [PATCH 03/31] engine: fix sketch name newline escaping and add .Control group Real newline characters in XMILE view element names (e.g. "Effect of fish density\non catch per ship") were emitted as actual line breaks in MDL sketch output, breaking Vensim's line-by-line parser. Add format_sketch_name() that escapes actual newlines to literal \n and use it for all sketch element name writers. Also emit the .Control group header before sim spec variables, and emit per-view sketch section separators for multi-view models. --- src/simlin-engine/src/mdl/writer.rs | 57 +++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 8449e531..a071eb16 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1193,12 +1193,23 @@ pub fn write_dimension_def(buf: &mut String, dim: &datamodel::Dimension) { // ---- Sketch element serialization ---- +/// Format a view element name for sketch output. +/// +/// Sketch names need underscores replaced with spaces (like `underbar_to_space`), +/// but also must escape actual newline characters as the literal two-character +/// sequence `\n`. XMILE sources may contain real newlines in view element name +/// attributes; Vensim MDL sketch lines are single-line records, so a real +/// newline in a name would break parsing. +fn format_sketch_name(name: &str) -> String { + name.replace('_', " ").replace('\n', "\\n") +} + /// Write a type 10 line for an Aux element. -/// Sketch element names use bare `underbar_to_space` (not `format_mdl_ident`) +/// Sketch element names use `format_sketch_name` (not `format_mdl_ident`) /// because MDL sketch lines are comma-delimited positional records where /// quoting is not used. fn write_aux_element(buf: &mut String, aux: &view_element::Aux) { - let name = underbar_to_space(&aux.name); + let name = format_sketch_name(&aux.name); // shape=8 (has equation), bits=3 (visible, primary) write!( buf, @@ -1208,10 +1219,9 @@ fn write_aux_element(buf: &mut String, aux: &view_element::Aux) { .unwrap(); } -/// Write a type 10 line for a Stock element. See `write_aux_element` for -/// why sketch names use `underbar_to_space` instead of `format_mdl_ident`. +/// Write a type 10 line for a Stock element. fn write_stock_element(buf: &mut String, stock: &view_element::Stock) { - let name = underbar_to_space(&stock.name); + let name = format_sketch_name(&stock.name); // shape=3 (box/stock shape), bits=3 (visible, primary) write!( buf, @@ -1307,7 +1317,7 @@ fn write_flow_element( cloud_uids: &HashSet, next_connector_uid: &mut i32, ) { - let name = underbar_to_space(&flow.name); + let name = format_sketch_name(&flow.name); let valve_uid = valve_uids.get(&flow.uid).copied().unwrap_or(flow.uid - 1); // Pipe connectors must come before the valve and flow label. @@ -1453,10 +1463,9 @@ fn write_alias_element( alias: &view_element::Alias, name_map: &HashMap, ) { - // Sketch names: see `write_aux_element` for why `underbar_to_space`. let name = name_map .get(&alias.alias_of_uid) - .map(|n| underbar_to_space(n)) + .map(|n| format_sketch_name(n)) .unwrap_or_default(); // shape=8, bits=2 (visible but bit 0 unset = ghost) write!( @@ -1681,20 +1690,26 @@ impl MdlWriter { } // Build a set of variable idents that belong to any group + // (skip .Control -- those vars are sim specs emitted separately) let mut grouped_idents: HashSet<&str> = HashSet::new(); for group in &model.groups { + if group.name == "Control" || group.name == "control" { + continue; + } for member in &group.members { grouped_idents.insert(member.as_str()); } } - // 2. Variables in group order + // 2. Variables in group order (skip .Control -- emitted with sim specs) for group in &model.groups { + if group.name == "Control" || group.name == "control" { + continue; + } // Group marker write!( self.buf, "\n********************************************************\n\t.{}\n********************************************************~\n\t\t{}\n\t|\n", - // Group names appear in comment-like header blocks, not in equations. underbar_to_space(&group.name), group.doc.as_deref().unwrap_or(""), ) @@ -1720,7 +1735,10 @@ impl MdlWriter { } } - // 4. Sim spec variables + // 4. .Control group header + sim spec variables + self.buf.push_str( + "\n********************************************************\n\t.Control\n********************************************************~\n\t\tSimulation Control Parameters\n\t|\n", + ); let sim_specs = model.sim_specs.as_ref().unwrap_or(&project.sim_specs); self.write_sim_specs(sim_specs); @@ -1731,13 +1749,20 @@ impl MdlWriter { /// Write the sketch/view section of the MDL file. /// - /// This emits the sketch header, each view's elements, and the sketch - /// terminator. The section follows the equations terminator line. + /// Each view gets its own `\\\---///` separator and `V300` header line. + /// The first view's separator is already emitted by `write_equations_section`. + /// The final `///---\\\` terminator follows the last view. fn write_sketch_section(&mut self, views: &[View]) { - self.buf - .push_str("V300 Do not put anything below this section - it will be ignored\n"); + for (i, view) in views.iter().enumerate() { + if i > 0 { + // Additional views need their own separator + self.buf.push_str( + "\\\\\\---/// Sketch information - do not modify anything except names\n", + ); + } + self.buf + .push_str("V300 Do not put anything below this section - it will be ignored\n"); - for view in views { let View::StockFlow(sf) = view; self.write_stock_flow_view(sf); } From 646ac509429083d663f3f188a1010931bca88bd8 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 11:17:56 -0700 Subject: [PATCH 04/31] engine: use CRLF line endings in MDL output Vensim requires CRLF (\r\n) line endings. Our writer was outputting LF (\n) which caused Vensim to report "syntax error" and fail to render most sketch elements. Convert all newlines to CRLF in the final output step. --- src/simlin-engine/src/mdl/writer.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index a071eb16..70b2bd9e 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1620,13 +1620,16 @@ impl MdlWriter { } /// Orchestrate the full MDL file assembly and return the result. + /// + /// Vensim requires CRLF (`\r\n`) line endings, so the final output + /// is converted from LF to CRLF before returning. pub(super) fn write_project(mut self, project: &datamodel::Project) -> Result { self.buf.push_str("{UTF-8}\n"); let model = &project.models[0]; self.write_equations_section(model, project); self.write_sketch_section(&model.views); self.write_settings_section(project); - Ok(self.buf) + Ok(self.buf.replace('\n', "\r\n")) } /// Write sim spec control variables (INITIAL TIME, FINAL TIME, TIME STEP, SAVEPER). @@ -3047,7 +3050,7 @@ mod tests { assert!(result.is_ok(), "should succeed: {:?}", result); let mdl = result.unwrap(); assert!( - mdl.starts_with("{UTF-8}\n"), + mdl.starts_with("{UTF-8}\r\n"), "MDL should start with UTF-8 marker, got: {:?}", mdl.lines().next() ); @@ -3275,7 +3278,7 @@ mod tests { // Dimension def at the start, before variables assert!( - mdl.contains("region:\n\tnorth, south\n\t~~|"), + mdl.contains("region:\r\n\tnorth, south\r\n\t~~|"), "should contain dimension def" ); let dim_pos = mdl.find("region:").unwrap(); @@ -3796,7 +3799,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 let mdl = crate::mdl::project_to_mdl(&project).expect("roundtrip MDL write should work"); assert!( - mdl.contains("*Overview\n"), + mdl.contains("*Overview\r\n"), "Roundtrip should preserve original view title: {}", mdl ); @@ -3829,7 +3832,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); assert!( - mdl.contains("*Overview Main\n"), + mdl.contains("*Overview Main\r\n"), "view title should be serialized as a single line: {mdl}", ); From c1dcf52c44488de8dc746233406fefd693aa94bd Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 12:31:59 -0700 Subject: [PATCH 05/31] engine: fix settings marker and display range values The settings marker requires a DEL byte (0x7F) between :L and <%^E!@ to match what Vensim produces and expects: :L\x7F<%^E!@. Without this byte Vensim reports "syntax error" on the file. Also fix type 26 (display range end) to use FINAL TIME instead of TIME STEP, matching all reference MDL files. --- src/simlin-engine/src/mdl/writer.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 70b2bd9e..ad89bf70 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1864,8 +1864,9 @@ impl MdlWriter { .and_then(|m| m.sim_specs.as_ref()) .unwrap_or(&project.sim_specs); - // The ///---\\\ separator is already emitted by write_sketch_section - self.buf.push_str(":L<%^E!@\n"); + // The ///---\\\ separator is already emitted by write_sketch_section. + // The 0x7F (DEL) between :L and <%^E!@ is required by Vensim's parser. + self.buf.push_str(":L\x7F<%^E!@\n"); // Type 22: Unit equivalences for unit in &project.units { @@ -1913,14 +1914,11 @@ impl MdlWriter { self.buf.push_str("41:0\n"); self.buf.push_str("42:0\n"); - // Types 24/25/26: Time bounds (initial, final, time step) + // Types 24/25/26: Display time range (start, end, end). + // All reference files set 24=start, 25=stop, 26=stop. writeln!(self.buf, "24:{}", format_f64(sim_specs.start)).unwrap(); writeln!(self.buf, "25:{}", format_f64(sim_specs.stop)).unwrap(); - let dt_val = match &sim_specs.dt { - datamodel::Dt::Dt(v) => format_f64(*v), - datamodel::Dt::Reciprocal(v) => format!("1/{}", format_f64(*v)), - }; - writeln!(self.buf, "26:{}", dt_val).unwrap(); + writeln!(self.buf, "26:{}", format_f64(sim_specs.stop)).unwrap(); } } @@ -4001,7 +3999,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 writer.write_settings_section(&project); let output = writer.buf; assert!( - output.starts_with(":L<%^E!@\n"), + output.starts_with(":L\x7F<%^E!@\n"), "settings section should start with marker (separator is in sketch section), got: {:?}", &output[..output.len().min(40)] ); @@ -4225,14 +4223,14 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 assert!(mdl.contains("*View 1"), "should have view title"); // Section 3: Settings -- marker and type codes - assert!(mdl.contains(":L<%^E!@"), "should have settings marker"); + assert!(mdl.contains(":L\x7F<%^E!@"), "should have settings marker"); assert!(mdl.contains("15:"), "should have Type 15 line"); // Sections should be in order: equations, sketch, settings let eq_term = mdl.find("\\\\\\---/// Sketch").unwrap(); let v300 = mdl.find("V300").unwrap(); let sketch_term = mdl.find("///---\\\\\\").unwrap(); - let settings_marker = mdl.find(":L<%^E!@").unwrap(); + let settings_marker = mdl.find(":L\x7F<%^E!@").unwrap(); assert!(eq_term < v300, "equations should come before sketch"); assert!( v300 < sketch_term, From 6261084d0d43bcf6472e5c1bbe33e188f948ad4c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 14:21:53 -0700 Subject: [PATCH 06/31] doc: add MDL roundtrip fidelity design plan Design for improving MDL writer output fidelity so roundtripped files preserve multi-view structure, element metadata, lookup syntax, variable name casing, and equation formatting. Six implementation phases covering datamodel extensions, parser metadata capture, writer multi-view split, lookup/casing fixes, equation formatting, and integration testing. --- .../2026-03-18-mdl-roundtrip-fidelity.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/design-plans/2026-03-18-mdl-roundtrip-fidelity.md diff --git a/docs/design-plans/2026-03-18-mdl-roundtrip-fidelity.md b/docs/design-plans/2026-03-18-mdl-roundtrip-fidelity.md new file mode 100644 index 00000000..bc4f8a6c --- /dev/null +++ b/docs/design-plans/2026-03-18-mdl-roundtrip-fidelity.md @@ -0,0 +1,181 @@ +# MDL Roundtrip Fidelity Design + +## Summary + +This design plan improves the fidelity of Simlin's MDL (Vensim model definition) writer so that a Vensim `.mdl` file can be parsed by Simlin and written back out with its format largely preserved -- a "roundtrip." Today the writer loses significant information: it collapses multiple diagram views into one, discards element sizing and font metadata, rewrites lookup call syntax into a different form, lowercases variable names in equations, and formats all equations identically. The result is an MDL file that, while functionally equivalent, looks substantially different from the original and can confuse Vensim users who open it. + +The approach is to thread Vensim-specific metadata through two layers. First, the MDL parser is extended to capture and store format details it currently discards -- element dimensions, font specifications, explicit lookup range bounds -- into new optional "compat" structs on the existing datamodel types. Second, the MDL writer is enhanced to consume that metadata: splitting merged views back into named sections using group boundaries, emitting native lookup call syntax, preserving variable name casing from view elements, and formatting equations with Vensim conventions (inline short equations, backslash continuations for long ones). A dedicated integration test roundtrips a real multi-view model (mark2.mdl) and asserts structural equivalence between input and output. + +## Definition of Done + +1. **Multi-view MDL output**: `simlin convert --to mdl` produces separate named views from groups/sectors in the datamodel. The MDL writer splits merged views on `ViewElement::Group` boundaries, emitting named views like `*1 housing` and `*2 investments`. + +2. **MDL format fidelity**: Written MDL files preserve element sizes/bits, view fonts, lookup range bounds, lookup call syntax (`name(Time)` not `LOOKUP()`), variable name casing in equation definitions, equation ordering by sector, inline formatting for short equations, and backslash line continuations for long lines. + +3. **Explicit MDL format test**: A new integration test (registered in Cargo.toml) roundtrips mark2.mdl through `parse_mdl` -> `project_to_mdl` and asserts: 2 named views, correct element counts per view, correct equation formatting patterns, and preserved metadata. + +4. **Vensim compatibility**: Roundtripped mark2.mdl is openable by Vensim with diagrams that look the same as the original. + +**Out of scope**: Graph definitions (`:GRAPH` blocks), VDF file references and other non-essential settings entries, web app multi-view support. + +## Acceptance Criteria + +### mdl-roundtrip-fidelity.AC1: Multi-view MDL output +- **mdl-roundtrip-fidelity.AC1.1 Success:** mark2.mdl roundtrip produces exactly 2 views with names `*1 housing` and `*2 investments` +- **mdl-roundtrip-fidelity.AC1.2 Success:** Each view contains the correct elements — every element line from the original view appears in the corresponding output view (unordered set comparison) +- **mdl-roundtrip-fidelity.AC1.3 Success:** Single-view models (no ViewElement::Group markers) produce a single view as before +- **mdl-roundtrip-fidelity.AC1.4 Success:** Each view has its own font specification line matching the original + +### mdl-roundtrip-fidelity.AC2: Element metadata preservation +- **mdl-roundtrip-fidelity.AC2.1 Success:** Stock elements preserve original width/height/bits (e.g. `53,32,3,131` not hardcoded `40,20,3,3`) +- **mdl-roundtrip-fidelity.AC2.2 Success:** Aux, flow, cloud, and alias elements preserve original dimensions and bits +- **mdl-roundtrip-fidelity.AC2.3 Success:** Elements without compat data (e.g. from XMILE imports) use hardcoded defaults without error + +### mdl-roundtrip-fidelity.AC3: Lookup fidelity +- **mdl-roundtrip-fidelity.AC3.1 Success:** Lookup invocations emit as `table_name ( input )` not `LOOKUP(table_name, input)` +- **mdl-roundtrip-fidelity.AC3.2 Success:** Explicit lookup range bounds are preserved (e.g. `[(0,0)-(300,10)]` not computed `[(0,0.98)-(300,8.29)]`) +- **mdl-roundtrip-fidelity.AC3.3 Success:** Lookups without explicit bounds still compute bounds from data (existing behavior for XMILE-sourced models) + +### mdl-roundtrip-fidelity.AC4: Equation formatting +- **mdl-roundtrip-fidelity.AC4.1 Success:** Short equations use inline format with spaces around `=` (e.g. `average repayment rate = 0.03`) +- **mdl-roundtrip-fidelity.AC4.2 Success:** Long equations use multiline format with backslash line continuations +- **mdl-roundtrip-fidelity.AC4.3 Success:** Variable name casing on equation LHS matches original (e.g. `Endogenous Federal Funds Rate=`) +- **mdl-roundtrip-fidelity.AC4.4 Success:** Ungrouped variables are ordered deterministically (alphabetically by ident) +- **mdl-roundtrip-fidelity.AC4.5 Success:** Grouped variables retain sector-based ordering + +### mdl-roundtrip-fidelity.AC5: Test coverage +- **mdl-roundtrip-fidelity.AC5.1 Success:** `mdl_roundtrip` test is registered in Cargo.toml and runs with `cargo test` +- **mdl-roundtrip-fidelity.AC5.2 Success:** Format test roundtrips mark2.mdl and asserts per-view element lines match as unordered sets (with only documented normalizations) +- **mdl-roundtrip-fidelity.AC5.3 Success:** Existing roundtrip and simulation tests continue to pass + +## Glossary + +- **MDL**: Vensim's native plain-text model file format. Contains equations (variable definitions with units and comments) and sketch (diagram layout) sections. Uses `|` as a variable delimiter and `~` to separate equation, units, and comment fields. +- **Roundtrip**: Reading a file, converting through an internal representation, and writing back to the same format. "Roundtrip fidelity" measures how closely the output matches the original. +- **Vensim**: Commercial system dynamics modeling software by Ventana Systems. Defines the MDL format that Simlin interoperates with. +- **Compat struct**: A Simlin pattern where format-specific metadata (no XMILE equivalent) is stored in an optional struct on the relevant datamodel type. Existing example: `datamodel::Compat` on Variable types. +- **ViewElement::Group**: A datamodel variant representing a Vensim "sector" or named group of diagram elements. During MDL parsing, multiple views are merged into one view with Group markers at boundaries; the writer reverses this. +- **Lookup / Graphical function**: A table-defined function mapping input to output via interpolation. Vensim invokes as `table_name(input)`. Simlin normalizes to `LOOKUP(table_name, input)` internally. +- **Bits**: An integer bitmask in MDL sketch element lines encoding Vensim display flags (label visibility, variable type hints). Opaque to Simlin but must be preserved for compatibility. +- **MdlPrintVisitor**: The AST visitor in `writer.rs` that walks equation syntax trees and serializes them to MDL text. +- **Canonical ident**: Simlin's internal identifier form: lowercase with underscores (e.g., `federal_funds_rate`). MDL uses space-separated mixed-case names. +- **mark2.mdl**: A multi-view Vensim test model (views: "housing" and "investments") used as the primary roundtrip fidelity test fixture. +- **Backslash line continuation**: Vensim's convention for wrapping long equation lines: backslash at end of line followed by tab-indented continuation. + +## Architecture + +The MDL writer (`src/simlin-engine/src/mdl/writer.rs`) is the primary change surface. It currently emits a single view, hardcodes element dimensions and fonts, uses alphabetical equation ordering, and always formats equations as multiline. The parser (`src/simlin-engine/src/mdl/`) captures some metadata that is lost before reaching the datamodel. + +The fix has two layers: + +**Datamodel enrichment (parse-time):** Extend view element types in `src/simlin-engine/src/datamodel.rs` with optional compat structs carrying Vensim-specific metadata (width, height, bits). Add an optional font field to `StockFlow`. Fix `build_graphical_function` in `src/simlin-engine/src/mdl/convert/variables.rs` to preserve explicit y-range bounds from lookup definitions instead of recomputing from data. + +**Writer improvements (write-time):** The writer splits a merged single-view model back into multiple named views using `ViewElement::Group` boundaries. It uses compat metadata when present (falling back to current defaults). It pattern-matches `LOOKUP(name, input)` calls in the equation AST and emits Vensim-native `name(input)` syntax. It uses view element names for equation LHS casing. It formats short equations inline and wraps long ones with backslash continuations. + +## Existing Patterns + +**Compat struct pattern:** `datamodel::Compat` (line 241) already stores Vensim-specific variable metadata (active_initial, non_negative, visibility, data_source) as an optional struct on Stock, Flow, Aux, and Module. View element compat follows the same pattern. + +**View element name for display:** View elements already store original-casing names distinct from canonical idents. The TypeScript drawing code (`src/diagram/drawing/common.ts:76`) uses `displayName(element.name)` for rendering labels. The MDL writer will use this same name field for equation LHS casing. + +**AST walking in writer:** `MdlPrintVisitor` in `writer.rs` already walks the equation AST for serialization. The lookup call detection adds a special case to the existing `walk()` method. + +**Equation group ordering:** The writer already iterates `model.groups` to emit sector markers and grouped variables. The fix extends this to sort ungrouped variables deterministically. + +## Implementation Phases + + +### Phase 1: Datamodel Extensions +**Goal:** Add compat metadata fields to view element types and StockFlow so the parser can store Vensim-specific display information. + +**Components:** +- `ViewElementCompat` struct in `src/simlin-engine/src/datamodel.rs` with optional `width: f64`, `height: f64`, `bits: u32` +- Optional `compat` field on `view_element::Aux`, `Stock`, `Flow`, `Cloud`, `Alias` +- Optional `font: String` on `StockFlow` (or a `StockFlowCompat` wrapper) +- Protobuf schema updates in `src/simlin-engine/src/project_io.proto` for new fields +- Regenerate protobuf bindings (`pnpm build:gen-protobufs`) + +**Dependencies:** None + +**Done when:** Project builds, existing tests pass, new types are available for use by parser and writer + + + +### Phase 2: Parser Metadata Capture +**Goal:** Thread Vensim-specific metadata from the MDL parser through to the datamodel types added in Phase 1. + +**Components:** +- MDL view converter in `src/simlin-engine/src/mdl/view/convert.rs` — pass width/height/bits from parsed `VensimVariable` into `view_element::Aux`, `Stock`, etc. compat fields +- MDL view parser in `src/simlin-engine/src/mdl/view/mod.rs` — capture the `$...` font specification line instead of skipping it, store on `StockFlow.font` +- Lookup bounds fix in `src/simlin-engine/src/mdl/convert/variables.rs` — add `if let Some(y_range) = table.y_range` guard in `build_graphical_function` to preserve explicit y bounds, mirroring existing x_range handling +- Update `test_graphical_function_y_scale_computed_from_data` unit test + +**Dependencies:** Phase 1 + +**Done when:** Parsing mark2.mdl produces a datamodel with: correct per-element width/height/bits in compat, font spec on StockFlow, preserved y-range bounds on lookups. Tests verify these values. + + + +### Phase 3: Multi-View Split in Writer +**Goal:** The MDL writer splits a merged single-view model into multiple named views based on `ViewElement::Group` boundaries. + +**Components:** +- `write_sketch_section` in `src/simlin-engine/src/mdl/writer.rs` — detect `ViewElement::Group` elements, partition elements into per-view segments, emit each as a separate named view with its own separator and header +- View naming — use Group name directly (e.g. `*1 housing`) since the parser preserves the original Vensim `N name` format +- Font line — emit from StockFlow compat font when present, fall back to hardcoded default +- Element writing (`write_aux_element`, `write_stock_element`, etc.) — use compat width/height/bits when present, fall back to current defaults + +**Dependencies:** Phase 2 + +**Done when:** `project_to_mdl` on a parsed mark2.mdl produces MDL with 2 named views (`*1 housing`, `*2 investments`), correct element dimensions, and correct font lines. Tests verify view structure. + + + +### Phase 4: Lookup Syntax and Equation Casing +**Goal:** Fix lookup call syntax and variable name casing in the MDL writer output. + +**Components:** +- Lookup call detection in `MdlPrintVisitor::walk()` in `src/simlin-engine/src/mdl/writer.rs` — when visiting a function call where name is `LOOKUP`, 2 args, first arg is a variable reference: emit as `first_arg ( second_arg )` instead of `LOOKUP(first_arg, second_arg)` +- Equation LHS casing — in `write_variable_entry` / `write_single_entry`, look up the view element with matching ident and use its `name` field (original casing) for the equation LHS. Fall back to canonical ident when no view element match exists. + +**Dependencies:** Phase 3 + +**Done when:** Roundtripped mark2.mdl output contains `federal funds rate lookup ( Time )` (not `LOOKUP(...)`) and `Endogenous Federal Funds Rate=` (not lowercased). Tests verify both patterns. + + + +### Phase 5: Equation Formatting +**Goal:** Match Vensim equation formatting conventions: inline short equations, backslash line continuations, deterministic ordering. + +**Components:** +- Inline formatting in `write_single_entry` — when the full `name = equation` line is under ~80 chars and contains no line breaks, emit on one line with spaces around `=`. Otherwise use current multiline format (no spaces around `=`). +- Line continuation — for multiline equations exceeding ~80 chars, wrap with `\\\n\t\t` at reasonable break points (after commas, before operators) +- Ungrouped variable ordering — sort ungrouped variables alphabetically by ident in `write_equations_section` for deterministic output + +**Dependencies:** Phase 4 + +**Done when:** Roundtripped mark2.mdl output has inline format for short equations (e.g. `average repayment rate = 0.03`), backslash continuations for long equations, and deterministic ordering. Tests verify formatting patterns. + + + +### Phase 6: MDL Format Test +**Goal:** Comprehensive integration test that validates MDL output format against the original mark2.mdl. + +**Components:** +- Register `mdl_roundtrip` test in `src/simlin-engine/Cargo.toml` — add `[[test]] name = "mdl_roundtrip"` entry +- New test function in `src/simlin-engine/tests/mdl_roundtrip.rs` that roundtrips mark2.mdl through `parse_mdl` -> `project_to_mdl` and asserts: + - View structure: exactly 2 views named `*1 housing` and `*2 investments` + - Per-view element matching: extract element lines from each view, compare as unordered sets against original (after CRLF/whitespace normalization). Every element line (arrows, valves, clouds, variables) must match field-by-field. Any normalization applied is documented with a comment explaining why. + - Font lines match original `Verdana|10` spec + - Equation section: lookup syntax, lookup bounds, variable casing, inline formatting spot-checks + +**Dependencies:** Phase 5 + +**Done when:** `cargo test -p simlin-engine --test mdl_roundtrip` passes with all assertions. The test validates that the roundtripped MDL output is structurally identical to the original (modulo documented normalizations). + + +## Additional Considerations + +**Protobuf backwards compatibility:** The datamodel extensions add new optional fields to protobuf messages. Since all new fields are optional with default values, existing serialized instances remain valid. No migration needed. + +**Web app impact:** None. The web app reads `views[0]` and ignores compat fields it doesn't know about. The merge behavior during parsing is unchanged — the writer-side split only affects MDL output. From 0afa26f65d7cba1a11f74dbe26a9f8724ddc929c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 15:34:41 -0700 Subject: [PATCH 07/31] engine: add ViewElementCompat and font fields for MDL roundtrip fidelity Infrastructure for preserving Vensim-specific view element metadata during MDL roundtripping. Adds ViewElementCompat (width, height, bits) to Aux, Stock, Flow, Cloud, and Alias view elements, and a font field to StockFlow. Flow gets both compat (valve dimensions) and label_compat (attached label dimensions). Updates protobuf schema, serde conversions, and all construction sites across the codebase (json, xmile, mdl, layout, diagram, stdlib). --- src/simlin-cli/src/gen_stdlib.rs | 8 +++ src/simlin-engine/src/datamodel.rs | 20 +++++++ src/simlin-engine/src/diagram/connector.rs | 2 + src/simlin-engine/src/diagram/elements.rs | 6 +++ src/simlin-engine/src/diagram/flow.rs | 4 ++ src/simlin-engine/src/diagram/render.rs | 6 +++ src/simlin-engine/src/diagram/render_png.rs | 6 +++ src/simlin-engine/src/json.rs | 7 +++ src/simlin-engine/src/layout/mod.rs | 18 +++++++ src/simlin-engine/src/layout/placement.rs | 4 ++ src/simlin-engine/src/mdl/view/convert.rs | 8 +++ src/simlin-engine/src/mdl/writer.rs | 25 +++++++++ src/simlin-engine/src/patch.rs | 1 + src/simlin-engine/src/project_io.gen.rs | 49 ++++++++++++----- src/simlin-engine/src/project_io.proto | 13 +++++ src/simlin-engine/src/serde.rs | 47 ++++++++++++++++ src/simlin-engine/src/stdlib.gen.rs | 60 +++++++++++++++++++++ src/simlin-engine/src/xmile/views.rs | 40 ++++++++++++++ 18 files changed, 311 insertions(+), 13 deletions(-) diff --git a/src/simlin-cli/src/gen_stdlib.rs b/src/simlin-cli/src/gen_stdlib.rs index 7661ce0f..617f6a33 100644 --- a/src/simlin-cli/src/gen_stdlib.rs +++ b/src/simlin-cli/src/gen_stdlib.rs @@ -316,6 +316,7 @@ fn gen_view_element(elem: &ViewElement) -> String { x: {}_f64, y: {}_f64, label_side: {}, + compat: None, }})", escape_string(&a.name), a.uid, @@ -330,6 +331,7 @@ fn gen_view_element(elem: &ViewElement) -> String { x: {}_f64, y: {}_f64, label_side: {}, + compat: None, }})", escape_string(&s.name), s.uid, @@ -347,6 +349,8 @@ fn gen_view_element(elem: &ViewElement) -> String { y: {}_f64, label_side: {}, points: vec![{}], + compat: None, + label_compat: None, }})", escape_string(&f.name), f.uid, @@ -391,6 +395,7 @@ fn gen_view_element(elem: &ViewElement) -> String { x: {}_f64, y: {}_f64, label_side: {}, + compat: None, }})", a.uid, a.alias_of_uid, @@ -404,6 +409,7 @@ fn gen_view_element(elem: &ViewElement) -> String { flow_uid: {}, x: {}_f64, y: {}_f64, + compat: None, }})", c.uid, c.flow_uid, c.x, c.y ), @@ -446,6 +452,7 @@ fn gen_stock_flow(sf: &StockFlow) -> String { view_box: {}, zoom: {}_f64, use_lettered_polarity: {}, + font: None, }}", name_str, elems.join(",\n "), @@ -681,6 +688,7 @@ fn npv() -> Model { view_box: Rect { x: 0_f64, y: 0_f64, width: 0_f64, height: 0_f64 }, zoom: 1_f64, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], diff --git a/src/simlin-engine/src/datamodel.rs b/src/simlin-engine/src/datamodel.rs index 101ef0b6..855d5329 100644 --- a/src/simlin-engine/src/datamodel.rs +++ b/src/simlin-engine/src/datamodel.rs @@ -464,6 +464,17 @@ pub mod view_element { Right, } + /// Vensim-specific layout metadata preserved during MDL roundtripping. + /// Stores original element dimensions and type bits so the writer can + /// reproduce the sketch section byte-for-byte. + #[cfg_attr(feature = "debug-derive", derive(Debug))] + #[derive(Clone, PartialEq)] + pub struct ViewElementCompat { + pub width: f64, + pub height: f64, + pub bits: u32, + } + #[cfg_attr(feature = "debug-derive", derive(Debug))] #[derive(Clone, PartialEq)] pub struct Aux { @@ -472,6 +483,7 @@ pub mod view_element { pub x: f64, pub y: f64, pub label_side: LabelSide, + pub compat: Option, } #[cfg_attr(feature = "debug-derive", derive(Debug))] @@ -482,6 +494,7 @@ pub mod view_element { pub x: f64, pub y: f64, pub label_side: LabelSide, + pub compat: Option, } #[cfg_attr(feature = "debug-derive", derive(Debug))] @@ -503,6 +516,8 @@ pub mod view_element { // pub segment_with_aux: i32, // pub aux_percentage_into_segment: f64, pub points: Vec, + pub compat: Option, + pub label_compat: Option, } #[cfg_attr(feature = "debug-derive", derive(Debug))] @@ -548,6 +563,7 @@ pub mod view_element { pub x: f64, pub y: f64, pub label_side: LabelSide, + pub compat: Option, } #[cfg_attr(feature = "debug-derive", derive(Debug))] @@ -557,6 +573,7 @@ pub mod view_element { pub flow_uid: i32, pub x: f64, pub y: f64, + pub compat: Option, } /// Visual container for grouping related model elements. @@ -638,6 +655,9 @@ pub struct StockFlow { /// letters (S/O) rather than symbols (+/-). Corresponds to xmutil's /// bLetterPolarity flag and XMILE's isee:use_lettered_polarity attribute. pub use_lettered_polarity: bool, + /// Vensim default font string (e.g. "Arial|12||0-0-0|0-0-0|-1--1--1|-1--1--1|96,96") + /// preserved during MDL roundtripping. + pub font: Option, } impl StockFlow { diff --git a/src/simlin-engine/src/diagram/connector.rs b/src/simlin-engine/src/diagram/connector.rs index 13b29de9..a59f5a0e 100644 --- a/src/simlin-engine/src/diagram/connector.rs +++ b/src/simlin-engine/src/diagram/connector.rs @@ -469,6 +469,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, }) } @@ -479,6 +480,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, }) } diff --git a/src/simlin-engine/src/diagram/elements.rs b/src/simlin-engine/src/diagram/elements.rs index 58982930..435663ae 100644 --- a/src/simlin-engine/src/diagram/elements.rs +++ b/src/simlin-engine/src/diagram/elements.rs @@ -292,6 +292,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, } } @@ -302,6 +303,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, } } @@ -380,6 +382,7 @@ mod tests { flow_uid: 5, x: 100.0, y: 200.0, + compat: None, }; let svg = render_cloud(&element); assert!(svg.contains("simlin-cloud")); @@ -394,6 +397,7 @@ mod tests { flow_uid: 5, x: 100.0, y: 200.0, + compat: None, }; let bounds = cloud_bounds(&element); assert_eq!(bounds.left, 100.0 - CLOUD_RADIUS); @@ -408,6 +412,7 @@ mod tests { x: 100.0, y: 200.0, label_side: LabelSide::Bottom, + compat: None, }; let svg = render_alias(&element, Some("population")); assert!(svg.contains("simlin-alias")); @@ -422,6 +427,7 @@ mod tests { x: 100.0, y: 200.0, label_side: LabelSide::Bottom, + compat: None, }; let svg = render_alias(&element, None); assert!(svg.contains("unknown alias")); diff --git a/src/simlin-engine/src/diagram/flow.rs b/src/simlin-engine/src/diagram/flow.rs index a6a93218..91e91155 100644 --- a/src/simlin-engine/src/diagram/flow.rs +++ b/src/simlin-engine/src/diagram/flow.rs @@ -195,6 +195,8 @@ mod tests { attached_to_uid: attached, }) .collect(), + compat: None, + label_compat: None, } } @@ -204,6 +206,7 @@ mod tests { flow_uid: 10, x, y, + compat: None, }) } @@ -214,6 +217,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, }) } diff --git a/src/simlin-engine/src/diagram/render.rs b/src/simlin-engine/src/diagram/render.rs index 43973842..68fb887c 100644 --- a/src/simlin-engine/src/diagram/render.rs +++ b/src/simlin-engine/src/diagram/render.rs @@ -396,6 +396,7 @@ mod tests { view_box: datamodel::Rect::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -412,6 +413,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, }) } @@ -422,6 +424,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, }) } @@ -431,6 +434,7 @@ mod tests { flow_uid, x, y, + compat: None, }) } @@ -455,6 +459,8 @@ mod tests { attached_to_uid: attached, }) .collect(), + compat: None, + label_compat: None, }) } diff --git a/src/simlin-engine/src/diagram/render_png.rs b/src/simlin-engine/src/diagram/render_png.rs index 1b49e740..a3d02d5a 100644 --- a/src/simlin-engine/src/diagram/render_png.rs +++ b/src/simlin-engine/src/diagram/render_png.rs @@ -126,6 +126,7 @@ mod tests { view_box: datamodel::Rect::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -142,6 +143,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, }) } @@ -152,6 +154,7 @@ mod tests { x, y, label_side: LabelSide::Bottom, + compat: None, }) } @@ -161,6 +164,7 @@ mod tests { flow_uid, x, y, + compat: None, }) } @@ -185,6 +189,8 @@ mod tests { attached_to_uid: attached, }) .collect(), + compat: None, + label_compat: None, }) } diff --git a/src/simlin-engine/src/json.rs b/src/simlin-engine/src/json.rs index 8ab43c6b..d0d3a850 100644 --- a/src/simlin-engine/src/json.rs +++ b/src/simlin-engine/src/json.rs @@ -1043,6 +1043,7 @@ impl From for datamodel::ViewElement { x: s.x, y: s.y, label_side: label_side_from_string(&s.label_side), + compat: None, }) } ViewElement::Flow(f) => datamodel::ViewElement::Flow(datamodel::view_element::Flow { @@ -1064,6 +1065,8 @@ impl From for datamodel::ViewElement { }, }) .collect(), + compat: None, + label_compat: None, }), ViewElement::Auxiliary(a) => { datamodel::ViewElement::Aux(datamodel::view_element::Aux { @@ -1072,6 +1075,7 @@ impl From for datamodel::ViewElement { x: a.x, y: a.y, label_side: label_side_from_string(&a.label_side), + compat: None, }) } ViewElement::Cloud(c) => { @@ -1080,6 +1084,7 @@ impl From for datamodel::ViewElement { flow_uid: c.flow_uid, x: c.x, y: c.y, + compat: None, }) } ViewElement::Link(l) => datamodel::ViewElement::Link(datamodel::view_element::Link { @@ -1124,6 +1129,7 @@ impl From for datamodel::ViewElement { x: a.x, y: a.y, label_side: label_side_from_string(&a.label_side), + compat: None, }) } ViewElement::Group(g) => { @@ -1167,6 +1173,7 @@ impl From for datamodel::View { .unwrap_or_default(), zoom: if view.zoom == 0.0 { 1.0 } else { view.zoom }, use_lettered_polarity: view.use_lettered_polarity, + font: None, }) } } diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index 2959078c..11c0b1aa 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -166,6 +166,7 @@ impl<'a> LayoutEngine<'a> { }, zoom: 1.0, use_lettered_polarity: false, + font: None, }); } @@ -224,6 +225,7 @@ impl<'a> LayoutEngine<'a> { view_box, zoom: 1.0, use_lettered_polarity: false, + font: None, }) } @@ -447,6 +449,7 @@ impl<'a> LayoutEngine<'a> { x: pos.x, y: pos.y, label_side: LabelSide::Bottom, + compat: None, }); self.elements.push(elem); self.positions.insert(uid, pos); @@ -579,6 +582,8 @@ impl<'a> LayoutEngine<'a> { y: pos.y, label_side, points: flow_points, + compat: None, + label_compat: None, }; // Update bounds for flow points @@ -620,6 +625,7 @@ impl<'a> LayoutEngine<'a> { flow_uid: flow_elem.uid, x: cx, y: cy, + compat: None, }); self.elements.push(cloud); flow_elem.points[0].attached_to_uid = Some(cloud_uid); @@ -642,6 +648,7 @@ impl<'a> LayoutEngine<'a> { flow_uid: flow_elem.uid, x: cx, y: cy, + compat: None, }); self.elements.push(cloud); flow_elem.points[last_idx].attached_to_uid = Some(cloud_uid); @@ -1258,6 +1265,7 @@ impl<'a> LayoutEngine<'a> { x: pos.x, y: pos.y, label_side: LabelSide::Bottom, + compat: None, }); self.elements.push(elem); self.positions.insert(uid, pos); @@ -3148,6 +3156,7 @@ mod tests { }, zoom: 1.0, use_lettered_polarity: false, + font: None, }, crossings: 5, seed: 42, @@ -3164,6 +3173,7 @@ mod tests { }, zoom: 1.0, use_lettered_polarity: false, + font: None, }, crossings: 2, seed: 123, @@ -3186,6 +3196,7 @@ mod tests { x: 0.0, y: 0.0, label_side: LabelSide::Bottom, + compat: None, })], view_box: Rect { x: 0.0, @@ -3195,6 +3206,7 @@ mod tests { }, zoom: 1.0, use_lettered_polarity: false, + font: None, }, crossings: 3, seed: 123, @@ -3208,6 +3220,7 @@ mod tests { x: 0.0, y: 0.0, label_side: LabelSide::Bottom, + compat: None, })], view_box: Rect { x: 0.0, @@ -3217,6 +3230,7 @@ mod tests { }, zoom: 1.0, use_lettered_polarity: false, + font: None, }, crossings: 3, seed: 42, @@ -3734,6 +3748,7 @@ mod tests { x: 0.0, y: 0.0, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Aux(view_element::Aux { name: "b".to_string(), @@ -3741,6 +3756,7 @@ mod tests { x: 10.0, y: 10.0, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Aux(view_element::Aux { name: "c".to_string(), @@ -3748,6 +3764,7 @@ mod tests { x: 10.0, y: -10.0, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Link(view_element::Link { uid: 4, @@ -3772,6 +3789,7 @@ mod tests { }, zoom: 1.0, use_lettered_polarity: false, + font: None, }; assert_eq!(count_view_crossings(&view), 0); diff --git a/src/simlin-engine/src/layout/placement.rs b/src/simlin-engine/src/layout/placement.rs index 985090c7..a679da48 100644 --- a/src/simlin-engine/src/layout/placement.rs +++ b/src/simlin-engine/src/layout/placement.rs @@ -494,6 +494,7 @@ mod tests { x: 10.0, y: 20.0, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Stock(view_element::Stock { name: "s".to_string(), @@ -501,6 +502,7 @@ mod tests { x: 30.0, y: 40.0, label_side: LabelSide::Bottom, + compat: None, }), ]; @@ -543,6 +545,8 @@ mod tests { attached_to_uid: None, }, ], + compat: None, + label_compat: None, })]; normalize_coordinates(&mut elements, 50.0); diff --git a/src/simlin-engine/src/mdl/view/convert.rs b/src/simlin-engine/src/mdl/view/convert.rs index f6b6f3be..3625b860 100644 --- a/src/simlin-engine/src/mdl/view/convert.rs +++ b/src/simlin-engine/src/mdl/view/convert.rs @@ -265,6 +265,7 @@ fn merge_views(views: Vec) -> Vec { view_box: Default::default(), zoom: 1.0, use_lettered_polarity, + font: None, }); vec![merged] @@ -367,6 +368,7 @@ fn convert_view( view_box: Default::default(), zoom: 1.0, use_lettered_polarity, + font: None, })) } @@ -460,6 +462,7 @@ fn convert_variable( x: alias_x, y: alias_y, label_side: view_element::LabelSide::Bottom, + compat: None, })); } } @@ -473,6 +476,7 @@ fn convert_variable( x: var.x as f64, y: var.y as f64, label_side: view_element::LabelSide::Top, // Stocks default to top + compat: None, })), VariableType::Flow => { // For flows, find the associated valve and compute flow points @@ -486,6 +490,8 @@ fn convert_variable( y: flow_y as f64, label_side: view_element::LabelSide::Bottom, points, + compat: None, + label_compat: None, })) } VariableType::Aux => Some(ViewElement::Aux(view_element::Aux { @@ -494,6 +500,7 @@ fn convert_variable( x: var.x as f64, y: var.y as f64, label_side: view_element::LabelSide::Bottom, + compat: None, })), } } @@ -561,6 +568,7 @@ fn convert_comment_as_cloud(comment: &VensimComment, uid: i32, flow_uid: i32) -> flow_uid, x: comment.x as f64, y: comment.y as f64, + compat: None, }) } diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index ad89bf70..30d8dd1a 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -3294,6 +3294,7 @@ mod tests { x: 100.0, y: 200.0, label_side: view_element::LabelSide::Bottom, + compat: None, }; let mut buf = String::new(); write_aux_element(&mut buf, &aux); @@ -3308,6 +3309,7 @@ mod tests { x: 300.0, y: 150.0, label_side: view_element::LabelSide::Top, + compat: None, }; let mut buf = String::new(); write_stock_element(&mut buf, &stock); @@ -3323,6 +3325,8 @@ mod tests { y: 191.0, label_side: view_element::LabelSide::Bottom, points: vec![], + compat: None, + label_compat: None, }; let mut buf = String::new(); let valve_uids = HashMap::from([(6, 100)]); @@ -3359,6 +3363,8 @@ mod tests { attached_to_uid: Some(2), }, ], + compat: None, + label_compat: None, }; let mut buf = String::new(); let valve_uids = HashMap::from([(6, 100)]); @@ -3401,6 +3407,7 @@ mod tests { x: 100.0, y: 100.0, label_side: view_element::LabelSide::Bottom, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "Birth_Rate".to_string(), @@ -3409,6 +3416,8 @@ mod tests { y: 100.0, label_side: view_element::LabelSide::Bottom, points: vec![], + compat: None, + label_compat: None, }), ]; @@ -3426,6 +3435,7 @@ mod tests { flow_uid: 6, x: 479.0, y: 235.0, + compat: None, }; let mut buf = String::new(); write_cloud_element(&mut buf, &cloud); @@ -3440,6 +3450,7 @@ mod tests { x: 200.0, y: 300.0, label_side: view_element::LabelSide::Bottom, + compat: None, }; let mut name_map = HashMap::new(); name_map.insert(1, "Growth_Rate"); @@ -3570,6 +3581,7 @@ mod tests { x: 100.0, y: 100.0, label_side: view_element::LabelSide::Top, + compat: None, }), ViewElement::Aux(view_element::Aux { name: "Growth_Rate".to_string(), @@ -3577,6 +3589,7 @@ mod tests { x: 200.0, y: 200.0, label_side: view_element::LabelSide::Bottom, + compat: None, }), ViewElement::Link(view_element::Link { uid: 3, @@ -3592,6 +3605,7 @@ mod tests { view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }; let views = vec![View::StockFlow(sf)]; @@ -3637,6 +3651,7 @@ mod tests { x: 100.0, y: 100.0, label_side: view_element::LabelSide::Bottom, + compat: None, })]; let model = datamodel::Model { name: "default".to_owned(), @@ -3648,6 +3663,7 @@ mod tests { view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -3818,10 +3834,12 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 x: 100.0, y: 100.0, label_side: view_element::LabelSide::Bottom, + compat: None, })], view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -3891,6 +3909,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 x: 100.0, y: 100.0, label_side: view_element::LabelSide::Bottom, + compat: None, }), ViewElement::Stock(view_element::Stock { name: "Stock_B".to_owned(), @@ -3898,6 +3917,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 x: 300.0, y: 100.0, label_side: view_element::LabelSide::Bottom, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "Flow_AB".to_owned(), @@ -3917,11 +3937,14 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 attached_to_uid: Some(2), }, ], + compat: None, + label_compat: None, }), ], view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -4185,6 +4208,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 x: 100.0, y: 100.0, label_side: view_element::LabelSide::Bottom, + compat: None, })]; let model = datamodel::Model { name: "default".to_owned(), @@ -4196,6 +4220,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], diff --git a/src/simlin-engine/src/patch.rs b/src/simlin-engine/src/patch.rs index 05a8b67d..c2d73e23 100644 --- a/src/simlin-engine/src/patch.rs +++ b/src/simlin-engine/src/patch.rs @@ -1222,6 +1222,7 @@ mod tests { view_box: datamodel::Rect::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }); let patch = ProjectPatch { project_ops: vec![], diff --git a/src/simlin-engine/src/project_io.gen.rs b/src/simlin-engine/src/project_io.gen.rs index 4c928e17..a3edbf7e 100644 --- a/src/simlin-engine/src/project_io.gen.rs +++ b/src/simlin-engine/src/project_io.gen.rs @@ -1,7 +1,7 @@ // @generated by prost-build from project_io.proto // DO NOT EDIT - regenerate with: pnpm build:gen-protobufs // -// Proto file SHA256: 6f11401bd57a4338f35aab95b7d4a1d12c95c70187bca585f16169755e5202d3 +// Proto file SHA256: cb756c694e6971777ed61c0c16140ac6f8918a7e1b80bfaf73d4b12fddd2c4f2 // prost-build version: 0.14 // This file is @generated by prost-build. @@ -151,7 +151,7 @@ pub mod variable { /// String value of the enum field names used in the ProtoBuf definition. /// /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programeli use. + /// (if the ProtoBuf definition does not change) and safe for programmatic use. pub fn as_str_name(&self) -> &'static str { match self { Self::Data => "DATA", @@ -331,6 +331,15 @@ pub struct ViewElement { } /// Nested message and enum types in `ViewElement`. pub mod view_element { + #[derive(Clone, Copy, PartialEq, ::prost::Message)] + pub struct ViewElementCompat { + #[prost(double, optional, tag = "1")] + pub width: ::core::option::Option, + #[prost(double, optional, tag = "2")] + pub height: ::core::option::Option, + #[prost(uint32, optional, tag = "3")] + pub bits: ::core::option::Option, + } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Aux { #[prost(string, tag = "1")] @@ -343,6 +352,8 @@ pub mod view_element { pub y: f64, #[prost(enumeration = "LabelSide", tag = "5")] pub label_side: i32, + #[prost(message, optional, tag = "6")] + pub compat: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Stock { @@ -356,6 +367,8 @@ pub mod view_element { pub y: f64, #[prost(enumeration = "LabelSide", tag = "5")] pub label_side: i32, + #[prost(message, optional, tag = "6")] + pub compat: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct FlowPoint { @@ -382,6 +395,10 @@ pub mod view_element { /// double auxPercentageIntoSegment = 4; #[prost(message, repeated, tag = "6")] pub points: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag = "7")] + pub compat: ::core::option::Option, + #[prost(message, optional, tag = "8")] + pub label_compat: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Link { @@ -470,6 +487,8 @@ pub mod view_element { pub y: f64, #[prost(enumeration = "LabelSide", tag = "5")] pub label_side: i32, + #[prost(message, optional, tag = "6")] + pub compat: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct Cloud { @@ -481,6 +500,8 @@ pub mod view_element { pub x: f64, #[prost(double, tag = "4")] pub y: f64, + #[prost(message, optional, tag = "5")] + pub compat: ::core::option::Option, } /// Visual container for grouping related model elements. /// x/y are center coordinates (matching the internal convention). @@ -582,6 +603,8 @@ pub struct View { pub name: ::prost::alloc::string::String, #[prost(bool, tag = "8")] pub has_name: bool, + #[prost(string, optional, tag = "9")] + pub font: ::core::option::Option<::prost::alloc::string::String>, } /// Nested message and enum types in `View`. pub mod view { @@ -676,7 +699,7 @@ pub struct SimSpecs { #[prost(string, optional, tag = "6")] pub time_units: ::core::option::Option<::prost::alloc::string::String>, } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct Dimension { #[prost(string, tag = "1")] pub name: ::prost::alloc::string::String, @@ -702,6 +725,16 @@ pub struct Dimension { /// Nested message and enum types in `Dimension`. pub mod dimension { #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct DimensionElements { + #[prost(string, repeated, tag = "1")] + pub elements: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + } + #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] + pub struct DimensionSize { + #[prost(uint32, tag = "1")] + pub size: u32, + } + #[derive(Clone, PartialEq, ::prost::Message)] pub struct DimensionMapping { #[prost(string, tag = "1")] pub target: ::prost::alloc::string::String, @@ -718,16 +751,6 @@ pub mod dimension { pub to_element: ::prost::alloc::string::String, } } - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] - pub struct DimensionElements { - #[prost(string, repeated, tag = "1")] - pub elements: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - } - #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] - pub struct DimensionSize { - #[prost(uint32, tag = "1")] - pub size: u32, - } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] pub enum Dimension { #[prost(message, tag = "3")] diff --git a/src/simlin-engine/src/project_io.proto b/src/simlin-engine/src/project_io.proto index 32cec58b..937d7483 100644 --- a/src/simlin-engine/src/project_io.proto +++ b/src/simlin-engine/src/project_io.proto @@ -158,12 +158,19 @@ message ViewElement { RIGHT = 4; }; + message ViewElementCompat { + optional double width = 1; + optional double height = 2; + optional uint32 bits = 3; + } + message Aux { string name = 1; int32 uid = 2; double x = 3; double y = 4; LabelSide label_side = 5; + ViewElementCompat compat = 6; }; message Stock { @@ -172,6 +179,7 @@ message ViewElement { double x = 3; double y = 4; LabelSide label_side = 5; + ViewElementCompat compat = 6; }; message FlowPoint { @@ -189,6 +197,8 @@ message ViewElement { // int32 segmentWithAux = 3; // double auxPercentageIntoSegment = 4; repeated FlowPoint points = 6; + ViewElementCompat compat = 7; + ViewElementCompat label_compat = 8; }; message Link { @@ -225,6 +235,7 @@ message ViewElement { double x = 3; double y = 4; LabelSide label_side = 5; + ViewElementCompat compat = 6; }; message Cloud { @@ -232,6 +243,7 @@ message ViewElement { int32 flow_uid = 2; double x = 3; double y = 4; + ViewElementCompat compat = 5; }; // Visual container for grouping related model elements. @@ -280,6 +292,7 @@ message View { bool use_lettered_polarity = 6; string name = 7; bool has_name = 8; + optional string font = 9; }; message LoopMetadata { diff --git a/src/simlin-engine/src/serde.rs b/src/simlin-engine/src/serde.rs index d2cca8d2..ee303a48 100644 --- a/src/simlin-engine/src/serde.rs +++ b/src/simlin-engine/src/serde.rs @@ -1182,6 +1182,28 @@ fn test_label_side_roundtrip() { ); } +fn view_compat_to_proto( + compat: &Option, +) -> Option { + compat + .as_ref() + .map(|c| project_io::view_element::ViewElementCompat { + width: Some(c.width), + height: Some(c.height), + bits: Some(c.bits), + }) +} + +fn view_compat_from_proto( + compat: Option, +) -> Option { + compat.map(|c| view_element::ViewElementCompat { + width: c.width.unwrap_or(0.0), + height: c.height.unwrap_or(0.0), + bits: c.bits.unwrap_or(0), + }) +} + impl From for view_element::Aux { fn from(v: project_io::view_element::Aux) -> Self { view_element::Aux { @@ -1192,6 +1214,7 @@ impl From for view_element::Aux { label_side: view_element::LabelSide::from( project_io::view_element::LabelSide::try_from(v.label_side).unwrap_or_default(), ), + compat: view_compat_from_proto(v.compat), } } } @@ -1204,6 +1227,7 @@ impl From for project_io::view_element::Aux { x: v.x, y: v.y, label_side: project_io::view_element::LabelSide::from(v.label_side) as i32, + compat: view_compat_to_proto(&v.compat), } } } @@ -1216,6 +1240,7 @@ fn test_view_element_aux_roundtrip() { x: 2.0, y: 3.0, label_side: view_element::LabelSide::Top, + compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -1234,6 +1259,7 @@ impl From for view_element::Stock { label_side: view_element::LabelSide::from( project_io::view_element::LabelSide::try_from(v.label_side).unwrap_or_default(), ), + compat: view_compat_from_proto(v.compat), } } } @@ -1246,6 +1272,7 @@ impl From for project_io::view_element::Stock { x: v.x, y: v.y, label_side: project_io::view_element::LabelSide::from(v.label_side) as i32, + compat: view_compat_to_proto(&v.compat), } } } @@ -1258,6 +1285,7 @@ fn test_view_element_stock_roundtrip() { x: 2.0, y: 3.0, label_side: view_element::LabelSide::Top, + compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -1366,6 +1394,8 @@ impl From for view_element::Flow { .into_iter() .map(view_element::FlowPoint::from) .collect(), + compat: view_compat_from_proto(v.compat), + label_compat: view_compat_from_proto(v.label_compat), } } } @@ -1383,6 +1413,8 @@ impl From for project_io::view_element::Flow { .into_iter() .map(project_io::view_element::FlowPoint::from) .collect(), + compat: view_compat_to_proto(&v.compat), + label_compat: view_compat_to_proto(&v.label_compat), } } } @@ -1407,6 +1439,8 @@ fn test_view_element_flow_roundtrip() { attached_to_uid: None, }, ], + compat: None, + label_compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -1591,6 +1625,7 @@ impl From for view_element::Alias { label_side: view_element::LabelSide::from( project_io::view_element::LabelSide::try_from(v.label_side).unwrap_or_default(), ), + compat: view_compat_from_proto(v.compat), } } } @@ -1603,6 +1638,7 @@ impl From for project_io::view_element::Alias { x: v.x, y: v.y, label_side: project_io::view_element::LabelSide::from(v.label_side) as i32, + compat: view_compat_to_proto(&v.compat), } } } @@ -1615,6 +1651,7 @@ fn test_view_element_alias_roundtrip() { x: 2.0, y: 3.0, label_side: view_element::LabelSide::Top, + compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -1631,6 +1668,7 @@ impl From for view_element::Cloud { flow_uid: v.flow_uid, x: v.x, y: v.y, + compat: view_compat_from_proto(v.compat), } } } @@ -1642,6 +1680,7 @@ impl From for project_io::view_element::Cloud { flow_uid: v.flow_uid, x: v.x, y: v.y, + compat: view_compat_to_proto(&v.compat), } } } @@ -1679,6 +1718,7 @@ fn test_view_element_cloud_roundtrip() { flow_uid: 124, x: 2.0, y: 3.0, + compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -1760,6 +1800,7 @@ fn test_view_element_roundtrip() { flow_uid: 124, x: 2.0, y: 3.0, + compat: None, }), ViewElement::Group(view_element::Group { uid: 200, @@ -1798,6 +1839,7 @@ impl From for project_io::View { use_lettered_polarity: view.use_lettered_polarity, name, has_name, + font: view.font, } } } @@ -1813,6 +1855,7 @@ impl From for View { use_lettered_polarity, name, has_name, + font, .. } = view; @@ -1830,6 +1873,7 @@ impl From for View { zoom }, use_lettered_polarity, + font, }) } } @@ -1842,6 +1886,7 @@ fn test_view_roundtrip_preserves_explicit_empty_title() { view_box: Rect::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }); let roundtrip = View::from(project_io::View::from(view)); @@ -1861,6 +1906,7 @@ fn test_view_roundtrip_preserves_absent_title() { view_box: Rect::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }); let roundtrip = View::from(project_io::View::from(view)); @@ -1878,6 +1924,7 @@ fn test_view_deserialize_keeps_nonempty_name_without_presence_flag() { use_lettered_polarity: false, name: "Overview".to_string(), has_name: false, + font: None, }; let view = View::from(proto); diff --git a/src/simlin-engine/src/stdlib.gen.rs b/src/simlin-engine/src/stdlib.gen.rs index 5125b0d6..58f378f1 100644 --- a/src/simlin-engine/src/stdlib.gen.rs +++ b/src/simlin-engine/src/stdlib.gen.rs @@ -104,6 +104,7 @@ fn delay1() -> Model { x: 232_f64, y: 168_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "input".to_string(), @@ -123,6 +124,8 @@ fn delay1() -> Model { attached_to_uid: Some(1), }, ], + compat: None, + label_compat: None, }), ViewElement::Flow(view_element::Flow { name: "output".to_string(), @@ -142,6 +145,8 @@ fn delay1() -> Model { attached_to_uid: Some(9), }, ], + compat: None, + label_compat: None, }), ViewElement::Aux(view_element::Aux { name: "delay time".to_string(), @@ -149,6 +154,7 @@ fn delay1() -> Model { x: 337.5_f64, y: 233_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Link(view_element::Link { uid: 5, @@ -170,18 +176,21 @@ fn delay1() -> Model { x: 221_f64, y: 41_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Cloud(view_element::Cloud { uid: 8, flow_uid: 2, x: 130_f64, y: 170_f64, + compat: None, }), ViewElement::Cloud(view_element::Cloud { uid: 9, flow_uid: 3, x: 353_f64, y: 168_f64, + compat: None, }), ], view_box: Rect { @@ -192,6 +201,7 @@ fn delay1() -> Model { }, zoom: 1_f64, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -305,6 +315,7 @@ fn delay3() -> Model { x: 232_f64, y: 168_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "input".to_string(), @@ -313,6 +324,8 @@ fn delay3() -> Model { y: 170_f64, label_side: LabelSide::Bottom, points: vec![FlowPoint { x: 130_f64, y: 170_f64, attached_to_uid: Some(16) }, FlowPoint { x: 209.5_f64, y: 170_f64, attached_to_uid: Some(1) }], + compat: None, + label_compat: None, }), ViewElement::Flow(view_element::Flow { name: "output".to_string(), @@ -321,6 +334,8 @@ fn delay3() -> Model { y: 171_f64, label_side: LabelSide::Bottom, points: vec![FlowPoint { x: 520.5_f64, y: 171_f64, attached_to_uid: Some(9) }, FlowPoint { x: 620_f64, y: 171_f64, attached_to_uid: Some(17) }], + compat: None, + label_compat: None, }), ViewElement::Aux(view_element::Aux { name: "delay time".to_string(), @@ -328,6 +343,7 @@ fn delay3() -> Model { x: 426.5_f64, y: 300_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Link(view_element::Link { uid: 5, @@ -342,6 +358,7 @@ fn delay3() -> Model { x: 221_f64, y: 41_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "Flow 1".to_string(), @@ -350,6 +367,8 @@ fn delay3() -> Model { y: 169_f64, label_side: LabelSide::Bottom, points: vec![FlowPoint { x: 254.5_f64, y: 169_f64, attached_to_uid: Some(1) }, FlowPoint { x: 345.5_f64, y: 169_f64, attached_to_uid: Some(8) }], + compat: None, + label_compat: None, }), ViewElement::Stock(view_element::Stock { name: "Stock 2".to_string(), @@ -357,6 +376,7 @@ fn delay3() -> Model { x: 368_f64, y: 170_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Stock(view_element::Stock { name: "Stock 3".to_string(), @@ -364,6 +384,7 @@ fn delay3() -> Model { x: 498_f64, y: 171_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "Flow 2".to_string(), @@ -372,6 +393,8 @@ fn delay3() -> Model { y: 170_f64, label_side: LabelSide::Bottom, points: vec![FlowPoint { x: 390.5_f64, y: 170_f64, attached_to_uid: Some(8) }, FlowPoint { x: 475.5_f64, y: 170_f64, attached_to_uid: Some(9) }], + compat: None, + label_compat: None, }), ViewElement::Link(view_element::Link { uid: 11, @@ -413,16 +436,19 @@ fn delay3() -> Model { flow_uid: 2, x: 130_f64, y: 170_f64, + compat: None, }), ViewElement::Cloud(view_element::Cloud { uid: 17, flow_uid: 3, x: 620_f64, y: 171_f64, + compat: None, })], view_box: Rect { x: 0_f64, y: 0_f64, width: 0_f64, height: 0_f64 }, zoom: 1_f64, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -516,6 +542,8 @@ fn smth1() -> Model { attached_to_uid: Some(3), }, ], + compat: None, + label_compat: None, }), ViewElement::Stock(view_element::Stock { name: "Output".to_string(), @@ -523,6 +551,7 @@ fn smth1() -> Model { x: 242.083_f64, y: 165.333_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Aux(view_element::Aux { name: "delay time".to_string(), @@ -530,6 +559,7 @@ fn smth1() -> Model { x: 118_f64, y: 274.833_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Aux(view_element::Aux { name: "input".to_string(), @@ -537,6 +567,7 @@ fn smth1() -> Model { x: 106_f64, y: 117_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Link(view_element::Link { uid: 6, @@ -558,12 +589,14 @@ fn smth1() -> Model { x: 242.083_f64, y: 54_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Cloud(view_element::Cloud { uid: 9, flow_uid: 2, x: 103.833_f64, y: 165.333_f64, + compat: None, }), ], view_box: Rect { @@ -574,6 +607,7 @@ fn smth1() -> Model { }, zoom: 1_f64, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -741,6 +775,8 @@ fn smth3() -> Model { attached_to_uid: Some(7), }, ], + compat: None, + label_compat: None, }), ViewElement::Stock(view_element::Stock { name: "Stock 1".to_string(), @@ -748,6 +784,7 @@ fn smth3() -> Model { x: 242.083_f64, y: 165.333_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Stock(view_element::Stock { name: "Stock 2".to_string(), @@ -755,6 +792,7 @@ fn smth3() -> Model { x: 306.833_f64, y: 248.333_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "flow 2".to_string(), @@ -774,6 +812,8 @@ fn smth3() -> Model { attached_to_uid: Some(8), }, ], + compat: None, + label_compat: None, }), ViewElement::Stock(view_element::Stock { name: "Output".to_string(), @@ -781,6 +821,7 @@ fn smth3() -> Model { x: 391.833_f64, y: 317.333_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "flow 3".to_string(), @@ -800,6 +841,8 @@ fn smth3() -> Model { attached_to_uid: Some(10), }, ], + compat: None, + label_compat: None, }), ViewElement::Aux(view_element::Aux { name: "delay time".to_string(), @@ -807,6 +850,7 @@ fn smth3() -> Model { x: 118_f64, y: 274.833_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Aux(view_element::Aux { name: "input".to_string(), @@ -814,6 +858,7 @@ fn smth3() -> Model { x: 106_f64, y: 117_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Link(view_element::Link { uid: 14, @@ -849,24 +894,28 @@ fn smth3() -> Model { x: 242.083_f64, y: 54_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Cloud(view_element::Cloud { uid: 19, flow_uid: 6, x: 103.833_f64, y: 165.333_f64, + compat: None, }), ViewElement::Cloud(view_element::Cloud { uid: 20, flow_uid: 9, x: 187.833_f64, y: 248.333_f64, + compat: None, }), ViewElement::Cloud(view_element::Cloud { uid: 21, flow_uid: 11, x: 232.333_f64, y: 320.333_f64, + compat: None, }), ], view_box: Rect { @@ -877,6 +926,7 @@ fn smth3() -> Model { }, zoom: 1_f64, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -972,6 +1022,7 @@ fn trend() -> Model { x: 268.33299999999997_f64, y: 244.333_f64, label_side: LabelSide::Top, + compat: None, }), ViewElement::Flow(view_element::Flow { name: "flow".to_string(), @@ -980,6 +1031,8 @@ fn trend() -> Model { y: 244.333_f64, label_side: LabelSide::Bottom, points: vec![FlowPoint { x: 134.333_f64, y: 244.333_f64, attached_to_uid: Some(13) }, FlowPoint { x: 245.83299999999997_f64, y: 244.333_f64, attached_to_uid: Some(3) }], + compat: None, + label_compat: None, }), ViewElement::Aux(view_element::Aux { name: "output".to_string(), @@ -987,6 +1040,7 @@ fn trend() -> Model { x: 347.333_f64, y: 181.333_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Aux(view_element::Aux { name: "delay time".to_string(), @@ -994,6 +1048,7 @@ fn trend() -> Model { x: 255_f64, y: 176.333_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Link(view_element::Link { uid: 7, @@ -1015,6 +1070,7 @@ fn trend() -> Model { x: 197_f64, y: 118_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Link(view_element::Link { uid: 10, @@ -1036,16 +1092,19 @@ fn trend() -> Model { x: 268.333_f64, y: 92_f64, label_side: LabelSide::Bottom, + compat: None, }), ViewElement::Cloud(view_element::Cloud { uid: 13, flow_uid: 4, x: 134.333_f64, y: 244.333_f64, + compat: None, })], view_box: Rect { x: 0_f64, y: 0_f64, width: 0_f64, height: 0_f64 }, zoom: 1_f64, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], @@ -1142,6 +1201,7 @@ fn npv() -> Model { view_box: Rect { x: 0_f64, y: 0_f64, width: 0_f64, height: 0_f64 }, zoom: 1_f64, use_lettered_polarity: false, + font: None, })], loop_metadata: vec![], groups: vec![], diff --git a/src/simlin-engine/src/xmile/views.rs b/src/simlin-engine/src/xmile/views.rs index fb86487f..aa7d98e6 100644 --- a/src/simlin-engine/src/xmile/views.rs +++ b/src/simlin-engine/src/xmile/views.rs @@ -272,6 +272,7 @@ pub mod view_element { label_side: datamodel::view_element::LabelSide::from( v.label_side.unwrap_or(LabelSide::Bottom), ), + compat: None, } } } @@ -299,6 +300,7 @@ pub mod view_element { x: 72.0, y: 28.0, label_side: datamodel::view_element::LabelSide::Top, + compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -380,6 +382,7 @@ pub mod view_element { label_side: datamodel::view_element::LabelSide::from( v.label_side.unwrap_or(LabelSide::Top), ), + compat: None, } } } @@ -407,6 +410,7 @@ pub mod view_element { x: 73.0, y: 29.0, label_side: datamodel::view_element::LabelSide::Center, + compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -577,6 +581,8 @@ pub mod view_element { v.label_side.unwrap_or(LabelSide::Bottom), ), points, + compat: None, + label_compat: None, } } } @@ -619,6 +625,8 @@ pub mod view_element { attached_to_uid: Some(666), }, ], + compat: None, + label_compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -644,6 +652,8 @@ pub mod view_element { attached_to_uid: None, }, ], + compat: None, + label_compat: None, }; let expected_v = datamodel::view_element::Flow { name: "from_vensim_v".to_string(), @@ -663,6 +673,8 @@ pub mod view_element { attached_to_uid: None, }, ], + compat: None, + label_compat: None, }; let actual_v = datamodel::view_element::Flow::from(Flow::from(input_v)); assert_eq!(expected_v, actual_v); @@ -685,6 +697,8 @@ pub mod view_element { attached_to_uid: None, }, ], + compat: None, + label_compat: None, }; let expected_h = datamodel::view_element::Flow { name: "from_vensim_h".to_string(), @@ -704,6 +718,8 @@ pub mod view_element { attached_to_uid: None, }, ], + compat: None, + label_compat: None, }; let actual_h = datamodel::view_element::Flow::from(Flow::from(input_h)); assert_eq!(expected_h, actual_h); @@ -1032,6 +1048,7 @@ pub mod view_element { view_box: Default::default(), zoom: 0.0, use_lettered_polarity: false, + font: None, }; for expected in cases { let expected = expected.clone(); @@ -1053,6 +1070,7 @@ pub mod view_element { x: 0.0, y: 0.0, label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), datamodel::ViewElement::Aux(datamodel::view_element::Aux { name: "to_var".to_string(), @@ -1060,11 +1078,13 @@ pub mod view_element { x: 100.0, y: 0.0, // directly to the right label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), ], view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }; let link = datamodel::view_element::Link { @@ -1106,6 +1126,7 @@ pub mod view_element { x: 0.0, y: 0.0, label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), datamodel::ViewElement::Aux(datamodel::view_element::Aux { name: "to_var".to_string(), @@ -1113,11 +1134,13 @@ pub mod view_element { x: 100.0, y: 100.0, // down and to the right (45 degrees in canvas coords, Y-down) label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), ], view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }; let link = datamodel::view_element::Link { @@ -1153,6 +1176,7 @@ pub mod view_element { x: 0.0, y: 0.0, label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), datamodel::ViewElement::Aux(datamodel::view_element::Aux { name: "to_var".to_string(), @@ -1160,11 +1184,13 @@ pub mod view_element { x: 100.0, y: 0.0, // directly to the right label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), ], view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }; // Create an XMILE link with angle = 0 (straight horizontal right) @@ -1202,6 +1228,7 @@ pub mod view_element { x: 0.0, y: 0.0, label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), datamodel::ViewElement::Aux(datamodel::view_element::Aux { name: "to_var".to_string(), @@ -1209,11 +1236,13 @@ pub mod view_element { x: 100.0, y: 0.0, // directly to the right label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), ], view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }; // Create an XMILE link with angle = 45 (curved, not straight) @@ -1261,6 +1290,7 @@ pub mod view_element { x: 0.0, y: 0.0, label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), datamodel::ViewElement::Aux(datamodel::view_element::Aux { name: "to_var".to_string(), @@ -1268,11 +1298,13 @@ pub mod view_element { x: 100.0, y: 0.0, label_side: datamodel::view_element::LabelSide::Top, + compat: None, }), ], view_box: Default::default(), zoom: 1.0, use_lettered_polarity: false, + font: None, }; // Angle very close to straight (within epsilon) should become Straight @@ -1438,6 +1470,7 @@ pub mod view_element { label_side: datamodel::view_element::LabelSide::from( v.label_side.unwrap_or(LabelSide::Bottom), ), + compat: None, } } } @@ -1466,6 +1499,7 @@ pub mod view_element { x: 74.0, y: 31.0, label_side: datamodel::view_element::LabelSide::Right, + compat: None, }]; let view = StockFlow { name: None, @@ -1473,6 +1507,7 @@ pub mod view_element { view_box: Default::default(), zoom: 0.0, use_lettered_polarity: false, + font: None, }; for expected in cases { let expected = expected.clone(); @@ -1497,6 +1532,7 @@ pub mod view_element { flow_uid: v.flow_uid, x: v.x, y: v.y, + compat: None, } } } @@ -1519,6 +1555,7 @@ pub mod view_element { flow_uid: 31, x: 73.0, y: 29.0, + compat: None, }]; for expected in cases { let expected = expected.clone(); @@ -2162,6 +2199,7 @@ impl From for datamodel::View { } }, use_lettered_polarity: false, + font: None, }) } else { unreachable!("only stock_flow supported for now -- should be filtered out before here") @@ -2208,6 +2246,7 @@ fn test_view_roundtrip() { x: 73.0, y: 29.0, label_side: datamodel::view_element::LabelSide::Center, + compat: None, }, )], view_box: Rect { @@ -2218,6 +2257,7 @@ fn test_view_roundtrip() { }, zoom: 1.6, use_lettered_polarity: false, + font: None, })]; for expected in cases { let expected = expected.clone(); From da723fee7558e3cb70c3bf97a51bf36ec54f1e7d Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 15:52:47 -0700 Subject: [PATCH 08/31] engine: capture MDL element dimensions in ViewElementCompat during parsing Add raw bits field to VensimVariable, VensimValve, and VensimComment parsed types so the original MDL sketch metadata is preserved through the conversion pipeline. Populate ViewElementCompat on all element types during view conversion: stocks, auxes, aliases, flows (valve compat + label compat), and clouds. This is the parse-side of AC2.1/AC2.2 -- the writer side will consume these compat values to reproduce original sketch dimensions instead of hardcoded defaults. --- src/simlin-engine/src/mdl/view/convert.rs | 451 ++++++++++++++++++- src/simlin-engine/src/mdl/view/elements.rs | 12 +- src/simlin-engine/src/mdl/view/processing.rs | 26 ++ src/simlin-engine/src/mdl/view/types.rs | 10 + 4 files changed, 492 insertions(+), 7 deletions(-) diff --git a/src/simlin-engine/src/mdl/view/convert.rs b/src/simlin-engine/src/mdl/view/convert.rs index 3625b860..d122249a 100644 --- a/src/simlin-engine/src/mdl/view/convert.rs +++ b/src/simlin-engine/src/mdl/view/convert.rs @@ -402,6 +402,15 @@ fn should_filter_from_view( false } +/// Build a ViewElementCompat from raw MDL sketch dimensions. +fn make_compat(width: i32, height: i32, bits: i32) -> view_element::ViewElementCompat { + view_element::ViewElementCompat { + width: width as f64, + height: height as f64, + bits: bits as u32, + } +} + /// Convert a variable element to the appropriate ViewElement type. #[allow(clippy::too_many_arguments)] fn convert_variable( @@ -462,7 +471,7 @@ fn convert_variable( x: alias_x, y: alias_y, label_side: view_element::LabelSide::Bottom, - compat: None, + compat: Some(make_compat(var.width, var.height, var.bits)), })); } } @@ -476,13 +485,25 @@ fn convert_variable( x: var.x as f64, y: var.y as f64, label_side: view_element::LabelSide::Top, // Stocks default to top - compat: None, + compat: Some(make_compat(var.width, var.height, var.bits)), })), VariableType::Flow => { // For flows, find the associated valve and compute flow points let (flow_x, flow_y, points) = compute_flow_data(var, view, uid_offset, symbols, flow_to_valve); + // compat holds the valve's dimensions; label_compat holds the label variable's + let valve_uid = flow_to_valve.get(&var.uid).copied().unwrap_or(var.uid - 1); + let valve_compat = if var.attached { + if let Some(VensimElement::Valve(valve)) = view.get(valve_uid) { + Some(make_compat(valve.width, valve.height, valve.bits)) + } else { + None + } + } else { + None + }; + Some(ViewElement::Flow(view_element::Flow { name: xmile_name, uid, @@ -490,8 +511,8 @@ fn convert_variable( y: flow_y as f64, label_side: view_element::LabelSide::Bottom, points, - compat: None, - label_compat: None, + compat: valve_compat, + label_compat: Some(make_compat(var.width, var.height, var.bits)), })) } VariableType::Aux => Some(ViewElement::Aux(view_element::Aux { @@ -500,7 +521,7 @@ fn convert_variable( x: var.x as f64, y: var.y as f64, label_side: view_element::LabelSide::Bottom, - compat: None, + compat: Some(make_compat(var.width, var.height, var.bits)), })), } } @@ -568,7 +589,7 @@ fn convert_comment_as_cloud(comment: &VensimComment, uid: i32, flow_uid: i32) -> flow_uid, x: comment.x as f64, y: comment.y as f64, - compat: None, + compat: Some(make_compat(comment.width, comment.height, comment.bits)), }) } @@ -851,6 +872,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -920,6 +942,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -935,6 +958,7 @@ mod tests { height: 20, attached: false, is_ghost: true, // Usually marked as ghost + bits: 2, }), ); @@ -1011,6 +1035,7 @@ mod tests { height: 20, attached: false, is_ghost: false, // Primary definition + bits: 3, }), ); @@ -1026,6 +1051,7 @@ mod tests { height: 20, attached: false, is_ghost: true, // Ghost/alias + bits: 2, }), ); @@ -1080,6 +1106,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1121,6 +1148,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1158,6 +1186,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1173,6 +1202,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1215,6 +1245,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1230,6 +1261,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1287,6 +1319,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); view.insert( @@ -1300,6 +1333,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); @@ -1346,6 +1380,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); view.insert( @@ -1359,6 +1394,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); view.insert( @@ -1371,6 +1407,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); view.insert( @@ -1384,6 +1421,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); @@ -1484,6 +1522,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); view.insert( @@ -1497,6 +1536,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); view.insert( @@ -1509,6 +1549,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); view.insert( @@ -1522,6 +1563,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); view.insert( @@ -1613,4 +1655,401 @@ mod tests { "sink endpoint should remain attached to Stock_B", ); } + + // --- Compat field population tests (AC2.1, AC2.2) --- + + #[test] + fn test_stock_compat_preserves_dimensions() { + // AC2.1: Stock elements preserve original width/height/bits + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + }; + let mut view = VensimView::new(header); + + view.insert( + 1, + VensimElement::Variable(VensimVariable { + uid: 1, + name: "Test Stock".to_string(), + x: 100, + y: 50, + width: 53, + height: 32, + attached: false, + is_ghost: false, + bits: 131, + }), + ); + + let mut symbols = HashMap::new(); + symbols.insert( + "test stock".to_string(), + make_symbol_info(VariableType::Stock), + ); + + let result = build_views(vec![view], &symbols, &names_from_symbols(&symbols)); + let View::StockFlow(sf) = &result[0]; + + let stock = sf + .elements + .iter() + .find_map(|e| { + if let ViewElement::Stock(s) = e { + Some(s) + } else { + None + } + }) + .expect("expected stock element"); + + let compat = stock.compat.as_ref().expect("expected compat on stock"); + assert_eq!(compat.width, 53.0); + assert_eq!(compat.height, 32.0); + assert_eq!(compat.bits, 131); + } + + #[test] + fn test_aux_compat_preserves_dimensions() { + // AC2.2: Aux elements preserve original dimensions and bits + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + }; + let mut view = VensimView::new(header); + + view.insert( + 1, + VensimElement::Variable(VensimVariable { + uid: 1, + name: "Test Aux".to_string(), + x: 200, + y: 150, + width: 45, + height: 25, + attached: false, + is_ghost: false, + bits: 7, + }), + ); + + let mut symbols = HashMap::new(); + symbols.insert("test aux".to_string(), make_symbol_info(VariableType::Aux)); + + let result = build_views(vec![view], &symbols, &names_from_symbols(&symbols)); + let View::StockFlow(sf) = &result[0]; + + let aux = sf + .elements + .iter() + .find_map(|e| { + if let ViewElement::Aux(a) = e { + Some(a) + } else { + None + } + }) + .expect("expected aux element"); + + let compat = aux.compat.as_ref().expect("expected compat on aux"); + assert_eq!(compat.width, 45.0); + assert_eq!(compat.height, 25.0); + assert_eq!(compat.bits, 7); + } + + #[test] + fn test_flow_compat_preserves_valve_and_label_dimensions() { + // AC2.2: Flow elements preserve valve compat and label compat + use super::super::types::VensimValve; + + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + }; + let mut view = VensimView::new(header); + + // Stock for flow endpoint + view.insert( + 1, + VensimElement::Variable(VensimVariable { + uid: 1, + name: "Stock A".to_string(), + x: 50, + y: 100, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + }), + ); + + // Valve at uid 2 + view.insert( + 2, + VensimElement::Valve(VensimValve { + uid: 2, + name: "444".to_string(), + x: 150, + y: 100, + width: 9, + height: 11, + attached: true, + bits: 17, + }), + ); + + // Flow variable at uid 3 (attached to valve) + view.insert( + 3, + VensimElement::Variable(VensimVariable { + uid: 3, + name: "Flow Rate".to_string(), + x: 150, + y: 120, + width: 55, + height: 35, + attached: true, + is_ghost: false, + bits: 99, + }), + ); + + let mut symbols = HashMap::new(); + symbols.insert("stock a".to_string(), make_symbol_info(VariableType::Stock)); + symbols.insert( + "flow rate".to_string(), + make_symbol_info(VariableType::Flow), + ); + + let result = build_views(vec![view], &symbols, &names_from_symbols(&symbols)); + let View::StockFlow(sf) = &result[0]; + + let flow = sf + .elements + .iter() + .find_map(|e| { + if let ViewElement::Flow(f) = e { + Some(f) + } else { + None + } + }) + .expect("expected flow element"); + + // compat comes from the valve + let compat = flow + .compat + .as_ref() + .expect("expected compat on flow (from valve)"); + assert_eq!(compat.width, 9.0); + assert_eq!(compat.height, 11.0); + assert_eq!(compat.bits, 17); + + // label_compat comes from the flow variable itself + let label_compat = flow + .label_compat + .as_ref() + .expect("expected label_compat on flow"); + assert_eq!(label_compat.width, 55.0); + assert_eq!(label_compat.height, 35.0); + assert_eq!(label_compat.bits, 99); + } + + #[test] + fn test_cloud_compat_preserves_dimensions() { + // AC2.2: Cloud elements preserve original dimensions and bits + use super::super::types::VensimValve; + + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + }; + let mut view = VensimView::new(header); + + // Cloud (comment) at uid 1 + view.insert( + 1, + VensimElement::Comment(VensimComment { + uid: 1, + text: "".to_string(), + x: 50, + y: 100, + width: 18, + height: 18, + scratch_name: false, + bits: 12, + }), + ); + + // Stock at uid 2 + view.insert( + 2, + VensimElement::Variable(VensimVariable { + uid: 2, + name: "Stock B".to_string(), + x: 250, + y: 100, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + }), + ); + + // Valve at uid 3 + view.insert( + 3, + VensimElement::Valve(VensimValve { + uid: 3, + name: "444".to_string(), + x: 150, + y: 100, + width: 6, + height: 8, + attached: true, + bits: 3, + }), + ); + + // Flow at uid 4 (attached to valve) + view.insert( + 4, + VensimElement::Variable(VensimVariable { + uid: 4, + name: "Flow Rate".to_string(), + x: 150, + y: 120, + width: 40, + height: 20, + attached: true, + is_ghost: false, + bits: 3, + }), + ); + + // Connector from valve to cloud (cloud detection requires conn.to_uid == comment_uid) + view.insert( + 5, + VensimElement::Connector(super::super::types::VensimConnector { + uid: 5, + from_uid: 3, + to_uid: 1, + polarity: None, + letter_polarity: false, + control_point: (100, 100), + }), + ); + + // Connector from valve to stock + view.insert( + 6, + VensimElement::Connector(super::super::types::VensimConnector { + uid: 6, + from_uid: 3, + to_uid: 2, + polarity: None, + letter_polarity: false, + control_point: (200, 100), + }), + ); + + let mut symbols = HashMap::new(); + let mut stock_info = make_symbol_info(VariableType::Stock); + stock_info.inflows = vec!["flow rate".to_string()]; + symbols.insert("stock b".to_string(), stock_info); + symbols.insert( + "flow rate".to_string(), + make_symbol_info(VariableType::Flow), + ); + + let result = build_views(vec![view], &symbols, &names_from_symbols(&symbols)); + let View::StockFlow(sf) = &result[0]; + + let cloud = sf + .elements + .iter() + .find_map(|e| { + if let ViewElement::Cloud(c) = e { + Some(c) + } else { + None + } + }) + .expect("expected cloud element"); + + let compat = cloud.compat.as_ref().expect("expected compat on cloud"); + assert_eq!(compat.width, 18.0); + assert_eq!(compat.height, 18.0); + assert_eq!(compat.bits, 12); + } + + #[test] + fn test_alias_compat_preserves_dimensions() { + // AC2.2: Alias elements preserve original dimensions and bits + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + }; + let mut view = VensimView::new(header); + + // Primary variable at uid 1 + view.insert( + 1, + VensimElement::Variable(VensimVariable { + uid: 1, + name: "Contact Rate".to_string(), + x: 100, + y: 100, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + }), + ); + + // Ghost/alias at uid 2 with non-default dimensions + view.insert( + 2, + VensimElement::Variable(VensimVariable { + uid: 2, + name: "Contact Rate".to_string(), + x: 300, + y: 400, + width: 48, + height: 24, + attached: false, + is_ghost: true, + bits: 130, + }), + ); + + let mut symbols = HashMap::new(); + symbols.insert( + "contact rate".to_string(), + make_symbol_info(VariableType::Aux), + ); + + let result = build_views(vec![view], &symbols, &names_from_symbols(&symbols)); + let View::StockFlow(sf) = &result[0]; + + let alias = sf + .elements + .iter() + .find_map(|e| { + if let ViewElement::Alias(a) = e { + Some(a) + } else { + None + } + }) + .expect("expected alias element"); + + let compat = alias.compat.as_ref().expect("expected compat on alias"); + assert_eq!(compat.width, 48.0); + assert_eq!(compat.height, 24.0); + assert_eq!(compat.bits, 130); + } } diff --git a/src/simlin-engine/src/mdl/view/elements.rs b/src/simlin-engine/src/mdl/view/elements.rs index 52eed1de..715bec43 100644 --- a/src/simlin-engine/src/mdl/view/elements.rs +++ b/src/simlin-engine/src/mdl/view/elements.rs @@ -136,6 +136,7 @@ pub fn parse_variable(uid: i32, fields: &str) -> Result Result { let (y, rest) = parse_int_field(rest); let (width, rest) = parse_int_field(rest); let (height, rest) = parse_int_field(rest); - let (shape, _) = parse_int_field(rest); + let (shape, rest) = parse_int_field(rest); + let (bits, _) = parse_int_field(rest); let attached = (shape & (1 << 5)) != 0; @@ -162,6 +164,7 @@ pub fn parse_valve(uid: i32, fields: &str) -> Result { width, height, attached, + bits, }) } @@ -193,6 +196,7 @@ pub fn parse_comment(uid: i32, fields: &str) -> Result<(VensimComment, bool), Vi width, height, scratch_name, + bits, }, scratch_name, )) @@ -400,6 +404,7 @@ mod tests { assert_eq!(var.height, 20); assert!(!var.attached); // shape=3, bit 5 not set assert!(!var.is_ghost); // bits=3, bit 0 is set + assert_eq!(var.bits, 3); } #[test] @@ -411,6 +416,7 @@ mod tests { assert_eq!(var.name, "Infection Rate"); assert!(var.attached); // shape=40 has bit 5 set assert!(!var.is_ghost); + assert_eq!(var.bits, 3); } #[test] @@ -422,6 +428,7 @@ mod tests { assert_eq!(var.name, "Contact Rate c"); assert!(var.is_ghost); // bits=2, bit 0 not set + assert_eq!(var.bits, 2); } #[test] @@ -435,6 +442,7 @@ mod tests { assert_eq!(valve.x, 295); assert_eq!(valve.y, 191); assert!(valve.attached); // shape=34 has bit 5 set + assert_eq!(valve.bits, 3); } #[test] @@ -450,6 +458,7 @@ mod tests { assert_eq!(comment.y, 218); assert!(scratch_name); assert!(comment.scratch_name); + assert_eq!(comment.bits, 4); } #[test] @@ -461,6 +470,7 @@ mod tests { assert_eq!(comment.text, "Some text"); assert!(!scratch_name); assert!(!comment.scratch_name); + assert_eq!(comment.bits, 0); } #[test] diff --git a/src/simlin-engine/src/mdl/view/processing.rs b/src/simlin-engine/src/mdl/view/processing.rs index b86e6e78..502401c8 100644 --- a/src/simlin-engine/src/mdl/view/processing.rs +++ b/src/simlin-engine/src/mdl/view/processing.rs @@ -681,6 +681,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -718,6 +719,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -733,6 +735,7 @@ mod tests { height: 20, attached: false, is_ghost: true, + bits: 2, }), ); @@ -782,6 +785,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); @@ -797,6 +801,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); @@ -839,6 +844,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -853,6 +859,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); @@ -868,6 +875,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); @@ -932,6 +940,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -947,6 +956,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -961,6 +971,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); @@ -976,6 +987,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); @@ -1057,6 +1069,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1072,6 +1085,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1086,6 +1100,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); @@ -1101,6 +1116,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); @@ -1180,6 +1196,7 @@ mod tests { width: 15, height: 15, scratch_name: false, + bits: 0, }), ); @@ -1195,6 +1212,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1209,6 +1227,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); @@ -1224,6 +1243,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); @@ -1298,6 +1318,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1313,6 +1334,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -1327,6 +1349,7 @@ mod tests { width: 6, height: 8, attached: true, + bits: 3, }), ); @@ -1342,6 +1365,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }), ); @@ -1415,6 +1439,7 @@ mod tests { height: 20, attached: false, is_ghost: true, // ghost + bits: 2, }), ); @@ -1430,6 +1455,7 @@ mod tests { height: 20, attached: false, is_ghost: true, // ghost + bits: 2, }), ); diff --git a/src/simlin-engine/src/mdl/view/types.rs b/src/simlin-engine/src/mdl/view/types.rs index 312f1c80..346ce1a0 100644 --- a/src/simlin-engine/src/mdl/view/types.rs +++ b/src/simlin-engine/src/mdl/view/types.rs @@ -40,6 +40,8 @@ pub struct VensimVariable { /// Whether this is a ghost (alias) of another element. /// Ghost = !(bits & 1), so bits & 1 == 0 means ghost. pub is_ghost: bool, + /// Raw bits field from MDL sketch for roundtrip fidelity. + pub bits: i32, } /// A valve element in the view (type 11). @@ -56,6 +58,8 @@ pub struct VensimValve { pub height: i32, /// Whether this valve is attached to a flow (shape bit 5). pub attached: bool, + /// Raw bits field from MDL sketch for roundtrip fidelity. + pub bits: i32, } /// A comment element in the view (type 12). @@ -72,6 +76,8 @@ pub struct VensimComment { /// If true, the actual text content was on the next line (scratch_name). /// This is set when bits & (1 << 2) is true. pub scratch_name: bool, + /// Raw bits field from MDL sketch for roundtrip fidelity. + pub bits: i32, } /// A connector element in the view (type 1). @@ -343,6 +349,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }; view.insert(5, VensimElement::Variable(var)); @@ -378,6 +385,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); view.insert( @@ -391,6 +399,7 @@ mod tests { height: 20, attached: false, is_ghost: false, + bits: 3, }), ); @@ -411,6 +420,7 @@ mod tests { height: 20, attached: true, is_ghost: false, + bits: 3, }); assert_eq!(var.uid(), 1); From 096f584ce74fc57b64491a7f26172850b77501d9 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 16:00:32 -0700 Subject: [PATCH 09/31] engine: capture MDL font specification during view parsing Add font field to ViewHeader and capture the $-prefixed font line during MDL sketch parsing instead of discarding it. The font string (stored without the leading $) flows through convert_view into StockFlow.font for roundtrip fidelity. In multi-view merge, the font from the first view is preserved. --- src/simlin-engine/src/mdl/view/convert.rs | 98 +++++++++++++++++++- src/simlin-engine/src/mdl/view/mod.rs | 50 +++++++++- src/simlin-engine/src/mdl/view/processing.rs | 9 ++ src/simlin-engine/src/mdl/view/types.rs | 5 + 4 files changed, 155 insertions(+), 7 deletions(-) diff --git a/src/simlin-engine/src/mdl/view/convert.rs b/src/simlin-engine/src/mdl/view/convert.rs index d122249a..2e599ce8 100644 --- a/src/simlin-engine/src/mdl/view/convert.rs +++ b/src/simlin-engine/src/mdl/view/convert.rs @@ -252,10 +252,16 @@ fn merge_views(views: Vec) -> Vec { let mut all_elements = Vec::new(); let mut use_lettered_polarity = false; + // Use the font from the first view (all views in a Vensim file + // typically share the same font specification). + let mut font = None; for view in views { let View::StockFlow(sf) = view; use_lettered_polarity = use_lettered_polarity || sf.use_lettered_polarity; + if font.is_none() { + font = sf.font; + } all_elements.extend(sf.elements); } @@ -265,7 +271,7 @@ fn merge_views(views: Vec) -> Vec { view_box: Default::default(), zoom: 1.0, use_lettered_polarity, - font: None, + font, }); vec![merged] @@ -368,7 +374,7 @@ fn convert_view( view_box: Default::default(), zoom: 1.0, use_lettered_polarity, - font: None, + font: view.header.font.clone(), })) } @@ -858,6 +864,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -927,6 +934,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1020,6 +1028,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1092,6 +1101,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Population".to_string(), // Same as variable name after canonicalization + font: None, }; let mut view = VensimView::new(header); @@ -1134,6 +1144,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Demand/Supply-Overview*2026".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1171,6 +1182,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1230,6 +1242,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1306,6 +1319,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1366,6 +1380,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1508,6 +1523,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1664,6 +1680,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1715,6 +1732,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1765,6 +1783,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1864,6 +1883,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1991,6 +2011,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -2052,4 +2073,77 @@ mod tests { assert_eq!(compat.height, 24.0); assert_eq!(compat.bits, 130); } + + #[test] + fn test_font_flows_to_stock_flow() { + // AC1.4: font string from the parsed view header should appear in StockFlow.font + let font_spec = + "192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0"; + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + font: Some(font_spec.to_string()), + }; + let mut view = VensimView::new(header); + + view.insert( + 1, + VensimElement::Variable(VensimVariable { + uid: 1, + name: "Test Aux".to_string(), + x: 100, + y: 100, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + }), + ); + + let mut symbols = HashMap::new(); + symbols.insert("test aux".to_string(), make_symbol_info(VariableType::Aux)); + let all_names = names_from_symbols(&symbols); + + let views = build_views(vec![view], &symbols, &all_names); + assert_eq!(views.len(), 1); + + let View::StockFlow(sf) = &views[0]; + assert_eq!(sf.font.as_deref(), Some(font_spec)); + } + + #[test] + fn test_no_font_gives_none_in_stock_flow() { + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + font: None, + }; + let mut view = VensimView::new(header); + + view.insert( + 1, + VensimElement::Variable(VensimVariable { + uid: 1, + name: "Test Aux".to_string(), + x: 100, + y: 100, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + }), + ); + + let mut symbols = HashMap::new(); + symbols.insert("test aux".to_string(), make_symbol_info(VariableType::Aux)); + let all_names = names_from_symbols(&symbols); + + let views = build_views(vec![view], &symbols, &all_names); + assert_eq!(views.len(), 1); + + let View::StockFlow(sf) = &views[0]; + assert!(sf.font.is_none()); + } } diff --git a/src/simlin-engine/src/mdl/view/mod.rs b/src/simlin-engine/src/mdl/view/mod.rs index 6214ee2a..b0b2966f 100644 --- a/src/simlin-engine/src/mdl/view/mod.rs +++ b/src/simlin-engine/src/mdl/view/mod.rs @@ -138,15 +138,23 @@ impl<'a> ViewSectionParser<'a> { } }; - // Skip font line if present (we ignore PPI values per xmutil) - if let Some(line) = self.peek_line() + // Capture font line if present (the `$`-prefixed line after the + // title). Store without the leading `$` for roundtrip fidelity. + let font = if let Some(line) = self.peek_line() && line.starts_with('$') { - self.read_line(); - } + let font_line = self.read_line().unwrap(); + Some(font_line[1..].to_string()) + } else { + None + }; // Create view with header - let header = ViewHeader { version, title }; + let header = ViewHeader { + version, + title, + font, + }; let mut view = VensimView::new(header); // Parse elements @@ -452,4 +460,36 @@ $font assert_eq!(views.len(), 1); assert!(views[0].get(1).is_some()); } + + #[test] + fn test_font_line_captured_in_header() { + // AC1.4: font line should be captured without the leading '$' + let font_spec = + "192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0"; + let source = format!( + "\\\\\\\\ ---/// Sketch information\n\ + V300 Do not put anything below this section\n\ + *View 1\n\ + ${}\n\ + 10,1,Test Variable,100,200,40,20,3,3,0,0,0,0,0,0\n\ + ///---\\\\\\\\\n", + font_spec + ); + let views = parse_views(&source).unwrap(); + assert_eq!(views.len(), 1); + assert_eq!(views[0].header.font.as_deref(), Some(font_spec)); + } + + #[test] + fn test_no_font_line_gives_none() { + // When there is no font line, header.font should be None + let source = "\\\\\\\\ ---///\n\ + V300\n\ + *View 1\n\ + 10,1,Test Variable,100,200,40,20,3,3,0,0,0,0,0,0\n\ + ///---\\\\\\\\\n"; + let views = parse_views(source).unwrap(); + assert_eq!(views.len(), 1); + assert!(views[0].header.font.is_none()); + } } diff --git a/src/simlin-engine/src/mdl/view/processing.rs b/src/simlin-engine/src/mdl/view/processing.rs index 502401c8..a6c6c7f5 100644 --- a/src/simlin-engine/src/mdl/view/processing.rs +++ b/src/simlin-engine/src/mdl/view/processing.rs @@ -667,6 +667,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -704,6 +705,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -771,6 +773,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -829,6 +832,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -925,6 +929,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1054,6 +1059,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1182,6 +1188,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1303,6 +1310,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -1424,6 +1432,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); diff --git a/src/simlin-engine/src/mdl/view/types.rs b/src/simlin-engine/src/mdl/view/types.rs index 346ce1a0..952c9475 100644 --- a/src/simlin-engine/src/mdl/view/types.rs +++ b/src/simlin-engine/src/mdl/view/types.rs @@ -21,6 +21,9 @@ pub enum ViewVersion { pub struct ViewHeader { pub version: ViewVersion, pub title: String, + /// Font specification from the MDL sketch section (the `$`-prefixed line, + /// stored without the leading `$`). Preserved for roundtrip fidelity. + pub font: Option, } /// A variable element in the view (type 10). @@ -337,6 +340,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test View".to_string(), + font: None, }; let mut view = VensimView::new(header); @@ -371,6 +375,7 @@ mod tests { let header = ViewHeader { version: ViewVersion::V300, title: "Test".to_string(), + font: None, }; let mut view = VensimView::new(header); From 2dc8e5833787504d0e68d386d42d888a27317a33 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 16:04:27 -0700 Subject: [PATCH 10/31] engine: preserve explicit lookup y-range bounds from MDL source When MDL specifies explicit y_range via [(xmin,ymin)-(xmax,ymax)], preserve those bounds for roundtrip fidelity instead of always recomputing from data points. This mirrors the existing x_range handling. When no explicit bounds are present (e.g. WITH LOOKUP without range prefix), y_scale continues to be computed from actual data points. --- .../src/mdl/convert/variables.rs | 108 ++++++++++++++---- 1 file changed, 85 insertions(+), 23 deletions(-) diff --git a/src/simlin-engine/src/mdl/convert/variables.rs b/src/simlin-engine/src/mdl/convert/variables.rs index 4d6352dd..183cd26e 100644 --- a/src/simlin-engine/src/mdl/convert/variables.rs +++ b/src/simlin-engine/src/mdl/convert/variables.rs @@ -1349,20 +1349,25 @@ impl<'input> ConversionContext<'input> { } }; - // y-scale: ALWAYS compute from data points, matching xmutil behavior. - // xmutil ignores file-specified y-ranges and recomputes from actual data. - let y_min = y_vals.iter().cloned().fold(f64::INFINITY, f64::min); - let y_max = y_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - // When all y-values are identical (ymin == ymax), use ymin+1 as max - // to avoid degenerate range. Matches xmutil's fallback logic. - let y_max = if (y_min - y_max).abs() < f64::EPSILON { - y_min + 1.0 + // y-scale: use file-specified range if available for roundtrip fidelity, + // otherwise compute from data points + let y_scale = if let Some(y_range) = table.y_range { + GraphicalFunctionScale { + min: y_range.0, + max: y_range.1, + } } else { - y_max - }; - let y_scale = GraphicalFunctionScale { - min: y_min, - max: y_max, + let y_min = y_vals.iter().cloned().fold(f64::INFINITY, f64::min); + let y_max = y_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let y_max = if (y_min - y_max).abs() < f64::EPSILON { + y_min + 1.0 + } else { + y_max + }; + GraphicalFunctionScale { + min: y_min, + max: y_max, + } }; // Check if extrapolation should be enabled: @@ -2761,10 +2766,9 @@ x[bottom] = 2 } #[test] - fn test_graphical_function_y_scale_computed_from_data() { - // y-scale should always be computed from data points, not from file-specified range. - // This matches xmutil behavior: XMILEGenerator.cpp:513-532 always recomputes y-scale. - // The file specifies y_range [0,5] but data max is 1.36, so y_scale.max should be 1.36. + fn test_graphical_function_y_scale_from_explicit_range() { + // When the MDL source specifies an explicit y_range via [(xmin,ymin)-(xmax,ymax)], + // preserve those bounds for roundtrip fidelity instead of recomputing from data. let mdl = "lookup_var(\ [(0,0)-(2,5)],(0,0.5),(1,1.36),(2,0.8)) ~ ~| @@ -2785,17 +2789,17 @@ x[bottom] = 2 // x-scale uses file range assert_eq!(gf.x_scale.min, 0.0); assert_eq!(gf.x_scale.max, 2.0); - // y-scale computed from data, not file range - assert_eq!(gf.y_scale.min, 0.5); - assert_eq!(gf.y_scale.max, 1.36); + // y-scale uses file-specified range, not computed from data + assert_eq!(gf.y_scale.min, 0.0); + assert_eq!(gf.y_scale.max, 5.0); } else { panic!("Expected Aux variable"); } } #[test] - fn test_graphical_function_y_scale_all_same_fallback() { - // When all y-values are identical, ymax should be ymin+1 (degenerate range fallback). + fn test_graphical_function_y_scale_all_same_with_explicit_range() { + // When explicit y_range is present, it takes precedence even if min==max. let mdl = "zero_lookup(\ [(0,0)-(2,0)],(0,0),(1,0),(2,0)) ~ ~| @@ -2811,10 +2815,68 @@ x[bottom] = 2 .find(|v| v.get_ident() == "zero_lookup"); assert!(var.is_some(), "Should have zero_lookup"); + if let Some(Variable::Aux(a)) = var { + let gf = a.gf.as_ref().expect("Should have graphical function"); + // Explicit y_range [0,0] is preserved as-is + assert_eq!(gf.y_scale.min, 0.0); + assert_eq!(gf.y_scale.max, 0.0); + } else { + panic!("Expected Aux variable"); + } + } + + #[test] + fn test_graphical_function_y_scale_computed_from_data_when_no_explicit_range() { + // When no explicit y_range is specified (e.g. WITH LOOKUP without range prefix), + // y_scale should be computed from the actual data points. + let mdl = "y = WITH LOOKUP(Time, ((0,0.5),(1,1.36),(2,0.8))) +~ ~| +\\\\\\---/// +"; + let result = convert_mdl(mdl); + assert!(result.is_ok(), "Conversion should succeed: {:?}", result); + let project = result.unwrap(); + + let var = project.models[0] + .variables + .iter() + .find(|v| v.get_ident() == "y"); + assert!(var.is_some(), "Should have y variable"); + + if let Some(Variable::Aux(a)) = var { + let gf = a.gf.as_ref().expect("Should have graphical function"); + // No explicit range, so x-scale and y-scale are computed from data + assert_eq!(gf.x_scale.min, 0.0); + assert_eq!(gf.x_scale.max, 2.0); + assert_eq!(gf.y_scale.min, 0.5); + assert_eq!(gf.y_scale.max, 1.36); + } else { + panic!("Expected Aux variable"); + } + } + + #[test] + fn test_graphical_function_y_scale_all_same_fallback_no_explicit_range() { + // When no explicit y_range and all y-values are identical, + // ymax should be ymin+1 to avoid a degenerate range. + let mdl = "y = WITH LOOKUP(Time, ((0,0),(1,0),(2,0))) +~ ~| +\\\\\\---/// +"; + let result = convert_mdl(mdl); + assert!(result.is_ok(), "Conversion should succeed: {:?}", result); + let project = result.unwrap(); + + let var = project.models[0] + .variables + .iter() + .find(|v| v.get_ident() == "y"); + assert!(var.is_some(), "Should have y variable"); + if let Some(Variable::Aux(a)) = var { let gf = a.gf.as_ref().expect("Should have graphical function"); assert_eq!(gf.y_scale.min, 0.0); - assert_eq!(gf.y_scale.max, 1.0); // 0 + 1 = 1 + assert_eq!(gf.y_scale.max, 1.0); // 0 + 1 = 1 (degenerate range fallback) } else { panic!("Expected Aux variable"); } From 85b2da091182ff1c4a1afaf63cf79a39eef4a7c5 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 16:22:25 -0700 Subject: [PATCH 11/31] engine: split merged MDL views on Group boundaries in writer When the MDL parser merges multiple named views into a single StockFlow, it inserts Group elements as view boundary markers. The writer now detects these Group boundaries and splits the merged elements back into separate MDL views, each with its own V300 header, font line, and view title. Shared maps (valve UIDs, element positions, names) are built from all elements before splitting to ensure cross-view link and alias references resolve correctly. The preserved font from the StockFlow is emitted when present, falling back to the default Times New Roman spec. Includes 11 unit tests covering: split logic with/without Groups, element partitioning, Module filtering, font preservation, multi-view MDL output headers, and V300 separator counts. --- src/simlin-engine/src/mdl/writer.rs | 413 +++++++++++++++++++++++++--- 1 file changed, 373 insertions(+), 40 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 30d8dd1a..d7f08f2a 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1603,6 +1603,53 @@ fn compute_control_point(from: (i32, i32), to: (i32, i32), canvas_angle: f64) -> (cx.round() as i32, cy.round() as i32) } +/// Splits a StockFlow's elements into view segments at Group boundaries. +/// +/// When the MDL parser merges multiple named views into a single StockFlow, +/// it inserts a Group element at the start of each original view's elements. +/// This function reverses that merge by splitting on Group boundaries. +/// +/// Returns a Vec of (view_name, elements, font). If no Group elements exist, +/// returns a single segment using the StockFlow's own name (or "View 1"). +fn split_view_on_groups<'a>( + sf: &'a datamodel::StockFlow, +) -> Vec<(String, Vec<&'a ViewElement>, Option)> { + let has_groups = sf + .elements + .iter() + .any(|e| matches!(e, ViewElement::Group(_))); + + if !has_groups { + let name = sf.name.clone().unwrap_or_else(|| "View 1".to_string()); + let elements: Vec<&ViewElement> = sf + .elements + .iter() + .filter(|e| !matches!(e, ViewElement::Module(_))) + .collect(); + return vec![(name, elements, sf.font.clone())]; + } + + let mut segments = Vec::new(); + let mut current_name = sf.name.clone().unwrap_or_else(|| "View 1".to_string()); + let mut current_elements: Vec<&'a ViewElement> = Vec::new(); + + for element in &sf.elements { + if let ViewElement::Group(group) = element { + if !current_elements.is_empty() { + segments.push((current_name, current_elements, sf.font.clone())); + current_elements = Vec::new(); + } + current_name = group.name.clone(); + } else if !matches!(element, ViewElement::Module(_)) { + current_elements.push(element); + } + } + if !current_elements.is_empty() { + segments.push((current_name, current_elements, sf.font.clone())); + } + segments +} + /// Stateful writer that accumulates the full MDL file text. pub struct MdlWriter { buf: String, @@ -1755,55 +1802,86 @@ impl MdlWriter { /// Each view gets its own `\\\---///` separator and `V300` header line. /// The first view's separator is already emitted by `write_equations_section`. /// The final `///---\\\` terminator follows the last view. + /// + /// When a StockFlow contains Group elements (from merging multiple MDL + /// views at parse time), we split on those boundaries to reconstruct + /// the original multi-view structure. fn write_sketch_section(&mut self, views: &[View]) { - for (i, view) in views.iter().enumerate() { - if i > 0 { - // Additional views need their own separator + let mut segment_idx = 0; + for view in views { + let View::StockFlow(sf) = view; + + // Build shared maps from ALL elements so that cross-view + // references (links, aliases) resolve correctly. + let valve_uids = allocate_valve_uids(&sf.elements); + let mut next_connector_uid = max_sketch_uid(&sf.elements, &valve_uids) + 1; + let elem_positions = build_element_positions(&sf.elements, &valve_uids); + let name_map = build_name_map(&sf.elements); + + let segments = split_view_on_groups(sf); + for (view_name, elements, font) in &segments { + if segment_idx > 0 { + self.buf.push_str( + "\\\\\\---/// Sketch information - do not modify anything except names\n", + ); + } self.buf.push_str( - "\\\\\\---/// Sketch information - do not modify anything except names\n", + "V300 Do not put anything below this section - it will be ignored\n", + ); + self.write_view_segment( + view_name, + elements, + font.as_deref(), + sf.use_lettered_polarity, + &valve_uids, + &mut next_connector_uid, + &elem_positions, + &name_map, ); + segment_idx += 1; } - self.buf - .push_str("V300 Do not put anything below this section - it will be ignored\n"); - - let View::StockFlow(sf) = view; - self.write_stock_flow_view(sf); } self.buf.push_str("///---\\\\\\\n"); } - /// Write a single StockFlow view as sketch elements. - fn write_stock_flow_view(&mut self, sf: &datamodel::StockFlow) { - let view_title = sanitize_view_title_for_mdl(sf.name.as_deref().unwrap_or("View 1")); + /// Write a single view segment: title, font line, and all sketch elements. + #[allow(clippy::too_many_arguments)] + fn write_view_segment( + &mut self, + view_name: &str, + elements: &[&ViewElement], + font: Option<&str>, + use_lettered_polarity: bool, + valve_uids: &HashMap, + next_connector_uid: &mut i32, + elem_positions: &HashMap, + name_map: &HashMap, + ) { + let view_title = sanitize_view_title_for_mdl(view_name); writeln!(self.buf, "*{}", view_title).unwrap(); - self.buf.push_str( - "$192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0\n", - ); - - // Allocate non-conflicting valve UIDs for flow elements - let valve_uids = allocate_valve_uids(&sf.elements); - let mut next_connector_uid = max_sketch_uid(&sf.elements, &valve_uids) + 1; - - // Build position map for link control point computation - let elem_positions = build_element_positions(&sf.elements, &valve_uids); - // Build name map for alias resolution - let name_map = build_name_map(&sf.elements); + if let Some(f) = font { + writeln!(self.buf, "${}", f).unwrap(); + } else { + self.buf.push_str( + "$192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0\n", + ); + } // Collect cloud UIDs so flow pipe connectors can set the right direction flag. // Also build a map from flow_uid -> clouds so we can emit each cloud // just before its associated flow (Vensim requires this ordering). let mut cloud_uids: HashSet = HashSet::new(); let mut flow_clouds: HashMap> = HashMap::new(); - for elem in &sf.elements { - if let ViewElement::Cloud(c) = elem { + for elem in elements { + if let ViewElement::Cloud(c) = *elem { cloud_uids.insert(c.uid); flow_clouds.entry(c.flow_uid).or_default().push(c); } } - for elem in &sf.elements { + for elem in elements { match elem { ViewElement::Aux(aux) => { write_aux_element(&mut self.buf, aux); @@ -1824,30 +1902,23 @@ impl MdlWriter { write_flow_element( &mut self.buf, flow, - &valve_uids, + valve_uids, &cloud_uids, - &mut next_connector_uid, + next_connector_uid, ); self.buf.push('\n'); } ViewElement::Link(link) => { - write_link_element( - &mut self.buf, - link, - &elem_positions, - sf.use_lettered_polarity, - ); + write_link_element(&mut self.buf, link, elem_positions, use_lettered_polarity); self.buf.push('\n'); } // Clouds are emitted with their associated flow above ViewElement::Cloud(_) => {} ViewElement::Alias(alias) => { - write_alias_element(&mut self.buf, alias, &name_map); + write_alias_element(&mut self.buf, alias, name_map); self.buf.push('\n'); } - ViewElement::Module(_) | ViewElement::Group(_) => { - // Modules and groups are not serialized in MDL sketch format - } + ViewElement::Module(_) | ViewElement::Group(_) => {} } } } @@ -1981,7 +2052,7 @@ mod tests { use crate::common::RawIdent; use crate::datamodel::{ Aux, Compat, Equation, Flow, GraphicalFunction, GraphicalFunctionKind, - GraphicalFunctionScale, SimMethod, Stock, Unit, Variable, + GraphicalFunctionScale, Rect, SimMethod, Stock, StockFlow, Unit, Variable, view_element, }; use crate::lexer::LexerType; @@ -4627,4 +4698,266 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 "should produce GET DIRECT CONSTANTS: {eq}" ); } + + // ---- Multi-view split tests (Phase 3, Tasks 1-2) ---- + + fn make_view_aux(name: &str, uid: i32) -> ViewElement { + ViewElement::Aux(view_element::Aux { + name: name.to_owned(), + uid, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }) + } + + fn make_view_stock(name: &str, uid: i32) -> ViewElement { + ViewElement::Stock(view_element::Stock { + name: name.to_owned(), + uid, + x: 200.0, + y: 200.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }) + } + + fn make_view_flow(name: &str, uid: i32) -> ViewElement { + ViewElement::Flow(view_element::Flow { + name: name.to_owned(), + uid, + x: 150.0, + y: 150.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }) + } + + fn make_view_group(name: &str, uid: i32) -> ViewElement { + ViewElement::Group(view_element::Group { + uid, + name: name.to_owned(), + x: 0.0, + y: 0.0, + width: 500.0, + height: 500.0, + }) + } + + fn make_stock_flow(elements: Vec) -> StockFlow { + StockFlow { + name: None, + elements, + view_box: Rect::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + } + } + + #[test] + fn split_view_no_groups_returns_single_segment() { + let sf = make_stock_flow(vec![ + make_view_aux("price", 1), + make_view_stock("inventory", 2), + ]); + let segments = split_view_on_groups(&sf); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].0, "View 1"); + assert_eq!(segments[0].1.len(), 2); + } + + #[test] + fn split_view_no_groups_uses_stockflow_name() { + let mut sf = make_stock_flow(vec![make_view_aux("price", 1)]); + sf.name = Some("My Custom View".to_owned()); + let segments = split_view_on_groups(&sf); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].0, "My Custom View"); + } + + #[test] + fn split_view_two_groups_produces_two_segments() { + let sf = make_stock_flow(vec![ + make_view_group("1 housing", 100), + make_view_aux("price", 1), + make_view_stock("inventory", 2), + make_view_group("2 investments", 200), + make_view_aux("rate", 3), + make_view_flow("capital_flow", 4), + ]); + let segments = split_view_on_groups(&sf); + assert_eq!(segments.len(), 2, "expected 2 segments from 2 groups"); + assert_eq!(segments[0].0, "1 housing"); + assert_eq!(segments[0].1.len(), 2, "first segment: price + inventory"); + assert_eq!(segments[1].0, "2 investments"); + assert_eq!( + segments[1].1.len(), + 2, + "second segment: rate + capital_flow" + ); + } + + #[test] + fn split_view_elements_partitioned_correctly() { + let sf = make_stock_flow(vec![ + make_view_group("1 housing", 100), + make_view_aux("price", 1), + make_view_stock("inventory", 2), + make_view_group("2 investments", 200), + make_view_aux("rate", 3), + ]); + let segments = split_view_on_groups(&sf); + + // First segment should contain price and inventory + let seg1_names: Vec<&str> = segments[0].1.iter().filter_map(|e| e.get_name()).collect(); + assert_eq!(seg1_names, vec!["price", "inventory"]); + + // Second segment should contain rate + let seg2_names: Vec<&str> = segments[1].1.iter().filter_map(|e| e.get_name()).collect(); + assert_eq!(seg2_names, vec!["rate"]); + } + + #[test] + fn split_view_modules_filtered_out() { + let sf = make_stock_flow(vec![ + make_view_aux("price", 1), + ViewElement::Module(view_element::Module { + name: "submodel".to_owned(), + uid: 99, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + }), + ]); + let segments = split_view_on_groups(&sf); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].1.len(), 1, "module should be filtered out"); + } + + #[test] + fn split_view_preserves_font() { + let mut sf = make_stock_flow(vec![ + make_view_group("view1", 100), + make_view_aux("x", 1), + make_view_group("view2", 200), + make_view_aux("y", 2), + ]); + sf.font = Some("192-192-192,0,Verdana|10||0-0-0".to_owned()); + let segments = split_view_on_groups(&sf); + for (_, _, font) in &segments { + assert_eq!( + font.as_deref(), + Some("192-192-192,0,Verdana|10||0-0-0"), + "all segments should share the StockFlow font" + ); + } + } + + #[test] + fn multi_view_mdl_output_contains_view_headers() { + let sf = make_stock_flow(vec![ + make_view_group("1 housing", 100), + make_view_aux("price", 1), + make_view_group("2 investments", 200), + make_view_aux("rate", 2), + ]); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("*1 housing"), + "output should contain first view header: {output}" + ); + assert!( + output.contains("*2 investments"), + "output should contain second view header: {output}" + ); + } + + #[test] + fn multi_view_mdl_output_has_separators_between_views() { + let sf = make_stock_flow(vec![ + make_view_group("view1", 100), + make_view_aux("a", 1), + make_view_group("view2", 200), + make_view_aux("b", 2), + ]); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + // The second view should have a V300 header + let v300_count = output.matches("V300").count(); + assert_eq!( + v300_count, 2, + "two views should produce two V300 headers: {output}" + ); + } + + #[test] + fn single_view_no_groups_mdl_output() { + let sf = make_stock_flow(vec![make_view_aux("price", 1)]); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("*View 1"), + "single view should use default name: {output}" + ); + let v300_count = output.matches("V300").count(); + assert_eq!( + v300_count, 1, + "single view should produce one V300 header: {output}" + ); + } + + #[test] + fn multi_view_uses_font_when_present() { + let mut sf = make_stock_flow(vec![make_view_group("view1", 100), make_view_aux("a", 1)]); + sf.font = Some( + "192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0" + .to_owned(), + ); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("$192-192-192,0,Verdana|10||"), + "should use preserved font: {output}" + ); + assert!( + !output.contains("Times New Roman"), + "should not use default font when custom font present: {output}" + ); + } + + #[test] + fn single_view_uses_default_font_when_none() { + let sf = make_stock_flow(vec![make_view_aux("a", 1)]); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("Times New Roman|12"), + "should use default font when font is None: {output}" + ); + } } From 6d9256c39b73dad5f82d476f3a97eb7cd6c686aa Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 16:43:27 -0700 Subject: [PATCH 12/31] engine: use ViewElementCompat dimensions in MDL element output Each sketch element write function (stock, aux, flow, cloud, alias) now uses the preserved compat dimensions when present, falling back to hardcoded defaults for elements from non-MDL sources (XMILE imports). Flow elements use both compat (valve) and label_compat (attached label). --- src/simlin-engine/src/mdl/writer.rs | 297 ++++++++++++++++++++++++++-- 1 file changed, 281 insertions(+), 16 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index d7f08f2a..0acbd657 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1210,11 +1210,15 @@ fn format_sketch_name(name: &str) -> String { /// quoting is not used. fn write_aux_element(buf: &mut String, aux: &view_element::Aux) { let name = format_sketch_name(&aux.name); - // shape=8 (has equation), bits=3 (visible, primary) + let (w, h, bits) = match &aux.compat { + Some(c) => (c.width as i32, c.height as i32, c.bits), + None => (40, 20, 3), + }; + // shape=8 (has equation) write!( buf, - "10,{},{},{},{},40,20,8,3,0,0,-1,0,0,0", - aux.uid, name, aux.x as i32, aux.y as i32, + "10,{},{},{},{},{},{},8,{},0,0,-1,0,0,0", + aux.uid, name, aux.x as i32, aux.y as i32, w, h, bits, ) .unwrap(); } @@ -1222,11 +1226,15 @@ fn write_aux_element(buf: &mut String, aux: &view_element::Aux) { /// Write a type 10 line for a Stock element. fn write_stock_element(buf: &mut String, stock: &view_element::Stock) { let name = format_sketch_name(&stock.name); - // shape=3 (box/stock shape), bits=3 (visible, primary) + let (w, h, bits) = match &stock.compat { + Some(c) => (c.width as i32, c.height as i32, c.bits), + None => (40, 20, 3), + }; + // shape=3 (box/stock shape) write!( buf, - "10,{},{},{},{},40,20,3,3,0,0,0,0,0,0", - stock.uid, name, stock.x as i32, stock.y as i32, + "10,{},{},{},{},{},{},3,{},0,0,0,0,0,0", + stock.uid, name, stock.x as i32, stock.y as i32, w, h, bits, ) .unwrap(); } @@ -1324,6 +1332,15 @@ fn write_flow_element( let had_pipes = write_flow_pipe_connectors(buf, flow, valve_uid, cloud_uids, next_connector_uid); + let (valve_w, valve_h, valve_bits) = match &flow.compat { + Some(c) => (c.width as i32, c.height as i32, c.bits), + None => (6, 8, 3), + }; + let (label_w, label_h, label_bits) = match &flow.label_compat { + Some(c) => (c.width as i32, c.height as i32, c.bits), + None => (49, 8, 3), + }; + // Type 11 (valve): field 3 is always 0 in Vensim-generated files. // Prefix with \n only if pipes were emitted (otherwise caller handles separation). if had_pipes { @@ -1331,8 +1348,8 @@ fn write_flow_element( } write!( buf, - "11,{},0,{},{},6,8,34,3,0,0,1,0,0,0", - valve_uid, flow.x as i32, flow.y as i32, + "11,{},0,{},{},{},{},34,{},0,0,1,0,0,0", + valve_uid, flow.x as i32, flow.y as i32, valve_w, valve_h, valve_bits, ) .unwrap(); @@ -1341,8 +1358,8 @@ fn write_flow_element( let var_y = flow.y as i32 + 16; write!( buf, - "\n10,{},{},{},{},49,8,40,3,0,0,-1,0,0,0", - flow.uid, name, flow.x as i32, var_y, + "\n10,{},{},{},{},{},{},40,{},0,0,-1,0,0,0", + flow.uid, name, flow.x as i32, var_y, label_w, label_h, label_bits, ) .unwrap(); } @@ -1448,11 +1465,15 @@ fn write_flow_pipe_connectors( /// Write a type 12 line for a Cloud element. fn write_cloud_element(buf: &mut String, cloud: &view_element::Cloud) { - // Clouds: field 3 is 48 (ASCII '0') in Vensim-generated files, shape=0, bits=3 + let (w, h, bits) = match &cloud.compat { + Some(c) => (c.width as i32, c.height as i32, c.bits), + None => (10, 8, 3), + }; + // Clouds: field 3 is 48 (ASCII '0') in Vensim-generated files, shape=0 write!( buf, - "12,{},48,{},{},10,8,0,3,0,0,-1,0,0,0", - cloud.uid, cloud.x as i32, cloud.y as i32, + "12,{},48,{},{},{},{},0,{},0,0,-1,0,0,0", + cloud.uid, cloud.x as i32, cloud.y as i32, w, h, bits, ) .unwrap(); } @@ -1467,11 +1488,15 @@ fn write_alias_element( .get(&alias.alias_of_uid) .map(|n| format_sketch_name(n)) .unwrap_or_default(); - // shape=8, bits=2 (visible but bit 0 unset = ghost) + let (w, h, bits) = match &alias.compat { + Some(c) => (c.width as i32, c.height as i32, c.bits), + None => (40, 20, 2), + }; + // shape=8 write!( buf, - "10,{},{},{},{},40,20,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|12||128-128-128", - alias.uid, name, alias.x as i32, alias.y as i32, + "10,{},{},{},{},{},{},8,{},0,3,-1,0,0,0,128-128-128,0-0-0,|12||128-128-128", + alias.uid, name, alias.x as i32, alias.y as i32, w, h, bits, ) .unwrap(); } @@ -4960,4 +4985,244 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 "should use default font when font is None: {output}" ); } + + // ---- Task 5: compat dimensions in element output ---- + + #[test] + fn stock_compat_dimensions_emitted() { + let stock = view_element::Stock { + name: "Population".to_string(), + uid: 2, + x: 300.0, + y: 150.0, + label_side: view_element::LabelSide::Top, + compat: Some(view_element::ViewElementCompat { + width: 53.0, + height: 32.0, + bits: 131, + }), + }; + let mut buf = String::new(); + write_stock_element(&mut buf, &stock); + assert!( + buf.contains(",53,32,3,131,"), + "stock with compat should emit preserved dimensions: {buf}" + ); + } + + #[test] + fn stock_default_dimensions_without_compat() { + let stock = view_element::Stock { + name: "Population".to_string(), + uid: 2, + x: 300.0, + y: 150.0, + label_side: view_element::LabelSide::Top, + compat: None, + }; + let mut buf = String::new(); + write_stock_element(&mut buf, &stock); + assert!( + buf.contains(",40,20,3,3,"), + "stock without compat should use default 40,20,3,3: {buf}" + ); + } + + #[test] + fn aux_compat_dimensions_emitted() { + let aux = view_element::Aux { + name: "Rate".to_string(), + uid: 1, + x: 100.0, + y: 200.0, + label_side: view_element::LabelSide::Bottom, + compat: Some(view_element::ViewElementCompat { + width: 45.0, + height: 18.0, + bits: 131, + }), + }; + let mut buf = String::new(); + write_aux_element(&mut buf, &aux); + assert!( + buf.contains(",45,18,8,131,"), + "aux with compat should emit preserved dimensions: {buf}" + ); + } + + #[test] + fn aux_default_dimensions_without_compat() { + let aux = view_element::Aux { + name: "Rate".to_string(), + uid: 1, + x: 100.0, + y: 200.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }; + let mut buf = String::new(); + write_aux_element(&mut buf, &aux); + assert!( + buf.contains(",40,20,8,3,"), + "aux without compat should use default 40,20,8,3: {buf}" + ); + } + + #[test] + fn flow_valve_compat_dimensions_emitted() { + let flow = view_element::Flow { + name: "Birth_Rate".to_string(), + uid: 6, + x: 295.0, + y: 191.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: Some(view_element::ViewElementCompat { + width: 12.0, + height: 18.0, + bits: 131, + }), + label_compat: Some(view_element::ViewElementCompat { + width: 55.0, + height: 14.0, + bits: 35, + }), + }; + let mut buf = String::new(); + let valve_uids = HashMap::from([(6, 100)]); + let mut next_connector_uid = 200; + write_flow_element( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + ); + // Valve line should use flow.compat dimensions + assert!( + buf.contains(",12,18,34,131,"), + "valve with compat should emit preserved dimensions: {buf}" + ); + // Label line should use flow.label_compat dimensions + assert!( + buf.contains(",55,14,40,35,"), + "flow label with label_compat should emit preserved dimensions: {buf}" + ); + } + + #[test] + fn flow_default_dimensions_without_compat() { + let flow = view_element::Flow { + name: "Birth_Rate".to_string(), + uid: 6, + x: 295.0, + y: 191.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }; + let mut buf = String::new(); + let valve_uids = HashMap::from([(6, 100)]); + let mut next_connector_uid = 200; + write_flow_element( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + ); + // Valve line should use default dimensions + assert!( + buf.contains(",6,8,34,3,"), + "valve without compat should use default 6,8,34,3: {buf}" + ); + // Label line should use default dimensions + assert!( + buf.contains(",49,8,40,3,"), + "flow label without label_compat should use default 49,8,40,3: {buf}" + ); + } + + #[test] + fn cloud_compat_dimensions_emitted() { + let cloud = view_element::Cloud { + uid: 7, + flow_uid: 6, + x: 479.0, + y: 235.0, + compat: Some(view_element::ViewElementCompat { + width: 20.0, + height: 14.0, + bits: 131, + }), + }; + let mut buf = String::new(); + write_cloud_element(&mut buf, &cloud); + assert!( + buf.contains(",20,14,0,131,"), + "cloud with compat should emit preserved dimensions: {buf}" + ); + } + + #[test] + fn cloud_default_dimensions_without_compat() { + let cloud = view_element::Cloud { + uid: 7, + flow_uid: 6, + x: 479.0, + y: 235.0, + compat: None, + }; + let mut buf = String::new(); + write_cloud_element(&mut buf, &cloud); + assert!( + buf.contains(",10,8,0,3,"), + "cloud without compat should use default 10,8,0,3: {buf}" + ); + } + + #[test] + fn alias_compat_dimensions_emitted() { + let alias = view_element::Alias { + uid: 10, + alias_of_uid: 1, + x: 200.0, + y: 300.0, + label_side: view_element::LabelSide::Bottom, + compat: Some(view_element::ViewElementCompat { + width: 45.0, + height: 18.0, + bits: 66, + }), + }; + let mut name_map = HashMap::new(); + name_map.insert(1, "Growth_Rate"); + let mut buf = String::new(); + write_alias_element(&mut buf, &alias, &name_map); + assert!( + buf.contains(",45,18,8,66,"), + "alias with compat should emit preserved dimensions: {buf}" + ); + } + + #[test] + fn alias_default_dimensions_without_compat() { + let alias = view_element::Alias { + uid: 10, + alias_of_uid: 1, + x: 200.0, + y: 300.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }; + let mut name_map = HashMap::new(); + name_map.insert(1, "Growth_Rate"); + let mut buf = String::new(); + write_alias_element(&mut buf, &alias, &name_map); + assert!( + buf.contains(",40,20,8,2,"), + "alias without compat should use default 40,20,8,2: {buf}" + ); + } } From 7868bcc30b72a176e5c396d72081cd3688b099c7 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 16:55:56 -0700 Subject: [PATCH 13/31] engine: emit native Vensim lookup call syntax in MDL output When the AST contains a lookup(table, input) call where the first argument is a bare variable, emit it as `table name ( input )` -- the native Vensim syntax -- instead of `LOOKUP(table name, input)`. Falls back to the generic LOOKUP(...) form when the first argument is not a simple variable reference (e.g. a subscripted expression). --- src/simlin-engine/src/mdl/writer.rs | 62 +++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 0acbd657..d3d29482 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -554,6 +554,16 @@ impl Visitor for MdlPrintVisitor { { return kw.to_owned(); } + // Vensim lookup calls use `table_name ( input )` syntax + // rather than `LOOKUP(table_name, input)`. + if func == "lookup" + && args.len() == 2 + && let Expr0::Var(table_ident, _) = &args[0] + { + let table_name = format_mdl_ident(table_ident.as_str()); + let input = self.walk(&args[1]); + return format!("{table_name} ( {input} )"); + } // safediv with 3+ args is XIDZ (3-arg form), not ZIDZ (2-arg) let mdl_name = if func == "safediv" && args.len() >= 3 { "XIDZ".to_owned() @@ -2073,7 +2083,7 @@ fn build_name_map(elements: &[ViewElement]) -> HashMap { #[cfg(test)] mod tests { use super::*; - use crate::ast::{Expr0, Loc}; + use crate::ast::{Expr0, IndexExpr0, Loc}; use crate::common::RawIdent; use crate::datamodel::{ Aux, Compat, Equation, Flow, GraphicalFunction, GraphicalFunctionKind, @@ -2290,8 +2300,9 @@ mod tests { } #[test] - fn function_rename_lookup() { - assert_mdl("lookup(tbl, x)", "LOOKUP(tbl, x)"); + fn lookup_call_native_vensim_syntax() { + // Vensim uses `table ( input )` syntax for lookup calls + assert_mdl("lookup(tbl, x)", "tbl ( x )"); } #[test] @@ -2450,6 +2461,51 @@ mod tests { ); } + // ---- lookup call syntax tests ---- + + #[test] + fn lookup_call_with_spaced_table_name() { + // Multi-word table ident should be space-separated in output + assert_mdl( + "lookup(federal_funds_rate_lookup, time)", + "federal funds rate lookup ( Time )", + ); + } + + #[test] + fn lookup_call_with_expression_input() { + // The input argument can be an arbitrary expression + assert_mdl("lookup(my_table, a + b)", "my table ( a + b )"); + } + + #[test] + fn lookup_non_var_first_arg_falls_through() { + // When the first arg is not a bare variable (e.g. a subscripted + // reference), the generic LOOKUP(...) path is used as a fallback. + let table_sub = Expr0::Subscript( + RawIdent::new_from_str("tbl"), + vec![IndexExpr0::Expr(Expr0::Var( + RawIdent::new_from_str("i"), + Loc::default(), + ))], + Loc::default(), + ); + let input = Expr0::Var(RawIdent::new_from_str("x"), Loc::default()); + let expr = Expr0::App( + UntypedBuiltinFn("lookup".to_owned(), vec![table_sub, input]), + Loc::default(), + ); + let mdl = expr0_to_mdl(&expr); + assert_eq!(mdl, "LOOKUP(tbl[i], x)"); + } + + #[test] + fn non_lookup_function_emits_normally() { + // Other function calls should not be affected by the lookup special-case + assert_mdl("max(a, b)", "MAX(a, b)"); + assert_mdl("min(x, y)", "MIN(x, y)"); + } + // ---- Task 1: Variable entry formatting (scalar) ---- fn make_aux(ident: &str, eqn: &str, units: Option<&str>, doc: &str) -> Variable { From c14b4a29693de32420088967a1822d4f2d360e24 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 17:04:51 -0700 Subject: [PATCH 14/31] engine: use original variable name casing for MDL equation LHS Build a display name map from view elements (Aux, Stock, Flow) that preserves original casing from the MDL source. Variable ident names are canonical (lowercase, underscored), but the MDL format expects the original casing on equation LHS lines. The map is built at the start of write_equations_section and threaded through write_variable_entry and all downstream functions. When no view element matches a variable ident, the fallback format_mdl_ident produces the standard lowercase space-separated form. --- src/simlin-engine/src/mdl/writer.rs | 329 ++++++++++++++++++++++++---- 1 file changed, 286 insertions(+), 43 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index d3d29482..c8fb91dc 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -85,6 +85,45 @@ fn format_mdl_ident(name: &str) -> String { } } +/// Build a mapping from canonical variable ident to display name (with +/// original casing, spaces instead of underscores) by walking view elements. +/// +/// The first occurrence of a name wins, so if a variable appears in multiple +/// views the first view's casing is used. +fn build_display_name_map(views: &[View]) -> HashMap { + let mut map = HashMap::new(); + for view in views { + let View::StockFlow(sf) = view; + for element in &sf.elements { + let name = match element { + ViewElement::Aux(a) => &a.name, + ViewElement::Stock(s) => &s.name, + ViewElement::Flow(f) => &f.name, + _ => continue, + }; + let canonical = crate::common::canonicalize(name).into_owned(); + let display = underbar_to_space(name); + map.entry(canonical).or_insert(display); + } + } + map +} + +/// Look up the display name for a canonical ident, falling back to +/// `format_mdl_ident` if no view element provides original casing. +fn display_name_for_ident(ident: &str, display_names: &HashMap) -> String { + match display_names.get(ident) { + Some(name) => { + if needs_mdl_quoting(name) { + format!("\"{}\"", escape_mdl_quoted_ident(name)) + } else { + name.clone() + } + } + None => format_mdl_ident(ident), + } +} + /// Arrayed element keys encode multidimensional indices as comma-separated /// canonical names (for example `c,a,f`). Preserve tuple structure so MDL /// parsers can split indices, and format each token independently. @@ -752,10 +791,14 @@ fn format_f64(v: f64) -> String { /// ```text /// Name=\n\tequation\n\t~\tunits\n\t~\tcomment\n\t| /// ``` -pub fn write_variable_entry(buf: &mut String, var: &datamodel::Variable) { +pub fn write_variable_entry( + buf: &mut String, + var: &datamodel::Variable, + display_names: &HashMap, +) { match var { datamodel::Variable::Stock(s) => { - write_stock_variable(buf, s); + write_stock_variable(buf, s, display_names); return; } datamodel::Variable::Module(_) => return, @@ -784,13 +827,14 @@ pub fn write_variable_entry(buf: &mut String, var: &datamodel::Variable) { let data_source_eqn = compat_get_direct_equation(compat); let effective_gf = if data_source_eqn.is_some() { None } else { gf }; + let name = display_name_for_ident(ident, display_names); match equation { Equation::Scalar(eqn) => { let effective_eqn = data_source_eqn .clone() .unwrap_or_else(|| wrap_active_initial(eqn, compat)); - write_single_entry(buf, ident, &effective_eqn, &[], units, doc, effective_gf); + write_single_entry(buf, &name, &effective_eqn, &[], units, doc, effective_gf); } Equation::ApplyToAll(dims, eqn) => { let dim_names: Vec<&str> = dims.iter().map(|d| d.as_str()).collect(); @@ -799,7 +843,7 @@ pub fn write_variable_entry(buf: &mut String, var: &datamodel::Variable) { .unwrap_or_else(|| wrap_active_initial(eqn, compat)); write_single_entry( buf, - ident, + &name, &effective_eqn, &dim_names, units, @@ -808,7 +852,7 @@ pub fn write_variable_entry(buf: &mut String, var: &datamodel::Variable) { ); } Equation::Arrayed(dims, elements, default_eq, _) => { - write_arrayed_entries(buf, ident, dims, elements, default_eq, units, doc); + write_arrayed_entries(buf, &name, dims, elements, default_eq, units, doc); } } } @@ -868,7 +912,11 @@ fn compat_get_direct_equation(compat: &datamodel::Compat) -> Option { /// The datamodel stores stocks with the initial value in `equation` and /// inflows/outflows as separate string vectors. The MDL format requires /// `INTEG(net_flow, initial_value)`. -fn write_stock_variable(buf: &mut String, stock: &datamodel::Stock) { +fn write_stock_variable( + buf: &mut String, + stock: &datamodel::Stock, + display_names: &HashMap, +) { let mut net_flow = String::new(); for (i, inflow) in stock.inflows.iter().enumerate() { if i > 0 { @@ -884,10 +932,12 @@ fn write_stock_variable(buf: &mut String, stock: &datamodel::Stock) { net_flow.push('0'); } + let name = display_name_for_ident(&stock.ident, display_names); + match &stock.equation { Equation::Scalar(eqn) => write_stock_entry( buf, - &stock.ident, + &name, &net_flow, &equation_to_mdl(eqn), &[], @@ -898,7 +948,7 @@ fn write_stock_variable(buf: &mut String, stock: &datamodel::Stock) { let dim_names: Vec<&str> = dims.iter().map(|d| d.as_str()).collect(); write_stock_entry( buf, - &stock.ident, + &name, &net_flow, &equation_to_mdl(eqn), &dim_names, @@ -909,7 +959,7 @@ fn write_stock_variable(buf: &mut String, stock: &datamodel::Stock) { Equation::Arrayed(dims, elements, default_eq, _) => { write_arrayed_stock_entries( buf, - &stock.ident, + &name, &net_flow, dims, elements, @@ -929,16 +979,16 @@ fn normalized_stock_initial(initial: &str) -> String { } } +/// `name` is the pre-formatted display name (with original casing). fn write_stock_entry( buf: &mut String, - ident: &str, + name: &str, net_flow: &str, initial: &str, dims: &[&str], units: &Option, doc: &str, ) { - let name = format_mdl_ident(ident); let initial = normalized_stock_initial(initial); if dims.is_empty() { @@ -956,7 +1006,7 @@ fn write_stock_entry( #[allow(clippy::too_many_arguments)] fn write_arrayed_stock_entries( buf: &mut String, - ident: &str, + name: &str, net_flow: &str, _dims: &[String], elements: &[(String, String, Option, Option)], @@ -964,7 +1014,6 @@ fn write_arrayed_stock_entries( units: &Option, doc: &str, ) { - let name = format_mdl_ident(ident); let last_idx = elements.len().saturating_sub(1); for (i, (elem_name, eqn, _comment, _gf)) in elements.iter().enumerate() { @@ -1000,16 +1049,18 @@ fn wrap_active_initial(eqn: &str, compat: &datamodel::Compat) -> String { } /// Write one MDL entry (scalar or apply-to-all). +/// +/// `name` is the pre-formatted display name (with original casing from +/// view elements, or `format_mdl_ident` fallback). fn write_single_entry( buf: &mut String, - ident: &str, + name: &str, eqn: &str, dims: &[&str], units: &Option, doc: &str, gf: Option<&GraphicalFunction>, ) { - let name = format_mdl_ident(ident); let dim_suffix = if dims.is_empty() { String::new() } else { @@ -1055,15 +1106,14 @@ fn write_single_entry( /// element entries and leave omitted elements implicit. fn write_arrayed_entries( buf: &mut String, - ident: &str, + name: &str, _dims: &[String], elements: &[(String, String, Option, Option)], _default_equation: &Option, units: &Option, doc: &str, ) { - let name = format_mdl_ident(ident); - write_arrayed_element_entries(buf, &name, elements, units, doc); + write_arrayed_element_entries(buf, name, elements, units, doc); } fn write_arrayed_element_entries( @@ -1774,6 +1824,8 @@ impl MdlWriter { write_dimension_def(&mut self.buf, dim); } + let display_names = build_display_name_map(&model.views); + // Build a set of variable idents that belong to any group // (skip .Control -- those vars are sim specs emitted separately) let mut grouped_idents: HashSet<&str> = HashSet::new(); @@ -1806,7 +1858,7 @@ impl MdlWriter { .iter() .find(|v| v.get_ident() == member_ident) { - write_variable_entry(&mut self.buf, var); + write_variable_entry(&mut self.buf, var, &display_names); self.buf.push('\n'); } } @@ -1815,7 +1867,7 @@ impl MdlWriter { // 3. Ungrouped variables for var in &model.variables { if !grouped_idents.contains(var.get_ident()) { - write_variable_entry(&mut self.buf, var); + write_variable_entry(&mut self.buf, var, &display_names); self.buf.push('\n'); } } @@ -2549,7 +2601,7 @@ mod tests { fn scalar_aux_entry() { let var = make_aux("characteristic_time", "10", Some("Minutes"), "How long"); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!( buf, "characteristic time=\n\t10\n\t~\tMinutes\n\t~\tHow long\n\t|" @@ -2560,7 +2612,7 @@ mod tests { fn scalar_aux_entry_quotes_special_identifier_name() { let var = make_aux("$_euro", "10", Some("Dmnl"), ""); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!(buf, "\"$ euro\"=\n\t10\n\t~\tDmnl\n\t~\t\n\t|"); } @@ -2568,7 +2620,7 @@ mod tests { fn scalar_aux_no_units() { let var = make_aux("rate", "a + b", None, ""); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!(buf, "rate=\n\ta + b\n\t~\t\n\t~\t\n\t|"); } @@ -2589,7 +2641,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!( buf, "teacup temperature=\n\tINTEG(-heat loss to room, 180)\n\t~\tDegrees Fahrenheit\n\t~\tTemperature of tea\n\t|" @@ -2610,7 +2662,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert!( buf.contains("INTEG(births-deaths, 1000)"), "Expected INTEG with both inflow and outflow: {}", @@ -2632,7 +2684,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert!( buf.contains("inventory[region]=\n\tINTEG(inflow-outflow, 100)"), "ApplyToAll stock should emit arrayed INTEG with initial value: {}", @@ -2662,7 +2714,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert!( buf.contains("inventory[north]=\n\tINTEG(inflow-outflow, 100)"), "First arrayed stock element should retain initial value: {}", @@ -2691,7 +2743,7 @@ mod tests { }, }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert!( buf.contains("ACTIVE INITIAL(y * 2, 100)"), "Expected ACTIVE INITIAL wrapper: {}", @@ -2722,7 +2774,7 @@ mod tests { }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert!( buf.contains("imported constants:="), "GET DIRECT reconstruction should use := for data equations: {buf}" @@ -2751,7 +2803,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!( buf, "effect of x=\n\tWITH LOOKUP(Time, ([(0,0)-(2,1)],(0,0),(1,0.5),(2,1)))\n\t~\t\n\t~\tLookup effect\n\t|" @@ -2781,7 +2833,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); // Standalone lookup: name(\n\tbody) assert_eq!( buf, @@ -2807,7 +2859,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!( buf, "rate a[one dimensional subscript]=\n\t100\n\t~\t\n\t~\t\n\t|" @@ -2830,7 +2882,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!(buf, "matrix a[dim a,dim b]=\n\t0\n\t~\tDmnl\n\t~\t\n\t|"); } @@ -2856,7 +2908,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!( buf, "rate a[entry 1]=\n\t0.01\n\t~~|\nrate a[entry 2]=\n\t0.2\n\t~~|\nrate a[entry 3]=\n\t0.3\n\t~\t\n\t~\t\n\t|" @@ -2884,7 +2936,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); // Underscored element names should appear with spaces assert!(buf.contains("[north america]")); assert!(buf.contains("[south america]")); @@ -2921,7 +2973,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert!( buf.contains("power5[c,a,f]="), @@ -2959,7 +3011,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); // Element "a" has empty equation + gf → standalone lookup assert!(buf.contains("tbl[a](\n\t[(0,0)-(2,1)]")); assert!(buf.contains("tbl[b]=\n\t5")); @@ -3009,7 +3061,7 @@ mod tests { "", ); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); // Data equations use := instead of = assert!(buf.contains("direct data down:="), "expected := in: {buf}"); } @@ -3018,7 +3070,7 @@ mod tests { fn non_data_equation_uses_equals() { let var = make_aux("x", "42", None, ""); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert!(buf.starts_with("x="), "expected = in: {buf}"); } @@ -3055,7 +3107,7 @@ mod tests { "", ); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); // The equation content must preserve underscores in quoted strings assert!( buf.contains("GET DIRECT DATA('data_file.csv',',','A','B2')"), @@ -3100,7 +3152,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); // Flow with equation "TIME" + gf → WITH LOOKUP assert!(buf.contains("flow rate=\n\tWITH LOOKUP(Time, ([(0,0)-(2,1)]")); assert!(buf.contains("~\twidgets/year")); @@ -3120,7 +3172,7 @@ mod tests { compat: Compat::default(), }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); assert!(buf.is_empty()); } @@ -4459,7 +4511,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 }); let mut buf = String::new(); - write_variable_entry(&mut buf, &var); + write_variable_entry(&mut buf, &var, &HashMap::new()); // Must NOT contain dimension-level default (would apply to excepted elements) assert!( @@ -5281,4 +5333,195 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 "alias without compat should use default 40,20,8,2: {buf}" ); } + + // ---- Phase 4 Task 3/4: Equation LHS casing from view element names ---- + + #[test] + fn build_display_name_map_extracts_view_element_names() { + let views = vec![View::StockFlow(StockFlow { + name: None, + elements: vec![ + ViewElement::Aux(view_element::Aux { + name: "Endogenous Federal Funds Rate".to_owned(), + uid: 1, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Stock(view_element::Stock { + name: "Population Level".to_owned(), + uid: 2, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "Birth Rate".to_owned(), + uid: 3, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }), + ], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })]; + let map = build_display_name_map(&views); + assert_eq!( + map.get("endogenous_federal_funds_rate").map(|s| s.as_str()), + Some("Endogenous Federal Funds Rate"), + ); + assert_eq!( + map.get("population_level").map(|s| s.as_str()), + Some("Population Level"), + ); + assert_eq!( + map.get("birth_rate").map(|s| s.as_str()), + Some("Birth Rate"), + ); + } + + #[test] + fn build_display_name_map_first_occurrence_wins() { + // If a name appears in multiple views, the first one wins + let views = vec![View::StockFlow(StockFlow { + name: None, + elements: vec![ + ViewElement::Aux(view_element::Aux { + name: "Growth Rate".to_owned(), + uid: 1, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Aux(view_element::Aux { + name: "growth rate".to_owned(), + uid: 5, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })]; + let map = build_display_name_map(&views); + // The first element's casing wins + assert_eq!( + map.get("growth_rate").map(|s| s.as_str()), + Some("Growth Rate"), + ); + } + + #[test] + fn equation_lhs_uses_view_element_casing() { + let var = make_aux( + "endogenous_federal_funds_rate", + "0.05", + Some("1/Year"), + "Rate var", + ); + let views = vec![View::StockFlow(StockFlow { + name: None, + elements: vec![ViewElement::Aux(view_element::Aux { + name: "Endogenous Federal Funds Rate".to_owned(), + uid: 1, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + })], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })]; + let display_names = build_display_name_map(&views); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &display_names); + assert!( + buf.starts_with("Endogenous Federal Funds Rate="), + "LHS should use view element casing, got: {buf}" + ); + } + + #[test] + fn equation_lhs_fallback_without_view_element() { + let var = make_aux("unmatched_variable", "42", None, ""); + let display_names = HashMap::new(); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &display_names); + assert!( + buf.starts_with("unmatched variable="), + "LHS should fall back to format_mdl_ident when no view element matches, got: {buf}" + ); + } + + #[test] + fn equation_lhs_casing_for_stock() { + let var = Variable::Stock(Stock { + ident: "population_level".to_owned(), + equation: Equation::Scalar("1000".to_owned()), + documentation: String::new(), + units: None, + inflows: vec!["births".to_owned()], + outflows: vec![], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut display_names = HashMap::new(); + display_names.insert("population_level".to_owned(), "Population Level".to_owned()); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &display_names); + assert!( + buf.starts_with("Population Level="), + "Stock LHS should use view element casing, got: {buf}" + ); + } + + #[test] + fn equation_lhs_casing_in_full_project_roundtrip() { + let var = make_aux("growth_rate", "0.05", Some("1/Year"), "Rate"); + let elements = vec![ViewElement::Aux(view_element::Aux { + name: "Growth Rate".to_owned(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + })]; + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var], + views: vec![View::StockFlow(datamodel::StockFlow { + name: None, + elements, + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })], + loop_metadata: vec![], + groups: vec![], + }; + let project = make_project(vec![model]); + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + assert!( + mdl.contains("Growth Rate="), + "Full project MDL should use view element casing on LHS, got: {mdl}" + ); + } } From 004c4cc9543814e2e950bbefb7e9fdbd98537a36 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 17:18:23 -0700 Subject: [PATCH 15/31] engine: inline format for short MDL equations Short equations (name + equation <= 80 chars, single line) now use Vensim's inline format with spaces around the operator: average repayment rate = 0.03 Longer or multiline equations still use the traditional format: name= equation Lookup and graphical function entries always use multiline format regardless of length. --- src/simlin-engine/src/mdl/writer.rs | 122 +++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 21 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index c8fb91dc..423bfb00 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1088,10 +1088,19 @@ fn write_single_entry( } } else { let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; - write!(buf, "{name}{dim_suffix}{assign_op}").unwrap(); let mdl_eqn = equation_to_mdl(eqn); - buf.push_str("\n\t"); - buf.push_str(&mdl_eqn); + + // Short, single-line equations use inline format with spaces around + // the operator (e.g. `average repayment rate = 0.03`). Longer or + // multiline equations use the traditional Vensim multiline format. + let inline_line = format!("{name}{dim_suffix} {assign_op} {mdl_eqn}"); + if inline_line.len() <= 80 && !mdl_eqn.contains('\n') { + buf.push_str(&inline_line); + } else { + write!(buf, "{name}{dim_suffix}{assign_op}").unwrap(); + buf.push_str("\n\t"); + buf.push_str(&mdl_eqn); + } } write_units_and_comment(buf, units, doc); @@ -2604,7 +2613,7 @@ mod tests { write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!( buf, - "characteristic time=\n\t10\n\t~\tMinutes\n\t~\tHow long\n\t|" + "characteristic time = 10\n\t~\tMinutes\n\t~\tHow long\n\t|" ); } @@ -2613,7 +2622,7 @@ mod tests { let var = make_aux("$_euro", "10", Some("Dmnl"), ""); let mut buf = String::new(); write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!(buf, "\"$ euro\"=\n\t10\n\t~\tDmnl\n\t~\t\n\t|"); + assert_eq!(buf, "\"$ euro\" = 10\n\t~\tDmnl\n\t~\t\n\t|"); } #[test] @@ -2621,7 +2630,78 @@ mod tests { let var = make_aux("rate", "a + b", None, ""); let mut buf = String::new(); write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!(buf, "rate=\n\ta + b\n\t~\t\n\t~\t\n\t|"); + assert_eq!(buf, "rate = a + b\n\t~\t\n\t~\t\n\t|"); + } + + // ---- Inline vs multiline equation formatting ---- + + #[test] + fn short_equation_uses_inline_format() { + let var = make_aux("average_repayment_rate", "0.03", Some("1/Year"), ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.starts_with("average repayment rate = 0.03\n"), + "short equation should use inline format: {buf}" + ); + assert!( + !buf.contains("=\n\t0.03"), + "short equation should not use multiline format: {buf}" + ); + } + + #[test] + fn long_equation_uses_multiline_format() { + // Build an equation that, combined with the name, exceeds 80 chars + let long_eqn = "very_long_variable_a + very_long_variable_b + very_long_variable_c + very_long_variable_d"; + let var = make_aux("some_computed_value", long_eqn, None, ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains("some computed value=\n\t"), + "long equation should use multiline format: {buf}" + ); + } + + #[test] + fn lookup_always_uses_multiline_format() { + let gf = make_gf(); + let var = Variable::Aux(Aux { + ident: "x".to_owned(), + equation: Equation::Scalar("TIME".to_owned()), + documentation: String::new(), + units: None, + gf: Some(gf), + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.starts_with("x=\n\t"), + "lookup equation should always use multiline format: {buf}" + ); + } + + #[test] + fn data_equation_uses_data_equals_inline() { + let var = make_aux( + "small_data", + "{GET_DIRECT_DATA('f.csv',',','A','B')}", + None, + "", + ); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains(":="), + "data equation should use := operator: {buf}" + ); + assert!( + buf.contains(" := "), + "short data equation should use inline format with spaces: {buf}" + ); } #[test] @@ -2776,7 +2856,7 @@ mod tests { let mut buf = String::new(); write_variable_entry(&mut buf, &var, &HashMap::new()); assert!( - buf.contains("imported constants:="), + buf.contains(":="), "GET DIRECT reconstruction should use := for data equations: {buf}" ); assert!( @@ -2862,7 +2942,7 @@ mod tests { write_variable_entry(&mut buf, &var, &HashMap::new()); assert_eq!( buf, - "rate a[one dimensional subscript]=\n\t100\n\t~\t\n\t~\t\n\t|" + "rate a[one dimensional subscript] = 100\n\t~\t\n\t~\t\n\t|" ); } @@ -2883,7 +2963,7 @@ mod tests { }); let mut buf = String::new(); write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!(buf, "matrix a[dim a,dim b]=\n\t0\n\t~\tDmnl\n\t~\t\n\t|"); + assert_eq!(buf, "matrix a[dim a,dim b] = 0\n\t~\tDmnl\n\t~\t\n\t|"); } #[test] @@ -3063,7 +3143,7 @@ mod tests { let mut buf = String::new(); write_variable_entry(&mut buf, &var, &HashMap::new()); // Data equations use := instead of = - assert!(buf.contains("direct data down:="), "expected := in: {buf}"); + assert!(buf.contains(":="), "expected := in: {buf}"); } #[test] @@ -3071,7 +3151,7 @@ mod tests { let var = make_aux("x", "42", None, ""); let mut buf = String::new(); write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!(buf.starts_with("x="), "expected = in: {buf}"); + assert!(buf.starts_with("x = "), "expected = in: {buf}"); } #[test] @@ -3256,7 +3336,7 @@ mod tests { "MDL should start with UTF-8 marker, got: {:?}", mdl.lines().next() ); - assert!(mdl.contains("x=")); + assert!(mdl.contains("x = ")); assert!(mdl.contains("\\\\\\---///")); } @@ -3384,7 +3464,7 @@ mod tests { // Variable entries present assert!( - mdl.contains("growth rate="), + mdl.contains("growth rate = "), "should contain growth rate variable" ); assert!( @@ -3405,7 +3485,7 @@ mod tests { ); // Ordering: variables before sim specs, sim specs before terminator - let var_pos = mdl.find("growth rate=").unwrap(); + let var_pos = mdl.find("growth rate = ").unwrap(); let initial_pos = mdl.find("INITIAL TIME").unwrap(); let terminator_pos = mdl.find("\\\\\\---///").unwrap(); assert!( @@ -3455,8 +3535,8 @@ mod tests { ); // Grouped variables come before ungrouped - let rate_a_pos = mdl.find("rate a=").unwrap(); - let ungrouped_pos = mdl.find("ungrouped var=").unwrap(); + let rate_a_pos = mdl.find("rate a = ").unwrap(); + let ungrouped_pos = mdl.find("ungrouped var = ").unwrap(); assert!( rate_a_pos < ungrouped_pos, "grouped variables should come before ungrouped" @@ -3484,7 +3564,7 @@ mod tests { "should contain dimension def" ); let dim_pos = mdl.find("region:").unwrap(); - let var_pos = mdl.find("x=").unwrap(); + let var_pos = mdl.find("x = ").unwrap(); assert!(dim_pos < var_pos, "dimensions should come before variables"); } @@ -4440,7 +4520,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 let mdl = result.unwrap(); // Section 1: Equations -- contains variable entry - assert!(mdl.contains("x="), "should contain equation for x"); + assert!(mdl.contains("x = "), "should contain equation for x"); // Equations terminator assert!( mdl.contains("\\\\\\---/// Sketch information"), @@ -5451,7 +5531,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 let mut buf = String::new(); write_variable_entry(&mut buf, &var, &display_names); assert!( - buf.starts_with("Endogenous Federal Funds Rate="), + buf.starts_with("Endogenous Federal Funds Rate = "), "LHS should use view element casing, got: {buf}" ); } @@ -5463,7 +5543,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 let mut buf = String::new(); write_variable_entry(&mut buf, &var, &display_names); assert!( - buf.starts_with("unmatched variable="), + buf.starts_with("unmatched variable = "), "LHS should fall back to format_mdl_ident when no view element matches, got: {buf}" ); } @@ -5520,7 +5600,7 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 let project = make_project(vec![model]); let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); assert!( - mdl.contains("Growth Rate="), + mdl.contains("Growth Rate = "), "Full project MDL should use view element casing on LHS, got: {mdl}" ); } From 7a0f32f1e2206ee27c11aa259319f509ff441b98 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 17:25:11 -0700 Subject: [PATCH 16/31] engine: backslash line continuations for long MDL equations Add tokenize_for_wrapping() to split MDL equation text at natural break points (after commas, before binary operators, around parens/brackets) and wrap_equation_with_continuations() to insert Vensim-style backslash continuation sequences (\) when lines exceed 80 characters. Applied in both the scalar multiline path and the arrayed element entry path. --- src/simlin-engine/src/mdl/writer.rs | 225 +++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 2 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 423bfb00..9c36e9a6 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1048,6 +1048,128 @@ fn wrap_active_initial(eqn: &str, compat: &datamodel::Compat) -> String { } } +/// Split an MDL equation string into tokens suitable for line wrapping. +/// +/// Tokens preserve the original text exactly -- concatenating them yields +/// the input. The split points are chosen so that line breaks can be +/// inserted *between* tokens at natural boundaries: after commas (with +/// their trailing space), before binary operators, or after open parens. +fn tokenize_for_wrapping(eqn: &str) -> Vec { + let mut tokens: Vec = Vec::new(); + let mut current = String::new(); + let mut chars = eqn.chars().peekable(); + + while let Some(&c) = chars.peek() { + match c { + ',' => { + current.push(chars.next().unwrap()); + // Absorb trailing space after comma so it stays with the comma token + if chars.peek() == Some(&' ') { + current.push(chars.next().unwrap()); + } + tokens.push(std::mem::take(&mut current)); + } + '(' | ')' | '[' | ']' => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + current.push(chars.next().unwrap()); + tokens.push(std::mem::take(&mut current)); + } + '+' | '-' | '*' | '/' | '^' => { + // Emit the accumulated text before the operator so a + // line break can be inserted before the operator. + // But first check if this minus/plus is at the very start + // or follows an operator/open-paren (i.e. is unary). + let is_unary = current.is_empty() + && tokens.last().is_none_or(|t| { + let trimmed = t.trim(); + trimmed.is_empty() + || trimmed.ends_with('(') + || trimmed.ends_with(',') + || trimmed == "+" + || trimmed == "-" + || trimmed == "*" + || trimmed == "/" + || trimmed == "^" + }); + if is_unary { + current.push(chars.next().unwrap()); + } else { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + // Collect the operator and any surrounding spaces as one token + // so "a + b" keeps the " + " together. + current.push(chars.next().unwrap()); + tokens.push(std::mem::take(&mut current)); + } + } + '\'' => { + // Quoted literal -- consume the whole thing as one piece + current.push(chars.next().unwrap()); + while let Some(&ch) = chars.peek() { + current.push(chars.next().unwrap()); + if ch == '\'' { + break; + } + } + } + '"' => { + // Quoted identifier -- consume the whole thing + current.push(chars.next().unwrap()); + while let Some(&ch) = chars.peek() { + current.push(chars.next().unwrap()); + if ch == '"' { + break; + } + } + } + _ => { + current.push(chars.next().unwrap()); + } + } + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +/// Wrap a long equation with backslash-newline continuations in Vensim style. +/// +/// Short equations (fitting within `max_line_len` characters) pass through +/// unchanged. Longer ones are split at token boundaries with `\\\n\t\t` +/// continuation sequences (backslash, newline, two tabs for continuation +/// indent under the single-tab equation indent). +fn wrap_equation_with_continuations(eqn: &str, max_line_len: usize) -> String { + if eqn.len() <= max_line_len { + return eqn.to_string(); + } + + let tokens = tokenize_for_wrapping(eqn); + let mut result = String::new(); + let mut current_line_len: usize = 0; + + for token in &tokens { + // If adding this token would exceed the limit and we already have + // content on the current line, break before it. + if current_line_len + token.len() > max_line_len && current_line_len > 0 { + // Trim trailing whitespace from the current line before the break + let trimmed_end = result.trim_end_matches(' ').len(); + result.truncate(trimmed_end); + result.push_str("\\\n\t\t"); + current_line_len = 0; + } + result.push_str(token); + current_line_len += token.len(); + } + + result +} + /// Write one MDL entry (scalar or apply-to-all). /// /// `name` is the pre-formatted display name (with original casing from @@ -1098,8 +1220,9 @@ fn write_single_entry( buf.push_str(&inline_line); } else { write!(buf, "{name}{dim_suffix}{assign_op}").unwrap(); + let wrapped = wrap_equation_with_continuations(&mdl_eqn, 80); buf.push_str("\n\t"); - buf.push_str(&mdl_eqn); + buf.push_str(&wrapped); } } @@ -1156,8 +1279,9 @@ fn write_arrayed_element_entries( let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; write!(buf, "{name}[{elem_display}]{assign_op}").unwrap(); let mdl_eqn = equation_to_mdl(eqn); + let wrapped = wrap_equation_with_continuations(&mdl_eqn, 80); buf.push_str("\n\t"); - buf.push_str(&mdl_eqn); + buf.push_str(&wrapped); } if i < last_idx { @@ -2704,6 +2828,103 @@ mod tests { ); } + // ---- Backslash line continuation tests ---- + + #[test] + fn wrap_short_equation_unchanged() { + let eqn = "a + b"; + let wrapped = wrap_equation_with_continuations(eqn, 80); + assert_eq!(wrapped, eqn); + assert!( + !wrapped.contains('\\'), + "short equation should not be wrapped: {wrapped}" + ); + } + + #[test] + fn wrap_long_equation_with_continuations() { + // Build an equation >80 chars with multiple terms + let eqn = "very long variable a + very long variable b + very long variable c + very long variable d"; + assert!(eqn.len() > 80, "test equation should exceed 80 chars"); + let wrapped = wrap_equation_with_continuations(eqn, 80); + assert!( + wrapped.contains("\\\n\t\t"), + "long equation should contain continuation: {wrapped}" + ); + // Verify the continuation produces valid content when joined + let rejoined = wrapped.replace("\\\n\t\t", ""); + // The rejoined text should reconstruct the original (modulo trimmed trailing spaces) + assert!( + rejoined.contains("very long variable a"), + "content should be preserved: {rejoined}" + ); + } + + #[test] + fn wrap_equation_breaks_after_comma() { + // A function call with many arguments + let eqn = "IF THEN ELSE(very long condition variable > threshold value, very long true result, very long false result)"; + assert!(eqn.len() > 80); + let wrapped = wrap_equation_with_continuations(eqn, 80); + assert!(wrapped.contains("\\\n\t\t"), "should wrap: {wrapped}"); + // Verify breaks happen at reasonable points (after commas or before operators) + let lines: Vec<&str> = wrapped.split("\\\n\t\t").collect(); + assert!(lines.len() >= 2, "should have at least 2 lines: {wrapped}"); + } + + #[test] + fn long_equation_variable_entry_uses_continuation() { + let long_eqn = "very_long_variable_a + very_long_variable_b + very_long_variable_c + very_long_variable_d"; + let var = make_aux("some_computed_value", long_eqn, None, ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains("some computed value=\n\t"), + "long equation should use multiline format: {buf}" + ); + // The equation body should have a continuation if the MDL form exceeds 80 chars + let mdl_eqn = equation_to_mdl(long_eqn); + if mdl_eqn.len() > 80 { + assert!( + buf.contains("\\\n\t\t"), + "long MDL equation should use backslash continuation: {buf}" + ); + } + } + + #[test] + fn short_equation_variable_entry_no_continuation() { + let var = make_aux("x", "42", None, ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + !buf.contains("\\\n\t\t"), + "short equation should not have continuation: {buf}" + ); + } + + #[test] + fn tokenize_preserves_equation_text() { + let eqn = "IF THEN ELSE(a > b, c + d, e * f)"; + let tokens = tokenize_for_wrapping(eqn); + let rejoined: String = tokens.concat(); + assert_eq!( + rejoined, eqn, + "concatenating tokens should reproduce the original" + ); + } + + #[test] + fn tokenize_splits_at_operators_and_commas() { + let eqn = "a + b, c * d"; + let tokens = tokenize_for_wrapping(eqn); + // Should have splits at +, *, and after comma + assert!( + tokens.len() >= 5, + "expected multiple tokens from operators/commas: {tokens:?}" + ); + } + #[test] fn scalar_stock_integ() { // Real stocks from the MDL reader store only the initial value in From b36a9dee965c280e446f83e6780d65b1b095573d Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 17:30:53 -0700 Subject: [PATCH 17/31] engine: sort ungrouped MDL variables alphabetically Ungrouped variables in MDL output now appear in alphabetical order by canonical ident, matching Vensim's deterministic ordering convention. Grouped variables continue to follow their sector-based ordering from the model groups. --- src/simlin-engine/src/mdl/writer.rs | 90 +++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 9c36e9a6..76e0d75b 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1997,12 +1997,17 @@ impl MdlWriter { } } - // 3. Ungrouped variables - for var in &model.variables { - if !grouped_idents.contains(var.get_ident()) { - write_variable_entry(&mut self.buf, var, &display_names); - self.buf.push('\n'); - } + // 3. Ungrouped variables (alphabetical by ident for deterministic output) + let mut ungrouped: Vec<&datamodel::Variable> = model + .variables + .iter() + .filter(|v| !grouped_idents.contains(v.get_ident())) + .collect(); + ungrouped.sort_by_key(|v| v.get_ident()); + + for var in ungrouped { + write_variable_entry(&mut self.buf, var, &display_names); + self.buf.push('\n'); } // 4. .Control group header + sim spec variables @@ -5825,4 +5830,77 @@ $192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,1 "Full project MDL should use view element casing on LHS, got: {mdl}" ); } + + // ---- Phase 5 Subcomponent C: Variable ordering ---- + + #[test] + fn ungrouped_variables_sorted_alphabetically() { + // Variables inserted in non-alphabetical order: c, a, b + let var_c = make_aux("c_var", "3", None, ""); + let var_a = make_aux("a_var", "1", None, ""); + let var_b = make_aux("b_var", "2", None, ""); + let model = make_model(vec![var_c, var_a, var_b]); + let project = make_project(vec![model]); + + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + + let pos_a = mdl.find("a var = ").expect("should contain a var"); + let pos_b = mdl.find("b var = ").expect("should contain b var"); + let pos_c = mdl.find("c var = ").expect("should contain c var"); + assert!( + pos_a < pos_b && pos_b < pos_c, + "ungrouped variables should appear in alphabetical order: a={pos_a}, b={pos_b}, c={pos_c}" + ); + } + + #[test] + fn grouped_variables_retain_group_order() { + // Group members in a specific order: z, m, a -- should NOT be alphabetized + let var_z = make_aux("z_rate", "10", None, ""); + let var_m = make_aux("m_rate", "20", None, ""); + let var_a = make_aux("a_rate", "30", None, ""); + let var_ungrouped = make_aux("ungrouped_x", "40", None, ""); + + let group = datamodel::ModelGroup { + name: "My Sector".to_owned(), + doc: Some("Sector docs".to_owned()), + parent: None, + members: vec![ + "z_rate".to_owned(), + "m_rate".to_owned(), + "a_rate".to_owned(), + ], + run_enabled: false, + }; + + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var_z, var_m, var_a, var_ungrouped], + views: vec![], + loop_metadata: vec![], + groups: vec![group], + }; + let project = make_project(vec![model]); + + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + + // Grouped vars should appear in group order (z, m, a), not alphabetical + let pos_z = mdl.find("z rate = ").expect("should contain z rate"); + let pos_m = mdl.find("m rate = ").expect("should contain m rate"); + let pos_a = mdl.find("a rate = ").expect("should contain a rate"); + assert!( + pos_z < pos_m && pos_m < pos_a, + "grouped variables should retain group order: z={pos_z}, m={pos_m}, a={pos_a}" + ); + + // Ungrouped variables should come after grouped section + let pos_ungrouped = mdl + .find("ungrouped x = ") + .expect("should contain ungrouped x"); + assert!( + pos_a < pos_ungrouped, + "ungrouped variables should come after grouped: a={pos_a}, ungrouped={pos_ungrouped}" + ); + } } From 2cfe890264bfdf1bc5f92bff7522e5ee1bf52563 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 17:36:34 -0700 Subject: [PATCH 18/31] engine: fix misleading comment on operator tokenization in writer.rs The comment claimed the operator and its surrounding spaces were collected into one token, which misrepresented what the code does. The spaces stay with the adjacent operand tokens; only the bare operator character is emitted as its own token. --- src/simlin-engine/src/mdl/writer.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 76e0d75b..faea6f9c 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1099,8 +1099,7 @@ fn tokenize_for_wrapping(eqn: &str) -> Vec { if !current.is_empty() { tokens.push(std::mem::take(&mut current)); } - // Collect the operator and any surrounding spaces as one token - // so "a + b" keeps the " + " together. + // Emit the operator as its own token so line breaks can be inserted before it. current.push(chars.next().unwrap()); tokens.push(std::mem::take(&mut current)); } From 3106f0f1f12ea84eb57a61ae9ab084509605b23c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 17:47:34 -0700 Subject: [PATCH 19/31] engine: add MDL format roundtrip test for mark2.mdl Adds mdl_format_roundtrip test that roundtrips mark2.mdl through parse/write/re-parse and verifies: - AC1.1: 2 views with names "1 housing" and "2 investments" - AC1.2: Per-view named elements match; valve/cloud counts match; connector counts within tolerance of documented normalizations (Time shadow elements dropped, init-only links not preserved) - AC1.4: Font specification "Verdana|10" preserved per view - AC3.1: Lookup calls use table(input) syntax, not LOOKUP() - AC3.2: Explicit lookup range bounds preserved - AC4.1: Short equations use inline format - AC4.3: Variable name casing preserved on LHS --- src/simlin-engine/tests/mdl_roundtrip.rs | 367 +++++++++++++++++++++++ 1 file changed, 367 insertions(+) diff --git a/src/simlin-engine/tests/mdl_roundtrip.rs b/src/simlin-engine/tests/mdl_roundtrip.rs index d4d05c48..0640d8c7 100644 --- a/src/simlin-engine/tests/mdl_roundtrip.rs +++ b/src/simlin-engine/tests/mdl_roundtrip.rs @@ -2,6 +2,7 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. +use std::collections::HashSet; use std::fs; use std::io::BufReader; @@ -699,3 +700,369 @@ fn mark2_sketch_ordering() { } } } + +// --------------------------------------------------------------------------- +// Task 5: MDL format roundtrip (per-view element fidelity) +// --------------------------------------------------------------------------- + +/// Split the sketch section of an MDL string into per-view segments. +/// Returns a vec of (view_name, element_lines, font_line) tuples. +fn split_sketch_into_views(mdl_text: &str) -> Vec<(&str, Vec<&str>, Option<&str>)> { + let sketch_marker = "\\\\\\---/// Sketch information"; + let mut views = Vec::new(); + + // Find all sketch section starts + let mut search_start = 0; + let mut section_starts = Vec::new(); + while let Some(pos) = mdl_text[search_start..].find(sketch_marker) { + let abs_pos = search_start + pos; + section_starts.push(abs_pos); + search_start = abs_pos + sketch_marker.len(); + } + + for (idx, &start) in section_starts.iter().enumerate() { + let end = if idx + 1 < section_starts.len() { + section_starts[idx + 1] + } else { + // Find the final terminator "///---\\\\\\". + mdl_text[start..] + .find("///---\\\\\\") + .map(|p| start + p) + .unwrap_or(mdl_text.len()) + }; + + let section = &mdl_text[start..end]; + let lines: Vec<&str> = section.lines().collect(); + + // Parse: first line is the marker, second is V300, third is *ViewName, + // fourth is $font, rest are element lines. + let mut view_name = ""; + let mut font_line = None; + let mut element_lines = Vec::new(); + + for line in &lines { + if let Some(name) = line.strip_prefix('*') { + view_name = name; + } else if let Some(font) = line.strip_prefix('$') { + font_line = Some(font); + } else if line.starts_with("10,") + || line.starts_with("11,") + || line.starts_with("12,") + || line.starts_with("1,") + { + element_lines.push(*line); + } + } + + views.push((view_name, element_lines, font_line)); + } + + views +} + +/// Extract the variable name from a type-10 sketch element line. +/// Type-10 lines have format: 10,uid,name,x,y,... +fn extract_element_name(line: &str) -> Option<&str> { + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() > 2 && fields[0] == "10" { + Some(fields[2]) + } else { + None + } +} + +/// Count sketch elements by type within a set of element lines. +fn count_sketch_element_types(lines: &[&str]) -> (usize, usize, usize, usize) { + let mut connectors = 0; + let mut labels = 0; + let mut valves = 0; + let mut clouds = 0; + for line in lines { + if line.starts_with("1,") { + connectors += 1; + } else if line.starts_with("10,") { + labels += 1; + } else if line.starts_with("11,") { + valves += 1; + } else if line.starts_with("12,") { + clouds += 1; + } + } + (connectors, labels, valves, clouds) +} + +/// Verify mark2.mdl format roundtrip: parse, write, and compare the +/// output against the original at the per-view-element level. +/// +/// Checks: +/// - AC1.1: Exactly 2 views with correct names +/// - AC1.2: Per-view element lines match as unordered sets +/// - AC1.4: Font specification preserved per view +/// - AC3.1: Lookup calls use `table ( input )` syntax +/// - AC3.2: Lookup range bounds preserved +/// - AC4.1: Short equations use inline format +/// - AC4.3: Variable name casing preserved +#[test] +fn mdl_format_roundtrip() { + let mut failures: Vec = Vec::new(); + + let file_path = resolve_path("test/bobby/vdf/econ/mark2.mdl"); + let source = fs::read_to_string(&file_path).expect("read mark2.mdl"); + let project = mdl::parse_mdl(&source).expect("parse mark2.mdl"); + let output = mdl::project_to_mdl(&project).expect("write mark2.mdl"); + + // Re-parse the output to confirm it is valid MDL + let _project2 = mdl::parse_mdl(&output).expect("re-parse roundtripped mark2.mdl"); + + // ----------------------------------------------------------------------- + // AC1.1: Exactly 2 views with correct names + // ----------------------------------------------------------------------- + let orig_views = split_sketch_into_views(&source); + let output_views = split_sketch_into_views(&output); + + if output_views.len() != 2 { + failures.push(format!( + "AC1.1: expected 2 views, got {}", + output_views.len() + )); + } else { + // View names should contain the numbered prefix from the original + let expected_names = ["1 housing", "2 investments"]; + for (i, expected) in expected_names.iter().enumerate() { + if !output_views[i].0.contains(expected) { + failures.push(format!( + "AC1.1: view[{i}] name {:?} does not contain {:?}", + output_views[i].0, expected + )); + } + } + } + + // ----------------------------------------------------------------------- + // AC1.2: Per-view elements match as unordered sets + // + // UIDs are renumbered and coordinates may shift during roundtrip, so + // we compare named elements (type 10) by name and non-named elements + // (connectors, valves, clouds) by count. + // ----------------------------------------------------------------------- + if orig_views.len() == output_views.len() { + for (i, (orig, out)) in orig_views.iter().zip(&output_views).enumerate() { + // Compare named elements (type 10) by variable name + let orig_names: HashSet<&str> = orig + .1 + .iter() + .filter_map(|l| extract_element_name(l)) + .collect(); + let out_names: HashSet<&str> = out + .1 + .iter() + .filter_map(|l| extract_element_name(l)) + .collect(); + + let missing_names: Vec<_> = orig_names.difference(&out_names).collect(); + let extra_names: Vec<_> = out_names.difference(&orig_names).collect(); + + // Shadow references to the built-in "Time" variable are not + // preserved during roundtrip (Time is not a model variable). + let missing_non_time: Vec<_> = + missing_names.iter().filter(|n| **n != &"Time").collect(); + if !missing_non_time.is_empty() { + failures.push(format!( + "AC1.2: view[{i}] ({:?}) missing named element(s): {:?}", + orig.0, missing_non_time + )); + } + if !extra_names.is_empty() { + failures.push(format!( + "AC1.2: view[{i}] ({:?}) has extra named element(s): {:?}", + out.0, extra_names + )); + } + + // Compare element type counts + let (orig_conn, orig_lbl, orig_valve, orig_cloud) = count_sketch_element_types(&orig.1); + let (out_conn, out_lbl, out_valve, out_cloud) = count_sketch_element_types(&out.1); + + // Label count may differ by the number of Time shadow elements + let time_shadow_count = orig + .1 + .iter() + .filter(|l| l.starts_with("10,") && extract_element_name(l) == Some("Time")) + .count(); + if orig_lbl - time_shadow_count != out_lbl { + failures.push(format!( + "AC1.2: view[{i}] label count: orig={orig_lbl} (minus {time_shadow_count} Time shadows) \ + vs out={out_lbl}" + )); + } + if orig_valve != out_valve { + failures.push(format!( + "AC1.2: view[{i}] valve count: orig={orig_valve} out={out_valve}" + )); + } + if orig_cloud != out_cloud { + failures.push(format!( + "AC1.2: view[{i}] cloud count: orig={orig_cloud} out={out_cloud}" + )); + } + // Connector counts may differ for documented reasons: + // 1. Shadow references to the built-in Time variable are dropped + // (Time is not a model variable), along with their connectors. + // 2. Init-only links (field 10 = 1, dashed arrows in Vensim) may + // not survive the roundtrip. + // Count the expected dropped connectors from both sources. + let time_uids: HashSet<&str> = orig + .1 + .iter() + .filter(|l| l.starts_with("10,") && extract_element_name(l) == Some("Time")) + .filter_map(|l| l.split(',').nth(1)) + .collect(); + let dropped_time_connectors = orig + .1 + .iter() + .filter(|l| { + if !l.starts_with("1,") { + return false; + } + let fields: Vec<&str> = l.split(',').collect(); + fields.len() > 3 + && (time_uids.contains(fields[2]) || time_uids.contains(fields[3])) + }) + .count(); + let init_only_connectors = orig + .1 + .iter() + .filter(|l| { + if !l.starts_with("1,") { + return false; + } + let fields: Vec<&str> = l.split(',').collect(); + fields.len() > 10 && fields[10] == "1" + }) + .count(); + let expected_dropped = dropped_time_connectors + init_only_connectors; + let conn_diff = (orig_conn as i32 - out_conn as i32).abs(); + if conn_diff > expected_dropped as i32 { + failures.push(format!( + "AC1.2: view[{i}] connector count: orig={orig_conn} out={out_conn} \ + (diff={conn_diff} exceeds expected_dropped={expected_dropped}: \ + time={dropped_time_connectors}, init_only={init_only_connectors})" + )); + } + } + } + + // ----------------------------------------------------------------------- + // AC1.4: Font specification preserved per view + // ----------------------------------------------------------------------- + for (i, (orig, out)) in orig_views.iter().zip(&output_views).enumerate() { + match (orig.2, out.2) { + (Some(orig_font), Some(out_font)) => { + if !out_font.contains("Verdana|10") { + failures.push(format!( + "AC1.4: view[{i}] font does not contain 'Verdana|10': {:?}", + out_font + )); + } + if orig_font != out_font { + failures.push(format!( + "AC1.4: view[{i}] font differs: orig={:?} out={:?}", + orig_font, out_font + )); + } + } + (Some(_), None) => { + failures.push(format!("AC1.4: view[{i}] missing font line in output")); + } + _ => {} + } + } + + // ----------------------------------------------------------------------- + // AC3.1: Lookup calls use `table ( input )` syntax, not LOOKUP() + // ----------------------------------------------------------------------- + // mark2.mdl has: "historical federal funds rate = federal funds rate lookup ( Time )" + if output.contains("LOOKUP(") + && !output.contains("WITH LOOKUP(") + && !output.contains("LOOKUP INVERT(") + { + failures.push( + "AC3.1: output contains bare 'LOOKUP(' which should be table_name(input) syntax" + .to_string(), + ); + } + + // Positive check: the lookup call should use the Vensim table-call syntax + let has_table_call = output.contains("federal funds rate lookup ( Time )") + || output.contains("federal funds rate lookup( Time )") + || output.contains("federal funds rate lookup (Time)") + || output.contains("federal funds rate lookup(Time)"); + if !has_table_call { + // Also accept the pattern where it appears in any casing + let lower_output = output.to_lowercase(); + let has_lower_table_call = lower_output.contains("federal funds rate lookup") + && !lower_output.contains("lookup(federal"); + if !has_lower_table_call { + failures.push( + "AC3.1: output does not contain 'federal funds rate lookup(...)' table call syntax" + .to_string(), + ); + } + } + + // ----------------------------------------------------------------------- + // AC3.2: Lookup range bounds preserved + // ----------------------------------------------------------------------- + // mark2.mdl has lookups with explicit ranges like [(0,0)-(300,10)] + if !output.contains("[(0,0)-(300,10)]") { + failures.push( + "AC3.2: federal funds rate lookup range [(0,0)-(300,10)] not preserved".to_string(), + ); + } + if !output.contains("[(0,0)-(400,10)]") { + failures + .push("AC3.2: inflation rate lookup range [(0,0)-(400,10)] not preserved".to_string()); + } + // hud policy lookup range + if !output.contains("[(108,0)-(800,1)]") { + failures.push("AC3.2: hud policy lookup range [(108,0)-(800,1)] not preserved".to_string()); + } + + // ----------------------------------------------------------------------- + // AC4.1: Short equations use inline format + // ----------------------------------------------------------------------- + // mark2.mdl has: "average repayment rate = 0.03" + if !output.contains("average repayment rate = 0.03") + && !output.contains("average repayment rate= 0.03") + && !output.contains("average repayment rate =0.03") + { + failures.push( + "AC4.1: short equation 'average repayment rate = 0.03' not in inline format" + .to_string(), + ); + } + + // ----------------------------------------------------------------------- + // AC4.3: Variable name casing preserved on LHS + // ----------------------------------------------------------------------- + // mark2.mdl has mixed case: "New Homes On Market", "Endogenous Federal Funds Rate" + let has_original_casing = + output.contains("Endogenous Federal Funds Rate") || output.contains("New Homes On Market"); + if !has_original_casing { + failures.push( + "AC4.3: original variable casing not preserved (expected 'Endogenous Federal Funds Rate' or 'New Homes On Market')" + .to_string(), + ); + } + + // ----------------------------------------------------------------------- + // Report all failures + // ----------------------------------------------------------------------- + if !failures.is_empty() { + panic!( + "{} MDL format roundtrip failure(s):\n{}", + failures.len(), + failures.join("\n") + ); + } +} From d7b285189c7ed1fb297dd5c8c8344a1cf809a2aa Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 17:55:27 -0700 Subject: [PATCH 20/31] doc: update engine context for MDL roundtrip fidelity Document the new ViewElementCompat struct and font field added to datamodel view element types for Vensim MDL roundtrip fidelity. Expand the mdl/ module description to cover writer.rs and the view/ subdir's convert.rs module. --- src/simlin-engine/CLAUDE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/simlin-engine/CLAUDE.md b/src/simlin-engine/CLAUDE.md index 0e037c90..6e9551d3 100644 --- a/src/simlin-engine/CLAUDE.md +++ b/src/simlin-engine/CLAUDE.md @@ -28,7 +28,7 @@ Equation text flows through these stages in order: ## Data model and project structure - **`src/common.rs`** - Error types (`ErrorCode` with 100+ variants), `Result`, identifier types (`RawIdent`, `Ident`, dimension/element name types), canonicalization -- **`src/datamodel.rs`** - Core structures: `Project`, `Model`, `Variable`, `Equation` (including `Arrayed` variant with `default_equation` for EXCEPT semantics and `has_except_default` bool flag), `Dimension` (with `mappings: Vec` replacing the old `maps_to` field, and `parent: Option` for indexed subdimension relationships), `DimensionMapping`, `DataSource`/`DataSourceKind`, `UnitMap` +- **`src/datamodel.rs`** - Core structures: `Project`, `Model`, `Variable`, `Equation` (including `Arrayed` variant with `default_equation` for EXCEPT semantics and `has_except_default` bool flag), `Dimension` (with `mappings: Vec` replacing the old `maps_to` field, and `parent: Option` for indexed subdimension relationships), `DimensionMapping`, `DataSource`/`DataSourceKind`, `UnitMap`. View element types (`Aux`, `Stock`, `Flow`, `Alias`, `Cloud`) carry an optional `ViewElementCompat` with original Vensim sketch dimensions/bits for MDL roundtrip fidelity. `StockFlow` has an optional `font` string for the Vensim default font spec. - **`src/variable.rs`** - Variable variants (`Stock`, `Flow`, `Aux`, `Module`), `ModuleInput`, `Table` (graphical functions). `classify_dependencies()` is the primary API for extracting dependency categories from an AST in a single walk, returning a `DepClassification` with five sets: `all` (every referenced ident), `init_referenced`, `previous_referenced`, `previous_only` (idents only inside PREVIOUS), and `init_only` (idents only inside INIT/PREVIOUS). The older single-purpose functions (`identifier_set`, `init_referenced_idents`, etc.) remain as thin wrappers. `parse_var_with_module_context` accepts a `module_idents` set so `PREVIOUS(module_var)` rewrites through a scalar helper aux instead of `LoadPrev`. - **`src/dimensions.rs`** - `DimensionsContext` for dimension matching, subdimension detection, and element-level mappings. Supports indexed subdimensions via `parent` field (child maps to first N elements of parent). `has_mapping_to()` checks for element-level dimension mappings between two dimensions. `SubdimensionRelation` caches parent-child offset mappings for both named (element containment) and indexed (declared parent) dimensions - **`src/model.rs`** - Model compilation stages (`ModelStage0` -> `ModelStage1` -> `ModuleStage2`), dependency resolution, topological sort. `collect_module_idents` pre-scans datamodel variables to identify which names will expand to modules (preventing incorrect `LoadPrev` compilation). `init_referenced_vars` extends the Initials runlist to include variables referenced by `INIT()` calls, ensuring their values are captured in the `initial_values` snapshot. `check_units` is gated behind `cfg(any(test, feature = "testing"))` (production unit checking uses salsa tracked functions). @@ -53,11 +53,12 @@ The primary compilation path uses salsa tracked functions for fine-grained incre - **`src/compat.rs`** - Top-level format entry points: `open_vensim()`, `open_vensim_with_data()`, `open_xmile()`, `to_xmile()`, `.dat`/CSV loading - **`src/data_provider/`** - `DataProvider` trait for resolving external data references (GET DIRECT DATA/CONSTANTS/LOOKUPS/SUBSCRIPT). `NullDataProvider` (default), `FilesystemDataProvider` (CSV/Excel via calamine; feature-gated on `file_io`) - **`src/xmile/`** - XMILE (XML interchange format) parsing and generation. Submodules: `model.rs`, `variables.rs`, `dimensions.rs`, `views.rs`. Uses `simlin:` vendor-extension elements for features beyond the XMILE spec: `simlin:mapping`/`simlin:elem` for element-level dimension mappings, `simlin:data-source` for external data references, `simlin:except` for EXCEPT equation metadata -- **`src/mdl/`** - Native Rust Vensim MDL parser (replaces C++ xmutil): +- **`src/mdl/`** - Native Rust Vensim MDL parser and writer (replaces C++ xmutil): - `lexer.rs` -> `normalizer.rs` -> `parser.rs` -> `reader.rs` (pipeline) - `ast.rs`, `builtins.rs` (Vensim function recognition) - convert/ subdir - Multi-pass AST to datamodel conversion (includes `external_data.rs` for GET DIRECT resolution via `DataProvider`) - - view/ subdir - Sketch/diagram parsing + - view/ subdir - Sketch/diagram parsing (`elements.rs`, `processing.rs`, `types.rs`, `mod.rs`) and datamodel conversion (`convert.rs`). Captures font specs and element dimensions/bits during parsing for roundtrip fidelity. + - `writer.rs` - MDL output: variable equations (with original casing, native LOOKUP syntax, backslash continuations), sketch sections (view splitting on Group boundaries, element ordering) - `xmile_compat.rs` - Expression formatting for XMILE output - `settings.rs` - Integration settings parser - **`src/vdf.rs`** - Vensim VDF (binary data file) parser. Parses all structural elements (sections, records, name/slot/offset tables, data blocks). Model-guided name-to-OT mapping via `build_section6_guided_ot_map()` uses section-6 OT class codes to identify contiguous stock/non-stock blocks, classifies variables using the parsed model, and assigns OT indices by alphabetical sort within each block. See `docs/design/vdf.md` for the format specification and reverse-engineering history. From 0de40e6961ed8dd19cfb49cf6c0b076d2fb2ed71 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 18:06:37 -0700 Subject: [PATCH 21/31] doc: add test plan for MDL roundtrip fidelity --- .../2026-03-18-mdl-roundtrip-fidelity.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/test-plans/2026-03-18-mdl-roundtrip-fidelity.md diff --git a/docs/test-plans/2026-03-18-mdl-roundtrip-fidelity.md b/docs/test-plans/2026-03-18-mdl-roundtrip-fidelity.md new file mode 100644 index 00000000..bb756434 --- /dev/null +++ b/docs/test-plans/2026-03-18-mdl-roundtrip-fidelity.md @@ -0,0 +1,79 @@ +# MDL Roundtrip Fidelity -- Human Test Plan + +## Prerequisites + +- Vensim (any edition, including PLE) installed and available +- simlin-engine builds successfully: `cargo build -p simlin-engine` +- All automated tests pass: `cargo test -p simlin-engine --test mdl_roundtrip` +- The original model file exists: `test/bobby/vdf/econ/mark2.mdl` +- `simlin-cli` builds: `cargo build -p simlin-cli` + +## Phase 1: Generate the roundtripped MDL file + +| Step | Action | Expected | +|------|--------|----------| +| 1.1 | Run `cargo run -p simlin-cli -- convert --to mdl test/bobby/vdf/econ/mark2.mdl -o /tmp/roundtrip_mark2.mdl` | Command completes without errors. File `/tmp/roundtrip_mark2.mdl` is created. | +| 1.2 | If simlin-cli does not support `convert --to mdl`, run `cargo test -p simlin-engine --test mdl_roundtrip write_mdl_for_vensim_validation -- --ignored --nocapture` and note the output directory. | Test prints an output directory path. Note: you may need to add mark2 to the test's `models_to_export` list. | +| 1.3 | Alternative: add mark2.mdl to `models_to_export` in `write_mdl_for_vensim_validation`, rebuild, and re-run the ignored test. | Roundtripped file is produced at the temp directory path printed to stderr. | + +## Phase 2: Open in Vensim and verify view structure + +| Step | Action | Expected | +|------|--------|----------| +| 2.1 | Open `/tmp/roundtrip_mark2.mdl` in Vensim. | File opens without errors or warnings. No parse errors displayed. | +| 2.2 | Navigate to the view selector (bottom-left in Vensim). | Exactly 2 views are listed: one containing "1 housing" and one containing "2 investments". | +| 2.3 | Select the "1 housing" view. | Diagram renders with stocks, flows, auxiliaries, clouds, and connectors. No blank/empty view. | +| 2.4 | Select the "2 investments" view. | Diagram renders with its own set of elements. No blank/empty view. | + +## Phase 3: Visual comparison of element layout + +| Step | Action | Expected | +|------|--------|----------| +| 3.1 | Open the original `test/bobby/vdf/econ/mark2.mdl` in a separate Vensim instance. | Both original and roundtripped files are open side by side. | +| 3.2 | Compare the "1 housing" view. Check: element positions, stock box sizes, flow valve placement, auxiliary variable positions. | Elements appear in approximately the same positions. Stock boxes should have the same relative size. Flow arrows connect the same stocks. | +| 3.3 | Compare the "2 investments" view in the same manner. | Same visual layout as original. | +| 3.4 | Check connector routing (influence arrows). Verify they connect the correct variables and bend points look reasonable. | Connectors link the correct source/target pairs. Angular routing may differ slightly but connections are correct. | +| 3.5 | Check that label text is readable and not overlapping other elements. | Variable names are legible, not clipped, and do not obscure other diagram elements. | +| 3.6 | Check the font rendering: text should appear in Verdana 10pt (the original font). | Text in the diagram uses Verdana, approximately 10-point size. | + +## Phase 4: Verify simulation equivalence + +| Step | Action | Expected | +|------|--------|----------| +| 4.1 | In Vensim with the roundtripped file open, click "Simulate". | Simulation runs to completion without errors. | +| 4.2 | Plot key variables: "New Homes On Market", "Endogenous Federal Funds Rate", "inflation rate". | Plots show curves that match the expected economic dynamics (no flat lines, no NaN/infinity). | +| 4.3 | In the original file, run the same simulation and plot the same variables. | Identical or nearly identical curves in both files. | +| 4.4 | Use Vensim's "Compare" or "SyntheSim" to overlay results from both runs. | Time series match exactly (or within floating-point tolerance of ~1e-10). | + +## Phase 5: Edge case visual checks + +| Step | Action | Expected | +|------|--------|----------| +| 5.1 | In the roundtripped file, check for any Vensim warnings about undefined variables, missing equations, or broken links. | No warnings. The model should be in a fully valid state. | +| 5.2 | Right-click a variable with mixed-case naming (e.g., "Endogenous Federal Funds Rate") and select "Equation". | The equation editor shows the variable with its original casing, and the equation text is valid. | +| 5.3 | Check a lookup variable (e.g., "federal funds rate lookup") -- right-click and view its lookup table. | The lookup table has correct range bounds (e.g., [(0,0)-(300,10)]) and data points. The graphical function plot matches the original. | +| 5.4 | Verify that a variable referencing the lookup uses native call syntax: open "historical federal funds rate" equation. | Equation reads like `federal funds rate lookup ( Time )`, NOT `LOOKUP(federal funds rate lookup, Time)`. | + +## Traceability + +| Acceptance Criterion | Automated Test | Manual Step | +|----------------------|----------------|-------------| +| AC1.1 -- 2 views with correct names | `split_view_two_groups_produces_two_segments`, `multi_view_mdl_output_contains_view_headers`, `mdl_format_roundtrip` | Phase 2, steps 2.1-2.4 | +| AC1.2 -- correct elements per view | `split_view_elements_partitioned_correctly`, `mdl_format_roundtrip` | Phase 3, steps 3.2-3.3 | +| AC1.3 -- single-view models | `split_view_no_groups_returns_single_segment`, `single_view_no_groups_mdl_output` | N/A (mark2 is multi-view) | +| AC1.4 -- font spec per view | `multi_view_uses_font_when_present`, `test_font_flows_to_stock_flow`, `mdl_format_roundtrip` | Phase 3, step 3.6 | +| AC2.1 -- stock dimensions preserved | `test_stock_compat_preserves_dimensions`, `stock_compat_dimensions_emitted` | Phase 3, step 3.2 | +| AC2.2 -- aux/flow/cloud/alias dimensions | parse + write tests in convert.rs and writer.rs | Phase 3, steps 3.2-3.4 | +| AC2.3 -- defaults without compat | `*_default_dimensions_without_compat` (5 tests) | N/A (mark2 has compat data) | +| AC3.1 -- lookup call syntax | `lookup_call_native_vensim_syntax`, `mdl_format_roundtrip` | Phase 5, step 5.4 | +| AC3.2 -- explicit lookup bounds | `test_graphical_function_y_scale_from_explicit_range`, `mdl_format_roundtrip` | Phase 5, step 5.3 | +| AC3.3 -- computed lookup bounds | `test_graphical_function_y_scale_computed_from_data_when_no_explicit_range` | N/A (mark2 uses explicit bounds) | +| AC4.1 -- inline format | `short_equation_uses_inline_format`, `mdl_format_roundtrip` | Phase 5, step 5.2 | +| AC4.2 -- multiline continuations | `long_equation_uses_multiline_format`, `wrap_long_equation_with_continuations` | N/A (format not visible in Vensim) | +| AC4.3 -- variable casing | `equation_lhs_uses_view_element_casing`, `mdl_format_roundtrip` | Phase 5, step 5.2 | +| AC4.4 -- alphabetical ungrouped order | `ungrouped_variables_sorted_alphabetically` | N/A (internal ordering) | +| AC4.5 -- group-based ordering | `grouped_variables_retain_group_order` | N/A (internal ordering) | +| AC5.1 -- test registered/runnable | Cargo auto-discovery verified | N/A | +| AC5.2 -- format roundtrip test | `mdl_format_roundtrip` | N/A | +| AC5.3 -- regression tests pass | Runtime verification (5 pass, 0 fail) | N/A | +| Design DoD #4 -- Vensim visual compatibility | None (requires Vensim) | Phases 2-5 | From a922eaf83ad06f65771604b987112a6d746c3b90 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 20:00:17 -0700 Subject: [PATCH 22/31] doc: add implementation plan for MDL roundtrip fidelity --- .../phase_01.md | 198 +++++++++++++ .../phase_02.md | 276 ++++++++++++++++++ .../phase_03.md | 240 +++++++++++++++ .../phase_04.md | 196 +++++++++++++ .../phase_05.md | 270 +++++++++++++++++ .../phase_06.md | 142 +++++++++ .../test-requirements.md | 40 +++ 7 files changed, 1362 insertions(+) create mode 100644 docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_01.md create mode 100644 docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_02.md create mode 100644 docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_03.md create mode 100644 docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_04.md create mode 100644 docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_05.md create mode 100644 docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_06.md create mode 100644 docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/test-requirements.md diff --git a/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_01.md b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_01.md new file mode 100644 index 00000000..ee9b2b3b --- /dev/null +++ b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_01.md @@ -0,0 +1,198 @@ +# MDL Roundtrip Fidelity Implementation Plan + +**Goal:** Improve MDL writer fidelity so Vensim .mdl files roundtrip through Simlin with format preserved. + +**Architecture:** Two-layer approach: (1) enrich datamodel with Vensim-specific metadata at parse time, (2) enhance writer to consume that metadata. Changes span datamodel.rs, protobuf schema, serde.rs, MDL parser, and MDL writer. + +**Tech Stack:** Rust, protobuf (prost), cargo + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-18 + +--- + +## Acceptance Criteria Coverage + +This phase is infrastructure. **Verifies: None** -- enables Phases 2-6. + +--- + + + + +### Task 1: Add ViewElementCompat struct, compat fields, and font to datamodel types + +**Files:** +- Modify: `src/simlin-engine/src/datamodel.rs` + +**Implementation:** + +Add the `ViewElementCompat` struct inside the `view_element` module (before the `Aux` struct around line 468): + +```rust +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, PartialEq)] +pub struct ViewElementCompat { + pub width: f64, + pub height: f64, + pub bits: u32, +} +``` + +Note: Use `#[cfg_attr(feature = "debug-derive", derive(Debug))]` (not unconditional `derive(Debug)`) to match the existing pattern on all other view element structs. This avoids bloating WASM binaries with Debug impls. + +Add `pub compat: Option` as the last field on: +- `view_element::Aux` (line 469) +- `view_element::Stock` (line 479) +- `view_element::Cloud` (line 555) +- `view_element::Alias` (line 545) + +For `view_element::Flow` (line 497), add two fields: +- `pub compat: Option` -- valve (type 11) dimensions +- `pub label_compat: Option` -- attached label (type 10) dimensions + +Add `pub font: Option` to `StockFlow` (line 632). + +**Verification:** +Will not compile yet -- construction sites need updating in subsequent tasks. + + + + +### Task 2: Update protobuf schema and regenerate bindings + +**Files:** +- Modify: `src/simlin-engine/src/project_io.proto` + +**Implementation:** + +Add a `ViewElementCompat` message inside the `ViewElement` message (before the `Aux` submessage): + +```protobuf +message ViewElementCompat { + optional double width = 1; + optional double height = 2; + optional uint32 bits = 3; +} +``` + +Add compat fields to each view element submessage using the next available tag number: +- `Aux` (next tag 6): `ViewElementCompat compat = 6;` +- `Stock` (next tag 6): `ViewElementCompat compat = 6;` +- `Flow` (next tag 7): `ViewElementCompat compat = 7;` and `ViewElementCompat label_compat = 8;` +- `Cloud` (next tag 5): `ViewElementCompat compat = 5;` +- `Alias` (next tag 6): `ViewElementCompat compat = 6;` + +Add font to the `View` protobuf message (which maps to `StockFlow` in Rust datamodel) at next tag 9: `optional string font = 9;` + +Regenerate bindings: + +```bash +pnpm build:gen-protobufs +``` + +**Verification:** +Run `cargo check -p simlin-engine` -- proto-generated types should compile. Datamodel construction sites will still error. + + + + +### Task 3: Update serde.rs serialization and deserialization + +**Files:** +- Modify: `src/simlin-engine/src/serde.rs` + +**Implementation:** + +Add helper functions for ViewElementCompat conversion near the existing `compat_to_proto` / `compat_from_proto` helpers (around line 309): + +```rust +fn view_compat_to_proto( + compat: &Option, +) -> Option { + compat.as_ref().map(|c| project_io::view_element::ViewElementCompat { + width: Some(c.width), + height: Some(c.height), + bits: Some(c.bits), + }) +} + +fn view_compat_from_proto( + compat: Option, +) -> Option { + compat.map(|c| view_element::ViewElementCompat { + width: c.width.unwrap_or(0.0), + height: c.height.unwrap_or(0.0), + bits: c.bits.unwrap_or(0), + }) +} +``` + +Update each view element's `From` impl pair. Pattern for Aux (apply same to Stock, Cloud, Alias): + +In `From for view_element::Aux` (line 1187): +- Add: `compat: view_compat_from_proto(a.compat),` + +In `From for project_io::view_element::Aux` (line 1201): +- Add: `compat: view_compat_to_proto(&a.compat),` + +For Flow, handle both `compat` and `label_compat` using the same helpers. + +Update StockFlow/View conversions: +- In `From for View` (around line 1819): add `font: v.font,` +- In `From for project_io::View` (around line 1780): add `font: sf.font.clone(),` + +Update the serde roundtrip test fixtures in the same file to include `compat: None` on view elements and `font: None` on StockFlow constructions (tests at lines 1213, 1255, 1392, 1612, 1677, 1758, 1764, 1839, 1858). + +**Verification:** +Run `cargo check -p simlin-engine` -- serde module should compile. Other construction sites may still error. + + + + +### Task 4: Fix all remaining construction sites + +**Files (compiler-driven -- run `cargo check -p simlin-engine` repeatedly to find each site):** +- `src/simlin-engine/src/json.rs` -- JSON deserialization (lines ~1040-1156) +- `src/simlin-engine/src/xmile/views.rs` -- XMILE parser and tests (~30 sites) +- `src/simlin-engine/src/mdl/view/convert.rs` -- MDL view converter (~6 sites) +- `src/simlin-engine/src/mdl/writer.rs` -- writer test helpers and tests (~20 sites) +- `src/simlin-engine/src/layout/mod.rs` -- layout production code and tests (~17 sites) +- `src/simlin-engine/src/layout/placement.rs` -- placement normalization (~3 sites) +- `src/simlin-engine/src/diagram/flow.rs` -- flow test helpers (~3 sites) +- `src/simlin-engine/src/diagram/elements.rs` -- element tests (~8 sites) +- `src/simlin-engine/src/diagram/connector.rs` -- connector tests (~2 sites) +- `src/simlin-engine/src/diagram/render.rs` -- render tests (~5 sites) +- `src/simlin-engine/src/diagram/render_png.rs` -- render_png tests (~5 sites) +- `src/simlin-engine/src/patch.rs` -- patch tests (~1 site) +- `src/simlin-engine/src/stdlib.gen.rs` -- generated stdlib models (~60 sites, see note) + +**Pattern for all sites:** +- `view_element::Aux`, `Stock`, `Cloud`, `Alias` struct literals: add `compat: None,` +- `view_element::Flow` struct literals: add `compat: None, label_compat: None,` +- `StockFlow` struct literals: add `font: None,` + +**stdlib.gen.rs note:** This file is generated. Check `package.json` scripts or `scripts/` directory for a stdlib generator command (e.g., `build:gen-stdlib`). If a generator exists, update its template to include the new fields, then regenerate. If no generator exists, manually add the fields. + +**Verification:** +Run `cargo check -p simlin-engine` -- should compile with zero errors. + + + + +### Task 5: Verify build and tests pass, commit + +**Verification:** + +```bash +cargo test -p simlin-engine +``` + +All existing tests must pass. No new tests needed for this infrastructure phase. + +**Commit:** `engine: add ViewElementCompat and font fields for MDL roundtrip fidelity` + + + + diff --git a/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_02.md b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_02.md new file mode 100644 index 00000000..f8e5a756 --- /dev/null +++ b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_02.md @@ -0,0 +1,276 @@ +# MDL Roundtrip Fidelity Implementation Plan + +**Goal:** Improve MDL writer fidelity so Vensim .mdl files roundtrip through Simlin with format preserved. + +**Architecture:** Two-layer approach: (1) enrich datamodel with Vensim-specific metadata at parse time, (2) enhance writer to consume that metadata. Changes span datamodel.rs, protobuf schema, serde.rs, MDL parser, and MDL writer. + +**Tech Stack:** Rust, protobuf (prost), cargo + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-18 + +--- + +## Acceptance Criteria Coverage + +This phase captures Vensim-specific metadata during MDL parsing: + +### mdl-roundtrip-fidelity.AC2: Element metadata preservation +- **mdl-roundtrip-fidelity.AC2.1 Success:** Stock elements preserve original width/height/bits (e.g. `53,32,3,131` not hardcoded `40,20,3,3`) +- **mdl-roundtrip-fidelity.AC2.2 Success:** Aux, flow, cloud, and alias elements preserve original dimensions and bits + +### mdl-roundtrip-fidelity.AC3: Lookup fidelity +- **mdl-roundtrip-fidelity.AC3.2 Success:** Explicit lookup range bounds are preserved (e.g. `[(0,0)-(300,10)]` not computed `[(0,0.98)-(300,8.29)]`) + +Note: AC2.1 and AC2.2 are captured here (parse-side) and verified in writer output in Phase 3. + +--- + + + + +### Task 1: Add bits field to parsed view types and store during element parsing + +**Files:** +- Modify: `src/simlin-engine/src/mdl/view/types.rs` +- Modify: `src/simlin-engine/src/mdl/view/elements.rs` + +**Implementation:** + +In `types.rs`, add a `bits` field to the parsed intermediate types: + +`VensimVariable` (line 30): add `pub bits: i32,` +`VensimValve` (around line 45): add `pub bits: i32,` +`VensimComment` (around line 65): add `pub bits: i32,` + +In `elements.rs`, where element lines are parsed: +- For VensimVariable: the `bits` value is already parsed as a local variable (line 125, `let bits = ...`) but only used to derive `is_ghost`. Store the raw value: add `bits` to the VensimVariable construction. +- For VensimValve: the `parse_valve` function (elements.rs, around line 147) currently parses up to `shape` but does NOT parse `bits`. Extend `parse_valve` to also read the `bits` field, which follows `shape` in the type 11 line format (same field position as type 10 lines). Store it on VensimValve. +- For VensimComment: find where cloud element lines are parsed (type 12 lines). Extend parsing to include `bits` and store it. + +Note: Updating these structs will also require updating all test construction sites in `types.rs` tests (3+ sites at lines ~337, ~372, ~405) to include the new `bits` field. Use `cargo check` to find all sites. + +**Verification:** +Run `cargo check -p simlin-engine` -- types module should compile. + + + + +### Task 2: Pass width/height/bits through to view element compat in convert.rs + +**Verifies:** mdl-roundtrip-fidelity.AC2.1, mdl-roundtrip-fidelity.AC2.2 (parse-side) + +**Files:** +- Modify: `src/simlin-engine/src/mdl/view/convert.rs` + +**Implementation:** + +In `convert_variable()` (lines 405-498), where view elements are constructed, create `ViewElementCompat` from the parsed dimensions: + +For Stock (line 470): +```rust +view_element::Stock { + name: ..., + uid: ..., + x: ..., + y: ..., + label_side: LabelSide::Top, + compat: Some(view_element::ViewElementCompat { + width: var.width as f64, + height: var.height as f64, + bits: var.bits as u32, + }), +} +``` + +Apply the same pattern for Aux (line 491) and Alias (line 457). + +For Flow (line 482): the flow element combines a VensimValve (for the valve) and a VensimVariable (for the attached label). Find where the valve data is available and populate: +- `compat` with valve width/height/bits +- `label_compat` with the attached variable's width/height/bits + +For Cloud in `convert_comment_as_cloud()` (line 559): populate compat from VensimComment's width/height/bits. + +**Testing:** +Tests must verify AC2.1 and AC2.2 (parse-side): +- mdl-roundtrip-fidelity.AC2.1: Parse a stock element line with known dimensions (e.g. `10,1,Test Stock,100,50,53,32,3,131,...`), verify resulting view_element::Stock has `compat == Some(ViewElementCompat { width: 53.0, height: 32.0, bits: 131 })` +- mdl-roundtrip-fidelity.AC2.2: Parse an aux, flow, cloud, and alias element, verify each has correct compat values + +Unit tests should go in `src/simlin-engine/src/mdl/view/convert.rs` (existing `#[cfg(test)]` module) or a new test module in the view directory. + +**Verification:** +Run `cargo test -p simlin-engine` -- new tests pass. + +**Commit:** `engine: capture MDL element dimensions in ViewElementCompat during parsing` + + + + +### Task 3: Tests for compat field population + +**Verifies:** mdl-roundtrip-fidelity.AC2.1, mdl-roundtrip-fidelity.AC2.2 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/view/convert.rs` (or appropriate test location) + +**Testing:** +Write unit tests that parse MDL element lines and verify compat fields: +- Stock: parse element line with width=53, height=32, bits=131 -> verify compat matches +- Aux: parse element line with non-default dimensions -> verify compat +- Flow: parse valve + attached label lines -> verify both compat and label_compat +- Cloud: parse cloud element line -> verify compat +- Alias: parse alias element line -> verify compat + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + + + + + +### Task 4: Parse font specification line in MDL view parser + +**Verifies:** mdl-roundtrip-fidelity.AC1.4 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/view/mod.rs` +- Modify: `src/simlin-engine/src/mdl/view/convert.rs` (if font needs to flow through conversion) + +**Implementation:** + +In `mod.rs` (lines 141-146), the font line is currently consumed and discarded: +```rust +// Skip font line if present (we ignore PPI values per xmutil) +if let Some(line) = self.peek_line() + && line.starts_with('$') +{ + self.read_line(); +} +``` + +Change this to capture the font line content (excluding the `$` prefix) and store it. The font string should flow through to `StockFlow.font`. Trace the path from the view parser through the view conversion to where `StockFlow` is constructed: + +- In `convert_view()` (convert.rs line 364) or `merge_views()` (convert.rs line 262), the font string from the first parsed view should be stored in `StockFlow.font`. +- Pass the parsed font string through the `ViewHeader` struct (types.rs, currently holds only `version` and `title`) or as a separate parameter. + +**Testing:** +Tests must verify AC1.4: +- Parse an MDL view section with font line `$192-192-192,0,Verdana|10||0-0-0|...`, verify `StockFlow.font == Some("192-192-192,0,Verdana|10||0-0-0|...")` +- Parse an MDL view without a font line (if possible), verify `StockFlow.font == None` + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: capture MDL font specification during view parsing` + + + + +### Task 5: Test for font parsing + +**Verifies:** mdl-roundtrip-fidelity.AC1.4 + +**Files:** +- Test in: `src/simlin-engine/src/mdl/view/mod.rs` or `convert.rs` test module + +**Testing:** +Write a test that parses a minimal MDL with a font spec line and verifies the font string is preserved in StockFlow.font. The font line format is: +`$192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0` + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + + + + + +### Task 6: Preserve explicit y_range in build_graphical_function + +**Verifies:** mdl-roundtrip-fidelity.AC3.2 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/convert/variables.rs` + +**Implementation:** + +In `build_graphical_function()` (lines 1318-1389), the y_scale is currently always computed from data points (lines 1352-1366). The `table.y_range` is available but ignored. Change to preserve explicit bounds when present, mirroring the existing x_range handling (lines 1337-1350): + +```rust +let y_scale = if let Some(y_range) = table.y_range { + GraphicalFunctionScale { + min: y_range.0, + max: y_range.1, + } +} else { + let y_min = y_vals.iter().cloned().fold(f64::INFINITY, f64::min); + let y_max = y_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let y_max = if (y_min - y_max).abs() < f64::EPSILON { + y_min + 1.0 + } else { + y_max + }; + GraphicalFunctionScale { + min: y_min, + max: y_max, + } +}; +``` + +Update the comment to explain the change: when explicit y_range bounds exist in the MDL source, preserve them for roundtrip fidelity; only compute from data when no explicit bounds are present. + +**Verification:** +Run `cargo check -p simlin-engine` -- compiles. + + + + +### Task 7: Update y_range test + +**Verifies:** mdl-roundtrip-fidelity.AC3.2, mdl-roundtrip-fidelity.AC3.3 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/convert/variables.rs` (test at lines 2763-2794) + +**Implementation:** + +The test `test_graphical_function_y_scale_computed_from_data` currently asserts that y_scale is computed from data points (y_min=0.5, y_max=1.36) even though the MDL input specifies `[(0,0)-(2,5)]` (y_range 0-5). + +Update the test to reflect the new behavior: when explicit y_range is present, it should be used: +- Change assertion on `gf.y_scale.min` from `0.5` to `0.0` (the file-specified value) +- Change assertion on `gf.y_scale.max` from `1.36` to `5.0` (the file-specified value) +- Rename the test to reflect the new semantics (e.g., `test_graphical_function_y_scale_from_explicit_range`) + +Add a second test for AC3.3: a lookup WITHOUT explicit y_range bounds should still compute bounds from data (the existing behavior for XMILE-sourced models). + +**Testing:** +- mdl-roundtrip-fidelity.AC3.2: Lookup with explicit `[(0,0)-(2,5)]` range has `y_scale = {min: 0.0, max: 5.0}` (from file, not data) +- mdl-roundtrip-fidelity.AC3.3: Lookup without explicit range has y_scale computed from actual data points + +**Verification:** +Run `cargo test -p simlin-engine` -- both tests pass. + +**Commit:** `engine: preserve explicit lookup y-range bounds from MDL source` + + + + +### Task 8: Verify all Phase 2 changes + +**Verification:** + +```bash +cargo test -p simlin-engine +``` + +All tests pass, including the new compat, font, and y_range tests. + + + + diff --git a/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_03.md b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_03.md new file mode 100644 index 00000000..b71151cd --- /dev/null +++ b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_03.md @@ -0,0 +1,240 @@ +# MDL Roundtrip Fidelity Implementation Plan + +**Goal:** Improve MDL writer fidelity so Vensim .mdl files roundtrip through Simlin with format preserved. + +**Architecture:** Two-layer approach: (1) enrich datamodel with Vensim-specific metadata at parse time, (2) enhance writer to consume that metadata. Changes span datamodel.rs, protobuf schema, serde.rs, MDL parser, and MDL writer. + +**Tech Stack:** Rust, protobuf (prost), cargo + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-18 + +--- + +## Acceptance Criteria Coverage + +This phase implements the multi-view split and element metadata emission in the MDL writer: + +### mdl-roundtrip-fidelity.AC1: Multi-view MDL output +- **mdl-roundtrip-fidelity.AC1.1 Success:** mark2.mdl roundtrip produces exactly 2 views with names `*1 housing` and `*2 investments` +- **mdl-roundtrip-fidelity.AC1.2 Success:** Each view contains the correct elements -- every element line from the original view appears in the corresponding output view (unordered set comparison) +- **mdl-roundtrip-fidelity.AC1.3 Success:** Single-view models (no ViewElement::Group markers) produce a single view as before +- **mdl-roundtrip-fidelity.AC1.4 Success:** Each view has its own font specification line matching the original + +### mdl-roundtrip-fidelity.AC2: Element metadata preservation +- **mdl-roundtrip-fidelity.AC2.1 Success:** Stock elements preserve original width/height/bits (e.g. `53,32,3,131` not hardcoded `40,20,3,3`) +- **mdl-roundtrip-fidelity.AC2.2 Success:** Aux, flow, cloud, and alias elements preserve original dimensions and bits +- **mdl-roundtrip-fidelity.AC2.3 Success:** Elements without compat data (e.g. from XMILE imports) use hardcoded defaults without error + +--- + + + + +### Task 1: Split merged view into multiple named views based on Group boundaries + +**Verifies:** mdl-roundtrip-fidelity.AC1.1, mdl-roundtrip-fidelity.AC1.2, mdl-roundtrip-fidelity.AC1.3 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` + +**Implementation:** + +The current `write_stock_flow_view()` (lines 1667-1720) emits a single view and skips `ViewElement::Group` elements (line 1715). Replace this with logic that splits on Group boundaries. + +Modify `write_sketch_section()` (lines 1654-1720). The key change: before calling `write_stock_flow_view`, partition the StockFlow's elements into separate view segments using Group elements as delimiters. + +Algorithm: + +```rust +fn write_sketch_section(&mut self, views: &[View]) { + self.buf.push_str("V300 Do not put anything below this section - it will be ignored\r\n"); + for view in views { + let View::StockFlow(sf) = view; + let segments = split_view_on_groups(sf); + for (view_name, elements, font) in &segments { + self.write_view_segment(view_name, elements, font); + } + } + self.buf.push_str("///---\\\\\\\r\n"); +} + +/// Splits a StockFlow's elements into view segments at Group boundaries. +/// Returns Vec of (view_name, elements, font_override). +/// If no Group elements exist, returns a single segment (AC1.3 compatibility). +fn split_view_on_groups(sf: &StockFlow) -> Vec<(String, Vec<&ViewElement>, &Option)> { + let mut segments = Vec::new(); + let mut current_name = sf.name.clone().unwrap_or_else(|| "View 1".to_string()); + let mut current_elements: Vec<&ViewElement> = Vec::new(); + + for element in &sf.elements { + if let ViewElement::Group(group) = element { + if !current_elements.is_empty() { + segments.push((current_name, current_elements, &sf.font)); + current_elements = Vec::new(); + } + current_name = group.name.clone(); + } else if !matches!(element, ViewElement::Module(_)) { + current_elements.push(element); + } + } + if !current_elements.is_empty() { + segments.push((current_name, current_elements, &sf.font)); + } + segments +} +``` + +Note: All sub-views share `&sf.font` because the parser merges multiple MDL views into a single StockFlow. If the original MDL had different fonts per view, only one is preserved. This is a design-level constraint; for mark2.mdl both views use the same font (`Verdana|10`) so this is not an issue for the roundtrip test. + +Verify that `write_sketch_section` and the new `write_view_segment` use CRLF (`\r\n`) consistently with the rest of the writer. Recent commit `646ac509` standardized MDL output to CRLF. + +Then `write_view_segment()` replaces the old `write_stock_flow_view()`, emitting: +1. `*{view_name}\r\n` +2. Font line (see Task 4) +3. All elements via existing per-type write functions +4. View terminator + +**Testing:** +Tests must verify: +- mdl-roundtrip-fidelity.AC1.1: A model with two Group elements produces 2 separate named views +- mdl-roundtrip-fidelity.AC1.2: Elements are partitioned correctly between views +- mdl-roundtrip-fidelity.AC1.3: A model with no Group elements produces a single view (existing behavior preserved) + +Write unit tests in the writer's `#[cfg(test)]` module that construct StockFlow with/without Group elements and verify the split logic. + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: split merged MDL views on Group boundaries in writer` + + + + +### Task 2: Tests for multi-view split + +**Verifies:** mdl-roundtrip-fidelity.AC1.1, mdl-roundtrip-fidelity.AC1.2, mdl-roundtrip-fidelity.AC1.3 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` (test module) + +**Testing:** +- AC1.1: Construct a StockFlow with elements [Group("1 housing"), Aux, Stock, Group("2 investments"), Aux, Flow]. Write to MDL. Assert output contains `*1 housing` and `*2 investments` view headers. +- AC1.2: Assert the elements between `*1 housing` and `*2 investments` are the first batch, and elements after `*2 investments` are the second batch. +- AC1.3: Construct a StockFlow with NO Group elements. Write to MDL. Assert a single view is produced with a default name. + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + + + + + +### Task 3: Use compat metadata for element dimensions in write functions + +**Verifies:** mdl-roundtrip-fidelity.AC2.1, mdl-roundtrip-fidelity.AC2.2, mdl-roundtrip-fidelity.AC2.3 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` + +**Implementation:** + +Update each element writing function to use compat dimensions when present, falling back to hardcoded defaults. + +**`write_stock_element`** (lines 1172-1181): Currently hardcodes `40,20,3,3`. Change to: + +```rust +fn write_stock_element(&mut self, stock: &view_element::Stock, ...) { + let (w, h, bits) = match &stock.compat { + Some(c) => (c.width, c.height, c.bits), + None => (40.0, 20.0, 3), // AC2.3: default for non-MDL sources + }; + write!(self.buf, "10,{uid},{name},{x},{y},{w},{h},3,{bits},0,0,0,0,0,0\r\n", ...).unwrap(); +} +``` + +Apply the same pattern to: +- **`write_aux_element`** (lines 1159-1168): default `(40.0, 20.0, 3)`, shape=8 +- **`write_flow_element`** (lines 1262-1293): valve uses `flow.compat` default `(6.0, 8.0, 3)` shape=34; attached label uses `flow.label_compat` default `(49.0, 8.0, 3)` shape=40 +- **`write_cloud_element`** (lines 1359-1367): default `(10.0, 8.0, 3)`, shape=0 +- **`write_alias_element`** (lines 1370-1387): default `(40.0, 20.0, 2)`, shape=8 + +Each function needs access to the element's compat field, so update signatures to pass the full view element struct (or add the compat as a parameter) if not already available. + +**Testing:** +Tests must verify: +- mdl-roundtrip-fidelity.AC2.1: Stock with `compat = Some(ViewElementCompat { width: 53.0, height: 32.0, bits: 131 })` emits `53,32,3,131` in the element line +- mdl-roundtrip-fidelity.AC2.2: Each element type with compat emits preserved dimensions +- mdl-roundtrip-fidelity.AC2.3: Each element type with `compat = None` emits hardcoded defaults + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: use ViewElementCompat dimensions in MDL element output` + + + + +### Task 4: Use StockFlow.font for font line emission + +**Verifies:** mdl-roundtrip-fidelity.AC1.4 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` + +**Implementation:** + +In the view segment writing function (formerly `write_stock_flow_view`, now `write_view_segment` or equivalent), the font line is currently hardcoded (line 1671): + +```rust +self.buf.push_str("$192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0\r\n"); +``` + +Change to use the font from StockFlow when present: + +```rust +if let Some(font) = font { + write!(self.buf, "${font}\r\n").unwrap(); +} else { + self.buf.push_str("$192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0\r\n"); +} +``` + +**Testing:** +- mdl-roundtrip-fidelity.AC1.4: A StockFlow with `font = Some("192-192-192,0,Verdana|10||...")` emits that font string in the view section. A StockFlow with `font = None` emits the hardcoded default. + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: emit preserved font spec in MDL view output` + + + + +### Task 5: Tests for compat dimensions and font in writer output + +**Verifies:** mdl-roundtrip-fidelity.AC2.1, mdl-roundtrip-fidelity.AC2.2, mdl-roundtrip-fidelity.AC2.3, mdl-roundtrip-fidelity.AC1.4 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` (test module) + +**Testing:** +Write unit tests in the writer's test module: +- Stock element with compat `{width: 53.0, height: 32.0, bits: 131}` produces element line containing `53,32,3,131` +- Stock element with `compat: None` produces element line containing `40,20,3,3` (default) +- Aux element with compat produces correct dimensions +- Flow element: valve dimensions from `compat`, label dimensions from `label_compat` +- Cloud and alias elements with compat produce correct dimensions +- StockFlow with font produces correct `$` font line +- StockFlow without font produces default font line + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + diff --git a/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_04.md b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_04.md new file mode 100644 index 00000000..47028815 --- /dev/null +++ b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_04.md @@ -0,0 +1,196 @@ +# MDL Roundtrip Fidelity Implementation Plan + +**Goal:** Improve MDL writer fidelity so Vensim .mdl files roundtrip through Simlin with format preserved. + +**Architecture:** Two-layer approach: (1) enrich datamodel with Vensim-specific metadata at parse time, (2) enhance writer to consume that metadata. Changes span datamodel.rs, protobuf schema, serde.rs, MDL parser, and MDL writer. + +**Tech Stack:** Rust, protobuf (prost), cargo + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-18 + +--- + +## Acceptance Criteria Coverage + +This phase fixes lookup call syntax and variable name casing: + +### mdl-roundtrip-fidelity.AC3: Lookup fidelity +- **mdl-roundtrip-fidelity.AC3.1 Success:** Lookup invocations emit as `table_name ( input )` not `LOOKUP(table_name, input)` +- **mdl-roundtrip-fidelity.AC3.3 Success:** Lookups without explicit bounds still compute bounds from data (existing behavior for XMILE-sourced models) + +### mdl-roundtrip-fidelity.AC4: Equation formatting +- **mdl-roundtrip-fidelity.AC4.3 Success:** Variable name casing on equation LHS matches original (e.g. `Endogenous Federal Funds Rate=`) + +--- + + + + +### Task 1: Detect LOOKUP calls and emit native Vensim syntax + +**Verifies:** mdl-roundtrip-fidelity.AC3.1 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` + +**Implementation:** + +In `MdlPrintVisitor::walk()` (lines 527-625), function calls are handled in the `Expr0::App(UntypedBuiltinFn(func, args), _)` arm (around line 550). Currently, a `lookup` call is emitted as `LOOKUP(table_name, input)` via the generic function formatting path. + +Note: Verify the internal function name stored in `UntypedBuiltinFn` by checking `builtins.rs`. The code below assumes the internal name is lowercase `"lookup"`. Match against whatever case is used internally (it may be `"lookup"` or `"LOOKUP"`). + +Add special-case detection. Two approaches (pick the one that fits the existing code structure better): + +**Option A: In the `Expr0::App` arm (simpler):** + +```rust +Expr0::App(UntypedBuiltinFn(func, args), _) => { + // Detect lookup calls and emit native Vensim syntax + if func == "lookup" && args.len() == 2 { + if let Expr0::Var(table_ident, _) = &*args[0] { + let table_name = format_mdl_ident(table_ident.as_str()); + let input = self.walk(&args[1]); + return format!("{table_name} ( {input} )"); + } + } + // ... existing generic path ... +} +``` + +**Option B: Add to `recognize_vensim_patterns` chain (lines 492-525):** + +Add a `recognize_lookup_call` function following the existing pattern of `recognize_random_0_1`, `recognize_log_2arg`, etc.: + +```rust +fn recognize_lookup_call( + expr: &Expr0, + walk: &mut dyn FnMut(&Expr0) -> String, +) -> Option { + if let Expr0::App(UntypedBuiltinFn(func, args), _) = expr { + if func == "lookup" && args.len() == 2 { + if let Expr0::Var(table_ident, _) = &*args[0] { + let table_name = format_mdl_ident(table_ident.as_str()); + let input = walk(&args[1]); + return Some(format!("{table_name} ( {input} )")); + } + } + } + None +} +``` + +Then add to the chain in `recognize_vensim_patterns`. + +**Testing:** +- mdl-roundtrip-fidelity.AC3.1: An equation AST containing `lookup(federal_funds_rate_lookup, time)` emits as `federal funds rate lookup ( Time )` not `LOOKUP(federal funds rate lookup, Time)` +- mdl-roundtrip-fidelity.AC3.3: Existing behavior for lookups without explicit bounds is unchanged (this is verified by the y_range tests from Phase 2 continuing to pass) + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: emit native Vensim lookup call syntax in MDL output` + + + + +### Task 2: Tests for lookup syntax emission + +**Verifies:** mdl-roundtrip-fidelity.AC3.1 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` (test module) + +**Testing:** +- Construct an equation AST with a lookup call (Expr0::App with func="lookup", args=[Var("table_name"), Var("time")]) +- Walk it through MdlPrintVisitor +- Assert output is `table name ( Time )` (spaces around parens, space-separated ident) +- Also test the negative case: a regular function call (not "lookup") should still emit normally + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + + + + + +### Task 3: Use view element names for equation LHS casing + +**Verifies:** mdl-roundtrip-fidelity.AC4.3 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` + +**Implementation:** + +Currently in `write_single_entry()` (line 978), the LHS variable name is produced by `format_mdl_ident(ident)` where `ident` is the canonical lowercase form. This produces `federal funds rate` from `federal_funds_rate`, but cannot recover the original casing `Endogenous Federal Funds Rate`. + +The view elements store the original-casing name in their `name` field (e.g., `view_element::Aux { name: "Endogenous Federal Funds Rate", ... }`). Build a mapping from canonical ident to display name from the view elements. + +In `write_equations_section()` (lines 1595-1648) or the parent `write_project()`, build a `HashMap` mapping canonical idents to their view element names: + +```rust +fn build_display_name_map(views: &[View]) -> HashMap { + let mut map = HashMap::new(); + for view in views { + let View::StockFlow(sf) = view; + for element in &sf.elements { + if let Some((ident, name)) = element_ident_and_name(element) { + map.entry(ident).or_insert(name); + } + } + } + map +} +``` + +Where `element_ident_and_name()` extracts the canonical ident (via existing `canonicalize`/`normalize_ident` functions) and the original-casing name from Aux, Stock, Flow elements. + +Then in `write_single_entry()`, look up the display name: + +```rust +let display_name = display_names + .get(ident) + .map(|n| n.as_str()) + .unwrap_or(&format_mdl_ident(ident)); +``` + +Use `display_name` for the equation LHS instead of `format_mdl_ident(ident)`. + +Pass the display name map through to `write_variable_entry` and `write_single_entry` (and `write_stock_entry` for stocks). This requires updating function signatures to accept the map reference. + +**Testing:** +- mdl-roundtrip-fidelity.AC4.3: A variable with canonical ident `endogenous_federal_funds_rate` and view element name `Endogenous Federal Funds Rate` emits as `Endogenous Federal Funds Rate=` on the equation LHS +- Fallback: a variable with no matching view element uses `format_mdl_ident` (lowercase, space-separated) + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: use original variable name casing for MDL equation LHS` + + + + +### Task 4: Tests for equation LHS casing + +**Verifies:** mdl-roundtrip-fidelity.AC4.3 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` (test module) + +**Testing:** +- Build a model with a variable `endogenous_federal_funds_rate` and a view element named `Endogenous Federal Funds Rate` +- Write to MDL +- Assert the equation line starts with `Endogenous Federal Funds Rate=` (preserving the view element's casing) +- Test a variable without a matching view element: should use the canonical format_mdl_ident output + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + diff --git a/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_05.md b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_05.md new file mode 100644 index 00000000..a28b6ccc --- /dev/null +++ b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_05.md @@ -0,0 +1,270 @@ +# MDL Roundtrip Fidelity Implementation Plan + +**Goal:** Improve MDL writer fidelity so Vensim .mdl files roundtrip through Simlin with format preserved. + +**Architecture:** Two-layer approach: (1) enrich datamodel with Vensim-specific metadata at parse time, (2) enhance writer to consume that metadata. Changes span datamodel.rs, protobuf schema, serde.rs, MDL parser, and MDL writer. + +**Tech Stack:** Rust, protobuf (prost), cargo + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-18 + +--- + +## Acceptance Criteria Coverage + +This phase implements equation formatting conventions: + +### mdl-roundtrip-fidelity.AC4: Equation formatting +- **mdl-roundtrip-fidelity.AC4.1 Success:** Short equations use inline format with spaces around `=` (e.g. `average repayment rate = 0.03`) +- **mdl-roundtrip-fidelity.AC4.2 Success:** Long equations use multiline format with backslash line continuations +- **mdl-roundtrip-fidelity.AC4.4 Success:** Ungrouped variables are ordered deterministically (alphabetically by ident) +- **mdl-roundtrip-fidelity.AC4.5 Success:** Grouped variables retain sector-based ordering + +--- + + + + +### Task 1: Inline format for short equations + +**Verifies:** mdl-roundtrip-fidelity.AC4.1 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` + +**Implementation:** + +Currently `write_single_entry()` (lines 978-1008) always writes equations in multiline format: +``` +name= +\teqn +\t~\tunits +\t~\tcomment +\t| +``` + +Add an inline path for short equations. After computing the full equation text, check if the combined `name = equation` fits inline (under ~80 chars, no embedded newlines): + +```rust +fn write_single_entry( + buf: &mut String, + display_name: &str, + ident: &str, + eqn: &str, + dims: &[&str], + units: &Option, + doc: &str, + gf: Option<&GraphicalFunction>, +) { + let assign_op = if is_data_equation(eqn) { ":=" } else { "=" }; + + if gf.is_some() { + // Lookup tables always use multiline format + write_multiline_entry(buf, display_name, assign_op, dims, eqn, units, doc, gf); + return; + } + + let mdl_eqn = equation_to_mdl(eqn); + let dim_suffix = if dims.is_empty() { + String::new() + } else { + let dim_strs: Vec = dims.iter().map(|d| format_mdl_ident(d)).collect(); + format!("[{}]", dim_strs.join(",")) + }; + + let inline_line = format!("{display_name}{dim_suffix} {assign_op} {mdl_eqn}"); + + if inline_line.len() <= 80 && !mdl_eqn.contains('\n') { + // Inline format: name = equation + buf.push_str(&inline_line); + buf.push_str("\r\n"); + write_units_and_comment(buf, units, doc); + } else { + // Multiline format: name=\n\tequation (existing behavior) + write_multiline_entry(buf, display_name, assign_op, dims, &mdl_eqn, units, doc, None); + } +} +``` + +Extract the current multiline writing into a helper `write_multiline_entry()` to avoid duplication. + +The inline format uses spaces around `=`: `average repayment rate = 0.03`. The multiline format uses no spaces: `Complex Variable Name=`. + +**Testing:** +- mdl-roundtrip-fidelity.AC4.1: A variable with equation `0.03` and name `average repayment rate` emits as `average repayment rate = 0.03\r\n\t~\t...\r\n\t~\t...\r\n\t|` + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: inline format for short MDL equations` + + + + +### Task 2: Tests for inline formatting + +**Verifies:** mdl-roundtrip-fidelity.AC4.1 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` (test module) + +**Testing:** +- Short equation (e.g. `0.03`, name `average repayment rate`): assert inline format `average repayment rate = 0.03` +- Long equation (>80 chars): assert multiline format (name=\n\tequation) +- Equation with graphical function (lookup): assert always multiline +- Data equation: assert uses `:=` operator + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + + + + + +### Task 3: Backslash line continuations for long equations + +**Verifies:** mdl-roundtrip-fidelity.AC4.2 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` + +**Implementation:** + +For multiline equations exceeding ~80 characters per line, Vensim uses backslash continuation: `\\\r\n\t\t` (backslash, CRLF, two tabs for continuation indent). + +In the multiline equation writing path, after computing the MDL equation text, wrap long lines: + +```rust +fn wrap_equation_with_continuations(eqn: &str, max_line_len: usize) -> String { + // If the equation fits in one line, return as-is + if eqn.len() <= max_line_len { + return eqn.to_string(); + } + + let mut result = String::new(); + let mut current_line = String::new(); + + // Break at reasonable points: after commas, before operators + for token in tokenize_for_wrapping(eqn) { + if current_line.len() + token.len() > max_line_len && !current_line.is_empty() { + result.push_str(current_line.trim_end()); + result.push_str("\\\r\n\t\t"); + current_line.clear(); + } + current_line.push_str(&token); + } + if !current_line.is_empty() { + result.push_str(¤t_line); + } + result +} +``` + +The `tokenize_for_wrapping` function should split the equation into tokens at natural break points while preserving the full text. Tokens should be: identifiers/numbers (contiguous alphanumeric + spaces), operators (`+`, `-`, `*`, `/`, `^`), parenthesized groups or individual parens, commas with trailing space, and whitespace. Break preferentially after commas and before binary operators. The exact tokenization depends on the equation text format produced by `equation_to_mdl`. + +Apply this wrapping in `write_multiline_entry()` when emitting the equation body. + +**Testing:** +- mdl-roundtrip-fidelity.AC4.2: A long equation (>80 chars) is wrapped with `\\\r\n\t\t` at reasonable break points +- Short equations are NOT wrapped (no spurious continuations) + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: backslash line continuations for long MDL equations` + + + + +### Task 4: Tests for backslash continuations + +**Verifies:** mdl-roundtrip-fidelity.AC4.2 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` (test module) + +**Testing:** +- Long equation with multiple terms: assert output contains `\\\r\n\t\t` continuation +- Short equation: assert output does NOT contain backslash continuation +- Equation with commas (e.g., function call with many args): assert break happens after comma + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + + + + + +### Task 5: Sort ungrouped variables alphabetically + +**Verifies:** mdl-roundtrip-fidelity.AC4.4, mdl-roundtrip-fidelity.AC4.5 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` + +**Implementation:** + +In `write_equations_section()` (lines 1595-1648), ungrouped variables are currently emitted in `model.variables` iteration order (parser insertion order, lines 1633-1639): + +```rust +for var in &model.variables { + if !grouped_idents.contains(var.get_ident()) { + write_variable_entry(&mut self.buf, var); + self.buf.push('\n'); + } +} +``` + +Change to sort ungrouped variables alphabetically by canonical ident: + +```rust +let mut ungrouped: Vec<&Variable> = model.variables.iter() + .filter(|v| !grouped_idents.contains(v.get_ident())) + .collect(); +ungrouped.sort_by_key(|v| v.get_ident()); + +for var in ungrouped { + write_variable_entry(&mut self.buf, var, &display_names); + self.buf.push_str("\r\n"); +} +``` + +Grouped variables (emitted via `model.groups` iteration, lines 1621-1632) already follow sector-based ordering from the parser, satisfying AC4.5. No change needed for grouped variable ordering. + +**Testing:** +- mdl-roundtrip-fidelity.AC4.4: A model with ungrouped variables [c, a, b] emits them in order [a, b, c] +- mdl-roundtrip-fidelity.AC4.5: Grouped variables retain their sector ordering (existing behavior, verify not broken) + +**Verification:** +Run `cargo test -p simlin-engine` -- tests pass. + +**Commit:** `engine: sort ungrouped MDL variables alphabetically` + + + + +### Task 6: Tests for variable ordering + +**Verifies:** mdl-roundtrip-fidelity.AC4.4, mdl-roundtrip-fidelity.AC4.5 + +**Files:** +- Modify: `src/simlin-engine/src/mdl/writer.rs` (test module) + +**Testing:** +- Create a model with 3 ungrouped variables with idents [c, a, b]. Write to MDL. Assert they appear in alphabetical order [a, b, c] in the output. +- Create a model with grouped variables in a specific group order. Write to MDL. Assert grouped variables appear in group order, not alphabetical. + +**Verification:** +Run `cargo test -p simlin-engine` -- all tests pass. + + + + diff --git a/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_06.md b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_06.md new file mode 100644 index 00000000..4fd41ec2 --- /dev/null +++ b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/phase_06.md @@ -0,0 +1,142 @@ +# MDL Roundtrip Fidelity Implementation Plan + +**Goal:** Improve MDL writer fidelity so Vensim .mdl files roundtrip through Simlin with format preserved. + +**Architecture:** Two-layer approach: (1) enrich datamodel with Vensim-specific metadata at parse time, (2) enhance writer to consume that metadata. Changes span datamodel.rs, protobuf schema, serde.rs, MDL parser, and MDL writer. + +**Tech Stack:** Rust, protobuf (prost), cargo + +**Scope:** 6 phases from original design (phases 1-6) + +**Codebase verified:** 2026-03-18 + +--- + +## Acceptance Criteria Coverage + +This phase adds the comprehensive integration test: + +### mdl-roundtrip-fidelity.AC5: Test coverage +- **mdl-roundtrip-fidelity.AC5.1 Success:** `mdl_roundtrip` test is registered in Cargo.toml and runs with `cargo test` +- **mdl-roundtrip-fidelity.AC5.2 Success:** Format test roundtrips mark2.mdl and asserts per-view element lines match as unordered sets (with only documented normalizations) +- **mdl-roundtrip-fidelity.AC5.3 Success:** Existing roundtrip and simulation tests continue to pass + +Also provides end-to-end verification for all previously implemented ACs: + +### mdl-roundtrip-fidelity.AC1: Multi-view MDL output +- **mdl-roundtrip-fidelity.AC1.1 Success:** mark2.mdl roundtrip produces exactly 2 views with names `*1 housing` and `*2 investments` +- **mdl-roundtrip-fidelity.AC1.2 Success:** Each view contains the correct elements -- every element line from the original view appears in the corresponding output view (unordered set comparison) +- **mdl-roundtrip-fidelity.AC1.4 Success:** Each view has its own font specification line matching the original + +### mdl-roundtrip-fidelity.AC3: Lookup fidelity +- **mdl-roundtrip-fidelity.AC3.1 Success:** Lookup invocations emit as `table_name ( input )` not `LOOKUP(table_name, input)` +- **mdl-roundtrip-fidelity.AC3.2 Success:** Explicit lookup range bounds are preserved + +### mdl-roundtrip-fidelity.AC4: Equation formatting +- **mdl-roundtrip-fidelity.AC4.1 Success:** Short equations use inline format +- **mdl-roundtrip-fidelity.AC4.3 Success:** Variable name casing on equation LHS matches original + +--- + + + + +### Task 1: Verify mdl_roundtrip test registration + +**Verifies:** mdl-roundtrip-fidelity.AC5.1 + +**Files:** +- Read: `src/simlin-engine/Cargo.toml` + +**Implementation:** + +The `mdl_roundtrip` test is already registered in `Cargo.toml` as a `[[test]]` entry (confirmed by codebase investigation). The test file is at `src/simlin-engine/tests/mdl_roundtrip.rs`. + +No changes needed for registration. The new test function will be added to the existing test file. + +**Verification:** + +```bash +cargo test -p simlin-engine --test mdl_roundtrip -- --list +``` + +Should list the existing test functions. AC5.1 is already satisfied. + + + + +### Task 2: Write format roundtrip test for mark2.mdl + +**Verifies:** mdl-roundtrip-fidelity.AC5.2, mdl-roundtrip-fidelity.AC1.1, mdl-roundtrip-fidelity.AC1.2, mdl-roundtrip-fidelity.AC1.4, mdl-roundtrip-fidelity.AC3.1, mdl-roundtrip-fidelity.AC3.2, mdl-roundtrip-fidelity.AC4.1, mdl-roundtrip-fidelity.AC4.3 + +**Files:** +- Modify: `src/simlin-engine/tests/mdl_roundtrip.rs` + +**Implementation:** + +Add a new test function `mdl_format_roundtrip` (or similar) that: + +1. Reads `test/bobby/vdf/econ/mark2.mdl` using `resolve_path("test/bobby/vdf/econ/mark2.mdl")` +2. Parses via `mdl::parse_mdl(&source)` +3. Writes back via `mdl::project_to_mdl(&project)` +4. Parses the original and output into comparable structures +5. Asserts structural equivalence + +**Assertions to make:** + +**View structure (AC1.1):** +- Split output on view header pattern (`*N name`) -- assert exactly 2 views +- Assert view names are `1 housing` and `2 investments` + +**Per-view element matching (AC1.2):** +- Extract element lines from each view section (lines between view header and next header/terminator) +- Parse each element line into its fields +- Compare as unordered sets against the original mark2.mdl view sections +- Document any normalization applied (e.g., floating-point rounding of x/y coordinates) + +**Font lines (AC1.4):** +- Assert each view section contains the font line matching `Verdana|10` + +**Equation section spot-checks:** +- AC3.1: Assert the output contains `federal funds rate lookup ( Time )` (or equivalent lookup call from mark2.mdl) and does NOT contain `LOOKUP(` +- AC3.2: Assert lookup definitions preserve explicit bounds (e.g., `[(0,0)-(300,10)]` not recomputed) +- AC4.1: Assert at least one short equation uses inline format (e.g., `average repayment rate = 0.03`) +- AC4.3: Assert at least one variable has original casing (search for capitalized variable name from mark2.mdl) + +**Testing approach:** +Follow the existing test pattern in mdl_roundtrip.rs: collect failures into a `Vec` and panic at the end with all failures. Use helper functions for parsing views and extracting element lines. + +**Verification:** + +```bash +cargo test -p simlin-engine --test mdl_roundtrip mdl_format_roundtrip +``` + +Test passes with all assertions. + +**Commit:** `engine: add MDL format roundtrip test for mark2.mdl` + + + + +### Task 3: Verify all tests pass + +**Verifies:** mdl-roundtrip-fidelity.AC5.3 + +**Verification:** + +```bash +cargo test -p simlin-engine +``` + +All existing roundtrip tests (`mdl_to_mdl_roundtrip`, `xmile_to_mdl_roundtrip`) and simulation tests continue to pass alongside the new format test. + +If any existing tests fail due to the formatting changes (e.g., the semantic roundtrip now produces differently-formatted but semantically equivalent MDL), investigate and fix: +- If the semantic equivalence check fails because of casing differences in equations: the semantic comparison should already normalize casing. If not, update the comparison. +- If existing tests expected specific MDL formatting patterns: update the expected patterns to match the new formatting. + +**Commit:** `engine: verify all MDL roundtrip tests pass with format improvements` + + + + diff --git a/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/test-requirements.md b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/test-requirements.md new file mode 100644 index 00000000..90464f5f --- /dev/null +++ b/docs/implementation-plans/2026-03-18-mdl-roundtrip-fidelity/test-requirements.md @@ -0,0 +1,40 @@ +# Test Requirements: MDL Roundtrip Fidelity + +## Automated Tests + +| AC | Criterion | Test Type | Test Location | Phase | +|---|---|---|---|---| +| AC1.1 | mark2.mdl roundtrip produces exactly 2 views with names `*1 housing` and `*2 investments` | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- construct StockFlow with two Group elements, write to MDL, assert two view headers with correct names | 3 | +| AC1.1 | mark2.mdl roundtrip produces exactly 2 views (end-to-end) | integration | `src/simlin-engine/tests/mdl_roundtrip.rs` -- parse mark2.mdl, write back, split output on `*N name` pattern, assert exactly 2 views with names `1 housing` and `2 investments` | 6 | +| AC1.2 | Each view contains the correct elements (unordered set comparison) | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- construct StockFlow with `[Group("1 housing"), Aux, Stock, Group("2 investments"), Aux, Flow]`, write to MDL, assert elements partition correctly between views | 3 | +| AC1.2 | Each view contains the correct elements (end-to-end) | integration | `src/simlin-engine/tests/mdl_roundtrip.rs` -- extract element lines from each view section of roundtripped mark2.mdl, compare as unordered sets against original | 6 | +| AC1.3 | Single-view models produce a single view as before | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- construct StockFlow with NO Group elements, write to MDL, assert single view with default name | 3 | +| AC1.4 | Each view has its own font specification line matching the original | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- StockFlow with `font = Some("192-192-192,0,Verdana|10||...")` emits that font; StockFlow with `font = None` emits hardcoded default | 3 | +| AC1.4 | Font spec preserved through parse-write roundtrip | unit | `src/simlin-engine/src/mdl/view/mod.rs` or `convert.rs` (test module) -- parse MDL view with font line `$192-192-192,0,Verdana|10||0-0-0|...`, verify `StockFlow.font` preserves it; parse MDL without font line, verify `StockFlow.font == None` | 2 | +| AC1.4 | Font line matches original in roundtripped mark2.mdl | integration | `src/simlin-engine/tests/mdl_roundtrip.rs` -- assert each view section contains font line matching `Verdana|10` | 6 | +| AC2.1 | Stock elements preserve original width/height/bits | unit (parse) | `src/simlin-engine/src/mdl/view/convert.rs` (test module) -- parse stock element line with width=53, height=32, bits=131, verify `compat == Some(ViewElementCompat { width: 53.0, height: 32.0, bits: 131 })` | 2 | +| AC2.1 | Stock elements emit preserved dimensions in writer | unit (write) | `src/simlin-engine/src/mdl/writer.rs` (test module) -- stock with `compat = Some(ViewElementCompat { width: 53.0, height: 32.0, bits: 131 })` emits element line containing `53,32,3,131` | 3 | +| AC2.2 | Aux, flow, cloud, and alias elements preserve original dimensions and bits | unit (parse) | `src/simlin-engine/src/mdl/view/convert.rs` (test module) -- parse each element type with non-default dimensions, verify compat fields match | 2 | +| AC2.2 | Aux, flow, cloud, and alias elements emit preserved dimensions in writer | unit (write) | `src/simlin-engine/src/mdl/writer.rs` (test module) -- each element type with compat emits preserved dimensions; flow tests both `compat` (valve) and `label_compat` (attached label) | 3 | +| AC2.3 | Elements without compat data use hardcoded defaults without error | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- each element type with `compat: None` emits hardcoded defaults (stock: `40,20,3,3`; aux: `40,20,8,3`; flow valve: `6,8,34,3`; cloud: `10,8,0,3`; alias: `40,20,8,2`) | 3 | +| AC3.1 | Lookup invocations emit as `table_name ( input )` not `LOOKUP(table_name, input)` | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- construct AST with lookup call, walk through MdlPrintVisitor, assert output is `table name ( Time )` | 4 | +| AC3.1 | Lookup syntax correct in roundtripped mark2.mdl | integration | `src/simlin-engine/tests/mdl_roundtrip.rs` -- assert output contains native lookup call syntax and does NOT contain `LOOKUP(` | 6 | +| AC3.2 | Explicit lookup range bounds are preserved | unit | `src/simlin-engine/src/mdl/convert/variables.rs` (test module) -- lookup with explicit `[(0,0)-(2,5)]` range has `y_scale = {min: 0.0, max: 5.0}` (from file, not computed from data) | 2 | +| AC3.2 | Lookup bounds preserved in roundtripped mark2.mdl | integration | `src/simlin-engine/tests/mdl_roundtrip.rs` -- assert lookup definitions preserve explicit bounds | 6 | +| AC3.3 | Lookups without explicit bounds still compute bounds from data | unit | `src/simlin-engine/src/mdl/convert/variables.rs` (test module) -- lookup WITHOUT explicit y_range has y_scale computed from actual data points | 2 | +| AC4.1 | Short equations use inline format with spaces around `=` | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- variable with equation `0.03` and name `average repayment rate` emits `average repayment rate = 0.03` | 5 | +| AC4.1 | Inline format present in roundtripped mark2.mdl | integration | `src/simlin-engine/tests/mdl_roundtrip.rs` -- assert at least one short equation uses inline format | 6 | +| AC4.2 | Long equations use multiline format with backslash line continuations | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- long equation (>80 chars) contains `\\\r\n\t\t` continuation; short equations do NOT | 5 | +| AC4.3 | Variable name casing on equation LHS matches original | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- variable with canonical ident and view element name emits original casing on LHS | 4 | +| AC4.3 | Variable casing correct in roundtripped mark2.mdl | integration | `src/simlin-engine/tests/mdl_roundtrip.rs` -- assert at least one variable has original casing | 6 | +| AC4.4 | Ungrouped variables are ordered deterministically (alphabetically by ident) | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- model with ungrouped variables [c, a, b] emits in order [a, b, c] | 5 | +| AC4.5 | Grouped variables retain sector-based ordering | unit | `src/simlin-engine/src/mdl/writer.rs` (test module) -- grouped variables appear in group order, not alphabetical | 5 | +| AC5.1 | `mdl_roundtrip` test is registered in Cargo.toml and runs with `cargo test` | infrastructure | `src/simlin-engine/Cargo.toml` -- verify `[[test]] name = "mdl_roundtrip"` entry exists | 6 | +| AC5.2 | Format test roundtrips mark2.mdl and asserts per-view element lines match as unordered sets | integration | `src/simlin-engine/tests/mdl_roundtrip.rs` -- `mdl_format_roundtrip` test | 6 | +| AC5.3 | Existing roundtrip and simulation tests continue to pass | regression | `cargo test -p simlin-engine` -- all existing tests pass alongside new format tests | 6 | + +## Human Verification + +| AC | Criterion | Verification Approach | Justification | +|---|---|---|---| +| Design DoD #4 | Vensim compatibility: Roundtripped mark2.mdl is openable by Vensim with diagrams that look the same as the original | 1. Run `simlin convert --to mdl` on `test/bobby/vdf/econ/mark2.mdl` to produce roundtripped output. 2. Open the roundtripped MDL file in Vensim. 3. Visually compare the `1 housing` and `2 investments` views against the original. 4. Verify: (a) both views present and selectable, (b) element positions/sizes/connections look the same, (c) labels readable and positioned correctly, (d) no Vensim warnings on open, (e) simulation runs and produces same results. | Vensim's MDL parser has undocumented tolerances and rendering heuristics that cannot be captured by automated field-by-field comparison. Visual equivalence in the actual target application is the authoritative test. | From 160dff37419bb1e5e49d72c199d5e61d6888ccdc Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 20:41:26 -0700 Subject: [PATCH 23/31] engine: fix MDL name escaping, aux format, and improve roundtrip test Fix escape_mdl_quoted_ident to handle newline characters in variable names. Literal newlines now become the two-character \n escape, and existing \n sequences (backslash + n from XMILE name attributes) pass through without double-escaping. This fixes the "Missing matching quote" error when converting XMILE models with multi-line variable names (e.g. fishbanks). Fix split_view_on_groups to emit empty segments for consecutive Group markers, preventing silent loss of empty views in multi-view MDL files. Add clarifying comment on is_lookup_only_equation explaining the sentinel values and their MDL parser origin. Strengthen the mdl_format_roundtrip test with per-element field-level comparison (AC1.3): for each type-10 element matched by name, compare width, height, and bits fields between original and roundtripped output. Shape comparison is skipped because Vensim allows displaying any variable type with any shape, and cross-view duplicate shapes are excluded since they become aliases during view composition. --- src/simlin-engine/src/mdl/writer.rs | 49 ++++++++++++++-- src/simlin-engine/tests/mdl_roundtrip.rs | 75 ++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index faea6f9c..8b9f5afe 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -61,9 +61,20 @@ fn needs_mdl_quoting(name: &str) -> bool { fn escape_mdl_quoted_ident(name: &str) -> String { let mut escaped = String::with_capacity(name.len()); - for c in name.chars() { + let mut chars = name.chars().peekable(); + while let Some(c) = chars.next() { match c { - '\\' => escaped.push_str("\\\\"), + // Literal newlines must become the two-character escape `\n`. + '\n' => escaped.push_str("\\n"), + '\\' => { + if chars.peek() == Some(&'n') { + // Already the two-character sequence `\n` — keep as-is. + escaped.push_str("\\n"); + chars.next(); + } else { + escaped.push_str("\\\\"); + } + } '"' => escaped.push_str("\\\""), _ => escaped.push(c), } @@ -765,6 +776,12 @@ fn write_lookup(buf: &mut String, gf: &GraphicalFunction) { /// Returns true when the equation text is a placeholder sentinel rather /// than a real input expression (standalone lookup definition). +/// +/// The MDL parser produces these sentinels when a variable is defined as +/// a pure lookup (no input expression). Vensim's native representation +/// is `name(body)` rather than `name = WITH LOOKUP(input, body)`. The +/// sentinels `""`, `"0"`, and `"0+0"` are the values the parser emits +/// for each of the known lookup-only forms. fn is_lookup_only_equation(eqn: &str) -> bool { let trimmed = eqn.trim(); trimmed.is_empty() || trimmed == "0+0" || trimmed == "0" @@ -1850,18 +1867,23 @@ fn split_view_on_groups<'a>( let mut current_name = sf.name.clone().unwrap_or_else(|| "View 1".to_string()); let mut current_elements: Vec<&'a ViewElement> = Vec::new(); + let mut seen_group = false; for element in &sf.elements { if let ViewElement::Group(group) = element { - if !current_elements.is_empty() { + // Push the previous segment. Skip the initial pre-Group segment + // only if it has no elements (no content before the first Group). + if seen_group || !current_elements.is_empty() { segments.push((current_name, current_elements, sf.font.clone())); current_elements = Vec::new(); } + seen_group = true; current_name = group.name.clone(); } else if !matches!(element, ViewElement::Module(_)) { current_elements.push(element); } } - if !current_elements.is_empty() { + // Push the final segment (may be empty for trailing Groups). + if seen_group || !current_elements.is_empty() { segments.push((current_name, current_elements, sf.font.clone())); } segments @@ -2339,6 +2361,25 @@ mod tests { assert_eq!(format_mdl_ident(r#"it"s_a_test"#), r#""it\"s a test""#,); } + #[test] + fn quoted_identifiers_handle_newlines() { + // Literal newline chars become the two-character escape \n + assert_eq!( + escape_mdl_quoted_ident("Maximum\nfishery size"), + r"Maximum\nfishery size" + ); + // Already-escaped \n (two chars: backslash + n) stays as-is + assert_eq!( + escape_mdl_quoted_ident(r"Maximum\nfishery size"), + r"Maximum\nfishery size" + ); + // Full round through format_mdl_ident: name with literal newline + assert_eq!( + format_mdl_ident("Maximum\nfishery_size"), + r#""Maximum\nfishery size""# + ); + } + #[test] fn needs_mdl_quoting_edge_cases() { assert!(needs_mdl_quoting("")); diff --git a/src/simlin-engine/tests/mdl_roundtrip.rs b/src/simlin-engine/tests/mdl_roundtrip.rs index 0640d8c7..2e891ccf 100644 --- a/src/simlin-engine/tests/mdl_roundtrip.rs +++ b/src/simlin-engine/tests/mdl_roundtrip.rs @@ -952,6 +952,81 @@ fn mdl_format_roundtrip() { } } + // ----------------------------------------------------------------------- + // AC1.3: Per-element field-level fidelity + // + // For each type-10 (named) element matched by name between original + // and output, compare dimension and shape fields. Skip uid (field 1), + // coordinates (fields 3,4), and fields that depend on display state + // we don't yet preserve (field 9 = init-link flag, field 11 = varies + // by element type, fields 14+ = ghost color/font). + // ----------------------------------------------------------------------- + if orig_views.len() == output_views.len() { + for (i, (orig, out)) in orig_views.iter().zip(&output_views).enumerate() { + fn build_name_fields<'a>( + lines: &[&'a str], + ) -> std::collections::HashMap<&'a str, Vec<&'a str>> { + let mut map = std::collections::HashMap::new(); + for line in lines { + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() > 2 && fields[0] == "10" { + map.insert(fields[2], fields); + } + } + map + } + + let orig_fields = build_name_fields(&orig.1); + let out_fields = build_name_fields(&out.1); + + // Fields to compare: w(5), h(6), bits(8). + // Shape (field 7) is excluded because Vensim allows displaying + // any variable type with any shape (e.g. an aux as a stock box). + // Our converter classifies variable type from the equation, not + // the sketch shape, so non-stock variables displayed as boxes + // (shape=3) will roundtrip as shape=8. + let compare_indices = [5, 6, 8]; + + // Elements that appear in multiple views are converted to + // aliases during view composition. Their shape changes from + // stock(3) to aux(8) and is not preserved. Collect names that + // appear in OTHER views so we can exclude them from shape checks. + let mut cross_view_names: HashSet<&str> = HashSet::new(); + for (j, other) in output_views.iter().enumerate() { + if j != i { + for line in &other.1 { + if let Some(n) = extract_element_name(line) { + cross_view_names.insert(n); + } + } + } + } + + for (name, orig_f) in &orig_fields { + if *name == "Time" { + continue; + } + let is_cross_view = cross_view_names.contains(name); + if let Some(out_f) = out_fields.get(name) { + for &idx in &compare_indices { + // Skip shape comparison for cross-view duplicates + // (they become aliases with shape=8). + if idx == 7 && is_cross_view { + continue; + } + if idx < orig_f.len() && idx < out_f.len() && orig_f[idx] != out_f[idx] { + failures.push(format!( + "AC1.3: view[{i}] element {:?} field[{idx}] \ + orig={:?} out={:?}", + name, orig_f[idx], out_f[idx] + )); + } + } + } + } + } + } + // ----------------------------------------------------------------------- // AC1.4: Font specification preserved per view // ----------------------------------------------------------------------- From 54c1a729f5f18114e8bc8e5e9e09cedc1fa9681b Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 21:00:23 -0700 Subject: [PATCH 24/31] engine: fix lookup sentinel and proto compat deserialization Remove "0" from is_lookup_only_equation sentinels. The MDL parser produces "0+0" for standalone lookups and the actual input expression for WITH LOOKUP. A real equation like WITH LOOKUP(0, body) would produce equation="0", which was incorrectly treated as a standalone lookup sentinel and roundtripped as name(body) instead of preserving the input expression. Fix view_compat_from_proto to return None when the proto message has all default field values, preventing zero-dimension sketch elements when deserializing old protos that lack compat data. Clarify escape_mdl_quoted_ident comment explaining why backslash+n sequences from XMILE name attributes are preserved as-is. --- src/simlin-engine/src/mdl/writer.rs | 17 ++++++++++------- src/simlin-engine/src/serde.rs | 15 +++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 8b9f5afe..63fe3bc5 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -68,7 +68,9 @@ fn escape_mdl_quoted_ident(name: &str) -> String { '\n' => escaped.push_str("\\n"), '\\' => { if chars.peek() == Some(&'n') { - // Already the two-character sequence `\n` — keep as-is. + // XMILE name attributes may contain the literal two-char + // sequence `\n` (backslash + 'n') as a display newline. + // Preserve it as-is rather than double-escaping to `\\n`. escaped.push_str("\\n"); chars.next(); } else { @@ -777,14 +779,15 @@ fn write_lookup(buf: &mut String, gf: &GraphicalFunction) { /// Returns true when the equation text is a placeholder sentinel rather /// than a real input expression (standalone lookup definition). /// -/// The MDL parser produces these sentinels when a variable is defined as -/// a pure lookup (no input expression). Vensim's native representation -/// is `name(body)` rather than `name = WITH LOOKUP(input, body)`. The -/// sentinels `""`, `"0"`, and `"0+0"` are the values the parser emits -/// for each of the known lookup-only forms. +/// The MDL parser produces the sentinel `"0+0"` when a variable is +/// defined as a pure lookup (no input expression) -- see +/// `MdlEquation::Lookup` in `convert/variables.rs`. Vensim's native +/// representation is `name(body)` rather than `name = WITH LOOKUP(input, +/// body)`. An empty string covers XMILE variables that have a graphical +/// function but no equation. fn is_lookup_only_equation(eqn: &str) -> bool { let trimmed = eqn.trim(); - trimmed.is_empty() || trimmed == "0+0" || trimmed == "0" + trimmed.is_empty() || trimmed == "0+0" } /// Format f64 for MDL output: omit trailing `.0` for whole numbers. diff --git a/src/simlin-engine/src/serde.rs b/src/simlin-engine/src/serde.rs index ee303a48..3dcf1c86 100644 --- a/src/simlin-engine/src/serde.rs +++ b/src/simlin-engine/src/serde.rs @@ -1197,10 +1197,17 @@ fn view_compat_to_proto( fn view_compat_from_proto( compat: Option, ) -> Option { - compat.map(|c| view_element::ViewElementCompat { - width: c.width.unwrap_or(0.0), - height: c.height.unwrap_or(0.0), - bits: c.bits.unwrap_or(0), + compat.and_then(|c| { + // Only produce Some if at least one field was explicitly set, + // otherwise treat a default-valued proto message as absent. + if c.width.is_none() && c.height.is_none() && c.bits.is_none() { + return None; + } + Some(view_element::ViewElementCompat { + width: c.width.unwrap_or(0.0), + height: c.height.unwrap_or(0.0), + bits: c.bits.unwrap_or(0), + }) }) } From ded3cc203b3819dc363b669cd97520af0c8f35e5 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 21:19:01 -0700 Subject: [PATCH 25/31] engine: preserve original view titles in Group elements Pass the original MDL view title to create_sector_group instead of the normalized title. View names go through make_view_names_unique during parsing (replacing special characters, deduplicating), but Group elements should carry the original title so the MDL writer can reproduce it in the sketch section header. --- src/simlin-engine/src/mdl/view/convert.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/simlin-engine/src/mdl/view/convert.rs b/src/simlin-engine/src/mdl/view/convert.rs index 2e599ce8..45d56087 100644 --- a/src/simlin-engine/src/mdl/view/convert.rs +++ b/src/simlin-engine/src/mdl/view/convert.rs @@ -306,7 +306,7 @@ fn convert_view( // If multi-view, add a group element for this view if is_multi_view { - let group = create_sector_group(view, uid_offset, start_x, start_y); + let group = create_sector_group(view, original_title, uid_offset, start_x, start_y); elements.push(group); } @@ -762,6 +762,7 @@ fn calculate_link_shape( /// datamodel::view_element::Group expects CENTER coordinates. fn create_sector_group( view: &VensimView, + original_title: &str, uid_offset: i32, start_x: i32, start_y: i32, @@ -780,7 +781,7 @@ fn create_sector_group( // Convert to center coordinates for datamodel ViewElement::Group(view_element::Group { uid: uid_offset, - name: view.title().to_string(), + name: original_title.to_string(), x: top_left_x + width / 2.0, y: top_left_y + height / 2.0, width, @@ -911,7 +912,7 @@ mod tests { fn test_create_sector_group() { let view = create_test_view(); // Typical starting position: x=100, y=100 - let group = create_sector_group(&view, 0, 100, 100); + let group = create_sector_group(&view, view.title(), 0, 100, 100); if let ViewElement::Group(g) = group { assert_eq!(g.name, "Test View"); From 975baf0e88bacd31c628dc79e45fb1863e52152b Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 18 Mar 2026 21:36:03 -0700 Subject: [PATCH 26/31] engine: use case-insensitive comparison for Control group The control group skip logic matched only "Control" and "control" but Vensim allows other casings (e.g. ".CONTROL"). Use eq_ignore_ascii_case to handle all variants and prevent duplicate control sections in the output. --- src/simlin-engine/src/mdl/writer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 63fe3bc5..ef105d49 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1987,7 +1987,7 @@ impl MdlWriter { // (skip .Control -- those vars are sim specs emitted separately) let mut grouped_idents: HashSet<&str> = HashSet::new(); for group in &model.groups { - if group.name == "Control" || group.name == "control" { + if group.name.eq_ignore_ascii_case("Control") { continue; } for member in &group.members { @@ -1997,7 +1997,7 @@ impl MdlWriter { // 2. Variables in group order (skip .Control -- emitted with sim specs) for group in &model.groups { - if group.name == "Control" || group.name == "control" { + if group.name.eq_ignore_ascii_case("Control") { continue; } // Group marker From 1a7b0e5ae0ed0566679bff1ed9ae4111fe7ff185 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Thu, 19 Mar 2026 06:09:31 -0700 Subject: [PATCH 27/31] engine: address review feedback for MDL roundtrip fidelity Gate view splitting on is_mdl_view_marker flag so XMILE organizational groups are not incorrectly split into separate views during MDL export. Groups created during MDL multi-view merge set this flag; XMILE groups leave it false. Emit a minimal valid sketch section when a model has no views, preventing malformed MDL output for equation-only models. Extract the lookup sentinel "0+0" into a named constant LOOKUP_SENTINEL shared between the MDL parser and writer to reduce coupling on a magic string. Split writer_tests.rs out of writer.rs to stay under the 6000-line lint threshold. --- src/simlin-engine/src/datamodel.rs | 5 + src/simlin-engine/src/diagram/elements.rs | 2 + src/simlin-engine/src/json.rs | 6 + .../src/mdl/convert/variables.rs | 5 +- src/simlin-engine/src/mdl/mod.rs | 6 + src/simlin-engine/src/mdl/view/convert.rs | 1 + src/simlin-engine/src/mdl/writer.rs | 3720 +--------------- src/simlin-engine/src/mdl/writer_tests.rs | 3741 +++++++++++++++++ src/simlin-engine/src/project_io.gen.rs | 4 +- src/simlin-engine/src/project_io.proto | 1 + src/simlin-engine/src/serde.rs | 3 + src/simlin-engine/src/xmile/views.rs | 2 + 12 files changed, 3820 insertions(+), 3676 deletions(-) create mode 100644 src/simlin-engine/src/mdl/writer_tests.rs diff --git a/src/simlin-engine/src/datamodel.rs b/src/simlin-engine/src/datamodel.rs index 855d5329..550f5250 100644 --- a/src/simlin-engine/src/datamodel.rs +++ b/src/simlin-engine/src/datamodel.rs @@ -590,6 +590,11 @@ pub mod view_element { pub y: f64, pub width: f64, pub height: f64, + /// When true, this Group was synthesized during MDL multi-view merge + /// to mark a view boundary. The MDL writer splits on these markers + /// to reconstruct the original per-view structure. XMILE-sourced + /// groups (organizational containers) leave this `false`. + pub is_mdl_view_marker: bool, } } diff --git a/src/simlin-engine/src/diagram/elements.rs b/src/simlin-engine/src/diagram/elements.rs index 435663ae..ca6a56fc 100644 --- a/src/simlin-engine/src/diagram/elements.rs +++ b/src/simlin-engine/src/diagram/elements.rs @@ -442,6 +442,7 @@ mod tests { y: 200.0, width: 300.0, height: 200.0, + is_mdl_view_marker: false, }; let svg = render_group(&element); assert!(svg.contains("simlin-group")); @@ -458,6 +459,7 @@ mod tests { y: 200.0, width: 300.0, height: 200.0, + is_mdl_view_marker: false, }; let bounds = group_bounds(&element); assert_eq!(bounds.left, 50.0); diff --git a/src/simlin-engine/src/json.rs b/src/simlin-engine/src/json.rs index d0d3a850..e18c4810 100644 --- a/src/simlin-engine/src/json.rs +++ b/src/simlin-engine/src/json.rs @@ -414,6 +414,8 @@ pub struct GroupViewElement { pub y: f64, pub width: f64, pub height: f64, + #[serde(default, skip_serializing_if = "is_false")] + pub is_mdl_view_marker: bool, } #[cfg_attr(feature = "debug-derive", derive(Debug))] @@ -1141,6 +1143,7 @@ impl From for datamodel::ViewElement { y: g.y + g.height / 2.0, width: g.width, height: g.height, + is_mdl_view_marker: g.is_mdl_view_marker, }) } } @@ -1784,6 +1787,7 @@ impl From for ViewElement { y: g.y - g.height / 2.0, width: g.width, height: g.height, + is_mdl_view_marker: g.is_mdl_view_marker, }) } } @@ -2558,6 +2562,7 @@ mod tests { y: 0.0, width: 100.0, height: 80.0, + is_mdl_view_marker: false, }), ), ]; @@ -2614,6 +2619,7 @@ mod tests { y: 100.0, // top-left y width: 200.0, height: 150.0, + is_mdl_view_marker: false, }); // Roundtrip through datamodel and back diff --git a/src/simlin-engine/src/mdl/convert/variables.rs b/src/simlin-engine/src/mdl/convert/variables.rs index 183cd26e..11278a0e 100644 --- a/src/simlin-engine/src/mdl/convert/variables.rs +++ b/src/simlin-engine/src/mdl/convert/variables.rs @@ -11,6 +11,7 @@ use crate::datamodel::{ GraphicalFunctionScale, Model, ModelGroup, Project, Variable, View, }; +use crate::mdl::LOOKUP_SENTINEL; use crate::mdl::view; use crate::mdl::ast::{CallKind, Equation as MdlEquation, Expr, FullEquation, Lhs, Subscript}; @@ -619,7 +620,7 @@ impl<'input> ConversionContext<'input> { Ok((eq_str, None, None)) } MdlEquation::Lookup(_, table) => Ok(( - "0+0".to_string(), + LOOKUP_SENTINEL.to_string(), None, Some(self.build_graphical_function(var_name, table)), )), @@ -628,7 +629,7 @@ impl<'input> ConversionContext<'input> { None, Some(self.build_graphical_function(var_name, table)), )), - MdlEquation::EmptyRhs(_, _) => Ok(("0+0".to_string(), None, None)), + MdlEquation::EmptyRhs(_, _) => Ok((LOOKUP_SENTINEL.to_string(), None, None)), MdlEquation::Implicit(_) => { let gf = self.make_default_lookup(); Ok(("TIME".to_string(), None, Some(gf))) diff --git a/src/simlin-engine/src/mdl/mod.rs b/src/simlin-engine/src/mdl/mod.rs index b23b1adc..e6059846 100644 --- a/src/simlin-engine/src/mdl/mod.rs +++ b/src/simlin-engine/src/mdl/mod.rs @@ -34,6 +34,12 @@ use crate::datamodel::{Project, Variable}; use convert::convert_mdl_with_data; use writer::MdlWriter; +/// Sentinel equation produced by the MDL parser for variables that are +/// pure lookup definitions (no input expression) or have an empty RHS. +/// The writer recognises this to emit native Vensim `name(body)` syntax +/// instead of `name = WITH LOOKUP(input, body)`. +pub(crate) const LOOKUP_SENTINEL: &str = "0+0"; + /// Convert a Project to Vensim MDL text. pub fn project_to_mdl(project: &Project) -> Result { if project.models.len() != 1 { diff --git a/src/simlin-engine/src/mdl/view/convert.rs b/src/simlin-engine/src/mdl/view/convert.rs index 45d56087..f627117b 100644 --- a/src/simlin-engine/src/mdl/view/convert.rs +++ b/src/simlin-engine/src/mdl/view/convert.rs @@ -786,6 +786,7 @@ fn create_sector_group( y: top_left_y + height / 2.0, width, height, + is_mdl_view_marker: true, }) } diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index ef105d49..5131c345 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -779,15 +779,15 @@ fn write_lookup(buf: &mut String, gf: &GraphicalFunction) { /// Returns true when the equation text is a placeholder sentinel rather /// than a real input expression (standalone lookup definition). /// -/// The MDL parser produces the sentinel `"0+0"` when a variable is -/// defined as a pure lookup (no input expression) -- see -/// `MdlEquation::Lookup` in `convert/variables.rs`. Vensim's native +/// The MDL parser produces [`LOOKUP_SENTINEL`](super::LOOKUP_SENTINEL) +/// when a variable is defined as a pure lookup (no input expression) -- +/// see `MdlEquation::Lookup` in `convert/variables.rs`. Vensim's native /// representation is `name(body)` rather than `name = WITH LOOKUP(input, /// body)`. An empty string covers XMILE variables that have a graphical /// function but no equation. fn is_lookup_only_equation(eqn: &str) -> bool { let trimmed = eqn.trim(); - trimmed.is_empty() || trimmed == "0+0" + trimmed.is_empty() || trimmed == super::LOOKUP_SENTINEL } /// Format f64 for MDL output: omit trailing `.0` for whole numbers. @@ -1840,23 +1840,29 @@ fn compute_control_point(from: (i32, i32), to: (i32, i32), canvas_angle: f64) -> (cx.round() as i32, cy.round() as i32) } -/// Splits a StockFlow's elements into view segments at Group boundaries. +/// Splits a StockFlow's elements into view segments at MDL view-marker +/// Group boundaries. /// /// When the MDL parser merges multiple named views into a single StockFlow, -/// it inserts a Group element at the start of each original view's elements. -/// This function reverses that merge by splitting on Group boundaries. +/// it inserts a Group element (with `is_mdl_view_marker == true`) at the +/// start of each original view's elements. This function reverses that +/// merge by splitting on those markers. Organizational groups from XMILE +/// (where `is_mdl_view_marker == false`) are passed through as regular +/// elements rather than triggering a view split. /// -/// Returns a Vec of (view_name, elements, font). If no Group elements exist, +/// Returns a Vec of (view_name, elements, font). If no marker Groups exist, /// returns a single segment using the StockFlow's own name (or "View 1"). fn split_view_on_groups<'a>( sf: &'a datamodel::StockFlow, ) -> Vec<(String, Vec<&'a ViewElement>, Option)> { - let has_groups = sf - .elements - .iter() - .any(|e| matches!(e, ViewElement::Group(_))); + let has_mdl_markers = sf.elements.iter().any(|e| { + matches!( + e, + ViewElement::Group(g) if g.is_mdl_view_marker + ) + }); - if !has_groups { + if !has_mdl_markers { let name = sf.name.clone().unwrap_or_else(|| "View 1".to_string()); let elements: Vec<&ViewElement> = sf .elements @@ -1870,23 +1876,27 @@ fn split_view_on_groups<'a>( let mut current_name = sf.name.clone().unwrap_or_else(|| "View 1".to_string()); let mut current_elements: Vec<&'a ViewElement> = Vec::new(); - let mut seen_group = false; + let mut seen_marker = false; for element in &sf.elements { - if let ViewElement::Group(group) = element { + if let ViewElement::Group(group) = element + && group.is_mdl_view_marker + { // Push the previous segment. Skip the initial pre-Group segment // only if it has no elements (no content before the first Group). - if seen_group || !current_elements.is_empty() { + if seen_marker || !current_elements.is_empty() { segments.push((current_name, current_elements, sf.font.clone())); current_elements = Vec::new(); } - seen_group = true; + seen_marker = true; current_name = group.name.clone(); - } else if !matches!(element, ViewElement::Module(_)) { + continue; + } + if !matches!(element, ViewElement::Module(_)) { current_elements.push(element); } } // Push the final segment (may be empty for trailing Groups). - if seen_group || !current_elements.is_empty() { + if seen_marker || !current_elements.is_empty() { segments.push((current_name, current_elements, sf.font.clone())); } segments @@ -2056,6 +2066,17 @@ impl MdlWriter { /// views at parse time), we split on those boundaries to reconstruct /// the original multi-view structure. fn write_sketch_section(&mut self, views: &[View]) { + if views.is_empty() { + // Emit a minimal valid sketch so the output is not malformed. + self.buf + .push_str("V300 Do not put anything below this section - it will be ignored\n"); + self.buf.push_str("*View 1\n"); + self.buf + .push_str("$192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0\n"); + self.buf.push_str("///---\\\\\\\n"); + return; + } + let mut segment_idx = 0; for view in views { let View::StockFlow(sf) = view; @@ -2234,8 +2255,11 @@ impl MdlWriter { self.buf.push_str("41:0\n"); self.buf.push_str("42:0\n"); - // Types 24/25/26: Display time range (start, end, end). - // All reference files set 24=start, 25=stop, 26=stop. + // Types 24/25/26: Display time range for the graph/chart output. + // These control what Vensim shows in its default output graphs, + // NOT the simulation time range (which comes from the TIME STEP, + // INITIAL TIME, and FINAL TIME variable definitions). + // All reference MDL files set 24=start, 25=stop, 26=stop. writeln!(self.buf, "24:{}", format_f64(sim_specs.start)).unwrap(); writeln!(self.buf, "25:{}", format_f64(sim_specs.stop)).unwrap(); writeln!(self.buf, "26:{}", format_f64(sim_specs.stop)).unwrap(); @@ -2295,3655 +2319,5 @@ fn build_name_map(elements: &[ViewElement]) -> HashMap { } #[cfg(test)] -mod tests { - use super::*; - use crate::ast::{Expr0, IndexExpr0, Loc}; - use crate::common::RawIdent; - use crate::datamodel::{ - Aux, Compat, Equation, Flow, GraphicalFunction, GraphicalFunctionKind, - GraphicalFunctionScale, Rect, SimMethod, Stock, StockFlow, Unit, Variable, view_element, - }; - use crate::lexer::LexerType; - - /// Parse XMILE equation text to Expr0, then convert to MDL and assert. - fn assert_mdl(xmile_eqn: &str, expected_mdl: &str) { - let ast = Expr0::new(xmile_eqn, LexerType::Equation) - .expect("parse should succeed") - .expect("expression should not be empty"); - let mdl = expr0_to_mdl(&ast); - assert_eq!( - expected_mdl, &mdl, - "MDL mismatch for XMILE input: {xmile_eqn:?}" - ); - } - - #[test] - fn constants() { - assert_mdl("5", "5"); - assert_mdl("3.14", "3.14"); - assert_mdl("1e3", "1e3"); - } - - #[test] - fn nan_constant() { - let ast = Expr0::new("NAN", LexerType::Equation).unwrap().unwrap(); - let mdl = expr0_to_mdl(&ast); - assert_eq!("NaN", &mdl); - } - - #[test] - fn variable_references() { - assert_mdl("population_growth_rate", "population growth rate"); - assert_mdl("x", "x"); - assert_mdl("a_b_c", "a b c"); - } - - #[test] - fn variable_references_quote_special_identifiers() { - let special = Expr0::Var(RawIdent::new_from_str("$_euro"), Loc::default()); - assert_eq!(expr0_to_mdl(&special), "\"$ euro\""); - - let expr = Expr0::Op2( - BinaryOp::Add, - Box::new(Expr0::Var(RawIdent::new_from_str("$_euro"), Loc::default())), - Box::new(Expr0::Var( - RawIdent::new_from_str("revenue"), - Loc::default(), - )), - Loc::default(), - ); - assert_eq!(expr0_to_mdl(&expr), "\"$ euro\" + revenue"); - } - - #[test] - fn quoted_identifiers_escape_embedded_quotes_and_backslashes() { - assert_eq!(escape_mdl_quoted_ident(r#"it"s"#), r#"it\"s"#); - assert_eq!(escape_mdl_quoted_ident(r"back\slash"), r"back\\slash"); - assert_eq!(escape_mdl_quoted_ident(r#"a"b\c"#), r#"a\"b\\c"#,); - - assert_eq!(format_mdl_ident(r#"it"s_a_test"#), r#""it\"s a test""#,); - } - - #[test] - fn quoted_identifiers_handle_newlines() { - // Literal newline chars become the two-character escape \n - assert_eq!( - escape_mdl_quoted_ident("Maximum\nfishery size"), - r"Maximum\nfishery size" - ); - // Already-escaped \n (two chars: backslash + n) stays as-is - assert_eq!( - escape_mdl_quoted_ident(r"Maximum\nfishery size"), - r"Maximum\nfishery size" - ); - // Full round through format_mdl_ident: name with literal newline - assert_eq!( - format_mdl_ident("Maximum\nfishery_size"), - r#""Maximum\nfishery size""# - ); - } - - #[test] - fn needs_mdl_quoting_edge_cases() { - assert!(needs_mdl_quoting("")); - assert!(needs_mdl_quoting(" leading")); - assert!(needs_mdl_quoting("trailing ")); - assert!(needs_mdl_quoting("1starts_with_digit")); - assert!(!needs_mdl_quoting("normal name")); - assert!(!needs_mdl_quoting("_private")); - assert!(needs_mdl_quoting("has/slash")); - assert!(needs_mdl_quoting("has|pipe")); - } - - #[test] - fn arithmetic_operators() { - assert_mdl("a + b", "a + b"); - assert_mdl("a - b", "a - b"); - assert_mdl("a * b", "a * b"); - assert_mdl("a / b", "a / b"); - assert_mdl("a ^ b", "a ^ b"); - } - - #[test] - fn precedence_no_extra_parens() { - assert_mdl("a + b * c", "a + b * c"); - } - - #[test] - fn right_child_same_precedence_non_commutative() { - // a - (b - c) must preserve parens: subtraction is not associative - assert_mdl("a - (b - c)", "a - (b - c)"); - // a / (b / c) must preserve parens: division is not associative - assert_mdl("a / (b / c)", "a / (b / c)"); - // a - (b + c) must preserve parens: + has same precedence as - - assert_mdl("a - (b + c)", "a - (b + c)"); - } - - #[test] - fn left_child_same_precedence_no_extra_parens() { - // (a - b) - c should NOT get extra parens: left-to-right is natural - assert_mdl("a - b - c", "a - b - c"); - // (a / b) / c should NOT get extra parens - assert_mdl("a / b / c", "a / b / c"); - // (a + b) + c should NOT get extra parens: + is commutative anyway - assert_mdl("a + b + c", "a + b + c"); - } - - #[test] - fn precedence_parens_emitted() { - assert_mdl("(a + b) * c", "(a + b) * c"); - } - - #[test] - fn nested_precedence() { - assert_mdl("a * (b + c) / d", "a * (b + c) / d"); - } - - #[test] - fn unary_operators() { - assert_mdl("-a", "-a"); - assert_mdl("+a", "+a"); - // XMILE uses `not` keyword; MDL uses `:NOT:` with a trailing space before the operand - assert_mdl("not a", ":NOT: a"); - } - - #[test] - fn logical_operators_and() { - // XMILE uses `and` keyword; MDL uses `:AND:` infix operator - assert_mdl("a and b", "a :AND: b"); - } - - #[test] - fn logical_operators_or() { - // XMILE uses `or` keyword; MDL uses `:OR:` infix operator - assert_mdl("a or b", "a :OR: b"); - } - - #[test] - fn function_rename_smooth() { - assert_mdl("smth1(x, 5)", "SMOOTH(x, 5)"); - } - - #[test] - fn function_rename_smooth3() { - assert_mdl("smth3(x, 5)", "SMOOTH3(x, 5)"); - } - - #[test] - fn function_rename_safediv() { - assert_mdl("safediv(a, b)", "ZIDZ(a, b)"); - } - - #[test] - fn function_rename_safediv_three_args_emits_xidz() { - assert_mdl("safediv(a, b, x)", "XIDZ(a, b, x)"); - } - - #[test] - fn function_rename_init() { - assert_mdl("init(x, 10)", "ACTIVE INITIAL(x, 10)"); - } - - #[test] - fn function_rename_int() { - assert_mdl("int(x)", "INTEGER(x)"); - } - - #[test] - fn function_rename_uniform() { - assert_mdl("uniform(0, 10)", "RANDOM UNIFORM(0, 10)"); - } - - #[test] - fn function_rename_forcst() { - assert_mdl("forcst(x, 5, 0)", "FORECAST(x, 5, 0)"); - } - - #[test] - fn function_rename_delay() { - assert_mdl("delay(x, 5, 0)", "DELAY FIXED(x, 5, 0)"); - } - - #[test] - fn function_rename_delay1() { - assert_mdl("delay1(x, 5)", "DELAY1(x, 5)"); - } - - #[test] - fn function_rename_delay3() { - assert_mdl("delay3(x, 5)", "DELAY3(x, 5)"); - } - - #[test] - fn function_rename_integ() { - assert_mdl( - "integ(inflow - outflow, 100)", - "INTEG(inflow - outflow, 100)", - ); - } - - #[test] - fn function_rename_lookupinv() { - assert_mdl("lookupinv(tbl, 0.5)", "LOOKUP INVERT(tbl, 0.5)"); - } - - #[test] - fn function_rename_normalpink() { - assert_mdl("normalpink(x, 5)", "RANDOM PINK NOISE(x, 5)"); - } - - #[test] - fn lookup_call_native_vensim_syntax() { - // Vensim uses `table ( input )` syntax for lookup calls - assert_mdl("lookup(tbl, x)", "tbl ( x )"); - } - - #[test] - fn function_unknown_uppercased() { - assert_mdl("abs(x)", "ABS(x)"); - assert_mdl("ln(x)", "LN(x)"); - assert_mdl("max(a, b)", "MAX(a, b)"); - } - - #[test] - fn arg_reorder_delay_n() { - // XMILE: delayn(input, delay_time, n, init) -> MDL: DELAY N(input, delay_time, init, n) - assert_mdl( - "delayn(input, delay_time, 3, init_val)", - "DELAY N(input, delay time, init val, 3)", - ); - } - - #[test] - fn arg_reorder_smooth_n() { - // XMILE: smthn(input, delay_time, n, init) -> MDL: SMOOTH N(input, delay_time, init, n) - assert_mdl( - "smthn(input, delay_time, 3, init_val)", - "SMOOTH N(input, delay time, init val, 3)", - ); - } - - #[test] - fn arg_reorder_random_normal() { - // XMILE: normal(mean, sd, seed, min, max) -> MDL: RANDOM NORMAL(min, max, mean, sd, seed) - assert_mdl( - "normal(mean, sd, seed, min_val, max_val)", - "RANDOM NORMAL(min val, max val, mean, sd, seed)", - ); - } - - // -- pattern recognizer tests (Task 2) -- - - #[test] - fn pattern_random_0_1() { - // XMILE: uniform(0, 1) -> MDL: RANDOM 0 1() - assert_mdl("uniform(0, 1)", "RANDOM 0 1()"); - } - - #[test] - fn pattern_random_0_1_not_matched_different_args() { - // uniform with non-(0,1) args should NOT match the RANDOM 0 1 pattern - assert_mdl("uniform(0, 10)", "RANDOM UNIFORM(0, 10)"); - } - - #[test] - fn pattern_log_2arg() { - // XMILE: ln(x) / ln(base) -> MDL: LOG(x, base) - assert_mdl("ln(x) / ln(base)", "LOG(x, base)"); - } - - #[test] - fn pattern_quantum() { - // XMILE: q * int(x / q) -> MDL: QUANTUM(x, q) - assert_mdl("q * int(x / q)", "QUANTUM(x, q)"); - } - - #[test] - fn pattern_quantum_not_matched_different_q() { - // q1 * int(x / q2) should NOT match QUANTUM when q1 != q2 - assert_mdl("q1 * int(x / q2)", "q1 * INTEGER(x / q2)"); - } - - #[test] - fn pattern_pulse() { - // XMILE expansion of PULSE(start, width): - // IF TIME >= start AND TIME < (start + MAX(DT, width)) THEN 1 ELSE 0 - assert_mdl( - "if time >= start and time < (start + max(dt, width)) then 1 else 0", - "PULSE(start, width)", - ); - } - - #[test] - fn pattern_pulse_not_matched_missing_lt() { - // Missing the Lt branch -- should fall through to mechanical conversion - assert_mdl( - "if time >= start then 1 else 0", - "IF THEN ELSE(Time >= start, 1, 0)", - ); - } - - #[test] - fn pattern_pulse_train() { - // XMILE expansion of PULSE TRAIN(start, width, interval, end_val): - // IF TIME >= start AND TIME <= end_val AND (TIME - start) MOD interval < width THEN 1 ELSE 0 - assert_mdl( - "if time >= start and time <= end_val and (time - start) mod interval < width then 1 else 0", - "PULSE TRAIN(start, width, interval, end val)", - ); - } - - #[test] - fn mod_emits_modulo() { - assert_mdl("a mod b", "MODULO(a, b)"); - assert_mdl("(time) mod (5)", "MODULO(Time, 5)"); - } - - #[test] - fn pattern_sample_if_true() { - // XMILE expansion of SAMPLE IF TRUE(cond, input, init): - // IF cond THEN input ELSE PREVIOUS(SELF, init) - assert_mdl( - "if cond then input else previous(self, init_val)", - "SAMPLE IF TRUE(cond, input, init val)", - ); - } - - #[test] - fn pattern_time_base() { - // XMILE expansion of TIME BASE(t, delta): - // t + delta * TIME - assert_mdl("t_val + delta * time", "TIME BASE(t val, delta)"); - } - - #[test] - fn pattern_random_poisson() { - // XMILE expansion of RANDOM POISSON(min, max, mean, sdev, factor, seed): - // poisson(mean / dt, seed, min, max) * factor + sdev - assert_mdl( - "poisson(mean / dt, seed, min_val, max_val) * factor + sdev", - "RANDOM POISSON(min val, max val, mean, sdev, factor, seed)", - ); - } - - #[test] - fn pattern_fallthrough_no_match() { - // An If expression that doesn't match any pattern should use mechanical conversion - assert_mdl("if a > b then c else d", "IF THEN ELSE(a > b, c, d)"); - } - - #[test] - fn pattern_allocate_by_priority() { - // XMILE expansion of ALLOCATE BY PRIORITY(demand[region], priority, ignore, width, supply): - // allocate(supply, region, demand[region.*], priority, width) - // - // The last subscript (region.*) is replaced with the dimension name, yielding demand[region]. - // The arguments are reordered: demand first, then priority, then 0 (ignore), width, supply. - assert_mdl( - "allocate(supply, region, demand[region.*], priority, width)", - "ALLOCATE BY PRIORITY(demand[region], priority, 0, width, supply)", - ); - } - - #[test] - fn pattern_allocate_by_priority_no_subscript() { - // When the demand argument has no subscript (simple variable), it passes through as-is. - assert_mdl( - "allocate(supply, region, demand, priority, width)", - "ALLOCATE BY PRIORITY(demand, priority, 0, width, supply)", - ); - } - - // ---- lookup call syntax tests ---- - - #[test] - fn lookup_call_with_spaced_table_name() { - // Multi-word table ident should be space-separated in output - assert_mdl( - "lookup(federal_funds_rate_lookup, time)", - "federal funds rate lookup ( Time )", - ); - } - - #[test] - fn lookup_call_with_expression_input() { - // The input argument can be an arbitrary expression - assert_mdl("lookup(my_table, a + b)", "my table ( a + b )"); - } - - #[test] - fn lookup_non_var_first_arg_falls_through() { - // When the first arg is not a bare variable (e.g. a subscripted - // reference), the generic LOOKUP(...) path is used as a fallback. - let table_sub = Expr0::Subscript( - RawIdent::new_from_str("tbl"), - vec![IndexExpr0::Expr(Expr0::Var( - RawIdent::new_from_str("i"), - Loc::default(), - ))], - Loc::default(), - ); - let input = Expr0::Var(RawIdent::new_from_str("x"), Loc::default()); - let expr = Expr0::App( - UntypedBuiltinFn("lookup".to_owned(), vec![table_sub, input]), - Loc::default(), - ); - let mdl = expr0_to_mdl(&expr); - assert_eq!(mdl, "LOOKUP(tbl[i], x)"); - } - - #[test] - fn non_lookup_function_emits_normally() { - // Other function calls should not be affected by the lookup special-case - assert_mdl("max(a, b)", "MAX(a, b)"); - assert_mdl("min(x, y)", "MIN(x, y)"); - } - - // ---- Task 1: Variable entry formatting (scalar) ---- - - fn make_aux(ident: &str, eqn: &str, units: Option<&str>, doc: &str) -> Variable { - Variable::Aux(Aux { - ident: ident.to_owned(), - equation: Equation::Scalar(eqn.to_owned()), - documentation: doc.to_owned(), - units: units.map(|u| u.to_owned()), - gf: None, - ai_state: None, - uid: None, - compat: Compat::default(), - }) - } - - fn make_stock(ident: &str, eqn: &str, units: Option<&str>, doc: &str) -> Variable { - Variable::Stock(Stock { - ident: ident.to_owned(), - equation: Equation::Scalar(eqn.to_owned()), - documentation: doc.to_owned(), - units: units.map(|u| u.to_owned()), - inflows: vec![], - outflows: vec![], - ai_state: None, - uid: None, - compat: Compat::default(), - }) - } - - fn make_gf() -> GraphicalFunction { - GraphicalFunction { - kind: GraphicalFunctionKind::Continuous, - x_points: Some(vec![0.0, 1.0, 2.0]), - y_points: vec![0.0, 0.5, 1.0], - x_scale: GraphicalFunctionScale { min: 0.0, max: 2.0 }, - y_scale: GraphicalFunctionScale { min: 0.0, max: 1.0 }, - } - } - - #[test] - fn scalar_aux_entry() { - let var = make_aux("characteristic_time", "10", Some("Minutes"), "How long"); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!( - buf, - "characteristic time = 10\n\t~\tMinutes\n\t~\tHow long\n\t|" - ); - } - - #[test] - fn scalar_aux_entry_quotes_special_identifier_name() { - let var = make_aux("$_euro", "10", Some("Dmnl"), ""); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!(buf, "\"$ euro\" = 10\n\t~\tDmnl\n\t~\t\n\t|"); - } - - #[test] - fn scalar_aux_no_units() { - let var = make_aux("rate", "a + b", None, ""); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!(buf, "rate = a + b\n\t~\t\n\t~\t\n\t|"); - } - - // ---- Inline vs multiline equation formatting ---- - - #[test] - fn short_equation_uses_inline_format() { - let var = make_aux("average_repayment_rate", "0.03", Some("1/Year"), ""); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.starts_with("average repayment rate = 0.03\n"), - "short equation should use inline format: {buf}" - ); - assert!( - !buf.contains("=\n\t0.03"), - "short equation should not use multiline format: {buf}" - ); - } - - #[test] - fn long_equation_uses_multiline_format() { - // Build an equation that, combined with the name, exceeds 80 chars - let long_eqn = "very_long_variable_a + very_long_variable_b + very_long_variable_c + very_long_variable_d"; - let var = make_aux("some_computed_value", long_eqn, None, ""); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.contains("some computed value=\n\t"), - "long equation should use multiline format: {buf}" - ); - } - - #[test] - fn lookup_always_uses_multiline_format() { - let gf = make_gf(); - let var = Variable::Aux(Aux { - ident: "x".to_owned(), - equation: Equation::Scalar("TIME".to_owned()), - documentation: String::new(), - units: None, - gf: Some(gf), - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.starts_with("x=\n\t"), - "lookup equation should always use multiline format: {buf}" - ); - } - - #[test] - fn data_equation_uses_data_equals_inline() { - let var = make_aux( - "small_data", - "{GET_DIRECT_DATA('f.csv',',','A','B')}", - None, - "", - ); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.contains(":="), - "data equation should use := operator: {buf}" - ); - assert!( - buf.contains(" := "), - "short data equation should use inline format with spaces: {buf}" - ); - } - - // ---- Backslash line continuation tests ---- - - #[test] - fn wrap_short_equation_unchanged() { - let eqn = "a + b"; - let wrapped = wrap_equation_with_continuations(eqn, 80); - assert_eq!(wrapped, eqn); - assert!( - !wrapped.contains('\\'), - "short equation should not be wrapped: {wrapped}" - ); - } - - #[test] - fn wrap_long_equation_with_continuations() { - // Build an equation >80 chars with multiple terms - let eqn = "very long variable a + very long variable b + very long variable c + very long variable d"; - assert!(eqn.len() > 80, "test equation should exceed 80 chars"); - let wrapped = wrap_equation_with_continuations(eqn, 80); - assert!( - wrapped.contains("\\\n\t\t"), - "long equation should contain continuation: {wrapped}" - ); - // Verify the continuation produces valid content when joined - let rejoined = wrapped.replace("\\\n\t\t", ""); - // The rejoined text should reconstruct the original (modulo trimmed trailing spaces) - assert!( - rejoined.contains("very long variable a"), - "content should be preserved: {rejoined}" - ); - } - - #[test] - fn wrap_equation_breaks_after_comma() { - // A function call with many arguments - let eqn = "IF THEN ELSE(very long condition variable > threshold value, very long true result, very long false result)"; - assert!(eqn.len() > 80); - let wrapped = wrap_equation_with_continuations(eqn, 80); - assert!(wrapped.contains("\\\n\t\t"), "should wrap: {wrapped}"); - // Verify breaks happen at reasonable points (after commas or before operators) - let lines: Vec<&str> = wrapped.split("\\\n\t\t").collect(); - assert!(lines.len() >= 2, "should have at least 2 lines: {wrapped}"); - } - - #[test] - fn long_equation_variable_entry_uses_continuation() { - let long_eqn = "very_long_variable_a + very_long_variable_b + very_long_variable_c + very_long_variable_d"; - let var = make_aux("some_computed_value", long_eqn, None, ""); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.contains("some computed value=\n\t"), - "long equation should use multiline format: {buf}" - ); - // The equation body should have a continuation if the MDL form exceeds 80 chars - let mdl_eqn = equation_to_mdl(long_eqn); - if mdl_eqn.len() > 80 { - assert!( - buf.contains("\\\n\t\t"), - "long MDL equation should use backslash continuation: {buf}" - ); - } - } - - #[test] - fn short_equation_variable_entry_no_continuation() { - let var = make_aux("x", "42", None, ""); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - !buf.contains("\\\n\t\t"), - "short equation should not have continuation: {buf}" - ); - } - - #[test] - fn tokenize_preserves_equation_text() { - let eqn = "IF THEN ELSE(a > b, c + d, e * f)"; - let tokens = tokenize_for_wrapping(eqn); - let rejoined: String = tokens.concat(); - assert_eq!( - rejoined, eqn, - "concatenating tokens should reproduce the original" - ); - } - - #[test] - fn tokenize_splits_at_operators_and_commas() { - let eqn = "a + b, c * d"; - let tokens = tokenize_for_wrapping(eqn); - // Should have splits at +, *, and after comma - assert!( - tokens.len() >= 5, - "expected multiple tokens from operators/commas: {tokens:?}" - ); - } - - #[test] - fn scalar_stock_integ() { - // Real stocks from the MDL reader store only the initial value in - // equation, with inflows/outflows in separate fields. The writer - // must reconstruct the INTEG(...) expression. - let var = Variable::Stock(Stock { - ident: "teacup_temperature".to_owned(), - equation: Equation::Scalar("180".to_owned()), - documentation: "Temperature of tea".to_owned(), - units: Some("Degrees Fahrenheit".to_owned()), - inflows: vec![], - outflows: vec!["heat_loss_to_room".to_owned()], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!( - buf, - "teacup temperature=\n\tINTEG(-heat loss to room, 180)\n\t~\tDegrees Fahrenheit\n\t~\tTemperature of tea\n\t|" - ); - } - - #[test] - fn stock_with_inflows_and_outflows() { - let var = Variable::Stock(Stock { - ident: "population".to_owned(), - equation: Equation::Scalar("1000".to_owned()), - documentation: String::new(), - units: None, - inflows: vec!["births".to_owned()], - outflows: vec!["deaths".to_owned()], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.contains("INTEG(births-deaths, 1000)"), - "Expected INTEG with both inflow and outflow: {}", - buf - ); - } - - #[test] - fn arrayed_stock_apply_to_all_preserves_initial_value() { - let var = Variable::Stock(Stock { - ident: "inventory".to_owned(), - equation: Equation::ApplyToAll(vec!["region".to_owned()], "100".to_owned()), - documentation: "Stock by region".to_owned(), - units: Some("widgets".to_owned()), - inflows: vec!["inflow".to_owned()], - outflows: vec!["outflow".to_owned()], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.contains("inventory[region]=\n\tINTEG(inflow-outflow, 100)"), - "ApplyToAll stock should emit arrayed INTEG with initial value: {}", - buf - ); - } - - #[test] - fn arrayed_stock_elements_preserve_each_initial_value() { - let var = Variable::Stock(Stock { - ident: "inventory".to_owned(), - equation: Equation::Arrayed( - vec!["region".to_owned()], - vec![ - ("north".to_owned(), "100".to_owned(), None, None), - ("south".to_owned(), "200".to_owned(), None, None), - ], - None, - false, - ), - documentation: "Stock by region".to_owned(), - units: Some("widgets".to_owned()), - inflows: vec!["inflow".to_owned()], - outflows: vec!["outflow".to_owned()], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.contains("inventory[north]=\n\tINTEG(inflow-outflow, 100)"), - "First arrayed stock element should retain initial value: {}", - buf - ); - assert!( - buf.contains("inventory[south]=\n\tINTEG(inflow-outflow, 200)"), - "Second arrayed stock element should retain initial value: {}", - buf - ); - } - - #[test] - fn active_initial_preserved_on_aux() { - let var = Variable::Aux(Aux { - ident: "x".to_owned(), - equation: Equation::Scalar("y * 2".to_owned()), - documentation: String::new(), - units: None, - gf: None, - ai_state: None, - uid: None, - compat: Compat { - active_initial: Some("100".to_owned()), - ..Compat::default() - }, - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.contains("ACTIVE INITIAL(y * 2, 100)"), - "Expected ACTIVE INITIAL wrapper: {}", - buf - ); - } - - #[test] - fn compat_data_source_reconstructs_get_direct_constants() { - let var = Variable::Aux(Aux { - ident: "imported_constants".to_owned(), - equation: Equation::Scalar("0".to_owned()), - documentation: String::new(), - units: None, - gf: Some(make_gf()), - ai_state: None, - uid: None, - compat: Compat { - data_source: Some(crate::datamodel::DataSource { - kind: crate::datamodel::DataSourceKind::Constants, - file: "workbook.xlsx".to_owned(), - tab_or_delimiter: "Sheet1".to_owned(), - row_or_col: "A".to_owned(), - cell: String::new(), - }), - ..Compat::default() - }, - }); - - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!( - buf.contains(":="), - "GET DIRECT reconstruction should use := for data equations: {buf}" - ); - assert!( - buf.contains("GET DIRECT CONSTANTS('workbook.xlsx', 'Sheet1', 'A')"), - "writer should reconstruct GET DIRECT CONSTANTS from compat metadata: {buf}" - ); - assert!( - !buf.contains("([(0,0)-(2,1)]"), - "lookup table output must be suppressed when data_source metadata is present: {buf}" - ); - } - - #[test] - fn scalar_aux_with_lookup() { - let gf = make_gf(); - let var = Variable::Aux(Aux { - ident: "effect_of_x".to_owned(), - equation: Equation::Scalar("TIME".to_owned()), - documentation: "Lookup effect".to_owned(), - units: None, - gf: Some(gf), - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!( - buf, - "effect of x=\n\tWITH LOOKUP(Time, ([(0,0)-(2,1)],(0,0),(1,0.5),(2,1)))\n\t~\t\n\t~\tLookup effect\n\t|" - ); - } - - #[test] - fn lookup_without_explicit_x_points() { - let gf = GraphicalFunction { - kind: GraphicalFunctionKind::Continuous, - x_points: None, - y_points: vec![0.0, 0.5, 1.0], - x_scale: GraphicalFunctionScale { - min: 0.0, - max: 10.0, - }, - y_scale: GraphicalFunctionScale { min: 0.0, max: 1.0 }, - }; - let var = Variable::Aux(Aux { - ident: "tbl".to_owned(), - equation: Equation::Scalar(String::new()), - documentation: String::new(), - units: None, - gf: Some(gf), - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - // Standalone lookup: name(\n\tbody) - assert_eq!( - buf, - "tbl(\n\t[(0,0)-(10,1)],(0,0),(5,0.5),(10,1))\n\t~\t\n\t~\t\n\t|" - ); - } - - // ---- Task 2: Subscripted equation formatting ---- - - #[test] - fn apply_to_all_entry() { - let var = Variable::Aux(Aux { - ident: "rate_a".to_owned(), - equation: Equation::ApplyToAll( - vec!["one_dimensional_subscript".to_owned()], - "100".to_owned(), - ), - documentation: String::new(), - units: None, - gf: None, - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!( - buf, - "rate a[one dimensional subscript] = 100\n\t~\t\n\t~\t\n\t|" - ); - } - - #[test] - fn apply_to_all_multi_dim() { - let var = Variable::Aux(Aux { - ident: "matrix_a".to_owned(), - equation: Equation::ApplyToAll( - vec!["dim_a".to_owned(), "dim_b".to_owned()], - "0".to_owned(), - ), - documentation: String::new(), - units: Some("Dmnl".to_owned()), - gf: None, - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!(buf, "matrix a[dim a,dim b] = 0\n\t~\tDmnl\n\t~\t\n\t|"); - } - - #[test] - fn arrayed_per_element() { - let var = Variable::Aux(Aux { - ident: "rate_a".to_owned(), - equation: Equation::Arrayed( - vec!["one_dimensional_subscript".to_owned()], - vec![ - ("entry_1".to_owned(), "0.01".to_owned(), None, None), - ("entry_2".to_owned(), "0.2".to_owned(), None, None), - ("entry_3".to_owned(), "0.3".to_owned(), None, None), - ], - None, - false, - ), - documentation: String::new(), - units: None, - gf: None, - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert_eq!( - buf, - "rate a[entry 1]=\n\t0.01\n\t~~|\nrate a[entry 2]=\n\t0.2\n\t~~|\nrate a[entry 3]=\n\t0.3\n\t~\t\n\t~\t\n\t|" - ); - } - - #[test] - fn arrayed_subscript_names_with_underscores() { - let var = Variable::Aux(Aux { - ident: "demand".to_owned(), - equation: Equation::Arrayed( - vec!["region".to_owned()], - vec![ - ("north_america".to_owned(), "100".to_owned(), None, None), - ("south_america".to_owned(), "200".to_owned(), None, None), - ], - None, - false, - ), - documentation: String::new(), - units: None, - gf: None, - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - // Underscored element names should appear with spaces - assert!(buf.contains("[north america]")); - assert!(buf.contains("[south america]")); - } - - #[test] - fn arrayed_multidimensional_element_keys_preserve_tuple_shape() { - let var = Variable::Aux(Aux { - ident: "power5".to_owned(), - equation: Equation::Arrayed( - vec!["subs2".to_owned(), "subs1".to_owned(), "subs3".to_owned()], - vec![ - ( - "c,a,f".to_owned(), - "power(var3[subs2, subs1], var4[subs2, subs3])".to_owned(), - None, - None, - ), - ( - "d,b,g".to_owned(), - "power(var3[subs2, subs1], var4[subs2, subs3])".to_owned(), - None, - None, - ), - ], - None, - false, - ), - documentation: String::new(), - units: None, - gf: None, - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - - assert!( - buf.contains("power5[c,a,f]="), - "missing first tuple key: {buf}" - ); - assert!( - buf.contains("power5[d,b,g]="), - "missing second tuple key: {buf}" - ); - assert!( - !buf.contains("power5[\"c,a,f\"]"), - "tuple key must not be quoted as a single symbol: {buf}" - ); - } - - #[test] - fn arrayed_with_per_element_lookup() { - let gf = make_gf(); - let var = Variable::Aux(Aux { - ident: "tbl".to_owned(), - equation: Equation::Arrayed( - vec!["dim".to_owned()], - vec![ - ("a".to_owned(), String::new(), None, Some(gf.clone())), - ("b".to_owned(), "5".to_owned(), None, None), - ], - None, - false, - ), - documentation: String::new(), - units: None, - gf: None, - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - // Element "a" has empty equation + gf → standalone lookup - assert!(buf.contains("tbl[a](\n\t[(0,0)-(2,1)]")); - assert!(buf.contains("tbl[b]=\n\t5")); - } - - // ---- Task 3: Dimension definitions ---- - - #[test] - fn dimension_def_named() { - let dim = datamodel::Dimension::named( - "dim_a".to_owned(), - vec!["a1".to_owned(), "a2".to_owned(), "a3".to_owned()], - ); - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - assert_eq!(buf, "dim a:\n\ta1, a2, a3\n\t~~|\n"); - } - - #[test] - fn dimension_def_indexed() { - let dim = datamodel::Dimension::indexed("dim_b".to_owned(), 5); - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - assert_eq!(buf, "dim b:\n\t(1-5)\n\t~~|\n"); - } - - #[test] - fn dimension_def_with_mapping() { - let mut dim = datamodel::Dimension::named( - "dim_c".to_owned(), - vec!["dc1".to_owned(), "dc2".to_owned(), "dc3".to_owned()], - ); - dim.set_maps_to("dim_b".to_owned()); - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - assert_eq!(buf, "dim c:\n\tdc1, dc2, dc3 -> dim b\n\t~~|\n"); - } - - // ---- Task 3: Data equations ---- - - #[test] - fn data_equation_uses_data_equals() { - let var = make_aux( - "direct_data_down", - "{GET_DIRECT_DATA('data_down.csv',',','A','B2')}", - None, - "", - ); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - // Data equations use := instead of = - assert!(buf.contains(":="), "expected := in: {buf}"); - } - - #[test] - fn non_data_equation_uses_equals() { - let var = make_aux("x", "42", None, ""); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!(buf.starts_with("x = "), "expected = in: {buf}"); - } - - #[test] - fn is_data_equation_detection() { - // Underscore-separated form (as might appear in some equation strings) - assert!(is_data_equation("{GET_DIRECT_DATA('f',',','A','B')}")); - assert!(is_data_equation("{GET_XLS_DATA('f','s','A','B')}")); - assert!(is_data_equation("{GET_VDF_DATA('f','v')}")); - assert!(is_data_equation("{GET_DATA_AT_TIME('v', 5)}")); - assert!(is_data_equation("{GET_123_DATA('f','s','A','B')}")); - - // Space-separated form (as produced by the normalizer's SymbolClass::GetXls) - assert!(is_data_equation("{GET DIRECT DATA('f',',','A','B')}")); - assert!(is_data_equation("{GET XLS DATA('f','s','A','B')}")); - assert!(is_data_equation("{GET VDF DATA('f','v')}")); - assert!(is_data_equation("{GET DATA AT TIME('v', 5)}")); - assert!(is_data_equation("{GET 123 DATA('f','s','A','B')}")); - - assert!(!is_data_equation("100")); - assert!(!is_data_equation("integ(a, b)")); - assert!(!is_data_equation("")); - } - - #[test] - fn data_equation_preserves_raw_content() { - // Data equations should not go through expr0_to_mdl() because - // the GET XLS/DIRECT/etc. placeholders are not parseable as Expr0. - // Verify the raw content is preserved (not mangled by underbar_to_space). - let var = make_aux( - "my_data", - "{GET DIRECT DATA('data_file.csv',',','A','B2')}", - None, - "", - ); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - // The equation content must preserve underscores in quoted strings - assert!( - buf.contains("GET DIRECT DATA('data_file.csv',',','A','B2')"), - "Data equation content mangled: {}", - buf - ); - // Must use := for data equations - assert!(buf.contains(":="), "Expected := for data equation: {}", buf); - } - - #[test] - fn format_f64_whole_numbers() { - assert_eq!(format_f64(0.0), "0"); - assert_eq!(format_f64(1.0), "1"); - assert_eq!(format_f64(-5.0), "-5"); - assert_eq!(format_f64(100.0), "100"); - } - - #[test] - fn format_f64_fractional() { - assert_eq!(format_f64(0.5), "0.5"); - assert_eq!(format_f64(2.71), "2.71"); - } - - #[test] - fn format_f64_infinity_uses_vensim_numeric_sentinels() { - assert_eq!(format_f64(f64::INFINITY), "1e+38"); - assert_eq!(format_f64(f64::NEG_INFINITY), "-1e+38"); - } - - #[test] - fn flow_with_lookup() { - let gf = make_gf(); - let var = Variable::Flow(Flow { - ident: "flow_rate".to_owned(), - equation: Equation::Scalar("TIME".to_owned()), - documentation: "A flow".to_owned(), - units: Some("widgets/year".to_owned()), - gf: Some(gf), - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - // Flow with equation "TIME" + gf → WITH LOOKUP - assert!(buf.contains("flow rate=\n\tWITH LOOKUP(Time, ([(0,0)-(2,1)]")); - assert!(buf.contains("~\twidgets/year")); - assert!(buf.contains("~\tA flow")); - } - - #[test] - fn module_variable_produces_no_output() { - let var = Variable::Module(datamodel::Module { - ident: "mod1".to_owned(), - model_name: "model1".to_owned(), - documentation: String::new(), - units: None, - references: vec![], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - assert!(buf.is_empty()); - } - - // ---- Phase 4 Task 1: Validation ---- - - fn make_project(models: Vec) -> datamodel::Project { - datamodel::Project { - name: "test".to_owned(), - sim_specs: datamodel::SimSpecs { - start: 0.0, - stop: 100.0, - dt: datamodel::Dt::Dt(1.0), - save_step: None, - sim_method: datamodel::SimMethod::Euler, - time_units: None, - }, - dimensions: vec![], - units: vec![], - models, - source: None, - ai_information: None, - } - } - - fn make_model(variables: Vec) -> datamodel::Model { - datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables, - views: vec![], - loop_metadata: vec![], - groups: vec![], - } - } - - #[test] - fn project_to_mdl_rejects_multiple_models() { - let project = make_project(vec![make_model(vec![]), make_model(vec![])]); - let result = crate::mdl::project_to_mdl(&project); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.to_string().contains("single model"), - "error should mention single model, got: {}", - err - ); - } - - #[test] - fn project_to_mdl_rejects_module_variable() { - let module_var = Variable::Module(datamodel::Module { - ident: "submodel".to_owned(), - model_name: "inner".to_owned(), - documentation: String::new(), - units: None, - references: vec![], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let project = make_project(vec![make_model(vec![module_var])]); - let result = crate::mdl::project_to_mdl(&project); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.to_string().contains("Module"), - "error should mention Module, got: {}", - err - ); - } - - #[test] - fn project_to_mdl_succeeds_single_model() { - let var = make_aux("x", "5", Some("Units"), "A constant"); - let project = make_project(vec![make_model(vec![var])]); - let result = crate::mdl::project_to_mdl(&project); - assert!(result.is_ok(), "should succeed: {:?}", result); - let mdl = result.unwrap(); - assert!( - mdl.starts_with("{UTF-8}\r\n"), - "MDL should start with UTF-8 marker, got: {:?}", - mdl.lines().next() - ); - assert!(mdl.contains("x = ")); - assert!(mdl.contains("\\\\\\---///")); - } - - // ---- Phase 4 Task 2: Sim spec emission ---- - - #[test] - fn sim_specs_emission() { - let sim_specs = datamodel::SimSpecs { - start: 0.0, - stop: 100.0, - dt: datamodel::Dt::Dt(0.5), - save_step: Some(datamodel::Dt::Dt(1.0)), - sim_method: datamodel::SimMethod::Euler, - time_units: Some("Month".to_owned()), - }; - let mut writer = MdlWriter::new(); - writer.write_sim_specs(&sim_specs); - let output = writer.buf; - - assert!( - output.contains("INITIAL TIME = \n\t0"), - "should have INITIAL TIME, got: {output}" - ); - assert!( - output.contains("~\tMonth\n\t~\tThe initial time for the simulation."), - "INITIAL TIME should have Month units" - ); - assert!( - output.contains("FINAL TIME = \n\t100"), - "should have FINAL TIME, got: {output}" - ); - assert!( - output.contains("TIME STEP = \n\t0.5"), - "should have TIME STEP = 0.5, got: {output}" - ); - assert!( - output.contains("Month [0,?]"), - "TIME STEP should have units with range, got: {output}" - ); - assert!( - output.contains("SAVEPER = \n\t1"), - "should have SAVEPER = 1, got: {output}" - ); - } - - #[test] - fn sim_specs_saveper_defaults_to_time_step() { - let sim_specs = datamodel::SimSpecs { - start: 0.0, - stop: 50.0, - dt: datamodel::Dt::Dt(1.0), - save_step: None, - sim_method: datamodel::SimMethod::Euler, - time_units: None, - }; - let mut writer = MdlWriter::new(); - writer.write_sim_specs(&sim_specs); - let output = writer.buf; - - assert!( - output.contains("SAVEPER = \n\tTIME STEP"), - "SAVEPER should reference TIME STEP when save_step is None, got: {output}" - ); - } - - #[test] - fn sim_specs_reciprocal_dt() { - let sim_specs = datamodel::SimSpecs { - start: 0.0, - stop: 100.0, - dt: datamodel::Dt::Reciprocal(4.0), - save_step: None, - sim_method: datamodel::SimMethod::Euler, - time_units: Some("Year".to_owned()), - }; - let mut writer = MdlWriter::new(); - writer.write_sim_specs(&sim_specs); - let output = writer.buf; - - assert!( - output.contains("TIME STEP = \n\t1/4"), - "reciprocal dt should emit 1/v, got: {output}" - ); - } - - // ---- Phase 4 Task 3: Equations section assembly ---- - - #[test] - fn equations_section_full_assembly() { - let var1 = make_aux("growth_rate", "0.05", Some("1/Year"), "Growth rate"); - let var2 = make_stock( - "population", - "integ(growth_rate * population, 100)", - Some("People"), - "Total population", - ); - let model = datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables: vec![var1, var2], - views: vec![], - loop_metadata: vec![], - groups: vec![], - }; - let project = datamodel::Project { - name: "test".to_owned(), - sim_specs: datamodel::SimSpecs { - start: 0.0, - stop: 100.0, - dt: datamodel::Dt::Dt(1.0), - save_step: None, - sim_method: datamodel::SimMethod::Euler, - time_units: Some("Year".to_owned()), - }, - dimensions: vec![], - units: vec![], - models: vec![model], - source: None, - ai_information: None, - }; - - let result = crate::mdl::project_to_mdl(&project); - assert!(result.is_ok(), "should succeed: {:?}", result); - let mdl = result.unwrap(); - - // Variable entries present - assert!( - mdl.contains("growth rate = "), - "should contain growth rate variable" - ); - assert!( - mdl.contains("population="), - "should contain population variable" - ); - - // Sim specs present - assert!(mdl.contains("INITIAL TIME"), "should contain INITIAL TIME"); - assert!(mdl.contains("FINAL TIME"), "should contain FINAL TIME"); - assert!(mdl.contains("TIME STEP"), "should contain TIME STEP"); - assert!(mdl.contains("SAVEPER"), "should contain SAVEPER"); - - // Terminator present - assert!( - mdl.contains("\\\\\\---/// Sketch information - do not modify anything except names"), - "should contain section terminator" - ); - - // Ordering: variables before sim specs, sim specs before terminator - let var_pos = mdl.find("growth rate = ").unwrap(); - let initial_pos = mdl.find("INITIAL TIME").unwrap(); - let terminator_pos = mdl.find("\\\\\\---///").unwrap(); - assert!( - var_pos < initial_pos, - "variables should come before sim specs" - ); - assert!( - initial_pos < terminator_pos, - "sim specs should come before terminator" - ); - } - - #[test] - fn equations_section_with_groups() { - let var1 = make_aux("rate_a", "10", None, ""); - let var2 = make_aux("rate_b", "20", None, ""); - let var3 = make_aux("ungrouped_var", "30", None, ""); - let group = datamodel::ModelGroup { - name: "my_group".to_owned(), - doc: Some("Group docs".to_owned()), - parent: None, - members: vec!["rate_a".to_owned(), "rate_b".to_owned()], - run_enabled: false, - }; - let model = datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables: vec![var1, var2, var3], - views: vec![], - loop_metadata: vec![], - groups: vec![group], - }; - let project = make_project(vec![model]); - - let result = crate::mdl::project_to_mdl(&project); - assert!(result.is_ok(), "should succeed: {:?}", result); - let mdl = result.unwrap(); - - // Group marker present - assert!( - mdl.contains(".my group"), - "should contain group marker, got: {mdl}" - ); - assert!( - mdl.contains("Group docs"), - "should contain group documentation" - ); - - // Grouped variables come before ungrouped - let rate_a_pos = mdl.find("rate a = ").unwrap(); - let ungrouped_pos = mdl.find("ungrouped var = ").unwrap(); - assert!( - rate_a_pos < ungrouped_pos, - "grouped variables should come before ungrouped" - ); - } - - #[test] - fn equations_section_with_dimensions() { - let dim = datamodel::Dimension::named( - "region".to_owned(), - vec!["north".to_owned(), "south".to_owned()], - ); - let var = make_aux("x", "1", None, ""); - let model = make_model(vec![var]); - let mut project = make_project(vec![model]); - project.dimensions.push(dim); - - let result = crate::mdl::project_to_mdl(&project); - assert!(result.is_ok(), "should succeed: {:?}", result); - let mdl = result.unwrap(); - - // Dimension def at the start, before variables - assert!( - mdl.contains("region:\r\n\tnorth, south\r\n\t~~|"), - "should contain dimension def" - ); - let dim_pos = mdl.find("region:").unwrap(); - let var_pos = mdl.find("x = ").unwrap(); - assert!(dim_pos < var_pos, "dimensions should come before variables"); - } - - // ---- Phase 5 Task 1: Sketch element serialization (types 10, 11, 12) ---- - - #[test] - fn sketch_aux_element() { - let aux = view_element::Aux { - name: "Growth_Rate".to_string(), - uid: 1, - x: 100.0, - y: 200.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }; - let mut buf = String::new(); - write_aux_element(&mut buf, &aux); - assert_eq!(buf, "10,1,Growth Rate,100,200,40,20,8,3,0,0,-1,0,0,0"); - } - - #[test] - fn sketch_stock_element() { - let stock = view_element::Stock { - name: "Population".to_string(), - uid: 2, - x: 300.0, - y: 150.0, - label_side: view_element::LabelSide::Top, - compat: None, - }; - let mut buf = String::new(); - write_stock_element(&mut buf, &stock); - assert_eq!(buf, "10,2,Population,300,150,40,20,3,3,0,0,0,0,0,0"); - } - - #[test] - fn sketch_flow_element_produces_valve_and_variable() { - let flow = view_element::Flow { - name: "Infection_Rate".to_string(), - uid: 6, - x: 295.0, - y: 191.0, - label_side: view_element::LabelSide::Bottom, - points: vec![], - compat: None, - label_compat: None, - }; - let mut buf = String::new(); - let valve_uids = HashMap::from([(6, 100)]); - let mut next_connector_uid = 200; - write_flow_element( - &mut buf, - &flow, - &valve_uids, - &HashSet::new(), - &mut next_connector_uid, - ); - // No flow points, so no pipe connectors; valve and label follow - assert!(buf.contains("11,100,0,295,191,6,8,34,3,0,0,1,0,0,0")); - assert!(buf.contains("10,6,Infection Rate,295,207,49,8,40,3,0,0,-1,0,0,0")); - } - - #[test] - fn sketch_flow_element_emits_pipe_connectors_from_flow_points() { - let flow = view_element::Flow { - name: "Infection_Rate".to_string(), - uid: 6, - x: 150.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - points: vec![ - view_element::FlowPoint { - x: 100.0, - y: 100.0, - attached_to_uid: Some(1), - }, - view_element::FlowPoint { - x: 200.0, - y: 100.0, - attached_to_uid: Some(2), - }, - ], - compat: None, - label_compat: None, - }; - let mut buf = String::new(); - let valve_uids = HashMap::from([(6, 100)]); - let mut next_connector_uid = 200; - write_flow_element( - &mut buf, - &flow, - &valve_uids, - &HashSet::new(), - &mut next_connector_uid, - ); - - let connector_lines: Vec<&str> = - buf.lines().filter(|line| line.starts_with("1,")).collect(); - assert_eq!( - connector_lines.len(), - 2, - "Expected two type-1 connector lines for flow endpoints: {}", - buf - ); - assert!( - connector_lines.iter().any(|line| line.contains(",100,1,")), - "Expected connector from valve uid 100 to endpoint uid 1: {}", - buf - ); - assert!( - connector_lines.iter().any(|line| line.contains(",100,2,")), - "Expected connector from valve uid 100 to endpoint uid 2: {}", - buf - ); - } - - #[test] - fn valve_uids_do_not_collide_with_existing_elements() { - // stock uid=1, flow uid=2 -> valve must NOT get uid=1 - let elements = vec![ - ViewElement::Stock(view_element::Stock { - name: "Population".to_string(), - uid: 1, - x: 100.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }), - ViewElement::Flow(view_element::Flow { - name: "Birth_Rate".to_string(), - uid: 2, - x: 200.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - points: vec![], - compat: None, - label_compat: None, - }), - ]; - - let valve_uids = allocate_valve_uids(&elements); - // The valve for flow uid=2 must not equal 1 (stock's uid) - let valve_uid = valve_uids[&2]; - assert_ne!(valve_uid, 1, "Valve UID collides with stock UID"); - assert_ne!(valve_uid, 2, "Valve UID collides with flow UID"); - } - - #[test] - fn sketch_cloud_element() { - let cloud = view_element::Cloud { - uid: 7, - flow_uid: 6, - x: 479.0, - y: 235.0, - compat: None, - }; - let mut buf = String::new(); - write_cloud_element(&mut buf, &cloud); - assert_eq!(buf, "12,7,48,479,235,10,8,0,3,0,0,-1,0,0,0"); - } - - #[test] - fn sketch_alias_element() { - let alias = view_element::Alias { - uid: 10, - alias_of_uid: 1, - x: 200.0, - y: 300.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }; - let mut name_map = HashMap::new(); - name_map.insert(1, "Growth_Rate"); - let mut buf = String::new(); - write_alias_element(&mut buf, &alias, &name_map); - assert!(buf.starts_with("10,10,Growth Rate,200,300,40,20,8,2,0,3,-1,0,0,0,")); - assert!(buf.contains("128-128-128")); - } - - // ---- Phase 5 Task 2: Connector serialization (type 1) ---- - - #[test] - fn sketch_link_straight() { - let link = view_element::Link { - uid: 3, - from_uid: 1, - to_uid: 2, - shape: LinkShape::Straight, - polarity: None, - }; - let mut positions = HashMap::new(); - positions.insert(1, (100, 100)); - positions.insert(2, (200, 200)); - let mut buf = String::new(); - write_link_element(&mut buf, &link, &positions, false); - // Straight => control point (0,0), field 9 = 64 (influence connector) - assert_eq!(buf, "1,3,1,2,0,0,0,0,0,64,0,-1--1--1,,1|(0,0)|"); - } - - #[test] - fn sketch_link_with_polarity_symbol() { - let link = view_element::Link { - uid: 5, - from_uid: 1, - to_uid: 2, - shape: LinkShape::Straight, - polarity: Some(LinkPolarity::Positive), - }; - let positions = HashMap::new(); - let mut buf = String::new(); - write_link_element(&mut buf, &link, &positions, false); - // polarity=43 ('+'), field 9 = 64 - assert!(buf.contains(",0,0,43,0,0,64,0,")); - } - - #[test] - fn sketch_link_with_polarity_letter() { - let link = view_element::Link { - uid: 5, - from_uid: 1, - to_uid: 2, - shape: LinkShape::Straight, - polarity: Some(LinkPolarity::Positive), - }; - let positions = HashMap::new(); - let mut buf = String::new(); - write_link_element(&mut buf, &link, &positions, true); - // polarity=83 ('S' for lettered positive), field 9 = 64 - assert!(buf.contains(",0,0,83,0,0,64,0,")); - } - - #[test] - fn sketch_link_arc_produces_nonzero_control_point() { - let link = view_element::Link { - uid: 3, - from_uid: 1, - to_uid: 2, - shape: LinkShape::Arc(45.0), - polarity: None, - }; - let mut positions = HashMap::new(); - positions.insert(1, (100, 100)); - positions.insert(2, (200, 100)); - let mut buf = String::new(); - write_link_element(&mut buf, &link, &positions, false); - // Arc should produce a non-(0,0) control point - assert!( - !buf.contains("|(0,0)|"), - "arc should not produce (0,0) control point" - ); - } - - #[test] - fn sketch_link_multipoint_emits_all_points() { - let points = vec![ - view_element::FlowPoint { - x: 150.0, - y: 120.0, - attached_to_uid: None, - }, - view_element::FlowPoint { - x: 170.0, - y: 140.0, - attached_to_uid: None, - }, - view_element::FlowPoint { - x: 190.0, - y: 160.0, - attached_to_uid: None, - }, - ]; - let link = view_element::Link { - uid: 4, - from_uid: 1, - to_uid: 2, - shape: LinkShape::MultiPoint(points), - polarity: None, - }; - let mut positions = HashMap::new(); - positions.insert(1, (100, 100)); - positions.insert(2, (200, 200)); - let mut buf = String::new(); - write_link_element(&mut buf, &link, &positions, false); - assert!( - buf.contains("3|(150,120)|(170,140)|(190,160)|"), - "multipoint should emit all three points: {buf}" - ); - } - - // ---- Phase 5 Task 3: Complete sketch section assembly ---- - - #[test] - fn sketch_section_structure() { - let elements = vec![ - ViewElement::Stock(view_element::Stock { - name: "Population".to_string(), - uid: 1, - x: 100.0, - y: 100.0, - label_side: view_element::LabelSide::Top, - compat: None, - }), - ViewElement::Aux(view_element::Aux { - name: "Growth_Rate".to_string(), - uid: 2, - x: 200.0, - y: 200.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }), - ViewElement::Link(view_element::Link { - uid: 3, - from_uid: 2, - to_uid: 1, - shape: LinkShape::Straight, - polarity: None, - }), - ]; - let sf = datamodel::StockFlow { - name: None, - elements, - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - }; - let views = vec![View::StockFlow(sf)]; - - let mut writer = MdlWriter::new(); - writer.write_sketch_section(&views); - let output = writer.buf; - - // Header - assert!( - output.starts_with("V300 Do not put anything below this section"), - "should start with V300 header" - ); - // View title - assert!(output.contains("*View 1\n"), "should have view title"); - // Font line - assert!( - output.contains("$192-192-192"), - "should have font settings line" - ); - // Elements - assert!( - output.contains("10,1,Population,"), - "should have stock element" - ); - assert!( - output.contains("10,2,Growth Rate,"), - "should have aux element" - ); - assert!(output.contains("1,3,2,1,"), "should have link element"); - // Terminator - assert!( - output.ends_with("///---\\\\\\\n"), - "should end with sketch terminator" - ); - } - - #[test] - fn sketch_section_in_full_project() { - let var = make_aux("x", "1", None, ""); - let elements = vec![ViewElement::Aux(view_element::Aux { - name: "x".to_string(), - uid: 1, - x: 100.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - })]; - let model = datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables: vec![var], - views: vec![View::StockFlow(datamodel::StockFlow { - name: None, - elements, - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - })], - loop_metadata: vec![], - groups: vec![], - }; - let project = make_project(vec![model]); - - let result = crate::mdl::project_to_mdl(&project); - assert!(result.is_ok()); - let mdl = result.unwrap(); - - // The sketch section should appear after the equations terminator - let terminator_pos = mdl - .find("\\\\\\---/// Sketch information") - .expect("should have equations terminator"); - let v300_pos = mdl.find("V300").expect("should have V300 header"); - assert!( - terminator_pos < v300_pos, - "V300 should come after equations terminator" - ); - - // The sketch terminator should be at the end - assert!( - mdl.contains("///---\\\\\\"), - "should have sketch terminator" - ); - } - - #[test] - fn sketch_roundtrip_teacup() { - // Read teacup.mdl, parse to Project, write sketch section, verify structure - let mdl_contents = include_str!("../../../../test/test-models/samples/teacup/teacup.mdl"); - let project = - crate::mdl::parse_mdl(mdl_contents).expect("teacup.mdl should parse successfully"); - - let model = &project.models[0]; - assert!( - !model.views.is_empty(), - "teacup model should have at least one view" - ); - - // Write the sketch section - let mut writer = MdlWriter::new(); - writer.write_sketch_section(&model.views); - let output = writer.buf; - - // Verify structural elements: the teacup model should have stocks, auxes, - // flows (valve + attached variable), links, and clouds. - assert!(output.contains("V300"), "output should contain V300 header"); - assert!( - output.contains("*View 1"), - "output should contain view title" - ); - assert!( - output.contains("///---\\\\\\"), - "output should end with sketch terminator" - ); - - // The teacup model elements (after roundtrip through datamodel): - // Stock: Teacup_Temperature -> type 10 with shape=3 - // Aux: Heat_Loss_to_Room flow -> type 11 valve + type 10 attached - // Aux: Room_Temperature, Characteristic_Time -> type 10 - // Links -> type 1 - // Clouds -> type 12 - - // Count element types in output - let lines: Vec<&str> = output.lines().collect(); - let type10_count = lines.iter().filter(|l| l.starts_with("10,")).count(); - let type11_count = lines.iter().filter(|l| l.starts_with("11,")).count(); - let type12_count = lines.iter().filter(|l| l.starts_with("12,")).count(); - let type1_count = lines.iter().filter(|l| l.starts_with("1,")).count(); - - // Teacup has: 1 stock (Teacup_Temperature), 3 auxes (Heat_Loss_to_Room, - // Room_Temperature, Characteristic_Time), 1 flow (Heat_Loss_to_Room) - // which produces valve+variable, plus 1 cloud. - // The exact numbers depend on the MDL->datamodel conversion, but - // we should have a reasonable set of elements. - assert!( - type10_count >= 2, - "should have at least 2 type-10 elements (variables/stocks), got {type10_count}" - ); - assert!( - type11_count >= 1, - "should have at least 1 type-11 element (valve), got {type11_count}" - ); - assert!( - type12_count >= 1, - "should have at least 1 type-12 element (cloud/comment), got {type12_count}" - ); - assert!( - type1_count >= 1, - "should have at least 1 type-1 element (connector), got {type1_count}" - ); - // Verify no empty lines were introduced between elements - let element_lines: Vec<&&str> = lines - .iter() - .filter(|l| { - l.starts_with("10,") - || l.starts_with("11,") - || l.starts_with("12,") - || l.starts_with("1,") - }) - .collect(); - assert!( - !element_lines.is_empty(), - "should have sketch elements in output" - ); - - // Verify the output can be re-parsed as a valid sketch section - let reparsed = crate::mdl::view::parse_views(&output); - assert!( - reparsed.is_ok(), - "re-serialized sketch should parse: {:?}", - reparsed.err() - ); - let views = reparsed.unwrap(); - assert!( - !views.is_empty(), - "re-parsed sketch should have at least one view" - ); - - // Verify all expected element types are present after re-parse - let view = &views[0]; - let has_variable = view - .iter() - .any(|e| matches!(e, crate::mdl::view::VensimElement::Variable(_))); - let has_connector = view - .iter() - .any(|e| matches!(e, crate::mdl::view::VensimElement::Connector(_))); - assert!(has_variable, "re-parsed view should have variables"); - assert!(has_connector, "re-parsed view should have connectors"); - } - - #[test] - fn sketch_roundtrip_preserves_view_title() { - let mdl_contents = r#"x = 5 -~ ~| -\\\---/// Sketch information -V300 Do not put anything below this section - it will be ignored -*Overview -$192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0 -10,1,x,100,100,40,20,8,3,0,0,-1,0,0,0 -///---\\\ -"#; - - let project = - crate::mdl::parse_mdl(mdl_contents).expect("source MDL should parse successfully"); - let mdl = crate::mdl::project_to_mdl(&project).expect("roundtrip MDL write should work"); - - assert!( - mdl.contains("*Overview\r\n"), - "Roundtrip should preserve original view title: {}", - mdl - ); - } - - #[test] - fn sketch_roundtrip_sanitizes_multiline_view_title() { - let var = make_aux("x", "5", Some("Units"), "A constant"); - let model = datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables: vec![var], - views: vec![View::StockFlow(datamodel::StockFlow { - name: Some("Overview\r\nMain".to_owned()), - elements: vec![ViewElement::Aux(view_element::Aux { - name: "x".to_owned(), - uid: 1, - x: 100.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - })], - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - })], - loop_metadata: vec![], - groups: vec![], - }; - let project = make_project(vec![model]); - - let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); - assert!( - mdl.contains("*Overview Main\r\n"), - "view title should be serialized as a single line: {mdl}", - ); - - let reparsed = crate::mdl::parse_mdl(&mdl).expect("written MDL should parse"); - let View::StockFlow(sf) = &reparsed.models[0].views[0]; - assert_eq!( - sf.name.as_deref(), - Some("Overview Main"), - "sanitized title should roundtrip through MDL", - ); - } - - #[test] - fn sketch_roundtrip_preserves_flow_endpoints_with_nonadjacent_valve_uid() { - let stock_a = Variable::Stock(Stock { - ident: "stock_a".to_owned(), - equation: Equation::Scalar("100".to_owned()), - documentation: String::new(), - units: None, - inflows: vec![], - outflows: vec!["flow_ab".to_owned()], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let stock_b = Variable::Stock(Stock { - ident: "stock_b".to_owned(), - equation: Equation::Scalar("0".to_owned()), - documentation: String::new(), - units: None, - inflows: vec!["flow_ab".to_owned()], - outflows: vec![], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let flow = Variable::Flow(Flow { - ident: "flow_ab".to_owned(), - equation: Equation::Scalar("10".to_owned()), - documentation: String::new(), - units: None, - gf: None, - ai_state: None, - uid: None, - compat: Compat::default(), - }); - - let model = datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables: vec![stock_a, stock_b, flow], - views: vec![View::StockFlow(datamodel::StockFlow { - name: Some("View 1".to_owned()), - elements: vec![ - ViewElement::Stock(view_element::Stock { - name: "Stock_A".to_owned(), - uid: 1, - x: 100.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }), - ViewElement::Stock(view_element::Stock { - name: "Stock_B".to_owned(), - uid: 2, - x: 300.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }), - ViewElement::Flow(view_element::Flow { - name: "Flow_AB".to_owned(), - uid: 6, - x: 200.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - points: vec![ - view_element::FlowPoint { - x: 122.5, - y: 100.0, - attached_to_uid: Some(1), - }, - view_element::FlowPoint { - x: 277.5, - y: 100.0, - attached_to_uid: Some(2), - }, - ], - compat: None, - label_compat: None, - }), - ], - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - })], - loop_metadata: vec![], - groups: vec![], - }; - let project = make_project(vec![model]); - - let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); - let reparsed = crate::mdl::parse_mdl(&mdl).expect("written MDL should parse"); - let View::StockFlow(sf) = &reparsed.models[0].views[0]; - - let stock_uid_by_name: HashMap<&str, i32> = sf - .elements - .iter() - .filter_map(|elem| { - if let ViewElement::Stock(stock) = elem { - Some((stock.name.as_str(), stock.uid)) - } else { - None - } - }) - .collect(); - - let flow = sf - .elements - .iter() - .find_map(|elem| { - if let ViewElement::Flow(flow) = elem { - Some(flow) - } else { - None - } - }) - .expect("expected flow element after roundtrip"); - - assert_eq!( - flow.points.first().and_then(|pt| pt.attached_to_uid), - stock_uid_by_name.get("Stock_A").copied(), - "flow source attachment should roundtrip to Stock_A", - ); - assert_eq!( - flow.points.last().and_then(|pt| pt.attached_to_uid), - stock_uid_by_name.get("Stock_B").copied(), - "flow sink attachment should roundtrip to Stock_B", - ); - } - - #[test] - fn compute_control_point_straight_midpoint() { - // For a nearly-straight arc angle, the control point should be near the midpoint - let from = (100, 100); - let to = (200, 100); - // Canvas angle of 0 degrees = straight line along x-axis - let (cx, cy) = compute_control_point(from, to, 0.0); - // For a straight line, the midpoint should be returned - assert_eq!(cx, 150); - assert_eq!(cy, 100); - } - - #[test] - fn compute_control_point_arc_off_center() { - // A 45-degree arc should produce a control point off the midpoint - let from = (100, 100); - let to = (200, 100); - let (_cx, cy) = compute_control_point(from, to, 45.0); - // The control point should be above or below the line, not on it - assert_ne!(cy, 100, "arc control point should be off the straight line"); - } - - // ---- Phase 6 Task 1: Settings section ---- - - #[test] - fn settings_section_starts_with_marker() { - let project = make_project(vec![make_model(vec![])]); - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - let output = writer.buf; - assert!( - output.starts_with(":L\x7F<%^E!@\n"), - "settings section should start with marker (separator is in sketch section), got: {:?}", - &output[..output.len().min(40)] - ); - } - - #[test] - fn settings_section_contains_type_15_euler() { - let project = make_project(vec![make_model(vec![])]); - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - let output = writer.buf; - assert!( - output.contains("15:0,0,0,0,0,0\n"), - "Euler method should emit method code 0, got: {:?}", - output - ); - } - - #[test] - fn settings_section_contains_type_15_rk4() { - let mut project = make_project(vec![make_model(vec![])]); - project.sim_specs.sim_method = SimMethod::RungeKutta4; - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - let output = writer.buf; - assert!( - output.contains("15:0,0,0,1,0,0\n"), - "RK4 method should emit method code 1, got: {:?}", - output - ); - } - - #[test] - fn settings_section_contains_type_15_rk2() { - let mut project = make_project(vec![make_model(vec![])]); - project.sim_specs.sim_method = SimMethod::RungeKutta2; - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - let output = writer.buf; - assert!( - output.contains("15:0,0,0,3,0,0\n"), - "RK2 method should emit method code 3, got: {:?}", - output - ); - } - - #[test] - fn settings_section_contains_type_22_units() { - let mut project = make_project(vec![make_model(vec![])]); - project.units = vec![ - Unit { - name: "Dollar".to_owned(), - equation: Some("$".to_owned()), - disabled: false, - aliases: vec!["Dollars".to_owned(), "$s".to_owned()], - }, - Unit { - name: "Hour".to_owned(), - equation: None, - disabled: false, - aliases: vec!["Hours".to_owned()], - }, - ]; - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - let output = writer.buf; - assert!( - output.contains("22:$,Dollar,Dollars,$s\n"), - "should contain Dollar unit equivalence, got: {:?}", - output - ); - assert!( - output.contains("22:Hour,Hours\n"), - "should contain Hour unit equivalence, got: {:?}", - output - ); - } - - #[test] - fn settings_section_skips_disabled_units() { - let mut project = make_project(vec![make_model(vec![])]); - project.units = vec![Unit { - name: "Disabled".to_owned(), - equation: None, - disabled: true, - aliases: vec![], - }]; - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - let output = writer.buf; - assert!( - !output.contains("22:Disabled"), - "disabled units should not appear in output" - ); - } - - #[test] - fn settings_section_contains_common_defaults() { - let project = make_project(vec![make_model(vec![])]); - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - let output = writer.buf; - // Type 4 (Time), Type 19 (display), Type 24/25/26 (time bounds) - assert!(output.contains("\n4:Time\n"), "should have Type 4 (Time)"); - assert!( - output.contains("\n19:"), - "should have Type 19 (display settings)" - ); - assert!( - output.contains("\n24:"), - "should have Type 24 (initial time)" - ); - assert!(output.contains("\n25:"), "should have Type 25 (final time)"); - assert!(output.contains("\n26:"), "should have Type 26 (time step)"); - } - - #[test] - fn settings_roundtrip_integration_method() { - // Write settings, then parse them back and check integration method - for method in [ - SimMethod::Euler, - SimMethod::RungeKutta4, - SimMethod::RungeKutta2, - ] { - let mut project = make_project(vec![make_model(vec![])]); - project.sim_specs.sim_method = method; - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - // Prepend the separator that write_sketch_section normally emits - let output = format!("///---\\\\\\\n{}", writer.buf); - - let parser = crate::mdl::settings::PostEquationParser::new(&output); - let settings = parser.parse_settings(); - assert_eq!( - settings.integration_method, method, - "integration method should roundtrip for {:?}", - method - ); - } - } - - #[test] - fn settings_roundtrip_unit_equivalences() { - let mut project = make_project(vec![make_model(vec![])]); - project.units = vec![ - Unit { - name: "Dollar".to_owned(), - equation: Some("$".to_owned()), - disabled: false, - aliases: vec!["Dollars".to_owned()], - }, - Unit { - name: "Hour".to_owned(), - equation: None, - disabled: false, - aliases: vec!["Hours".to_owned(), "Hr".to_owned()], - }, - ]; - let mut writer = MdlWriter::new(); - writer.write_settings_section(&project); - // Prepend the separator that write_sketch_section normally emits - let output = format!("///---\\\\\\\n{}", writer.buf); - - let parser = crate::mdl::settings::PostEquationParser::new(&output); - let settings = parser.parse_settings(); - assert_eq!(settings.unit_equivs.len(), 2); - assert_eq!(settings.unit_equivs[0].name, "Dollar"); - assert_eq!(settings.unit_equivs[0].equation, Some("$".to_string())); - assert_eq!(settings.unit_equivs[0].aliases, vec!["Dollars"]); - assert_eq!(settings.unit_equivs[1].name, "Hour"); - assert_eq!(settings.unit_equivs[1].equation, None); - assert_eq!(settings.unit_equivs[1].aliases, vec!["Hours", "Hr"]); - } - - // ---- Phase 6 Task 2: Full file assembly ---- - - #[test] - fn full_assembly_has_all_three_sections() { - let var = make_aux("x", "5", Some("Units"), "A constant"); - let elements = vec![ViewElement::Aux(view_element::Aux { - name: "x".to_string(), - uid: 1, - x: 100.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - })]; - let model = datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables: vec![var], - views: vec![View::StockFlow(datamodel::StockFlow { - name: None, - elements, - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - })], - loop_metadata: vec![], - groups: vec![], - }; - let project = make_project(vec![model]); - - let result = crate::mdl::project_to_mdl(&project); - assert!( - result.is_ok(), - "project_to_mdl should succeed: {:?}", - result - ); - let mdl = result.unwrap(); - - // Section 1: Equations -- contains variable entry - assert!(mdl.contains("x = "), "should contain equation for x"); - // Equations terminator - assert!( - mdl.contains("\\\\\\---/// Sketch information"), - "should have equations terminator" - ); - - // Section 2: Sketch -- V300 header and elements - assert!(mdl.contains("V300"), "should have V300 sketch header"); - assert!(mdl.contains("*View 1"), "should have view title"); - - // Section 3: Settings -- marker and type codes - assert!(mdl.contains(":L\x7F<%^E!@"), "should have settings marker"); - assert!(mdl.contains("15:"), "should have Type 15 line"); - - // Sections should be in order: equations, sketch, settings - let eq_term = mdl.find("\\\\\\---/// Sketch").unwrap(); - let v300 = mdl.find("V300").unwrap(); - let sketch_term = mdl.find("///---\\\\\\").unwrap(); - let settings_marker = mdl.find(":L\x7F<%^E!@").unwrap(); - assert!(eq_term < v300, "equations should come before sketch"); - assert!( - v300 < sketch_term, - "V300 should come before sketch terminator" - ); - assert!( - sketch_term < settings_marker, - "sketch terminator should come before settings marker" - ); - } - - // ---- Phase 6 Task 3: compat wrapper ---- - - #[test] - fn compat_to_mdl_matches_project_to_mdl() { - let var = make_aux("x", "5", Some("Units"), "A constant"); - let project = make_project(vec![make_model(vec![var])]); - - let direct = crate::mdl::project_to_mdl(&project).unwrap(); - let compat = crate::compat::to_mdl(&project).unwrap(); - assert_eq!( - direct, compat, - "compat::to_mdl should produce same result as mdl::project_to_mdl" - ); - } - - #[test] - fn write_arrayed_with_default_equation_omits_dimension_level_default() { - // When default_equation is set (from EXCEPT syntax), the writer must - // NOT emit name[Dim...]=default because that would apply the default - // equation to excepted elements that should default to 0. - let var = datamodel::Variable::Aux(datamodel::Aux { - ident: "cost".to_string(), - equation: datamodel::Equation::Arrayed( - vec!["region".to_string()], - vec![ - ("north".to_string(), "base+1".to_string(), None, None), - ("south".to_string(), "base+2".to_string(), None, None), - ], - Some("base".to_string()), - true, - ), - documentation: String::new(), - units: Some("dollars".to_string()), - gf: None, - ai_state: None, - uid: None, - compat: datamodel::Compat::default(), - }); - - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &HashMap::new()); - - // Must NOT contain dimension-level default (would apply to excepted elements) - assert!( - !buf.contains("cost[region]="), - "should NOT contain dimension-level default equation, got: {buf}" - ); - // Individual element equations should be present - assert!( - buf.contains("cost[north]="), - "should contain north element equation, got: {buf}" - ); - assert!( - buf.contains("cost[south]="), - "should contain south element equation, got: {buf}" - ); - } - - #[test] - fn write_dimension_with_element_level_mapping() { - let dim = datamodel::Dimension { - name: "dim_a".to_string(), - elements: datamodel::DimensionElements::Named(vec!["a1".to_string(), "a2".to_string()]), - mappings: vec![datamodel::DimensionMapping { - target: "dim_b".to_string(), - element_map: vec![ - ("a1".to_string(), "b2".to_string()), - ("a2".to_string(), "b1".to_string()), - ], - }], - parent: None, - }; - - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - - assert!( - buf.contains("-> (dim b: b2, b1)"), - "element-level mapping must use parenthesized syntax, got: {buf}" - ); - } - - #[test] - fn write_dimension_with_multi_target_positional_mapping() { - let dim = datamodel::Dimension { - name: "dim_a".to_string(), - elements: datamodel::DimensionElements::Named(vec!["a1".to_string(), "a2".to_string()]), - mappings: vec![ - datamodel::DimensionMapping { - target: "dim_b".to_string(), - element_map: vec![], - }, - datamodel::DimensionMapping { - target: "dim_c".to_string(), - element_map: vec![], - }, - ], - parent: None, - }; - - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - - assert!( - buf.contains("dim b") && buf.contains("dim c"), - "both positional mapping targets should be emitted, got: {buf}" - ); - } - - #[test] - fn write_dimension_element_mapping_sorted_by_source_position() { - // element_map entries out of source order should still emit - // targets in the dimension's element order for correct positional - // correspondence on re-import. - let dim = datamodel::Dimension { - name: "dim_a".to_string(), - elements: datamodel::DimensionElements::Named(vec![ - "a1".to_string(), - "a2".to_string(), - "a3".to_string(), - ]), - mappings: vec![datamodel::DimensionMapping { - target: "dim_b".to_string(), - element_map: vec![ - ("a3".to_string(), "b3".to_string()), - ("a1".to_string(), "b1".to_string()), - ("a2".to_string(), "b2".to_string()), - ], - }], - parent: None, - }; - - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - - assert!( - buf.contains("-> (dim b: b1, b2, b3)"), - "targets should be in source element order (a1->b1, a2->b2, a3->b3), got: {buf}" - ); - } - - #[test] - fn write_dimension_element_mapping_case_insensitive_lookup() { - // element_map uses canonical (lowercase) keys, but dim.elements - // may preserve original casing -- the sort must still work. - let dim = datamodel::Dimension { - name: "Region".to_string(), - elements: datamodel::DimensionElements::Named(vec![ - "North".to_string(), - "South".to_string(), - "East".to_string(), - ]), - mappings: vec![datamodel::DimensionMapping { - target: "zone".to_string(), - element_map: vec![ - ("east".to_string(), "z3".to_string()), - ("north".to_string(), "z1".to_string()), - ("south".to_string(), "z2".to_string()), - ], - }], - parent: None, - }; - - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - - assert!( - buf.contains("-> (zone: z1, z2, z3)"), - "targets should be sorted by source element order despite case mismatch, got: {buf}" - ); - } - - #[test] - fn write_dimension_element_mapping_underscored_names() { - // Element names with underscores must be normalized via to_lower_space() - // to match the canonical form used in element_map keys. - let dim = datamodel::Dimension { - name: "Continent".to_string(), - elements: datamodel::DimensionElements::Named(vec![ - "North_America".to_string(), - "South_America".to_string(), - "Europe".to_string(), - ]), - mappings: vec![datamodel::DimensionMapping { - target: "zone".to_string(), - element_map: vec![ - ("europe".to_string(), "z3".to_string()), - ("north america".to_string(), "z1".to_string()), - ("south america".to_string(), "z2".to_string()), - ], - }], - parent: None, - }; - - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - - assert!( - buf.contains("-> (zone: z1, z2, z3)"), - "underscore element names should match canonical element_map keys, got: {buf}" - ); - } - - #[test] - fn write_dimension_one_to_many_falls_back_to_positional() { - // When a source element maps to multiple targets (from subdimension - // expansion), the element-level notation can't round-trip correctly. - // The writer should fall back to a positional dimension-name mapping. - let dim = datamodel::Dimension { - name: "dim_b".to_string(), - elements: datamodel::DimensionElements::Named(vec!["b1".to_string(), "b2".to_string()]), - mappings: vec![datamodel::DimensionMapping { - target: "dim_a".to_string(), - element_map: vec![ - ("b1".to_string(), "a1".to_string()), - ("b1".to_string(), "a2".to_string()), - ("b2".to_string(), "a3".to_string()), - ], - }], - parent: None, - }; - - let mut buf = String::new(); - write_dimension_def(&mut buf, &dim); - - assert!( - buf.contains("-> dim a") && !buf.contains("(dim a:"), - "one-to-many mapping should fall back to positional notation, got: {buf}" - ); - } - - #[test] - fn write_arrayed_with_default_equation_writes_explicit_elements() { - let mut buf = String::new(); - write_arrayed_entries( - &mut buf, - "g", - &["DimA".to_string()], - &[ - ("A1".to_string(), "10".to_string(), None, None), - ("A2".to_string(), "7".to_string(), None, None), - ("A3".to_string(), "7".to_string(), None, None), - ], - &Some("7".to_string()), - &None, - "", - ); - assert!( - !buf.contains("g[DimA]"), - "dimension-level default must not be emitted, got: {buf}" - ); - assert!( - !buf.contains(":EXCEPT:"), - "EXCEPT syntax should not be emitted" - ); - assert!( - buf.contains("g[A1]"), - "A1 entry should be written explicitly, got: {buf}" - ); - assert!( - buf.contains("g[A2]") && buf.contains("g[A3]"), - "all explicit array elements should be written, got: {buf}" - ); - } - - #[test] - fn write_arrayed_no_default_writes_all_elements() { - let mut buf = String::new(); - write_arrayed_entries( - &mut buf, - "h", - &["DimA".to_string()], - &[ - ("A1".to_string(), "8".to_string(), None, None), - ("A2".to_string(), "0".to_string(), None, None), - ], - &None, - &None, - "", - ); - assert!( - !buf.contains(":EXCEPT:"), - "should not emit EXCEPT when no default_equation, got: {buf}" - ); - assert!(buf.contains("h[A1]"), "should write A1 element, got: {buf}"); - assert!(buf.contains("h[A2]"), "should write A2 element, got: {buf}"); - } - - #[test] - fn write_arrayed_except_no_exceptions_all_default() { - let mut buf = String::new(); - write_arrayed_entries( - &mut buf, - "k", - &["DimA".to_string()], - &[ - ("A1".to_string(), "5".to_string(), None, None), - ("A2".to_string(), "5".to_string(), None, None), - ], - &Some("5".to_string()), - &None, - "", - ); - assert!( - !buf.contains("k[DimA]"), - "dimension-level default must not be emitted, got: {buf}" - ); - assert!(buf.contains("k[A1]"), "should write A1 element, got: {buf}"); - assert!(buf.contains("k[A2]"), "should write A2 element, got: {buf}"); - assert!( - !buf.contains(":EXCEPT:"), - "EXCEPT syntax should not be emitted, got: {buf}" - ); - } - - #[test] - fn write_arrayed_except_with_omitted_elements_avoids_dimension_default() { - let mut buf = String::new(); - write_arrayed_entries( - &mut buf, - "h", - &["DimA".to_string()], - &[("A1".to_string(), "8".to_string(), None, None)], - &Some("8".to_string()), - &None, - "", - ); - - assert!( - !buf.contains("h[DimA]"), - "dimension-level default would apply to omitted EXCEPT elements, got: {buf}" - ); - assert!( - buf.contains("h[A1]"), - "explicitly present elements must still be emitted, got: {buf}" - ); - } - - #[test] - fn compat_get_direct_equation_does_not_produce_backslash_escapes() { - let compat = Compat { - data_source: Some(crate::datamodel::DataSource { - kind: crate::datamodel::DataSourceKind::Constants, - file: "data/a.csv".to_string(), - tab_or_delimiter: ",".to_string(), - row_or_col: "B2".to_string(), - cell: String::new(), - }), - ..Compat::default() - }; - let eq = compat_get_direct_equation(&compat).expect("should produce equation"); - assert!( - !eq.contains("\\'"), - "writer must not emit backslash-escaped quotes (parser treats ' as toggle): {eq}" - ); - assert!( - eq.contains("GET DIRECT CONSTANTS"), - "should produce GET DIRECT CONSTANTS: {eq}" - ); - } - - // ---- Multi-view split tests (Phase 3, Tasks 1-2) ---- - - fn make_view_aux(name: &str, uid: i32) -> ViewElement { - ViewElement::Aux(view_element::Aux { - name: name.to_owned(), - uid, - x: 100.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }) - } - - fn make_view_stock(name: &str, uid: i32) -> ViewElement { - ViewElement::Stock(view_element::Stock { - name: name.to_owned(), - uid, - x: 200.0, - y: 200.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }) - } - - fn make_view_flow(name: &str, uid: i32) -> ViewElement { - ViewElement::Flow(view_element::Flow { - name: name.to_owned(), - uid, - x: 150.0, - y: 150.0, - label_side: view_element::LabelSide::Bottom, - points: vec![], - compat: None, - label_compat: None, - }) - } - - fn make_view_group(name: &str, uid: i32) -> ViewElement { - ViewElement::Group(view_element::Group { - uid, - name: name.to_owned(), - x: 0.0, - y: 0.0, - width: 500.0, - height: 500.0, - }) - } - - fn make_stock_flow(elements: Vec) -> StockFlow { - StockFlow { - name: None, - elements, - view_box: Rect::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - } - } - - #[test] - fn split_view_no_groups_returns_single_segment() { - let sf = make_stock_flow(vec![ - make_view_aux("price", 1), - make_view_stock("inventory", 2), - ]); - let segments = split_view_on_groups(&sf); - assert_eq!(segments.len(), 1); - assert_eq!(segments[0].0, "View 1"); - assert_eq!(segments[0].1.len(), 2); - } - - #[test] - fn split_view_no_groups_uses_stockflow_name() { - let mut sf = make_stock_flow(vec![make_view_aux("price", 1)]); - sf.name = Some("My Custom View".to_owned()); - let segments = split_view_on_groups(&sf); - assert_eq!(segments.len(), 1); - assert_eq!(segments[0].0, "My Custom View"); - } - - #[test] - fn split_view_two_groups_produces_two_segments() { - let sf = make_stock_flow(vec![ - make_view_group("1 housing", 100), - make_view_aux("price", 1), - make_view_stock("inventory", 2), - make_view_group("2 investments", 200), - make_view_aux("rate", 3), - make_view_flow("capital_flow", 4), - ]); - let segments = split_view_on_groups(&sf); - assert_eq!(segments.len(), 2, "expected 2 segments from 2 groups"); - assert_eq!(segments[0].0, "1 housing"); - assert_eq!(segments[0].1.len(), 2, "first segment: price + inventory"); - assert_eq!(segments[1].0, "2 investments"); - assert_eq!( - segments[1].1.len(), - 2, - "second segment: rate + capital_flow" - ); - } - - #[test] - fn split_view_elements_partitioned_correctly() { - let sf = make_stock_flow(vec![ - make_view_group("1 housing", 100), - make_view_aux("price", 1), - make_view_stock("inventory", 2), - make_view_group("2 investments", 200), - make_view_aux("rate", 3), - ]); - let segments = split_view_on_groups(&sf); - - // First segment should contain price and inventory - let seg1_names: Vec<&str> = segments[0].1.iter().filter_map(|e| e.get_name()).collect(); - assert_eq!(seg1_names, vec!["price", "inventory"]); - - // Second segment should contain rate - let seg2_names: Vec<&str> = segments[1].1.iter().filter_map(|e| e.get_name()).collect(); - assert_eq!(seg2_names, vec!["rate"]); - } - - #[test] - fn split_view_modules_filtered_out() { - let sf = make_stock_flow(vec![ - make_view_aux("price", 1), - ViewElement::Module(view_element::Module { - name: "submodel".to_owned(), - uid: 99, - x: 0.0, - y: 0.0, - label_side: view_element::LabelSide::Bottom, - }), - ]); - let segments = split_view_on_groups(&sf); - assert_eq!(segments.len(), 1); - assert_eq!(segments[0].1.len(), 1, "module should be filtered out"); - } - - #[test] - fn split_view_preserves_font() { - let mut sf = make_stock_flow(vec![ - make_view_group("view1", 100), - make_view_aux("x", 1), - make_view_group("view2", 200), - make_view_aux("y", 2), - ]); - sf.font = Some("192-192-192,0,Verdana|10||0-0-0".to_owned()); - let segments = split_view_on_groups(&sf); - for (_, _, font) in &segments { - assert_eq!( - font.as_deref(), - Some("192-192-192,0,Verdana|10||0-0-0"), - "all segments should share the StockFlow font" - ); - } - } - - #[test] - fn multi_view_mdl_output_contains_view_headers() { - let sf = make_stock_flow(vec![ - make_view_group("1 housing", 100), - make_view_aux("price", 1), - make_view_group("2 investments", 200), - make_view_aux("rate", 2), - ]); - let views = vec![View::StockFlow(sf)]; - - let mut writer = MdlWriter::new(); - writer.write_sketch_section(&views); - let output = writer.buf; - - assert!( - output.contains("*1 housing"), - "output should contain first view header: {output}" - ); - assert!( - output.contains("*2 investments"), - "output should contain second view header: {output}" - ); - } - - #[test] - fn multi_view_mdl_output_has_separators_between_views() { - let sf = make_stock_flow(vec![ - make_view_group("view1", 100), - make_view_aux("a", 1), - make_view_group("view2", 200), - make_view_aux("b", 2), - ]); - let views = vec![View::StockFlow(sf)]; - - let mut writer = MdlWriter::new(); - writer.write_sketch_section(&views); - let output = writer.buf; - - // The second view should have a V300 header - let v300_count = output.matches("V300").count(); - assert_eq!( - v300_count, 2, - "two views should produce two V300 headers: {output}" - ); - } - - #[test] - fn single_view_no_groups_mdl_output() { - let sf = make_stock_flow(vec![make_view_aux("price", 1)]); - let views = vec![View::StockFlow(sf)]; - - let mut writer = MdlWriter::new(); - writer.write_sketch_section(&views); - let output = writer.buf; - - assert!( - output.contains("*View 1"), - "single view should use default name: {output}" - ); - let v300_count = output.matches("V300").count(); - assert_eq!( - v300_count, 1, - "single view should produce one V300 header: {output}" - ); - } - - #[test] - fn multi_view_uses_font_when_present() { - let mut sf = make_stock_flow(vec![make_view_group("view1", 100), make_view_aux("a", 1)]); - sf.font = Some( - "192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0" - .to_owned(), - ); - let views = vec![View::StockFlow(sf)]; - - let mut writer = MdlWriter::new(); - writer.write_sketch_section(&views); - let output = writer.buf; - - assert!( - output.contains("$192-192-192,0,Verdana|10||"), - "should use preserved font: {output}" - ); - assert!( - !output.contains("Times New Roman"), - "should not use default font when custom font present: {output}" - ); - } - - #[test] - fn single_view_uses_default_font_when_none() { - let sf = make_stock_flow(vec![make_view_aux("a", 1)]); - let views = vec![View::StockFlow(sf)]; - - let mut writer = MdlWriter::new(); - writer.write_sketch_section(&views); - let output = writer.buf; - - assert!( - output.contains("Times New Roman|12"), - "should use default font when font is None: {output}" - ); - } - - // ---- Task 5: compat dimensions in element output ---- - - #[test] - fn stock_compat_dimensions_emitted() { - let stock = view_element::Stock { - name: "Population".to_string(), - uid: 2, - x: 300.0, - y: 150.0, - label_side: view_element::LabelSide::Top, - compat: Some(view_element::ViewElementCompat { - width: 53.0, - height: 32.0, - bits: 131, - }), - }; - let mut buf = String::new(); - write_stock_element(&mut buf, &stock); - assert!( - buf.contains(",53,32,3,131,"), - "stock with compat should emit preserved dimensions: {buf}" - ); - } - - #[test] - fn stock_default_dimensions_without_compat() { - let stock = view_element::Stock { - name: "Population".to_string(), - uid: 2, - x: 300.0, - y: 150.0, - label_side: view_element::LabelSide::Top, - compat: None, - }; - let mut buf = String::new(); - write_stock_element(&mut buf, &stock); - assert!( - buf.contains(",40,20,3,3,"), - "stock without compat should use default 40,20,3,3: {buf}" - ); - } - - #[test] - fn aux_compat_dimensions_emitted() { - let aux = view_element::Aux { - name: "Rate".to_string(), - uid: 1, - x: 100.0, - y: 200.0, - label_side: view_element::LabelSide::Bottom, - compat: Some(view_element::ViewElementCompat { - width: 45.0, - height: 18.0, - bits: 131, - }), - }; - let mut buf = String::new(); - write_aux_element(&mut buf, &aux); - assert!( - buf.contains(",45,18,8,131,"), - "aux with compat should emit preserved dimensions: {buf}" - ); - } - - #[test] - fn aux_default_dimensions_without_compat() { - let aux = view_element::Aux { - name: "Rate".to_string(), - uid: 1, - x: 100.0, - y: 200.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }; - let mut buf = String::new(); - write_aux_element(&mut buf, &aux); - assert!( - buf.contains(",40,20,8,3,"), - "aux without compat should use default 40,20,8,3: {buf}" - ); - } - - #[test] - fn flow_valve_compat_dimensions_emitted() { - let flow = view_element::Flow { - name: "Birth_Rate".to_string(), - uid: 6, - x: 295.0, - y: 191.0, - label_side: view_element::LabelSide::Bottom, - points: vec![], - compat: Some(view_element::ViewElementCompat { - width: 12.0, - height: 18.0, - bits: 131, - }), - label_compat: Some(view_element::ViewElementCompat { - width: 55.0, - height: 14.0, - bits: 35, - }), - }; - let mut buf = String::new(); - let valve_uids = HashMap::from([(6, 100)]); - let mut next_connector_uid = 200; - write_flow_element( - &mut buf, - &flow, - &valve_uids, - &HashSet::new(), - &mut next_connector_uid, - ); - // Valve line should use flow.compat dimensions - assert!( - buf.contains(",12,18,34,131,"), - "valve with compat should emit preserved dimensions: {buf}" - ); - // Label line should use flow.label_compat dimensions - assert!( - buf.contains(",55,14,40,35,"), - "flow label with label_compat should emit preserved dimensions: {buf}" - ); - } - - #[test] - fn flow_default_dimensions_without_compat() { - let flow = view_element::Flow { - name: "Birth_Rate".to_string(), - uid: 6, - x: 295.0, - y: 191.0, - label_side: view_element::LabelSide::Bottom, - points: vec![], - compat: None, - label_compat: None, - }; - let mut buf = String::new(); - let valve_uids = HashMap::from([(6, 100)]); - let mut next_connector_uid = 200; - write_flow_element( - &mut buf, - &flow, - &valve_uids, - &HashSet::new(), - &mut next_connector_uid, - ); - // Valve line should use default dimensions - assert!( - buf.contains(",6,8,34,3,"), - "valve without compat should use default 6,8,34,3: {buf}" - ); - // Label line should use default dimensions - assert!( - buf.contains(",49,8,40,3,"), - "flow label without label_compat should use default 49,8,40,3: {buf}" - ); - } - - #[test] - fn cloud_compat_dimensions_emitted() { - let cloud = view_element::Cloud { - uid: 7, - flow_uid: 6, - x: 479.0, - y: 235.0, - compat: Some(view_element::ViewElementCompat { - width: 20.0, - height: 14.0, - bits: 131, - }), - }; - let mut buf = String::new(); - write_cloud_element(&mut buf, &cloud); - assert!( - buf.contains(",20,14,0,131,"), - "cloud with compat should emit preserved dimensions: {buf}" - ); - } - - #[test] - fn cloud_default_dimensions_without_compat() { - let cloud = view_element::Cloud { - uid: 7, - flow_uid: 6, - x: 479.0, - y: 235.0, - compat: None, - }; - let mut buf = String::new(); - write_cloud_element(&mut buf, &cloud); - assert!( - buf.contains(",10,8,0,3,"), - "cloud without compat should use default 10,8,0,3: {buf}" - ); - } - - #[test] - fn alias_compat_dimensions_emitted() { - let alias = view_element::Alias { - uid: 10, - alias_of_uid: 1, - x: 200.0, - y: 300.0, - label_side: view_element::LabelSide::Bottom, - compat: Some(view_element::ViewElementCompat { - width: 45.0, - height: 18.0, - bits: 66, - }), - }; - let mut name_map = HashMap::new(); - name_map.insert(1, "Growth_Rate"); - let mut buf = String::new(); - write_alias_element(&mut buf, &alias, &name_map); - assert!( - buf.contains(",45,18,8,66,"), - "alias with compat should emit preserved dimensions: {buf}" - ); - } - - #[test] - fn alias_default_dimensions_without_compat() { - let alias = view_element::Alias { - uid: 10, - alias_of_uid: 1, - x: 200.0, - y: 300.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }; - let mut name_map = HashMap::new(); - name_map.insert(1, "Growth_Rate"); - let mut buf = String::new(); - write_alias_element(&mut buf, &alias, &name_map); - assert!( - buf.contains(",40,20,8,2,"), - "alias without compat should use default 40,20,8,2: {buf}" - ); - } - - // ---- Phase 4 Task 3/4: Equation LHS casing from view element names ---- - - #[test] - fn build_display_name_map_extracts_view_element_names() { - let views = vec![View::StockFlow(StockFlow { - name: None, - elements: vec![ - ViewElement::Aux(view_element::Aux { - name: "Endogenous Federal Funds Rate".to_owned(), - uid: 1, - x: 0.0, - y: 0.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }), - ViewElement::Stock(view_element::Stock { - name: "Population Level".to_owned(), - uid: 2, - x: 0.0, - y: 0.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }), - ViewElement::Flow(view_element::Flow { - name: "Birth Rate".to_owned(), - uid: 3, - x: 0.0, - y: 0.0, - label_side: view_element::LabelSide::Bottom, - points: vec![], - compat: None, - label_compat: None, - }), - ], - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - })]; - let map = build_display_name_map(&views); - assert_eq!( - map.get("endogenous_federal_funds_rate").map(|s| s.as_str()), - Some("Endogenous Federal Funds Rate"), - ); - assert_eq!( - map.get("population_level").map(|s| s.as_str()), - Some("Population Level"), - ); - assert_eq!( - map.get("birth_rate").map(|s| s.as_str()), - Some("Birth Rate"), - ); - } - - #[test] - fn build_display_name_map_first_occurrence_wins() { - // If a name appears in multiple views, the first one wins - let views = vec![View::StockFlow(StockFlow { - name: None, - elements: vec![ - ViewElement::Aux(view_element::Aux { - name: "Growth Rate".to_owned(), - uid: 1, - x: 0.0, - y: 0.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }), - ViewElement::Aux(view_element::Aux { - name: "growth rate".to_owned(), - uid: 5, - x: 0.0, - y: 0.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - }), - ], - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - })]; - let map = build_display_name_map(&views); - // The first element's casing wins - assert_eq!( - map.get("growth_rate").map(|s| s.as_str()), - Some("Growth Rate"), - ); - } - - #[test] - fn equation_lhs_uses_view_element_casing() { - let var = make_aux( - "endogenous_federal_funds_rate", - "0.05", - Some("1/Year"), - "Rate var", - ); - let views = vec![View::StockFlow(StockFlow { - name: None, - elements: vec![ViewElement::Aux(view_element::Aux { - name: "Endogenous Federal Funds Rate".to_owned(), - uid: 1, - x: 0.0, - y: 0.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - })], - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - })]; - let display_names = build_display_name_map(&views); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &display_names); - assert!( - buf.starts_with("Endogenous Federal Funds Rate = "), - "LHS should use view element casing, got: {buf}" - ); - } - - #[test] - fn equation_lhs_fallback_without_view_element() { - let var = make_aux("unmatched_variable", "42", None, ""); - let display_names = HashMap::new(); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &display_names); - assert!( - buf.starts_with("unmatched variable = "), - "LHS should fall back to format_mdl_ident when no view element matches, got: {buf}" - ); - } - - #[test] - fn equation_lhs_casing_for_stock() { - let var = Variable::Stock(Stock { - ident: "population_level".to_owned(), - equation: Equation::Scalar("1000".to_owned()), - documentation: String::new(), - units: None, - inflows: vec!["births".to_owned()], - outflows: vec![], - ai_state: None, - uid: None, - compat: Compat::default(), - }); - let mut display_names = HashMap::new(); - display_names.insert("population_level".to_owned(), "Population Level".to_owned()); - let mut buf = String::new(); - write_variable_entry(&mut buf, &var, &display_names); - assert!( - buf.starts_with("Population Level="), - "Stock LHS should use view element casing, got: {buf}" - ); - } - - #[test] - fn equation_lhs_casing_in_full_project_roundtrip() { - let var = make_aux("growth_rate", "0.05", Some("1/Year"), "Rate"); - let elements = vec![ViewElement::Aux(view_element::Aux { - name: "Growth Rate".to_owned(), - uid: 1, - x: 100.0, - y: 100.0, - label_side: view_element::LabelSide::Bottom, - compat: None, - })]; - let model = datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables: vec![var], - views: vec![View::StockFlow(datamodel::StockFlow { - name: None, - elements, - view_box: Default::default(), - zoom: 1.0, - use_lettered_polarity: false, - font: None, - })], - loop_metadata: vec![], - groups: vec![], - }; - let project = make_project(vec![model]); - let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); - assert!( - mdl.contains("Growth Rate = "), - "Full project MDL should use view element casing on LHS, got: {mdl}" - ); - } - - // ---- Phase 5 Subcomponent C: Variable ordering ---- - - #[test] - fn ungrouped_variables_sorted_alphabetically() { - // Variables inserted in non-alphabetical order: c, a, b - let var_c = make_aux("c_var", "3", None, ""); - let var_a = make_aux("a_var", "1", None, ""); - let var_b = make_aux("b_var", "2", None, ""); - let model = make_model(vec![var_c, var_a, var_b]); - let project = make_project(vec![model]); - - let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); - - let pos_a = mdl.find("a var = ").expect("should contain a var"); - let pos_b = mdl.find("b var = ").expect("should contain b var"); - let pos_c = mdl.find("c var = ").expect("should contain c var"); - assert!( - pos_a < pos_b && pos_b < pos_c, - "ungrouped variables should appear in alphabetical order: a={pos_a}, b={pos_b}, c={pos_c}" - ); - } - - #[test] - fn grouped_variables_retain_group_order() { - // Group members in a specific order: z, m, a -- should NOT be alphabetized - let var_z = make_aux("z_rate", "10", None, ""); - let var_m = make_aux("m_rate", "20", None, ""); - let var_a = make_aux("a_rate", "30", None, ""); - let var_ungrouped = make_aux("ungrouped_x", "40", None, ""); - - let group = datamodel::ModelGroup { - name: "My Sector".to_owned(), - doc: Some("Sector docs".to_owned()), - parent: None, - members: vec![ - "z_rate".to_owned(), - "m_rate".to_owned(), - "a_rate".to_owned(), - ], - run_enabled: false, - }; - - let model = datamodel::Model { - name: "default".to_owned(), - sim_specs: None, - variables: vec![var_z, var_m, var_a, var_ungrouped], - views: vec![], - loop_metadata: vec![], - groups: vec![group], - }; - let project = make_project(vec![model]); - - let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); - - // Grouped vars should appear in group order (z, m, a), not alphabetical - let pos_z = mdl.find("z rate = ").expect("should contain z rate"); - let pos_m = mdl.find("m rate = ").expect("should contain m rate"); - let pos_a = mdl.find("a rate = ").expect("should contain a rate"); - assert!( - pos_z < pos_m && pos_m < pos_a, - "grouped variables should retain group order: z={pos_z}, m={pos_m}, a={pos_a}" - ); - - // Ungrouped variables should come after grouped section - let pos_ungrouped = mdl - .find("ungrouped x = ") - .expect("should contain ungrouped x"); - assert!( - pos_a < pos_ungrouped, - "ungrouped variables should come after grouped: a={pos_a}, ungrouped={pos_ungrouped}" - ); - } -} +#[path = "writer_tests.rs"] +mod tests; diff --git a/src/simlin-engine/src/mdl/writer_tests.rs b/src/simlin-engine/src/mdl/writer_tests.rs new file mode 100644 index 00000000..cd8ba359 --- /dev/null +++ b/src/simlin-engine/src/mdl/writer_tests.rs @@ -0,0 +1,3741 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +use super::*; +use crate::ast::{Expr0, IndexExpr0, Loc}; +use crate::common::RawIdent; +use crate::datamodel::{ + Aux, Compat, Equation, Flow, GraphicalFunction, GraphicalFunctionKind, GraphicalFunctionScale, + Rect, SimMethod, Stock, StockFlow, Unit, Variable, view_element, +}; +use crate::lexer::LexerType; +use crate::mdl::LOOKUP_SENTINEL; + +/// Parse XMILE equation text to Expr0, then convert to MDL and assert. +fn assert_mdl(xmile_eqn: &str, expected_mdl: &str) { + let ast = Expr0::new(xmile_eqn, LexerType::Equation) + .expect("parse should succeed") + .expect("expression should not be empty"); + let mdl = expr0_to_mdl(&ast); + assert_eq!( + expected_mdl, &mdl, + "MDL mismatch for XMILE input: {xmile_eqn:?}" + ); +} + +#[test] +fn constants() { + assert_mdl("5", "5"); + assert_mdl("3.14", "3.14"); + assert_mdl("1e3", "1e3"); +} + +#[test] +fn nan_constant() { + let ast = Expr0::new("NAN", LexerType::Equation).unwrap().unwrap(); + let mdl = expr0_to_mdl(&ast); + assert_eq!("NaN", &mdl); +} + +#[test] +fn variable_references() { + assert_mdl("population_growth_rate", "population growth rate"); + assert_mdl("x", "x"); + assert_mdl("a_b_c", "a b c"); +} + +#[test] +fn variable_references_quote_special_identifiers() { + let special = Expr0::Var(RawIdent::new_from_str("$_euro"), Loc::default()); + assert_eq!(expr0_to_mdl(&special), "\"$ euro\""); + + let expr = Expr0::Op2( + BinaryOp::Add, + Box::new(Expr0::Var(RawIdent::new_from_str("$_euro"), Loc::default())), + Box::new(Expr0::Var( + RawIdent::new_from_str("revenue"), + Loc::default(), + )), + Loc::default(), + ); + assert_eq!(expr0_to_mdl(&expr), "\"$ euro\" + revenue"); +} + +#[test] +fn quoted_identifiers_escape_embedded_quotes_and_backslashes() { + assert_eq!(escape_mdl_quoted_ident(r#"it"s"#), r#"it\"s"#); + assert_eq!(escape_mdl_quoted_ident(r"back\slash"), r"back\\slash"); + assert_eq!(escape_mdl_quoted_ident(r#"a"b\c"#), r#"a\"b\\c"#,); + + assert_eq!(format_mdl_ident(r#"it"s_a_test"#), r#""it\"s a test""#,); +} + +#[test] +fn quoted_identifiers_handle_newlines() { + // Literal newline chars become the two-character escape \n + assert_eq!( + escape_mdl_quoted_ident("Maximum\nfishery size"), + r"Maximum\nfishery size" + ); + // Already-escaped \n (two chars: backslash + n) stays as-is + assert_eq!( + escape_mdl_quoted_ident(r"Maximum\nfishery size"), + r"Maximum\nfishery size" + ); + // Full round through format_mdl_ident: name with literal newline + assert_eq!( + format_mdl_ident("Maximum\nfishery_size"), + r#""Maximum\nfishery size""# + ); +} + +#[test] +fn needs_mdl_quoting_edge_cases() { + assert!(needs_mdl_quoting("")); + assert!(needs_mdl_quoting(" leading")); + assert!(needs_mdl_quoting("trailing ")); + assert!(needs_mdl_quoting("1starts_with_digit")); + assert!(!needs_mdl_quoting("normal name")); + assert!(!needs_mdl_quoting("_private")); + assert!(needs_mdl_quoting("has/slash")); + assert!(needs_mdl_quoting("has|pipe")); +} + +#[test] +fn arithmetic_operators() { + assert_mdl("a + b", "a + b"); + assert_mdl("a - b", "a - b"); + assert_mdl("a * b", "a * b"); + assert_mdl("a / b", "a / b"); + assert_mdl("a ^ b", "a ^ b"); +} + +#[test] +fn precedence_no_extra_parens() { + assert_mdl("a + b * c", "a + b * c"); +} + +#[test] +fn right_child_same_precedence_non_commutative() { + // a - (b - c) must preserve parens: subtraction is not associative + assert_mdl("a - (b - c)", "a - (b - c)"); + // a / (b / c) must preserve parens: division is not associative + assert_mdl("a / (b / c)", "a / (b / c)"); + // a - (b + c) must preserve parens: + has same precedence as - + assert_mdl("a - (b + c)", "a - (b + c)"); +} + +#[test] +fn left_child_same_precedence_no_extra_parens() { + // (a - b) - c should NOT get extra parens: left-to-right is natural + assert_mdl("a - b - c", "a - b - c"); + // (a / b) / c should NOT get extra parens + assert_mdl("a / b / c", "a / b / c"); + // (a + b) + c should NOT get extra parens: + is commutative anyway + assert_mdl("a + b + c", "a + b + c"); +} + +#[test] +fn precedence_parens_emitted() { + assert_mdl("(a + b) * c", "(a + b) * c"); +} + +#[test] +fn nested_precedence() { + assert_mdl("a * (b + c) / d", "a * (b + c) / d"); +} + +#[test] +fn unary_operators() { + assert_mdl("-a", "-a"); + assert_mdl("+a", "+a"); + // XMILE uses `not` keyword; MDL uses `:NOT:` with a trailing space before the operand + assert_mdl("not a", ":NOT: a"); +} + +#[test] +fn logical_operators_and() { + // XMILE uses `and` keyword; MDL uses `:AND:` infix operator + assert_mdl("a and b", "a :AND: b"); +} + +#[test] +fn logical_operators_or() { + // XMILE uses `or` keyword; MDL uses `:OR:` infix operator + assert_mdl("a or b", "a :OR: b"); +} + +#[test] +fn function_rename_smooth() { + assert_mdl("smth1(x, 5)", "SMOOTH(x, 5)"); +} + +#[test] +fn function_rename_smooth3() { + assert_mdl("smth3(x, 5)", "SMOOTH3(x, 5)"); +} + +#[test] +fn function_rename_safediv() { + assert_mdl("safediv(a, b)", "ZIDZ(a, b)"); +} + +#[test] +fn function_rename_safediv_three_args_emits_xidz() { + assert_mdl("safediv(a, b, x)", "XIDZ(a, b, x)"); +} + +#[test] +fn function_rename_init() { + assert_mdl("init(x, 10)", "ACTIVE INITIAL(x, 10)"); +} + +#[test] +fn function_rename_int() { + assert_mdl("int(x)", "INTEGER(x)"); +} + +#[test] +fn function_rename_uniform() { + assert_mdl("uniform(0, 10)", "RANDOM UNIFORM(0, 10)"); +} + +#[test] +fn function_rename_forcst() { + assert_mdl("forcst(x, 5, 0)", "FORECAST(x, 5, 0)"); +} + +#[test] +fn function_rename_delay() { + assert_mdl("delay(x, 5, 0)", "DELAY FIXED(x, 5, 0)"); +} + +#[test] +fn function_rename_delay1() { + assert_mdl("delay1(x, 5)", "DELAY1(x, 5)"); +} + +#[test] +fn function_rename_delay3() { + assert_mdl("delay3(x, 5)", "DELAY3(x, 5)"); +} + +#[test] +fn function_rename_integ() { + assert_mdl( + "integ(inflow - outflow, 100)", + "INTEG(inflow - outflow, 100)", + ); +} + +#[test] +fn function_rename_lookupinv() { + assert_mdl("lookupinv(tbl, 0.5)", "LOOKUP INVERT(tbl, 0.5)"); +} + +#[test] +fn function_rename_normalpink() { + assert_mdl("normalpink(x, 5)", "RANDOM PINK NOISE(x, 5)"); +} + +#[test] +fn lookup_call_native_vensim_syntax() { + // Vensim uses `table ( input )` syntax for lookup calls + assert_mdl("lookup(tbl, x)", "tbl ( x )"); +} + +#[test] +fn function_unknown_uppercased() { + assert_mdl("abs(x)", "ABS(x)"); + assert_mdl("ln(x)", "LN(x)"); + assert_mdl("max(a, b)", "MAX(a, b)"); +} + +#[test] +fn arg_reorder_delay_n() { + // XMILE: delayn(input, delay_time, n, init) -> MDL: DELAY N(input, delay_time, init, n) + assert_mdl( + "delayn(input, delay_time, 3, init_val)", + "DELAY N(input, delay time, init val, 3)", + ); +} + +#[test] +fn arg_reorder_smooth_n() { + // XMILE: smthn(input, delay_time, n, init) -> MDL: SMOOTH N(input, delay_time, init, n) + assert_mdl( + "smthn(input, delay_time, 3, init_val)", + "SMOOTH N(input, delay time, init val, 3)", + ); +} + +#[test] +fn arg_reorder_random_normal() { + // XMILE: normal(mean, sd, seed, min, max) -> MDL: RANDOM NORMAL(min, max, mean, sd, seed) + assert_mdl( + "normal(mean, sd, seed, min_val, max_val)", + "RANDOM NORMAL(min val, max val, mean, sd, seed)", + ); +} + +// -- pattern recognizer tests (Task 2) -- + +#[test] +fn pattern_random_0_1() { + // XMILE: uniform(0, 1) -> MDL: RANDOM 0 1() + assert_mdl("uniform(0, 1)", "RANDOM 0 1()"); +} + +#[test] +fn pattern_random_0_1_not_matched_different_args() { + // uniform with non-(0,1) args should NOT match the RANDOM 0 1 pattern + assert_mdl("uniform(0, 10)", "RANDOM UNIFORM(0, 10)"); +} + +#[test] +fn pattern_log_2arg() { + // XMILE: ln(x) / ln(base) -> MDL: LOG(x, base) + assert_mdl("ln(x) / ln(base)", "LOG(x, base)"); +} + +#[test] +fn pattern_quantum() { + // XMILE: q * int(x / q) -> MDL: QUANTUM(x, q) + assert_mdl("q * int(x / q)", "QUANTUM(x, q)"); +} + +#[test] +fn pattern_quantum_not_matched_different_q() { + // q1 * int(x / q2) should NOT match QUANTUM when q1 != q2 + assert_mdl("q1 * int(x / q2)", "q1 * INTEGER(x / q2)"); +} + +#[test] +fn pattern_pulse() { + // XMILE expansion of PULSE(start, width): + // IF TIME >= start AND TIME < (start + MAX(DT, width)) THEN 1 ELSE 0 + assert_mdl( + "if time >= start and time < (start + max(dt, width)) then 1 else 0", + "PULSE(start, width)", + ); +} + +#[test] +fn pattern_pulse_not_matched_missing_lt() { + // Missing the Lt branch -- should fall through to mechanical conversion + assert_mdl( + "if time >= start then 1 else 0", + "IF THEN ELSE(Time >= start, 1, 0)", + ); +} + +#[test] +fn pattern_pulse_train() { + // XMILE expansion of PULSE TRAIN(start, width, interval, end_val): + // IF TIME >= start AND TIME <= end_val AND (TIME - start) MOD interval < width THEN 1 ELSE 0 + assert_mdl( + "if time >= start and time <= end_val and (time - start) mod interval < width then 1 else 0", + "PULSE TRAIN(start, width, interval, end val)", + ); +} + +#[test] +fn mod_emits_modulo() { + assert_mdl("a mod b", "MODULO(a, b)"); + assert_mdl("(time) mod (5)", "MODULO(Time, 5)"); +} + +#[test] +fn pattern_sample_if_true() { + // XMILE expansion of SAMPLE IF TRUE(cond, input, init): + // IF cond THEN input ELSE PREVIOUS(SELF, init) + assert_mdl( + "if cond then input else previous(self, init_val)", + "SAMPLE IF TRUE(cond, input, init val)", + ); +} + +#[test] +fn pattern_time_base() { + // XMILE expansion of TIME BASE(t, delta): + // t + delta * TIME + assert_mdl("t_val + delta * time", "TIME BASE(t val, delta)"); +} + +#[test] +fn pattern_random_poisson() { + // XMILE expansion of RANDOM POISSON(min, max, mean, sdev, factor, seed): + // poisson(mean / dt, seed, min, max) * factor + sdev + assert_mdl( + "poisson(mean / dt, seed, min_val, max_val) * factor + sdev", + "RANDOM POISSON(min val, max val, mean, sdev, factor, seed)", + ); +} + +#[test] +fn pattern_fallthrough_no_match() { + // An If expression that doesn't match any pattern should use mechanical conversion + assert_mdl("if a > b then c else d", "IF THEN ELSE(a > b, c, d)"); +} + +#[test] +fn pattern_allocate_by_priority() { + // XMILE expansion of ALLOCATE BY PRIORITY(demand[region], priority, ignore, width, supply): + // allocate(supply, region, demand[region.*], priority, width) + // + // The last subscript (region.*) is replaced with the dimension name, yielding demand[region]. + // The arguments are reordered: demand first, then priority, then 0 (ignore), width, supply. + assert_mdl( + "allocate(supply, region, demand[region.*], priority, width)", + "ALLOCATE BY PRIORITY(demand[region], priority, 0, width, supply)", + ); +} + +#[test] +fn pattern_allocate_by_priority_no_subscript() { + // When the demand argument has no subscript (simple variable), it passes through as-is. + assert_mdl( + "allocate(supply, region, demand, priority, width)", + "ALLOCATE BY PRIORITY(demand, priority, 0, width, supply)", + ); +} + +// ---- lookup call syntax tests ---- + +#[test] +fn lookup_call_with_spaced_table_name() { + // Multi-word table ident should be space-separated in output + assert_mdl( + "lookup(federal_funds_rate_lookup, time)", + "federal funds rate lookup ( Time )", + ); +} + +#[test] +fn lookup_call_with_expression_input() { + // The input argument can be an arbitrary expression + assert_mdl("lookup(my_table, a + b)", "my table ( a + b )"); +} + +#[test] +fn lookup_non_var_first_arg_falls_through() { + // When the first arg is not a bare variable (e.g. a subscripted + // reference), the generic LOOKUP(...) path is used as a fallback. + let table_sub = Expr0::Subscript( + RawIdent::new_from_str("tbl"), + vec![IndexExpr0::Expr(Expr0::Var( + RawIdent::new_from_str("i"), + Loc::default(), + ))], + Loc::default(), + ); + let input = Expr0::Var(RawIdent::new_from_str("x"), Loc::default()); + let expr = Expr0::App( + UntypedBuiltinFn("lookup".to_owned(), vec![table_sub, input]), + Loc::default(), + ); + let mdl = expr0_to_mdl(&expr); + assert_eq!(mdl, "LOOKUP(tbl[i], x)"); +} + +#[test] +fn non_lookup_function_emits_normally() { + // Other function calls should not be affected by the lookup special-case + assert_mdl("max(a, b)", "MAX(a, b)"); + assert_mdl("min(x, y)", "MIN(x, y)"); +} + +// ---- Task 1: Variable entry formatting (scalar) ---- + +fn make_aux(ident: &str, eqn: &str, units: Option<&str>, doc: &str) -> Variable { + Variable::Aux(Aux { + ident: ident.to_owned(), + equation: Equation::Scalar(eqn.to_owned()), + documentation: doc.to_owned(), + units: units.map(|u| u.to_owned()), + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }) +} + +fn make_stock(ident: &str, eqn: &str, units: Option<&str>, doc: &str) -> Variable { + Variable::Stock(Stock { + ident: ident.to_owned(), + equation: Equation::Scalar(eqn.to_owned()), + documentation: doc.to_owned(), + units: units.map(|u| u.to_owned()), + inflows: vec![], + outflows: vec![], + ai_state: None, + uid: None, + compat: Compat::default(), + }) +} + +fn make_gf() -> GraphicalFunction { + GraphicalFunction { + kind: GraphicalFunctionKind::Continuous, + x_points: Some(vec![0.0, 1.0, 2.0]), + y_points: vec![0.0, 0.5, 1.0], + x_scale: GraphicalFunctionScale { min: 0.0, max: 2.0 }, + y_scale: GraphicalFunctionScale { min: 0.0, max: 1.0 }, + } +} + +#[test] +fn scalar_aux_entry() { + let var = make_aux("characteristic_time", "10", Some("Minutes"), "How long"); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert_eq!( + buf, + "characteristic time = 10\n\t~\tMinutes\n\t~\tHow long\n\t|" + ); +} + +#[test] +fn scalar_aux_entry_quotes_special_identifier_name() { + let var = make_aux("$_euro", "10", Some("Dmnl"), ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert_eq!(buf, "\"$ euro\" = 10\n\t~\tDmnl\n\t~\t\n\t|"); +} + +#[test] +fn scalar_aux_no_units() { + let var = make_aux("rate", "a + b", None, ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert_eq!(buf, "rate = a + b\n\t~\t\n\t~\t\n\t|"); +} + +// ---- Inline vs multiline equation formatting ---- + +#[test] +fn short_equation_uses_inline_format() { + let var = make_aux("average_repayment_rate", "0.03", Some("1/Year"), ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.starts_with("average repayment rate = 0.03\n"), + "short equation should use inline format: {buf}" + ); + assert!( + !buf.contains("=\n\t0.03"), + "short equation should not use multiline format: {buf}" + ); +} + +#[test] +fn long_equation_uses_multiline_format() { + // Build an equation that, combined with the name, exceeds 80 chars + let long_eqn = + "very_long_variable_a + very_long_variable_b + very_long_variable_c + very_long_variable_d"; + let var = make_aux("some_computed_value", long_eqn, None, ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains("some computed value=\n\t"), + "long equation should use multiline format: {buf}" + ); +} + +#[test] +fn lookup_always_uses_multiline_format() { + let gf = make_gf(); + let var = Variable::Aux(Aux { + ident: "x".to_owned(), + equation: Equation::Scalar("TIME".to_owned()), + documentation: String::new(), + units: None, + gf: Some(gf), + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.starts_with("x=\n\t"), + "lookup equation should always use multiline format: {buf}" + ); +} + +#[test] +fn data_equation_uses_data_equals_inline() { + let var = make_aux( + "small_data", + "{GET_DIRECT_DATA('f.csv',',','A','B')}", + None, + "", + ); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains(":="), + "data equation should use := operator: {buf}" + ); + assert!( + buf.contains(" := "), + "short data equation should use inline format with spaces: {buf}" + ); +} + +// ---- Backslash line continuation tests ---- + +#[test] +fn wrap_short_equation_unchanged() { + let eqn = "a + b"; + let wrapped = wrap_equation_with_continuations(eqn, 80); + assert_eq!(wrapped, eqn); + assert!( + !wrapped.contains('\\'), + "short equation should not be wrapped: {wrapped}" + ); +} + +#[test] +fn wrap_long_equation_with_continuations() { + // Build an equation >80 chars with multiple terms + let eqn = + "very long variable a + very long variable b + very long variable c + very long variable d"; + assert!(eqn.len() > 80, "test equation should exceed 80 chars"); + let wrapped = wrap_equation_with_continuations(eqn, 80); + assert!( + wrapped.contains("\\\n\t\t"), + "long equation should contain continuation: {wrapped}" + ); + // Verify the continuation produces valid content when joined + let rejoined = wrapped.replace("\\\n\t\t", ""); + // The rejoined text should reconstruct the original (modulo trimmed trailing spaces) + assert!( + rejoined.contains("very long variable a"), + "content should be preserved: {rejoined}" + ); +} + +#[test] +fn wrap_equation_breaks_after_comma() { + // A function call with many arguments + let eqn = "IF THEN ELSE(very long condition variable > threshold value, very long true result, very long false result)"; + assert!(eqn.len() > 80); + let wrapped = wrap_equation_with_continuations(eqn, 80); + assert!(wrapped.contains("\\\n\t\t"), "should wrap: {wrapped}"); + // Verify breaks happen at reasonable points (after commas or before operators) + let lines: Vec<&str> = wrapped.split("\\\n\t\t").collect(); + assert!(lines.len() >= 2, "should have at least 2 lines: {wrapped}"); +} + +#[test] +fn long_equation_variable_entry_uses_continuation() { + let long_eqn = + "very_long_variable_a + very_long_variable_b + very_long_variable_c + very_long_variable_d"; + let var = make_aux("some_computed_value", long_eqn, None, ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains("some computed value=\n\t"), + "long equation should use multiline format: {buf}" + ); + // The equation body should have a continuation if the MDL form exceeds 80 chars + let mdl_eqn = equation_to_mdl(long_eqn); + if mdl_eqn.len() > 80 { + assert!( + buf.contains("\\\n\t\t"), + "long MDL equation should use backslash continuation: {buf}" + ); + } +} + +#[test] +fn short_equation_variable_entry_no_continuation() { + let var = make_aux("x", "42", None, ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + !buf.contains("\\\n\t\t"), + "short equation should not have continuation: {buf}" + ); +} + +#[test] +fn tokenize_preserves_equation_text() { + let eqn = "IF THEN ELSE(a > b, c + d, e * f)"; + let tokens = tokenize_for_wrapping(eqn); + let rejoined: String = tokens.concat(); + assert_eq!( + rejoined, eqn, + "concatenating tokens should reproduce the original" + ); +} + +#[test] +fn tokenize_splits_at_operators_and_commas() { + let eqn = "a + b, c * d"; + let tokens = tokenize_for_wrapping(eqn); + // Should have splits at +, *, and after comma + assert!( + tokens.len() >= 5, + "expected multiple tokens from operators/commas: {tokens:?}" + ); +} + +#[test] +fn scalar_stock_integ() { + // Real stocks from the MDL reader store only the initial value in + // equation, with inflows/outflows in separate fields. The writer + // must reconstruct the INTEG(...) expression. + let var = Variable::Stock(Stock { + ident: "teacup_temperature".to_owned(), + equation: Equation::Scalar("180".to_owned()), + documentation: "Temperature of tea".to_owned(), + units: Some("Degrees Fahrenheit".to_owned()), + inflows: vec![], + outflows: vec!["heat_loss_to_room".to_owned()], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert_eq!( + buf, + "teacup temperature=\n\tINTEG(-heat loss to room, 180)\n\t~\tDegrees Fahrenheit\n\t~\tTemperature of tea\n\t|" + ); +} + +#[test] +fn stock_with_inflows_and_outflows() { + let var = Variable::Stock(Stock { + ident: "population".to_owned(), + equation: Equation::Scalar("1000".to_owned()), + documentation: String::new(), + units: None, + inflows: vec!["births".to_owned()], + outflows: vec!["deaths".to_owned()], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains("INTEG(births-deaths, 1000)"), + "Expected INTEG with both inflow and outflow: {}", + buf + ); +} + +#[test] +fn arrayed_stock_apply_to_all_preserves_initial_value() { + let var = Variable::Stock(Stock { + ident: "inventory".to_owned(), + equation: Equation::ApplyToAll(vec!["region".to_owned()], "100".to_owned()), + documentation: "Stock by region".to_owned(), + units: Some("widgets".to_owned()), + inflows: vec!["inflow".to_owned()], + outflows: vec!["outflow".to_owned()], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains("inventory[region]=\n\tINTEG(inflow-outflow, 100)"), + "ApplyToAll stock should emit arrayed INTEG with initial value: {}", + buf + ); +} + +#[test] +fn arrayed_stock_elements_preserve_each_initial_value() { + let var = Variable::Stock(Stock { + ident: "inventory".to_owned(), + equation: Equation::Arrayed( + vec!["region".to_owned()], + vec![ + ("north".to_owned(), "100".to_owned(), None, None), + ("south".to_owned(), "200".to_owned(), None, None), + ], + None, + false, + ), + documentation: "Stock by region".to_owned(), + units: Some("widgets".to_owned()), + inflows: vec!["inflow".to_owned()], + outflows: vec!["outflow".to_owned()], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains("inventory[north]=\n\tINTEG(inflow-outflow, 100)"), + "First arrayed stock element should retain initial value: {}", + buf + ); + assert!( + buf.contains("inventory[south]=\n\tINTEG(inflow-outflow, 200)"), + "Second arrayed stock element should retain initial value: {}", + buf + ); +} + +#[test] +fn active_initial_preserved_on_aux() { + let var = Variable::Aux(Aux { + ident: "x".to_owned(), + equation: Equation::Scalar("y * 2".to_owned()), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat { + active_initial: Some("100".to_owned()), + ..Compat::default() + }, + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains("ACTIVE INITIAL(y * 2, 100)"), + "Expected ACTIVE INITIAL wrapper: {}", + buf + ); +} + +#[test] +fn compat_data_source_reconstructs_get_direct_constants() { + let var = Variable::Aux(Aux { + ident: "imported_constants".to_owned(), + equation: Equation::Scalar("0".to_owned()), + documentation: String::new(), + units: None, + gf: Some(make_gf()), + ai_state: None, + uid: None, + compat: Compat { + data_source: Some(crate::datamodel::DataSource { + kind: crate::datamodel::DataSourceKind::Constants, + file: "workbook.xlsx".to_owned(), + tab_or_delimiter: "Sheet1".to_owned(), + row_or_col: "A".to_owned(), + cell: String::new(), + }), + ..Compat::default() + }, + }); + + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!( + buf.contains(":="), + "GET DIRECT reconstruction should use := for data equations: {buf}" + ); + assert!( + buf.contains("GET DIRECT CONSTANTS('workbook.xlsx', 'Sheet1', 'A')"), + "writer should reconstruct GET DIRECT CONSTANTS from compat metadata: {buf}" + ); + assert!( + !buf.contains("([(0,0)-(2,1)]"), + "lookup table output must be suppressed when data_source metadata is present: {buf}" + ); +} + +#[test] +fn scalar_aux_with_lookup() { + let gf = make_gf(); + let var = Variable::Aux(Aux { + ident: "effect_of_x".to_owned(), + equation: Equation::Scalar("TIME".to_owned()), + documentation: "Lookup effect".to_owned(), + units: None, + gf: Some(gf), + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert_eq!( + buf, + "effect of x=\n\tWITH LOOKUP(Time, ([(0,0)-(2,1)],(0,0),(1,0.5),(2,1)))\n\t~\t\n\t~\tLookup effect\n\t|" + ); +} + +#[test] +fn lookup_without_explicit_x_points() { + let gf = GraphicalFunction { + kind: GraphicalFunctionKind::Continuous, + x_points: None, + y_points: vec![0.0, 0.5, 1.0], + x_scale: GraphicalFunctionScale { + min: 0.0, + max: 10.0, + }, + y_scale: GraphicalFunctionScale { min: 0.0, max: 1.0 }, + }; + let var = Variable::Aux(Aux { + ident: "tbl".to_owned(), + equation: Equation::Scalar(String::new()), + documentation: String::new(), + units: None, + gf: Some(gf), + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + // Standalone lookup: name(\n\tbody) + assert_eq!( + buf, + "tbl(\n\t[(0,0)-(10,1)],(0,0),(5,0.5),(10,1))\n\t~\t\n\t~\t\n\t|" + ); +} + +// ---- Task 2: Subscripted equation formatting ---- + +#[test] +fn apply_to_all_entry() { + let var = Variable::Aux(Aux { + ident: "rate_a".to_owned(), + equation: Equation::ApplyToAll( + vec!["one_dimensional_subscript".to_owned()], + "100".to_owned(), + ), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert_eq!( + buf, + "rate a[one dimensional subscript] = 100\n\t~\t\n\t~\t\n\t|" + ); +} + +#[test] +fn apply_to_all_multi_dim() { + let var = Variable::Aux(Aux { + ident: "matrix_a".to_owned(), + equation: Equation::ApplyToAll( + vec!["dim_a".to_owned(), "dim_b".to_owned()], + "0".to_owned(), + ), + documentation: String::new(), + units: Some("Dmnl".to_owned()), + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert_eq!(buf, "matrix a[dim a,dim b] = 0\n\t~\tDmnl\n\t~\t\n\t|"); +} + +#[test] +fn arrayed_per_element() { + let var = Variable::Aux(Aux { + ident: "rate_a".to_owned(), + equation: Equation::Arrayed( + vec!["one_dimensional_subscript".to_owned()], + vec![ + ("entry_1".to_owned(), "0.01".to_owned(), None, None), + ("entry_2".to_owned(), "0.2".to_owned(), None, None), + ("entry_3".to_owned(), "0.3".to_owned(), None, None), + ], + None, + false, + ), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert_eq!( + buf, + "rate a[entry 1]=\n\t0.01\n\t~~|\nrate a[entry 2]=\n\t0.2\n\t~~|\nrate a[entry 3]=\n\t0.3\n\t~\t\n\t~\t\n\t|" + ); +} + +#[test] +fn arrayed_subscript_names_with_underscores() { + let var = Variable::Aux(Aux { + ident: "demand".to_owned(), + equation: Equation::Arrayed( + vec!["region".to_owned()], + vec![ + ("north_america".to_owned(), "100".to_owned(), None, None), + ("south_america".to_owned(), "200".to_owned(), None, None), + ], + None, + false, + ), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + // Underscored element names should appear with spaces + assert!(buf.contains("[north america]")); + assert!(buf.contains("[south america]")); +} + +#[test] +fn arrayed_multidimensional_element_keys_preserve_tuple_shape() { + let var = Variable::Aux(Aux { + ident: "power5".to_owned(), + equation: Equation::Arrayed( + vec!["subs2".to_owned(), "subs1".to_owned(), "subs3".to_owned()], + vec![ + ( + "c,a,f".to_owned(), + "power(var3[subs2, subs1], var4[subs2, subs3])".to_owned(), + None, + None, + ), + ( + "d,b,g".to_owned(), + "power(var3[subs2, subs1], var4[subs2, subs3])".to_owned(), + None, + None, + ), + ], + None, + false, + ), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + + assert!( + buf.contains("power5[c,a,f]="), + "missing first tuple key: {buf}" + ); + assert!( + buf.contains("power5[d,b,g]="), + "missing second tuple key: {buf}" + ); + assert!( + !buf.contains("power5[\"c,a,f\"]"), + "tuple key must not be quoted as a single symbol: {buf}" + ); +} + +#[test] +fn arrayed_with_per_element_lookup() { + let gf = make_gf(); + let var = Variable::Aux(Aux { + ident: "tbl".to_owned(), + equation: Equation::Arrayed( + vec!["dim".to_owned()], + vec![ + ("a".to_owned(), String::new(), None, Some(gf.clone())), + ("b".to_owned(), "5".to_owned(), None, None), + ], + None, + false, + ), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + // Element "a" has empty equation + gf → standalone lookup + assert!(buf.contains("tbl[a](\n\t[(0,0)-(2,1)]")); + assert!(buf.contains("tbl[b]=\n\t5")); +} + +// ---- Task 3: Dimension definitions ---- + +#[test] +fn dimension_def_named() { + let dim = datamodel::Dimension::named( + "dim_a".to_owned(), + vec!["a1".to_owned(), "a2".to_owned(), "a3".to_owned()], + ); + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + assert_eq!(buf, "dim a:\n\ta1, a2, a3\n\t~~|\n"); +} + +#[test] +fn dimension_def_indexed() { + let dim = datamodel::Dimension::indexed("dim_b".to_owned(), 5); + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + assert_eq!(buf, "dim b:\n\t(1-5)\n\t~~|\n"); +} + +#[test] +fn dimension_def_with_mapping() { + let mut dim = datamodel::Dimension::named( + "dim_c".to_owned(), + vec!["dc1".to_owned(), "dc2".to_owned(), "dc3".to_owned()], + ); + dim.set_maps_to("dim_b".to_owned()); + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + assert_eq!(buf, "dim c:\n\tdc1, dc2, dc3 -> dim b\n\t~~|\n"); +} + +// ---- Task 3: Data equations ---- + +#[test] +fn data_equation_uses_data_equals() { + let var = make_aux( + "direct_data_down", + "{GET_DIRECT_DATA('data_down.csv',',','A','B2')}", + None, + "", + ); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + // Data equations use := instead of = + assert!(buf.contains(":="), "expected := in: {buf}"); +} + +#[test] +fn non_data_equation_uses_equals() { + let var = make_aux("x", "42", None, ""); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!(buf.starts_with("x = "), "expected = in: {buf}"); +} + +#[test] +fn is_data_equation_detection() { + // Underscore-separated form (as might appear in some equation strings) + assert!(is_data_equation("{GET_DIRECT_DATA('f',',','A','B')}")); + assert!(is_data_equation("{GET_XLS_DATA('f','s','A','B')}")); + assert!(is_data_equation("{GET_VDF_DATA('f','v')}")); + assert!(is_data_equation("{GET_DATA_AT_TIME('v', 5)}")); + assert!(is_data_equation("{GET_123_DATA('f','s','A','B')}")); + + // Space-separated form (as produced by the normalizer's SymbolClass::GetXls) + assert!(is_data_equation("{GET DIRECT DATA('f',',','A','B')}")); + assert!(is_data_equation("{GET XLS DATA('f','s','A','B')}")); + assert!(is_data_equation("{GET VDF DATA('f','v')}")); + assert!(is_data_equation("{GET DATA AT TIME('v', 5)}")); + assert!(is_data_equation("{GET 123 DATA('f','s','A','B')}")); + + assert!(!is_data_equation("100")); + assert!(!is_data_equation("integ(a, b)")); + assert!(!is_data_equation("")); +} + +#[test] +fn data_equation_preserves_raw_content() { + // Data equations should not go through expr0_to_mdl() because + // the GET XLS/DIRECT/etc. placeholders are not parseable as Expr0. + // Verify the raw content is preserved (not mangled by underbar_to_space). + let var = make_aux( + "my_data", + "{GET DIRECT DATA('data_file.csv',',','A','B2')}", + None, + "", + ); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + // The equation content must preserve underscores in quoted strings + assert!( + buf.contains("GET DIRECT DATA('data_file.csv',',','A','B2')"), + "Data equation content mangled: {}", + buf + ); + // Must use := for data equations + assert!(buf.contains(":="), "Expected := for data equation: {}", buf); +} + +#[test] +fn format_f64_whole_numbers() { + assert_eq!(format_f64(0.0), "0"); + assert_eq!(format_f64(1.0), "1"); + assert_eq!(format_f64(-5.0), "-5"); + assert_eq!(format_f64(100.0), "100"); +} + +#[test] +fn format_f64_fractional() { + assert_eq!(format_f64(0.5), "0.5"); + assert_eq!(format_f64(2.71), "2.71"); +} + +#[test] +fn format_f64_infinity_uses_vensim_numeric_sentinels() { + assert_eq!(format_f64(f64::INFINITY), "1e+38"); + assert_eq!(format_f64(f64::NEG_INFINITY), "-1e+38"); +} + +#[test] +fn flow_with_lookup() { + let gf = make_gf(); + let var = Variable::Flow(Flow { + ident: "flow_rate".to_owned(), + equation: Equation::Scalar("TIME".to_owned()), + documentation: "A flow".to_owned(), + units: Some("widgets/year".to_owned()), + gf: Some(gf), + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + // Flow with equation "TIME" + gf → WITH LOOKUP + assert!(buf.contains("flow rate=\n\tWITH LOOKUP(Time, ([(0,0)-(2,1)]")); + assert!(buf.contains("~\twidgets/year")); + assert!(buf.contains("~\tA flow")); +} + +#[test] +fn module_variable_produces_no_output() { + let var = Variable::Module(datamodel::Module { + ident: "mod1".to_owned(), + model_name: "model1".to_owned(), + documentation: String::new(), + units: None, + references: vec![], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + assert!(buf.is_empty()); +} + +// ---- Phase 4 Task 1: Validation ---- + +fn make_project(models: Vec) -> datamodel::Project { + datamodel::Project { + name: "test".to_owned(), + sim_specs: datamodel::SimSpecs { + start: 0.0, + stop: 100.0, + dt: datamodel::Dt::Dt(1.0), + save_step: None, + sim_method: datamodel::SimMethod::Euler, + time_units: None, + }, + dimensions: vec![], + units: vec![], + models, + source: None, + ai_information: None, + } +} + +fn make_model(variables: Vec) -> datamodel::Model { + datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables, + views: vec![], + loop_metadata: vec![], + groups: vec![], + } +} + +#[test] +fn project_to_mdl_rejects_multiple_models() { + let project = make_project(vec![make_model(vec![]), make_model(vec![])]); + let result = crate::mdl::project_to_mdl(&project); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("single model"), + "error should mention single model, got: {}", + err + ); +} + +#[test] +fn project_to_mdl_rejects_module_variable() { + let module_var = Variable::Module(datamodel::Module { + ident: "submodel".to_owned(), + model_name: "inner".to_owned(), + documentation: String::new(), + units: None, + references: vec![], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let project = make_project(vec![make_model(vec![module_var])]); + let result = crate::mdl::project_to_mdl(&project); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("Module"), + "error should mention Module, got: {}", + err + ); +} + +#[test] +fn project_to_mdl_succeeds_single_model() { + let var = make_aux("x", "5", Some("Units"), "A constant"); + let project = make_project(vec![make_model(vec![var])]); + let result = crate::mdl::project_to_mdl(&project); + assert!(result.is_ok(), "should succeed: {:?}", result); + let mdl = result.unwrap(); + assert!( + mdl.starts_with("{UTF-8}\r\n"), + "MDL should start with UTF-8 marker, got: {:?}", + mdl.lines().next() + ); + assert!(mdl.contains("x = ")); + assert!(mdl.contains("\\\\\\---///")); +} + +// ---- Phase 4 Task 2: Sim spec emission ---- + +#[test] +fn sim_specs_emission() { + let sim_specs = datamodel::SimSpecs { + start: 0.0, + stop: 100.0, + dt: datamodel::Dt::Dt(0.5), + save_step: Some(datamodel::Dt::Dt(1.0)), + sim_method: datamodel::SimMethod::Euler, + time_units: Some("Month".to_owned()), + }; + let mut writer = MdlWriter::new(); + writer.write_sim_specs(&sim_specs); + let output = writer.buf; + + assert!( + output.contains("INITIAL TIME = \n\t0"), + "should have INITIAL TIME, got: {output}" + ); + assert!( + output.contains("~\tMonth\n\t~\tThe initial time for the simulation."), + "INITIAL TIME should have Month units" + ); + assert!( + output.contains("FINAL TIME = \n\t100"), + "should have FINAL TIME, got: {output}" + ); + assert!( + output.contains("TIME STEP = \n\t0.5"), + "should have TIME STEP = 0.5, got: {output}" + ); + assert!( + output.contains("Month [0,?]"), + "TIME STEP should have units with range, got: {output}" + ); + assert!( + output.contains("SAVEPER = \n\t1"), + "should have SAVEPER = 1, got: {output}" + ); +} + +#[test] +fn sim_specs_saveper_defaults_to_time_step() { + let sim_specs = datamodel::SimSpecs { + start: 0.0, + stop: 50.0, + dt: datamodel::Dt::Dt(1.0), + save_step: None, + sim_method: datamodel::SimMethod::Euler, + time_units: None, + }; + let mut writer = MdlWriter::new(); + writer.write_sim_specs(&sim_specs); + let output = writer.buf; + + assert!( + output.contains("SAVEPER = \n\tTIME STEP"), + "SAVEPER should reference TIME STEP when save_step is None, got: {output}" + ); +} + +#[test] +fn sim_specs_reciprocal_dt() { + let sim_specs = datamodel::SimSpecs { + start: 0.0, + stop: 100.0, + dt: datamodel::Dt::Reciprocal(4.0), + save_step: None, + sim_method: datamodel::SimMethod::Euler, + time_units: Some("Year".to_owned()), + }; + let mut writer = MdlWriter::new(); + writer.write_sim_specs(&sim_specs); + let output = writer.buf; + + assert!( + output.contains("TIME STEP = \n\t1/4"), + "reciprocal dt should emit 1/v, got: {output}" + ); +} + +// ---- Phase 4 Task 3: Equations section assembly ---- + +#[test] +fn equations_section_full_assembly() { + let var1 = make_aux("growth_rate", "0.05", Some("1/Year"), "Growth rate"); + let var2 = make_stock( + "population", + "integ(growth_rate * population, 100)", + Some("People"), + "Total population", + ); + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var1, var2], + views: vec![], + loop_metadata: vec![], + groups: vec![], + }; + let project = datamodel::Project { + name: "test".to_owned(), + sim_specs: datamodel::SimSpecs { + start: 0.0, + stop: 100.0, + dt: datamodel::Dt::Dt(1.0), + save_step: None, + sim_method: datamodel::SimMethod::Euler, + time_units: Some("Year".to_owned()), + }, + dimensions: vec![], + units: vec![], + models: vec![model], + source: None, + ai_information: None, + }; + + let result = crate::mdl::project_to_mdl(&project); + assert!(result.is_ok(), "should succeed: {:?}", result); + let mdl = result.unwrap(); + + // Variable entries present + assert!( + mdl.contains("growth rate = "), + "should contain growth rate variable" + ); + assert!( + mdl.contains("population="), + "should contain population variable" + ); + + // Sim specs present + assert!(mdl.contains("INITIAL TIME"), "should contain INITIAL TIME"); + assert!(mdl.contains("FINAL TIME"), "should contain FINAL TIME"); + assert!(mdl.contains("TIME STEP"), "should contain TIME STEP"); + assert!(mdl.contains("SAVEPER"), "should contain SAVEPER"); + + // Terminator present + assert!( + mdl.contains("\\\\\\---/// Sketch information - do not modify anything except names"), + "should contain section terminator" + ); + + // Ordering: variables before sim specs, sim specs before terminator + let var_pos = mdl.find("growth rate = ").unwrap(); + let initial_pos = mdl.find("INITIAL TIME").unwrap(); + let terminator_pos = mdl.find("\\\\\\---///").unwrap(); + assert!( + var_pos < initial_pos, + "variables should come before sim specs" + ); + assert!( + initial_pos < terminator_pos, + "sim specs should come before terminator" + ); +} + +#[test] +fn equations_section_with_groups() { + let var1 = make_aux("rate_a", "10", None, ""); + let var2 = make_aux("rate_b", "20", None, ""); + let var3 = make_aux("ungrouped_var", "30", None, ""); + let group = datamodel::ModelGroup { + name: "my_group".to_owned(), + doc: Some("Group docs".to_owned()), + parent: None, + members: vec!["rate_a".to_owned(), "rate_b".to_owned()], + run_enabled: false, + }; + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var1, var2, var3], + views: vec![], + loop_metadata: vec![], + groups: vec![group], + }; + let project = make_project(vec![model]); + + let result = crate::mdl::project_to_mdl(&project); + assert!(result.is_ok(), "should succeed: {:?}", result); + let mdl = result.unwrap(); + + // Group marker present + assert!( + mdl.contains(".my group"), + "should contain group marker, got: {mdl}" + ); + assert!( + mdl.contains("Group docs"), + "should contain group documentation" + ); + + // Grouped variables come before ungrouped + let rate_a_pos = mdl.find("rate a = ").unwrap(); + let ungrouped_pos = mdl.find("ungrouped var = ").unwrap(); + assert!( + rate_a_pos < ungrouped_pos, + "grouped variables should come before ungrouped" + ); +} + +#[test] +fn equations_section_with_dimensions() { + let dim = datamodel::Dimension::named( + "region".to_owned(), + vec!["north".to_owned(), "south".to_owned()], + ); + let var = make_aux("x", "1", None, ""); + let model = make_model(vec![var]); + let mut project = make_project(vec![model]); + project.dimensions.push(dim); + + let result = crate::mdl::project_to_mdl(&project); + assert!(result.is_ok(), "should succeed: {:?}", result); + let mdl = result.unwrap(); + + // Dimension def at the start, before variables + assert!( + mdl.contains("region:\r\n\tnorth, south\r\n\t~~|"), + "should contain dimension def" + ); + let dim_pos = mdl.find("region:").unwrap(); + let var_pos = mdl.find("x = ").unwrap(); + assert!(dim_pos < var_pos, "dimensions should come before variables"); +} + +// ---- Phase 5 Task 1: Sketch element serialization (types 10, 11, 12) ---- + +#[test] +fn sketch_aux_element() { + let aux = view_element::Aux { + name: "Growth_Rate".to_string(), + uid: 1, + x: 100.0, + y: 200.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }; + let mut buf = String::new(); + write_aux_element(&mut buf, &aux); + assert_eq!(buf, "10,1,Growth Rate,100,200,40,20,8,3,0,0,-1,0,0,0"); +} + +#[test] +fn sketch_stock_element() { + let stock = view_element::Stock { + name: "Population".to_string(), + uid: 2, + x: 300.0, + y: 150.0, + label_side: view_element::LabelSide::Top, + compat: None, + }; + let mut buf = String::new(); + write_stock_element(&mut buf, &stock); + assert_eq!(buf, "10,2,Population,300,150,40,20,3,3,0,0,0,0,0,0"); +} + +#[test] +fn sketch_flow_element_produces_valve_and_variable() { + let flow = view_element::Flow { + name: "Infection_Rate".to_string(), + uid: 6, + x: 295.0, + y: 191.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }; + let mut buf = String::new(); + let valve_uids = HashMap::from([(6, 100)]); + let mut next_connector_uid = 200; + write_flow_element( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + ); + // No flow points, so no pipe connectors; valve and label follow + assert!(buf.contains("11,100,0,295,191,6,8,34,3,0,0,1,0,0,0")); + assert!(buf.contains("10,6,Infection Rate,295,207,49,8,40,3,0,0,-1,0,0,0")); +} + +#[test] +fn sketch_flow_element_emits_pipe_connectors_from_flow_points() { + let flow = view_element::Flow { + name: "Infection_Rate".to_string(), + uid: 6, + x: 150.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + points: vec![ + view_element::FlowPoint { + x: 100.0, + y: 100.0, + attached_to_uid: Some(1), + }, + view_element::FlowPoint { + x: 200.0, + y: 100.0, + attached_to_uid: Some(2), + }, + ], + compat: None, + label_compat: None, + }; + let mut buf = String::new(); + let valve_uids = HashMap::from([(6, 100)]); + let mut next_connector_uid = 200; + write_flow_element( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + ); + + let connector_lines: Vec<&str> = buf.lines().filter(|line| line.starts_with("1,")).collect(); + assert_eq!( + connector_lines.len(), + 2, + "Expected two type-1 connector lines for flow endpoints: {}", + buf + ); + assert!( + connector_lines.iter().any(|line| line.contains(",100,1,")), + "Expected connector from valve uid 100 to endpoint uid 1: {}", + buf + ); + assert!( + connector_lines.iter().any(|line| line.contains(",100,2,")), + "Expected connector from valve uid 100 to endpoint uid 2: {}", + buf + ); +} + +#[test] +fn valve_uids_do_not_collide_with_existing_elements() { + // stock uid=1, flow uid=2 -> valve must NOT get uid=1 + let elements = vec![ + ViewElement::Stock(view_element::Stock { + name: "Population".to_string(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "Birth_Rate".to_string(), + uid: 2, + x: 200.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }), + ]; + + let valve_uids = allocate_valve_uids(&elements); + // The valve for flow uid=2 must not equal 1 (stock's uid) + let valve_uid = valve_uids[&2]; + assert_ne!(valve_uid, 1, "Valve UID collides with stock UID"); + assert_ne!(valve_uid, 2, "Valve UID collides with flow UID"); +} + +#[test] +fn sketch_cloud_element() { + let cloud = view_element::Cloud { + uid: 7, + flow_uid: 6, + x: 479.0, + y: 235.0, + compat: None, + }; + let mut buf = String::new(); + write_cloud_element(&mut buf, &cloud); + assert_eq!(buf, "12,7,48,479,235,10,8,0,3,0,0,-1,0,0,0"); +} + +#[test] +fn sketch_alias_element() { + let alias = view_element::Alias { + uid: 10, + alias_of_uid: 1, + x: 200.0, + y: 300.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }; + let mut name_map = HashMap::new(); + name_map.insert(1, "Growth_Rate"); + let mut buf = String::new(); + write_alias_element(&mut buf, &alias, &name_map); + assert!(buf.starts_with("10,10,Growth Rate,200,300,40,20,8,2,0,3,-1,0,0,0,")); + assert!(buf.contains("128-128-128")); +} + +// ---- Phase 5 Task 2: Connector serialization (type 1) ---- + +#[test] +fn sketch_link_straight() { + let link = view_element::Link { + uid: 3, + from_uid: 1, + to_uid: 2, + shape: LinkShape::Straight, + polarity: None, + }; + let mut positions = HashMap::new(); + positions.insert(1, (100, 100)); + positions.insert(2, (200, 200)); + let mut buf = String::new(); + write_link_element(&mut buf, &link, &positions, false); + // Straight => control point (0,0), field 9 = 64 (influence connector) + assert_eq!(buf, "1,3,1,2,0,0,0,0,0,64,0,-1--1--1,,1|(0,0)|"); +} + +#[test] +fn sketch_link_with_polarity_symbol() { + let link = view_element::Link { + uid: 5, + from_uid: 1, + to_uid: 2, + shape: LinkShape::Straight, + polarity: Some(LinkPolarity::Positive), + }; + let positions = HashMap::new(); + let mut buf = String::new(); + write_link_element(&mut buf, &link, &positions, false); + // polarity=43 ('+'), field 9 = 64 + assert!(buf.contains(",0,0,43,0,0,64,0,")); +} + +#[test] +fn sketch_link_with_polarity_letter() { + let link = view_element::Link { + uid: 5, + from_uid: 1, + to_uid: 2, + shape: LinkShape::Straight, + polarity: Some(LinkPolarity::Positive), + }; + let positions = HashMap::new(); + let mut buf = String::new(); + write_link_element(&mut buf, &link, &positions, true); + // polarity=83 ('S' for lettered positive), field 9 = 64 + assert!(buf.contains(",0,0,83,0,0,64,0,")); +} + +#[test] +fn sketch_link_arc_produces_nonzero_control_point() { + let link = view_element::Link { + uid: 3, + from_uid: 1, + to_uid: 2, + shape: LinkShape::Arc(45.0), + polarity: None, + }; + let mut positions = HashMap::new(); + positions.insert(1, (100, 100)); + positions.insert(2, (200, 100)); + let mut buf = String::new(); + write_link_element(&mut buf, &link, &positions, false); + // Arc should produce a non-(0,0) control point + assert!( + !buf.contains("|(0,0)|"), + "arc should not produce (0,0) control point" + ); +} + +#[test] +fn sketch_link_multipoint_emits_all_points() { + let points = vec![ + view_element::FlowPoint { + x: 150.0, + y: 120.0, + attached_to_uid: None, + }, + view_element::FlowPoint { + x: 170.0, + y: 140.0, + attached_to_uid: None, + }, + view_element::FlowPoint { + x: 190.0, + y: 160.0, + attached_to_uid: None, + }, + ]; + let link = view_element::Link { + uid: 4, + from_uid: 1, + to_uid: 2, + shape: LinkShape::MultiPoint(points), + polarity: None, + }; + let mut positions = HashMap::new(); + positions.insert(1, (100, 100)); + positions.insert(2, (200, 200)); + let mut buf = String::new(); + write_link_element(&mut buf, &link, &positions, false); + assert!( + buf.contains("3|(150,120)|(170,140)|(190,160)|"), + "multipoint should emit all three points: {buf}" + ); +} + +// ---- Phase 5 Task 3: Complete sketch section assembly ---- + +#[test] +fn sketch_section_structure() { + let elements = vec![ + ViewElement::Stock(view_element::Stock { + name: "Population".to_string(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Top, + compat: None, + }), + ViewElement::Aux(view_element::Aux { + name: "Growth_Rate".to_string(), + uid: 2, + x: 200.0, + y: 200.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Link(view_element::Link { + uid: 3, + from_uid: 2, + to_uid: 1, + shape: LinkShape::Straight, + polarity: None, + }), + ]; + let sf = datamodel::StockFlow { + name: None, + elements, + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + }; + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + // Header + assert!( + output.starts_with("V300 Do not put anything below this section"), + "should start with V300 header" + ); + // View title + assert!(output.contains("*View 1\n"), "should have view title"); + // Font line + assert!( + output.contains("$192-192-192"), + "should have font settings line" + ); + // Elements + assert!( + output.contains("10,1,Population,"), + "should have stock element" + ); + assert!( + output.contains("10,2,Growth Rate,"), + "should have aux element" + ); + assert!(output.contains("1,3,2,1,"), "should have link element"); + // Terminator + assert!( + output.ends_with("///---\\\\\\\n"), + "should end with sketch terminator" + ); +} + +#[test] +fn sketch_section_in_full_project() { + let var = make_aux("x", "1", None, ""); + let elements = vec![ViewElement::Aux(view_element::Aux { + name: "x".to_string(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + })]; + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var], + views: vec![View::StockFlow(datamodel::StockFlow { + name: None, + elements, + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })], + loop_metadata: vec![], + groups: vec![], + }; + let project = make_project(vec![model]); + + let result = crate::mdl::project_to_mdl(&project); + assert!(result.is_ok()); + let mdl = result.unwrap(); + + // The sketch section should appear after the equations terminator + let terminator_pos = mdl + .find("\\\\\\---/// Sketch information") + .expect("should have equations terminator"); + let v300_pos = mdl.find("V300").expect("should have V300 header"); + assert!( + terminator_pos < v300_pos, + "V300 should come after equations terminator" + ); + + // The sketch terminator should be at the end + assert!( + mdl.contains("///---\\\\\\"), + "should have sketch terminator" + ); +} + +#[test] +fn sketch_roundtrip_teacup() { + // Read teacup.mdl, parse to Project, write sketch section, verify structure + let mdl_contents = include_str!("../../../../test/test-models/samples/teacup/teacup.mdl"); + let project = + crate::mdl::parse_mdl(mdl_contents).expect("teacup.mdl should parse successfully"); + + let model = &project.models[0]; + assert!( + !model.views.is_empty(), + "teacup model should have at least one view" + ); + + // Write the sketch section + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&model.views); + let output = writer.buf; + + // Verify structural elements: the teacup model should have stocks, auxes, + // flows (valve + attached variable), links, and clouds. + assert!(output.contains("V300"), "output should contain V300 header"); + assert!( + output.contains("*View 1"), + "output should contain view title" + ); + assert!( + output.contains("///---\\\\\\"), + "output should end with sketch terminator" + ); + + // The teacup model elements (after roundtrip through datamodel): + // Stock: Teacup_Temperature -> type 10 with shape=3 + // Aux: Heat_Loss_to_Room flow -> type 11 valve + type 10 attached + // Aux: Room_Temperature, Characteristic_Time -> type 10 + // Links -> type 1 + // Clouds -> type 12 + + // Count element types in output + let lines: Vec<&str> = output.lines().collect(); + let type10_count = lines.iter().filter(|l| l.starts_with("10,")).count(); + let type11_count = lines.iter().filter(|l| l.starts_with("11,")).count(); + let type12_count = lines.iter().filter(|l| l.starts_with("12,")).count(); + let type1_count = lines.iter().filter(|l| l.starts_with("1,")).count(); + + // Teacup has: 1 stock (Teacup_Temperature), 3 auxes (Heat_Loss_to_Room, + // Room_Temperature, Characteristic_Time), 1 flow (Heat_Loss_to_Room) + // which produces valve+variable, plus 1 cloud. + // The exact numbers depend on the MDL->datamodel conversion, but + // we should have a reasonable set of elements. + assert!( + type10_count >= 2, + "should have at least 2 type-10 elements (variables/stocks), got {type10_count}" + ); + assert!( + type11_count >= 1, + "should have at least 1 type-11 element (valve), got {type11_count}" + ); + assert!( + type12_count >= 1, + "should have at least 1 type-12 element (cloud/comment), got {type12_count}" + ); + assert!( + type1_count >= 1, + "should have at least 1 type-1 element (connector), got {type1_count}" + ); + // Verify no empty lines were introduced between elements + let element_lines: Vec<&&str> = lines + .iter() + .filter(|l| { + l.starts_with("10,") + || l.starts_with("11,") + || l.starts_with("12,") + || l.starts_with("1,") + }) + .collect(); + assert!( + !element_lines.is_empty(), + "should have sketch elements in output" + ); + + // Verify the output can be re-parsed as a valid sketch section + let reparsed = crate::mdl::view::parse_views(&output); + assert!( + reparsed.is_ok(), + "re-serialized sketch should parse: {:?}", + reparsed.err() + ); + let views = reparsed.unwrap(); + assert!( + !views.is_empty(), + "re-parsed sketch should have at least one view" + ); + + // Verify all expected element types are present after re-parse + let view = &views[0]; + let has_variable = view + .iter() + .any(|e| matches!(e, crate::mdl::view::VensimElement::Variable(_))); + let has_connector = view + .iter() + .any(|e| matches!(e, crate::mdl::view::VensimElement::Connector(_))); + assert!(has_variable, "re-parsed view should have variables"); + assert!(has_connector, "re-parsed view should have connectors"); +} + +#[test] +fn sketch_roundtrip_preserves_view_title() { + let mdl_contents = r#"x = 5 +~ ~| +\\\---/// Sketch information +V300 Do not put anything below this section - it will be ignored +*Overview +$192-192-192,0,Times New Roman|12||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0 +10,1,x,100,100,40,20,8,3,0,0,-1,0,0,0 +///---\\\ +"#; + + let project = + crate::mdl::parse_mdl(mdl_contents).expect("source MDL should parse successfully"); + let mdl = crate::mdl::project_to_mdl(&project).expect("roundtrip MDL write should work"); + + assert!( + mdl.contains("*Overview\r\n"), + "Roundtrip should preserve original view title: {}", + mdl + ); +} + +#[test] +fn sketch_roundtrip_sanitizes_multiline_view_title() { + let var = make_aux("x", "5", Some("Units"), "A constant"); + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var], + views: vec![View::StockFlow(datamodel::StockFlow { + name: Some("Overview\r\nMain".to_owned()), + elements: vec![ViewElement::Aux(view_element::Aux { + name: "x".to_owned(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + })], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })], + loop_metadata: vec![], + groups: vec![], + }; + let project = make_project(vec![model]); + + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + assert!( + mdl.contains("*Overview Main\r\n"), + "view title should be serialized as a single line: {mdl}", + ); + + let reparsed = crate::mdl::parse_mdl(&mdl).expect("written MDL should parse"); + let View::StockFlow(sf) = &reparsed.models[0].views[0]; + assert_eq!( + sf.name.as_deref(), + Some("Overview Main"), + "sanitized title should roundtrip through MDL", + ); +} + +#[test] +fn sketch_roundtrip_preserves_flow_endpoints_with_nonadjacent_valve_uid() { + let stock_a = Variable::Stock(Stock { + ident: "stock_a".to_owned(), + equation: Equation::Scalar("100".to_owned()), + documentation: String::new(), + units: None, + inflows: vec![], + outflows: vec!["flow_ab".to_owned()], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let stock_b = Variable::Stock(Stock { + ident: "stock_b".to_owned(), + equation: Equation::Scalar("0".to_owned()), + documentation: String::new(), + units: None, + inflows: vec!["flow_ab".to_owned()], + outflows: vec![], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let flow = Variable::Flow(Flow { + ident: "flow_ab".to_owned(), + equation: Equation::Scalar("10".to_owned()), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![stock_a, stock_b, flow], + views: vec![View::StockFlow(datamodel::StockFlow { + name: Some("View 1".to_owned()), + elements: vec![ + ViewElement::Stock(view_element::Stock { + name: "Stock_A".to_owned(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Stock(view_element::Stock { + name: "Stock_B".to_owned(), + uid: 2, + x: 300.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "Flow_AB".to_owned(), + uid: 6, + x: 200.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + points: vec![ + view_element::FlowPoint { + x: 122.5, + y: 100.0, + attached_to_uid: Some(1), + }, + view_element::FlowPoint { + x: 277.5, + y: 100.0, + attached_to_uid: Some(2), + }, + ], + compat: None, + label_compat: None, + }), + ], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })], + loop_metadata: vec![], + groups: vec![], + }; + let project = make_project(vec![model]); + + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + let reparsed = crate::mdl::parse_mdl(&mdl).expect("written MDL should parse"); + let View::StockFlow(sf) = &reparsed.models[0].views[0]; + + let stock_uid_by_name: HashMap<&str, i32> = sf + .elements + .iter() + .filter_map(|elem| { + if let ViewElement::Stock(stock) = elem { + Some((stock.name.as_str(), stock.uid)) + } else { + None + } + }) + .collect(); + + let flow = sf + .elements + .iter() + .find_map(|elem| { + if let ViewElement::Flow(flow) = elem { + Some(flow) + } else { + None + } + }) + .expect("expected flow element after roundtrip"); + + assert_eq!( + flow.points.first().and_then(|pt| pt.attached_to_uid), + stock_uid_by_name.get("Stock_A").copied(), + "flow source attachment should roundtrip to Stock_A", + ); + assert_eq!( + flow.points.last().and_then(|pt| pt.attached_to_uid), + stock_uid_by_name.get("Stock_B").copied(), + "flow sink attachment should roundtrip to Stock_B", + ); +} + +#[test] +fn compute_control_point_straight_midpoint() { + // For a nearly-straight arc angle, the control point should be near the midpoint + let from = (100, 100); + let to = (200, 100); + // Canvas angle of 0 degrees = straight line along x-axis + let (cx, cy) = compute_control_point(from, to, 0.0); + // For a straight line, the midpoint should be returned + assert_eq!(cx, 150); + assert_eq!(cy, 100); +} + +#[test] +fn compute_control_point_arc_off_center() { + // A 45-degree arc should produce a control point off the midpoint + let from = (100, 100); + let to = (200, 100); + let (_cx, cy) = compute_control_point(from, to, 45.0); + // The control point should be above or below the line, not on it + assert_ne!(cy, 100, "arc control point should be off the straight line"); +} + +// ---- Phase 6 Task 1: Settings section ---- + +#[test] +fn settings_section_starts_with_marker() { + let project = make_project(vec![make_model(vec![])]); + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + let output = writer.buf; + assert!( + output.starts_with(":L\x7F<%^E!@\n"), + "settings section should start with marker (separator is in sketch section), got: {:?}", + &output[..output.len().min(40)] + ); +} + +#[test] +fn settings_section_contains_type_15_euler() { + let project = make_project(vec![make_model(vec![])]); + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + let output = writer.buf; + assert!( + output.contains("15:0,0,0,0,0,0\n"), + "Euler method should emit method code 0, got: {:?}", + output + ); +} + +#[test] +fn settings_section_contains_type_15_rk4() { + let mut project = make_project(vec![make_model(vec![])]); + project.sim_specs.sim_method = SimMethod::RungeKutta4; + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + let output = writer.buf; + assert!( + output.contains("15:0,0,0,1,0,0\n"), + "RK4 method should emit method code 1, got: {:?}", + output + ); +} + +#[test] +fn settings_section_contains_type_15_rk2() { + let mut project = make_project(vec![make_model(vec![])]); + project.sim_specs.sim_method = SimMethod::RungeKutta2; + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + let output = writer.buf; + assert!( + output.contains("15:0,0,0,3,0,0\n"), + "RK2 method should emit method code 3, got: {:?}", + output + ); +} + +#[test] +fn settings_section_contains_type_22_units() { + let mut project = make_project(vec![make_model(vec![])]); + project.units = vec![ + Unit { + name: "Dollar".to_owned(), + equation: Some("$".to_owned()), + disabled: false, + aliases: vec!["Dollars".to_owned(), "$s".to_owned()], + }, + Unit { + name: "Hour".to_owned(), + equation: None, + disabled: false, + aliases: vec!["Hours".to_owned()], + }, + ]; + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + let output = writer.buf; + assert!( + output.contains("22:$,Dollar,Dollars,$s\n"), + "should contain Dollar unit equivalence, got: {:?}", + output + ); + assert!( + output.contains("22:Hour,Hours\n"), + "should contain Hour unit equivalence, got: {:?}", + output + ); +} + +#[test] +fn settings_section_skips_disabled_units() { + let mut project = make_project(vec![make_model(vec![])]); + project.units = vec![Unit { + name: "Disabled".to_owned(), + equation: None, + disabled: true, + aliases: vec![], + }]; + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + let output = writer.buf; + assert!( + !output.contains("22:Disabled"), + "disabled units should not appear in output" + ); +} + +#[test] +fn settings_section_contains_common_defaults() { + let project = make_project(vec![make_model(vec![])]); + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + let output = writer.buf; + // Type 4 (Time), Type 19 (display), Type 24/25/26 (time bounds) + assert!(output.contains("\n4:Time\n"), "should have Type 4 (Time)"); + assert!( + output.contains("\n19:"), + "should have Type 19 (display settings)" + ); + assert!( + output.contains("\n24:"), + "should have Type 24 (initial time)" + ); + assert!(output.contains("\n25:"), "should have Type 25 (final time)"); + assert!(output.contains("\n26:"), "should have Type 26 (time step)"); +} + +#[test] +fn settings_roundtrip_integration_method() { + // Write settings, then parse them back and check integration method + for method in [ + SimMethod::Euler, + SimMethod::RungeKutta4, + SimMethod::RungeKutta2, + ] { + let mut project = make_project(vec![make_model(vec![])]); + project.sim_specs.sim_method = method; + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + // Prepend the separator that write_sketch_section normally emits + let output = format!("///---\\\\\\\n{}", writer.buf); + + let parser = crate::mdl::settings::PostEquationParser::new(&output); + let settings = parser.parse_settings(); + assert_eq!( + settings.integration_method, method, + "integration method should roundtrip for {:?}", + method + ); + } +} + +#[test] +fn settings_roundtrip_unit_equivalences() { + let mut project = make_project(vec![make_model(vec![])]); + project.units = vec![ + Unit { + name: "Dollar".to_owned(), + equation: Some("$".to_owned()), + disabled: false, + aliases: vec!["Dollars".to_owned()], + }, + Unit { + name: "Hour".to_owned(), + equation: None, + disabled: false, + aliases: vec!["Hours".to_owned(), "Hr".to_owned()], + }, + ]; + let mut writer = MdlWriter::new(); + writer.write_settings_section(&project); + // Prepend the separator that write_sketch_section normally emits + let output = format!("///---\\\\\\\n{}", writer.buf); + + let parser = crate::mdl::settings::PostEquationParser::new(&output); + let settings = parser.parse_settings(); + assert_eq!(settings.unit_equivs.len(), 2); + assert_eq!(settings.unit_equivs[0].name, "Dollar"); + assert_eq!(settings.unit_equivs[0].equation, Some("$".to_string())); + assert_eq!(settings.unit_equivs[0].aliases, vec!["Dollars"]); + assert_eq!(settings.unit_equivs[1].name, "Hour"); + assert_eq!(settings.unit_equivs[1].equation, None); + assert_eq!(settings.unit_equivs[1].aliases, vec!["Hours", "Hr"]); +} + +// ---- Phase 6 Task 2: Full file assembly ---- + +#[test] +fn full_assembly_has_all_three_sections() { + let var = make_aux("x", "5", Some("Units"), "A constant"); + let elements = vec![ViewElement::Aux(view_element::Aux { + name: "x".to_string(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + })]; + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var], + views: vec![View::StockFlow(datamodel::StockFlow { + name: None, + elements, + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })], + loop_metadata: vec![], + groups: vec![], + }; + let project = make_project(vec![model]); + + let result = crate::mdl::project_to_mdl(&project); + assert!( + result.is_ok(), + "project_to_mdl should succeed: {:?}", + result + ); + let mdl = result.unwrap(); + + // Section 1: Equations -- contains variable entry + assert!(mdl.contains("x = "), "should contain equation for x"); + // Equations terminator + assert!( + mdl.contains("\\\\\\---/// Sketch information"), + "should have equations terminator" + ); + + // Section 2: Sketch -- V300 header and elements + assert!(mdl.contains("V300"), "should have V300 sketch header"); + assert!(mdl.contains("*View 1"), "should have view title"); + + // Section 3: Settings -- marker and type codes + assert!(mdl.contains(":L\x7F<%^E!@"), "should have settings marker"); + assert!(mdl.contains("15:"), "should have Type 15 line"); + + // Sections should be in order: equations, sketch, settings + let eq_term = mdl.find("\\\\\\---/// Sketch").unwrap(); + let v300 = mdl.find("V300").unwrap(); + let sketch_term = mdl.find("///---\\\\\\").unwrap(); + let settings_marker = mdl.find(":L\x7F<%^E!@").unwrap(); + assert!(eq_term < v300, "equations should come before sketch"); + assert!( + v300 < sketch_term, + "V300 should come before sketch terminator" + ); + assert!( + sketch_term < settings_marker, + "sketch terminator should come before settings marker" + ); +} + +// ---- Phase 6 Task 3: compat wrapper ---- + +#[test] +fn compat_to_mdl_matches_project_to_mdl() { + let var = make_aux("x", "5", Some("Units"), "A constant"); + let project = make_project(vec![make_model(vec![var])]); + + let direct = crate::mdl::project_to_mdl(&project).unwrap(); + let compat = crate::compat::to_mdl(&project).unwrap(); + assert_eq!( + direct, compat, + "compat::to_mdl should produce same result as mdl::project_to_mdl" + ); +} + +#[test] +fn write_arrayed_with_default_equation_omits_dimension_level_default() { + // When default_equation is set (from EXCEPT syntax), the writer must + // NOT emit name[Dim...]=default because that would apply the default + // equation to excepted elements that should default to 0. + let var = datamodel::Variable::Aux(datamodel::Aux { + ident: "cost".to_string(), + equation: datamodel::Equation::Arrayed( + vec!["region".to_string()], + vec![ + ("north".to_string(), "base+1".to_string(), None, None), + ("south".to_string(), "base+2".to_string(), None, None), + ], + Some("base".to_string()), + true, + ), + documentation: String::new(), + units: Some("dollars".to_string()), + gf: None, + ai_state: None, + uid: None, + compat: datamodel::Compat::default(), + }); + + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &HashMap::new()); + + // Must NOT contain dimension-level default (would apply to excepted elements) + assert!( + !buf.contains("cost[region]="), + "should NOT contain dimension-level default equation, got: {buf}" + ); + // Individual element equations should be present + assert!( + buf.contains("cost[north]="), + "should contain north element equation, got: {buf}" + ); + assert!( + buf.contains("cost[south]="), + "should contain south element equation, got: {buf}" + ); +} + +#[test] +fn write_dimension_with_element_level_mapping() { + let dim = datamodel::Dimension { + name: "dim_a".to_string(), + elements: datamodel::DimensionElements::Named(vec!["a1".to_string(), "a2".to_string()]), + mappings: vec![datamodel::DimensionMapping { + target: "dim_b".to_string(), + element_map: vec![ + ("a1".to_string(), "b2".to_string()), + ("a2".to_string(), "b1".to_string()), + ], + }], + parent: None, + }; + + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + + assert!( + buf.contains("-> (dim b: b2, b1)"), + "element-level mapping must use parenthesized syntax, got: {buf}" + ); +} + +#[test] +fn write_dimension_with_multi_target_positional_mapping() { + let dim = datamodel::Dimension { + name: "dim_a".to_string(), + elements: datamodel::DimensionElements::Named(vec!["a1".to_string(), "a2".to_string()]), + mappings: vec![ + datamodel::DimensionMapping { + target: "dim_b".to_string(), + element_map: vec![], + }, + datamodel::DimensionMapping { + target: "dim_c".to_string(), + element_map: vec![], + }, + ], + parent: None, + }; + + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + + assert!( + buf.contains("dim b") && buf.contains("dim c"), + "both positional mapping targets should be emitted, got: {buf}" + ); +} + +#[test] +fn write_dimension_element_mapping_sorted_by_source_position() { + // element_map entries out of source order should still emit + // targets in the dimension's element order for correct positional + // correspondence on re-import. + let dim = datamodel::Dimension { + name: "dim_a".to_string(), + elements: datamodel::DimensionElements::Named(vec![ + "a1".to_string(), + "a2".to_string(), + "a3".to_string(), + ]), + mappings: vec![datamodel::DimensionMapping { + target: "dim_b".to_string(), + element_map: vec![ + ("a3".to_string(), "b3".to_string()), + ("a1".to_string(), "b1".to_string()), + ("a2".to_string(), "b2".to_string()), + ], + }], + parent: None, + }; + + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + + assert!( + buf.contains("-> (dim b: b1, b2, b3)"), + "targets should be in source element order (a1->b1, a2->b2, a3->b3), got: {buf}" + ); +} + +#[test] +fn write_dimension_element_mapping_case_insensitive_lookup() { + // element_map uses canonical (lowercase) keys, but dim.elements + // may preserve original casing -- the sort must still work. + let dim = datamodel::Dimension { + name: "Region".to_string(), + elements: datamodel::DimensionElements::Named(vec![ + "North".to_string(), + "South".to_string(), + "East".to_string(), + ]), + mappings: vec![datamodel::DimensionMapping { + target: "zone".to_string(), + element_map: vec![ + ("east".to_string(), "z3".to_string()), + ("north".to_string(), "z1".to_string()), + ("south".to_string(), "z2".to_string()), + ], + }], + parent: None, + }; + + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + + assert!( + buf.contains("-> (zone: z1, z2, z3)"), + "targets should be sorted by source element order despite case mismatch, got: {buf}" + ); +} + +#[test] +fn write_dimension_element_mapping_underscored_names() { + // Element names with underscores must be normalized via to_lower_space() + // to match the canonical form used in element_map keys. + let dim = datamodel::Dimension { + name: "Continent".to_string(), + elements: datamodel::DimensionElements::Named(vec![ + "North_America".to_string(), + "South_America".to_string(), + "Europe".to_string(), + ]), + mappings: vec![datamodel::DimensionMapping { + target: "zone".to_string(), + element_map: vec![ + ("europe".to_string(), "z3".to_string()), + ("north america".to_string(), "z1".to_string()), + ("south america".to_string(), "z2".to_string()), + ], + }], + parent: None, + }; + + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + + assert!( + buf.contains("-> (zone: z1, z2, z3)"), + "underscore element names should match canonical element_map keys, got: {buf}" + ); +} + +#[test] +fn write_dimension_one_to_many_falls_back_to_positional() { + // When a source element maps to multiple targets (from subdimension + // expansion), the element-level notation can't round-trip correctly. + // The writer should fall back to a positional dimension-name mapping. + let dim = datamodel::Dimension { + name: "dim_b".to_string(), + elements: datamodel::DimensionElements::Named(vec!["b1".to_string(), "b2".to_string()]), + mappings: vec![datamodel::DimensionMapping { + target: "dim_a".to_string(), + element_map: vec![ + ("b1".to_string(), "a1".to_string()), + ("b1".to_string(), "a2".to_string()), + ("b2".to_string(), "a3".to_string()), + ], + }], + parent: None, + }; + + let mut buf = String::new(); + write_dimension_def(&mut buf, &dim); + + assert!( + buf.contains("-> dim a") && !buf.contains("(dim a:"), + "one-to-many mapping should fall back to positional notation, got: {buf}" + ); +} + +#[test] +fn write_arrayed_with_default_equation_writes_explicit_elements() { + let mut buf = String::new(); + write_arrayed_entries( + &mut buf, + "g", + &["DimA".to_string()], + &[ + ("A1".to_string(), "10".to_string(), None, None), + ("A2".to_string(), "7".to_string(), None, None), + ("A3".to_string(), "7".to_string(), None, None), + ], + &Some("7".to_string()), + &None, + "", + ); + assert!( + !buf.contains("g[DimA]"), + "dimension-level default must not be emitted, got: {buf}" + ); + assert!( + !buf.contains(":EXCEPT:"), + "EXCEPT syntax should not be emitted" + ); + assert!( + buf.contains("g[A1]"), + "A1 entry should be written explicitly, got: {buf}" + ); + assert!( + buf.contains("g[A2]") && buf.contains("g[A3]"), + "all explicit array elements should be written, got: {buf}" + ); +} + +#[test] +fn write_arrayed_no_default_writes_all_elements() { + let mut buf = String::new(); + write_arrayed_entries( + &mut buf, + "h", + &["DimA".to_string()], + &[ + ("A1".to_string(), "8".to_string(), None, None), + ("A2".to_string(), "0".to_string(), None, None), + ], + &None, + &None, + "", + ); + assert!( + !buf.contains(":EXCEPT:"), + "should not emit EXCEPT when no default_equation, got: {buf}" + ); + assert!(buf.contains("h[A1]"), "should write A1 element, got: {buf}"); + assert!(buf.contains("h[A2]"), "should write A2 element, got: {buf}"); +} + +#[test] +fn write_arrayed_except_no_exceptions_all_default() { + let mut buf = String::new(); + write_arrayed_entries( + &mut buf, + "k", + &["DimA".to_string()], + &[ + ("A1".to_string(), "5".to_string(), None, None), + ("A2".to_string(), "5".to_string(), None, None), + ], + &Some("5".to_string()), + &None, + "", + ); + assert!( + !buf.contains("k[DimA]"), + "dimension-level default must not be emitted, got: {buf}" + ); + assert!(buf.contains("k[A1]"), "should write A1 element, got: {buf}"); + assert!(buf.contains("k[A2]"), "should write A2 element, got: {buf}"); + assert!( + !buf.contains(":EXCEPT:"), + "EXCEPT syntax should not be emitted, got: {buf}" + ); +} + +#[test] +fn write_arrayed_except_with_omitted_elements_avoids_dimension_default() { + let mut buf = String::new(); + write_arrayed_entries( + &mut buf, + "h", + &["DimA".to_string()], + &[("A1".to_string(), "8".to_string(), None, None)], + &Some("8".to_string()), + &None, + "", + ); + + assert!( + !buf.contains("h[DimA]"), + "dimension-level default would apply to omitted EXCEPT elements, got: {buf}" + ); + assert!( + buf.contains("h[A1]"), + "explicitly present elements must still be emitted, got: {buf}" + ); +} + +#[test] +fn compat_get_direct_equation_does_not_produce_backslash_escapes() { + let compat = Compat { + data_source: Some(crate::datamodel::DataSource { + kind: crate::datamodel::DataSourceKind::Constants, + file: "data/a.csv".to_string(), + tab_or_delimiter: ",".to_string(), + row_or_col: "B2".to_string(), + cell: String::new(), + }), + ..Compat::default() + }; + let eq = compat_get_direct_equation(&compat).expect("should produce equation"); + assert!( + !eq.contains("\\'"), + "writer must not emit backslash-escaped quotes (parser treats ' as toggle): {eq}" + ); + assert!( + eq.contains("GET DIRECT CONSTANTS"), + "should produce GET DIRECT CONSTANTS: {eq}" + ); +} + +// ---- Multi-view split tests (Phase 3, Tasks 1-2) ---- + +fn make_view_aux(name: &str, uid: i32) -> ViewElement { + ViewElement::Aux(view_element::Aux { + name: name.to_owned(), + uid, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }) +} + +fn make_view_stock(name: &str, uid: i32) -> ViewElement { + ViewElement::Stock(view_element::Stock { + name: name.to_owned(), + uid, + x: 200.0, + y: 200.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }) +} + +fn make_view_flow(name: &str, uid: i32) -> ViewElement { + ViewElement::Flow(view_element::Flow { + name: name.to_owned(), + uid, + x: 150.0, + y: 150.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }) +} + +fn make_view_group(name: &str, uid: i32) -> ViewElement { + ViewElement::Group(view_element::Group { + uid, + name: name.to_owned(), + x: 0.0, + y: 0.0, + width: 500.0, + height: 500.0, + is_mdl_view_marker: true, + }) +} + +fn make_xmile_group(name: &str, uid: i32) -> ViewElement { + ViewElement::Group(view_element::Group { + uid, + name: name.to_owned(), + x: 0.0, + y: 0.0, + width: 500.0, + height: 500.0, + is_mdl_view_marker: false, + }) +} + +fn make_stock_flow(elements: Vec) -> StockFlow { + StockFlow { + name: None, + elements, + view_box: Rect::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + } +} + +#[test] +fn split_view_no_groups_returns_single_segment() { + let sf = make_stock_flow(vec![ + make_view_aux("price", 1), + make_view_stock("inventory", 2), + ]); + let segments = split_view_on_groups(&sf); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].0, "View 1"); + assert_eq!(segments[0].1.len(), 2); +} + +#[test] +fn split_view_no_groups_uses_stockflow_name() { + let mut sf = make_stock_flow(vec![make_view_aux("price", 1)]); + sf.name = Some("My Custom View".to_owned()); + let segments = split_view_on_groups(&sf); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].0, "My Custom View"); +} + +#[test] +fn split_view_two_groups_produces_two_segments() { + let sf = make_stock_flow(vec![ + make_view_group("1 housing", 100), + make_view_aux("price", 1), + make_view_stock("inventory", 2), + make_view_group("2 investments", 200), + make_view_aux("rate", 3), + make_view_flow("capital_flow", 4), + ]); + let segments = split_view_on_groups(&sf); + assert_eq!(segments.len(), 2, "expected 2 segments from 2 groups"); + assert_eq!(segments[0].0, "1 housing"); + assert_eq!(segments[0].1.len(), 2, "first segment: price + inventory"); + assert_eq!(segments[1].0, "2 investments"); + assert_eq!( + segments[1].1.len(), + 2, + "second segment: rate + capital_flow" + ); +} + +#[test] +fn split_view_elements_partitioned_correctly() { + let sf = make_stock_flow(vec![ + make_view_group("1 housing", 100), + make_view_aux("price", 1), + make_view_stock("inventory", 2), + make_view_group("2 investments", 200), + make_view_aux("rate", 3), + ]); + let segments = split_view_on_groups(&sf); + + // First segment should contain price and inventory + let seg1_names: Vec<&str> = segments[0].1.iter().filter_map(|e| e.get_name()).collect(); + assert_eq!(seg1_names, vec!["price", "inventory"]); + + // Second segment should contain rate + let seg2_names: Vec<&str> = segments[1].1.iter().filter_map(|e| e.get_name()).collect(); + assert_eq!(seg2_names, vec!["rate"]); +} + +#[test] +fn split_view_modules_filtered_out() { + let sf = make_stock_flow(vec![ + make_view_aux("price", 1), + ViewElement::Module(view_element::Module { + name: "submodel".to_owned(), + uid: 99, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + }), + ]); + let segments = split_view_on_groups(&sf); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].1.len(), 1, "module should be filtered out"); +} + +#[test] +fn split_view_preserves_font() { + let mut sf = make_stock_flow(vec![ + make_view_group("view1", 100), + make_view_aux("x", 1), + make_view_group("view2", 200), + make_view_aux("y", 2), + ]); + sf.font = Some("192-192-192,0,Verdana|10||0-0-0".to_owned()); + let segments = split_view_on_groups(&sf); + for (_, _, font) in &segments { + assert_eq!( + font.as_deref(), + Some("192-192-192,0,Verdana|10||0-0-0"), + "all segments should share the StockFlow font" + ); + } +} + +#[test] +fn multi_view_mdl_output_contains_view_headers() { + let sf = make_stock_flow(vec![ + make_view_group("1 housing", 100), + make_view_aux("price", 1), + make_view_group("2 investments", 200), + make_view_aux("rate", 2), + ]); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("*1 housing"), + "output should contain first view header: {output}" + ); + assert!( + output.contains("*2 investments"), + "output should contain second view header: {output}" + ); +} + +#[test] +fn multi_view_mdl_output_has_separators_between_views() { + let sf = make_stock_flow(vec![ + make_view_group("view1", 100), + make_view_aux("a", 1), + make_view_group("view2", 200), + make_view_aux("b", 2), + ]); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + // The second view should have a V300 header + let v300_count = output.matches("V300").count(); + assert_eq!( + v300_count, 2, + "two views should produce two V300 headers: {output}" + ); +} + +#[test] +fn single_view_no_groups_mdl_output() { + let sf = make_stock_flow(vec![make_view_aux("price", 1)]); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("*View 1"), + "single view should use default name: {output}" + ); + let v300_count = output.matches("V300").count(); + assert_eq!( + v300_count, 1, + "single view should produce one V300 header: {output}" + ); +} + +#[test] +fn multi_view_uses_font_when_present() { + let mut sf = make_stock_flow(vec![make_view_group("view1", 100), make_view_aux("a", 1)]); + sf.font = Some( + "192-192-192,0,Verdana|10||0-0-0|0-0-0|0-0-255|-1--1--1|-1--1--1|96,96,100,0".to_owned(), + ); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("$192-192-192,0,Verdana|10||"), + "should use preserved font: {output}" + ); + assert!( + !output.contains("Times New Roman"), + "should not use default font when custom font present: {output}" + ); +} + +#[test] +fn single_view_uses_default_font_when_none() { + let sf = make_stock_flow(vec![make_view_aux("a", 1)]); + let views = vec![View::StockFlow(sf)]; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("Times New Roman|12"), + "should use default font when font is None: {output}" + ); +} + +// ---- Task 5: compat dimensions in element output ---- + +#[test] +fn stock_compat_dimensions_emitted() { + let stock = view_element::Stock { + name: "Population".to_string(), + uid: 2, + x: 300.0, + y: 150.0, + label_side: view_element::LabelSide::Top, + compat: Some(view_element::ViewElementCompat { + width: 53.0, + height: 32.0, + bits: 131, + }), + }; + let mut buf = String::new(); + write_stock_element(&mut buf, &stock); + assert!( + buf.contains(",53,32,3,131,"), + "stock with compat should emit preserved dimensions: {buf}" + ); +} + +#[test] +fn stock_default_dimensions_without_compat() { + let stock = view_element::Stock { + name: "Population".to_string(), + uid: 2, + x: 300.0, + y: 150.0, + label_side: view_element::LabelSide::Top, + compat: None, + }; + let mut buf = String::new(); + write_stock_element(&mut buf, &stock); + assert!( + buf.contains(",40,20,3,3,"), + "stock without compat should use default 40,20,3,3: {buf}" + ); +} + +#[test] +fn aux_compat_dimensions_emitted() { + let aux = view_element::Aux { + name: "Rate".to_string(), + uid: 1, + x: 100.0, + y: 200.0, + label_side: view_element::LabelSide::Bottom, + compat: Some(view_element::ViewElementCompat { + width: 45.0, + height: 18.0, + bits: 131, + }), + }; + let mut buf = String::new(); + write_aux_element(&mut buf, &aux); + assert!( + buf.contains(",45,18,8,131,"), + "aux with compat should emit preserved dimensions: {buf}" + ); +} + +#[test] +fn aux_default_dimensions_without_compat() { + let aux = view_element::Aux { + name: "Rate".to_string(), + uid: 1, + x: 100.0, + y: 200.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }; + let mut buf = String::new(); + write_aux_element(&mut buf, &aux); + assert!( + buf.contains(",40,20,8,3,"), + "aux without compat should use default 40,20,8,3: {buf}" + ); +} + +#[test] +fn flow_valve_compat_dimensions_emitted() { + let flow = view_element::Flow { + name: "Birth_Rate".to_string(), + uid: 6, + x: 295.0, + y: 191.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: Some(view_element::ViewElementCompat { + width: 12.0, + height: 18.0, + bits: 131, + }), + label_compat: Some(view_element::ViewElementCompat { + width: 55.0, + height: 14.0, + bits: 35, + }), + }; + let mut buf = String::new(); + let valve_uids = HashMap::from([(6, 100)]); + let mut next_connector_uid = 200; + write_flow_element( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + ); + // Valve line should use flow.compat dimensions + assert!( + buf.contains(",12,18,34,131,"), + "valve with compat should emit preserved dimensions: {buf}" + ); + // Label line should use flow.label_compat dimensions + assert!( + buf.contains(",55,14,40,35,"), + "flow label with label_compat should emit preserved dimensions: {buf}" + ); +} + +#[test] +fn flow_default_dimensions_without_compat() { + let flow = view_element::Flow { + name: "Birth_Rate".to_string(), + uid: 6, + x: 295.0, + y: 191.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }; + let mut buf = String::new(); + let valve_uids = HashMap::from([(6, 100)]); + let mut next_connector_uid = 200; + write_flow_element( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + ); + // Valve line should use default dimensions + assert!( + buf.contains(",6,8,34,3,"), + "valve without compat should use default 6,8,34,3: {buf}" + ); + // Label line should use default dimensions + assert!( + buf.contains(",49,8,40,3,"), + "flow label without label_compat should use default 49,8,40,3: {buf}" + ); +} + +#[test] +fn cloud_compat_dimensions_emitted() { + let cloud = view_element::Cloud { + uid: 7, + flow_uid: 6, + x: 479.0, + y: 235.0, + compat: Some(view_element::ViewElementCompat { + width: 20.0, + height: 14.0, + bits: 131, + }), + }; + let mut buf = String::new(); + write_cloud_element(&mut buf, &cloud); + assert!( + buf.contains(",20,14,0,131,"), + "cloud with compat should emit preserved dimensions: {buf}" + ); +} + +#[test] +fn cloud_default_dimensions_without_compat() { + let cloud = view_element::Cloud { + uid: 7, + flow_uid: 6, + x: 479.0, + y: 235.0, + compat: None, + }; + let mut buf = String::new(); + write_cloud_element(&mut buf, &cloud); + assert!( + buf.contains(",10,8,0,3,"), + "cloud without compat should use default 10,8,0,3: {buf}" + ); +} + +#[test] +fn alias_compat_dimensions_emitted() { + let alias = view_element::Alias { + uid: 10, + alias_of_uid: 1, + x: 200.0, + y: 300.0, + label_side: view_element::LabelSide::Bottom, + compat: Some(view_element::ViewElementCompat { + width: 45.0, + height: 18.0, + bits: 66, + }), + }; + let mut name_map = HashMap::new(); + name_map.insert(1, "Growth_Rate"); + let mut buf = String::new(); + write_alias_element(&mut buf, &alias, &name_map); + assert!( + buf.contains(",45,18,8,66,"), + "alias with compat should emit preserved dimensions: {buf}" + ); +} + +#[test] +fn alias_default_dimensions_without_compat() { + let alias = view_element::Alias { + uid: 10, + alias_of_uid: 1, + x: 200.0, + y: 300.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }; + let mut name_map = HashMap::new(); + name_map.insert(1, "Growth_Rate"); + let mut buf = String::new(); + write_alias_element(&mut buf, &alias, &name_map); + assert!( + buf.contains(",40,20,8,2,"), + "alias without compat should use default 40,20,8,2: {buf}" + ); +} + +// ---- Phase 4 Task 3/4: Equation LHS casing from view element names ---- + +#[test] +fn build_display_name_map_extracts_view_element_names() { + let views = vec![View::StockFlow(StockFlow { + name: None, + elements: vec![ + ViewElement::Aux(view_element::Aux { + name: "Endogenous Federal Funds Rate".to_owned(), + uid: 1, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Stock(view_element::Stock { + name: "Population Level".to_owned(), + uid: 2, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "Birth Rate".to_owned(), + uid: 3, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }), + ], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })]; + let map = build_display_name_map(&views); + assert_eq!( + map.get("endogenous_federal_funds_rate").map(|s| s.as_str()), + Some("Endogenous Federal Funds Rate"), + ); + assert_eq!( + map.get("population_level").map(|s| s.as_str()), + Some("Population Level"), + ); + assert_eq!( + map.get("birth_rate").map(|s| s.as_str()), + Some("Birth Rate"), + ); +} + +#[test] +fn build_display_name_map_first_occurrence_wins() { + // If a name appears in multiple views, the first one wins + let views = vec![View::StockFlow(StockFlow { + name: None, + elements: vec![ + ViewElement::Aux(view_element::Aux { + name: "Growth Rate".to_owned(), + uid: 1, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Aux(view_element::Aux { + name: "growth rate".to_owned(), + uid: 5, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })]; + let map = build_display_name_map(&views); + // The first element's casing wins + assert_eq!( + map.get("growth_rate").map(|s| s.as_str()), + Some("Growth Rate"), + ); +} + +#[test] +fn equation_lhs_uses_view_element_casing() { + let var = make_aux( + "endogenous_federal_funds_rate", + "0.05", + Some("1/Year"), + "Rate var", + ); + let views = vec![View::StockFlow(StockFlow { + name: None, + elements: vec![ViewElement::Aux(view_element::Aux { + name: "Endogenous Federal Funds Rate".to_owned(), + uid: 1, + x: 0.0, + y: 0.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + })], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })]; + let display_names = build_display_name_map(&views); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &display_names); + assert!( + buf.starts_with("Endogenous Federal Funds Rate = "), + "LHS should use view element casing, got: {buf}" + ); +} + +#[test] +fn equation_lhs_fallback_without_view_element() { + let var = make_aux("unmatched_variable", "42", None, ""); + let display_names = HashMap::new(); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &display_names); + assert!( + buf.starts_with("unmatched variable = "), + "LHS should fall back to format_mdl_ident when no view element matches, got: {buf}" + ); +} + +#[test] +fn equation_lhs_casing_for_stock() { + let var = Variable::Stock(Stock { + ident: "population_level".to_owned(), + equation: Equation::Scalar("1000".to_owned()), + documentation: String::new(), + units: None, + inflows: vec!["births".to_owned()], + outflows: vec![], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let mut display_names = HashMap::new(); + display_names.insert("population_level".to_owned(), "Population Level".to_owned()); + let mut buf = String::new(); + write_variable_entry(&mut buf, &var, &display_names); + assert!( + buf.starts_with("Population Level="), + "Stock LHS should use view element casing, got: {buf}" + ); +} + +#[test] +fn equation_lhs_casing_in_full_project_roundtrip() { + let var = make_aux("growth_rate", "0.05", Some("1/Year"), "Rate"); + let elements = vec![ViewElement::Aux(view_element::Aux { + name: "Growth Rate".to_owned(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + })]; + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var], + views: vec![View::StockFlow(datamodel::StockFlow { + name: None, + elements, + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + })], + loop_metadata: vec![], + groups: vec![], + }; + let project = make_project(vec![model]); + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + assert!( + mdl.contains("Growth Rate = "), + "Full project MDL should use view element casing on LHS, got: {mdl}" + ); +} + +// ---- Phase 5 Subcomponent C: Variable ordering ---- + +#[test] +fn ungrouped_variables_sorted_alphabetically() { + // Variables inserted in non-alphabetical order: c, a, b + let var_c = make_aux("c_var", "3", None, ""); + let var_a = make_aux("a_var", "1", None, ""); + let var_b = make_aux("b_var", "2", None, ""); + let model = make_model(vec![var_c, var_a, var_b]); + let project = make_project(vec![model]); + + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + + let pos_a = mdl.find("a var = ").expect("should contain a var"); + let pos_b = mdl.find("b var = ").expect("should contain b var"); + let pos_c = mdl.find("c var = ").expect("should contain c var"); + assert!( + pos_a < pos_b && pos_b < pos_c, + "ungrouped variables should appear in alphabetical order: a={pos_a}, b={pos_b}, c={pos_c}" + ); +} + +#[test] +fn grouped_variables_retain_group_order() { + // Group members in a specific order: z, m, a -- should NOT be alphabetized + let var_z = make_aux("z_rate", "10", None, ""); + let var_m = make_aux("m_rate", "20", None, ""); + let var_a = make_aux("a_rate", "30", None, ""); + let var_ungrouped = make_aux("ungrouped_x", "40", None, ""); + + let group = datamodel::ModelGroup { + name: "My Sector".to_owned(), + doc: Some("Sector docs".to_owned()), + parent: None, + members: vec![ + "z_rate".to_owned(), + "m_rate".to_owned(), + "a_rate".to_owned(), + ], + run_enabled: false, + }; + + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![var_z, var_m, var_a, var_ungrouped], + views: vec![], + loop_metadata: vec![], + groups: vec![group], + }; + let project = make_project(vec![model]); + + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + + // Grouped vars should appear in group order (z, m, a), not alphabetical + let pos_z = mdl.find("z rate = ").expect("should contain z rate"); + let pos_m = mdl.find("m rate = ").expect("should contain m rate"); + let pos_a = mdl.find("a rate = ").expect("should contain a rate"); + assert!( + pos_z < pos_m && pos_m < pos_a, + "grouped variables should retain group order: z={pos_z}, m={pos_m}, a={pos_a}" + ); + + // Ungrouped variables should come after grouped section + let pos_ungrouped = mdl + .find("ungrouped x = ") + .expect("should contain ungrouped x"); + assert!( + pos_a < pos_ungrouped, + "ungrouped variables should come after grouped: a={pos_a}, ungrouped={pos_ungrouped}" + ); +} + +#[test] +fn xmile_groups_are_not_split_into_separate_views() { + let sf = make_stock_flow(vec![ + make_xmile_group("Economic Sector", 100), + make_view_aux("price", 1), + make_view_stock("inventory", 2), + make_xmile_group("Social Sector", 200), + make_view_aux("population", 3), + ]); + let segments = split_view_on_groups(&sf); + assert_eq!( + segments.len(), + 1, + "XMILE groups should not trigger view splitting" + ); + // All elements including groups should be in the single segment + assert_eq!( + segments[0].1.len(), + 5, + "all elements (including XMILE groups) should be in the segment" + ); +} + +#[test] +fn mixed_mdl_markers_and_xmile_groups() { + let sf = make_stock_flow(vec![ + make_view_group("View 1", 100), // MDL marker + make_xmile_group("Sector A", 101), // XMILE org group + make_view_aux("price", 1), + make_view_group("View 2", 200), // MDL marker + make_view_aux("rate", 2), + ]); + let segments = split_view_on_groups(&sf); + assert_eq!( + segments.len(), + 2, + "should split on MDL markers only, not XMILE groups" + ); + // First segment: XMILE group + price + assert_eq!(segments[0].0, "View 1"); + assert_eq!(segments[0].1.len(), 2, "first segment: xmile_group + price"); + // Second segment: rate + assert_eq!(segments[1].0, "View 2"); + assert_eq!(segments[1].1.len(), 1, "second segment: rate"); +} + +#[test] +fn empty_views_produce_valid_sketch_section() { + let views: Vec = vec![]; + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&views); + let output = writer.buf; + + assert!( + output.contains("V300"), + "empty views should still produce a V300 header: {output}" + ); + assert!( + output.contains("///---\\\\\\"), + "should have terminator: {output}" + ); +} + +#[test] +fn lookup_sentinel_constant_used_consistently() { + assert!(is_lookup_only_equation(LOOKUP_SENTINEL)); + assert!(is_lookup_only_equation("")); + assert!(is_lookup_only_equation(" 0+0 ")); + assert!(!is_lookup_only_equation("TIME")); + assert!(!is_lookup_only_equation("x + y")); +} diff --git a/src/simlin-engine/src/project_io.gen.rs b/src/simlin-engine/src/project_io.gen.rs index a3edbf7e..389ccfa7 100644 --- a/src/simlin-engine/src/project_io.gen.rs +++ b/src/simlin-engine/src/project_io.gen.rs @@ -1,7 +1,7 @@ // @generated by prost-build from project_io.proto // DO NOT EDIT - regenerate with: pnpm build:gen-protobufs // -// Proto file SHA256: cb756c694e6971777ed61c0c16140ac6f8918a7e1b80bfaf73d4b12fddd2c4f2 +// Proto file SHA256: afaeecd6a1d1faf007afe883cac400d8e261848ea7b21f9e611bce8ce2f71b7a // prost-build version: 0.14 // This file is @generated by prost-build. @@ -519,6 +519,8 @@ pub mod view_element { pub width: f64, #[prost(double, tag = "6")] pub height: f64, + #[prost(bool, tag = "7")] + pub is_mdl_view_marker: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] diff --git a/src/simlin-engine/src/project_io.proto b/src/simlin-engine/src/project_io.proto index 937d7483..7741e5fc 100644 --- a/src/simlin-engine/src/project_io.proto +++ b/src/simlin-engine/src/project_io.proto @@ -255,6 +255,7 @@ message ViewElement { double y = 4; double width = 5; double height = 6; + bool is_mdl_view_marker = 7; }; oneof element { diff --git a/src/simlin-engine/src/serde.rs b/src/simlin-engine/src/serde.rs index 3dcf1c86..676ab085 100644 --- a/src/simlin-engine/src/serde.rs +++ b/src/simlin-engine/src/serde.rs @@ -1701,6 +1701,7 @@ impl From for view_element::Group { y: v.y, width: v.width, height: v.height, + is_mdl_view_marker: v.is_mdl_view_marker, } } } @@ -1714,6 +1715,7 @@ impl From for project_io::view_element::Group { y: v.y, width: v.width, height: v.height, + is_mdl_view_marker: v.is_mdl_view_marker, } } } @@ -1816,6 +1818,7 @@ fn test_view_element_roundtrip() { y: 175.0, width: 200.0, height: 150.0, + is_mdl_view_marker: false, }), ]; for expected in cases { diff --git a/src/simlin-engine/src/xmile/views.rs b/src/simlin-engine/src/xmile/views.rs index aa7d98e6..a8c315d3 100644 --- a/src/simlin-engine/src/xmile/views.rs +++ b/src/simlin-engine/src/xmile/views.rs @@ -1611,6 +1611,7 @@ pub mod view_element { y: v.y + v.height / 2.0, width: v.width, height: v.height, + is_mdl_view_marker: false, } } } @@ -1638,6 +1639,7 @@ pub mod view_element { y: 175.0, width: 200.0, height: 150.0, + is_mdl_view_marker: false, }]; for expected in cases { let expected = expected.clone(); From 38db1c65dd95cce3c6b8d2596c9d2ff3c7512973 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Thu, 19 Mar 2026 07:38:44 -0700 Subject: [PATCH 28/31] update schema --- docs/simlin-project.schema.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/simlin-project.schema.json b/docs/simlin-project.schema.json index 0681d4e3..80b039c7 100644 --- a/docs/simlin-project.schema.json +++ b/docs/simlin-project.schema.json @@ -1033,6 +1033,9 @@ "height": { "type": "number", "format": "double" + }, + "isMdlViewMarker": { + "type": "boolean" } }, "required": [ From 31a18c09c6039856a2653855342ffdfbe7d0958c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Thu, 19 Mar 2026 11:38:52 -0700 Subject: [PATCH 29/31] engine: preserve mdl sketch roundtrip fidelity Preserve the raw sketch metadata that the standard MDL import path loses during view composition and normalization so MDL export can reconstruct Vensim flow blocks and connector endpoints correctly. Tighten the MDL roundtrip tests around raw sketch records and add focused unit coverage for preserved flow geometry, attached-valve links, stock-targeted causal links, stock ghost offsets, and per-view offset inversion. --- src/simlin-engine/src/datamodel.rs | 68 ++ src/simlin-engine/src/diagram/render.rs | 1 + src/simlin-engine/src/diagram/render_png.rs | 1 + src/simlin-engine/src/json.rs | 1 + src/simlin-engine/src/layout/mod.rs | 7 + src/simlin-engine/src/mdl/view/convert.rs | 773 +++++++++++++++++-- src/simlin-engine/src/mdl/view/elements.rs | 20 +- src/simlin-engine/src/mdl/view/processing.rs | 72 ++ src/simlin-engine/src/mdl/view/types.rs | 26 + src/simlin-engine/src/mdl/writer.rs | 504 +++++++++--- src/simlin-engine/src/mdl/writer_tests.rs | 263 +++++++ src/simlin-engine/src/patch.rs | 1 + src/simlin-engine/src/serde.rs | 6 + src/simlin-engine/src/stdlib.gen.rs | 6 + src/simlin-engine/src/xmile/views.rs | 9 + src/simlin-engine/tests/mdl_roundtrip.rs | 653 +++++++++++----- 16 files changed, 2048 insertions(+), 363 deletions(-) diff --git a/src/simlin-engine/src/datamodel.rs b/src/simlin-engine/src/datamodel.rs index 550f5250..2a0cb333 100644 --- a/src/simlin-engine/src/datamodel.rs +++ b/src/simlin-engine/src/datamodel.rs @@ -472,7 +472,72 @@ pub mod view_element { pub struct ViewElementCompat { pub width: f64, pub height: f64, + pub shape: i32, pub bits: u32, + /// Optional raw sketch field stored between the UID and coordinates. + /// Vensim uses this slot for attached valves and cloud comments. + pub name_field: Option, + /// Raw fields following `bits` in the sketch record, preserved so the + /// writer can roundtrip MDL-specific flags it does not interpret. + pub tail: Option, + } + + #[cfg_attr(feature = "debug-derive", derive(Debug))] + #[derive(Clone, PartialEq)] + pub struct SketchSegmentCompat { + /// Translation applied by MDL view composition before the datamodel sees + /// the view. Serializing a split view subtracts this offset again. + pub x_offset: f64, + pub y_offset: f64, + } + + #[cfg_attr(feature = "debug-derive", derive(Debug))] + #[derive(Clone, PartialEq)] + pub struct FlowSketchPointCompat { + /// Pipe-connector control point after MDL view composition. + pub connector_x: f64, + pub connector_y: f64, + /// Flow point coordinates as imported into the datamodel after MDL + /// normalization. The writer applies the current point delta to the + /// stored connector control point so edited flows still serialize + /// sensibly instead of snapping back to the original sketch geometry. + pub point_x: f64, + pub point_y: f64, + } + + #[cfg_attr(feature = "debug-derive", derive(Debug))] + #[derive(Clone, PartialEq)] + pub struct FlowSketchCompat { + pub uid: i32, + pub valve_x: f64, + pub valve_y: f64, + pub label_x: f64, + pub label_y: f64, + pub pipe_points: Vec, + } + + #[cfg_attr(feature = "debug-derive", derive(Debug))] + #[derive(Clone, PartialEq)] + pub struct LinkSketchCompat { + pub uid: i32, + pub field4: i32, + pub field10: i32, + pub from_attached_valve: bool, + pub to_attached_valve: bool, + pub control_x: f64, + pub control_y: f64, + pub from_x: f64, + pub from_y: f64, + pub to_x: f64, + pub to_y: f64, + } + + #[cfg_attr(feature = "debug-derive", derive(Debug))] + #[derive(Clone, PartialEq, Default)] + pub struct StockFlowSketchCompat { + pub segments: Vec, + pub flows: Vec, + pub links: Vec, } #[cfg_attr(feature = "debug-derive", derive(Debug))] @@ -663,6 +728,9 @@ pub struct StockFlow { /// Vensim default font string (e.g. "Arial|12||0-0-0|0-0-0|-1--1--1|-1--1--1|96,96") /// preserved during MDL roundtripping. pub font: Option, + /// Raw MDL sketch metadata that must survive parse normalization so the + /// writer can invert the standard MDL import path when exporting again. + pub sketch_compat: Option, } impl StockFlow { diff --git a/src/simlin-engine/src/diagram/render.rs b/src/simlin-engine/src/diagram/render.rs index 68fb887c..2d6a1467 100644 --- a/src/simlin-engine/src/diagram/render.rs +++ b/src/simlin-engine/src/diagram/render.rs @@ -397,6 +397,7 @@ mod tests { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], diff --git a/src/simlin-engine/src/diagram/render_png.rs b/src/simlin-engine/src/diagram/render_png.rs index a3d02d5a..043f3138 100644 --- a/src/simlin-engine/src/diagram/render_png.rs +++ b/src/simlin-engine/src/diagram/render_png.rs @@ -127,6 +127,7 @@ mod tests { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], diff --git a/src/simlin-engine/src/json.rs b/src/simlin-engine/src/json.rs index e18c4810..78b2ff7f 100644 --- a/src/simlin-engine/src/json.rs +++ b/src/simlin-engine/src/json.rs @@ -1177,6 +1177,7 @@ impl From for datamodel::View { zoom: if view.zoom == 0.0 { 1.0 } else { view.zoom }, use_lettered_polarity: view.use_lettered_polarity, font: None, + sketch_compat: None, }) } } diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index 11c0b1aa..d51bd8df 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -167,6 +167,7 @@ impl<'a> LayoutEngine<'a> { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }); } @@ -226,6 +227,7 @@ impl<'a> LayoutEngine<'a> { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }) } @@ -3157,6 +3159,7 @@ mod tests { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }, crossings: 5, seed: 42, @@ -3174,6 +3177,7 @@ mod tests { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }, crossings: 2, seed: 123, @@ -3207,6 +3211,7 @@ mod tests { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }, crossings: 3, seed: 123, @@ -3231,6 +3236,7 @@ mod tests { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }, crossings: 3, seed: 42, @@ -3790,6 +3796,7 @@ mod tests { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; assert_eq!(count_view_crossings(&view), 0); diff --git a/src/simlin-engine/src/mdl/view/convert.rs b/src/simlin-engine/src/mdl/view/convert.rs index f627117b..6cbdf8f8 100644 --- a/src/simlin-engine/src/mdl/view/convert.rs +++ b/src/simlin-engine/src/mdl/view/convert.rs @@ -112,8 +112,11 @@ pub fn build_views( // We do steps 3 then 1 (flow points already have attached_to_uid from compute_flow_points). for view in &mut result { let View::StockFlow(sf) = view; - fixup_flow_takeoffs(&mut sf.elements); - reassign_uids_sequential(&mut sf.elements); + fixup_flow_takeoffs(&mut sf.elements, sf.sketch_compat.as_mut()); + let uid_map = reassign_uids_sequential(&mut sf.elements); + if let Some(sketch_compat) = sf.sketch_compat.as_mut() { + remap_sketch_compat_uids(sketch_compat, &uid_map); + } } result @@ -128,7 +131,10 @@ const STOCK_HEIGHT: f64 = 35.0; /// Matches the XMILE path's `fixup_flow_takeoffs()` in xmile.rs. /// When a flow point is attached to a stock, the coordinate is snapped /// to the nearest edge of the stock rectangle rather than its center. -fn fixup_flow_takeoffs(elements: &mut [ViewElement]) { +fn fixup_flow_takeoffs( + elements: &mut [ViewElement], + sketch_compat: Option<&mut view_element::StockFlowSketchCompat>, +) { // Collect stock positions by UID let stocks: HashMap = elements .iter() @@ -143,11 +149,27 @@ fn fixup_flow_takeoffs(elements: &mut [ViewElement]) { for elem in elements.iter_mut() { if let ViewElement::Flow(flow) = elem { - if flow.points.len() != 2 { + if flow.points.len() < 2 { continue; } - let source = flow.points[0].clone(); - let sink = flow.points[1].clone(); + let source = flow + .points + .first() + .cloned() + .unwrap_or(view_element::FlowPoint { + x: 0.0, + y: 0.0, + attached_to_uid: None, + }); + let sink = flow + .points + .last() + .cloned() + .unwrap_or(view_element::FlowPoint { + x: 0.0, + y: 0.0, + attached_to_uid: None, + }); // Adjust source point if attached to a stock if let Some(stock_uid) = source.attached_to_uid @@ -160,10 +182,15 @@ fn fixup_flow_takeoffs(elements: &mut [ViewElement]) { if let Some(stock_uid) = sink.attached_to_uid && let Some(&(sx, sy)) = stocks.get(&stock_uid) { - adjust_takeoff_point(&mut flow.points[1], sx, sy, &source); + let last_idx = flow.points.len() - 1; + adjust_takeoff_point(&mut flow.points[last_idx], sx, sy, &source); } } } + + if let Some(sketch_compat) = sketch_compat { + sync_flow_sketch_points(elements, sketch_compat); + } } /// Snap a flow point to the nearest edge of its attached stock. @@ -195,7 +222,7 @@ fn adjust_takeoff_point( /// /// Matches the XMILE path's `assign_uids()` in xmile.rs, which assigns /// UIDs sequentially in element order starting from 1. -fn reassign_uids_sequential(elements: &mut [ViewElement]) { +fn reassign_uids_sequential(elements: &mut [ViewElement]) -> HashMap { // Build old_uid -> new_uid mapping let mut uid_map: HashMap = HashMap::new(); let mut next_uid = 1; @@ -235,6 +262,42 @@ fn reassign_uids_sequential(elements: &mut [ViewElement]) { ViewElement::Group(g) => g.uid = remap(g.uid), } } + + uid_map +} + +fn sync_flow_sketch_points( + elements: &[ViewElement], + sketch_compat: &mut view_element::StockFlowSketchCompat, +) { + for flow_compat in &mut sketch_compat.flows { + let Some(ViewElement::Flow(flow)) = elements + .iter() + .find(|elem| elem.get_uid() == flow_compat.uid) + else { + continue; + }; + if flow.points.len() != flow_compat.pipe_points.len() { + continue; + } + for (point, compat) in flow.points.iter().zip(&mut flow_compat.pipe_points) { + compat.point_x = point.x; + compat.point_y = point.y; + } + } +} + +fn remap_sketch_compat_uids( + sketch_compat: &mut view_element::StockFlowSketchCompat, + uid_map: &HashMap, +) { + let remap = |uid: i32| -> i32 { uid_map.get(&uid).copied().unwrap_or(uid) }; + for flow in &mut sketch_compat.flows { + flow.uid = remap(flow.uid); + } + for link in &mut sketch_compat.links { + link.uid = remap(link.uid); + } } /// Merge multiple views into a single StockFlow view. @@ -255,6 +318,7 @@ fn merge_views(views: Vec) -> Vec { // Use the font from the first view (all views in a Vensim file // typically share the same font specification). let mut font = None; + let mut sketch_compat = view_element::StockFlowSketchCompat::default(); for view in views { let View::StockFlow(sf) = view; @@ -262,6 +326,11 @@ fn merge_views(views: Vec) -> Vec { if font.is_none() { font = sf.font; } + if let Some(mut compat) = sf.sketch_compat { + sketch_compat.segments.append(&mut compat.segments); + sketch_compat.flows.append(&mut compat.flows); + sketch_compat.links.append(&mut compat.links); + } all_elements.extend(sf.elements); } @@ -272,6 +341,7 @@ fn merge_views(views: Vec) -> Vec { zoom: 1.0, use_lettered_polarity, font, + sketch_compat: Some(sketch_compat), }); vec![merged] @@ -293,6 +363,8 @@ fn convert_view( use_lettered_polarity: bool, ) -> Option { let mut elements = Vec::new(); + let mut flow_sketch_compat = Vec::new(); + let mut link_sketch_compat = Vec::new(); let uid_offset = view.uid_offset; let (valve_to_flow, flow_to_valve) = build_attached_valve_flow_maps(view); @@ -323,7 +395,7 @@ fn convert_view( match elem { VensimElement::Variable(var) => { - if let Some(view_elem) = convert_variable( + if let Some((view_elem, flow_compat)) = convert_variable( var, uid, symbols, @@ -338,6 +410,9 @@ fn convert_view( if matches!(&view_elem, ViewElement::Flow(_)) { emitted_flow_uids.insert(uid); } + if let Some(flow_compat) = flow_compat { + flow_sketch_compat.push(flow_compat); + } elements.push(view_elem); } } @@ -352,9 +427,10 @@ fn convert_view( // Non-cloud comments are ignored } VensimElement::Connector(conn) => { - if let Some(link) = + if let Some((link, link_compat)) = convert_connector(conn, uid, view, uid_offset, symbols, &valve_to_flow) { + link_sketch_compat.push(link_compat); elements.push(link); } } @@ -375,6 +451,14 @@ fn convert_view( zoom: 1.0, use_lettered_polarity, font: view.header.font.clone(), + sketch_compat: Some(view_element::StockFlowSketchCompat { + segments: vec![view_element::SketchSegmentCompat { + x_offset: view.x_offset as f64, + y_offset: view.y_offset as f64, + }], + flows: flow_sketch_compat, + links: link_sketch_compat, + }), })) } @@ -409,11 +493,25 @@ fn should_filter_from_view( } /// Build a ViewElementCompat from raw MDL sketch dimensions. -fn make_compat(width: i32, height: i32, bits: i32) -> view_element::ViewElementCompat { +fn make_compat( + width: i32, + height: i32, + shape: i32, + bits: i32, + name_field: Option, + tail: &str, +) -> view_element::ViewElementCompat { view_element::ViewElementCompat { width: width as f64, height: height as f64, + shape, bits: bits as u32, + name_field, + tail: if tail.is_empty() { + None + } else { + Some(tail.to_string()) + }, } } @@ -430,7 +528,7 @@ fn convert_variable( uid_offset: i32, view_offsets: &[i32], flow_to_valve: &HashMap, -) -> Option { +) -> Option<(ViewElement, Option)> { let canonical = to_lower_space(&var.name); // Skip Time and unwanted control variables @@ -471,38 +569,55 @@ fn convert_variable( (var.x as f64, var.y as f64) }; - return Some(ViewElement::Alias(view_element::Alias { - uid, - alias_of_uid, - x: alias_x, - y: alias_y, - label_side: view_element::LabelSide::Bottom, - compat: Some(make_compat(var.width, var.height, var.bits)), - })); + return Some(( + ViewElement::Alias(view_element::Alias { + uid, + alias_of_uid, + x: alias_x, + y: alias_y, + label_side: view_element::LabelSide::Bottom, + compat: Some(make_compat( + var.width, var.height, var.shape, var.bits, None, &var.tail, + )), + }), + None, + )); } } let var_type = symbol_info.var_type; match var_type { - VariableType::Stock => Some(ViewElement::Stock(view_element::Stock { - name: xmile_name, - uid, - x: var.x as f64, - y: var.y as f64, - label_side: view_element::LabelSide::Top, // Stocks default to top - compat: Some(make_compat(var.width, var.height, var.bits)), - })), + VariableType::Stock => Some(( + ViewElement::Stock(view_element::Stock { + name: xmile_name, + uid, + x: var.x as f64, + y: var.y as f64, + label_side: view_element::LabelSide::Top, // Stocks default to top + compat: Some(make_compat( + var.width, var.height, var.shape, var.bits, None, &var.tail, + )), + }), + None, + )), VariableType::Flow => { // For flows, find the associated valve and compute flow points - let (flow_x, flow_y, points) = + let (flow_x, flow_y, points, pipe_points) = compute_flow_data(var, view, uid_offset, symbols, flow_to_valve); // compat holds the valve's dimensions; label_compat holds the label variable's let valve_uid = flow_to_valve.get(&var.uid).copied().unwrap_or(var.uid - 1); let valve_compat = if var.attached { if let Some(VensimElement::Valve(valve)) = view.get(valve_uid) { - Some(make_compat(valve.width, valve.height, valve.bits)) + Some(make_compat( + valve.width, + valve.height, + valve.shape, + valve.bits, + Some(valve.name.clone()), + &valve.tail, + )) } else { None } @@ -510,25 +625,42 @@ fn convert_variable( None }; - Some(ViewElement::Flow(view_element::Flow { + Some(( + ViewElement::Flow(view_element::Flow { + name: xmile_name, + uid, + x: flow_x as f64, + y: flow_y as f64, + label_side: view_element::LabelSide::Bottom, + points, + compat: valve_compat, + label_compat: Some(make_compat( + var.width, var.height, var.shape, var.bits, None, &var.tail, + )), + }), + Some(view_element::FlowSketchCompat { + uid, + valve_x: flow_x as f64, + valve_y: flow_y as f64, + label_x: var.x as f64, + label_y: var.y as f64, + pipe_points, + }), + )) + } + VariableType::Aux => Some(( + ViewElement::Aux(view_element::Aux { name: xmile_name, uid, - x: flow_x as f64, - y: flow_y as f64, + x: var.x as f64, + y: var.y as f64, label_side: view_element::LabelSide::Bottom, - points, - compat: valve_compat, - label_compat: Some(make_compat(var.width, var.height, var.bits)), - })) - } - VariableType::Aux => Some(ViewElement::Aux(view_element::Aux { - name: xmile_name, - uid, - x: var.x as f64, - y: var.y as f64, - label_side: view_element::LabelSide::Bottom, - compat: Some(make_compat(var.width, var.height, var.bits)), - })), + compat: Some(make_compat( + var.width, var.height, var.shape, var.bits, None, &var.tail, + )), + }), + None, + )), } } @@ -547,7 +679,12 @@ fn compute_flow_data( uid_offset: i32, symbols: &HashMap>, flow_to_valve: &HashMap, -) -> (i32, i32, Vec) { +) -> ( + i32, + i32, + Vec, + Vec, +) { // Look for valve at uid - 1 (typical Vensim layout) // xmutil requires BOTH conditions: // 1. Flow variable has attached=true (vele->Attached()) @@ -572,20 +709,81 @@ fn compute_flow_data( valve_uid, var.x, var.y, view, &canonical, symbols, uid_offset, ); - let points = vec![ - view_element::FlowPoint { - x: endpoints.from_x as f64, - y: endpoints.from_y as f64, - attached_to_uid: endpoints.from_uid, - }, - view_element::FlowPoint { - x: endpoints.to_x as f64, - y: endpoints.to_y as f64, - attached_to_uid: endpoints.to_uid, - }, - ]; + let mut points = vec![view_element::FlowPoint { + x: endpoints.from_x as f64, + y: endpoints.from_y as f64, + attached_to_uid: endpoints.from_uid, + }]; + let mut pipe_points = Vec::new(); + + let mut endpoint_connectors: HashMap = HashMap::new(); + let mut bend_points: Vec<(i32, (i32, i32))> = Vec::new(); + for elem in view.iter() { + let VensimElement::Connector(conn) = elem else { + continue; + }; + if conn.from_uid != valve_uid { + continue; + } + if conn.to_uid == valve_uid { + bend_points.push((conn.uid, conn.control_point)); + } else { + endpoint_connectors + .entry(conn.to_uid) + .or_insert(conn.control_point); + } + } + bend_points.sort_by_key(|(uid, _)| *uid); + + if let Some(from_uid) = endpoints.from_uid { + let local_uid = from_uid - uid_offset; + let (connector_x, connector_y) = endpoint_connectors + .get(&local_uid) + .copied() + .unwrap_or((endpoints.from_x, endpoints.from_y)); + pipe_points.push(view_element::FlowSketchPointCompat { + connector_x: connector_x as f64, + connector_y: connector_y as f64, + point_x: endpoints.from_x as f64, + point_y: endpoints.from_y as f64, + }); + } + + for (_, (bend_x, bend_y)) in bend_points { + points.push(view_element::FlowPoint { + x: bend_x as f64, + y: bend_y as f64, + attached_to_uid: None, + }); + pipe_points.push(view_element::FlowSketchPointCompat { + connector_x: bend_x as f64, + connector_y: bend_y as f64, + point_x: bend_x as f64, + point_y: bend_y as f64, + }); + } + + points.push(view_element::FlowPoint { + x: endpoints.to_x as f64, + y: endpoints.to_y as f64, + attached_to_uid: endpoints.to_uid, + }); - (flow_x, flow_y, points) + if let Some(to_uid) = endpoints.to_uid { + let local_uid = to_uid - uid_offset; + let (connector_x, connector_y) = endpoint_connectors + .get(&local_uid) + .copied() + .unwrap_or((endpoints.to_x, endpoints.to_y)); + pipe_points.push(view_element::FlowSketchPointCompat { + connector_x: connector_x as f64, + connector_y: connector_y as f64, + point_x: endpoints.to_x as f64, + point_y: endpoints.to_y as f64, + }); + } + + (flow_x, flow_y, points, pipe_points) } /// Convert a comment element that serves as a cloud (flow endpoint). @@ -595,7 +793,14 @@ fn convert_comment_as_cloud(comment: &VensimComment, uid: i32, flow_uid: i32) -> flow_uid, x: comment.x as f64, y: comment.y as f64, - compat: Some(make_compat(comment.width, comment.height, comment.bits)), + compat: Some(make_compat( + comment.width, + comment.height, + comment.shape, + comment.bits, + Some(comment.text.clone()), + &comment.tail, + )), }) } @@ -607,7 +812,7 @@ fn convert_connector( uid_offset: i32, symbols: &HashMap>, valve_to_flow: &HashMap, -) -> Option { +) -> Option<(ViewElement, view_element::LinkSketchCompat)> { let from_uid = uid_offset + conn.from_uid; let to_uid = uid_offset + conn.to_uid; @@ -630,6 +835,7 @@ fn convert_connector( } // Handle valve indirection: if 'from' is a valve, use the next element (flow) + let from_attached_valve = matches!(from_elem, VensimElement::Valve(v) if v.attached); let (actual_from, actual_from_uid) = match from_elem { VensimElement::Valve(v) if v.attached => { let flow_uid = resolve_flow_uid_for_valve(conn.from_uid, view, valve_to_flow)?; @@ -640,6 +846,7 @@ fn convert_connector( }; // Similarly for 'to' + let to_attached_valve = matches!(to_elem, VensimElement::Valve(v) if v.attached); let (actual_to, actual_to_uid) = match to_elem { VensimElement::Valve(v) if v.attached => { let flow_uid = resolve_flow_uid_for_valve(conn.to_uid, view, valve_to_flow)?; @@ -683,6 +890,7 @@ fn convert_connector( let canonical = to_lower_space(&v.name); if let Some(info) = symbols.get(&canonical) && info.var_type == VariableType::Stock + && (conn.field4 == 4 || conn.field4 == 100 || from_attached_valve || to_attached_valve) { return None; } @@ -697,13 +905,39 @@ fn convert_connector( _ => None, }; - Some(ViewElement::Link(view_element::Link { - uid, - from_uid: actual_from_uid, - to_uid: actual_to_uid, - shape, - polarity, - })) + let (raw_from_x, raw_from_y) = if from_attached_valve { + (from_elem.x() as f64, from_elem.y() as f64) + } else { + (actual_from.x() as f64, actual_from.y() as f64) + }; + let (raw_to_x, raw_to_y) = if to_attached_valve { + (to_elem.x() as f64, to_elem.y() as f64) + } else { + (actual_to.x() as f64, actual_to.y() as f64) + }; + + Some(( + ViewElement::Link(view_element::Link { + uid, + from_uid: actual_from_uid, + to_uid: actual_to_uid, + shape, + polarity, + }), + view_element::LinkSketchCompat { + uid, + field4: conn.field4, + field10: conn.field10, + from_attached_valve, + to_attached_valve, + control_x: conn.control_point.0 as f64, + control_y: conn.control_point.1 as f64, + from_x: raw_from_x, + from_y: raw_from_y, + to_x: raw_to_x, + to_y: raw_to_y, + }, + )) } /// Epsilon for comparing angles - angles within this threshold are considered equal. @@ -882,6 +1116,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -953,6 +1189,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -969,6 +1207,8 @@ mod tests { attached: false, is_ghost: true, // Usually marked as ghost bits: 2, + shape: 0, + tail: String::new(), }), ); @@ -982,6 +1222,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (0, 0), + field4: 0, + field10: 0, }), ); @@ -1047,6 +1289,8 @@ mod tests { attached: false, is_ghost: false, // Primary definition bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1063,6 +1307,8 @@ mod tests { attached: false, is_ghost: true, // Ghost/alias bits: 2, + shape: 0, + tail: String::new(), }), ); @@ -1119,6 +1365,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1162,6 +1410,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1201,6 +1451,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1217,6 +1469,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1261,6 +1515,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1277,6 +1533,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1290,6 +1548,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (0, 0), + field4: 0, + field10: 0, }), ); @@ -1336,6 +1596,8 @@ mod tests { height: 8, attached: true, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -1350,6 +1612,8 @@ mod tests { attached: true, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1360,6 +1624,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (175, 120), + field4: 0, + field10: 0, }; view.insert(10, VensimElement::Connector(bend_connector.clone())); @@ -1398,6 +1664,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -1412,6 +1680,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -1425,6 +1695,8 @@ mod tests { height: 8, attached: true, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -1439,6 +1711,8 @@ mod tests { attached: true, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1452,6 +1726,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (110, 100), + field4: 0, + field10: 0, }), ); view.insert( @@ -1463,6 +1739,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (160, 130), + field4: 0, + field10: 0, }), ); view.insert( @@ -1474,6 +1752,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (210, 100), + field4: 0, + field10: 0, }), ); @@ -1541,6 +1821,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -1555,6 +1837,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -1568,6 +1852,8 @@ mod tests { height: 8, attached: true, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -1582,6 +1868,8 @@ mod tests { attached: true, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -1593,6 +1881,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (122, 100), + field4: 0, + field10: 0, }), ); view.insert( @@ -1604,6 +1894,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (278, 100), + field4: 0, + field10: 0, }), ); @@ -1674,6 +1966,319 @@ mod tests { ); } + #[test] + fn test_stock_targeted_causal_link_is_preserved() { + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + font: None, + }; + let mut view = VensimView::new(header); + + view.insert( + 1, + VensimElement::Variable(VensimVariable { + uid: 1, + name: "Inventory".to_string(), + x: 220, + y: 120, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + shape: 0, + tail: String::new(), + }), + ); + view.insert( + 2, + VensimElement::Variable(VensimVariable { + uid: 2, + name: "Policy".to_string(), + x: 80, + y: 120, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + shape: 0, + tail: String::new(), + }), + ); + view.insert( + 3, + VensimElement::Connector(super::super::types::VensimConnector { + uid: 3, + from_uid: 2, + to_uid: 1, + polarity: Some('+'), + letter_polarity: false, + control_point: (150, 120), + field4: 0, + field10: 7, + }), + ); + + let mut symbols = HashMap::new(); + symbols.insert( + "inventory".to_string(), + make_symbol_info(VariableType::Stock), + ); + symbols.insert("policy".to_string(), make_symbol_info(VariableType::Aux)); + + let result = build_views(vec![view], &symbols, &names_from_symbols(&symbols)); + let View::StockFlow(sf) = &result[0]; + + let stock_uid = sf + .elements + .iter() + .find_map(|elem| match elem { + ViewElement::Stock(stock) => Some(stock.uid), + _ => None, + }) + .expect("expected stock element"); + let aux_uid = sf + .elements + .iter() + .find_map(|elem| match elem { + ViewElement::Aux(aux) => Some(aux.uid), + _ => None, + }) + .expect("expected aux element"); + let link = sf + .elements + .iter() + .find_map(|elem| match elem { + ViewElement::Link(link) => Some(link), + _ => None, + }) + .expect("expected causal link"); + + assert_eq!(link.from_uid, aux_uid, "link should start at the aux"); + assert_eq!(link.to_uid, stock_uid, "link should still target the stock"); + assert_eq!( + link.polarity, + Some(view_element::LinkPolarity::Positive), + "polarity should be preserved", + ); + + let sketch_compat = sf.sketch_compat.as_ref().expect("expected sketch compat"); + let link_compat = sketch_compat + .links + .iter() + .find(|compat| compat.uid == link.uid) + .expect("expected link sketch compat"); + assert_eq!(link_compat.field4, 0); + assert_eq!(link_compat.field10, 7); + assert!(!link_compat.from_attached_valve); + assert!(!link_compat.to_attached_valve); + } + + #[test] + fn test_attached_valve_connector_preserves_sketch_endpoint_metadata() { + let header = ViewHeader { + version: ViewVersion::V300, + title: "Test View".to_string(), + font: None, + }; + let mut view = VensimView::new(header); + + view.insert( + 1, + VensimElement::Variable(VensimVariable { + uid: 1, + name: "Stock A".to_string(), + x: 100, + y: 100, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + shape: 0, + tail: String::new(), + }), + ); + view.insert( + 2, + VensimElement::Variable(VensimVariable { + uid: 2, + name: "Stock B".to_string(), + x: 300, + y: 100, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + shape: 0, + tail: String::new(), + }), + ); + view.insert( + 100, + VensimElement::Valve(super::super::types::VensimValve { + uid: 100, + name: "100".to_string(), + x: 200, + y: 100, + width: 6, + height: 8, + attached: true, + bits: 3, + shape: 0, + tail: String::new(), + }), + ); + view.insert( + 6, + VensimElement::Variable(VensimVariable { + uid: 6, + name: "Flow Rate".to_string(), + x: 200, + y: 116, + width: 40, + height: 20, + attached: true, + is_ghost: false, + bits: 3, + shape: 0, + tail: String::new(), + }), + ); + view.insert( + 7, + VensimElement::Variable(VensimVariable { + uid: 7, + name: "Policy".to_string(), + x: 80, + y: 60, + width: 40, + height: 20, + attached: false, + is_ghost: false, + bits: 3, + shape: 0, + tail: String::new(), + }), + ); + view.insert( + 101, + VensimElement::Connector(super::super::types::VensimConnector { + uid: 101, + from_uid: 100, + to_uid: 1, + polarity: None, + letter_polarity: false, + control_point: (122, 100), + field4: 100, + field10: 0, + }), + ); + view.insert( + 102, + VensimElement::Connector(super::super::types::VensimConnector { + uid: 102, + from_uid: 100, + to_uid: 2, + polarity: None, + letter_polarity: false, + control_point: (278, 100), + field4: 4, + field10: 0, + }), + ); + view.insert( + 103, + VensimElement::Connector(super::super::types::VensimConnector { + uid: 103, + from_uid: 7, + to_uid: 100, + polarity: Some('+'), + letter_polarity: false, + control_point: (150, 70), + field4: 1, + field10: 9, + }), + ); + + let mut symbols = HashMap::new(); + symbols.insert( + "stock a".to_string(), + SymbolInfo { + var_type: VariableType::Stock, + equations: vec![], + inflows: vec![], + outflows: vec!["flow rate".to_string()], + unwanted: false, + alternate_name: None, + }, + ); + symbols.insert( + "stock b".to_string(), + SymbolInfo { + var_type: VariableType::Stock, + equations: vec![], + inflows: vec!["flow rate".to_string()], + outflows: vec![], + unwanted: false, + alternate_name: None, + }, + ); + symbols.insert( + "flow rate".to_string(), + make_symbol_info(VariableType::Flow), + ); + symbols.insert("policy".to_string(), make_symbol_info(VariableType::Aux)); + + let result = build_views(vec![view], &symbols, &names_from_symbols(&symbols)); + let View::StockFlow(sf) = &result[0]; + + let flow_uid = sf + .elements + .iter() + .find_map(|elem| match elem { + ViewElement::Flow(flow) => Some(flow.uid), + _ => None, + }) + .expect("expected flow element"); + let link = sf + .elements + .iter() + .find_map(|elem| match elem { + ViewElement::Link(link) => Some(link), + _ => None, + }) + .expect("expected causal link"); + + assert_eq!( + link.to_uid, flow_uid, + "datamodel link should target the flow, not the synthetic valve", + ); + assert_eq!( + link.polarity, + Some(view_element::LinkPolarity::Positive), + "polarity should be preserved", + ); + + let sketch_compat = sf.sketch_compat.as_ref().expect("expected sketch compat"); + let link_compat = sketch_compat + .links + .iter() + .find(|compat| compat.uid == link.uid) + .expect("expected link sketch compat"); + assert_eq!(link_compat.field4, 1); + assert_eq!(link_compat.field10, 9); + assert!(!link_compat.from_attached_valve); + assert!(link_compat.to_attached_valve); + assert_eq!(link_compat.control_x, 170.0); + assert_eq!(link_compat.control_y, 130.0); + assert_eq!(link_compat.to_x, 220.0); + assert_eq!(link_compat.to_y, 160.0); + } + // --- Compat field population tests (AC2.1, AC2.2) --- #[test] @@ -1698,6 +2303,8 @@ mod tests { attached: false, is_ghost: false, bits: 131, + shape: 0, + tail: String::new(), }), ); @@ -1750,6 +2357,8 @@ mod tests { attached: false, is_ghost: false, bits: 7, + shape: 0, + tail: String::new(), }), ); @@ -1802,6 +2411,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1817,6 +2428,8 @@ mod tests { height: 11, attached: true, bits: 17, + shape: 0, + tail: String::new(), }), ); @@ -1833,6 +2446,8 @@ mod tests { attached: true, is_ghost: false, bits: 99, + shape: 0, + tail: String::new(), }), ); @@ -1901,6 +2516,8 @@ mod tests { height: 18, scratch_name: false, bits: 12, + shape: 0, + tail: String::new(), }), ); @@ -1917,6 +2534,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1932,6 +2551,8 @@ mod tests { height: 8, attached: true, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1948,6 +2569,8 @@ mod tests { attached: true, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -1961,6 +2584,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (100, 100), + field4: 0, + field10: 0, }), ); @@ -1974,6 +2599,8 @@ mod tests { polarity: None, letter_polarity: false, control_point: (200, 100), + field4: 0, + field10: 0, }), ); @@ -2030,6 +2657,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -2046,6 +2675,8 @@ mod tests { attached: false, is_ghost: true, bits: 130, + shape: 0, + tail: String::new(), }), ); @@ -2100,6 +2731,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -2135,6 +2768,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); diff --git a/src/simlin-engine/src/mdl/view/elements.rs b/src/simlin-engine/src/mdl/view/elements.rs index 715bec43..8a57a4c7 100644 --- a/src/simlin-engine/src/mdl/view/elements.rs +++ b/src/simlin-engine/src/mdl/view/elements.rs @@ -122,7 +122,7 @@ pub fn parse_variable(uid: i32, fields: &str) -> Result Result Result { let (width, rest) = parse_int_field(rest); let (height, rest) = parse_int_field(rest); let (shape, rest) = parse_int_field(rest); - let (bits, _) = parse_int_field(rest); + let (bits, tail) = parse_int_field(rest); let attached = (shape & (1 << 5)) != 0; @@ -163,8 +165,10 @@ pub fn parse_valve(uid: i32, fields: &str) -> Result { y, width, height, + shape, attached, bits, + tail: tail.to_string(), }) } @@ -183,7 +187,7 @@ pub fn parse_comment(uid: i32, fields: &str) -> Result<(VensimComment, bool), Vi let (width, rest) = parse_int_field(rest); let (height, rest) = parse_int_field(rest); let (_shape, rest) = parse_int_field(rest); - let (bits, _) = parse_int_field(rest); + let (bits, tail) = parse_int_field(rest); let scratch_name = (bits & (1 << 2)) != 0; @@ -195,8 +199,10 @@ pub fn parse_comment(uid: i32, fields: &str) -> Result<(VensimComment, bool), Vi y, width, height, + shape: _shape, scratch_name, bits, + tail: tail.to_string(), }, scratch_name, )) @@ -214,15 +220,15 @@ pub fn parse_comment(uid: i32, fields: &str) -> Result<(VensimComment, bool), Vi pub fn parse_connector(uid: i32, fields: &str) -> Result { let (from_uid, rest) = parse_int_field(fields); let (to_uid, rest) = parse_int_field(rest); - let (_ignore1, rest) = parse_string_field(rest); + let (field4, rest) = parse_int_field(rest); let (_ignore2, rest) = parse_string_field(rest); let (polarity_ascii, rest) = parse_int_field(rest); - // Skip 6 ignored fields - let (_, rest) = parse_string_field(rest); + // Skip field 7, 8, and 9, then preserve field 10. let (_, rest) = parse_string_field(rest); let (_, rest) = parse_string_field(rest); let (_, rest) = parse_string_field(rest); + let (field10, rest) = parse_int_field(rest); let (_, rest) = parse_string_field(rest); let (_, rest) = parse_string_field(rest); @@ -235,9 +241,11 @@ pub fn parse_connector(uid: i32, fields: &str) -> Result, /// Whether the original polarity was specified using letter notation (S/O) @@ -99,6 +110,8 @@ pub struct VensimConnector { pub letter_polarity: bool, /// Control point for curved connectors. (0, 0) indicates a straight line. pub control_point: (i32, i32), + /// Raw field 10 in the sketch record. + pub field10: i32, } /// A parsed view element. @@ -212,6 +225,9 @@ pub struct VensimView { pub elements: Vec>, /// UID offset for multi-view composition. pub uid_offset: i32, + /// Translation applied by MDL view composition. + pub x_offset: i32, + pub y_offset: i32, } impl VensimView { @@ -221,6 +237,8 @@ impl VensimView { header, elements: Vec::new(), uid_offset: 0, + x_offset: 0, + y_offset: 0, } } @@ -354,6 +372,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }; view.insert(5, VensimElement::Variable(var)); @@ -391,6 +411,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); view.insert( @@ -405,6 +427,8 @@ mod tests { attached: false, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }), ); @@ -426,6 +450,8 @@ mod tests { attached: true, is_ghost: false, bits: 3, + shape: 0, + tail: String::new(), }); assert_eq!(var.uid(), 1); diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index 5131c345..bdd602c1 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1420,33 +1420,53 @@ fn format_sketch_name(name: &str) -> String { /// Sketch element names use `format_sketch_name` (not `format_mdl_ident`) /// because MDL sketch lines are comma-delimited positional records where /// quoting is not used. +#[cfg(test)] fn write_aux_element(buf: &mut String, aux: &view_element::Aux) { + write_aux_element_with_context(buf, aux, SketchTransform::identity()); +} + +fn write_aux_element_with_context( + buf: &mut String, + aux: &view_element::Aux, + transform: SketchTransform, +) { let name = format_sketch_name(&aux.name); - let (w, h, bits) = match &aux.compat { - Some(c) => (c.width as i32, c.height as i32, c.bits), - None => (40, 20, 3), + let (w, h, shape, bits) = match &aux.compat { + Some(c) => (c.width as i32, c.height as i32, c.shape, c.bits), + None => (40, 20, 8, 3), }; - // shape=8 (has equation) + let (x, y) = transform.point(aux.x, aux.y); + let tail = compat_tail(aux.compat.as_ref(), "0,0,-1,0,0,0"); write!( buf, - "10,{},{},{},{},{},{},8,{},0,0,-1,0,0,0", - aux.uid, name, aux.x as i32, aux.y as i32, w, h, bits, + "10,{},{},{},{},{},{},{},{},{}", + aux.uid, name, x, y, w, h, shape, bits, tail, ) .unwrap(); } /// Write a type 10 line for a Stock element. +#[cfg(test)] fn write_stock_element(buf: &mut String, stock: &view_element::Stock) { + write_stock_element_with_context(buf, stock, SketchTransform::identity()); +} + +fn write_stock_element_with_context( + buf: &mut String, + stock: &view_element::Stock, + transform: SketchTransform, +) { let name = format_sketch_name(&stock.name); - let (w, h, bits) = match &stock.compat { - Some(c) => (c.width as i32, c.height as i32, c.bits), - None => (40, 20, 3), + let (w, h, shape, bits) = match &stock.compat { + Some(c) => (c.width as i32, c.height as i32, c.shape, c.bits), + None => (40, 20, 3, 3), }; - // shape=3 (box/stock shape) + let (x, y) = transform.point(stock.x, stock.y); + let tail = compat_tail(stock.compat.as_ref(), "0,0,0,0,0,0"); write!( buf, - "10,{},{},{},{},{},{},3,{},0,0,0,0,0,0", - stock.uid, name, stock.x as i32, stock.y as i32, w, h, bits, + "10,{},{},{},{},{},{},{},{},{}", + stock.uid, name, x, y, w, h, shape, bits, tail, ) .unwrap(); } @@ -1524,65 +1544,166 @@ fn sanitize_view_title_for_mdl(title: &str) -> String { out } +#[derive(Clone, Copy)] +struct SketchTransform { + x_offset: f64, + y_offset: f64, +} + +impl SketchTransform { + fn identity() -> Self { + Self { + x_offset: 0.0, + y_offset: 0.0, + } + } + + fn point(self, x: f64, y: f64) -> (i32, i32) { + ( + (x - self.x_offset).round() as i32, + (y - self.y_offset).round() as i32, + ) + } +} + +fn compat_tail<'a>( + compat: Option<&'a view_element::ViewElementCompat>, + default: &'a str, +) -> &'a str { + compat.and_then(|c| c.tail.as_deref()).unwrap_or(default) +} + +fn compat_name_field<'a>( + compat: Option<&'a view_element::ViewElementCompat>, + default: &'a str, +) -> &'a str { + compat + .and_then(|c| c.name_field.as_deref()) + .unwrap_or(default) +} + /// Write a Flow element as type 1 pipe connectors, type 11 (valve), and /// type 10 (attached flow variable). /// /// Vensim requires this exact ordering: pipe connectors first, then valve, /// then flow label. The valve UID is looked up from the pre-allocated /// valve_uids map to avoid collisions. +#[cfg(test)] fn write_flow_element( buf: &mut String, flow: &view_element::Flow, valve_uids: &HashMap, cloud_uids: &HashSet, next_connector_uid: &mut i32, +) { + write_flow_element_with_context( + buf, + flow, + valve_uids, + cloud_uids, + next_connector_uid, + SketchTransform::identity(), + None, + ); +} + +fn write_flow_element_with_context( + buf: &mut String, + flow: &view_element::Flow, + valve_uids: &HashMap, + _cloud_uids: &HashSet, + next_connector_uid: &mut i32, + transform: SketchTransform, + flow_compat: Option<&view_element::FlowSketchCompat>, ) { let name = format_sketch_name(&flow.name); let valve_uid = valve_uids.get(&flow.uid).copied().unwrap_or(flow.uid - 1); + let valve_compat = flow.compat.as_ref(); + let label_compat = flow.label_compat.as_ref(); + let (valve_x, valve_y) = transform.point(flow.x, flow.y); // Pipe connectors must come before the valve and flow label. - let had_pipes = - write_flow_pipe_connectors(buf, flow, valve_uid, cloud_uids, next_connector_uid); - - let (valve_w, valve_h, valve_bits) = match &flow.compat { - Some(c) => (c.width as i32, c.height as i32, c.bits), - None => (6, 8, 3), + let had_pipes = write_flow_pipe_connectors_with_context( + buf, + flow, + valve_uid, + next_connector_uid, + transform, + flow_compat, + ); + + let (valve_w, valve_h, valve_shape, valve_bits) = match valve_compat { + Some(c) => (c.width as i32, c.height as i32, c.shape, c.bits), + None => (6, 8, 34, 3), }; - let (label_w, label_h, label_bits) = match &flow.label_compat { - Some(c) => (c.width as i32, c.height as i32, c.bits), - None => (49, 8, 3), + let (label_w, label_h, label_shape, label_bits) = match label_compat { + Some(c) => (c.width as i32, c.height as i32, c.shape, c.bits), + None => (49, 8, 40, 3), }; + let valve_name = compat_name_field(valve_compat, "0"); + let valve_tail = compat_tail(valve_compat, "0,0,1,0,0,0"); - // Type 11 (valve): field 3 is always 0 in Vensim-generated files. - // Prefix with \n only if pipes were emitted (otherwise caller handles separation). if had_pipes { buf.push('\n'); } write!( buf, - "11,{},0,{},{},{},{},34,{},0,0,1,0,0,0", - valve_uid, flow.x as i32, flow.y as i32, valve_w, valve_h, valve_bits, + "11,{},{},{},{},{},{},{},{},{}", + valve_uid, + valve_name, + valve_x, + valve_y, + valve_w, + valve_h, + valve_shape, + valve_bits, + valve_tail, ) .unwrap(); - // Type 10 (attached flow variable): shape=40 (bit 3 = equation, bit 5 = attached) - // The variable is positioned slightly below the valve. - let var_y = flow.y as i32 + 16; + let (label_x, label_y) = if let Some(compat) = flow_compat { + let dx = flow.x - compat.valve_x; + let dy = flow.y - compat.valve_y; + transform.point(compat.label_x + dx, compat.label_y + dy) + } else { + transform.point(flow.x, flow.y + 16.0) + }; + let label_tail = compat_tail(label_compat, "0,0,-1,0,0,0"); write!( buf, - "\n10,{},{},{},{},{},{},40,{},0,0,-1,0,0,0", - flow.uid, name, flow.x as i32, var_y, label_w, label_h, label_bits, + "\n10,{},{},{},{},{},{},{},{},{}", + flow.uid, name, label_x, label_y, label_w, label_h, label_shape, label_bits, label_tail, ) .unwrap(); } /// Returns true if any pipe connectors were written. +#[cfg(test)] +#[allow(dead_code)] fn write_flow_pipe_connectors( buf: &mut String, flow: &view_element::Flow, valve_uid: i32, - cloud_uids: &HashSet, + _cloud_uids: &HashSet, + next_connector_uid: &mut i32, +) -> bool { + write_flow_pipe_connectors_with_context( + buf, + flow, + valve_uid, + next_connector_uid, + SketchTransform::identity(), + None, + ) +} + +fn write_flow_pipe_connectors_with_context( + buf: &mut String, + flow: &view_element::Flow, + valve_uid: i32, next_connector_uid: &mut i32, + transform: SketchTransform, + flow_compat: Option<&view_element::FlowSketchCompat>, ) -> bool { let mut wrote_any = false; @@ -1607,34 +1728,42 @@ fn write_flow_pipe_connectors( .unwrap(); }; - if let Some(first) = flow.points.first() - && let Some(endpoint_uid) = first.attached_to_uid + let connector_point = |idx: usize, point: &view_element::FlowPoint| -> (i32, i32) { + if let Some(compat) = flow_compat.and_then(|compat| compat.pipe_points.get(idx)) { + let dx = point.x - compat.point_x; + let dy = point.y - compat.point_y; + return transform.point(compat.connector_x + dx, compat.connector_y + dy); + } + transform.point(point.x, point.y) + }; + + if flow.points.len() > 1 + && let Some(last) = flow.points.last() + && let Some(endpoint_uid) = last.attached_to_uid { - let flag = if cloud_uids.contains(&endpoint_uid) { - 100 - } else { - 4 - }; + let (x, y) = connector_point(flow.points.len() - 1, last); write_pipe( buf, !wrote_any, *next_connector_uid, valve_uid, endpoint_uid, - flag, - first.x as i32, - first.y as i32, + 4, + x, + y, ); wrote_any = true; *next_connector_uid += 1; } - for point in flow + for (idx, point) in flow .points .iter() + .enumerate() .skip(1) .take(flow.points.len().saturating_sub(2)) { + let (x, y) = connector_point(idx, point); write_pipe( buf, !wrote_any, @@ -1642,31 +1771,26 @@ fn write_flow_pipe_connectors( valve_uid, valve_uid, 0, - point.x as i32, - point.y as i32, + x, + y, ); wrote_any = true; *next_connector_uid += 1; } - if flow.points.len() > 1 - && let Some(last) = flow.points.last() - && let Some(endpoint_uid) = last.attached_to_uid + if let Some(first) = flow.points.first() + && let Some(endpoint_uid) = first.attached_to_uid { - let flag = if cloud_uids.contains(&endpoint_uid) { - 100 - } else { - 4 - }; + let (x, y) = connector_point(0, first); write_pipe( buf, !wrote_any, *next_connector_uid, valve_uid, endpoint_uid, - flag, - last.x as i32, - last.y as i32, + 100, + x, + y, ); wrote_any = true; *next_connector_uid += 1; @@ -1676,39 +1800,77 @@ fn write_flow_pipe_connectors( } /// Write a type 12 line for a Cloud element. +#[cfg(test)] fn write_cloud_element(buf: &mut String, cloud: &view_element::Cloud) { - let (w, h, bits) = match &cloud.compat { - Some(c) => (c.width as i32, c.height as i32, c.bits), - None => (10, 8, 3), + write_cloud_element_with_context(buf, cloud, SketchTransform::identity()); +} + +fn write_cloud_element_with_context( + buf: &mut String, + cloud: &view_element::Cloud, + transform: SketchTransform, +) { + let (w, h, shape, bits) = match &cloud.compat { + Some(c) => (c.width as i32, c.height as i32, c.shape, c.bits), + None => (10, 8, 0, 3), }; - // Clouds: field 3 is 48 (ASCII '0') in Vensim-generated files, shape=0 + let (x, y) = transform.point(cloud.x, cloud.y); + let name_field = compat_name_field(cloud.compat.as_ref(), "48"); + let tail = compat_tail(cloud.compat.as_ref(), "0,0,-1,0,0,0"); write!( buf, - "12,{},48,{},{},{},{},0,{},0,0,-1,0,0,0", - cloud.uid, cloud.x as i32, cloud.y as i32, w, h, bits, + "12,{},{},{},{},{},{},{},{},{}", + cloud.uid, name_field, x, y, w, h, shape, bits, tail, ) .unwrap(); } /// Write a type 10 line for an Alias (ghost) element. +#[cfg(test)] fn write_alias_element( buf: &mut String, alias: &view_element::Alias, name_map: &HashMap, +) { + write_alias_element_with_context( + buf, + alias, + name_map, + &HashSet::new(), + SketchTransform::identity(), + ); +} + +fn write_alias_element_with_context( + buf: &mut String, + alias: &view_element::Alias, + name_map: &HashMap, + stock_uids: &HashSet, + transform: SketchTransform, ) { let name = name_map .get(&alias.alias_of_uid) .map(|n| format_sketch_name(n)) .unwrap_or_default(); - let (w, h, bits) = match &alias.compat { - Some(c) => (c.width as i32, c.height as i32, c.bits), - None => (40, 20, 2), + let (w, h, shape, bits) = match &alias.compat { + Some(c) => (c.width as i32, c.height as i32, c.shape, c.bits), + None => (40, 20, 8, 2), }; + let (alias_x, alias_y) = if stock_uids.contains(&alias.alias_of_uid) { + (alias.x + 22.0, alias.y + 17.0) + } else { + (alias.x, alias.y) + }; + let (x, y) = transform.point(alias_x, alias_y); + let tail = compat_tail( + alias.compat.as_ref(), + "0,3,-1,0,0,0,128-128-128,0-0-0,|12||128-128-128", + ); // shape=8 write!( buf, - "10,{},{},{},{},{},{},8,{},0,3,-1,0,0,0,128-128-128,0-0-0,|12||128-128-128", - alias.uid, name, alias.x as i32, alias.y as i32, w, h, bits, + "10,{},{},{},{},{},{},{},{},{}", + alias.uid, name, x, y, w, h, shape, bits, tail, ) .unwrap(); } @@ -1717,11 +1879,32 @@ fn write_alias_element( /// /// For arc connectors, we reverse-compute a control point from the stored /// canvas angle using the endpoints of the connected elements. +#[cfg(test)] fn write_link_element( buf: &mut String, link: &view_element::Link, elem_positions: &HashMap, use_lettered_polarity: bool, +) { + write_link_element_with_context( + buf, + link, + elem_positions, + use_lettered_polarity, + None, + SketchTransform::identity(), + &HashMap::new(), + ); +} + +fn write_link_element_with_context( + buf: &mut String, + link: &view_element::Link, + elem_positions: &HashMap, + use_lettered_polarity: bool, + link_compat: Option<&view_element::LinkSketchCompat>, + transform: SketchTransform, + valve_uids: &HashMap, ) { let polarity_val = match link.polarity { Some(LinkPolarity::Positive) if use_lettered_polarity => 83, // 'S' @@ -1731,28 +1914,46 @@ fn write_link_element( None => 0, }; - let from_pos = elem_positions - .get(&link.from_uid) - .copied() - .unwrap_or((0, 0)); - let to_pos = elem_positions.get(&link.to_uid).copied().unwrap_or((0, 0)); + let from_uid = link_compat + .filter(|compat| compat.from_attached_valve) + .and_then(|_| valve_uids.get(&link.from_uid).copied()) + .unwrap_or(link.from_uid); + let to_uid = link_compat + .filter(|compat| compat.to_attached_valve) + .and_then(|_| valve_uids.get(&link.to_uid).copied()) + .unwrap_or(link.to_uid); + let from_pos = elem_positions.get(&from_uid).copied().unwrap_or((0, 0)); + let to_pos = elem_positions.get(&to_uid).copied().unwrap_or((0, 0)); + let field4 = link_compat.map(|compat| compat.field4).unwrap_or(0); + let field10 = link_compat.map(|compat| compat.field10).unwrap_or(0); // Field 9 = 64 marks influence (causal) connectors in Vensim sketches. match &link.shape { LinkShape::Straight => { write!( buf, - "1,{},{},{},0,0,{},0,0,64,0,-1--1--1,,1|(0,0)|", - link.uid, link.from_uid, link.to_uid, polarity_val, + "1,{},{},{},{},0,{},0,0,64,{},-1--1--1,,1|(0,0)|", + link.uid, from_uid, to_uid, field4, polarity_val, field10, ) .unwrap(); } LinkShape::Arc(canvas_angle) => { - let (ctrl_x, ctrl_y) = compute_control_point(from_pos, to_pos, *canvas_angle); + let (ctrl_x, ctrl_y) = if let Some(compat) = link_compat { + let delta_x = + ((from_pos.0 as f64 - compat.from_x) + (to_pos.0 as f64 - compat.to_x)) / 2.0; + let delta_y = + ((from_pos.1 as f64 - compat.from_y) + (to_pos.1 as f64 - compat.to_y)) / 2.0; + ( + (compat.control_x + delta_x).round() as i32, + (compat.control_y + delta_y).round() as i32, + ) + } else { + compute_control_point(from_pos, to_pos, *canvas_angle) + }; write!( buf, - "1,{},{},{},0,0,{},0,0,64,0,-1--1--1,,1|({},{})|", - link.uid, link.from_uid, link.to_uid, polarity_val, ctrl_x, ctrl_y, + "1,{},{},{},{},0,{},0,0,64,{},-1--1--1,,1|({},{})|", + link.uid, from_uid, to_uid, field4, polarity_val, field10, ctrl_x, ctrl_y, ) .unwrap(); } @@ -1760,12 +1961,13 @@ fn write_link_element( let npoints = points.len(); write!( buf, - "1,{},{},{},0,0,{},0,0,64,0,-1--1--1,,{}|", - link.uid, link.from_uid, link.to_uid, polarity_val, npoints, + "1,{},{},{},{},0,{},0,0,64,{},-1--1--1,,{}|", + link.uid, from_uid, to_uid, field4, polarity_val, field10, npoints, ) .unwrap(); for pt in points { - write!(buf, "({},{})|", pt.x as i32, pt.y as i32).unwrap(); + let (x, y) = transform.point(pt.x, pt.y); + write!(buf, "({},{})|", x, y).unwrap(); } } } @@ -2085,11 +2287,48 @@ impl MdlWriter { // references (links, aliases) resolve correctly. let valve_uids = allocate_valve_uids(&sf.elements); let mut next_connector_uid = max_sketch_uid(&sf.elements, &valve_uids) + 1; - let elem_positions = build_element_positions(&sf.elements, &valve_uids); let name_map = build_name_map(&sf.elements); + let mut flow_compat_by_uid: HashMap = + HashMap::new(); + let mut link_compat_by_uid: HashMap = + HashMap::new(); + let mut stock_uids: HashSet = HashSet::new(); + if let Some(sketch_compat) = sf.sketch_compat.as_ref() { + for flow in &sketch_compat.flows { + flow_compat_by_uid.insert(flow.uid, flow); + } + for link in &sketch_compat.links { + link_compat_by_uid.insert(link.uid, link); + } + } + for elem in &sf.elements { + if let ViewElement::Stock(stock) = elem { + stock_uids.insert(stock.uid); + } + } let segments = split_view_on_groups(sf); - for (view_name, elements, font) in &segments { + let mut elem_positions = HashMap::new(); + for (segment_ix, (_, elements, _)) in segments.iter().enumerate() { + let transform = sf + .sketch_compat + .as_ref() + .and_then(|compat| compat.segments.get(segment_ix)) + .map(|compat| SketchTransform { + x_offset: compat.x_offset, + y_offset: compat.y_offset, + }) + .unwrap_or_else(SketchTransform::identity); + elem_positions.extend(build_element_positions_with_transform( + elements, + &valve_uids, + transform, + &stock_uids, + &flow_compat_by_uid, + )); + } + + for (segment_ix, (view_name, elements, font)) in segments.iter().enumerate() { if segment_idx > 0 { self.buf.push_str( "\\\\\\---/// Sketch information - do not modify anything except names\n", @@ -2105,8 +2344,19 @@ impl MdlWriter { sf.use_lettered_polarity, &valve_uids, &mut next_connector_uid, + sf.sketch_compat + .as_ref() + .and_then(|compat| compat.segments.get(segment_ix)) + .map(|compat| SketchTransform { + x_offset: compat.x_offset, + y_offset: compat.y_offset, + }) + .unwrap_or_else(SketchTransform::identity), &elem_positions, &name_map, + &stock_uids, + &flow_compat_by_uid, + &link_compat_by_uid, ); segment_idx += 1; } @@ -2125,8 +2375,12 @@ impl MdlWriter { use_lettered_polarity: bool, valve_uids: &HashMap, next_connector_uid: &mut i32, + transform: SketchTransform, elem_positions: &HashMap, name_map: &HashMap, + stock_uids: &HashSet, + flow_compat_by_uid: &HashMap, + link_compat_by_uid: &HashMap, ) { let view_title = sanitize_view_title_for_mdl(view_name); writeln!(self.buf, "*{}", view_title).unwrap(); @@ -2154,38 +2408,54 @@ impl MdlWriter { for elem in elements { match elem { ViewElement::Aux(aux) => { - write_aux_element(&mut self.buf, aux); + write_aux_element_with_context(&mut self.buf, aux, transform); self.buf.push('\n'); } ViewElement::Stock(stock) => { - write_stock_element(&mut self.buf, stock); + write_stock_element_with_context(&mut self.buf, stock, transform); self.buf.push('\n'); } ViewElement::Flow(flow) => { // Emit associated clouds before the flow pipes if let Some(clouds) = flow_clouds.get(&flow.uid) { for cloud in clouds { - write_cloud_element(&mut self.buf, cloud); + write_cloud_element_with_context(&mut self.buf, cloud, transform); self.buf.push('\n'); } } - write_flow_element( + write_flow_element_with_context( &mut self.buf, flow, valve_uids, &cloud_uids, next_connector_uid, + transform, + flow_compat_by_uid.get(&flow.uid).copied(), ); self.buf.push('\n'); } ViewElement::Link(link) => { - write_link_element(&mut self.buf, link, elem_positions, use_lettered_polarity); + write_link_element_with_context( + &mut self.buf, + link, + elem_positions, + use_lettered_polarity, + link_compat_by_uid.get(&link.uid).copied(), + transform, + valve_uids, + ); self.buf.push('\n'); } // Clouds are emitted with their associated flow above ViewElement::Cloud(_) => {} ViewElement::Alias(alias) => { - write_alias_element(&mut self.buf, alias, name_map); + write_alias_element_with_context( + &mut self.buf, + alias, + name_map, + stock_uids, + transform, + ); self.buf.push('\n'); } ViewElement::Module(_) | ViewElement::Group(_) => {} @@ -2271,26 +2541,72 @@ impl MdlWriter { /// For flow elements, `write_flow_element` emits a synthetic valve using the /// pre-allocated `valve_uids` map. We register that valve UID here so that any /// connector whose endpoint is the valve can resolve a position. +#[cfg(test)] +#[allow(dead_code)] fn build_element_positions( elements: &[ViewElement], valve_uids: &HashMap, +) -> HashMap { + build_element_positions_with_transform( + &elements.iter().collect::>(), + valve_uids, + SketchTransform::identity(), + &HashSet::new(), + &HashMap::new(), + ) +} + +fn build_element_positions_with_transform( + elements: &[&ViewElement], + valve_uids: &HashMap, + transform: SketchTransform, + stock_uids: &HashSet, + flow_compat_by_uid: &HashMap, ) -> HashMap { let mut positions = HashMap::new(); for elem in elements { let (uid, x, y) = match elem { - ViewElement::Aux(a) => (a.uid, a.x as i32, a.y as i32), - ViewElement::Stock(s) => (s.uid, s.x as i32, s.y as i32), + ViewElement::Aux(a) => { + let (x, y) = transform.point(a.x, a.y); + (a.uid, x, y) + } + ViewElement::Stock(s) => { + let (x, y) = transform.point(s.x, s.y); + (s.uid, x, y) + } ViewElement::Flow(f) => { + let (valve_x, valve_y) = transform.point(f.x, f.y); // Also register the allocated valve UID so connectors that // reference the valve position can resolve. if let Some(&valve_uid) = valve_uids.get(&f.uid) { - positions.insert(valve_uid, (f.x as i32, f.y as i32)); + positions.insert(valve_uid, (valve_x, valve_y)); } - (f.uid, f.x as i32, f.y as i32) + if let Some(compat) = flow_compat_by_uid.get(&f.uid) { + let dx = f.x - compat.valve_x; + let dy = f.y - compat.valve_y; + let (label_x, label_y) = + transform.point(compat.label_x + dx, compat.label_y + dy); + (f.uid, label_x, label_y) + } else { + (f.uid, valve_x, valve_y) + } + } + ViewElement::Cloud(c) => { + let (x, y) = transform.point(c.x, c.y); + (c.uid, x, y) + } + ViewElement::Alias(a) => { + let (x, y) = if stock_uids.contains(&a.alias_of_uid) { + transform.point(a.x + 22.0, a.y + 17.0) + } else { + transform.point(a.x, a.y) + }; + (a.uid, x, y) + } + ViewElement::Module(m) => { + let (x, y) = transform.point(m.x, m.y); + (m.uid, x, y) } - ViewElement::Cloud(c) => (c.uid, c.x as i32, c.y as i32), - ViewElement::Alias(a) => (a.uid, a.x as i32, a.y as i32), - ViewElement::Module(m) => (m.uid, m.x as i32, m.y as i32), ViewElement::Link(_) | ViewElement::Group(_) => continue, }; positions.insert(uid, (x, y)); diff --git a/src/simlin-engine/src/mdl/writer_tests.rs b/src/simlin-engine/src/mdl/writer_tests.rs index cd8ba359..03136770 100644 --- a/src/simlin-engine/src/mdl/writer_tests.rs +++ b/src/simlin-engine/src/mdl/writer_tests.rs @@ -1659,6 +1659,77 @@ fn sketch_flow_element_emits_pipe_connectors_from_flow_points() { ); } +#[test] +fn sketch_flow_element_with_compat_preserves_pipe_points_and_label_position() { + let flow = view_element::Flow { + name: "Infection_Rate".to_string(), + uid: 6, + x: 150.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + points: vec![ + view_element::FlowPoint { + x: 120.0, + y: 100.0, + attached_to_uid: Some(1), + }, + view_element::FlowPoint { + x: 180.0, + y: 100.0, + attached_to_uid: Some(2), + }, + ], + compat: None, + label_compat: None, + }; + let flow_compat = view_element::FlowSketchCompat { + uid: 6, + valve_x: 150.0, + valve_y: 100.0, + label_x: 170.0, + label_y: 134.0, + pipe_points: vec![ + view_element::FlowSketchPointCompat { + connector_x: 90.0, + connector_y: 96.0, + point_x: 120.0, + point_y: 100.0, + }, + view_element::FlowSketchPointCompat { + connector_x: 210.0, + connector_y: 104.0, + point_x: 180.0, + point_y: 100.0, + }, + ], + }; + let mut buf = String::new(); + let valve_uids = HashMap::from([(6, 100)]); + let mut next_connector_uid = 200; + write_flow_element_with_context( + &mut buf, + &flow, + &valve_uids, + &HashSet::new(), + &mut next_connector_uid, + SketchTransform::identity(), + Some(&flow_compat), + ); + + assert!( + buf.contains("1,200,100,2,4,0,0,22,0,0,0,-1--1--1,,1|(210,104)|"), + "sink pipe connector should use preserved raw control point: {buf}" + ); + assert!( + buf.contains("1,201,100,1,100,0,0,22,0,0,0,-1--1--1,,1|(90,96)|"), + "source pipe connector should use preserved raw control point: {buf}" + ); + assert!( + buf.contains("10,6,Infection Rate,170,134,49,8,40,3,0,0,-1,0,0,0"), + "flow label should use preserved raw sketch position: {buf}" + ); +} + #[test] fn valve_uids_do_not_collide_with_existing_elements() { // stock uid=1, flow uid=2 -> valve must NOT get uid=1 @@ -1722,6 +1793,32 @@ fn sketch_alias_element() { assert!(buf.contains("128-128-128")); } +#[test] +fn sketch_alias_element_offsets_stock_ghost_coordinates() { + let alias = view_element::Alias { + uid: 10, + alias_of_uid: 1, + x: 200.0, + y: 300.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }; + let mut name_map = HashMap::new(); + name_map.insert(1, "Population"); + let mut buf = String::new(); + write_alias_element_with_context( + &mut buf, + &alias, + &name_map, + &HashSet::from([1]), + SketchTransform::identity(), + ); + assert!( + buf.starts_with("10,10,Population,222,317,40,20,8,2,0,3,-1,0,0,0,"), + "stock ghosts should serialize using Vensim's stock-alias offset: {buf}" + ); +} + // ---- Phase 5 Task 2: Connector serialization (type 1) ---- #[test] @@ -1795,6 +1892,79 @@ fn sketch_link_arc_produces_nonzero_control_point() { ); } +#[test] +fn sketch_link_with_attached_valve_target_preserves_uid_and_fields() { + let link = view_element::Link { + uid: 3, + from_uid: 1, + to_uid: 2, + shape: LinkShape::Straight, + polarity: None, + }; + let positions = HashMap::from([(1, (100, 100)), (2, (200, 116)), (100, (200, 100))]); + let valve_uids = HashMap::from([(2, 100)]); + let compat = view_element::LinkSketchCompat { + uid: 3, + field4: 1, + field10: 7, + from_attached_valve: false, + to_attached_valve: true, + control_x: 150.0, + control_y: 80.0, + from_x: 100.0, + from_y: 100.0, + to_x: 200.0, + to_y: 100.0, + }; + let mut buf = String::new(); + write_link_element_with_context( + &mut buf, + &link, + &positions, + false, + Some(&compat), + SketchTransform::identity(), + &valve_uids, + ); + assert_eq!(buf, "1,3,1,100,1,0,0,0,0,64,7,-1--1--1,,1|(0,0)|"); +} + +#[test] +fn sketch_link_with_compat_reuses_control_point_delta() { + let link = view_element::Link { + uid: 3, + from_uid: 1, + to_uid: 2, + shape: LinkShape::Arc(45.0), + polarity: None, + }; + let positions = HashMap::from([(1, (110, 100)), (2, (210, 100))]); + let compat = view_element::LinkSketchCompat { + uid: 3, + field4: 0, + field10: 0, + from_attached_valve: false, + to_attached_valve: false, + control_x: 160.0, + control_y: 70.0, + from_x: 100.0, + from_y: 100.0, + to_x: 200.0, + to_y: 100.0, + }; + let mut buf = String::new(); + write_link_element_with_context( + &mut buf, + &link, + &positions, + false, + Some(&compat), + SketchTransform::identity(), + &HashMap::new(), + ); + assert_eq!(buf, "1,3,1,2,0,0,0,0,0,64,0,-1--1--1,,1|(170,70)|"); +} + #[test] fn sketch_link_multipoint_emits_all_points() { let points = vec![ @@ -1868,6 +2038,7 @@ fn sketch_section_structure() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; let views = vec![View::StockFlow(sf)]; @@ -1926,6 +2097,7 @@ fn sketch_section_in_full_project() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -2102,6 +2274,7 @@ fn sketch_roundtrip_sanitizes_multiline_view_title() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -2207,6 +2380,7 @@ fn sketch_roundtrip_preserves_flow_endpoints_with_nonadjacent_valve_uid() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -2483,6 +2657,7 @@ fn full_assembly_has_all_three_sections() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -2959,6 +3134,7 @@ fn make_stock_flow(elements: Vec) -> StockFlow { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, } } @@ -2983,6 +3159,71 @@ fn split_view_no_groups_uses_stockflow_name() { assert_eq!(segments[0].0, "My Custom View"); } +#[test] +fn write_sketch_section_reapplies_segment_offsets() { + let sf = StockFlow { + name: None, + elements: vec![ + make_view_group("1 housing", 100), + ViewElement::Aux(view_element::Aux { + name: "First_Aux".to_string(), + uid: 1, + x: 120.0, + y: 130.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + make_view_group("2 investments", 200), + ViewElement::Aux(view_element::Aux { + name: "Second_Aux".to_string(), + uid: 2, + x: 340.0, + y: 470.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ], + view_box: Rect::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: Some(view_element::StockFlowSketchCompat { + segments: vec![ + view_element::SketchSegmentCompat { + x_offset: 20.0, + y_offset: 30.0, + }, + view_element::SketchSegmentCompat { + x_offset: 240.0, + y_offset: 370.0, + }, + ], + flows: vec![], + links: vec![], + }), + }; + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&[View::StockFlow(sf)]); + let output = writer.buf; + + assert!( + output.contains("*1 housing"), + "missing first view header: {output}" + ); + assert!( + output.contains("*2 investments"), + "missing second view header: {output}" + ); + assert!( + output.contains("10,1,First Aux,100,100,40,20,8,3,0,0,-1,0,0,0"), + "first segment should subtract its stored offset: {output}" + ); + assert!( + output.contains("10,2,Second Aux,100,100,40,20,8,3,0,0,-1,0,0,0"), + "second segment should subtract its stored offset: {output}" + ); +} + #[test] fn split_view_two_groups_produces_two_segments() { let sf = make_stock_flow(vec![ @@ -3177,7 +3418,10 @@ fn stock_compat_dimensions_emitted() { compat: Some(view_element::ViewElementCompat { width: 53.0, height: 32.0, + shape: 3, bits: 131, + name_field: None, + tail: None, }), }; let mut buf = String::new(); @@ -3217,7 +3461,10 @@ fn aux_compat_dimensions_emitted() { compat: Some(view_element::ViewElementCompat { width: 45.0, height: 18.0, + shape: 8, bits: 131, + name_field: None, + tail: None, }), }; let mut buf = String::new(); @@ -3258,12 +3505,18 @@ fn flow_valve_compat_dimensions_emitted() { compat: Some(view_element::ViewElementCompat { width: 12.0, height: 18.0, + shape: 34, bits: 131, + name_field: None, + tail: None, }), label_compat: Some(view_element::ViewElementCompat { width: 55.0, height: 14.0, + shape: 40, bits: 35, + name_field: None, + tail: None, }), }; let mut buf = String::new(); @@ -3332,7 +3585,10 @@ fn cloud_compat_dimensions_emitted() { compat: Some(view_element::ViewElementCompat { width: 20.0, height: 14.0, + shape: 0, bits: 131, + name_field: None, + tail: None, }), }; let mut buf = String::new(); @@ -3371,7 +3627,10 @@ fn alias_compat_dimensions_emitted() { compat: Some(view_element::ViewElementCompat { width: 45.0, height: 18.0, + shape: 8, bits: 66, + name_field: None, + tail: None, }), }; let mut name_map = HashMap::new(); @@ -3442,6 +3701,7 @@ fn build_display_name_map_extracts_view_element_names() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })]; let map = build_display_name_map(&views); assert_eq!( @@ -3485,6 +3745,7 @@ fn build_display_name_map_first_occurrence_wins() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })]; let map = build_display_name_map(&views); // The first element's casing wins @@ -3516,6 +3777,7 @@ fn equation_lhs_uses_view_element_casing() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })]; let display_names = build_display_name_map(&views); let mut buf = String::new(); @@ -3583,6 +3845,7 @@ fn equation_lhs_casing_in_full_project_roundtrip() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], diff --git a/src/simlin-engine/src/patch.rs b/src/simlin-engine/src/patch.rs index c2d73e23..6df80cca 100644 --- a/src/simlin-engine/src/patch.rs +++ b/src/simlin-engine/src/patch.rs @@ -1223,6 +1223,7 @@ mod tests { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }); let patch = ProjectPatch { project_ops: vec![], diff --git a/src/simlin-engine/src/serde.rs b/src/simlin-engine/src/serde.rs index 676ab085..bf96a9f8 100644 --- a/src/simlin-engine/src/serde.rs +++ b/src/simlin-engine/src/serde.rs @@ -1206,7 +1206,10 @@ fn view_compat_from_proto( Some(view_element::ViewElementCompat { width: c.width.unwrap_or(0.0), height: c.height.unwrap_or(0.0), + shape: 0, bits: c.bits.unwrap_or(0), + name_field: None, + tail: None, }) }) } @@ -1884,6 +1887,7 @@ impl From for View { }, use_lettered_polarity, font, + sketch_compat: None, }) } } @@ -1897,6 +1901,7 @@ fn test_view_roundtrip_preserves_explicit_empty_title() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }); let roundtrip = View::from(project_io::View::from(view)); @@ -1917,6 +1922,7 @@ fn test_view_roundtrip_preserves_absent_title() { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }); let roundtrip = View::from(project_io::View::from(view)); diff --git a/src/simlin-engine/src/stdlib.gen.rs b/src/simlin-engine/src/stdlib.gen.rs index 58f378f1..ccb2fef7 100644 --- a/src/simlin-engine/src/stdlib.gen.rs +++ b/src/simlin-engine/src/stdlib.gen.rs @@ -202,6 +202,7 @@ fn delay1() -> Model { zoom: 1_f64, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -449,6 +450,7 @@ fn delay3() -> Model { zoom: 1_f64, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -608,6 +610,7 @@ fn smth1() -> Model { zoom: 1_f64, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -927,6 +930,7 @@ fn smth3() -> Model { zoom: 1_f64, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -1105,6 +1109,7 @@ fn trend() -> Model { zoom: 1_f64, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], @@ -1202,6 +1207,7 @@ fn npv() -> Model { zoom: 1_f64, use_lettered_polarity: false, font: None, + sketch_compat: None, })], loop_metadata: vec![], groups: vec![], diff --git a/src/simlin-engine/src/xmile/views.rs b/src/simlin-engine/src/xmile/views.rs index a8c315d3..3f82450b 100644 --- a/src/simlin-engine/src/xmile/views.rs +++ b/src/simlin-engine/src/xmile/views.rs @@ -1049,6 +1049,7 @@ pub mod view_element { zoom: 0.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; for expected in cases { let expected = expected.clone(); @@ -1085,6 +1086,7 @@ pub mod view_element { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; let link = datamodel::view_element::Link { @@ -1141,6 +1143,7 @@ pub mod view_element { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; let link = datamodel::view_element::Link { @@ -1191,6 +1194,7 @@ pub mod view_element { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; // Create an XMILE link with angle = 0 (straight horizontal right) @@ -1243,6 +1247,7 @@ pub mod view_element { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; // Create an XMILE link with angle = 45 (curved, not straight) @@ -1305,6 +1310,7 @@ pub mod view_element { zoom: 1.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; // Angle very close to straight (within epsilon) should become Straight @@ -1508,6 +1514,7 @@ pub mod view_element { zoom: 0.0, use_lettered_polarity: false, font: None, + sketch_compat: None, }; for expected in cases { let expected = expected.clone(); @@ -2202,6 +2209,7 @@ impl From for datamodel::View { }, use_lettered_polarity: false, font: None, + sketch_compat: None, }) } else { unreachable!("only stock_flow supported for now -- should be filtered out before here") @@ -2260,6 +2268,7 @@ fn test_view_roundtrip() { zoom: 1.6, use_lettered_polarity: false, font: None, + sketch_compat: None, })]; for expected in cases { let expected = expected.clone(); diff --git a/src/simlin-engine/tests/mdl_roundtrip.rs b/src/simlin-engine/tests/mdl_roundtrip.rs index 2e891ccf..d6743aa0 100644 --- a/src/simlin-engine/tests/mdl_roundtrip.rs +++ b/src/simlin-engine/tests/mdl_roundtrip.rs @@ -2,7 +2,7 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::BufReader; @@ -505,6 +505,50 @@ fn view_element_roundtrip() { } }; + let source_views = split_sketch_into_views(&source); + let output_views = split_sketch_into_views(&mdl_text); + if source_views.len() != output_views.len() { + failures.push(format!( + "{path}: raw sketch view count differs: {} vs {}", + source_views.len(), + output_views.len() + )); + } else { + for (j, (source_view, output_view)) in + source_views.iter().zip(&output_views).enumerate() + { + if source_view.name != output_view.name { + failures.push(format!( + "{path}: raw sketch view[{j}] name differs: {:?} vs {:?}", + source_view.name, output_view.name + )); + } + + if source_view.font_line != output_view.font_line { + failures.push(format!( + "{path}: raw sketch view[{j}] font differs: {:?} vs {:?}", + source_view.font_line, output_view.font_line + )); + } + + let expected_named: Vec<_> = source_view + .element_lines + .iter() + .filter_map(|line| normalize_named_sketch_line(line)) + .collect(); + let actual_named: Vec<_> = output_view + .element_lines + .iter() + .filter_map(|line| normalize_named_sketch_line(line)) + .collect(); + if let Some(diff) = diff_multiset(&expected_named, &actual_named) { + failures.push(format!( + "{path}: raw sketch view[{j}] named lines differ: {diff}" + )); + } + } + } + for (i, (m1, m2)) in project1.models.iter().zip(&project2.models).enumerate() { if m1.views.len() != m2.views.len() { failures.push(format!( @@ -706,8 +750,14 @@ fn mark2_sketch_ordering() { // --------------------------------------------------------------------------- /// Split the sketch section of an MDL string into per-view segments. -/// Returns a vec of (view_name, element_lines, font_line) tuples. -fn split_sketch_into_views(mdl_text: &str) -> Vec<(&str, Vec<&str>, Option<&str>)> { +#[derive(Debug, Clone)] +struct SketchView { + name: String, + element_lines: Vec, + font_line: Option, +} + +fn split_sketch_into_views(mdl_text: &str) -> Vec { let sketch_marker = "\\\\\\---/// Sketch information"; let mut views = Vec::new(); @@ -736,34 +786,42 @@ fn split_sketch_into_views(mdl_text: &str) -> Vec<(&str, Vec<&str>, Option<&str> // Parse: first line is the marker, second is V300, third is *ViewName, // fourth is $font, rest are element lines. - let mut view_name = ""; + let mut view_name = String::new(); let mut font_line = None; let mut element_lines = Vec::new(); for line in &lines { if let Some(name) = line.strip_prefix('*') { - view_name = name; + view_name = name.to_owned(); } else if let Some(font) = line.strip_prefix('$') { - font_line = Some(font); + font_line = Some(font.to_owned()); } else if line.starts_with("10,") || line.starts_with("11,") || line.starts_with("12,") || line.starts_with("1,") { - element_lines.push(*line); + element_lines.push((*line).to_owned()); } } - views.push((view_name, element_lines, font_line)); + views.push(SketchView { + name: view_name, + element_lines, + font_line, + }); } views } +fn line_fields(line: &str) -> Vec<&str> { + line.split(',').collect() +} + /// Extract the variable name from a type-10 sketch element line. /// Type-10 lines have format: 10,uid,name,x,y,... fn extract_element_name(line: &str) -> Option<&str> { - let fields: Vec<&str> = line.split(',').collect(); + let fields = line_fields(line); if fields.len() > 2 && fields[0] == "10" { Some(fields[2]) } else { @@ -771,24 +829,326 @@ fn extract_element_name(line: &str) -> Option<&str> { } } -/// Count sketch elements by type within a set of element lines. -fn count_sketch_element_types(lines: &[&str]) -> (usize, usize, usize, usize) { - let mut connectors = 0; - let mut labels = 0; - let mut valves = 0; - let mut clouds = 0; +fn is_time_shadow_line(line: &str) -> bool { + extract_element_name(line) == Some("Time") +} + +fn is_flow_label_line(line: &str) -> bool { + let fields = line_fields(line); + fields.len() > 7 && fields[0] == "10" && fields[7] == "40" +} + +fn is_pipe_connector(line: &str) -> bool { + let fields = line_fields(line); + fields.len() > 7 && fields[0] == "1" && fields[7] == "22" +} + +fn is_influence_connector(line: &str) -> bool { + let fields = line_fields(line); + fields.len() > 7 && fields[0] == "1" && fields[7] != "22" +} + +fn normalize_named_sketch_line(line: &str) -> Option { + if is_time_shadow_line(line) { + return None; + } + let fields = line_fields(line); + if fields.len() > 3 && fields[0] == "10" { + Some(format!("10,{},{}", fields[2], fields[3..].join(","))) + } else { + None + } +} + +fn normalize_valve_line(line: &str) -> String { + let fields = line_fields(line); + format!("11,{}", fields[2..].join(",")) +} + +fn normalize_cloud_line(line: &str) -> String { + let fields = line_fields(line); + format!("12,{}", fields[2..].join(",")) +} + +fn uid_field(line: &str) -> Option<&str> { + line_fields(line).get(1).copied() +} + +fn multiset(items: &[String]) -> HashMap { + let mut counts = HashMap::new(); + for item in items { + *counts.entry(item.clone()).or_insert(0) += 1; + } + counts +} + +fn multiset_delta(lhs: &HashMap, rhs: &HashMap) -> Vec { + let mut delta = Vec::new(); + let mut items: Vec<_> = lhs.iter().collect(); + items.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (item, lhs_count) in items { + let rhs_count = rhs.get(item).copied().unwrap_or(0); + for _ in 0..lhs_count.saturating_sub(rhs_count) { + delta.push(item.clone()); + } + } + delta +} + +fn preview_items(items: &[String]) -> String { + const LIMIT: usize = 4; + if items.is_empty() { + return "[]".to_owned(); + } + let shown = items + .iter() + .take(LIMIT) + .cloned() + .collect::>() + .join(" | "); + if items.len() > LIMIT { + format!("[{shown} | ... +{} more]", items.len() - LIMIT) + } else { + format!("[{shown}]") + } +} + +fn diff_multiset(expected: &[String], actual: &[String]) -> Option { + let expected_counts = multiset(expected); + let actual_counts = multiset(actual); + let missing = multiset_delta(&expected_counts, &actual_counts); + let extra = multiset_delta(&actual_counts, &expected_counts); + if missing.is_empty() && extra.is_empty() { + None + } else { + Some(format!( + "missing={} extra={}", + preview_items(&missing), + preview_items(&extra) + )) + } +} + +#[derive(Debug, Clone)] +struct FlowBlock { + name: String, + valve_uid: String, + valve_line: String, + label_line: String, + cloud_lines: Vec, + pipe_lines: Vec, +} + +fn parse_flow_blocks(view: &SketchView) -> Result, String> { + let mut blocks = HashMap::new(); + for (idx, line) in view.element_lines.iter().enumerate() { + if !line.starts_with("11,") { + continue; + } + + let Some(label_line) = view.element_lines.get(idx + 1) else { + return Err(format!( + "view {:?}: valve at line {} is missing its flow label", + view.name, idx + )); + }; + if !is_flow_label_line(label_line) { + return Err(format!( + "view {:?}: valve at line {} is followed by a non-flow label line: {}", + view.name, idx, label_line + )); + } + + let mut start = idx; + while start > 0 { + let prev = &view.element_lines[start - 1]; + if prev.starts_with("12,") || is_pipe_connector(prev) { + start -= 1; + } else { + break; + } + } + + let name = extract_element_name(label_line) + .ok_or_else(|| { + format!( + "view {:?}: missing flow name for block at {}", + view.name, idx + ) + })? + .to_owned(); + let valve_uid = uid_field(line) + .ok_or_else(|| { + format!( + "view {:?}: missing valve uid for flow {:?}", + view.name, name + ) + })? + .to_owned(); + + let mut cloud_lines = Vec::new(); + let mut pipe_lines = Vec::new(); + for block_line in &view.element_lines[start..idx] { + if block_line.starts_with("12,") { + cloud_lines.push(block_line.clone()); + } else if is_pipe_connector(block_line) { + pipe_lines.push(block_line.clone()); + } else { + return Err(format!( + "view {:?}: unexpected line inside flow block {:?}: {}", + view.name, name, block_line + )); + } + } + + let block = FlowBlock { + name: name.clone(), + valve_uid, + valve_line: line.clone(), + label_line: label_line.clone(), + cloud_lines, + pipe_lines, + }; + if blocks.insert(name.clone(), block).is_some() { + return Err(format!( + "view {:?}: duplicate flow block for {:?}", + view.name, name + )); + } + } + Ok(blocks) +} + +fn build_named_uid_map(lines: &[String]) -> HashMap { + let mut names = HashMap::new(); for line in lines { - if line.starts_with("1,") { - connectors += 1; - } else if line.starts_with("10,") { - labels += 1; - } else if line.starts_with("11,") { - valves += 1; - } else if line.starts_with("12,") { - clouds += 1; + if let (Some(uid), Some(name)) = (uid_field(line), extract_element_name(line)) { + names.insert(uid.to_owned(), name.to_owned()); } } - (connectors, labels, valves, clouds) + names +} + +fn normalize_pipe_line( + line: &str, + named_uids: &HashMap, + cloud_uids: &HashMap, +) -> String { + let fields = line_fields(line); + let endpoint_uid = fields[3]; + let endpoint = named_uids + .get(endpoint_uid) + .cloned() + .or_else(|| cloud_uids.get(endpoint_uid).cloned()) + .unwrap_or_else(|| format!("uid:{endpoint_uid}")); + format!("1,to={endpoint},{}", fields[4..].join(",")) +} + +fn normalize_flow_blocks(view: &SketchView) -> Result>, String> { + let named_uids = build_named_uid_map(&view.element_lines); + let flow_blocks = parse_flow_blocks(view)?; + let mut normalized = HashMap::new(); + + for (name, block) in flow_blocks { + let mut cloud_uids = HashMap::new(); + for (idx, cloud_line) in block.cloud_lines.iter().enumerate() { + let cloud_uid = uid_field(cloud_line).ok_or_else(|| { + format!( + "view {:?}: cloud line in flow {:?} is missing a uid: {}", + view.name, block.name, cloud_line + ) + })?; + let label = if block.cloud_lines.len() == 1 { + format!("cloud:{name}") + } else { + format!("cloud:{name}:{}", idx + 1) + }; + cloud_uids.insert(cloud_uid.to_owned(), label); + } + + let mut records = vec![ + format!( + "label:{}", + normalize_named_sketch_line(&block.label_line).ok_or_else(|| { + format!( + "view {:?}: flow label for {:?} did not normalize: {}", + view.name, block.name, block.label_line + ) + })? + ), + format!("valve:{}", normalize_valve_line(&block.valve_line)), + ]; + + let mut clouds: Vec<_> = block + .cloud_lines + .iter() + .map(|line| format!("cloud:{}", normalize_cloud_line(line))) + .collect(); + clouds.sort(); + records.extend(clouds); + + let mut pipes: Vec<_> = block + .pipe_lines + .iter() + .map(|line| { + format!( + "pipe:{}", + normalize_pipe_line(line, &named_uids, &cloud_uids) + ) + }) + .collect(); + pipes.sort(); + records.extend(pipes); + + records.sort(); + normalized.insert(name, records); + } + + Ok(normalized) +} + +fn normalize_influence_connectors(view: &SketchView) -> Result, String> { + let mut uid_labels = build_named_uid_map(&view.element_lines); + for (flow_name, block) in parse_flow_blocks(view)? { + uid_labels.insert(block.valve_uid.clone(), format!("valve:{flow_name}")); + for (idx, cloud_line) in block.cloud_lines.iter().enumerate() { + let cloud_uid = uid_field(cloud_line).ok_or_else(|| { + format!( + "view {:?}: cloud line in flow {:?} is missing a uid: {}", + view.name, flow_name, cloud_line + ) + })?; + let label = if block.cloud_lines.len() == 1 { + format!("cloud:{flow_name}") + } else { + format!("cloud:{flow_name}:{}", idx + 1) + }; + uid_labels.insert(cloud_uid.to_owned(), label); + } + } + + let mut connectors = Vec::new(); + for line in view + .element_lines + .iter() + .filter(|line| is_influence_connector(line)) + { + let fields = line_fields(line); + let from = uid_labels + .get(fields[2]) + .cloned() + .unwrap_or_else(|| format!("uid:{}", fields[2])); + let to = uid_labels + .get(fields[3]) + .cloned() + .unwrap_or_else(|| format!("uid:{}", fields[3])); + if from == "Time" || to == "Time" { + continue; + } + connectors.push(format!("1,{from}->{to},{}", fields[4..].join(","))); + } + connectors.sort(); + Ok(connectors) } /// Verify mark2.mdl format roundtrip: parse, write, and compare the @@ -796,8 +1156,10 @@ fn count_sketch_element_types(lines: &[&str]) -> (usize, usize, usize, usize) { /// /// Checks: /// - AC1.1: Exactly 2 views with correct names -/// - AC1.2: Per-view element lines match as unordered sets -/// - AC1.4: Font specification preserved per view +/// - AC1.2: Raw named sketch lines match as unordered multisets +/// - AC1.3: Flow blocks preserve raw valve/cloud/pipe records +/// - AC1.4: Influence connectors preserve resolved endpoint references +/// - AC1.5: Font specification preserved per view /// - AC3.1: Lookup calls use `table ( input )` syntax /// - AC3.2: Lookup range bounds preserved /// - AC4.1: Short equations use inline format @@ -826,228 +1188,131 @@ fn mdl_format_roundtrip() { output_views.len() )); } else { - // View names should contain the numbered prefix from the original let expected_names = ["1 housing", "2 investments"]; for (i, expected) in expected_names.iter().enumerate() { - if !output_views[i].0.contains(expected) { + if output_views[i].name != *expected { failures.push(format!( - "AC1.1: view[{i}] name {:?} does not contain {:?}", - output_views[i].0, expected + "AC1.1: view[{i}] name {:?} != {:?}", + output_views[i].name, expected )); } } } // ----------------------------------------------------------------------- - // AC1.2: Per-view elements match as unordered sets - // - // UIDs are renumbered and coordinates may shift during roundtrip, so - // we compare named elements (type 10) by name and non-named elements - // (connectors, valves, clouds) by count. + // AC1.2: Raw named sketch lines match as unordered multisets // ----------------------------------------------------------------------- if orig_views.len() == output_views.len() { for (i, (orig, out)) in orig_views.iter().zip(&output_views).enumerate() { - // Compare named elements (type 10) by variable name - let orig_names: HashSet<&str> = orig - .1 + let expected_named: Vec<_> = orig + .element_lines .iter() - .filter_map(|l| extract_element_name(l)) + .filter_map(|line| normalize_named_sketch_line(line)) .collect(); - let out_names: HashSet<&str> = out - .1 + let actual_named: Vec<_> = out + .element_lines .iter() - .filter_map(|l| extract_element_name(l)) + .filter_map(|line| normalize_named_sketch_line(line)) .collect(); - - let missing_names: Vec<_> = orig_names.difference(&out_names).collect(); - let extra_names: Vec<_> = out_names.difference(&orig_names).collect(); - - // Shadow references to the built-in "Time" variable are not - // preserved during roundtrip (Time is not a model variable). - let missing_non_time: Vec<_> = - missing_names.iter().filter(|n| **n != &"Time").collect(); - if !missing_non_time.is_empty() { + if let Some(diff) = diff_multiset(&expected_named, &actual_named) { failures.push(format!( - "AC1.2: view[{i}] ({:?}) missing named element(s): {:?}", - orig.0, missing_non_time - )); - } - if !extra_names.is_empty() { - failures.push(format!( - "AC1.2: view[{i}] ({:?}) has extra named element(s): {:?}", - out.0, extra_names - )); - } - - // Compare element type counts - let (orig_conn, orig_lbl, orig_valve, orig_cloud) = count_sketch_element_types(&orig.1); - let (out_conn, out_lbl, out_valve, out_cloud) = count_sketch_element_types(&out.1); - - // Label count may differ by the number of Time shadow elements - let time_shadow_count = orig - .1 - .iter() - .filter(|l| l.starts_with("10,") && extract_element_name(l) == Some("Time")) - .count(); - if orig_lbl - time_shadow_count != out_lbl { - failures.push(format!( - "AC1.2: view[{i}] label count: orig={orig_lbl} (minus {time_shadow_count} Time shadows) \ - vs out={out_lbl}" - )); - } - if orig_valve != out_valve { - failures.push(format!( - "AC1.2: view[{i}] valve count: orig={orig_valve} out={out_valve}" - )); - } - if orig_cloud != out_cloud { - failures.push(format!( - "AC1.2: view[{i}] cloud count: orig={orig_cloud} out={out_cloud}" - )); - } - // Connector counts may differ for documented reasons: - // 1. Shadow references to the built-in Time variable are dropped - // (Time is not a model variable), along with their connectors. - // 2. Init-only links (field 10 = 1, dashed arrows in Vensim) may - // not survive the roundtrip. - // Count the expected dropped connectors from both sources. - let time_uids: HashSet<&str> = orig - .1 - .iter() - .filter(|l| l.starts_with("10,") && extract_element_name(l) == Some("Time")) - .filter_map(|l| l.split(',').nth(1)) - .collect(); - let dropped_time_connectors = orig - .1 - .iter() - .filter(|l| { - if !l.starts_with("1,") { - return false; - } - let fields: Vec<&str> = l.split(',').collect(); - fields.len() > 3 - && (time_uids.contains(fields[2]) || time_uids.contains(fields[3])) - }) - .count(); - let init_only_connectors = orig - .1 - .iter() - .filter(|l| { - if !l.starts_with("1,") { - return false; - } - let fields: Vec<&str> = l.split(',').collect(); - fields.len() > 10 && fields[10] == "1" - }) - .count(); - let expected_dropped = dropped_time_connectors + init_only_connectors; - let conn_diff = (orig_conn as i32 - out_conn as i32).abs(); - if conn_diff > expected_dropped as i32 { - failures.push(format!( - "AC1.2: view[{i}] connector count: orig={orig_conn} out={out_conn} \ - (diff={conn_diff} exceeds expected_dropped={expected_dropped}: \ - time={dropped_time_connectors}, init_only={init_only_connectors})" + "AC1.2: view[{i}] ({:?}) raw named sketch lines differ: {diff}", + orig.name )); } } } // ----------------------------------------------------------------------- - // AC1.3: Per-element field-level fidelity - // - // For each type-10 (named) element matched by name between original - // and output, compare dimension and shape fields. Skip uid (field 1), - // coordinates (fields 3,4), and fields that depend on display state - // we don't yet preserve (field 9 = init-link flag, field 11 = varies - // by element type, fields 14+ = ghost color/font). + // AC1.3: Flow blocks preserve raw valve/cloud/pipe records // ----------------------------------------------------------------------- if orig_views.len() == output_views.len() { for (i, (orig, out)) in orig_views.iter().zip(&output_views).enumerate() { - fn build_name_fields<'a>( - lines: &[&'a str], - ) -> std::collections::HashMap<&'a str, Vec<&'a str>> { - let mut map = std::collections::HashMap::new(); - for line in lines { - let fields: Vec<&str> = line.split(',').collect(); - if fields.len() > 2 && fields[0] == "10" { - map.insert(fields[2], fields); + match (normalize_flow_blocks(orig), normalize_flow_blocks(out)) { + (Ok(expected_blocks), Ok(actual_blocks)) => { + let expected_names: HashSet<_> = expected_blocks.keys().cloned().collect(); + let actual_names: HashSet<_> = actual_blocks.keys().cloned().collect(); + + for missing_name in expected_names.difference(&actual_names) { + failures.push(format!( + "AC1.3: view[{i}] ({:?}) missing flow block {:?}", + orig.name, missing_name + )); } - } - map - } - - let orig_fields = build_name_fields(&orig.1); - let out_fields = build_name_fields(&out.1); - - // Fields to compare: w(5), h(6), bits(8). - // Shape (field 7) is excluded because Vensim allows displaying - // any variable type with any shape (e.g. an aux as a stock box). - // Our converter classifies variable type from the equation, not - // the sketch shape, so non-stock variables displayed as boxes - // (shape=3) will roundtrip as shape=8. - let compare_indices = [5, 6, 8]; - - // Elements that appear in multiple views are converted to - // aliases during view composition. Their shape changes from - // stock(3) to aux(8) and is not preserved. Collect names that - // appear in OTHER views so we can exclude them from shape checks. - let mut cross_view_names: HashSet<&str> = HashSet::new(); - for (j, other) in output_views.iter().enumerate() { - if j != i { - for line in &other.1 { - if let Some(n) = extract_element_name(line) { - cross_view_names.insert(n); - } + for extra_name in actual_names.difference(&expected_names) { + failures.push(format!( + "AC1.3: view[{i}] ({:?}) has extra flow block {:?}", + out.name, extra_name + )); } - } - } - for (name, orig_f) in &orig_fields { - if *name == "Time" { - continue; - } - let is_cross_view = cross_view_names.contains(name); - if let Some(out_f) = out_fields.get(name) { - for &idx in &compare_indices { - // Skip shape comparison for cross-view duplicates - // (they become aliases with shape=8). - if idx == 7 && is_cross_view { - continue; - } - if idx < orig_f.len() && idx < out_f.len() && orig_f[idx] != out_f[idx] { + for flow_name in expected_names.intersection(&actual_names) { + let expected = expected_blocks + .get(flow_name) + .expect("expected flow block by name"); + let actual = actual_blocks + .get(flow_name) + .expect("actual flow block by name"); + if let Some(diff) = diff_multiset(expected, actual) { failures.push(format!( - "AC1.3: view[{i}] element {:?} field[{idx}] \ - orig={:?} out={:?}", - name, orig_f[idx], out_f[idx] + "AC1.3: view[{i}] ({:?}) flow block {:?} differs: {diff}", + orig.name, flow_name )); } } } + (Err(e), _) => failures.push(format!("AC1.3: source flow block parse failed: {e}")), + (_, Err(e)) => failures.push(format!("AC1.3: output flow block parse failed: {e}")), + } + } + } + + // ----------------------------------------------------------------------- + // AC1.4: Influence connectors preserve resolved endpoint references + // ----------------------------------------------------------------------- + for (i, (orig, out)) in orig_views.iter().zip(&output_views).enumerate() { + match ( + normalize_influence_connectors(orig), + normalize_influence_connectors(out), + ) { + (Ok(expected), Ok(actual)) => { + if let Some(diff) = diff_multiset(&expected, &actual) { + failures.push(format!( + "AC1.4: view[{i}] ({:?}) influence connectors differ: {diff}", + orig.name + )); + } + } + (Err(e), _) => { + failures.push(format!("AC1.4: source connector normalization failed: {e}")) + } + (_, Err(e)) => { + failures.push(format!("AC1.4: output connector normalization failed: {e}")) } } } // ----------------------------------------------------------------------- - // AC1.4: Font specification preserved per view + // AC1.5: Font specification preserved per view // ----------------------------------------------------------------------- for (i, (orig, out)) in orig_views.iter().zip(&output_views).enumerate() { - match (orig.2, out.2) { + match (&orig.font_line, &out.font_line) { (Some(orig_font), Some(out_font)) => { if !out_font.contains("Verdana|10") { failures.push(format!( - "AC1.4: view[{i}] font does not contain 'Verdana|10': {:?}", + "AC1.5: view[{i}] font does not contain 'Verdana|10': {:?}", out_font )); } if orig_font != out_font { failures.push(format!( - "AC1.4: view[{i}] font differs: orig={:?} out={:?}", + "AC1.5: view[{i}] font differs: orig={:?} out={:?}", orig_font, out_font )); } } (Some(_), None) => { - failures.push(format!("AC1.4: view[{i}] missing font line in output")); + failures.push(format!("AC1.5: view[{i}] missing font line in output")); } _ => {} } From 3e05c800e3642edcaf66c9cd0eca2e2d08a031d2 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Thu, 19 Mar 2026 12:39:47 -0700 Subject: [PATCH 30/31] engine: generalize mdl view serialization Derive MDL flow and connector sketch geometry from normalized datamodel views instead of replaying imported raw sketch compat. This keeps export usable for XMILE/default-project and from-scratch model creation paths while retaining compat only for MDL-specific presentation details such as dimensions, font data, and view offsets. Update the roundtrip oracle to compare the view invariants the datamodel actually represents, and add coverage for from-scratch flow links plus fishbanks XMILE export so the tests exercise the general writer path rather than MDL-only replay. --- src/simlin-engine/src/mdl/writer.rs | 149 ++++++++------- src/simlin-engine/src/mdl/writer_tests.rs | 210 ++++++++++++++++++---- src/simlin-engine/tests/mdl_roundtrip.rs | 96 +++++++++- 3 files changed, 350 insertions(+), 105 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index bdd602c1..e18649f7 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -114,8 +114,9 @@ fn build_display_name_map(views: &[View]) -> HashMap { ViewElement::Flow(f) => &f.name, _ => continue, }; - let canonical = crate::common::canonicalize(name).into_owned(); - let display = underbar_to_space(name); + let normalized_name = name.replace("\\n", " ").replace('\n', " "); + let canonical = crate::common::canonicalize(&normalized_name).into_owned(); + let display = underbar_to_space(&normalized_name); map.entry(canonical).or_insert(display); } } @@ -127,10 +128,11 @@ fn build_display_name_map(views: &[View]) -> HashMap { fn display_name_for_ident(ident: &str, display_names: &HashMap) -> String { match display_names.get(ident) { Some(name) => { - if needs_mdl_quoting(name) { - format!("\"{}\"", escape_mdl_quoted_ident(name)) + let name = name.replace("\\n", " ").replace('\n', " "); + if needs_mdl_quoting(&name) { + format!("\"{}\"", escape_mdl_quoted_ident(&name)) } else { - name.clone() + name } } None => format_mdl_ident(ident), @@ -1416,6 +1418,10 @@ fn format_sketch_name(name: &str) -> String { name.replace('_', " ").replace('\n', "\\n") } +const STOCK_WIDTH: f64 = 45.0; +const STOCK_HEIGHT: f64 = 35.0; +const STOCK_EDGE_TOLERANCE: f64 = 1.0; + /// Write a type 10 line for an Aux element. /// Sketch element names use `format_sketch_name` (not `format_mdl_ident`) /// because MDL sketch lines are comma-delimited positional records where @@ -1582,6 +1588,17 @@ fn compat_name_field<'a>( .unwrap_or(default) } +fn default_flow_label_point(flow: &view_element::Flow, transform: SketchTransform) -> (i32, i32) { + let (x, y) = match flow.label_side { + view_element::LabelSide::Top => (flow.x, flow.y - 16.0), + view_element::LabelSide::Left => (flow.x - 16.0, flow.y), + view_element::LabelSide::Center => (flow.x, flow.y), + view_element::LabelSide::Bottom => (flow.x, flow.y + 16.0), + view_element::LabelSide::Right => (flow.x + 16.0, flow.y), + }; + transform.point(x, y) +} + /// Write a Flow element as type 1 pipe connectors, type 11 (valve), and /// type 10 (attached flow variable). /// @@ -1603,10 +1620,12 @@ fn write_flow_element( cloud_uids, next_connector_uid, SketchTransform::identity(), - None, + &HashMap::new(), + &HashSet::new(), ); } +#[allow(clippy::too_many_arguments)] fn write_flow_element_with_context( buf: &mut String, flow: &view_element::Flow, @@ -1614,7 +1633,8 @@ fn write_flow_element_with_context( _cloud_uids: &HashSet, next_connector_uid: &mut i32, transform: SketchTransform, - flow_compat: Option<&view_element::FlowSketchCompat>, + elem_positions: &HashMap, + stock_uids: &HashSet, ) { let name = format_sketch_name(&flow.name); let valve_uid = valve_uids.get(&flow.uid).copied().unwrap_or(flow.uid - 1); @@ -1629,7 +1649,8 @@ fn write_flow_element_with_context( valve_uid, next_connector_uid, transform, - flow_compat, + elem_positions, + stock_uids, ); let (valve_w, valve_h, valve_shape, valve_bits) = match valve_compat { @@ -1661,13 +1682,7 @@ fn write_flow_element_with_context( ) .unwrap(); - let (label_x, label_y) = if let Some(compat) = flow_compat { - let dx = flow.x - compat.valve_x; - let dy = flow.y - compat.valve_y; - transform.point(compat.label_x + dx, compat.label_y + dy) - } else { - transform.point(flow.x, flow.y + 16.0) - }; + let (label_x, label_y) = default_flow_label_point(flow, transform); let label_tail = compat_tail(label_compat, "0,0,-1,0,0,0"); write!( buf, @@ -1693,7 +1708,8 @@ fn write_flow_pipe_connectors( valve_uid, next_connector_uid, SketchTransform::identity(), - None, + &HashMap::new(), + &HashSet::new(), ) } @@ -1703,7 +1719,8 @@ fn write_flow_pipe_connectors_with_context( valve_uid: i32, next_connector_uid: &mut i32, transform: SketchTransform, - flow_compat: Option<&view_element::FlowSketchCompat>, + elem_positions: &HashMap, + stock_uids: &HashSet, ) -> bool { let mut wrote_any = false; @@ -1728,27 +1745,52 @@ fn write_flow_pipe_connectors_with_context( .unwrap(); }; - let connector_point = |idx: usize, point: &view_element::FlowPoint| -> (i32, i32) { - if let Some(compat) = flow_compat.and_then(|compat| compat.pipe_points.get(idx)) { - let dx = point.x - compat.point_x; - let dy = point.y - compat.point_y; - return transform.point(compat.connector_x + dx, compat.connector_y + dy); + let connector_point = |point: &view_element::FlowPoint| -> (i32, i32) { + let point_xy = transform.point(point.x, point.y); + let Some(endpoint_uid) = point.attached_to_uid else { + return point_xy; + }; + if !stock_uids.contains(&endpoint_uid) { + return point_xy; } - transform.point(point.x, point.y) + + let Some(&(stock_x, stock_y)) = elem_positions.get(&endpoint_uid) else { + return point_xy; + }; + let dx = f64::from(point_xy.0 - stock_x); + let dy = f64::from(point_xy.1 - stock_y); + let on_left_or_right = (dx.abs() - STOCK_WIDTH / 2.0).abs() <= STOCK_EDGE_TOLERANCE + && dy.abs() <= STOCK_HEIGHT / 2.0 + STOCK_EDGE_TOLERANCE; + if on_left_or_right { + return (stock_x, point_xy.1); + } + + let on_top_or_bottom = (dy.abs() - STOCK_HEIGHT / 2.0).abs() <= STOCK_EDGE_TOLERANCE + && dx.abs() <= STOCK_WIDTH / 2.0 + STOCK_EDGE_TOLERANCE; + if on_top_or_bottom { + return (point_xy.0, stock_y); + } + + point_xy }; if flow.points.len() > 1 && let Some(last) = flow.points.last() && let Some(endpoint_uid) = last.attached_to_uid { - let (x, y) = connector_point(flow.points.len() - 1, last); + let (x, y) = connector_point(last); + let direction = if stock_uids.contains(&endpoint_uid) { + 4 + } else { + 100 + }; write_pipe( buf, !wrote_any, *next_connector_uid, valve_uid, endpoint_uid, - 4, + direction, x, y, ); @@ -1756,14 +1798,13 @@ fn write_flow_pipe_connectors_with_context( *next_connector_uid += 1; } - for (idx, point) in flow + for point in flow .points .iter() - .enumerate() .skip(1) .take(flow.points.len().saturating_sub(2)) { - let (x, y) = connector_point(idx, point); + let (x, y) = connector_point(point); write_pipe( buf, !wrote_any, @@ -1781,14 +1822,19 @@ fn write_flow_pipe_connectors_with_context( if let Some(first) = flow.points.first() && let Some(endpoint_uid) = first.attached_to_uid { - let (x, y) = connector_point(0, first); + let (x, y) = connector_point(first); + let direction = if stock_uids.contains(&endpoint_uid) { + 4 + } else { + 100 + }; write_pipe( buf, !wrote_any, *next_connector_uid, valve_uid, endpoint_uid, - 100, + direction, x, y, ); @@ -1904,7 +1950,7 @@ fn write_link_element_with_context( use_lettered_polarity: bool, link_compat: Option<&view_element::LinkSketchCompat>, transform: SketchTransform, - valve_uids: &HashMap, + _valve_uids: &HashMap, ) { let polarity_val = match link.polarity { Some(LinkPolarity::Positive) if use_lettered_polarity => 83, // 'S' @@ -1914,14 +1960,8 @@ fn write_link_element_with_context( None => 0, }; - let from_uid = link_compat - .filter(|compat| compat.from_attached_valve) - .and_then(|_| valve_uids.get(&link.from_uid).copied()) - .unwrap_or(link.from_uid); - let to_uid = link_compat - .filter(|compat| compat.to_attached_valve) - .and_then(|_| valve_uids.get(&link.to_uid).copied()) - .unwrap_or(link.to_uid); + let from_uid = link.from_uid; + let to_uid = link.to_uid; let from_pos = elem_positions.get(&from_uid).copied().unwrap_or((0, 0)); let to_pos = elem_positions.get(&to_uid).copied().unwrap_or((0, 0)); let field4 = link_compat.map(|compat| compat.field4).unwrap_or(0); @@ -1938,18 +1978,7 @@ fn write_link_element_with_context( .unwrap(); } LinkShape::Arc(canvas_angle) => { - let (ctrl_x, ctrl_y) = if let Some(compat) = link_compat { - let delta_x = - ((from_pos.0 as f64 - compat.from_x) + (to_pos.0 as f64 - compat.to_x)) / 2.0; - let delta_y = - ((from_pos.1 as f64 - compat.from_y) + (to_pos.1 as f64 - compat.to_y)) / 2.0; - ( - (compat.control_x + delta_x).round() as i32, - (compat.control_y + delta_y).round() as i32, - ) - } else { - compute_control_point(from_pos, to_pos, *canvas_angle) - }; + let (ctrl_x, ctrl_y) = compute_control_point(from_pos, to_pos, *canvas_angle); write!( buf, "1,{},{},{},{},0,{},0,0,64,{},-1--1--1,,1|({},{})|", @@ -2379,7 +2408,7 @@ impl MdlWriter { elem_positions: &HashMap, name_map: &HashMap, stock_uids: &HashSet, - flow_compat_by_uid: &HashMap, + _flow_compat_by_uid: &HashMap, link_compat_by_uid: &HashMap, ) { let view_title = sanitize_view_title_for_mdl(view_name); @@ -2430,7 +2459,8 @@ impl MdlWriter { &cloud_uids, next_connector_uid, transform, - flow_compat_by_uid.get(&flow.uid).copied(), + elem_positions, + stock_uids, ); self.buf.push('\n'); } @@ -2561,7 +2591,7 @@ fn build_element_positions_with_transform( valve_uids: &HashMap, transform: SketchTransform, stock_uids: &HashSet, - flow_compat_by_uid: &HashMap, + _flow_compat_by_uid: &HashMap, ) -> HashMap { let mut positions = HashMap::new(); for elem in elements { @@ -2581,15 +2611,8 @@ fn build_element_positions_with_transform( if let Some(&valve_uid) = valve_uids.get(&f.uid) { positions.insert(valve_uid, (valve_x, valve_y)); } - if let Some(compat) = flow_compat_by_uid.get(&f.uid) { - let dx = f.x - compat.valve_x; - let dy = f.y - compat.valve_y; - let (label_x, label_y) = - transform.point(compat.label_x + dx, compat.label_y + dy); - (f.uid, label_x, label_y) - } else { - (f.uid, valve_x, valve_y) - } + let (label_x, label_y) = default_flow_label_point(f, transform); + (f.uid, label_x, label_y) } ViewElement::Cloud(c) => { let (x, y) = transform.point(c.x, c.y); diff --git a/src/simlin-engine/src/mdl/writer_tests.rs b/src/simlin-engine/src/mdl/writer_tests.rs index 03136770..4e8fe781 100644 --- a/src/simlin-engine/src/mdl/writer_tests.rs +++ b/src/simlin-engine/src/mdl/writer_tests.rs @@ -1660,7 +1660,7 @@ fn sketch_flow_element_emits_pipe_connectors_from_flow_points() { } #[test] -fn sketch_flow_element_with_compat_preserves_pipe_points_and_label_position() { +fn sketch_flow_element_derives_stock_connector_points_from_takeoffs() { let flow = view_element::Flow { name: "Infection_Rate".to_string(), uid: 6, @@ -1669,12 +1669,12 @@ fn sketch_flow_element_with_compat_preserves_pipe_points_and_label_position() { label_side: view_element::LabelSide::Bottom, points: vec![ view_element::FlowPoint { - x: 120.0, + x: 122.5, y: 100.0, attached_to_uid: Some(1), }, view_element::FlowPoint { - x: 180.0, + x: 177.5, y: 100.0, attached_to_uid: Some(2), }, @@ -1682,29 +1682,10 @@ fn sketch_flow_element_with_compat_preserves_pipe_points_and_label_position() { compat: None, label_compat: None, }; - let flow_compat = view_element::FlowSketchCompat { - uid: 6, - valve_x: 150.0, - valve_y: 100.0, - label_x: 170.0, - label_y: 134.0, - pipe_points: vec![ - view_element::FlowSketchPointCompat { - connector_x: 90.0, - connector_y: 96.0, - point_x: 120.0, - point_y: 100.0, - }, - view_element::FlowSketchPointCompat { - connector_x: 210.0, - connector_y: 104.0, - point_x: 180.0, - point_y: 100.0, - }, - ], - }; let mut buf = String::new(); let valve_uids = HashMap::from([(6, 100)]); + let elem_positions = HashMap::from([(1, (100, 100)), (2, (200, 100))]); + let stock_uids = HashSet::from([1, 2]); let mut next_connector_uid = 200; write_flow_element_with_context( &mut buf, @@ -1713,20 +1694,21 @@ fn sketch_flow_element_with_compat_preserves_pipe_points_and_label_position() { &HashSet::new(), &mut next_connector_uid, SketchTransform::identity(), - Some(&flow_compat), + &elem_positions, + &stock_uids, ); assert!( - buf.contains("1,200,100,2,4,0,0,22,0,0,0,-1--1--1,,1|(210,104)|"), - "sink pipe connector should use preserved raw control point: {buf}" + buf.contains("1,200,100,2,4,0,0,22,0,0,0,-1--1--1,,1|(200,100)|"), + "sink pipe connector should be reconstructed from the stock center: {buf}" ); assert!( - buf.contains("1,201,100,1,100,0,0,22,0,0,0,-1--1--1,,1|(90,96)|"), - "source pipe connector should use preserved raw control point: {buf}" + buf.contains("1,201,100,1,4,0,0,22,0,0,0,-1--1--1,,1|(100,100)|"), + "source pipe connector should be reconstructed from the stock center: {buf}" ); assert!( - buf.contains("10,6,Infection Rate,170,134,49,8,40,3,0,0,-1,0,0,0"), - "flow label should use preserved raw sketch position: {buf}" + buf.contains("10,6,Infection Rate,150,116,49,8,40,3,0,0,-1,0,0,0"), + "flow label should fall back to the canonical bottom label position: {buf}" ); } @@ -1893,7 +1875,7 @@ fn sketch_link_arc_produces_nonzero_control_point() { } #[test] -fn sketch_link_with_attached_valve_target_preserves_uid_and_fields() { +fn sketch_link_with_field_hints_preserves_nonsemantic_flags() { let link = view_element::Link { uid: 3, from_uid: 1, @@ -1926,11 +1908,11 @@ fn sketch_link_with_attached_valve_target_preserves_uid_and_fields() { SketchTransform::identity(), &valve_uids, ); - assert_eq!(buf, "1,3,1,100,1,0,0,0,0,64,7,-1--1--1,,1|(0,0)|"); + assert_eq!(buf, "1,3,1,2,1,0,0,0,0,64,7,-1--1--1,,1|(0,0)|"); } #[test] -fn sketch_link_with_compat_reuses_control_point_delta() { +fn sketch_link_with_field_hints_still_uses_link_geometry() { let link = view_element::Link { uid: 3, from_uid: 1, @@ -1962,7 +1944,11 @@ fn sketch_link_with_compat_reuses_control_point_delta() { SketchTransform::identity(), &HashMap::new(), ); - assert_eq!(buf, "1,3,1,2,0,0,0,0,0,64,0,-1--1--1,,1|(170,70)|"); + let (ctrl_x, ctrl_y) = compute_control_point((110, 100), (210, 100), 45.0); + assert_eq!( + buf, + format!("1,3,1,2,0,0,0,0,0,64,0,-1--1--1,,1|({ctrl_x},{ctrl_y})|") + ); } #[test] @@ -2427,6 +2413,160 @@ fn sketch_roundtrip_preserves_flow_endpoints_with_nonadjacent_valve_uid() { ); } +#[test] +fn sketch_roundtrip_preserves_causal_links_to_flows_without_sketch_compat() { + let stock_a = Variable::Stock(Stock { + ident: "stock_a".to_owned(), + equation: Equation::Scalar("100".to_owned()), + documentation: String::new(), + units: None, + inflows: vec![], + outflows: vec!["flow_ab".to_owned()], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let stock_b = Variable::Stock(Stock { + ident: "stock_b".to_owned(), + equation: Equation::Scalar("0".to_owned()), + documentation: String::new(), + units: None, + inflows: vec!["flow_ab".to_owned()], + outflows: vec![], + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let flow = Variable::Flow(Flow { + ident: "flow_ab".to_owned(), + equation: Equation::Scalar("driver".to_owned()), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + let driver = Variable::Aux(Aux { + ident: "driver".to_owned(), + equation: Equation::Scalar("1".to_owned()), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: Compat::default(), + }); + + let model = datamodel::Model { + name: "default".to_owned(), + sim_specs: None, + variables: vec![stock_a, stock_b, flow, driver], + views: vec![View::StockFlow(datamodel::StockFlow { + name: Some("View 1".to_owned()), + elements: vec![ + ViewElement::Stock(view_element::Stock { + name: "Stock_A".to_owned(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Stock(view_element::Stock { + name: "Stock_B".to_owned(), + uid: 2, + x: 300.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Aux(view_element::Aux { + name: "Driver".to_owned(), + uid: 3, + x: 200.0, + y: 40.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "Flow_AB".to_owned(), + uid: 6, + x: 200.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + points: vec![ + view_element::FlowPoint { + x: 122.5, + y: 100.0, + attached_to_uid: Some(1), + }, + view_element::FlowPoint { + x: 277.5, + y: 100.0, + attached_to_uid: Some(2), + }, + ], + compat: None, + label_compat: None, + }), + ViewElement::Link(view_element::Link { + uid: 7, + from_uid: 3, + to_uid: 6, + shape: LinkShape::Straight, + polarity: Some(LinkPolarity::Positive), + }), + ], + view_box: Default::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + })], + loop_metadata: vec![], + groups: vec![], + }; + let project = make_project(vec![model]); + + let mdl = crate::mdl::project_to_mdl(&project).expect("MDL write should succeed"); + let reparsed = crate::mdl::parse_mdl(&mdl).expect("written MDL should parse"); + let View::StockFlow(sf) = &reparsed.models[0].views[0]; + + let uid_by_name: HashMap<&str, i32> = sf + .elements + .iter() + .filter_map(|elem| match elem { + ViewElement::Aux(aux) => Some((aux.name.as_str(), aux.uid)), + ViewElement::Flow(flow) => Some((flow.name.as_str(), flow.uid)), + _ => None, + }) + .collect(); + + let link = sf + .elements + .iter() + .find_map(|elem| { + if let ViewElement::Link(link) = elem { + Some(link) + } else { + None + } + }) + .expect("expected link element after roundtrip"); + + assert_eq!( + link.from_uid, + uid_by_name.get("Driver").copied().expect("driver uid"), + "causal link source should roundtrip to Driver", + ); + assert_eq!( + link.to_uid, + uid_by_name.get("Flow_AB").copied().expect("flow uid"), + "causal link target should roundtrip to Flow_AB", + ); +} + #[test] fn compute_control_point_straight_midpoint() { // For a nearly-straight arc angle, the control point should be near the midpoint diff --git a/src/simlin-engine/tests/mdl_roundtrip.rs b/src/simlin-engine/tests/mdl_roundtrip.rs index d6743aa0..1a08829b 100644 --- a/src/simlin-engine/tests/mdl_roundtrip.rs +++ b/src/simlin-engine/tests/mdl_roundtrip.rs @@ -425,6 +425,49 @@ fn xmile_to_mdl_roundtrip() { } } +#[test] +fn default_project_fishbanks_xmile_to_mdl_roundtrip() { + let path = "default_projects/fishbanks/model.xmile"; + let file_path = resolve_path(path); + let source = fs::read_to_string(&file_path).expect("read fishbanks model"); + + let mut reader = BufReader::new(source.as_bytes()); + let xmile_project = xmile::project_from_reader(&mut reader).expect("parse fishbanks xmile"); + assert_eq!( + xmile_project.models.len(), + 1, + "fishbanks should be a single-model project" + ); + + let mdl_text = mdl::project_to_mdl(&xmile_project).expect("write fishbanks mdl"); + let mdl_project = mdl::parse_mdl(&mdl_text).expect("re-parse fishbanks mdl"); + assert_eq!( + mdl_project.models.len(), + 1, + "fishbanks mdl should stay single-model" + ); + assert!( + !mdl_project.models[0].views.is_empty(), + "fishbanks mdl should contain a view" + ); + + let expected_names: HashSet<_> = xmile_project.models[0] + .variables + .iter() + .map(|var| canonical_name(var.get_ident())) + .collect(); + let actual_names: HashSet<_> = mdl_project.models[0] + .variables + .iter() + .map(|var| canonical_name(var.get_ident())) + .collect(); + let missing: Vec<_> = expected_names.difference(&actual_names).cloned().collect(); + assert!( + missing.is_empty(), + "{path}: missing variables after XMILE->MDL->MDL parse roundtrip: {missing:?}" + ); +} + // --------------------------------------------------------------------------- // Task 3: View/sketch roundtrip // --------------------------------------------------------------------------- @@ -852,6 +895,9 @@ fn normalize_named_sketch_line(line: &str) -> Option { if is_time_shadow_line(line) { return None; } + if is_flow_label_line(line) { + return None; + } let fields = line_fields(line); if fields.len() > 3 && fields[0] == "10" { Some(format!("10,{},{}", fields[2], fields[3..].join(","))) @@ -860,6 +906,15 @@ fn normalize_named_sketch_line(line: &str) -> Option { } } +fn normalize_flow_label_line(line: &str) -> Option { + let fields = line_fields(line); + if fields.len() > 5 && fields[0] == "10" { + Some(format!("10,{},{}", fields[2], fields[5..].join(","))) + } else { + None + } +} + fn normalize_valve_line(line: &str) -> String { let fields = line_fields(line); format!("11,{}", fields[2..].join(",")) @@ -1031,17 +1086,37 @@ fn build_named_uid_map(lines: &[String]) -> HashMap { fn normalize_pipe_line( line: &str, + valve_uid: &str, named_uids: &HashMap, cloud_uids: &HashMap, ) -> String { let fields = line_fields(line); let endpoint_uid = fields[3]; + if endpoint_uid == valve_uid { + return "1,bend".to_owned(); + } let endpoint = named_uids .get(endpoint_uid) .cloned() .or_else(|| cloud_uids.get(endpoint_uid).cloned()) .unwrap_or_else(|| format!("uid:{endpoint_uid}")); - format!("1,to={endpoint},{}", fields[4..].join(",")) + format!("1,to={endpoint}") +} + +fn connector_shape(line: &str) -> String { + let tail = line.split(",,").nth(1).unwrap_or_default(); + let point_count = tail.split('|').next().unwrap_or_default(); + if point_count == "1" { + if tail.contains("(0,0)|") { + "straight".to_owned() + } else { + "arc".to_owned() + } + } else if point_count.is_empty() { + "unknown".to_owned() + } else { + format!("multipoint:{point_count}") + } } fn normalize_flow_blocks(view: &SketchView) -> Result>, String> { @@ -1069,7 +1144,7 @@ fn normalize_flow_blocks(view: &SketchView) -> Result Result Result Result, String> { let mut uid_labels = build_named_uid_map(&view.element_lines); for (flow_name, block) in parse_flow_blocks(view)? { - uid_labels.insert(block.valve_uid.clone(), format!("valve:{flow_name}")); + uid_labels.insert(block.valve_uid.clone(), flow_name.clone()); + if let Some(label_uid) = uid_field(&block.label_line) { + uid_labels.insert(label_uid.to_owned(), flow_name.clone()); + } for (idx, cloud_line) in block.cloud_lines.iter().enumerate() { let cloud_uid = uid_field(cloud_line).ok_or_else(|| { format!( @@ -1145,7 +1223,11 @@ fn normalize_influence_connectors(view: &SketchView) -> Result, Stri if from == "Time" || to == "Time" { continue; } - connectors.push(format!("1,{from}->{to},{}", fields[4..].join(","))); + connectors.push(format!( + "1,{from}->{to},pol={},shape={}", + fields.get(6).copied().unwrap_or("0"), + connector_shape(line) + )); } connectors.sort(); Ok(connectors) @@ -1157,8 +1239,8 @@ fn normalize_influence_connectors(view: &SketchView) -> Result, Stri /// Checks: /// - AC1.1: Exactly 2 views with correct names /// - AC1.2: Raw named sketch lines match as unordered multisets -/// - AC1.3: Flow blocks preserve raw valve/cloud/pipe records -/// - AC1.4: Influence connectors preserve resolved endpoint references +/// - AC1.3: Flow blocks preserve semantic valve/cloud/pipe structure +/// - AC1.4: Influence connectors preserve resolved endpoint references and shape /// - AC1.5: Font specification preserved per view /// - AC3.1: Lookup calls use `table ( input )` syntax /// - AC3.2: Lookup range bounds preserved From 505623e735de48a6fb6fd6b347dbcc758f42c175 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Thu, 19 Mar 2026 19:02:31 -0700 Subject: [PATCH 31/31] engine: densify mdl sketch ids Vensim appears to expect small, contiguous sketch IDs within each V300 view section. Reusing merged global IDs made the exported sketches dense enough to parse as text but sparse enough to misrender in the app. Remap sketch IDs per serialized view and cover that behavior with a regression test so multi-view MDL exports keep local numbering even when the in-memory model uses globally unique UIDs. --- src/simlin-engine/src/mdl/writer.rs | 184 +++++++++++++++++----- src/simlin-engine/src/mdl/writer_tests.rs | 120 +++++++++++++- 2 files changed, 261 insertions(+), 43 deletions(-) diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index e18649f7..b3fda4a0 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1418,6 +1418,67 @@ fn format_sketch_name(name: &str) -> String { name.replace('_', " ").replace('\n', "\\n") } +/// Remap merged/global datamodel UIDs into dense, view-local sketch IDs. +/// +/// Vensim sketches use small, contiguous IDs within each `V300` section. +/// After multi-view MDL files are merged into a single StockFlow, the +/// datamodel UIDs remain globally unique across the merged view. Re-using +/// those sparse IDs when serializing a single segment produces valid-looking +/// records that Vensim misrenders. The writer therefore assigns fresh, +/// per-segment IDs while leaving geometry lookups on the original IDs. +struct SketchUidRemap { + element_uids: HashMap, + valve_uids: HashMap, +} + +impl SketchUidRemap { + fn dense_for_segment(elements: &[&ViewElement]) -> Self { + let mut element_uids = HashMap::new(); + let mut flow_uids = Vec::new(); + let mut next_uid = 1; + + for element in elements { + let old_uid = match element { + ViewElement::Aux(aux) => aux.uid, + ViewElement::Stock(stock) => stock.uid, + ViewElement::Flow(flow) => { + flow_uids.push(flow.uid); + flow.uid + } + ViewElement::Link(link) => link.uid, + ViewElement::Alias(alias) => alias.uid, + ViewElement::Cloud(cloud) => cloud.uid, + ViewElement::Module(_) | ViewElement::Group(_) => continue, + }; + element_uids.insert(old_uid, next_uid); + next_uid += 1; + } + + let mut valve_uids = HashMap::new(); + for flow_uid in flow_uids { + valve_uids.insert(flow_uid, next_uid); + next_uid += 1; + } + + Self { + element_uids, + valve_uids, + } + } + + fn element_uid(&self, old_uid: i32) -> i32 { + self.element_uids.get(&old_uid).copied().unwrap_or(old_uid) + } + + fn valve_uid(&self, flow_uid: i32) -> Option { + self.valve_uids.get(&flow_uid).copied() + } + + fn next_connector_uid(&self) -> i32 { + (self.element_uids.len() + self.valve_uids.len()) as i32 + 1 + } +} + const STOCK_WIDTH: f64 = 45.0; const STOCK_HEIGHT: f64 = 35.0; const STOCK_EDGE_TOLERANCE: f64 = 1.0; @@ -1428,13 +1489,14 @@ const STOCK_EDGE_TOLERANCE: f64 = 1.0; /// quoting is not used. #[cfg(test)] fn write_aux_element(buf: &mut String, aux: &view_element::Aux) { - write_aux_element_with_context(buf, aux, SketchTransform::identity()); + write_aux_element_with_context(buf, aux, SketchTransform::identity(), None); } fn write_aux_element_with_context( buf: &mut String, aux: &view_element::Aux, transform: SketchTransform, + uid_remap: Option<&SketchUidRemap>, ) { let name = format_sketch_name(&aux.name); let (w, h, shape, bits) = match &aux.compat { @@ -1443,10 +1505,11 @@ fn write_aux_element_with_context( }; let (x, y) = transform.point(aux.x, aux.y); let tail = compat_tail(aux.compat.as_ref(), "0,0,-1,0,0,0"); + let uid = uid_remap.map_or(aux.uid, |ids| ids.element_uid(aux.uid)); write!( buf, "10,{},{},{},{},{},{},{},{},{}", - aux.uid, name, x, y, w, h, shape, bits, tail, + uid, name, x, y, w, h, shape, bits, tail, ) .unwrap(); } @@ -1454,13 +1517,14 @@ fn write_aux_element_with_context( /// Write a type 10 line for a Stock element. #[cfg(test)] fn write_stock_element(buf: &mut String, stock: &view_element::Stock) { - write_stock_element_with_context(buf, stock, SketchTransform::identity()); + write_stock_element_with_context(buf, stock, SketchTransform::identity(), None); } fn write_stock_element_with_context( buf: &mut String, stock: &view_element::Stock, transform: SketchTransform, + uid_remap: Option<&SketchUidRemap>, ) { let name = format_sketch_name(&stock.name); let (w, h, shape, bits) = match &stock.compat { @@ -1469,10 +1533,11 @@ fn write_stock_element_with_context( }; let (x, y) = transform.point(stock.x, stock.y); let tail = compat_tail(stock.compat.as_ref(), "0,0,0,0,0,0"); + let uid = uid_remap.map_or(stock.uid, |ids| ids.element_uid(stock.uid)); write!( buf, "10,{},{},{},{},{},{},{},{},{}", - stock.uid, name, x, y, w, h, shape, bits, tail, + uid, name, x, y, w, h, shape, bits, tail, ) .unwrap(); } @@ -1510,6 +1575,7 @@ fn allocate_valve_uids(elements: &[ViewElement]) -> HashMap { valve_uids } +#[allow(dead_code)] fn max_sketch_uid(elements: &[ViewElement], valve_uids: &HashMap) -> i32 { let mut max_uid = valve_uids.values().copied().max().unwrap_or(0); for elem in elements { @@ -1622,6 +1688,7 @@ fn write_flow_element( SketchTransform::identity(), &HashMap::new(), &HashSet::new(), + None, ); } @@ -1635,9 +1702,13 @@ fn write_flow_element_with_context( transform: SketchTransform, elem_positions: &HashMap, stock_uids: &HashSet, + uid_remap: Option<&SketchUidRemap>, ) { let name = format_sketch_name(&flow.name); - let valve_uid = valve_uids.get(&flow.uid).copied().unwrap_or(flow.uid - 1); + let valve_uid = uid_remap + .and_then(|ids| ids.valve_uid(flow.uid)) + .or_else(|| valve_uids.get(&flow.uid).copied()) + .unwrap_or(flow.uid - 1); let valve_compat = flow.compat.as_ref(); let label_compat = flow.label_compat.as_ref(); let (valve_x, valve_y) = transform.point(flow.x, flow.y); @@ -1648,9 +1719,12 @@ fn write_flow_element_with_context( flow, valve_uid, next_connector_uid, - transform, - elem_positions, - stock_uids, + FlowConnectorContext { + transform, + elem_positions, + stock_uids, + uid_remap, + }, ); let (valve_w, valve_h, valve_shape, valve_bits) = match valve_compat { @@ -1684,10 +1758,11 @@ fn write_flow_element_with_context( let (label_x, label_y) = default_flow_label_point(flow, transform); let label_tail = compat_tail(label_compat, "0,0,-1,0,0,0"); + let label_uid = uid_remap.map_or(flow.uid, |ids| ids.element_uid(flow.uid)); write!( buf, "\n10,{},{},{},{},{},{},{},{},{}", - flow.uid, name, label_x, label_y, label_w, label_h, label_shape, label_bits, label_tail, + label_uid, name, label_x, label_y, label_w, label_h, label_shape, label_bits, label_tail, ) .unwrap(); } @@ -1707,20 +1782,28 @@ fn write_flow_pipe_connectors( flow, valve_uid, next_connector_uid, - SketchTransform::identity(), - &HashMap::new(), - &HashSet::new(), + FlowConnectorContext { + transform: SketchTransform::identity(), + elem_positions: &HashMap::new(), + stock_uids: &HashSet::new(), + uid_remap: None, + }, ) } +struct FlowConnectorContext<'a> { + transform: SketchTransform, + elem_positions: &'a HashMap, + stock_uids: &'a HashSet, + uid_remap: Option<&'a SketchUidRemap>, +} + fn write_flow_pipe_connectors_with_context( buf: &mut String, flow: &view_element::Flow, valve_uid: i32, next_connector_uid: &mut i32, - transform: SketchTransform, - elem_positions: &HashMap, - stock_uids: &HashSet, + ctx: FlowConnectorContext<'_>, ) -> bool { let mut wrote_any = false; @@ -1746,15 +1829,15 @@ fn write_flow_pipe_connectors_with_context( }; let connector_point = |point: &view_element::FlowPoint| -> (i32, i32) { - let point_xy = transform.point(point.x, point.y); + let point_xy = ctx.transform.point(point.x, point.y); let Some(endpoint_uid) = point.attached_to_uid else { return point_xy; }; - if !stock_uids.contains(&endpoint_uid) { + if !ctx.stock_uids.contains(&endpoint_uid) { return point_xy; } - let Some(&(stock_x, stock_y)) = elem_positions.get(&endpoint_uid) else { + let Some(&(stock_x, stock_y)) = ctx.elem_positions.get(&endpoint_uid) else { return point_xy; }; let dx = f64::from(point_xy.0 - stock_x); @@ -1779,11 +1862,14 @@ fn write_flow_pipe_connectors_with_context( && let Some(endpoint_uid) = last.attached_to_uid { let (x, y) = connector_point(last); - let direction = if stock_uids.contains(&endpoint_uid) { + let direction = if ctx.stock_uids.contains(&endpoint_uid) { 4 } else { 100 }; + let endpoint_uid = ctx + .uid_remap + .map_or(endpoint_uid, |ids| ids.element_uid(endpoint_uid)); write_pipe( buf, !wrote_any, @@ -1823,11 +1909,14 @@ fn write_flow_pipe_connectors_with_context( && let Some(endpoint_uid) = first.attached_to_uid { let (x, y) = connector_point(first); - let direction = if stock_uids.contains(&endpoint_uid) { + let direction = if ctx.stock_uids.contains(&endpoint_uid) { 4 } else { 100 }; + let endpoint_uid = ctx + .uid_remap + .map_or(endpoint_uid, |ids| ids.element_uid(endpoint_uid)); write_pipe( buf, !wrote_any, @@ -1848,13 +1937,14 @@ fn write_flow_pipe_connectors_with_context( /// Write a type 12 line for a Cloud element. #[cfg(test)] fn write_cloud_element(buf: &mut String, cloud: &view_element::Cloud) { - write_cloud_element_with_context(buf, cloud, SketchTransform::identity()); + write_cloud_element_with_context(buf, cloud, SketchTransform::identity(), None); } fn write_cloud_element_with_context( buf: &mut String, cloud: &view_element::Cloud, transform: SketchTransform, + uid_remap: Option<&SketchUidRemap>, ) { let (w, h, shape, bits) = match &cloud.compat { Some(c) => (c.width as i32, c.height as i32, c.shape, c.bits), @@ -1863,10 +1953,11 @@ fn write_cloud_element_with_context( let (x, y) = transform.point(cloud.x, cloud.y); let name_field = compat_name_field(cloud.compat.as_ref(), "48"); let tail = compat_tail(cloud.compat.as_ref(), "0,0,-1,0,0,0"); + let uid = uid_remap.map_or(cloud.uid, |ids| ids.element_uid(cloud.uid)); write!( buf, "12,{},{},{},{},{},{},{},{},{}", - cloud.uid, name_field, x, y, w, h, shape, bits, tail, + uid, name_field, x, y, w, h, shape, bits, tail, ) .unwrap(); } @@ -1884,6 +1975,7 @@ fn write_alias_element( name_map, &HashSet::new(), SketchTransform::identity(), + None, ); } @@ -1893,6 +1985,7 @@ fn write_alias_element_with_context( name_map: &HashMap, stock_uids: &HashSet, transform: SketchTransform, + uid_remap: Option<&SketchUidRemap>, ) { let name = name_map .get(&alias.alias_of_uid) @@ -1912,11 +2005,12 @@ fn write_alias_element_with_context( alias.compat.as_ref(), "0,3,-1,0,0,0,128-128-128,0-0-0,|12||128-128-128", ); + let uid = uid_remap.map_or(alias.uid, |ids| ids.element_uid(alias.uid)); // shape=8 write!( buf, "10,{},{},{},{},{},{},{},{},{}", - alias.uid, name, x, y, w, h, shape, bits, tail, + uid, name, x, y, w, h, shape, bits, tail, ) .unwrap(); } @@ -1939,7 +2033,7 @@ fn write_link_element( use_lettered_polarity, None, SketchTransform::identity(), - &HashMap::new(), + None, ); } @@ -1950,7 +2044,7 @@ fn write_link_element_with_context( use_lettered_polarity: bool, link_compat: Option<&view_element::LinkSketchCompat>, transform: SketchTransform, - _valve_uids: &HashMap, + uid_remap: Option<&SketchUidRemap>, ) { let polarity_val = match link.polarity { Some(LinkPolarity::Positive) if use_lettered_polarity => 83, // 'S' @@ -1964,6 +2058,9 @@ fn write_link_element_with_context( let to_uid = link.to_uid; let from_pos = elem_positions.get(&from_uid).copied().unwrap_or((0, 0)); let to_pos = elem_positions.get(&to_uid).copied().unwrap_or((0, 0)); + let link_uid = uid_remap.map_or(link.uid, |ids| ids.element_uid(link.uid)); + let from_uid = uid_remap.map_or(from_uid, |ids| ids.element_uid(from_uid)); + let to_uid = uid_remap.map_or(to_uid, |ids| ids.element_uid(to_uid)); let field4 = link_compat.map(|compat| compat.field4).unwrap_or(0); let field10 = link_compat.map(|compat| compat.field10).unwrap_or(0); @@ -1973,7 +2070,7 @@ fn write_link_element_with_context( write!( buf, "1,{},{},{},{},0,{},0,0,64,{},-1--1--1,,1|(0,0)|", - link.uid, from_uid, to_uid, field4, polarity_val, field10, + link_uid, from_uid, to_uid, field4, polarity_val, field10, ) .unwrap(); } @@ -1982,7 +2079,7 @@ fn write_link_element_with_context( write!( buf, "1,{},{},{},{},0,{},0,0,64,{},-1--1--1,,1|({},{})|", - link.uid, from_uid, to_uid, field4, polarity_val, field10, ctrl_x, ctrl_y, + link_uid, from_uid, to_uid, field4, polarity_val, field10, ctrl_x, ctrl_y, ) .unwrap(); } @@ -1991,7 +2088,7 @@ fn write_link_element_with_context( write!( buf, "1,{},{},{},{},0,{},0,0,64,{},-1--1--1,,{}|", - link.uid, from_uid, to_uid, field4, polarity_val, field10, npoints, + link_uid, from_uid, to_uid, field4, polarity_val, field10, npoints, ) .unwrap(); for pt in points { @@ -2315,7 +2412,6 @@ impl MdlWriter { // Build shared maps from ALL elements so that cross-view // references (links, aliases) resolve correctly. let valve_uids = allocate_valve_uids(&sf.elements); - let mut next_connector_uid = max_sketch_uid(&sf.elements, &valve_uids) + 1; let name_map = build_name_map(&sf.elements); let mut flow_compat_by_uid: HashMap = HashMap::new(); @@ -2371,8 +2467,6 @@ impl MdlWriter { elements, font.as_deref(), sf.use_lettered_polarity, - &valve_uids, - &mut next_connector_uid, sf.sketch_compat .as_ref() .and_then(|compat| compat.segments.get(segment_ix)) @@ -2402,8 +2496,6 @@ impl MdlWriter { elements: &[&ViewElement], font: Option<&str>, use_lettered_polarity: bool, - valve_uids: &HashMap, - next_connector_uid: &mut i32, transform: SketchTransform, elem_positions: &HashMap, name_map: &HashMap, @@ -2411,6 +2503,8 @@ impl MdlWriter { _flow_compat_by_uid: &HashMap, link_compat_by_uid: &HashMap, ) { + let uid_remap = SketchUidRemap::dense_for_segment(elements); + let mut next_connector_uid = uid_remap.next_connector_uid(); let view_title = sanitize_view_title_for_mdl(view_name); writeln!(self.buf, "*{}", view_title).unwrap(); @@ -2437,30 +2531,41 @@ impl MdlWriter { for elem in elements { match elem { ViewElement::Aux(aux) => { - write_aux_element_with_context(&mut self.buf, aux, transform); + write_aux_element_with_context(&mut self.buf, aux, transform, Some(&uid_remap)); self.buf.push('\n'); } ViewElement::Stock(stock) => { - write_stock_element_with_context(&mut self.buf, stock, transform); + write_stock_element_with_context( + &mut self.buf, + stock, + transform, + Some(&uid_remap), + ); self.buf.push('\n'); } ViewElement::Flow(flow) => { // Emit associated clouds before the flow pipes if let Some(clouds) = flow_clouds.get(&flow.uid) { for cloud in clouds { - write_cloud_element_with_context(&mut self.buf, cloud, transform); + write_cloud_element_with_context( + &mut self.buf, + cloud, + transform, + Some(&uid_remap), + ); self.buf.push('\n'); } } write_flow_element_with_context( &mut self.buf, flow, - valve_uids, + &uid_remap.valve_uids, &cloud_uids, - next_connector_uid, + &mut next_connector_uid, transform, elem_positions, stock_uids, + Some(&uid_remap), ); self.buf.push('\n'); } @@ -2472,7 +2577,7 @@ impl MdlWriter { use_lettered_polarity, link_compat_by_uid.get(&link.uid).copied(), transform, - valve_uids, + Some(&uid_remap), ); self.buf.push('\n'); } @@ -2485,6 +2590,7 @@ impl MdlWriter { name_map, stock_uids, transform, + Some(&uid_remap), ); self.buf.push('\n'); } diff --git a/src/simlin-engine/src/mdl/writer_tests.rs b/src/simlin-engine/src/mdl/writer_tests.rs index 4e8fe781..80beae6e 100644 --- a/src/simlin-engine/src/mdl/writer_tests.rs +++ b/src/simlin-engine/src/mdl/writer_tests.rs @@ -1696,6 +1696,7 @@ fn sketch_flow_element_derives_stock_connector_points_from_takeoffs() { SketchTransform::identity(), &elem_positions, &stock_uids, + None, ); assert!( @@ -1794,6 +1795,7 @@ fn sketch_alias_element_offsets_stock_ghost_coordinates() { &name_map, &HashSet::from([1]), SketchTransform::identity(), + None, ); assert!( buf.starts_with("10,10,Population,222,317,40,20,8,2,0,3,-1,0,0,0,"), @@ -1884,7 +1886,6 @@ fn sketch_link_with_field_hints_preserves_nonsemantic_flags() { polarity: None, }; let positions = HashMap::from([(1, (100, 100)), (2, (200, 116)), (100, (200, 100))]); - let valve_uids = HashMap::from([(2, 100)]); let compat = view_element::LinkSketchCompat { uid: 3, field4: 1, @@ -1906,7 +1907,7 @@ fn sketch_link_with_field_hints_preserves_nonsemantic_flags() { false, Some(&compat), SketchTransform::identity(), - &valve_uids, + None, ); assert_eq!(buf, "1,3,1,2,1,0,0,0,0,64,7,-1--1--1,,1|(0,0)|"); } @@ -1942,7 +1943,7 @@ fn sketch_link_with_field_hints_still_uses_link_geometry() { false, Some(&compat), SketchTransform::identity(), - &HashMap::new(), + None, ); let (ctrl_x, ctrl_y) = compute_control_point((110, 100), (210, 100), 45.0); assert_eq!( @@ -3278,6 +3279,29 @@ fn make_stock_flow(elements: Vec) -> StockFlow { } } +fn sketch_record_uids_for_view(output: &str, view_title: &str) -> Vec { + let marker = format!("*{view_title}\n"); + let start = output.find(&marker).expect("view marker should exist"); + let section = &output[start + marker.len()..]; + let end = section + .find("\\\\\\---/// Sketch information - do not modify anything except names\n") + .or_else(|| section.find("///---\\\\\\\n")) + .expect("view should end at the next sketch boundary"); + let section = §ion[..end]; + + let mut ids = section + .lines() + .filter_map(|line| { + let record_type = line.split(',').next()?; + matches!(record_type, "1" | "10" | "11" | "12") + .then(|| line.split(',').nth(1)?.parse::().ok()) + .flatten() + }) + .collect::>(); + ids.sort_unstable(); + ids +} + #[test] fn split_view_no_groups_returns_single_segment() { let sf = make_stock_flow(vec![ @@ -3359,11 +3383,99 @@ fn write_sketch_section_reapplies_segment_offsets() { "first segment should subtract its stored offset: {output}" ); assert!( - output.contains("10,2,Second Aux,100,100,40,20,8,3,0,0,-1,0,0,0"), + output.contains("10,1,Second Aux,100,100,40,20,8,3,0,0,-1,0,0,0"), "second segment should subtract its stored offset: {output}" ); } +#[test] +fn write_sketch_section_reassigns_dense_uids_per_view() { + let sf = StockFlow { + name: None, + elements: vec![ + make_view_group("1 housing", 100), + ViewElement::Stock(view_element::Stock { + name: "Homes".to_string(), + uid: 10, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Stock(view_element::Stock { + name: "Inventory".to_string(), + uid: 20, + x: 300.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "Sales".to_string(), + uid: 60, + x: 200.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + points: vec![ + view_element::FlowPoint { + x: 122.5, + y: 100.0, + attached_to_uid: Some(10), + }, + view_element::FlowPoint { + x: 277.5, + y: 100.0, + attached_to_uid: Some(20), + }, + ], + compat: None, + label_compat: None, + }), + ViewElement::Link(view_element::Link { + uid: 80, + from_uid: 10, + to_uid: 60, + shape: LinkShape::Straight, + polarity: Some(LinkPolarity::Positive), + }), + make_view_group("2 investments", 200), + ViewElement::Aux(view_element::Aux { + name: "Risk".to_string(), + uid: 300, + x: 100.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "Funding".to_string(), + uid: 400, + x: 200.0, + y: 100.0, + label_side: view_element::LabelSide::Bottom, + points: vec![], + compat: None, + label_compat: None, + }), + ], + view_box: Rect::default(), + zoom: 1.0, + use_lettered_polarity: false, + font: None, + sketch_compat: None, + }; + + let mut writer = MdlWriter::new(); + writer.write_sketch_section(&[View::StockFlow(sf)]); + let output = writer.buf; + + let housing_ids = sketch_record_uids_for_view(&output, "1 housing"); + assert_eq!(housing_ids, vec![1, 2, 3, 4, 5, 6, 7]); + + let investment_ids = sketch_record_uids_for_view(&output, "2 investments"); + assert_eq!(investment_ids, vec![1, 2, 3]); +} + #[test] fn split_view_two_groups_produces_two_segments() { let sf = make_stock_flow(vec![