use merman_core::{Engine, ParseOptions};
use merman_render::{LayoutOptions, layout_parsed};
use std::path::PathBuf;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
}
fn load_class_layout_fixture(name: &str) -> merman_render::model::ClassDiagramV2Layout {
let path = workspace_root()
.join("fixtures")
.join("class")
.join(format!("{name}.mmd"));
let text = std::fs::read_to_string(&path).expect("fixture");
let engine = Engine::new();
let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
.expect("parse ok")
.expect("diagram detected");
let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
panic!("expected ClassDiagramV2 layout");
};
*layout
}
fn rect_from_node(n: &merman_render::model::LayoutNode) -> (f64, f64, f64, f64) {
let hw = n.width / 2.0;
let hh = n.height / 2.0;
(n.x - hw, n.y - hh, n.x + hw, n.y + hh)
}
fn rect_from_cluster(c: &merman_render::model::LayoutCluster) -> (f64, f64, f64, f64) {
let hw = c.width / 2.0;
let hh = c.height / 2.0;
(c.x - hw, c.y - hh, c.x + hw, c.y + hh)
}
fn rect_contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64), eps: f64) -> bool {
let (omin_x, omin_y, omax_x, omax_y) = outer;
let (imin_x, imin_y, imax_x, imax_y) = inner;
imin_x + eps >= omin_x
&& imax_x <= omax_x + eps
&& imin_y + eps >= omin_y
&& imax_y <= omax_y + eps
}
#[test]
fn class_layout_produces_positions_and_routes() {
let path = workspace_root()
.join("fixtures")
.join("class")
.join("basic.mmd");
let text = std::fs::read_to_string(&path).expect("fixture");
let engine = Engine::new();
let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
.expect("parse ok")
.expect("diagram detected");
let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
panic!("expected ClassDiagramV2 layout");
};
assert!(layout.nodes.len() >= 2);
assert!(!layout.edges.is_empty());
for n in &layout.nodes {
assert!(n.width.is_finite() && n.width > 0.0);
assert!(n.height.is_finite() && n.height > 0.0);
assert!(n.x.is_finite() && n.y.is_finite());
}
for e in &layout.edges {
assert!(
e.points.len() >= 2,
"edge {} should have at least two points",
e.id
);
for p in &e.points {
assert!(p.x.is_finite() && p.y.is_finite());
}
}
}
#[test]
fn class_namespaces_contain_member_classes() {
let path = workspace_root()
.join("fixtures")
.join("class")
.join("upstream_namespaces_and_generics.mmd");
let text = std::fs::read_to_string(&path).expect("fixture");
let engine = Engine::new();
let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
.expect("parse ok")
.expect("diagram detected");
let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
panic!("expected ClassDiagramV2 layout");
};
let mut node_by_id = std::collections::HashMap::new();
for n in &layout.nodes {
if !n.is_cluster {
node_by_id.insert(n.id.as_str(), n);
}
}
let mut cluster_by_id = std::collections::HashMap::new();
for c in &layout.clusters {
cluster_by_id.insert(c.id.as_str(), c);
}
let semantic = &out.semantic;
let Some(classes) = semantic.get("classes").and_then(|v| v.as_object()) else {
panic!("missing semantic.classes");
};
for (id, cls) in classes {
let parent = cls.get("parent").and_then(|v| v.as_str()).unwrap_or("");
if parent.is_empty() {
continue;
}
let Some(node) = node_by_id.get(id.as_str()) else {
continue;
};
let Some(cluster) = cluster_by_id.get(parent) else {
panic!("missing cluster {parent}");
};
assert!(
rect_contains(rect_from_cluster(cluster), rect_from_node(node), 0.01),
"cluster {parent} should contain {id}"
);
}
}
#[test]
fn class_layout_dense_namespaces_follow_declaration_order() {
let layout = load_class_layout_fixture("stress_class_dense_namespaces_generics_001");
let cluster_ids = layout
.clusters
.iter()
.map(|cluster| cluster.id.as_str())
.collect::<Vec<_>>();
assert_eq!(cluster_ids, vec!["Core", "API"]);
}
#[test]
fn class_terminal_labels_exist_for_cardinalities_fixture() {
let path = workspace_root()
.join("fixtures")
.join("class")
.join("upstream_relation_types_and_cardinalities_spec.mmd");
let text = std::fs::read_to_string(&path).expect("fixture");
let engine = Engine::new();
let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
.expect("parse ok")
.expect("diagram detected");
let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
panic!("expected ClassDiagramV2 layout");
};
let has_terminal = layout.edges.iter().any(|e| {
e.start_label_left.is_some()
|| e.start_label_right.is_some()
|| e.end_label_left.is_some()
|| e.end_label_right.is_some()
});
assert!(has_terminal, "expected at least one terminal label");
}
fn point_inside(rect: (f64, f64, f64, f64), x: f64, y: f64, eps: f64) -> bool {
let (min_x, min_y, max_x, max_y) = rect;
x >= min_x - eps && x <= max_x + eps && y >= min_y - eps && y <= max_y + eps
}
#[test]
fn class_note_heavy_tb_layout_prefers_mermaid_leftward_rank_order() {
let layout = load_class_layout_fixture("stress_class_notes_wrap_positions_014");
let node_a = layout.nodes.iter().find(|n| n.id == "A").expect("class A");
let node_b = layout.nodes.iter().find(|n| n.id == "B").expect("class B");
let node_c = layout.nodes.iter().find(|n| n.id == "C").expect("class C");
let note_a = layout
.nodes
.iter()
.find(|n| n.id == "note0")
.expect("note for A");
let note_b = layout
.nodes
.iter()
.find(|n| n.id == "note1")
.expect("note for B");
let note_c = layout
.nodes
.iter()
.find(|n| n.id == "note2")
.expect("note for C");
assert!(
node_a.x > node_b.x && node_b.x > node_c.x,
"expected TB note-heavy classes to lean left, got A={}, B={}, C={}",
node_a.x,
node_b.x,
node_c.x
);
assert!(
(note_a.x - node_a.x).abs() <= 0.01,
"expected note for A to stay centered over A, got note={}, class={}",
note_a.x,
node_a.x
);
assert!(
note_b.x < node_b.x,
"expected note for B to stay on the left, got note={}, class={}",
note_b.x,
node_b.x
);
assert!(
note_c.x < node_c.x,
"expected note for C to stay on the left, got note={}, class={}",
note_c.x,
node_c.x
);
}
#[test]
fn class_two_note_tb_layout_keeps_secondary_note_left_of_target() {
let layout = load_class_layout_fixture("stress_class_notes_and_keywords_003");
let node_a = layout.nodes.iter().find(|n| n.id == "A").expect("class A");
let node_b = layout.nodes.iter().find(|n| n.id == "B").expect("class B");
let note_b = layout
.nodes
.iter()
.find(|n| n.id == "note1")
.expect("note for B");
assert!(
node_a.x > node_b.x,
"expected A to remain to the right of B for the mirrored note-heavy solution, got A={}, B={}",
node_a.x,
node_b.x
);
assert!(
note_b.x < node_b.x,
"expected note for B to stay left of B, got note={}, class={}",
note_b.x,
node_b.x
);
}
#[test]
fn class_terminal_labels_are_outside_endpoint_nodes_for_cardinalities_fixture() {
let path = workspace_root()
.join("fixtures")
.join("class")
.join("upstream_relation_types_and_cardinalities_spec.mmd");
let text = std::fs::read_to_string(&path).expect("fixture");
let engine = Engine::new();
let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
.expect("parse ok")
.expect("diagram detected");
let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
panic!("expected ClassDiagramV2 layout");
};
let mut node_rect_by_id = std::collections::HashMap::new();
for n in &layout.nodes {
if n.is_cluster {
continue;
}
node_rect_by_id.insert(n.id.as_str(), rect_from_node(n));
}
let eps = 0.01;
let mut checked = 0usize;
for e in &layout.edges {
let Some(from_rect) = node_rect_by_id.get(e.from.as_str()) else {
continue;
};
let Some(to_rect) = node_rect_by_id.get(e.to.as_str()) else {
continue;
};
for lbl in [
e.start_label_left.as_ref(),
e.start_label_right.as_ref(),
e.end_label_left.as_ref(),
e.end_label_right.as_ref(),
] {
let Some(lbl) = lbl else {
continue;
};
checked += 1;
assert!(
!point_inside(*from_rect, lbl.x, lbl.y, eps),
"terminal label center should not be inside start node for edge {}",
e.id
);
assert!(
!point_inside(*to_rect, lbl.x, lbl.y, eps),
"terminal label center should not be inside end node for edge {}",
e.id
);
}
}
assert!(checked > 0, "expected to check at least one terminal label");
}
#[test]
fn class_single_glyph_svg_titles_use_upstream_bbox_width() {
let text = r#"---
config:
htmlLabels: false
---
classDiagram
A <|-- B
"#;
let engine = Engine::new();
let parsed = futures::executor::block_on(engine.parse_diagram(text, ParseOptions::default()))
.expect("parse ok")
.expect("diagram detected");
let out = layout_parsed(
&parsed,
&LayoutOptions {
text_measurer: std::sync::Arc::new(
merman_render::text::VendoredFontMetricsTextMeasurer::default(),
),
..LayoutOptions::default()
},
)
.expect("layout ok");
let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
panic!("expected ClassDiagramV2 layout");
};
let node_a = layout
.nodes
.iter()
.find(|n| n.id == "A")
.expect("class A node");
let node_b = layout
.nodes
.iter()
.find(|n| n.id == "B")
.expect("class B node");
let eps = 1e-6;
assert!(
(node_a.width - 34.140625).abs() <= eps,
"unexpected A width: {}",
node_a.width
);
assert!(
(node_b.width - 33.53125).abs() <= eps,
"unexpected B width: {}",
node_b.width
);
}