use std::collections::{HashMap, HashSet};
use ascii_dag::{Graph as AGraph, LayoutConfig as ALayoutConfig};
use unicode_width::UnicodeWidthStr;
use crate::layout::layered::{LayoutConfig, LayoutResult, node_box_height, node_box_width};
use crate::layout::subgraph::parallel_label_extra;
use crate::types::{Direction, Graph, Subgraph};
fn intra_orthogonal_edges(graph: &Graph) -> HashSet<(String, String)> {
let mut result = HashSet::new();
for sg in &graph.subgraphs {
collect_intra_edges(sg, graph, graph.direction, &mut result);
}
result
}
fn collect_intra_edges(
sg: &Subgraph,
graph: &Graph,
parent_dir: Direction,
out: &mut HashSet<(String, String)>,
) {
let effective_child_dir = sg.direction.unwrap_or(parent_dir);
for child_id in &sg.subgraph_ids {
if let Some(child) = graph.find_subgraph(child_id) {
collect_intra_edges(child, graph, effective_child_dir, out);
}
}
let Some(sg_dir) = sg.direction else { return };
if sg_dir.is_horizontal() == parent_dir.is_horizontal() {
return; }
let member_set: HashSet<&str> = sg.node_ids.iter().map(String::as_str).collect();
for edge in &graph.edges {
if member_set.contains(edge.from.as_str()) && member_set.contains(edge.to.as_str()) {
let (a, b) = if edge.from <= edge.to {
(edge.from.clone(), edge.to.clone())
} else {
(edge.to.clone(), edge.from.clone())
};
out.insert((a, b));
}
}
}
fn apply_direction_overrides(
positions: &mut HashMap<String, (usize, usize)>,
graph: &Graph,
config: &LayoutConfig,
) {
if graph.subgraphs.is_empty() {
return;
}
let sgs: Vec<Subgraph> = graph.subgraphs.clone();
apply_overrides_recursive(positions, graph, &sgs, graph.direction, config);
}
fn apply_overrides_recursive(
positions: &mut HashMap<String, (usize, usize)>,
graph: &Graph,
sgs: &[Subgraph],
parent_dir: Direction,
config: &LayoutConfig,
) {
for sg in sgs {
let Some(sg_dir) = sg.direction else {
let children: Vec<Subgraph> = sg
.subgraph_ids
.iter()
.filter_map(|id| graph.find_subgraph(id).cloned())
.collect();
apply_overrides_recursive(positions, graph, &children, parent_dir, config);
continue;
};
let children: Vec<Subgraph> = sg
.subgraph_ids
.iter()
.filter_map(|id| graph.find_subgraph(id).cloned())
.collect();
apply_overrides_recursive(positions, graph, &children, sg_dir, config);
if sg_dir.is_horizontal() == parent_dir.is_horizontal() {
continue; }
transpose_subgraph_positions(positions, graph, sg, sg_dir, config);
}
}
fn transpose_subgraph_positions(
positions: &mut HashMap<String, (usize, usize)>,
graph: &Graph,
sg: &Subgraph,
sg_dir: Direction,
config: &LayoutConfig,
) {
let members: Vec<&str> = sg
.node_ids
.iter()
.filter(|id| positions.contains_key(*id))
.map(String::as_str)
.collect();
if members.is_empty() {
return;
}
let anchor_col = members.iter().map(|id| positions[*id].0).min().unwrap();
let anchor_row = members.iter().map(|id| positions[*id].1).min().unwrap();
let topo = topo_sort_members(&members, graph);
let override_is_horizontal = sg_dir.is_horizontal();
let mut flow_offset = 0usize;
let mut new_positions: HashMap<String, (usize, usize)> = HashMap::with_capacity(topo.len());
for id in &topo {
let (col, row) = if override_is_horizontal {
(anchor_col + flow_offset, anchor_row)
} else {
(anchor_col, anchor_row + flow_offset)
};
new_positions.insert((*id).to_owned(), (col, row));
let step = if override_is_horizontal {
node_box_width(graph, id) + config.node_gap
} else {
node_box_height(graph, id) + config.node_gap
};
flow_offset += step;
}
if override_is_horizontal {
let max_col = new_positions
.values()
.map(|(c, _)| *c)
.max()
.unwrap_or(anchor_col);
if matches!(sg_dir, Direction::RightToLeft) {
for (col, _) in new_positions.values_mut() {
*col = anchor_col + (max_col - *col);
}
}
} else {
let max_row = new_positions
.values()
.map(|(_, r)| *r)
.max()
.unwrap_or(anchor_row);
if matches!(sg_dir, Direction::BottomToTop) {
for (_, row) in new_positions.values_mut() {
*row = anchor_row + (max_row - *row);
}
}
}
for (id, pos) in new_positions {
positions.insert(id, pos);
}
}
fn topo_sort_members(members: &[&str], graph: &Graph) -> Vec<String> {
let member_set: HashSet<&str> = members.iter().copied().collect();
let mut succ: HashMap<&str, Vec<&str>> = members.iter().map(|&m| (m, Vec::new())).collect();
let mut in_degree: HashMap<&str, usize> = members.iter().map(|&m| (m, 0usize)).collect();
for edge in &graph.edges {
let (f, t) = (edge.from.as_str(), edge.to.as_str());
if member_set.contains(f) && member_set.contains(t) && f != t {
succ.entry(f).or_default().push(t);
*in_degree.entry(t).or_default() += 1;
}
}
let mut queue: std::collections::VecDeque<&str> = members
.iter()
.filter(|&&m| in_degree.get(m).copied().unwrap_or(0) == 0)
.copied()
.collect();
let mut order: Vec<String> = Vec::with_capacity(members.len());
while let Some(node) = queue.pop_front() {
order.push(node.to_owned());
let succs: Vec<&str> = succ.get(node).cloned().unwrap_or_default();
for s in succs {
let d = in_degree.entry(s).or_default();
*d = d.saturating_sub(1);
if *d == 0 {
queue.push_back(s);
}
}
}
if order.len() < members.len() {
let in_order: HashSet<String> = order.iter().cloned().collect();
for &m in members {
if !in_order.contains(m) {
order.push(m.to_owned());
}
}
}
order
}
fn register_subgraphs<'g>(
adag: &mut AGraph<'g>,
graph: &'g Graph,
id_to_usize: &HashMap<String, usize>,
) {
let mut queue: std::collections::VecDeque<&str> =
graph.subgraphs.iter().map(|sg| sg.id.as_str()).collect();
let mut all_sg_ids: Vec<String> = Vec::new();
while let Some(id) = queue.pop_front() {
all_sg_ids.push(id.to_owned());
if let Some(sg) = graph.find_subgraph(id) {
for child_id in &sg.subgraph_ids {
queue.push_back(child_id.as_str());
}
}
}
let mut sg_id_map: HashMap<String, usize> = HashMap::with_capacity(all_sg_ids.len());
for sg_id in &all_sg_ids {
if let Some(sg) = graph.find_subgraph(sg_id) {
let adag_sg_id = adag.add_subgraph(&sg.label);
sg_id_map.insert(sg.id.clone(), adag_sg_id);
}
}
for sg_id in &all_sg_ids {
let Some(sg) = graph.find_subgraph(sg_id) else {
continue;
};
let Some(&parent_aid) = sg_id_map.get(&sg.id) else {
continue;
};
let node_aids: Vec<usize> = sg
.node_ids
.iter()
.filter_map(|nid| id_to_usize.get(nid).copied())
.collect();
if !node_aids.is_empty() {
adag.put_nodes(&node_aids)
.inside(parent_aid)
.expect("ascii-dag rejected node placement — id_to_usize mapping inconsistent");
}
let child_aids: Vec<usize> = sg
.subgraph_ids
.iter()
.filter_map(|cid| sg_id_map.get(cid).copied())
.collect();
if !child_aids.is_empty() {
adag.put_subgraphs(&child_aids)
.inside(parent_aid)
.expect("ascii-dag rejected subgraph nesting — sg_id_map inconsistent");
}
}
}
fn apply_parallel_edge_widening(
positions: &mut HashMap<String, (usize, usize)>,
id_to_level: &HashMap<String, usize>,
graph: &Graph,
) {
let parallel_groups = graph.parallel_edge_groups();
if parallel_groups.is_empty() {
return;
}
let max_level = id_to_level.values().copied().max().unwrap_or(0);
if max_level == 0 {
return;
}
let mut extra_per_gap: Vec<usize> = vec![0usize; max_level];
for group in ¶llel_groups {
let first_edge = &graph.edges[group[0]];
let Some(&from_lvl) = id_to_level.get(&first_edge.from) else {
continue;
};
let Some(&to_lvl) = id_to_level.get(&first_edge.to) else {
continue;
};
let (lo, hi) = if from_lvl <= to_lvl {
(from_lvl, to_lvl)
} else {
(to_lvl, from_lvl)
};
if hi != lo + 1 {
continue;
}
let labeled: Vec<usize> = group
.iter()
.filter_map(|&idx| {
graph.edges[idx]
.label
.as_deref()
.map(UnicodeWidthStr::width)
})
.collect();
let count = labeled.len();
if count < 2 {
continue;
}
let max_lbl = labeled.iter().copied().max().unwrap_or(0);
let extra = (count - 1) * (max_lbl + 2);
extra_per_gap[lo] = extra_per_gap[lo].max(extra);
}
let mut cumulative = vec![0usize; max_level + 1];
for l in 0..max_level {
cumulative[l + 1] = cumulative[l] + extra_per_gap[l];
}
for (id, pos) in positions.iter_mut() {
let Some(&lvl) = id_to_level.get(id) else {
continue;
};
let offset = cumulative[lvl];
if offset == 0 {
continue;
}
match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => pos.0 += offset,
Direction::TopToBottom | Direction::BottomToTop => pos.1 += offset,
}
}
}
fn collect_subgraph_extras(
graph: &Graph,
sg: &Subgraph,
parent_axis_horizontal: bool,
id_to_level: &HashMap<String, usize>,
layer_extra: &mut HashMap<usize, usize>,
) {
let (extra_w, extra_h) = parallel_label_extra(graph, sg);
let extra = if parent_axis_horizontal { extra_w } else { extra_h };
if extra > 0 {
let member_level = sg
.node_ids
.iter()
.filter_map(|nid| id_to_level.get(nid).copied())
.min();
if let Some(level) = member_level {
let cur = layer_extra.entry(level).or_insert(0);
*cur = (*cur).max(extra);
}
}
for child_id in &sg.subgraph_ids {
if let Some(child) = graph.find_subgraph(child_id) {
collect_subgraph_extras(
graph,
child,
parent_axis_horizontal,
id_to_level,
layer_extra,
);
}
}
}
pub fn sugiyama_layout(graph: &Graph, _config: &LayoutConfig) -> LayoutResult {
if graph.nodes.is_empty() {
return LayoutResult::default();
}
let mut id_to_usize: HashMap<String, usize> = HashMap::with_capacity(graph.nodes.len());
let mut usize_to_id: HashMap<usize, String> = HashMap::with_capacity(graph.nodes.len());
for (i, node) in graph.nodes.iter().enumerate() {
let aid = i + 1; id_to_usize.insert(node.id.clone(), aid);
usize_to_id.insert(aid, node.id.clone());
}
let transpose = matches!(
graph.direction,
Direction::LeftToRight | Direction::RightToLeft
);
let mut adag: AGraph = AGraph::new();
for node in &graph.nodes {
let aid = id_to_usize[&node.id];
let our_w = node_box_width(graph, &node.id);
let our_h = node_box_height(graph, &node.id);
let (adag_w, adag_h) = if transpose {
(our_h, our_w)
} else {
(our_w, our_h)
};
adag.add_node_with_size(aid, &node.id, adag_w, adag_h);
}
if !graph.subgraphs.is_empty() {
register_subgraphs(&mut adag, graph, &id_to_usize);
}
let skip_edges = intra_orthogonal_edges(graph);
for edge in &graph.edges {
let canonical = if edge.from <= edge.to {
(edge.from.clone(), edge.to.clone())
} else {
(edge.to.clone(), edge.from.clone())
};
if skip_edges.contains(&canonical) {
continue;
}
let (Some(&from), Some(&to)) = (id_to_usize.get(&edge.from), id_to_usize.get(&edge.to))
else {
continue;
};
adag.add_edge(from, to, edge.label.as_deref());
}
let mut cfg = ALayoutConfig::standard();
cfg.level_spacing = _config.layer_gap;
cfg.node_spacing = _config.node_gap;
cfg.include_dummy_nodes = true;
let ir = adag.compute_layout_with_config(&cfg);
let mut raw_positions: Vec<(String, usize, usize, usize)> =
Vec::with_capacity(ir.nodes().len()); let mut max_x = 0usize;
let mut max_y = 0usize;
for n in ir.nodes() {
if matches!(n.kind, ascii_dag::NodeKind::Dummy) {
continue;
}
let Some(real_id) = usize_to_id.get(&n.id) else {
continue;
};
let (col, row) = if transpose { (n.y, n.x) } else { (n.x, n.y) };
raw_positions.push((real_id.clone(), col, row, n.level));
max_x = max_x.max(col);
max_y = max_y.max(row);
}
{
let max_level = raw_positions
.iter()
.map(|(_, _, _, l)| *l)
.max()
.unwrap_or(0);
if max_level > 0 {
let has_outgoing: HashSet<&str> =
graph.edges.iter().map(|e| e.from.as_str()).collect();
let sinks: HashSet<String> = graph
.nodes
.iter()
.filter(|n| {
!has_outgoing.contains(n.id.as_str())
&& n.id.starts_with("__end__")
})
.map(|n| n.id.clone())
.collect();
let max_level_flow = raw_positions.iter().find_map(|(_, c, r, l)| {
if *l == max_level {
Some(if transpose { *c } else { *r })
} else {
None
}
});
let max_level_within_extent = raw_positions
.iter()
.filter(|(_, _, _, l)| *l == max_level)
.map(|(id, c, r, _)| {
let perp = if transpose { *r } else { *c };
let perp_size = if transpose {
node_box_height(graph, id)
} else {
node_box_width(graph, id)
};
perp + perp_size
})
.max();
if let Some(target_flow) = max_level_flow {
let mut next_within = max_level_within_extent.unwrap_or(0);
for (id, col, row, level) in raw_positions.iter_mut() {
if *level < max_level && sinks.contains(id) {
*level = max_level;
if transpose {
*col = target_flow;
*row = next_within + 1;
next_within = *row + node_box_height(graph, id);
} else {
*row = target_flow;
*col = next_within + 1;
next_within = *col + node_box_width(graph, id);
}
}
}
}
}
}
const ASCII_DAG_BASELINE_GAP: usize = 3;
let extra_per_layer = _config.layer_gap.saturating_sub(ASCII_DAG_BASELINE_GAP);
let mut positions: HashMap<String, (usize, usize)> =
HashMap::with_capacity(raw_positions.len());
let mut id_to_level: HashMap<String, usize> = HashMap::with_capacity(raw_positions.len());
for (id, col, row, level) in raw_positions {
id_to_level.insert(id.clone(), level);
let offset = level * extra_per_layer;
let (col, row) = match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => (col + offset, row),
Direction::TopToBottom | Direction::BottomToTop => (col, row + offset),
};
max_x = max_x.max(col);
max_y = max_y.max(row);
positions.insert(id, (col, row));
}
apply_parallel_edge_widening(&mut positions, &id_to_level, graph);
apply_direction_overrides(&mut positions, graph, _config);
{
let parent_axis_horizontal = matches!(
graph.direction,
Direction::LeftToRight | Direction::RightToLeft
);
let mut layer_extra: HashMap<usize, usize> = HashMap::new();
for sg in &graph.subgraphs {
collect_subgraph_extras(graph, sg, parent_axis_horizontal, &id_to_level, &mut layer_extra);
}
if !layer_extra.is_empty() {
let mut levels: Vec<usize> = layer_extra.keys().copied().collect();
levels.sort();
for boundary_level in levels {
let extra = layer_extra[&boundary_level];
for (id, (col, row)) in positions.iter_mut() {
if let Some(&node_level) = id_to_level.get(id)
&& node_level > boundary_level
{
if parent_axis_horizontal {
*col += extra;
} else {
*row += extra;
}
}
}
}
}
}
for (col, row) in positions.values() {
max_x = max_x.max(*col);
max_y = max_y.max(*row);
}
if matches!(graph.direction, Direction::RightToLeft) {
for (col, _) in positions.values_mut() {
*col = max_x - *col;
}
}
if matches!(graph.direction, Direction::BottomToTop) {
for (_, row) in positions.values_mut() {
*row = max_y - *row;
}
}
LayoutResult { positions }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Direction, Edge, Node, NodeShape};
#[test]
fn empty_graph_returns_empty() {
let g = Graph::new(Direction::TopToBottom);
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions.is_empty());
}
#[test]
fn simple_chain_lr() {
let mut g = Graph::new(Direction::LeftToRight);
g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
g.nodes.push(Node::new("C", "C", NodeShape::Rectangle));
g.edges.push(Edge::new("A", "B", None));
g.edges.push(Edge::new("B", "C", None));
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions["A"].0 < out.positions["B"].0);
assert!(out.positions["B"].0 < out.positions["C"].0);
}
#[test]
fn architecture_case_has_4_distinct_layers() {
let src = "graph LR\n App --> DB[(PostgreSQL)]\n App --> Cache[(Redis)]\n App --> Queue[(RabbitMQ)]\n Queue --> Worker[Worker]\n Worker --> DB";
let g = crate::parser::flowchart::parse(src).unwrap();
let out = sugiyama_layout(&g, &LayoutConfig::default());
let app_col = out.positions["App"].0;
let cache_col = out.positions["Cache"].0;
let queue_col = out.positions["Queue"].0;
let worker_col = out.positions["Worker"].0;
let db_col = out.positions["DB"].0;
assert!(
app_col < cache_col,
"App should precede Cache: {app_col} < {cache_col}"
);
assert_eq!(cache_col, queue_col, "Cache and Queue share a layer");
assert!(queue_col < worker_col, "Worker is its own layer");
assert!(worker_col < db_col, "DB is the rightmost layer");
}
#[test]
fn diamond_no_crossings() {
let mut g = Graph::new(Direction::TopToBottom);
for id in ["A", "B", "C", "D"] {
g.nodes.push(Node::new(id, id, NodeShape::Rectangle));
}
g.edges.push(Edge::new("A", "B", None));
g.edges.push(Edge::new("A", "C", None));
g.edges.push(Edge::new("B", "D", None));
g.edges.push(Edge::new("C", "D", None));
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions["A"].1 < out.positions["B"].1);
assert!(out.positions["A"].1 < out.positions["C"].1);
assert!(out.positions["B"].1 < out.positions["D"].1);
assert!(out.positions["C"].1 < out.positions["D"].1);
assert_eq!(out.positions["B"].1, out.positions["C"].1);
}
fn rect(id: &str) -> Node {
Node::new(id, id, NodeShape::Rectangle)
}
#[test]
fn subgraph_register_one_cluster() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::TopToBottom);
for id in ["X", "Y", "Z"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("X", "Y", None));
g.edges.push(Edge::new("Y", "Z", None));
let mut sg = Subgraph::new("S", "My Cluster");
sg.node_ids = vec!["X".into(), "Y".into(), "Z".into()];
g.subgraphs.push(sg);
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions.contains_key("X"), "X missing from positions");
assert!(out.positions.contains_key("Y"), "Y missing from positions");
assert!(out.positions.contains_key("Z"), "Z missing from positions");
let rx = out.positions["X"].1;
let ry = out.positions["Y"].1;
let rz = out.positions["Z"].1;
assert!(rx < ry, "X should be above Y: row {rx} < {ry}");
assert!(ry < rz, "Y should be above Z: row {ry} < {rz}");
let cx = out.positions["X"].0;
let cy = out.positions["Y"].0;
let cz = out.positions["Z"].0;
assert_eq!(
cx, cy,
"X and Y should share column in single-chain cluster"
);
assert_eq!(
cy, cz,
"Y and Z should share column in single-chain cluster"
);
}
#[test]
fn subgraph_register_two_sibling_clusters() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::LeftToRight);
for id in ["A1", "A2", "B1", "B2"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("A1", "A2", None));
g.edges.push(Edge::new("B1", "B2", None));
g.edges.push(Edge::new("A2", "B1", None));
let mut sga = Subgraph::new("SGA", "ClusterA");
sga.node_ids = vec!["A1".into(), "A2".into()];
let mut sgb = Subgraph::new("SGB", "ClusterB");
sgb.node_ids = vec!["B1".into(), "B2".into()];
g.subgraphs.push(sga);
g.subgraphs.push(sgb);
let out = sugiyama_layout(&g, &LayoutConfig::default());
for id in ["A1", "A2", "B1", "B2"] {
assert!(
out.positions.contains_key(id),
"{id} missing from positions"
);
}
assert!(
out.positions["A1"].0 < out.positions["A2"].0,
"A1 should be left of A2"
);
assert!(
out.positions["B1"].0 < out.positions["B2"].0,
"B1 should be left of B2"
);
let a_max_col = out.positions["A1"].0.max(out.positions["A2"].0);
let b_min_col = out.positions["B1"].0.min(out.positions["B2"].0);
assert!(
a_max_col < b_min_col,
"Cluster A's rightmost col ({a_max_col}) must be left of \
Cluster B's leftmost col ({b_min_col}) — clusters interleaved"
);
}
#[test]
fn subgraph_register_nested_clusters() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::TopToBottom);
for id in ["I1", "I2", "O1"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("I1", "I2", None));
g.edges.push(Edge::new("O1", "I1", None));
let mut inner = Subgraph::new("Inner", "Inner");
inner.node_ids = vec!["I1".into(), "I2".into()];
let mut outer = Subgraph::new("Outer", "Outer");
outer.node_ids = vec!["O1".into()];
outer.subgraph_ids = vec!["Inner".into()];
g.subgraphs.push(outer);
g.subgraphs.push(inner);
let out = sugiyama_layout(&g, &LayoutConfig::default());
for id in ["I1", "I2", "O1"] {
assert!(
out.positions.contains_key(id),
"{id} missing from positions"
);
}
let all_rows: Vec<usize> = ["I1", "I2", "O1"]
.iter()
.map(|id| out.positions[*id].1)
.collect();
let inner_rows: Vec<usize> = ["I1", "I2"].iter().map(|id| out.positions[*id].1).collect();
let outer_min = *all_rows.iter().min().unwrap();
let outer_max = *all_rows.iter().max().unwrap();
let inner_min = *inner_rows.iter().min().unwrap();
let inner_max = *inner_rows.iter().max().unwrap();
assert!(
outer_min <= inner_min,
"Outer min row ({outer_min}) must be <= Inner min row ({inner_min})"
);
assert!(
outer_max >= inner_max,
"Outer max row ({outer_max}) must be >= Inner max row ({inner_max})"
);
}
#[test]
fn single_subgraph_lr_sugiyama() {
let src = r#"graph LR
subgraph SG[My Group]
A-->B
end
B-->C"#;
let out = crate::render_with_options(
src,
&crate::RenderOptions {
backend: crate::layout::LayoutBackend::Sugiyama,
..Default::default()
},
)
.unwrap();
assert!(out.contains('A'), "node A missing from Sugiyama render");
assert!(out.contains('B'), "node B missing from Sugiyama render");
assert!(out.contains('C'), "node C missing from Sugiyama render");
assert!(
out.contains("My Group"),
"subgraph label missing from Sugiyama render:\n{out}"
);
insta::assert_snapshot!("single_subgraph_lr_sugiyama", out);
}
#[test]
fn nested_subgraphs_td_sugiyama() {
let src = r#"graph TD
subgraph Outer
subgraph Inner
A-->B
end
B-->C
end
C-->D"#;
let out = crate::render_with_options(
src,
&crate::RenderOptions {
backend: crate::layout::LayoutBackend::Sugiyama,
..Default::default()
},
)
.unwrap();
for node in ["A", "B", "C", "D"] {
assert!(
out.contains(node),
"node {node} missing from Sugiyama render"
);
}
assert!(out.contains("Outer"), "Outer label missing:\n{out}");
assert!(out.contains("Inner"), "Inner label missing:\n{out}");
insta::assert_snapshot!("nested_subgraphs_td_sugiyama", out);
}
#[test]
fn parallel_edges_two_styles_no_collision() {
let cfg = LayoutConfig::default();
let baseline_src = "graph LR\n T ==>|pass| D";
let g_base = crate::parser::flowchart::parse(baseline_src).unwrap();
let base_out = sugiyama_layout(&g_base, &cfg);
let base_gap = base_out.positions["D"]
.0
.saturating_sub(base_out.positions["T"].0);
let parallel_src = "graph LR\n T ==>|pass| D\n T -.->|skip| D";
let g_par = crate::parser::flowchart::parse(parallel_src).unwrap();
let par_out = sugiyama_layout(&g_par, &cfg);
for id in ["T", "D"] {
assert!(
par_out.positions.contains_key(id),
"{id} missing from positions"
);
}
let par_gap = par_out.positions["D"]
.0
.saturating_sub(par_out.positions["T"].0);
assert!(
par_gap > base_gap,
"parallel-edge gap T→D ({par_gap}) must exceed single-edge gap ({base_gap}); \
widening pass may have no-oped"
);
let expected_extra = "skip".len() + 2; assert_eq!(
par_gap.saturating_sub(base_gap),
expected_extra,
"gap delta should equal (count-1)*(max_lbl+2) = {expected_extra}"
);
}
#[test]
fn cicd_parallel_styles_to_same_target_sugiyama() {
let src = "graph LR
subgraph CI
L[Lint] ==> B[Build] ==> T[Test]
end
T ==>|pass| D[Deploy]
T -.->|skip| D";
let out = crate::render_with_options(
src,
&crate::RenderOptions {
backend: crate::layout::LayoutBackend::Sugiyama,
..Default::default()
},
)
.unwrap();
assert!(
out.contains("pass"),
"pass label missing from Sugiyama CI/CD render:\n{out}"
);
assert!(
out.contains("skip"),
"skip label missing from Sugiyama CI/CD render:\n{out}"
);
assert!(
!out.contains("│pass│"),
"pass label punctured subgraph border under Sugiyama:\n{out}"
);
insta::assert_snapshot!("cicd_parallel_styles_to_same_target_sugiyama", out);
}
#[test]
fn no_parallel_edges_widening_is_noop() {
let src = "graph LR\n A --> B\n B --> C\n C --> D";
let g = crate::parser::flowchart::parse(src).unwrap();
let out = sugiyama_layout(&g, &LayoutConfig::default());
let a = out.positions["A"].0;
let b = out.positions["B"].0;
let c = out.positions["C"].0;
let d = out.positions["D"].0;
assert!(a < b, "A must precede B: {a} < {b}");
assert!(b < c, "B must precede C: {b} < {c}");
assert!(c < d, "C must precede D: {c} < {d}");
assert!(
g.parallel_edge_groups().is_empty(),
"graph with no parallel edges should have empty groups"
);
}
#[test]
fn subgraph_direction_override_lr_in_tb() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::TopToBottom);
for id in ["A", "B", "C"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("A", "B", None));
g.edges.push(Edge::new("B", "C", None));
let mut sg = Subgraph::new("SG", "SG");
sg.direction = Some(Direction::LeftToRight);
sg.node_ids = vec!["A".into(), "B".into(), "C".into()];
g.subgraphs.push(sg);
let out = sugiyama_layout(&g, &LayoutConfig::default());
for id in ["A", "B", "C"] {
assert!(out.positions.contains_key(id), "{id} missing");
}
assert!(
out.positions["A"].0 < out.positions["B"].0,
"A must be left of B (LR override): {:?} {:?}",
out.positions["A"],
out.positions["B"]
);
assert!(
out.positions["B"].0 < out.positions["C"].0,
"B must be left of C (LR override): {:?} {:?}",
out.positions["B"],
out.positions["C"]
);
assert_eq!(
out.positions["A"].1, out.positions["B"].1,
"A and B should share the same row in LR override"
);
}
#[test]
fn subgraph_direction_override_tb_in_lr_supervisor_pattern() {
let src =
"graph LR\n subgraph X\n direction TB\n A-->B\n B-->C\n end";
let g = crate::parser::flowchart::parse(src).unwrap();
let out = sugiyama_layout(&g, &LayoutConfig::default());
for id in ["A", "B", "C"] {
assert!(out.positions.contains_key(id), "{id} missing");
}
assert!(
out.positions["A"].1 < out.positions["B"].1,
"A must be above B (TB override in LR): {:?} {:?}",
out.positions["A"],
out.positions["B"]
);
assert!(
out.positions["B"].1 < out.positions["C"].1,
"B must be above C (TB override in LR): {:?} {:?}",
out.positions["B"],
out.positions["C"]
);
assert_eq!(
out.positions["A"].0, out.positions["B"].0,
"A and B should share the same column in TB-in-LR override"
);
}
#[test]
fn subgraph_direction_override_same_axis_is_noop() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::LeftToRight);
for id in ["A", "B"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("A", "B", None));
let mut sg = Subgraph::new("SG", "SG");
sg.direction = Some(Direction::RightToLeft);
sg.node_ids = vec!["A".into(), "B".into()];
g.subgraphs.push(sg);
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions.contains_key("A"), "A missing");
assert!(out.positions.contains_key("B"), "B missing");
assert!(
out.positions["A"].0 < out.positions["B"].0,
"same-axis RL override must not transpose: A should still be left of B"
);
}
#[test]
fn subgraph_direction_override_nested_recursive() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::LeftToRight);
for id in ["A", "B", "C"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("A", "B", None));
g.edges.push(Edge::new("C", "A", None));
let mut inner = Subgraph::new("Inner", "Inner");
inner.direction = Some(Direction::LeftToRight);
inner.node_ids = vec!["A".into(), "B".into()];
let mut mid = Subgraph::new("Mid", "Mid");
mid.direction = Some(Direction::TopToBottom);
mid.node_ids = vec!["C".into()];
mid.subgraph_ids = vec!["Inner".into()];
g.subgraphs.push(mid);
g.subgraphs.push(inner);
let out = sugiyama_layout(&g, &LayoutConfig::default());
for id in ["A", "B", "C"] {
assert!(out.positions.contains_key(id), "{id} missing");
}
assert!(
out.positions["A"].0 < out.positions["B"].0,
"A must be left of B in LR-inside-TB override: {:?} {:?}",
out.positions["A"],
out.positions["B"]
);
assert_eq!(
out.positions["A"].1, out.positions["B"].1,
"A and B must share a row within the inner LR subgraph"
);
}
#[test]
fn perpendicular_subgraph_direction_sugiyama() {
let src = r#"graph LR
subgraph Sub
direction TD
X-->Y-->Z
end
A-->Sub"#;
let out = crate::render_with_options(
src,
&crate::RenderOptions {
backend: crate::layout::LayoutBackend::Sugiyama,
..Default::default()
},
)
.unwrap();
for node in ["X", "Y", "Z"] {
assert!(out.contains(node), "node {node} missing:\n{out}");
}
insta::assert_snapshot!("perpendicular_subgraph_direction_sugiyama", out);
}
#[test]
fn supervisor_bidirectional_in_subgraph_sugiyama() {
let src = "graph LR
subgraph Supervisor
direction TB
F[Factory] -->|creates| W[Worker]
W -->|panics| F
end
W -->|beat| HB[Heartbeat]
HB --> WD[Watchdog]";
let out = crate::render_with_options(
src,
&crate::RenderOptions {
backend: crate::layout::LayoutBackend::Sugiyama,
..Default::default()
},
)
.unwrap();
for node in ["Factory", "Worker", "Heartbeat", "Watchdog"] {
assert!(out.contains(node), "node {node} missing:\n{out}");
}
assert!(
!out.contains("└───panics┘") && !out.contains("└─────creates─────┘"),
"labels overwrote node border rows under Sugiyama:\n{out}"
);
insta::assert_snapshot!("supervisor_bidirectional_in_subgraph_sugiyama", out);
}
}