use pretty_assertions::assert_eq;
use ratatui::style::{Color, Style};
use super::{
art_from_layout, layout_canvas, Compartment, Direction, Edge, EdgeHead, Graph, GraphArt,
GraphError, GraphStyles, Node, NodeExtra, NodeStyle, RankOrdering, TextAlignment, WRAP_WIDTH,
};
#[test]
fn preserves_per_node_styles_and_node_geometry() {
let waiting = NodeStyle::new(
Style::default().fg(Color::Blue),
Style::default().fg(Color::Cyan),
);
let running = NodeStyle::new(
Style::default().fg(Color::Red),
Style::default().fg(Color::Yellow),
);
let graph = Graph::top_down(
vec![
Node::rectangular("Waiting node", waiting),
Node::rectangular("Running node", running),
],
vec![Edge::directed(0, 1)],
RankOrdering::PreserveInput,
)
.unwrap();
let art = graph.render(Style::default().fg(Color::DarkGray)).unwrap();
let waiting_text = text_style(&art.lines, "Waiting node");
let running_text = text_style(&art.lines, "Running node");
assert_eq!(waiting_text, waiting.text);
assert_eq!(running_text, running.text);
assert_eq!(
art.plain_lines
.iter()
.flat_map(|line| line.chars())
.filter(|character| *character == '▼')
.count(),
1
);
assert!(art
.lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| { span.style == waiting.border && span.content.chars().any(is_box_drawing) }));
assert!(art
.lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| { span.style == running.border && span.content.chars().any(is_box_drawing) }));
assert!(art.node_rects[0].y < art.node_rects[1].y);
}
#[test]
fn paints_zwj_graphemes_without_shifting_node_borders() {
let graph = Graph::top_down(
vec![Node::rectangular("👩\u{200d}💻", NodeStyle::default())],
Vec::new(),
RankOrdering::PreserveInput,
)
.unwrap();
let art = graph.render(Style::default()).unwrap();
assert_eq!(
art.plain_lines,
vec![
"┌────┐".to_owned(),
"│ 👩\u{200d}💻 │".to_owned(),
"└────┘".to_owned()
]
);
}
#[test]
fn paints_self_loop_endpoint_decorations() {
let mut edge = Edge::directed(0, 0);
edge.head_to = EdgeHead::None;
edge.head_from = EdgeHead::Circle;
let graph = Graph::top_down(
vec![Node::rectangular("Node", NodeStyle::default())],
vec![edge],
RankOrdering::PreserveInput,
)
.unwrap();
let art = graph.render(Style::default()).unwrap();
assert_eq!(
art.plain_lines,
vec![
"┌──────┐".to_owned(),
"│ Node │".to_owned(),
"└────o─┘".to_owned(),
" ││".to_owned(),
" ╰╯".to_owned(),
]
);
}
#[test]
fn aligns_each_compartment_by_its_model() {
let graph = Graph::top_down(
vec![Node::rectangular("unused", NodeStyle::default())],
Vec::new(),
RankOrdering::PreserveInput,
)
.unwrap();
let extras = [NodeExtra::Compartments(vec![
Compartment {
lines: Vec::new(),
alignment: TextAlignment::Left,
},
Compartment {
lines: vec!["T".to_owned()],
alignment: TextAlignment::Center,
},
Compartment {
lines: vec!["field".to_owned()],
alignment: TextAlignment::Left,
},
])];
let layout = layout_canvas(&graph, &extras, None, WRAP_WIDTH).unwrap();
let styles = GraphStyles::for_nodes(&graph.nodes, Style::default());
let art = art_from_layout(&graph, layout, &styles);
assert_eq!(
art.plain_lines,
vec![
" ┌───────┐".to_owned(),
" │ T │".to_owned(),
" ├───────┤".to_owned(),
" │ field │".to_owned(),
" └───────┘".to_owned(),
]
);
}
#[test]
fn renders_an_empty_graph_as_empty_art() {
let art = Graph::top_down(Vec::new(), Vec::new(), RankOrdering::PreserveInput)
.unwrap()
.render(Style::default())
.unwrap();
assert_eq!(
art,
GraphArt {
lines: Vec::new(),
plain_lines: Vec::new(),
width: 0,
height: 0,
node_rects: Vec::new(),
}
);
}
#[test]
fn applies_the_requested_rank_ordering_policy() {
let nodes = vec![
Node::rectangular("A", NodeStyle::default()),
Node::rectangular("B", NodeStyle::default()),
Node::rectangular("X", NodeStyle::default()),
Node::rectangular("Y", NodeStyle::default()),
];
let edges = vec![Edge::directed(0, 3), Edge::directed(1, 2)];
let preserved = Graph::top_down(nodes.clone(), edges.clone(), RankOrdering::PreserveInput)
.unwrap()
.render(Style::default())
.unwrap();
let minimized = Graph::top_down(nodes, edges, RankOrdering::MinimizeCrossings)
.unwrap()
.render(Style::default())
.unwrap();
assert!(preserved.node_rects[2].x < preserved.node_rects[3].x);
assert!(minimized.node_rects[3].x < minimized.node_rects[2].x);
}
#[test]
fn rejects_invalid_edge_endpoints() {
let error = Graph::top_down(
vec![Node::rectangular("only", NodeStyle::default())],
vec![Edge::directed(0, 1)],
RankOrdering::PreserveInput,
)
.unwrap_err();
assert_eq!(error, GraphError::InvalidEdgeEndpoint);
}
fn text_style(lines: &[ratatui::text::Line<'_>], needle: &str) -> Style {
lines
.iter()
.flat_map(|line| &line.spans)
.find(|span| span.content == needle)
.map(|span| span.style)
.expect("node label is rendered")
}
fn is_box_drawing(character: char) -> bool {
matches!(character, '┌' | '┐' | '└' | '┘' | '─' | '│')
}
#[test]
fn fan_in_edges_share_one_bus_row_per_target() {
let style = NodeStyle::default();
let nodes = vec![
Node::rectangular("first", style),
Node::rectangular("second", style),
Node::rectangular("left", style),
Node::rectangular("right", style),
];
let edges = vec![
Edge::directed(0, 2),
Edge::directed(1, 2),
Edge::directed(0, 3),
Edge::directed(1, 3),
];
let graph = Graph::top_down(nodes, edges, RankOrdering::PreserveInput).unwrap();
let art = graph.render(Style::default()).unwrap();
assert_eq!(
art.plain_lines,
vec![
" ┌───────┐ ┌────────┐".to_owned(),
" │ first │ │ second │".to_owned(),
" └───┬───┘ └────┬───┘".to_owned(),
" ├───────────┤".to_owned(),
" ▼ ▼".to_owned(),
" ┌──────┐ ┌───────┐".to_owned(),
" │ left │ │ right │".to_owned(),
" └──────┘ └───────┘".to_owned(),
]
);
}
#[test]
fn rank_skipping_edge_drops_into_the_target_from_above() {
let style = NodeStyle::default();
let nodes = vec![
Node::rectangular("setup", style),
Node::rectangular("review", style),
Node::rectangular("apply", style),
Node::rectangular("skip", style),
];
let edges = vec![
Edge::directed(0, 1),
Edge::directed(1, 2),
Edge::directed(1, 3),
Edge::directed(0, 3),
];
let graph = Graph::top_down(nodes, edges, RankOrdering::PreserveInput).unwrap();
let art = graph.render(Style::default()).unwrap();
assert!(art
.plain_lines
.iter()
.all(|line| !line.contains('\u{25c4}')));
assert_eq!(
art.plain_lines,
vec![
" ┌───────┐".to_owned(),
" │ setup │".to_owned(),
" └───┬───┘".to_owned(),
" ┌──┴───────┐".to_owned(),
" ▼ │".to_owned(),
" ┌────────┐ │".to_owned(),
" │ review │ │".to_owned(),
" └────┬───┘ │".to_owned(),
" ┌────┤ │".to_owned(),
" │ └─────┬────┘".to_owned(),
" ▼ ▼".to_owned(),
" ┌───────┐ ┌──────┐".to_owned(),
" │ apply │ │ skip │".to_owned(),
" └───────┘ └──────┘".to_owned(),
]
);
}
#[test]
fn skip_edges_join_the_shared_fan_in_bus_row() {
let style = NodeStyle::default();
let nodes = vec![
Node::rectangular("collect", style),
Node::rectangular("boundaries", style),
Node::rectangular("spaghetti", style),
Node::rectangular("structure", style),
Node::rectangular("apply", style),
Node::rectangular("none", style),
];
let edges = vec![
Edge::directed(0, 1),
Edge::directed(0, 2),
Edge::directed(0, 3),
Edge::directed(1, 4),
Edge::directed(2, 4),
Edge::directed(3, 4),
Edge::directed(0, 4),
Edge::directed(1, 5),
Edge::directed(2, 5),
Edge::directed(3, 5),
Edge::directed(0, 5),
];
let graph = Graph::top_down(nodes, edges, RankOrdering::PreserveInput).unwrap();
let art = graph.render(Style::default()).unwrap();
assert_eq!(
art.plain_lines,
vec![
" ┌─────────┐".to_owned(),
" │ collect │".to_owned(),
" └─────┬───┘".to_owned(),
" ┌────────────────┼───────────────┬───────┐".to_owned(),
" ▼ ▼ ▼ │".to_owned(),
"┌────────────┐ ┌───────────┐ ┌───────────┐ │".to_owned(),
"│ boundaries │ │ spaghetti │ │ structure │ │".to_owned(),
"└──────┬─────┘ └─────┬─────┘ └─────┬─────┘ │".to_owned(),
" └──────────┬─────┴────┬──────────┴───────┘".to_owned(),
" ▼ ▼".to_owned(),
" ┌───────┐ ┌──────┐".to_owned(),
" │ apply │ │ none │".to_owned(),
" └───────┘ └──────┘".to_owned(),
]
);
}
#[test]
fn skip_and_back_edges_use_opposite_side_lanes() {
let style = NodeStyle::default();
let graph = Graph::top_down(
vec![
Node::rectangular("start", style),
Node::rectangular("mid", style),
Node::rectangular("end", style),
],
vec![
Edge::directed(0, 1),
Edge::directed(1, 2),
Edge::directed(0, 2),
labeled_edge(2, 1, "no"),
],
RankOrdering::PreserveInput,
)
.unwrap();
let art = graph.render(Style::default()).unwrap();
assert_eq!(
art.plain_lines,
vec![
" ┌───────┐".to_owned(),
" │ start │".to_owned(),
" └───┬───┘".to_owned(),
" ├─────┐".to_owned(),
" ▼ │".to_owned(),
" no ┌─────┐ │".to_owned(),
"┌─────▶│ mid │ │".to_owned(),
"│ └──┬──┘ │".to_owned(),
"│ ├─────┘".to_owned(),
"│ ▼".to_owned(),
"│ ┌─────┐".to_owned(),
"└──────┤ end │".to_owned(),
" └─────┘".to_owned(),
]
);
let lr = Graph::from_parts(
vec![
Node::rectangular("start", style),
Node::rectangular("mid", style),
Node::rectangular("end", style),
],
vec![
Edge::directed(0, 1),
Edge::directed(1, 2),
Edge::directed(0, 2),
labeled_edge(2, 1, "no"),
],
Direction::LeftRight,
RankOrdering::PreserveInput,
)
.unwrap();
assert_eq!(
lr.render(Style::default()).unwrap().plain_lines,
vec![
" ┌──────────┐ no".to_owned(),
" │ │".to_owned(),
" ▼ │".to_owned(),
"┌───────┐ ┌─────┐ ┌──┴──┐".to_owned(),
"│ start ├───▶│ mid ├───▶│ end │".to_owned(),
"└───┬───┘ └─────┘ └─────┘".to_owned(),
" │ ▲".to_owned(),
" └──────────────────────┘".to_owned(),
]
);
}
#[test]
fn nested_skips_use_outer_lane_for_the_longer_span() {
let style = NodeStyle::default();
let graph = Graph::top_down(
vec![
Node::rectangular("A", style),
Node::rectangular("B", style),
Node::rectangular("M", style),
Node::rectangular("C", style),
Node::rectangular("D", style),
],
vec![
Edge::directed(0, 1),
Edge::directed(1, 2),
Edge::directed(2, 3),
Edge::directed(3, 4),
Edge::directed(0, 4),
Edge::directed(1, 3),
],
RankOrdering::PreserveInput,
)
.unwrap();
let art = graph.render(Style::default()).unwrap();
assert_eq!(
art.plain_lines,
vec![
" ┌───┐".to_owned(),
" │ A │".to_owned(),
" └─┬─┘".to_owned(),
" ├────┐".to_owned(),
" ▼ │".to_owned(),
" ┌───┐ │".to_owned(),
" │ B │ │".to_owned(),
" └─┬─┘ │".to_owned(),
" ├───┐│".to_owned(),
" ▼ ││".to_owned(),
" ┌───┐ ││".to_owned(),
" │ M │ ││".to_owned(),
" └─┬─┘ ││".to_owned(),
" ├───┘│".to_owned(),
" ▼ │".to_owned(),
" ┌───┐ │".to_owned(),
" │ C │ │".to_owned(),
" └─┬─┘ │".to_owned(),
" ├────┘".to_owned(),
" ▼".to_owned(),
" ┌───┐".to_owned(),
" │ D │".to_owned(),
" └───┘".to_owned(),
]
);
let lr = Graph::from_parts(
vec![
Node::rectangular("A", style),
Node::rectangular("B", style),
Node::rectangular("M", style),
Node::rectangular("C", style),
Node::rectangular("D", style),
],
vec![
Edge::directed(0, 1),
Edge::directed(1, 2),
Edge::directed(2, 3),
Edge::directed(3, 4),
Edge::directed(0, 4),
Edge::directed(1, 3),
],
Direction::LeftRight,
RankOrdering::PreserveInput,
)
.unwrap();
assert_eq!(
lr.render(Style::default()).unwrap().plain_lines,
vec![
"".to_owned(),
"┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐".to_owned(),
"│ A ├───▶│ B ├───▶│ M ├───▶│ C ├───▶│ D │".to_owned(),
"└─┬─┘ └─┬─┘ └───┘ └───┘ └───┘".to_owned(),
" │ │ ▲ ▲".to_owned(),
" │ └─────────────────┘ │".to_owned(),
" └───────────────────────────────────┘".to_owned(),
]
);
}
const WRAPPED_EDGE_LABEL: &str = "when the renderer reports a width failure";
fn labeled_edge(from: usize, to: usize, label: &str) -> Edge {
Edge {
label: Some(label.to_owned()),
..Edge::directed(from, to)
}
}
fn art_text(graph: &Graph) -> String {
graph
.render(Style::default())
.unwrap()
.plain_lines
.join("\n")
}
fn assert_wrapped_label_visible(art: &str) {
assert!(
art.contains("when the renderer") && art.contains("width failure"),
"expected every wrapped word to remain visible:\n{art}"
);
}
#[test]
fn wraps_lr_forward_edge_labels_without_dropping_words() {
let style = NodeStyle::default();
let graph = Graph::from_parts(
vec![
Node::rectangular("start", style),
Node::rectangular("top", style),
Node::rectangular("end", style),
],
vec![Edge::directed(0, 1), labeled_edge(0, 2, WRAPPED_EDGE_LABEL)],
Direction::LeftRight,
RankOrdering::PreserveInput,
)
.unwrap();
assert_wrapped_label_visible(&art_text(&graph));
}
#[test]
fn wraps_td_back_edge_labels_without_dropping_words() {
let style = NodeStyle::default();
let graph = Graph::from_parts(
vec![
Node::rectangular("start", style),
Node::rectangular("end", style),
],
vec![Edge::directed(0, 1), labeled_edge(1, 0, WRAPPED_EDGE_LABEL)],
Direction::TopDown,
RankOrdering::PreserveInput,
)
.unwrap();
assert_wrapped_label_visible(&art_text(&graph));
}
#[test]
fn wraps_lr_back_edge_labels_without_dropping_words() {
let style = NodeStyle::default();
let graph = Graph::from_parts(
vec![
Node::rectangular("start", style),
Node::rectangular("end", style),
],
vec![Edge::directed(0, 1), labeled_edge(1, 0, WRAPPED_EDGE_LABEL)],
Direction::LeftRight,
RankOrdering::PreserveInput,
)
.unwrap();
assert_wrapped_label_visible(&art_text(&graph));
}