use std::collections::{HashMap, HashSet};
use std::path::{Path as FsPath, PathBuf};
use resvg::usvg;
use super::clusters;
use super::edges;
use super::labels::Label;
use super::shapes::{self, Glyph, Size};
use super::svg::num;
use super::theme::{self, Theme};
use super::{
lay_out, lay_out_curve, lay_out_flow, orthogonal, render, render_curve, render_flow, Curve,
Diagram, PlacedCluster, PlacedEdge, PlacedNode, RenderError, Tip, MARGIN,
};
use crate::preview::mermaid::flowchart::{parse, Arrow, Shape, Stroke};
use crate::preview::mermaid::layout::Point;
use crate::preview::mermaid::text_metrics;
use crate::preview::svg::shared_fontdb;
pub(super) const CORPUS: &[(&str, &str)] = &[
(
"branch",
"flowchart TD\n A[Start] --> B{Ready?}\n B -->|yes| C([Ship])\n B -->|no| D[Fix it]\n D --> B",
),
(
"shapes",
"flowchart TD\n a[rect] --> b(round)\n b --> c([stadium])\n c --> d[[sub]]\n \
d --> e[(store)]\n e --> f((circle))\n f --> g{diamond}\n g --> h{{hex}}\n \
h --> i>odd]\n i --> j[/lean/]\n j --> k[\\lean back\\]\n k --> l[/trap\\]\n \
l --> m[\\inv trap/]",
),
(
"strokes",
"flowchart LR\n A --- B\n B -.-> C\n C ==> D\n D --o E\n E --x F\n A <--> F\n \
C ~~~ E",
),
(
"cjk",
"flowchart TD\n A[ツリー] -->|Enter| B{種別を解決}\n B -->|画像| C[全画面プレビュー]\n \
B -->|テキスト| D[窓読み]\n D --> A",
),
(
"long-edge",
"flowchart TD\n A --> B --> C --> D --> E\n A --> E\n E --> B\n C --> A",
),
(
"left-right",
"flowchart LR\n Parse -- tokens --> Layout -- boxes --> Draw\n Draw --> Parse",
),
(
"bottom-top",
"flowchart BT\n leaf1 --> mid\n leaf2 --> mid\n mid --> root",
),
(
"right-left",
"flowchart RL\n A[one] --> B[two]\n B --> C[three]",
),
(
"multiline",
"flowchart TD\n A[first line<br>second line<br>third] --> B[\"one\\ntwo\"]",
),
(
"self-loop",
"flowchart TD\n A[retry] --> A\n A --> B[done]",
),
(
"subgraph",
"flowchart TD\n subgraph one [Group]\n A --> B\n end\n B --> C\n C --> A",
),
(
"subgraph-nested",
"flowchart TD\n subgraph outer [Outer]\n subgraph inner [Inner]\n A --> B\n end\n B --> C\n end\n C --> D",
),
(
"subgraph-siblings",
"flowchart LR\n subgraph left [Read]\n A --> B\n end\n subgraph right [Write]\n C --> D\n end\n B --> C",
),
(
"subgraph-bypass",
"flowchart LR\n subgraph one [Middle]\n A --> B\n end\n X --> Y\n X --> A\n B --> Y",
),
(
"subgraph-endpoint",
"flowchart LR\n subgraph one [First]\n A --> B\n end\n subgraph two [Second]\n C --> D\n end\n one --> two\n E --> one",
),
(
"subgraph-direction",
"flowchart LR\n subgraph one [Steps]\n direction TB\n A --> B --> C\n end\n one --> D",
),
(
"subgraph-cjk",
"flowchart TD\n subgraph proc [プレビュー解決]\n A[種別] --> B[レンダラ]\n end\n B --> C[全画面表示]",
),
(
"subgraph-long-title",
"flowchart TD\n subgraph one [resolve the preview kind and delegate it]\n A --> B\n end\n subgraph two [b]\n C --> D\n end\n B --> C",
),
(
"subgraph-tall-title",
"flowchart TD\n subgraph one [Resolve the kind<br>and delegate]\n A --> B\n end\n B --> C",
),
(
"subgraph-untitled",
"flowchart TD\n subgraph\n A --> B\n end\n B --> C",
),
(
"amp-chain",
"flowchart LR\n A & B --> C & D\n C --> E",
),
(
"length",
"flowchart TD\n A ---> B\n A --> C\n B --> D\n C --> D",
),
(
"link-style-multi-index",
"flowchart TD\n A --> B\n B --> C\n C --> D\n D --> E\n \
linkStyle 0,1,2 stroke:#1f6feb\n linkStyle 3 stroke:#d4a017",
),
(
"classdef-and-style-cascade",
"flowchart TD\n classDef default fill:#223,stroke:#556\n \
classDef hot fill:#f9f,stroke:#a00\n A:::hot --> B\n style A fill:#0f0",
),
];
const LABELS: &[(&str, &str)] = &[
("ascii", "Start"),
("kerning", "AVATAR To Wa"),
("thin", "illicit lilli"),
("cjk", "全画面プレビュー"),
("cjk-punct", "開始、処理。「確認」"),
("emoji", "build 🚀 ship"),
(
"long",
"resolve the preview kind and hand it to the right renderer",
),
(
"mixed",
"konoma のプレビュー preview を全画面 fullscreen で",
),
("digits", "0123456789"),
("one", "x"),
("double-space", "loop Every minute"),
("edge-space", " padded out "),
];
fn laid_out(src: &str) -> Diagram {
let chart = parse(src).unwrap_or_else(|e| panic!("corpus source must parse: {e}"));
lay_out(&chart).unwrap_or_else(|e| panic!("corpus source must lay out: {e}"))
}
fn laid_out_curve(src: &str, curve: &str) -> Diagram {
let chart = parse(src).unwrap_or_else(|e| panic!("corpus source must parse: {e}"));
lay_out_curve(&chart, curve).unwrap_or_else(|e| panic!("corpus source must lay out: {e}"))
}
fn laid_out_flow(src: &str, curve: &str, routing: &str) -> Diagram {
let chart = parse(src).unwrap_or_else(|e| panic!("corpus source must parse: {e}"));
lay_out_flow(&chart, curve, routing)
.unwrap_or_else(|e| panic!("corpus source must lay out: {e}"))
}
fn placed_node(id: &str, cx: f64, cy: f64, w: f64, h: f64) -> PlacedNode {
PlacedNode {
id: id.to_string(),
shape: Glyph::default(),
center: Point::new(cx, cy),
size: Size::new(w, h),
label: Label::measure(""),
panel: None,
series: None,
mark: None,
style: None,
}
}
pub(super) fn tree_of(svg: &str) -> usvg::Tree {
let opt = usvg::Options {
fontdb: shared_fontdb(),
..usvg::Options::default()
};
usvg::Tree::from_data(svg.as_bytes(), &opt).expect("emitted SVG parses")
}
pub(super) fn text_widths(group: &usvg::Group, out: &mut Vec<f32>) {
for node in group.children() {
match node {
usvg::Node::Text(t) => out.push(t.bounding_box().width()),
usvg::Node::Group(g) => text_widths(g, out),
_ => {}
}
}
}
pub(super) fn measured_texts(group: &usvg::Group, out: &mut Vec<(String, f64)>) {
for node in group.children() {
match node {
usvg::Node::Text(t) => {
let s: String = t.chunks().iter().map(|c| c.text().to_string()).collect();
out.push((s, t.bounding_box().width() as f64));
}
usvg::Node::Group(g) => measured_texts(g, out),
_ => {}
}
}
}
pub(super) fn path_boxes(group: &usvg::Group, out: &mut Vec<usvg::Rect>) {
for node in group.children() {
match node {
usvg::Node::Path(p) => out.push(p.bounding_box()),
usvg::Node::Group(g) => path_boxes(g, out),
_ => {}
}
}
}
pub(super) fn dist_to_segment(p: &Point, a: &Point, b: &Point) -> f64 {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let len2 = dx * dx + dy * dy;
if len2 == 0.0 {
return (p.x - a.x).hypot(p.y - a.y);
}
let t = (((p.x - a.x) * dx + (p.y - a.y) * dy) / len2).clamp(0.0, 1.0);
(p.x - (a.x + t * dx)).hypot(p.y - (a.y + t * dy))
}
pub(super) fn dist_to_polyline(p: &Point, line: &[Point]) -> f64 {
line.windows(2)
.map(|w| dist_to_segment(p, &w[0], &w[1]))
.fold(f64::INFINITY, f64::min)
}
pub(super) fn boundary(node: &PlacedNode) -> Vec<Point> {
let (w, h) = (node.size.w, node.size.h);
let (cx, cy) = (node.center.x, node.center.y);
let local = shapes::polygon(node.shape, node.size);
if !local.is_empty() {
return local
.into_iter()
.map(|p| Point::new(cx + p.x, cy + p.y))
.collect();
}
match node.shape {
Glyph::Flow(Shape::Circle)
| Glyph::Flow(Shape::DoubleCircle)
| Glyph::StateStart
| Glyph::StateEnd => {
let r = w / 2.0;
(0..256)
.map(|i| {
let t = std::f64::consts::TAU * i as f64 / 256.0;
Point::new(cx + r * t.cos(), cy + r * t.sin())
})
.collect()
}
_ => vec![
Point::new(cx - w / 2.0, cy - h / 2.0),
Point::new(cx + w / 2.0, cy - h / 2.0),
Point::new(cx + w / 2.0, cy + h / 2.0),
Point::new(cx - w / 2.0, cy + h / 2.0),
],
}
}
fn flow_size(shape: Shape, label: Size) -> Size {
shapes::size(Glyph::Flow(shape), label)
}
pub(super) fn dist_to_boundary(p: &Point, poly: &[Point]) -> f64 {
let n = poly.len();
(0..n)
.map(|i| dist_to_segment(p, &poly[i], &poly[(i + 1) % n]))
.fold(f64::INFINITY, f64::min)
}
pub(super) fn inside(p: &Point, poly: &[Point]) -> bool {
let n = poly.len();
let mut hit = false;
let mut j = n - 1;
for i in 0..n {
let (a, b) = (&poly[i], &poly[j]);
if (a.y > p.y) != (b.y > p.y) {
let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
if p.x < x {
hit = !hit;
}
}
j = i;
}
hit
}
pub(super) fn depth(p: &Point, poly: &[Point]) -> f64 {
if inside(p, poly) {
dist_to_boundary(p, poly)
} else {
0.0
}
}
fn tree_of_src(src: &str) -> clusters::Tree {
let chart = parse(src).expect("parses");
let ids: HashSet<String> = chart.nodes.iter().map(|n| n.id.clone()).collect();
clusters::Tree::build(&chart, |id| ids.contains(id))
}
pub(super) fn rect_overlap(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> (f64, f64) {
(a.2.min(b.2) - a.0.max(b.0), a.3.min(b.3) - a.1.max(b.1))
}
pub(super) fn escapes(inner: (f64, f64, f64, f64), outer: (f64, f64, f64, f64)) -> f64 {
(outer.0 - inner.0)
.max(outer.1 - inner.1)
.max(inner.2 - outer.2)
.max(inner.3 - outer.3)
}
pub(super) fn polyline_depth_in_rect(line: &[Point], rect: (f64, f64, f64, f64)) -> f64 {
let depth_at = |p: &Point| {
let d = (p.x - rect.0)
.min(rect.2 - p.x)
.min(p.y - rect.1)
.min(rect.3 - p.y);
d.max(0.0)
};
let mut worst: f64 = 0.0;
for w in line.windows(2) {
let len = (w[1].x - w[0].x).hypot(w[1].y - w[0].y);
let steps = (len / 0.5).ceil().max(1.0) as usize;
for i in 0..=steps {
let t = i as f64 / steps as f64;
let p = Point::new(
w[0].x + t * (w[1].x - w[0].x),
w[0].y + t * (w[1].y - w[0].y),
);
worst = worst.max(depth_at(&p));
}
}
worst
}
pub(super) fn box_overlap(a: &PlacedNode, b: &PlacedNode) -> (f64, f64) {
let (al, at, ar, ab) = a.bounds();
let (bl, bt, br, bb) = b.bounds();
(ar.min(br) - al.max(bl), ab.min(bb) - at.max(bt))
}
pub(super) fn mask_numbers(svg: &str) -> String {
const NUMERIC: &[&str] = &[
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"width",
"height",
"rx",
"ry",
"cx",
"cy",
"r",
"d",
"points",
"stroke-width",
"dy",
"font-size",
"stroke-dasharray",
"viewBox",
];
let numeric: HashSet<&str> = NUMERIC.iter().copied().collect();
let mut out = String::with_capacity(svg.len());
let bytes: Vec<char> = svg.chars().collect();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == '=' && i + 1 < bytes.len() && bytes[i + 1] == '"' {
let mut start = i;
while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == '-')
{
start -= 1;
}
let name: String = bytes[start..i].iter().collect();
if numeric.contains(name.as_str()) {
out.push_str("=\"");
let mut j = i + 2;
let mut in_number = false;
while j < bytes.len() && bytes[j] != '"' {
let c = bytes[j];
if c.is_ascii_digit() || c == '.' || (c == '-' && !in_number) {
if !in_number {
out.push('#');
in_number = true;
}
} else {
in_number = false;
out.push(c);
}
j += 1;
}
out.push('"');
i = j + 1;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
out
}
pub(super) fn routing_signature(svg: &str) -> String {
let masked = mask_numbers(svg);
let mut out = String::new();
let chars: Vec<char> = masked.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '=' && i + 1 < chars.len() && chars[i + 1] == '"' {
let mut start = i;
while start > 0 && (chars[start - 1].is_ascii_alphanumeric() || chars[start - 1] == '-')
{
start -= 1;
}
let name: String = chars[start..i].iter().collect();
if matches!(name.as_str(), "d" | "fill" | "stroke") {
let mut j = i + 2;
while j < chars.len() && chars[j] != '"' {
j += 1;
}
let value: String = chars[i + 2..j].iter().collect();
out.push_str(&name);
out.push('=');
out.push_str(&value);
out.push('\n');
i = j + 1;
continue;
}
}
i += 1;
}
out
}
pub(super) fn snapshot_path(name: &str) -> PathBuf {
FsPath::new(env!("CARGO_MANIFEST_DIR"))
.join("snapshots")
.join(format!("{name}.snap"))
}
pub(super) fn assert_snapshot(name: &str, actual: &str) {
let path = snapshot_path(name);
if std::env::var_os("KONOMA_UPDATE_SNAPSHOTS").is_some() {
std::fs::create_dir_all(path.parent().expect("snapshot dir")).expect("create snapshots/");
std::fs::write(&path, actual).expect("write snapshot");
eprintln!("mermaid snapshot: wrote {}", path.display());
return;
}
let Ok(expected) = std::fs::read_to_string(&path) else {
eprintln!(
"mermaid snapshot: {} not found — skipping (published-crate build?); \
regenerate with `KONOMA_UPDATE_SNAPSHOTS=1 cargo test`",
path.display()
);
return;
};
if expected == actual {
return;
}
let (el, al): (Vec<&str>, Vec<&str>) = (expected.lines().collect(), actual.lines().collect());
let mut i = 0;
while i < el.len().min(al.len()) && el[i] == al[i] {
i += 1;
}
let lo = i.saturating_sub(2);
panic!(
"mermaid snapshot {name} drifted at line {i}\n--- expected ---\n{}\n--- actual ---\n{}\n\
Re-run with KONOMA_UPDATE_SNAPSHOTS=1 and inspect the diff if this is intentional.",
el[lo..(i + 3).min(el.len())].join("\n"),
al[lo..(i + 3).min(al.len())].join("\n"),
);
}
#[test]
fn box_width_matches_resvg() {
if !text_metrics::fonts_available() {
eprintln!("no sans-serif face — skipping (the renderer refuses to draw here too)");
return;
}
let expected_padding = shapes::PADDING * 4.0;
for (name, label) in LABELS {
let src = format!("flowchart TD\n A[\"{label}\"]");
let diagram = laid_out(&src);
let svg = render(&src, "dark").expect("renders");
let node = &diagram.nodes[0];
let mut widths = Vec::new();
text_widths(tree_of(&svg).root(), &mut widths);
assert_eq!(
widths.len(),
1,
"{name}: exactly one <text> should survive usvg (a dropped one means the font \
resolution konoma measured with is not the one resvg drew with)"
);
let drawn = widths[0] as f64;
let slack = node.size.w - drawn;
assert!(
(slack - expected_padding).abs() <= 1.0,
"{name} {label:?}: box is {} wide, resvg drew the text {} wide → {} of padding, \
but the shape declares {}",
num(node.size.w),
num(drawn),
num(slack),
num(expected_padding)
);
let mut boxes = Vec::new();
path_boxes(tree_of(&svg).root(), &mut boxes);
assert!(
boxes
.iter()
.any(|b| (b.width() as f64 - node.size.w).abs() < 0.01 && b.x() > 0.0),
"{name}: no drawn path has the width the model declares ({})",
num(node.size.w)
);
}
}
#[test]
fn edge_label_box_matches_resvg() {
if !text_metrics::fonts_available() {
return;
}
let src = "flowchart TD\n A[a] -->|全画面プレビュー| B[b]";
let diagram = laid_out(src);
let svg = render(src, "dark").expect("renders");
let label = diagram.edges[0]
.label
.as_ref()
.expect("edge carries a label");
let mut widths = Vec::new();
text_widths(tree_of(&svg).root(), &mut widths);
let drawn = widths.iter().copied().fold(0.0_f32, f32::max) as f64;
let slack = label.size.w - drawn;
assert!(
(slack - super::LABEL_PAD_X * 2.0).abs() <= 1.0,
"edge label box {} vs resvg's {} → {} of padding, declared {}",
num(label.size.w),
num(drawn),
num(slack),
num(super::LABEL_PAD_X * 2.0)
);
}
#[test]
fn multiline_label_is_measured_per_line() {
if !text_metrics::fonts_available() {
return;
}
let one = Label::measure("short");
let three = Label::measure("short\nconsiderably longer line\nmid");
assert_eq!(three.lines.len(), 3);
assert!((three.height - one.height * 3.0).abs() < 1e-9);
assert!(three.width > one.width);
assert!((three.width - Label::measure("considerably longer line").width).abs() < 1e-9);
}
pub(super) fn check_nodes_do_not_overlap(name: &str, d: &Diagram) {
for i in 0..d.nodes.len() {
for j in (i + 1)..d.nodes.len() {
let (dx, dy) = box_overlap(&d.nodes[i], &d.nodes[j]);
assert!(
dx <= 0.01 || dy <= 0.01,
"{name}: {} and {} overlap by {}x{}",
d.nodes[i].id,
d.nodes[j].id,
num(dx),
num(dy)
);
}
}
}
#[test]
fn invariant_nodes_do_not_overlap() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_nodes_do_not_overlap(name, &laid_out(src));
}
}
pub(super) fn check_edges_stay_out_of_shapes(name: &str, d: &Diagram) {
let outlines: Vec<Vec<Point>> = d.nodes.iter().map(boundary).collect();
for e in &d.edges {
if d.cluster(&e.from).is_some() || d.cluster(&e.to).is_some() {
continue;
}
let is_note = |id: &str| d.node(id).is_some_and(|n| n.shape == Glyph::Note);
if is_note(&e.from) || is_note(&e.to) {
continue;
}
let line = e.drawn_points();
for w in line.windows(2) {
let len = (w[1].x - w[0].x).hypot(w[1].y - w[0].y);
let steps = ((len * 2.0).ceil() as usize).clamp(1, 4000);
for s in 0..=steps {
let t = s as f64 / steps as f64;
let p = Point::new(
w[0].x + t * (w[1].x - w[0].x),
w[0].y + t * (w[1].y - w[0].y),
);
for (node, poly) in d.nodes.iter().zip(&outlines) {
let inside_by = depth(&p, poly);
assert!(
inside_by <= 1.0,
"{name}: edge {}->{} runs {}px inside {} at ({}, {})",
e.from,
e.to,
num(inside_by),
node.id,
num(p.x),
num(p.y)
);
}
}
}
}
}
#[test]
fn invariant_edges_stay_out_of_shapes() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_edges_stay_out_of_shapes(name, &laid_out(src));
}
}
pub(super) fn check_labels_ride_their_edge(name: &str, d: &Diagram) {
for e in &d.edges {
let Some(label) = &e.label else { continue };
let off = dist_to_polyline(&label.center, &e.points);
assert!(
off <= 0.5,
"{name}: label {:?} on {}->{} sits {}px off its own line",
label.label.lines.join(" "),
e.from,
e.to,
num(off)
);
for n in &d.nodes {
let (nl, nt, nr, nb) = n.bounds();
let (ll, lt, lr, lb) = (
label.center.x - label.size.w / 2.0,
label.center.y - label.size.h / 2.0,
label.center.x + label.size.w / 2.0,
label.center.y + label.size.h / 2.0,
);
let (dx, dy) = (nr.min(lr) - nl.max(ll), nb.min(lb) - nt.max(lt));
assert!(
dx <= 0.01 || dy <= 0.01,
"{name}: label {:?} overlaps node {} by {}x{}",
label.label.lines.join(" "),
n.id,
num(dx),
num(dy)
);
}
}
}
#[test]
fn invariant_labels_ride_their_edge_and_clear_every_node() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_labels_ride_their_edge(name, &laid_out(src));
}
}
pub(super) fn check_endpoints_land_on_the_outline(name: &str, d: &Diagram) {
for e in &d.edges {
let placed = |id: &str| match d.node(id) {
Some(n) => Some(n),
None if d.cluster(id).is_some() => None,
None => panic!(
"{name}: edge {}->{} names a node that is not placed",
e.from, e.to
),
};
let (tail, head) = (placed(&e.from), placed(&e.to));
let first = e.points.first().expect("edge has points");
let last = e.points.last().expect("edge has points");
for (label, p, node) in [("start", first, tail), ("end", last, head)]
.into_iter()
.filter_map(|(l, p, n)| n.map(|n| (l, p, n)))
{
let off = dist_to_boundary(p, &boundary(node));
assert!(
off <= 0.05,
"{name}: {label} of {}->{} is {}px off {}'s outline",
e.from,
e.to,
num(off),
node.id
);
}
}
}
#[test]
fn invariant_endpoints_land_on_the_outline() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_endpoints_land_on_the_outline(name, &laid_out(src));
}
}
pub(super) fn check_view_box_contains_everything(name: &str, d: &Diagram) {
let check = |x: f64, y: f64, what: &str| {
assert!(
x >= -0.01 && y >= -0.01 && x <= d.width + 0.01 && y <= d.height + 0.01,
"{name}: {what} at ({}, {}) is outside the {}x{} viewBox",
num(x),
num(y),
num(d.width),
num(d.height)
);
};
for n in &d.nodes {
let (l, t, r, b) = n.bounds();
check(l, t, &n.id);
check(r, b, &n.id);
}
for e in &d.edges {
for p in e.drawn_points() {
check(p.x, p.y, "waypoint");
}
if let Some(l) = &e.label {
check(
l.center.x - l.size.w / 2.0,
l.center.y - l.size.h / 2.0,
"label",
);
check(
l.center.x + l.size.w / 2.0,
l.center.y + l.size.h / 2.0,
"label",
);
}
}
let left = d
.nodes
.iter()
.map(|n| n.bounds().0)
.fold(f64::INFINITY, f64::min);
assert!(
left >= MARGIN - 0.01,
"{name}: drawing starts left of the margin"
);
}
#[test]
fn invariant_view_box_contains_everything() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_view_box_contains_everything(name, &laid_out(src));
}
}
pub(super) fn check_clusters_hold_their_members(name: &str, d: &Diagram, tree: &clusters::Tree) {
for c in &d.clusters {
let frame = c.bounds();
for n in &d.nodes {
if !tree.touches(&n.id, &c.id) {
continue;
}
let out = escapes(n.bounds(), frame);
assert!(
out <= 0.01,
"{name}: node {} pokes {out:.2}px out of frame {}",
n.id,
c.id
);
}
}
}
#[test]
fn invariant_clusters_hold_their_members() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_clusters_hold_their_members(name, &laid_out(src), &tree_of_src(src));
}
}
pub(super) fn check_nested_clusters_sit_inside_their_parent(name: &str, d: &Diagram) {
for c in &d.clusters {
let Some(parent) = c.parent.as_deref().and_then(|p| d.cluster(p)) else {
continue;
};
let out = escapes(c.bounds(), parent.bounds());
assert!(
out <= 0.01,
"{name}: frame {} pokes {out:.2}px out of its parent {}",
c.id,
parent.id
);
}
}
#[test]
fn invariant_nested_clusters_sit_inside_their_parent() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_nested_clusters_sit_inside_their_parent(name, &laid_out(src));
}
}
pub(super) fn check_unrelated_clusters_do_not_overlap(name: &str, d: &Diagram) {
let nested = |d: &Diagram, a: &PlacedCluster, b: &PlacedCluster| {
let mut cur = a.parent.clone();
while let Some(p) = cur {
if p == b.id {
return true;
}
cur = d.cluster(&p).and_then(|c| c.parent.clone());
}
false
};
for (i, a) in d.clusters.iter().enumerate() {
for b in d.clusters.iter().skip(i + 1) {
if nested(d, a, b) || nested(d, b, a) {
continue;
}
let (dx, dy) = rect_overlap(a.bounds(), b.bounds());
assert!(
dx <= 0.01 || dy <= 0.01,
"{name}: frames {} and {} overlap by {dx:.2}x{dy:.2}px",
a.id,
b.id
);
}
}
}
#[test]
fn invariant_unrelated_clusters_do_not_overlap() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_unrelated_clusters_do_not_overlap(name, &laid_out(src));
}
}
pub(super) fn check_edges_keep_out_of_foreign_frames(
name: &str,
d: &Diagram,
tree: &clusters::Tree,
) {
for e in &d.edges {
if tree.contains(&e.from) || tree.contains(&e.to) {
continue;
}
for c in &d.clusters {
if tree.touches(&e.from, &c.id) || tree.touches(&e.to, &c.id) {
continue;
}
let depth = polyline_depth_in_rect(&e.drawn_points(), c.bounds());
assert!(
depth <= 0.5,
"{name}: edge {} -> {} runs {depth:.2}px into frame {}",
e.from,
e.to,
c.id
);
}
}
}
#[test]
fn invariant_edges_keep_out_of_frames_they_do_not_belong_to() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_edges_keep_out_of_foreign_frames(name, &laid_out(src), &tree_of_src(src));
}
}
pub(super) fn check_edge_that_names_a_block_stops_on_its_frame(
name: &str,
d: &Diagram,
tree: &clusters::Tree,
) {
for e in &d.edges {
for (end, id) in [("tail", &e.from), ("head", &e.to)] {
if !tree.contains(id) {
continue;
}
let c = d
.cluster(id)
.unwrap_or_else(|| panic!("{name}: edge names block {id} but no frame was placed"));
let frame = c.bounds();
let point = if end == "tail" {
e.points.first()
} else {
e.points.last()
}
.expect("an edge has points");
let on_edge = (point.x - frame.0)
.abs()
.min((point.x - frame.2).abs())
.min((point.y - frame.1).abs())
.min((point.y - frame.3).abs());
assert!(
on_edge <= 0.01,
"{name}: the {end} of {} -> {} is {on_edge:.2}px off frame {id}",
e.from,
e.to
);
let depth = polyline_depth_in_rect(&e.drawn_points(), frame);
assert!(
depth <= 0.5,
"{name}: {} -> {} is drawn {depth:.2}px inside frame {id}",
e.from,
e.to
);
}
}
}
#[test]
fn invariant_an_edge_that_names_a_block_stops_on_its_frame() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_edge_that_names_a_block_stops_on_its_frame(name, &laid_out(src), &tree_of_src(src));
}
}
pub(super) fn check_cluster_titles(name: &str, d: &Diagram, tree: &clusters::Tree) {
for c in &d.clusters {
if c.title.is_blank() {
continue;
}
let t = c.title_center();
let title = (
t.x - c.title.width / 2.0,
t.y - c.title.height / 2.0,
t.x + c.title.width / 2.0,
t.y + c.title.height / 2.0,
);
let out = escapes(title, c.bounds());
assert!(
out <= 0.01,
"{name}: the title of {} pokes {out:.2}px out of its own frame",
c.id
);
for n in &d.nodes {
if !tree.touches(&n.id, &c.id) {
continue;
}
let (dx, dy) = rect_overlap(title, n.bounds());
assert!(
dx <= 0.01 || dy <= 0.01,
"{name}: the title of {} sits on node {} ({dx:.2}x{dy:.2}px)",
c.id,
n.id
);
}
for other in &d.clusters {
if other.parent.as_deref() != Some(c.id.as_str()) {
continue;
}
let (dx, dy) = rect_overlap(title, other.bounds());
assert!(
dx <= 0.01 || dy <= 0.01,
"{name}: the title of {} sits on the nested frame {} ({dx:.2}x{dy:.2}px)",
c.id,
other.id
);
}
}
}
pub(super) fn check_panels_stay_inside_their_box(name: &str, d: &Diagram) {
for n in &d.nodes {
let Some(panel) = &n.panel else { continue };
assert!(
(panel.size.w - n.size.w).abs() < 0.01 && (panel.size.h - n.size.h).abs() < 0.01,
"{name}: {} is {}x{} but its panel is {}x{}",
n.id,
num(n.size.w),
num(n.size.h),
num(panel.size.w),
num(panel.size.h)
);
for (l, t, r, b) in panel.cell_bounds() {
assert!(
l >= -0.01 && t >= -0.01 && r <= n.size.w + 0.01 && b <= n.size.h + 0.01,
"{name}: a row of {} runs from ({}, {}) to ({}, {}), outside its {}x{} box",
n.id,
num(l),
num(t),
num(r),
num(b),
num(n.size.w),
num(n.size.h)
);
}
for y in &panel.rules {
assert!(
*y >= -0.01 && *y <= n.size.h + 0.01,
"{name}: a rule of {} is at y={} in a box {} tall",
n.id,
num(*y),
num(n.size.h)
);
}
for x in &panel.columns {
assert!(
*x >= -0.01 && *x <= n.size.w + 0.01,
"{name}: a column rule of {} is at x={} in a box {} wide",
n.id,
num(*x),
num(n.size.w)
);
}
}
}
#[test]
fn invariant_cluster_titles_stay_in_the_frame_and_off_the_members() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
check_cluster_titles(name, &laid_out(src), &tree_of_src(src));
}
}
#[test]
#[ignore = "known gap: an edge naming a block is routed between stand-in leaves, so neither a third block between them nor a node beside them is avoided. Reproduces in mermaid too; needs a change to how a cluster endpoint reaches dagre."]
fn an_edge_that_names_a_block_is_not_kept_clear_of_its_surroundings() {
if !text_metrics::fonts_available() {
return;
}
let src = "flowchart TD\n Z --> one\n one --> two\n one --> three\n subgraph one [First]\n a --> b\n end\n subgraph two [Second]\n c --> d\n end\n subgraph three [Third]\n e --> f\n end";
let d = laid_out(src);
let tree = tree_of_src(src);
for e in &d.edges {
for c in &d.clusters {
if tree.touches(&e.from, &c.id) || tree.touches(&e.to, &c.id) {
continue;
}
let depth = polyline_depth_in_rect(&e.drawn_points(), c.bounds());
assert!(
depth <= 0.5,
"edge {} -> {} runs {depth:.2}px into frame {}",
e.from,
e.to,
c.id
);
}
}
let src = "flowchart LR\n Z --> A\n A --> B\n B --> C\n B --> D\n subgraph B [B]\n a --> b\n end";
let d = laid_out(src);
let outlines: Vec<Vec<Point>> = d.nodes.iter().map(boundary).collect();
for e in &d.edges {
for p in e.drawn_points() {
for (node, poly) in d.nodes.iter().zip(&outlines) {
let inside_by = depth(&p, poly);
assert!(
inside_by <= 1.0,
"edge {} -> {} runs {}px inside {}",
e.from,
e.to,
num(inside_by),
node.id
);
}
}
}
}
#[test]
fn every_block_that_holds_a_node_gets_exactly_one_frame() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
let d = laid_out(src);
let tree = tree_of_src(src);
let drawn: HashSet<&str> = d.clusters.iter().map(|c| c.id.as_str()).collect();
assert_eq!(
drawn.len(),
d.clusters.len(),
"{name}: a frame is drawn twice"
);
let declared: HashSet<&str> = tree.iter().map(|c| c.id.as_str()).collect();
assert_eq!(drawn, declared, "{name}: frames and blocks disagree");
}
}
#[test]
fn every_node_and_drawable_edge_survives() {
for (name, src) in CORPUS {
let chart = parse(src).expect("parses");
let tree = tree_of_src(src);
let d = laid_out(src);
assert_eq!(d.nodes.len(), chart.nodes.len(), "{name}: node count");
let drawable = chart
.edges
.iter()
.filter(|e| {
let end = |id: &str| chart.node(id).is_some() || tree.contains(id);
end(&e.from) && end(&e.to)
})
.count();
assert_eq!(d.edges.len(), drawable, "{name}: edge count");
}
}
const L: Size = Size { w: 100.0, h: 20.0 };
#[test]
fn diamond_side_is_the_sum_of_both_padded_axes() {
let s = flow_size(Shape::Diamond, L);
let expected = (L.w + shapes::PADDING) + (L.h + shapes::PADDING);
assert_eq!(s.w, expected);
assert_eq!(s.h, expected);
let p = shapes::polygon(Glyph::Flow(Shape::Diamond), s);
assert_eq!(p.len(), 4);
assert_eq!((p[0].x, p[0].y), (0.0, -expected / 2.0));
assert_eq!((p[1].x, p[1].y), (expected / 2.0, 0.0));
}
#[test]
fn rect_padding_is_four_by_two() {
let s = flow_size(Shape::Rect, L);
assert_eq!(s.w, L.w + shapes::PADDING * 4.0);
assert_eq!(s.h, L.h + shapes::PADDING * 2.0);
}
#[test]
fn stadium_height_takes_a_single_padding() {
let s = flow_size(Shape::Stadium, L);
let h = L.h + shapes::PADDING;
assert_eq!(s.h, h);
assert_eq!(s.w, L.w + h / 4.0 + shapes::PADDING);
assert_eq!(
shapes::outline(Glyph::Flow(Shape::Stadium), s, None),
shapes::Outline::Rect {
w: s.w,
h: s.h,
r: s.h / 2.0
}
);
}
#[test]
fn circle_radius_follows_the_label_width() {
let s = flow_size(Shape::Circle, L);
assert_eq!(s.w, s.h);
assert_eq!(s.w, L.w + shapes::PADDING * 2.0);
}
#[test]
fn hexagon_ends_are_a_quarter_of_its_height() {
let s = flow_size(Shape::Hexagon, L);
let h = L.h + shapes::PADDING;
assert_eq!(s.h, h);
assert_eq!(s.w, L.w + h / 2.0 + shapes::PADDING);
let p = shapes::polygon(Glyph::Flow(Shape::Hexagon), s);
assert_eq!(p.len(), 6);
assert_eq!(p[0].x, -s.w / 2.0 + h / 4.0);
}
#[test]
fn trapezoid_and_inverted_trapezoid_pad_differently() {
let up = flow_size(Shape::Trapezoid, L);
let down = flow_size(Shape::InvTrapezoid, L);
assert_eq!(up.h, L.h + shapes::PADDING);
assert_eq!(down.h, L.h + shapes::PADDING * 2.0);
assert_eq!(up.w, L.w + shapes::PADDING + up.h);
assert_eq!(down.w, L.w + shapes::PADDING * 2.0 + down.h);
let (a, b) = (
shapes::polygon(Glyph::Flow(Shape::Trapezoid), up),
shapes::polygon(Glyph::Flow(Shape::InvTrapezoid), down),
);
assert!(
a[0].y > 0.0 && a[0].x == -up.w / 2.0,
"trapezoid is wide at the bottom"
);
assert!(
b[2].y < 0.0 && b[2].x == down.w / 2.0,
"inv trapezoid is wide at the top"
);
}
#[test]
fn every_shape_holds_the_label_it_was_sized_for() {
for shape in [
Shape::Rect,
Shape::RoundedRect,
Shape::Stadium,
Shape::Subroutine,
Shape::Cylinder,
Shape::Diamond,
Shape::Hexagon,
Shape::Odd,
Shape::Trapezoid,
Shape::InvTrapezoid,
Shape::LeanRight,
Shape::LeanLeft,
Shape::Text,
] {
let size = flow_size(shape, L);
let node = PlacedNode {
id: format!("{shape:?}"),
shape: Glyph::Flow(shape),
center: Point::new(0.0, 0.0),
size,
label: Label {
lines: vec!["x".to_string()],
width: L.w,
height: L.h,
},
panel: None,
series: None,
mark: None,
style: None,
};
let outline = boundary(&node);
for (sx, sy) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
let corner = Point::new(sx * L.w / 2.0, sy * L.h / 2.0);
assert!(
inside(&corner, &outline),
"{shape:?}: a {}x{} label reaches ({}, {}), which is outside the {}x{} outline",
num(L.w),
num(L.h),
num(corner.x),
num(corner.y),
num(size.w),
num(size.h)
);
}
}
for shape in [Shape::Circle, Shape::DoubleCircle] {
let size = flow_size(shape, L);
assert!(size.w >= L.w, "{shape:?}: the label is wider than the ring");
}
}
#[test]
fn diamond_intersection_uses_the_slanted_sides() {
let s = Size::new(100.0, 100.0);
let c = Point::new(0.0, 0.0);
let east = shapes::intersect(
Glyph::Flow(Shape::Diamond),
c.clone(),
s,
&Point::new(500.0, 0.0),
);
assert!((east.x - 50.0).abs() < 1e-9 && east.y.abs() < 1e-9);
let ne = shapes::intersect(
Glyph::Flow(Shape::Diamond),
c,
s,
&Point::new(500.0, -500.0),
);
assert!(
(ne.x.abs() + ne.y.abs() - 50.0).abs() < 1e-9,
"on the slanted side"
);
assert!(
ne.x < 49.0 && ne.y > -49.0,
"not at the bounding box's corner"
);
}
#[test]
fn curve_basis_matches_d3() {
let pts = [
Point::new(0.0, 0.0),
Point::new(60.0, 0.0),
Point::new(60.0, 60.0),
];
assert_eq!(
edges::curve_basis_path(&pts),
"M0,0L10,0C20,0 40,0 50,10C60,20 60,40 60,50L60,60"
);
assert_eq!(edges::curve_basis_path(&pts[..2]), "M0,0L60,0");
assert_eq!(edges::curve_basis_path(&pts[..1]), "M0,0");
let vertical = [
Point::new(100.0, 50.0),
Point::new(100.0, 120.0),
Point::new(100.0, 190.0),
];
assert_eq!(
edges::curve_basis_path(&vertical),
"M100,50L100,61.667C100,73.333 100,96.667 100,120\
C100,143.333 100,166.667 100,178.333L100,190"
);
let horizontal = [
Point::new(50.0, 100.0),
Point::new(120.0, 100.0),
Point::new(190.0, 100.0),
];
assert_eq!(
edges::curve_basis_path(&horizontal),
"M50,100L61.667,100C73.333,100 96.667,100 120,100\
C143.333,100 166.667,100 178.333,100L190,100"
);
let duplicate = [
Point::new(0.0, 0.0),
Point::new(40.0, 40.0),
Point::new(40.0, 40.0),
Point::new(80.0, 80.0),
];
assert_eq!(
edges::curve_basis_path(&duplicate),
"M0,0L6.667,6.667C13.333,13.333 26.667,26.667 33.333,33.333\
C40,40 40,40 46.667,46.667C53.333,53.333 66.667,66.667 73.333,73.333L80,80"
);
}
#[test]
fn fix_corners_rounds_right_angles_only() {
let square = [
Point::new(0.0, 0.0),
Point::new(0.0, 100.0),
Point::new(100.0, 100.0),
];
let fixed = edges::fix_corners(&square);
assert_eq!(fixed.len(), 5, "the corner becomes three points");
assert!(
fixed[1].y < 100.0 && fixed[3].x > 0.0,
"the replacement points step back from the corner"
);
let tiny = [
Point::new(0.0, 0.0),
Point::new(0.0, 4.0),
Point::new(4.0, 4.0),
];
assert_eq!(edges::fix_corners(&tiny).len(), 3);
let straight = [
Point::new(0.0, 0.0),
Point::new(0.0, 50.0),
Point::new(0.0, 100.0),
];
assert_eq!(edges::fix_corners(&straight).len(), 3);
}
#[test]
fn arc_midpoint_is_measured_by_length_not_by_waypoint() {
let line = [
Point::new(0.0, 0.0),
Point::new(90.0, 0.0),
Point::new(100.0, 0.0),
];
let mid = edges::arc_midpoint(&line).expect("has a midpoint");
assert!((mid.x - 50.0).abs() < 1e-9 && mid.y.abs() < 1e-9);
assert_eq!(edges::arc_midpoint(&[]), None);
}
#[test]
fn arrow_head_points_along_the_last_segment() {
let head = edges::arrow_head(&Point::new(0.0, 0.0), &Point::new(0.0, 100.0));
assert_eq!((head[0].x, head[0].y), (0.0, 100.0));
assert!((head[1].y - (100.0 - edges::ARROW_LENGTH)).abs() < 1e-9);
assert!((head[1].x - head[2].x).abs() - edges::ARROW_HALF_WIDTH * 2.0 < 1e-9);
}
#[test]
fn a_flowchart_arrow_marks_only_the_ends_that_spelled_a_mark() {
if !text_metrics::fonts_available() {
return;
}
let cases: &[(&str, Arrow, Tip, Tip)] = &[
("---", Arrow::None, Tip::None, Tip::None),
("-->", Arrow::Point, Tip::None, Tip::Arrow),
("--x", Arrow::Cross, Tip::None, Tip::Cross),
("--o", Arrow::Circle, Tip::None, Tip::Circle),
("<-->", Arrow::DoublePoint, Tip::Arrow, Tip::Arrow),
("x--x", Arrow::DoubleCross, Tip::Cross, Tip::Cross),
("o--o", Arrow::DoubleCircle, Tip::Circle, Tip::Circle),
("x-- t -->", Arrow::Invalid, Tip::None, Tip::None),
];
fn covered(a: Arrow) -> bool {
match a {
Arrow::None
| Arrow::Point
| Arrow::Cross
| Arrow::Circle
| Arrow::DoublePoint
| Arrow::DoubleCross
| Arrow::DoubleCircle
| Arrow::Invalid => true,
}
}
let spelled: HashSet<Arrow> = cases.iter().map(|(_, a, _, _)| *a).collect();
assert_eq!(spelled.len(), cases.len(), "an arrow is spelled twice");
for a in &spelled {
assert!(covered(*a));
}
let drawn = |svg: &str| -> (usize, usize, usize) {
let heads = svg.matches("<polygon").count();
let crosses = svg
.lines()
.filter(|l| l.starts_with("<path") && l.matches('M').count() > 1)
.count();
let discs = svg.matches("<circle").count();
(heads, crosses, discs)
};
let wanted = |tip: Tip| -> (usize, usize, usize) {
match tip {
Tip::Arrow => (1, 0, 0),
Tip::Cross => (0, 1, 0),
Tip::Circle => (0, 0, 1),
_ => (0, 0, 0),
}
};
for (spelling, arrow, tip_start, tip_end) in cases {
let src = format!("flowchart LR\n A[a] {spelling} B[b]\n");
let chart = parse(&src).unwrap_or_else(|e| panic!("{spelling}: {e}"));
assert_eq!(chart.edges.len(), 1, "{spelling}: one edge");
assert_eq!(chart.edges[0].arrow, *arrow, "{spelling}: parsed arrow");
let d = laid_out(&src);
assert_eq!(d.edges.len(), 1, "{spelling}: one edge survives layout");
let e = &d.edges[0];
assert_eq!(
(e.tip_start, e.tip_end),
(*tip_start, *tip_end),
"{spelling}: the marks are on the wrong ends"
);
let svg = render(&src, "dark").expect("renders");
let (heads, crosses, discs) = drawn(&svg);
let (ws, wc, wd) = wanted(*tip_start);
let (we, wcc, wdd) = wanted(*tip_end);
assert_eq!(
(heads, crosses, discs),
(ws + we, wc + wcc, wd + wdd),
"{spelling}: {heads} arrow heads, {crosses} crosses and {discs} discs were drawn for \
({tip_start:?}, {tip_end:?})"
);
}
}
#[test]
fn trimming_leaves_a_line_with_a_direction() {
let line = [Point::new(0.0, 0.0), Point::new(0.0, 100.0)];
let cut = edges::trim_end(&line, edges::ARROW_LENGTH);
assert!((cut.last().unwrap().y - 91.0).abs() < 1e-9);
let stub = [Point::new(0.0, 0.0), Point::new(0.0, 3.0)];
assert_eq!(edges::trim_end(&stub, edges::ARROW_LENGTH), stub.to_vec());
}
#[test]
fn invisible_edge_constrains_but_does_not_draw() {
let with = laid_out("flowchart TD\n A[aaa]\n B[bbb]\n A ~~~ B");
let without = laid_out("flowchart TD\n A[aaa]\n B[bbb]");
assert!(
with.height > without.height,
"the invisible edge should have pushed B onto its own rank"
);
let svg = render("flowchart TD\n A[aaa]\n B[bbb]\n A ~~~ B", "dark").expect("renders");
assert_eq!(
svg.matches("<path").count(),
0,
"an invisible edge emits no path"
);
}
#[test]
fn emitted_svg_obeys_the_renderer_contract() {
for (name, src) in CORPUS {
for theme_name in ["dark", "light", "classic", "forest", "neutral"] {
let svg = render(src, theme_name).expect("renders");
assert!(
svg.contains("viewBox=\""),
"{name}/{theme_name}: without a viewBox usvg falls back to 100x100"
);
assert!(svg.contains("width=\"") && svg.contains("height=\""));
assert!(
!svg.contains("foreignObject"),
"{name}/{theme_name}: resvg drops <foreignObject> whole, label and all"
);
assert!(
!svg.contains("var(--"),
"{name}/{theme_name}: usvg cannot resolve CSS variables — fills go black"
);
assert!(
!svg.contains("<style") && !svg.contains("class=\"node default\""),
"{name}/{theme_name}: nothing may depend on a stylesheet"
);
assert!(
svg.contains("font-family=\"sans-serif\""),
"{name}/{theme_name}: the drawing font must be the one that was measured"
);
let opaque_background = svg
.lines()
.any(|l| l.starts_with("<rect width=") && !l.contains("fill=\"none\""));
assert!(
!opaque_background,
"{name}/{theme_name}: an opaque background turns the diagram into a card"
);
}
}
}
#[test]
fn every_corpus_diagram_rasterises() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CORPUS {
let svg = render(src, "dark").expect("renders");
let img = crate::preview::svg::rasterize_bytes(svg.as_bytes(), FsPath::new("m.svg"), 600)
.unwrap_or_else(|| panic!("{name}: konoma's resvg could not rasterise the output"));
assert!(img.width() > 0 && img.height() > 0, "{name}: empty raster");
}
}
#[test]
fn a_rendered_diagram_actually_has_ink_in_it() {
if !text_metrics::fonts_available() {
return;
}
let svg = render("flowchart TD\n A[Start] --> B{Ready?}", "dark").expect("renders");
let img = crate::preview::svg::rasterize_bytes(svg.as_bytes(), FsPath::new("m.svg"), 400)
.expect("rasterises");
let opaque = img.to_rgba8().pixels().filter(|p| p.0[3] > 32).count();
assert!(
opaque > 500,
"only {opaque} pixels were drawn — the diagram is effectively blank"
);
}
#[test]
fn every_theme_colour_is_normalised_hex() {
for t in theme::ALL {
assert_eq!(
t.background_paint, "none",
"{}: the background is a reference colour, never a paint (§4-2)",
t.name
);
for (field, value) in [
("background_ref", t.background_ref),
("node_fill", t.node_fill),
("node_stroke", t.node_stroke),
("node_text", t.node_text),
("line", t.line),
("arrowhead", t.arrowhead),
("edge_label_text", t.edge_label_text),
("cluster_fill", t.cluster_fill),
("cluster_stroke", t.cluster_stroke),
("cluster_text", t.cluster_text),
("state_marker", t.state_marker),
("note_fill", t.note_fill),
("note_stroke", t.note_stroke),
("note_text", t.note_text),
] {
assert!(
theme::parse_hex(value).is_some(),
"{}.{field} = {value:?} is not a normalised #rrggbb",
t.name
);
}
}
}
#[test]
fn lines_survive_being_composited_on_either_ground() {
const FLOOR: f64 = 2.2;
for t in theme::ALL {
for (field, colour) in [
("line", t.line),
("arrowhead", t.arrowhead),
("state_marker", t.state_marker),
] {
let on_black = Theme::contrast(colour, "#000000");
assert!(
on_black >= 3.0,
"{}.{field} = {colour} has contrast {:.2} on black — it disappears on a dark \
terminal (this is what happens to mermaid's forest)",
t.name,
on_black
);
if t.name != "dark" {
let on_white = Theme::contrast(colour, "#ffffff");
assert!(
on_white >= FLOOR,
"{}.{field} = {colour} has contrast {:.2} on white",
t.name,
on_white
);
}
}
assert!(
Theme::contrast(t.node_text, t.node_fill) >= 4.0,
"{}: node text {} on fill {} has contrast {:.2}",
t.name,
t.node_text,
t.node_fill,
Theme::contrast(t.node_text, t.node_fill)
);
assert!(
Theme::contrast(t.note_text, t.note_fill) >= 4.0,
"{}: note text {} on fill {} has contrast {:.2}",
t.name,
t.note_text,
t.note_fill,
Theme::contrast(t.note_text, t.note_fill)
);
assert!(
Theme::contrast(t.edge_label_text, t.background_ref) >= 4.0,
"{}: edge label text {} on {} has contrast {:.2}",
t.name,
t.edge_label_text,
t.background_ref,
Theme::contrast(t.edge_label_text, t.background_ref)
);
}
}
#[test]
fn theme_names_resolve_the_way_the_config_promises() {
assert_eq!(Theme::named("dark").name, "dark");
assert_eq!(Theme::named("light").name, "light");
assert_eq!(Theme::named("modern").name, "light");
assert_eq!(Theme::named("classic").name, "classic");
assert_eq!(Theme::named("mermaid").name, "classic");
assert_eq!(Theme::named("forest").name, "forest");
assert_eq!(Theme::named("neutral").name, "neutral");
assert_eq!(Theme::named("").name, "dark");
assert_eq!(Theme::named("Dracula").name, "dark");
}
#[test]
fn themes_change_colours_but_never_geometry() {
let src = "flowchart TD\n A[Start] -->|go| B{Ready?}\n B --> C((end))";
let geometry_of = |s: &str| {
s.lines()
.map(|l| {
l.split_whitespace()
.filter(|w| !w.starts_with("fill=") && !w.starts_with("stroke=\""))
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.join("\n")
};
let base = geometry_of(&render(src, "dark").expect("renders"));
for name in ["light", "classic", "forest", "neutral"] {
assert_eq!(
base,
geometry_of(&render(src, name).expect("renders")),
"theme {name} moved something"
);
}
}
#[test]
fn another_diagram_kind_is_refused() {
assert!(matches!(
render("venn-beta\n sets: [a, b]", "dark"),
Err(RenderError::Parse(_))
));
assert!(matches!(
render("sequenceDiagram\n A->>B: hi", "dark"),
Err(RenderError::Parse(_))
));
}
#[test]
fn a_flowchart_with_no_nodes_is_refused() {
assert!(matches!(
render("graph TD", "dark"),
Err(RenderError::Parse(_))
));
}
#[test]
fn a_block_is_a_frame_and_not_a_node() {
let d =
laid_out("flowchart TD\n subgraph one [Group]\n A --> B\n end\n B --> C\n C --> A");
assert_eq!(d.nodes.len(), 3, "every member is still drawn");
assert_eq!(d.edges.len(), 3);
assert!(d.node("one").is_none(), "the block is not a node");
let frame = d.cluster("one").expect("the block is a frame");
assert_eq!(frame.title.lines, vec!["Group".to_string()]);
assert_eq!(frame.parent, None);
assert_eq!(frame.depth, 0);
}
#[test]
fn an_edge_can_name_a_block() {
let d = laid_out(
"flowchart TD\n subgraph one [Group]\n A --> B\n end\n one --> C\n D --> one",
);
assert!(d.node("one").is_none(), "the block is still not a node");
let named: Vec<&PlacedEdge> = d
.edges
.iter()
.filter(|e| e.from == "one" || e.to == "one")
.collect();
assert_eq!(named.len(), 2, "both edges onto the block survive");
for e in named {
assert!(e.points.len() >= 2, "{} -> {} has a line", e.from, e.to);
}
}
#[test]
fn an_empty_block_is_not_drawn() {
let d = laid_out("flowchart TD\n subgraph hollow [Nothing]\n end\n A --> B\n hollow --> A");
assert!(d.clusters.is_empty(), "no frame for an empty block");
assert_eq!(d.edges.len(), 1, "the edge that named it is dropped");
assert_eq!(d.nodes.len(), 2);
}
#[test]
fn a_block_direction_does_not_move_anything() {
let with = laid_out(
"flowchart LR\n subgraph one [Steps]\n direction TB\n A --> B\n end\n B --> C",
);
let without = laid_out("flowchart LR\n subgraph one [Steps]\n A --> B\n end\n B --> C");
assert_eq!(with.nodes, without.nodes);
assert_eq!(with.clusters, without.clusters);
}
#[test]
fn errors_say_what_was_wrong() {
let e = render("venn-beta", "dark").unwrap_err();
assert!(
e.to_string().contains("venn-beta"),
"the message should name what it found: {e}"
);
}
#[test]
fn corpus_golden() {
if !text_metrics::fonts_available() {
return;
}
let mut out = String::new();
for (name, src) in CORPUS {
out.push_str(&format!("=== {name} ===\n"));
out.push_str(&mask_numbers(&render(src, "dark").expect("renders")));
out.push('\n');
}
assert_snapshot("mermaid_render", &out);
}
#[test]
fn emit_golden() {
let d = synthetic_diagram();
let mut out = String::new();
for t in theme::ALL {
out.push_str(&format!("=== {} ===\n", t.name));
out.push_str(&super::svg::emit(&d, t));
out.push('\n');
}
assert_snapshot("mermaid_emit", &out);
}
fn synthetic_diagram() -> Diagram {
let label = |text: &str, w: f64| Label {
lines: text.split('\n').map(str::to_string).collect(),
width: w,
height: text.split('\n').count() as f64 * super::labels::line_height(),
};
let shapes_in_order = [
Shape::Rect,
Shape::RoundedRect,
Shape::Stadium,
Shape::Subroutine,
Shape::Cylinder,
Shape::Circle,
Shape::DoubleCircle,
Shape::Diamond,
Shape::Hexagon,
Shape::Odd,
Shape::Trapezoid,
Shape::InvTrapezoid,
Shape::LeanRight,
Shape::LeanLeft,
Shape::Text,
];
let mut nodes = Vec::new();
for (i, shape) in shapes_in_order.into_iter().enumerate() {
let text = if i == 0 { "two\nlines" } else { "label" };
let l = label(text, 40.0);
let size = flow_size(shape, Size::new(l.width, l.height));
nodes.push(PlacedNode {
id: format!("n{i}"),
shape: Glyph::Flow(shape),
center: Point::new(
120.0 + (i % 5) as f64 * 200.0,
80.0 + (i / 5) as f64 * 160.0,
),
size,
label: l,
panel: None,
series: None,
mark: None,
style: None,
});
}
let arrows = [
(Arrow::Point, Stroke::Normal),
(Arrow::None, Stroke::Normal),
(Arrow::Cross, Stroke::Dotted),
(Arrow::Circle, Stroke::Thick),
(Arrow::DoublePoint, Stroke::Normal),
(Arrow::DoubleCross, Stroke::Normal),
(Arrow::DoubleCircle, Stroke::Normal),
(Arrow::Invalid, Stroke::Invalid),
(Arrow::Point, Stroke::Invisible),
];
let mut edges = Vec::new();
for (i, (arrow, stroke)) in arrows.into_iter().enumerate() {
let y = 420.0 + i as f64 * 30.0;
let points = vec![
Point::new(60.0, y),
Point::new(200.0, y),
Point::new(200.0, y + 20.0),
Point::new(380.0, y + 20.0),
];
let l = label("via", 20.0);
edges.push(PlacedEdge {
from: format!("n{i}"),
to: format!("n{}", i + 1),
label: (i % 2 == 0).then(|| super::PlacedEdgeLabel {
center: edges::arc_midpoint(&points).expect("midpoint"),
size: Size::new(
l.width + super::LABEL_PAD_X * 2.0,
l.height + super::LABEL_PAD_Y * 2.0,
),
label: l,
}),
points,
gaps: Vec::new(),
tip_start: super::Tip::of_arrow(arrow).0,
tip_end: super::Tip::of_arrow(arrow).1,
stroke,
start_label: None,
end_label: None,
badge: None,
series: None,
straight: false,
overlay: false,
style: None,
curve: Curve::Basis,
tip_matches_line: false,
});
}
let clusters = vec![
PlacedCluster {
id: "outer".to_string(),
title: label("Outer frame\nwith two lines", 130.0),
center: Point::new(300.0, 620.0),
size: Size::new(360.0, 160.0),
parent: None,
depth: 0,
dashed: false,
filled: true,
sections: Vec::new(),
},
PlacedCluster {
id: "inner".to_string(),
title: label("", 0.0),
center: Point::new(320.0, 645.0),
size: Size::new(200.0, 90.0),
parent: Some("outer".to_string()),
depth: 1,
dashed: false,
filled: true,
sections: Vec::new(),
},
];
Diagram {
width: 1040.0,
height: 720.0,
nodes,
edges,
clusters,
lifelines: Vec::new(),
}
}
#[test]
fn numbers_are_written_the_same_way_every_time() {
assert_eq!(num(12.0), "12");
assert_eq!(num(12.5), "12.5");
assert_eq!(num(1.0 / 3.0), "0.333");
assert_eq!(num(-0.0001), "0");
assert_eq!(num(-12.25), "-12.25");
}
#[test]
fn label_text_is_escaped() {
let svg = super::svg::escape("a < b & c > \"d\"");
assert_eq!(svg, "a < b & c > "d"");
if text_metrics::fonts_available() {
let out = render(
"flowchart TD\n A[\"a < b\"] --> B[\"x & y\"]",
"dark",
)
.expect("renders");
assert!(!out.contains("a < b"), "a raw < would break the document");
assert!(usvg::Tree::from_data(
out.as_bytes(),
&usvg::Options {
fontdb: shared_fontdb(),
..usvg::Options::default()
}
)
.is_ok());
}
}
#[test]
fn awkward_sources_produce_a_diagram_or_an_error_and_never_a_panic() {
if !text_metrics::fonts_available() {
return;
}
let mut wide = String::from("flowchart LR\n");
for i in 0..120 {
wide.push_str(&format!(" n{i} --> n{}\n", i + 1));
}
wide.push_str(" n0 --> n120\n n120 --> n0\n");
let mut deep = String::from("flowchart TD\n");
for i in 0..12 {
deep.push_str(&format!(" subgraph s{i} [Level {i}]\n"));
}
deep.push_str(" A --> B\n");
for _ in 0..12 {
deep.push_str(" end\n");
}
deep.push_str(" s0 --> C\n C --> s11\n");
let cases: &[&str] = &[
"",
" \n\n ",
"flowchart TD",
"flowchart TD\n A[\"\"]",
"flowchart TD\n A[\" \"] --> B[\"\"]",
"flowchart TD\n A -->|| B",
"flowchart TD\n A --> A",
"flowchart LR\n A[\"<script>&\"] --> B[\"a > b\"]",
"flowchart TD\n A[\"一\"] --> B[\"🎉\"]",
"flowchart TD\n %% only a comment\n A --> B",
"flowchart TD\n classDef hot fill:#f9f\n A:::hot --> B",
"flowchart TD\n A@{ shape: cyl, label: \"store\" } --> B",
"flowchart TD\n subgraph one\n end\n A --> B",
"flowchart TD\n subgraph one\n end\n one --> one",
"flowchart TD\n subgraph one [Group]\n A --> B\n end\n one --> A",
"flowchart TD\n subgraph one [Group]\n A --> B\n end\n one --> one",
"flowchart LR\n subgraph one\n subgraph two\n subgraph three\n A\n end\n end\n end\n one --> three",
"flowchart TD\n subgraph one [\"\"]\n A\n end\n A --> B",
"flowchart TD\n A --> one\n subgraph one [Declared after the edge]\n B --> C\n end",
&deep,
&wide,
];
for src in cases {
match render(src, "dark") {
Err(_) => {}
Ok(svg) => {
assert!(!svg.contains("NaN"), "NaN reached the document for {src:?}");
assert!(
!svg.contains("inf"),
"an infinity reached the document for {src:?}"
);
let d = laid_out(src);
assert!(
d.width.is_finite() && d.height.is_finite() && d.width > 0.0 && d.height > 0.0,
"{src:?}: {}x{} is not a drawable size",
num(d.width),
num(d.height)
);
assert!(
crate::preview::svg::rasterize_bytes(svg.as_bytes(), FsPath::new("m.svg"), 300)
.is_some(),
"{src:?}: the output did not rasterise"
);
}
}
}
}
#[test]
fn classdef_default_colours_nodes_but_not_an_untouched_edge() {
let d = laid_out("flowchart TD\n classDef default fill:#223,stroke:#556\n A --> B");
assert_eq!(
d.node("A")
.unwrap()
.style
.as_ref()
.unwrap()
.stroke
.as_deref(),
Some("#556"),
"classDef default must still colour every node"
);
assert!(
d.edges[0].style.is_none(),
"the edge names neither a class nor a linkStyle, so classDef default must not reach it"
);
let svg = render(
"flowchart TD\n classDef default fill:#223,stroke:#556\n A --> B",
"dark",
)
.expect("renders");
assert_eq!(
svg.matches("stroke=\"#556\"").count(),
2,
"exactly the two node rects (A and B) may carry classDef default's stroke, never the edge's path: {svg}"
);
assert!(
svg.contains(&format!("stroke=\"{}\"", theme::DARK.line)),
"the edge's path must still draw in the theme's own line colour: {svg}"
);
}
#[test]
fn classdef_and_class_colour_the_node_they_name() {
let d =
laid_out("flowchart TD\n classDef hot fill:#f9f,stroke:#a00\n A --> B\n class A hot");
let a = d
.node("A")
.expect("A exists")
.style
.as_ref()
.expect("A has a style");
assert_eq!(a.fill.as_deref(), Some("#f9f"));
assert_eq!(a.stroke.as_deref(), Some("#a00"));
assert!(
d.node("B").expect("B exists").style.is_none(),
"class hot named only A, so B must keep the theme's own colour"
);
let svg = render(
"flowchart TD\n classDef hot fill:#f9f,stroke:#a00\n A --> B\n class A hot",
"dark",
)
.expect("renders");
assert!(
svg.contains("fill=\"#f9f\""),
"the fill reaches the SVG: {svg}"
);
}
#[test]
fn triple_colon_is_the_same_as_a_class_statement() {
let d = laid_out("flowchart TD\n classDef hot fill:#f9f\n A:::hot --> B");
let a = d
.node("A")
.expect("A exists")
.style
.as_ref()
.expect("A has a style");
assert_eq!(a.fill.as_deref(), Some("#f9f"));
assert!(d.node("B").expect("B exists").style.is_none());
}
#[test]
fn a_nodes_own_style_wins_over_its_classdef() {
let d =
laid_out("flowchart TD\n classDef hot fill:#f9f\n A:::hot --> B\n style A fill:#00ff00");
let a = d
.node("A")
.expect("A exists")
.style
.as_ref()
.expect("A has a style");
assert_eq!(
a.fill.as_deref(),
Some("#00ff00"),
"the node's own `style` statement must be the final word, not the classDef"
);
}
#[test]
fn link_style_by_index_touches_only_that_edge() {
let d = laid_out("flowchart TD\n A --> B\n B --> C\n C --> D\n linkStyle 1 stroke:#1f6feb");
assert_eq!(d.edges.len(), 3, "three edges: A->B, B->C, C->D");
assert!(d.edges[0].style.is_none(), "A->B (index 0) is untouched");
assert_eq!(
d.edges[1]
.style
.as_ref()
.expect("B->C (index 1) has a style")
.stroke
.as_deref(),
Some("#1f6feb")
);
assert!(d.edges[2].style.is_none(), "C->D (index 2) is untouched");
}
#[test]
fn link_style_with_multiple_indices_reaches_every_named_edge() {
let d = laid_out(
"flowchart TD\n A --> B\n B --> C\n C --> D\n D --> E\n \
linkStyle 0,1,2 stroke:#1f6feb",
);
assert_eq!(d.edges.len(), 4);
for i in 0..3 {
assert_eq!(
d.edges[i]
.style
.as_ref()
.unwrap_or_else(|| panic!("edge {i} has a style"))
.stroke
.as_deref(),
Some("#1f6feb"),
"edge {i} is named by `linkStyle 0,1,2`"
);
}
assert!(
d.edges[3].style.is_none(),
"edge 3 (D->E) was not named, and must be untouched"
);
}
#[test]
fn link_style_default_colours_every_edge_and_an_index_still_overrides_it() {
let d = laid_out(
"flowchart TD\n A --> B\n B --> C\n linkStyle 0 stroke:#e11\n \
linkStyle default stroke:#39d",
);
assert_eq!(
d.edges[0].style.as_ref().unwrap().stroke.as_deref(),
Some("#e11"),
"edge 0's own index is more specific than `default`, even written first"
);
assert_eq!(
d.edges[1].style.as_ref().unwrap().stroke.as_deref(),
Some("#39d"),
"edge 1 has no index of its own, so `default` reaches it"
);
}
#[test]
fn link_style_stroke_width_reaches_the_unmasked_svg() {
let svg = render(
"flowchart TD\n A --> B\n linkStyle 0 stroke-width:4px",
"dark",
)
.expect("renders");
assert!(
svg.contains("stroke-width=\"4\""),
"the declared width must reach the path's own attribute, unmasked: {svg}"
);
}
#[test]
fn link_style_single_index_stroke_reaches_the_svg_and_only_that_edges_path() {
let svg = render(
"flowchart TD\n A --> B\n B --> C\n C --> D\n linkStyle 1 stroke:#1f6feb",
"dark",
)
.expect("renders");
assert_eq!(
svg.matches("stroke=\"#1f6feb\"").count(),
1,
"exactly one edge (index 1) was named: {svg}"
);
assert_eq!(
svg.matches(&format!("stroke=\"{}\"", theme::DARK.line))
.count(),
2,
"the other two edges must still draw in the theme's own line colour: {svg}"
);
}
#[test]
fn link_style_multiple_indices_stroke_reaches_every_named_edges_path() {
let svg = render(
"flowchart TD\n A --> B\n B --> C\n C --> D\n D --> E\n \
linkStyle 0,1,2 stroke:#1f6feb",
"dark",
)
.expect("renders");
assert_eq!(
svg.matches("stroke=\"#1f6feb\"").count(),
3,
"three edges (0, 1, 2) were named: {svg}"
);
assert_eq!(
svg.matches(&format!("stroke=\"{}\"", theme::DARK.line))
.count(),
1,
"the fourth edge (D->E) was not named and must keep the theme's line colour: {svg}"
);
}
#[test]
fn link_style_default_then_an_index_reach_the_svg_as_two_distinct_colours() {
let svg = render(
"flowchart TD\n A --> B\n B --> C\n linkStyle 0 stroke:#e11\n \
linkStyle default stroke:#39d",
"dark",
)
.expect("renders");
assert_eq!(
svg.matches("stroke=\"#e11\"").count(),
1,
"edge 0's own index must win over `default`, even written first: {svg}"
);
assert_eq!(
svg.matches("stroke=\"#39d\"").count(),
1,
"edge 1 has no index of its own, so `default` must reach its path: {svg}"
);
}
#[test]
fn an_invalid_color_falls_back_to_the_theme_instead_of_black() {
let d = laid_out("flowchart TD\n classDef bad fill:notacolor\n A:::bad --> B");
assert!(
d.node("A").unwrap().style.is_none(),
"the only declared field failed to validate, so there is nothing to override with"
);
let svg = render(
"flowchart TD\n classDef bad fill:notacolor\n A:::bad --> B",
"dark",
)
.expect("renders");
assert!(
svg.contains(theme::DARK.node_fill),
"A must draw in the theme's node colour, not vanish or turn black: {svg}"
);
assert!(
!svg.contains("notacolor"),
"the bad literal must never reach the SVG document"
);
tree_of(&svg);
}
#[test]
fn basis_curve_is_byte_identical_to_render() {
for (name, src) in CORPUS {
let via_curve =
render_curve(src, "dark", "basis").unwrap_or_else(|e| panic!("{name}: {e}"));
let via_render = render(src, "dark").unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(
via_curve, via_render,
"{name}: render_curve(..., \"basis\") must match render() exactly"
);
}
}
#[test]
fn basis_curve_still_rounds_corners() {
let raw = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 40.0),
Point::new(40.0, 40.0),
];
let edge = bent_edge(raw.clone(), Curve::Basis);
assert_eq!(edge.drawn_points(), edges::fix_corners(&raw));
assert_ne!(
edge.drawn_points(),
raw,
"a real right angle must actually be rounded under Curve::Basis"
);
}
#[test]
fn linear_curve_also_rounds_corners() {
let raw = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 40.0),
Point::new(40.0, 40.0),
];
let edge = bent_edge(raw.clone(), Curve::Linear);
assert_eq!(
edge.drawn_points(),
edges::fix_corners(&raw),
"Curve::Linear must run fix_corners, matching every released mermaid version"
);
assert_ne!(
edge.drawn_points(),
raw,
"a real right angle must actually be rounded under Curve::Linear too"
);
assert_eq!(
Curve::Linear.path(&edge.drawn_points()),
edges::polyline_path(&edge.drawn_points())
);
}
#[test]
fn step_curve_also_rounds_corners() {
let raw = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 40.0),
Point::new(40.0, 40.0),
];
let edge = bent_edge(raw.clone(), Curve::Step);
assert_eq!(
edge.drawn_points(),
edges::fix_corners(&raw),
"Curve::Step must run fix_corners, matching every released mermaid version"
);
assert_ne!(edge.drawn_points(), raw);
}
#[test]
fn rounded_curve_does_not_also_run_fix_corners() {
let raw = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 40.0),
Point::new(40.0, 40.0),
];
let edge = bent_edge(raw.clone(), Curve::Rounded);
assert_eq!(
edge.drawn_points(),
raw,
"Curve::Rounded must not run fix_corners — it rounds corners itself"
);
}
#[test]
fn step_variants_differ_from_each_other_and_match_d3() {
let pts = vec![Point::new(0.0, 0.0), Point::new(40.0, 60.0)];
let step = Curve::Step.path(&pts);
let before = Curve::StepBefore.path(&pts);
let after = Curve::StepAfter.path(&pts);
assert_ne!(step, before, "step and stepBefore must draw differently");
assert_ne!(step, after, "step and stepAfter must draw differently");
assert_ne!(
before, after,
"stepBefore and stepAfter must draw differently"
);
let m = |x: f64, y: f64| format!("M{},{}", num(x), num(y));
let l = |x: f64, y: f64| format!("L{},{}", num(x), num(y));
assert_eq!(
before,
format!("{}{}{}", m(0.0, 0.0), l(0.0, 60.0), l(40.0, 60.0)),
"stepBefore: vertical leg first, at the segment's starting x"
);
assert_eq!(
after,
format!("{}{}{}", m(0.0, 0.0), l(40.0, 0.0), l(40.0, 60.0)),
"stepAfter: horizontal leg first, at the segment's ending x"
);
assert_eq!(
step,
format!(
"{}{}{}{}",
m(0.0, 0.0),
l(20.0, 0.0),
l(20.0, 60.0),
l(40.0, 60.0)
),
"step: the transition sits at the segment's midpoint x, and the line still reaches p1"
);
}
#[test]
fn link_style_interpolate_affects_only_that_edge() {
let d = laid_out_curve(
"flowchart TD\n A --> B\n C --> D\n linkStyle 0 interpolate linear",
"basis",
);
assert_eq!(d.edges[0].curve, Curve::Linear);
assert_eq!(d.edges[1].curve, Curve::Basis);
}
#[test]
fn link_style_interpolate_overrides_the_chart_wide_default() {
let d = laid_out_curve(
"flowchart TD\n A --> B\n linkStyle 0 interpolate linear",
"step",
);
assert_eq!(
d.edges[0].curve,
Curve::Linear,
"the edge's own interpolate must win over ui.mermaid_curve"
);
}
#[test]
fn link_style_default_interpolate_applies_unless_an_index_overrides_it() {
let d = laid_out_curve(
"flowchart TD\n A --> B\n C --> D\n linkStyle default interpolate linear\n \
linkStyle 1 interpolate step",
"basis",
);
assert_eq!(
d.edges[0].curve,
Curve::Linear,
"linkStyle default interpolate applies to an edge with no index override"
);
assert_eq!(
d.edges[1].curve,
Curve::Step,
"linkStyle 1's own interpolate must still win over linkStyle default"
);
}
#[test]
fn unknown_curve_falls_back_to_basis_without_crashing() {
assert_eq!(
Curve::parse("basisClosed"),
Curve::Basis,
"a real d3-shape curve, but not one of mermaid's flowchart.curve values"
);
assert_eq!(
Curve::parse("Basis"),
Curve::Basis,
"wrong case — Curve::parse is case-sensitive, matching mermaid's own spellings"
);
assert_eq!(
Curve::parse("nonsense"),
Curve::Basis,
"not a curve name at all"
);
assert_eq!(Curve::parse(""), Curve::Basis, "empty string");
let d = laid_out_curve("flowchart TD\n A --> B", "basisClosed");
assert_eq!(d.edges[0].curve, Curve::Basis);
let svg = render_curve("flowchart TD\n A --> B", "dark", "nonsense")
.expect("an unknown curve must still render, as Curve::Basis");
assert!(svg.contains("<svg"));
}
const RIGHT_ANGLE: [(f64, f64); 3] = [(0.0, 0.0), (60.0, 0.0), (60.0, 60.0)];
const FOUR_POINT: [(f64, f64); 4] = [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (80.0, 40.0)];
const UNEVEN: [(f64, f64); 4] = [(0.0, 0.0), (10.0, 0.0), (40.0, 30.0), (90.0, 35.0)];
const VERTICAL: [(f64, f64); 3] = [(100.0, 50.0), (100.0, 120.0), (100.0, 190.0)];
const HORIZONTAL: [(f64, f64); 3] = [(50.0, 100.0), (120.0, 100.0), (190.0, 100.0)];
const DUPLICATE: [(f64, f64); 4] = [(0.0, 0.0), (40.0, 40.0), (40.0, 40.0), (80.0, 80.0)];
fn pts(raw: &[(f64, f64)]) -> Vec<Point> {
raw.iter().map(|&(x, y)| Point::new(x, y)).collect()
}
#[test]
fn curve_natural_matches_d3() {
assert_eq!(
edges::curve_natural_path(&pts(&RIGHT_ANGLE)),
"M0,0C25,-5 50,-10 60,0C70,10 65,35 60,60"
);
assert_eq!(
edges::curve_natural_path(&pts(&FOUR_POINT)),
"M0,0C17.778,-4.444 35.556,-8.889 40,0C44.444,8.889 35.556,31.111 40,40\
C44.444,48.889 62.222,44.444 80,40"
);
assert_eq!(
edges::curve_natural_path(&pts(&UNEVEN)),
"M0,0C2,-3.222 4,-6.444 10,0C16,6.444 26,22.556 40,30C54,37.444 72,36.222 90,35"
);
assert_eq!(
edges::curve_natural_path(&pts(&VERTICAL)),
"M100,50C100,73.333 100,96.667 100,120C100,143.333 100,166.667 100,190"
);
assert_eq!(
edges::curve_natural_path(&pts(&HORIZONTAL)),
"M50,100C73.333,100 96.667,100 120,100C143.333,100 166.667,100 190,100"
);
assert_eq!(
edges::curve_natural_path(&pts(&DUPLICATE)),
"M0,0C17.778,17.778 35.556,35.556 40,40C44.444,44.444 35.556,35.556 40,40\
C44.444,44.444 62.222,62.222 80,80"
);
assert_eq!(
edges::curve_natural_path(&pts(&RIGHT_ANGLE[..2])),
"M0,0L60,0"
);
assert_eq!(edges::curve_natural_path(&pts(&RIGHT_ANGLE[..1])), "M0,0");
assert_eq!(edges::curve_natural_path(&[]), "");
}
#[test]
fn curve_cardinal_matches_d3() {
assert_eq!(
edges::curve_cardinal_path(&pts(&RIGHT_ANGLE)),
"M0,0C0,0 50,-10 60,0C70,10 60,60 60,60"
);
assert_eq!(
edges::curve_cardinal_path(&pts(&FOUR_POINT)),
"M0,0C0,0 33.333,-6.667 40,0C46.667,6.667 33.333,33.333 40,40\
C46.667,46.667 80,40 80,40"
);
assert_eq!(
edges::curve_cardinal_path(&pts(&UNEVEN)),
"M0,0C0,0 3.333,-5 10,0C16.667,5 26.667,24.167 40,30C53.333,35.833 90,35 90,35"
);
assert_eq!(
edges::curve_cardinal_path(&pts(&VERTICAL)),
"M100,50C100,50 100,96.667 100,120C100,143.333 100,190 100,190"
);
assert_eq!(
edges::curve_cardinal_path(&pts(&HORIZONTAL)),
"M50,100C50,100 96.667,100 120,100C143.333,100 190,100 190,100"
);
assert_eq!(
edges::curve_cardinal_path(&pts(&DUPLICATE)),
"M0,0C0,0 33.333,33.333 40,40C46.667,46.667 33.333,33.333 40,40\
C46.667,46.667 80,80 80,80"
);
assert_eq!(
edges::curve_cardinal_path(&pts(&RIGHT_ANGLE[..2])),
"M0,0L60,0"
);
assert_eq!(edges::curve_cardinal_path(&pts(&RIGHT_ANGLE[..1])), "M0,0");
assert_eq!(edges::curve_cardinal_path(&[]), "");
}
#[test]
fn curve_catmull_rom_matches_d3() {
assert_eq!(
edges::curve_catmull_rom_path(&pts(&RIGHT_ANGLE)),
"M0,0C0,0 50,-10 60,0C70,10 60,60 60,60"
);
assert_eq!(
edges::curve_catmull_rom_path(&pts(&FOUR_POINT)),
"M0,0C0,0 33.333,-6.667 40,0C46.667,6.667 33.333,33.333 40,40\
C46.667,46.667 80,40 80,40"
);
assert_eq!(
edges::curve_catmull_rom_path(&pts(&UNEVEN)),
"M0,0C0,0 6.169,-1.587 10,0C17.89,3.268 27.455,24.055 40,30C53.653,36.47 90,35 90,35"
);
assert_ne!(
edges::curve_catmull_rom_path(&pts(&UNEVEN)),
edges::curve_cardinal_path(&pts(&UNEVEN)),
"catmullRom and cardinal must differ once waypoints are unevenly spaced"
);
assert_eq!(
edges::curve_catmull_rom_path(&pts(&VERTICAL)),
"M100,50C100,50 100,96.667 100,120C100,143.333 100,190 100,190"
);
assert_eq!(
edges::curve_catmull_rom_path(&pts(&HORIZONTAL)),
"M50,100C50,100 96.667,100 120,100C143.333,100 190,100 190,100"
);
assert_eq!(
edges::curve_catmull_rom_path(&pts(&DUPLICATE)),
"M0,0C0,0 40,40 40,40C40,40 40,40 40,40C40,40 80,80 80,80"
);
assert_eq!(
edges::curve_catmull_rom_path(&pts(&RIGHT_ANGLE[..2])),
"M0,0L60,0"
);
assert_eq!(
edges::curve_catmull_rom_path(&pts(&RIGHT_ANGLE[..1])),
"M0,0"
);
assert_eq!(edges::curve_catmull_rom_path(&[]), "");
}
#[test]
fn curve_monotone_x_matches_d3() {
assert_eq!(
edges::curve_monotone_x_path(&pts(&RIGHT_ANGLE)),
"M0,0C20,0 40,0 60,0C60,0 60,60 60,60"
);
assert_eq!(
edges::curve_monotone_x_path(&pts(&FOUR_POINT)),
"M0,0C13.333,0 26.667,0 40,0C40,0 40,40 40,40C53.333,40 66.667,40 80,40"
);
assert_eq!(
edges::curve_monotone_x_path(&pts(&UNEVEN)),
"M0,0C3.333,0 6.667,0 10,0C20,0 30,28 40,30C56.667,33.333 73.333,34.167 90,35"
);
assert_eq!(
edges::curve_monotone_x_path(&pts(&VERTICAL)),
"M100,50C100,50 100,120 100,120C100,120 100,190 100,190"
);
assert_eq!(
edges::curve_monotone_x_path(&pts(&HORIZONTAL)),
"M50,100C73.333,100 96.667,100 120,100C143.333,100 166.667,100 190,100"
);
assert_eq!(
edges::curve_monotone_x_path(&pts(&DUPLICATE)),
"M0,0C13.333,13.333 26.667,26.667 40,40C53.333,53.333 66.667,66.667 80,80"
);
assert_eq!(
edges::curve_monotone_x_path(&pts(&RIGHT_ANGLE[..2])),
"M0,0L60,0"
);
assert_eq!(
edges::curve_monotone_x_path(&pts(&RIGHT_ANGLE[..1])),
"M0,0"
);
assert_eq!(edges::curve_monotone_x_path(&[]), "");
}
#[test]
fn curve_monotone_y_matches_d3() {
assert_eq!(
edges::curve_monotone_y_path(&pts(&RIGHT_ANGLE)),
"M0,0C0,0 60,0 60,0C60,20 60,40 60,60"
);
assert_eq!(
edges::curve_monotone_y_path(&pts(&FOUR_POINT)),
"M0,0C0,0 40,0 40,0C40,13.333 40,26.667 40,40C40,40 80,40 80,40"
);
assert_eq!(
edges::curve_monotone_y_path(&pts(&UNEVEN)),
"M0,0C0,0 10,0 10,0C30,10 20,20 40,30C43.333,31.667 66.667,33.333 90,35"
);
assert_ne!(
edges::curve_monotone_x_path(&pts(&UNEVEN)),
edges::curve_monotone_y_path(&pts(&UNEVEN)),
"monotoneX and monotoneY must draw differently — the whole point of the reflected axis"
);
assert_eq!(
edges::curve_monotone_y_path(&pts(&VERTICAL)),
"M100,50C100,73.333 100,96.667 100,120C100,143.333 100,166.667 100,190"
);
assert_eq!(
edges::curve_monotone_y_path(&pts(&HORIZONTAL)),
"M50,100C50,100 120,100 120,100C120,100 190,100 190,100"
);
assert_eq!(
edges::curve_monotone_y_path(&pts(&DUPLICATE)),
"M0,0C13.333,13.333 26.667,26.667 40,40C53.333,53.333 66.667,66.667 80,80"
);
assert_eq!(
edges::curve_monotone_y_path(&pts(&RIGHT_ANGLE[..2])),
"M0,0L60,0"
);
assert_eq!(
edges::curve_monotone_y_path(&pts(&RIGHT_ANGLE[..1])),
"M0,0"
);
assert_eq!(edges::curve_monotone_y_path(&[]), "");
}
#[test]
fn curve_bump_x_matches_d3() {
assert_eq!(
edges::curve_bump_x_path(&pts(&RIGHT_ANGLE)),
"M0,0C30,0 30,0 60,0C60,0 60,60 60,60"
);
assert_eq!(
edges::curve_bump_x_path(&pts(&FOUR_POINT)),
"M0,0C20,0 20,0 40,0C40,0 40,40 40,40C60,40 60,40 80,40"
);
assert_eq!(
edges::curve_bump_x_path(&pts(&UNEVEN)),
"M0,0C5,0 5,0 10,0C25,0 25,30 40,30C65,30 65,35 90,35"
);
assert_eq!(
edges::curve_bump_x_path(&pts(&RIGHT_ANGLE[..2])),
"M0,0C30,0 30,0 60,0"
);
assert_eq!(
edges::curve_bump_x_path(&pts(&VERTICAL)),
"M100,50C100,50 100,120 100,120C100,120 100,190 100,190"
);
assert_eq!(
edges::curve_bump_x_path(&pts(&HORIZONTAL)),
"M50,100C85,100 85,100 120,100C155,100 155,100 190,100"
);
assert_eq!(
edges::curve_bump_x_path(&pts(&DUPLICATE)),
"M0,0C20,0 20,40 40,40C40,40 40,40 40,40C60,40 60,80 80,80"
);
assert_eq!(edges::curve_bump_x_path(&pts(&RIGHT_ANGLE[..1])), "M0,0");
assert_eq!(edges::curve_bump_x_path(&[]), "");
}
#[test]
fn curve_bump_y_matches_d3() {
assert_eq!(
edges::curve_bump_y_path(&pts(&RIGHT_ANGLE)),
"M0,0C0,0 60,0 60,0C60,30 60,30 60,60"
);
assert_eq!(
edges::curve_bump_y_path(&pts(&FOUR_POINT)),
"M0,0C0,0 40,0 40,0C40,20 40,20 40,40C40,40 80,40 80,40"
);
assert_eq!(
edges::curve_bump_y_path(&pts(&UNEVEN)),
"M0,0C0,0 10,0 10,0C10,15 40,15 40,30C40,32.5 90,32.5 90,35"
);
assert_ne!(
edges::curve_bump_x_path(&pts(&UNEVEN)),
edges::curve_bump_y_path(&pts(&UNEVEN)),
"bumpX and bumpY must draw differently"
);
assert_eq!(
edges::curve_bump_y_path(&pts(&RIGHT_ANGLE[..2])),
"M0,0C0,0 60,0 60,0"
);
assert_eq!(
edges::curve_bump_y_path(&pts(&VERTICAL)),
"M100,50C100,85 100,85 100,120C100,155 100,155 100,190"
);
assert_eq!(
edges::curve_bump_y_path(&pts(&HORIZONTAL)),
"M50,100C50,100 120,100 120,100C120,100 190,100 190,100"
);
assert_eq!(
edges::curve_bump_y_path(&pts(&DUPLICATE)),
"M0,0C0,20 40,20 40,40C40,40 40,40 40,40C40,60 80,60 80,80"
);
assert_eq!(edges::curve_bump_y_path(&pts(&RIGHT_ANGLE[..1])), "M0,0");
assert_eq!(edges::curve_bump_y_path(&[]), "");
}
#[test]
fn rounded_curve_matches_mermaid() {
let radius = edges::CORNER_RADIUS;
assert_eq!(
edges::rounded_path(&pts(&[(0.0, 0.0), (0.0, 40.0), (40.0, 40.0)]), radius),
"M0,0L0,32.929Q0,40 7.071,40L40,40"
);
assert_eq!(
edges::rounded_path(&pts(&UNEVEN), radius),
"M0,0L5,0Q10,0 13.536,3.536L29.483,19.483Q40,30 54.799,31.48L90,35"
);
assert_eq!(
edges::rounded_path(&pts(&[(0.0, 0.0), (50.0, 0.0), (100.0, 0.0)]), radius),
"M0,0L50,0L100,0"
);
assert_eq!(
edges::rounded_path(&pts(&RIGHT_ANGLE[..2]), radius),
"M0,0L60,0",
"two points is just a line, corner or not"
);
assert_eq!(
edges::rounded_path(&pts(&VERTICAL), radius),
"M100,50L100,120L100,190"
);
assert_eq!(
edges::rounded_path(&pts(&HORIZONTAL), radius),
"M50,100L120,100L190,100"
);
assert_eq!(
edges::rounded_path(&pts(&DUPLICATE), radius),
"M0,0L40,40L40,40L80,80"
);
assert_eq!(edges::rounded_path(&pts(&RIGHT_ANGLE[..1]), radius), "M0,0");
assert_eq!(edges::rounded_path(&[], radius), "");
}
#[test]
fn every_curve_name_parses_to_a_distinct_curve() {
let names = [
"basis",
"linear",
"step",
"stepBefore",
"stepAfter",
"natural",
"cardinal",
"catmullRom",
"monotoneX",
"monotoneY",
"bumpX",
"bumpY",
"rounded",
];
let parsed: Vec<Curve> = names.iter().map(|s| Curve::parse(s)).collect();
for (i, a) in parsed.iter().enumerate() {
for (j, b) in parsed.iter().enumerate() {
if i != j {
assert_ne!(
a, b,
"{} and {} must not parse to the same Curve",
names[i], names[j]
);
}
}
}
}
#[test]
fn the_eight_new_curves_draw_different_paths_from_each_other() {
let p = pts(&UNEVEN);
let named: Vec<(&str, String)> = vec![
("natural", edges::curve_natural_path(&p)),
("cardinal", edges::curve_cardinal_path(&p)),
("catmullRom", edges::curve_catmull_rom_path(&p)),
("monotoneX", edges::curve_monotone_x_path(&p)),
("monotoneY", edges::curve_monotone_y_path(&p)),
("bumpX", edges::curve_bump_x_path(&p)),
("bumpY", edges::curve_bump_y_path(&p)),
("rounded", edges::rounded_path(&p, edges::CORNER_RADIUS)),
];
for i in 0..named.len() {
for j in (i + 1)..named.len() {
assert_ne!(
named[i].1, named[j].1,
"{} and {} must draw differently",
named[i].0, named[j].0
);
}
}
}
type CurvePathFn = fn(&[Point]) -> String;
#[test]
fn curve_parse_and_path_reach_the_function_that_was_pinned_against_upstream() {
let p = pts(&UNEVEN);
let cases: &[(&str, CurvePathFn)] = &[
("basis", edges::curve_basis_path),
("linear", edges::polyline_path),
("natural", edges::curve_natural_path),
("cardinal", edges::curve_cardinal_path),
("catmullRom", edges::curve_catmull_rom_path),
("monotoneX", edges::curve_monotone_x_path),
("monotoneY", edges::curve_monotone_y_path),
("bumpX", edges::curve_bump_x_path),
("bumpY", edges::curve_bump_y_path),
];
for (name, f) in cases {
assert_eq!(
Curve::parse(name).path(&p),
f(&p),
"Curve::parse(\"{name}\").path(...) must reach the same function {name} was pinned \
against upstream with"
);
}
assert_eq!(
Curve::parse("rounded").path(&p),
edges::rounded_path(&p, edges::CORNER_RADIUS)
);
assert_eq!(Curve::parse("step").path(&p), edges::step_path(&p, 0.5));
assert_eq!(
Curve::parse("stepBefore").path(&p),
edges::step_path(&p, 0.0)
);
assert_eq!(
Curve::parse("stepAfter").path(&p),
edges::step_path(&p, 1.0)
);
}
type CurveDegenerateCase = (&'static str, &'static [(f64, f64)], &'static str);
#[test]
fn all_thirteen_curves_match_d3_or_mermaid_on_degenerate_points_through_curve_path() {
let cases: &[CurveDegenerateCase] = &[
(
"basis",
&VERTICAL,
"M100,50L100,61.667C100,73.333 100,96.667 100,120\
C100,143.333 100,166.667 100,178.333L100,190",
),
("linear", &VERTICAL, "M100,50 L100,120 L100,190"),
("step", &VERTICAL, "M100,50L100,120L100,190"),
("stepBefore", &VERTICAL, "M100,50L100,120L100,190"),
("stepAfter", &VERTICAL, "M100,50L100,120L100,190"),
(
"natural",
&VERTICAL,
"M100,50C100,73.333 100,96.667 100,120\
C100,143.333 100,166.667 100,190",
),
(
"cardinal",
&VERTICAL,
"M100,50C100,50 100,96.667 100,120C100,143.333 100,190 100,190",
),
(
"catmullRom",
&VERTICAL,
"M100,50C100,50 100,96.667 100,120C100,143.333 100,190 100,190",
),
(
"monotoneX",
&VERTICAL,
"M100,50C100,50 100,120 100,120C100,120 100,190 100,190",
),
(
"monotoneY",
&HORIZONTAL,
"M50,100C50,100 120,100 120,100C120,100 190,100 190,100",
),
(
"bumpX",
&VERTICAL,
"M100,50C100,50 100,120 100,120C100,120 100,190 100,190",
),
(
"bumpY",
&HORIZONTAL,
"M50,100C50,100 120,100 120,100C120,100 190,100 190,100",
),
("rounded", &VERTICAL, "M100,50L100,120L100,190"),
];
for (name, raw, expected) in cases {
let p = pts(raw);
assert_eq!(
Curve::parse(name).path(&p),
*expected,
"Curve::parse(\"{name}\").path(...) on a degenerate point set"
);
assert!(
!Curve::parse(name).path(&p).contains("NaN"),
"{name} must not emit NaN through Curve::path"
);
}
}
#[test]
fn no_curve_ever_emits_nan_or_infinite_coordinates() {
let names = [
"basis",
"linear",
"step",
"stepBefore",
"stepAfter",
"natural",
"cardinal",
"catmullRom",
"monotoneX",
"monotoneY",
"bumpX",
"bumpY",
"rounded",
];
let degenerate: &[&[(f64, f64)]] = &[
&VERTICAL,
&HORIZONTAL,
&DUPLICATE,
&[(50.0, 50.0), (50.0, 50.0), (50.0, 50.0)],
&[(0.0, 0.0), (0.0, 100.0)],
&[(0.0, 0.0), (100.0, 0.0)],
&[(100.0, 100.0), (100.0, 100.0)],
];
for name in names {
for raw in degenerate {
let p = pts(raw);
let d = Curve::parse(name).path(&p);
assert!(
!d.contains("NaN") && !d.contains("inf") && !d.contains("Inf"),
"{name} emitted a non-finite coordinate for {raw:?}: {d}"
);
}
}
}
#[test]
fn rounds_corners_matches_mermaids_fix_corners_gate() {
for curve in [
Curve::Basis,
Curve::Linear,
Curve::Step,
Curve::StepBefore,
Curve::StepAfter,
Curve::Natural,
Curve::Cardinal,
Curve::CatmullRom,
Curve::MonotoneX,
Curve::MonotoneY,
Curve::BumpX,
Curve::BumpY,
] {
assert!(curve.rounds_corners(), "{curve:?} must run fix_corners");
}
assert!(
!Curve::Rounded.rounds_corners(),
"Curve::Rounded must not run fix_corners — it rounds corners itself"
);
}
#[test]
fn init_directive_curve_overrides_the_config_default() {
let d = laid_out_curve(
"%%{init: {\"flowchart\": {\"curve\": \"linear\"}}}%%\nflowchart TD\n A --> B",
"step",
);
assert_eq!(
d.edges[0].curve,
Curve::Linear,
"the init directive must win over ui.mermaid_curve"
);
}
#[test]
fn link_style_interpolate_overrides_init_directive_curve() {
let d = laid_out_curve(
"%%{init: {\"flowchart\": {\"curve\": \"linear\"}}}%%\n\
flowchart TD\n A --> B\n linkStyle 0 interpolate step",
"basis",
);
assert_eq!(
d.edges[0].curve,
Curve::Step,
"linkStyle interpolate must win over both the init directive and the config default"
);
}
#[test]
fn link_style_default_interpolate_sits_between_init_and_indexed_link_style() {
let d = laid_out_curve(
"%%{init: {\"flowchart\": {\"curve\": \"linear\"}}}%%\n\
flowchart TD\n A --> B\n C --> D\n \
linkStyle default interpolate step\n linkStyle 1 interpolate stepBefore",
"basis",
);
assert_eq!(
d.edges[0].curve,
Curve::Step,
"linkStyle default interpolate must win over the init directive for an edge with no index override"
);
assert_eq!(
d.edges[1].curve,
Curve::StepBefore,
"linkStyle 1's own interpolate must still win over linkStyle default"
);
}
#[test]
fn init_directive_naming_only_theme_still_does_not_touch_curve() {
let d = laid_out_curve(
"%%{init: {\"theme\": \"dark\"}}%%\nflowchart TD\n A --> B",
"step",
);
assert_eq!(
d.edges[0].curve,
Curve::Step,
"a theme-only init directive must leave the config default untouched"
);
}
#[test]
fn init_directive_curve_survives_formatting_variation() {
let sources = [
"%%{init: {\"flowchart\": {\"curve\": \"linear\"}}}%%\nflowchart TD\n A --> B",
"%%{init: {'flowchart': {'curve': 'linear'}}}%%\nflowchart TD\n A --> B",
"%%{init: {\n \"flowchart\": {\n 'curve': \"linear\"\n }\n}}%%\n\
flowchart TD\n A --> B",
"%%{init: {\"theme\": \"dark\", \"flowchart\": {\"curve\": \"linear\"}}}%%\n\
flowchart TD\n A --> B",
"%%{init: {\"flowchart\": {\"htmlLabels\": false, \"curve\": \"linear\"}}}%%\n\
flowchart TD\n A --> B",
"%%{init: {flowchart: {curve: linear}}}%%\nflowchart TD\n A --> B",
"%%{init:{\"flowchart\":{\"curve\":\"linear\"}}}%%\nflowchart TD\n A --> B",
];
for src in sources {
let d = laid_out_curve(src, "basis");
assert_eq!(
d.edges[0].curve,
Curve::Linear,
"must resolve to Linear regardless of formatting: {src}"
);
}
}
#[test]
fn malformed_init_directive_falls_back_without_crashing() {
let sources = [
"%%{init: {\"flowchart\": {\"curve\": 5}}}%%\nflowchart TD\n A --> B",
"%%{init: {\"flowchart\": {\"curve\": {\"x\": 1}}}}%%\nflowchart TD\n A --> B",
"%%{init: {\"flowchart\": {\"curve\": [\"linear\"]}}}%%\nflowchart TD\n A --> B",
"%%{init: {\"flowchart\": \"oops\"}}%%\nflowchart TD\n A --> B",
"%%{init: {\"flowchart\": {\"curve\": \"linear\"\nflowchart TD\n A --> B",
"%%{init: {\"curve\": \"linear\"}}%%\nflowchart TD\n A --> B",
"%%{init: {}}%%\nflowchart TD\n A --> B",
];
for src in sources {
let d = laid_out_curve(src, "step");
assert_eq!(
d.edges[0].curve,
Curve::Step,
"a malformed init directive must fall back to the config default: {src}"
);
let svg = render_curve(src, "dark", "step")
.unwrap_or_else(|e| panic!("must still render despite a malformed directive: {e}"));
assert!(svg.contains("<svg"));
}
}
#[test]
fn cjk_labels_render_with_an_init_directive_curve() {
let src = "%%{init: {\"flowchart\": {\"curve\": \"monotoneX\"}}}%%\n\
flowchart TD\n A[ツリー] -->|Enter| B{種別を解決}\n B -->|画像| C[全画面プレビュー]";
let d = laid_out_curve(src, "basis");
assert_eq!(d.edges[0].curve, Curve::MonotoneX);
let svg = render_curve(src, "dark", "basis").unwrap_or_else(|e| panic!("must render: {e}"));
assert!(svg.contains("<svg"));
}
#[test]
fn different_curves_draw_different_svg_path_data() {
let (_, src) = CORPUS
.iter()
.find(|(name, _)| *name == "long-edge")
.expect("corpus must still have `long-edge`");
let basis = render_curve(src, "dark", "basis").expect("renders");
let linear = render_curve(src, "dark", "linear").expect("renders");
let step = render_curve(src, "dark", "step").expect("renders");
assert_ne!(
basis, linear,
"a bent edge must draw differently under linear"
);
assert_ne!(basis, step, "a bent edge must draw differently under step");
assert_ne!(
linear, step,
"linear and step must draw differently from each other too"
);
}
#[test]
fn cjk_labels_render_under_every_curve() {
let src = "flowchart TD\n A[ツリー] -->|Enter| B{種別を解決}\n \
B -->|画像| C[全画面プレビュー]\n B -->|テキスト| D[窓読み]\n D --> A";
for curve in [
"basis",
"linear",
"step",
"stepBefore",
"stepAfter",
"natural",
"cardinal",
"catmullRom",
"monotoneX",
"monotoneY",
"bumpX",
"bumpY",
"rounded",
] {
let svg = render_curve(src, "dark", curve).unwrap_or_else(|e| panic!("{curve}: {e}"));
assert!(
svg.contains("<svg"),
"{curve}: CJK labels must still render"
);
}
}
#[test]
fn whitespace_only_curve_name_falls_back_to_basis() {
assert_eq!(Curve::parse(" "), Curve::Basis);
assert_eq!(Curve::parse("\t"), Curve::Basis);
assert_eq!(Curve::parse("\n"), Curve::Basis);
let d = laid_out_curve("flowchart TD\n A --> B", " ");
assert_eq!(d.edges[0].curve, Curve::Basis);
let svg = render_curve("flowchart TD\n A --> B", "dark", " ")
.expect("a blank curve name must still render, as Curve::Basis");
assert!(svg.contains("<svg"));
}
#[test]
fn a_second_init_directive_overrides_the_first_ones_curve() {
let d = laid_out_curve(
"%%{init: {\"flowchart\": {\"curve\": \"linear\"}}}%%\n\
%%{init: {\"flowchart\": {\"curve\": \"step\"}}}%%\n\
flowchart TD\n A --> B",
"basis",
);
assert_eq!(
d.edges[0].curve,
Curve::Step,
"the second init directive must win over the first"
);
}
#[test]
fn an_init_directive_after_the_header_still_sets_the_curve() {
let d = laid_out_curve(
"flowchart TD\n A --> B\n \
%%{init: {\"flowchart\": {\"curve\": \"linear\"}}}%%\n B --> C",
"basis",
);
assert_eq!(
d.edges[0].curve,
Curve::Linear,
"a mid-diagram init directive must still set the chart-wide curve"
);
assert_eq!(d.edges[1].curve, Curve::Linear);
}
#[test]
fn link_style_interpolate_with_multiple_indices_reaches_every_named_edge() {
let d = laid_out_curve(
"flowchart TD\n A --> B\n B --> C\n C --> D\n D --> E\n \
linkStyle 0,1,2 interpolate linear",
"basis",
);
assert_eq!(d.edges.len(), 4);
for i in 0..3 {
assert_eq!(
d.edges[i].curve,
Curve::Linear,
"edge {i} is named by `linkStyle 0,1,2 interpolate linear`"
);
}
assert_eq!(
d.edges[3].curve,
Curve::Basis,
"edge 3 (D->E) was not named, and must keep the chart-wide default"
);
}
#[test]
fn link_style_interpolate_naming_an_index_past_the_last_edge_does_not_crash() {
let d = laid_out_curve(
"flowchart TD\n A --> B\n linkStyle 5 interpolate linear",
"step",
);
assert_eq!(d.edges.len(), 1);
assert_eq!(
d.edges[0].curve,
Curve::Step,
"an out-of-range linkStyle index must touch no real edge"
);
let svg = render_curve(
"flowchart TD\n A --> B\n linkStyle 5 interpolate linear",
"dark",
"step",
)
.expect("an out-of-range linkStyle index must not crash the render");
assert!(svg.contains("<svg"));
}
#[test]
fn link_style_interpolate_and_stroke_on_the_same_statement_both_apply() {
let d = laid_out_curve(
"flowchart TD\n A --> B\n linkStyle 0 interpolate linear stroke:#1f6feb",
"basis",
);
assert_eq!(d.edges[0].curve, Curve::Linear, "the curve must apply");
assert_eq!(
d.edges[0]
.style
.as_ref()
.expect("the style must apply too")
.stroke
.as_deref(),
Some("#1f6feb")
);
}
#[test]
fn non_flowchart_diagrams_ignore_mermaid_curve() {
use crate::preview::markdown::mermaid_to_svg_curve;
let cases = [
"stateDiagram-v2\n [*] --> A\n A --> B\n B --> [*]",
"classDiagram\n A --|> B",
"erDiagram\n A ||--o{ B : has",
];
for src in cases {
let basis = mermaid_to_svg_curve(src, "dark", "basis").expect("must render");
let step = mermaid_to_svg_curve(src, "dark", "step").expect("must render");
assert_eq!(
basis, step,
"a non-flowchart diagram must draw identically regardless of mermaid_curve: {src}"
);
}
}
#[test]
fn curve_reaches_an_edge_that_crosses_a_subgraph_frame() {
let src = "flowchart LR\n subgraph one [First]\n A --> B\n end\n \
subgraph two [Second]\n C --> D\n end\n one --> two\n E --> one";
let d = laid_out_curve(src, "step");
assert!(
!d.clusters.is_empty(),
"the source must actually produce a subgraph frame"
);
for e in &d.edges {
assert_eq!(
e.curve,
Curve::Step,
"every edge must resolve to the configured curve, cluster-bound or not"
);
}
let basis = render_curve(src, "dark", "basis").expect("renders");
let step = render_curve(src, "dark", "step").expect("renders");
assert_ne!(
basis, step,
"a subgraph-bearing diagram must still draw visibly differently under two curves"
);
}
#[test]
fn curve_resolves_identically_under_the_legacy_graph_keyword() {
let flowchart = laid_out_curve("flowchart LR\n A --> B", "monotoneX");
let graph = laid_out_curve("graph LR\n A --> B", "monotoneX");
assert_eq!(flowchart.edges[0].curve, Curve::MonotoneX);
assert_eq!(graph.edges[0].curve, Curve::MonotoneX);
let flowchart2 = laid_out_curve(
"flowchart LR\n A --> B\n linkStyle 0 interpolate step",
"basis",
);
let graph2 = laid_out_curve(
"graph LR\n A --> B\n linkStyle 0 interpolate step",
"basis",
);
assert_eq!(flowchart2.edges[0].curve, Curve::Step);
assert_eq!(graph2.edges[0].curve, Curve::Step);
}
#[test]
fn every_corpus_source_survives_every_curve() {
if !text_metrics::fonts_available() {
return;
}
let curves = [
"basis",
"linear",
"step",
"stepBefore",
"stepAfter",
"natural",
"cardinal",
"catmullRom",
"monotoneX",
"monotoneY",
"bumpX",
"bumpY",
"rounded",
];
let extra: &[(&str, &str)] = &[
("zero-edges", "flowchart TD\n A[one]\n B[two]\n C[three]"),
("single-node", "flowchart TD\n A[Solo]"),
];
for (name, src) in CORPUS.iter().chain(extra) {
for curve in curves {
let svg = render_curve(src, "dark", curve)
.unwrap_or_else(|e| panic!("{name} under {curve}: must render: {e}"));
assert!(
!svg.contains("NaN") && !svg.contains("inf") && !svg.contains("Inf"),
"{name} under {curve}: emitted a non-finite coordinate: {svg}"
);
let d = laid_out_curve(src, curve);
assert!(
d.width.is_finite() && d.height.is_finite() && d.width > 0.0 && d.height > 0.0,
"{name} under {curve}: {}x{} is not a drawable size",
num(d.width),
num(d.height)
);
assert!(
crate::preview::svg::rasterize_bytes(svg.as_bytes(), FsPath::new("m.svg"), 300)
.is_some(),
"{name} under {curve}: the output did not rasterise"
);
}
}
}
#[test]
fn a_large_cyclic_and_deeply_nested_graph_survives_every_curve() {
if !text_metrics::fonts_available() {
return;
}
let mut wide = String::from("flowchart LR\n");
for i in 0..40 {
wide.push_str(&format!(" n{i} --> n{}\n", i + 1));
}
wide.push_str(" n0 --> n40\n n40 --> n0\n");
let mut nested = String::from("flowchart TD\n");
for i in 0..8 {
nested.push_str(&format!(" subgraph s{i} [Level {i}]\n"));
}
nested.push_str(" A --> B\n");
for _ in 0..8 {
nested.push_str(" end\n");
}
nested.push_str(" s0 --> C\n C --> s7\n");
let curves = [
"basis",
"linear",
"step",
"stepBefore",
"stepAfter",
"natural",
"cardinal",
"catmullRom",
"monotoneX",
"monotoneY",
"bumpX",
"bumpY",
"rounded",
];
for (label, src) in [
("wide-cycle", wide.as_str()),
("deep-nested", nested.as_str()),
] {
for curve in curves {
let svg = render_curve(src, "dark", curve)
.unwrap_or_else(|e| panic!("{label} under {curve}: must render: {e}"));
assert!(
!svg.contains("NaN") && !svg.contains("inf") && !svg.contains("Inf"),
"{label} under {curve}: emitted a non-finite coordinate"
);
}
}
}
#[test]
fn a_second_link_style_interpolate_on_the_same_index_overrides_the_first() {
let d = laid_out_curve(
"flowchart TD\n A --> B\n \
linkStyle 0 interpolate step\n linkStyle 0 interpolate linear",
"basis",
);
assert_eq!(
d.edges[0].curve,
Curve::Linear,
"a later linkStyle 0 interpolate must override an earlier one naming the same index"
);
let d2 = laid_out_curve(
"flowchart TD\n A --> B\n \
linkStyle default interpolate step\n linkStyle default interpolate linear",
"basis",
);
assert_eq!(
d2.edges[0].curve,
Curve::Linear,
"a later linkStyle default interpolate must override an earlier one"
);
}
#[test]
fn mermaid_curve_and_mermaid_theme_are_independent_axes() {
let (_, src) = CORPUS
.iter()
.find(|(name, _)| *name == "long-edge")
.expect("corpus must still have `long-edge`");
fn color_attrs(svg: &str) -> Vec<&str> {
svg.split_whitespace()
.filter(|w| w.starts_with("fill=\"") || w.starts_with("stroke=\""))
.collect()
}
let geometry_of = |svg: &str| -> String {
svg.split_whitespace()
.filter(|w| !w.starts_with("fill=") && !w.starts_with("stroke=\""))
.collect::<Vec<_>>()
.join(" ")
};
let basis = render_curve(src, "dark", "basis").expect("renders");
for curve in ["linear", "step", "monotoneX"] {
let other = render_curve(src, "dark", curve).expect("renders");
assert_eq!(
color_attrs(&basis),
color_attrs(&other),
"curve {curve} must not move a single colour attribute away from the basis render"
);
assert_ne!(
basis, other,
"curve {curve} must still change something (the path data) — otherwise this \
comparison is vacuous"
);
}
let linear_dark = render_curve(src, "dark", "linear").expect("renders");
let base_geometry = geometry_of(&linear_dark);
for theme in ["light", "modern", "classic", "mermaid", "forest", "neutral"] {
let other = render_curve(src, theme, "linear").expect("renders");
assert_eq!(
base_geometry,
geometry_of(&other),
"theme {theme} moved curve=\"linear\" geometry, not just colour"
);
assert_ne!(
linear_dark, other,
"theme {theme} must still change colour — otherwise this comparison is vacuous"
);
}
}
fn bent_edge(points: Vec<Point>, curve: Curve) -> PlacedEdge {
PlacedEdge {
from: "a".to_string(),
to: "b".to_string(),
points,
gaps: Vec::new(),
tip_start: Tip::None,
tip_end: Tip::None,
stroke: Stroke::Normal,
label: None,
start_label: None,
end_label: None,
badge: None,
series: None,
straight: false,
overlay: false,
style: None,
curve,
tip_matches_line: false,
}
}
fn edge_label_text(e: &PlacedEdge) -> String {
e.label
.as_ref()
.map(|l| l.label.lines.join("\n"))
.unwrap_or_default()
}
#[test]
fn link_style_index_targets_the_declared_edge_among_parallel_edges() {
let d = laid_out_curve(
"flowchart TD\n A -->|x| B\n A -->|y| B\n \
linkStyle 0 interpolate linear\n linkStyle 1 interpolate step",
"basis",
);
let x = d
.edges
.iter()
.find(|e| edge_label_text(e) == "x")
.expect("the \"x\"-labelled edge must exist");
let y = d
.edges
.iter()
.find(|e| edge_label_text(e) == "y")
.expect("the \"y\"-labelled edge must exist");
assert_eq!(
x.curve,
Curve::Linear,
"linkStyle 0 must land on the first-declared A->B edge (labelled x)"
);
assert_eq!(
y.curve,
Curve::Step,
"linkStyle 1 must land on the second-declared A->B edge (labelled y), not the first"
);
}
#[test]
fn link_style_index_targets_the_declared_edge_among_three_parallel_edges() {
let d = laid_out_curve(
"flowchart TD\n A -->|x| B\n A -->|y| B\n A -->|z| B\n \
linkStyle 0 interpolate linear\n linkStyle 1 interpolate step\n \
linkStyle 2 interpolate monotoneX",
"basis",
);
let find = |name: &str| {
d.edges
.iter()
.find(|e| edge_label_text(e) == name)
.unwrap_or_else(|| panic!("edge labelled {name} must exist"))
};
assert_eq!(
find("x").curve,
Curve::Linear,
"linkStyle 0 -> first-declared (x)"
);
assert_eq!(
find("y").curve,
Curve::Step,
"linkStyle 1 -> second-declared (y)"
);
assert_eq!(
find("z").curve,
Curve::MonotoneX,
"linkStyle 2 -> third-declared (z)"
);
}
#[test]
fn self_loop_before_parallel_edges_does_not_shift_link_style_indices() {
let d = laid_out_curve(
"flowchart TD\n A --> A\n A -->|x| B\n A -->|y| B\n \
linkStyle 1 interpolate linear\n linkStyle 2 interpolate step",
"basis",
);
let x = d
.edges
.iter()
.find(|e| edge_label_text(e) == "x")
.expect("the \"x\"-labelled edge must exist");
let y = d
.edges
.iter()
.find(|e| edge_label_text(e) == "y")
.expect("the \"y\"-labelled edge must exist");
let loop_edge = d
.edges
.iter()
.find(|e| e.from == "A" && e.to == "A")
.expect("the self-loop must still be drawn");
assert_eq!(
loop_edge.curve,
Curve::Basis,
"the self-loop (index 0) names no linkStyle of its own; it must keep the chart-wide default"
);
assert_eq!(
x.curve,
Curve::Linear,
"linkStyle 1 must land on the edge declared second (x), not on the self-loop"
);
assert_eq!(
y.curve,
Curve::Step,
"linkStyle 2 must land on the edge declared third (y)"
);
}
#[test]
fn self_loop_between_parallel_edges_does_not_shift_link_style_indices() {
let d = laid_out_curve(
"flowchart TD\n A -->|x| B\n A --> A\n A -->|y| B\n \
linkStyle 0 interpolate linear\n linkStyle 2 interpolate step",
"basis",
);
let x = d
.edges
.iter()
.find(|e| edge_label_text(e) == "x")
.expect("the \"x\"-labelled edge must exist");
let y = d
.edges
.iter()
.find(|e| edge_label_text(e) == "y")
.expect("the \"y\"-labelled edge must exist");
let loop_edge = d
.edges
.iter()
.find(|e| e.from == "A" && e.to == "A")
.expect("the self-loop must still be drawn");
assert_eq!(
x.curve,
Curve::Linear,
"linkStyle 0 must land on the first-declared edge (x)"
);
assert_eq!(
loop_edge.curve,
Curve::Basis,
"the self-loop (index 1) names no linkStyle; it must keep the chart-wide default"
);
assert_eq!(
y.curve,
Curve::Step,
"linkStyle 2 must land on the third-declared edge (y), not shifted by the self-loop between them"
);
}
fn orthogonal_corpus() -> Vec<(&'static str, &'static str)> {
CORPUS.to_vec()
}
fn orthogonal_dag_corpus() -> Vec<(&'static str, &'static str)> {
const UNCAPPED: &[&str] = &[
"branch",
"cjk",
"long-edge",
"left-right",
"self-loop",
"amp-chain",
"strokes",
"subgraph-bypass",
"subgraph",
];
orthogonal_corpus()
.into_iter()
.filter(|(name, _)| !UNCAPPED.contains(name))
.collect()
}
fn edge_segments(d: &Diagram) -> Vec<(Point, Point)> {
let mut out = Vec::new();
for e in &d.edges {
for w in e.points.windows(2) {
out.push((w[0].clone(), w[1].clone()));
}
}
out
}
const AXIS_EPS: f64 = 1e-6;
#[test]
fn orthogonal_splines_routing_is_deterministic_and_does_not_panic() {
for (name, src) in CORPUS {
for curve in ["basis", "linear", "step", "monotoneX"] {
let a = render_curve(src, "dark", curve)
.unwrap_or_else(|e| panic!("{name}/{curve}: render_curve must render: {e}"));
let b = render_flow(src, "dark", curve, "splines")
.unwrap_or_else(|e| panic!("{name}/{curve}: render_flow must render: {e}"));
assert_eq!(
a, b,
"{name}/{curve}: render_flow(..., \"splines\") must match render_curve(...) exactly \
(both calls take the same code path, so this can only fail on nondeterminism)"
);
}
}
}
#[test]
#[ignore]
fn dump_routing_signatures_for_pinning() {
for name in [
"branch",
"left-right",
"subgraph",
"long-edge",
"link-style-multi-index",
] {
let (_, src) = CORPUS
.iter()
.find(|(n, _)| *n == name)
.expect("known corpus name");
let svg = render_flow(src, "dark", "basis", "splines").expect("must render");
println!("=== {name} ===\n{}", routing_signature(&svg));
}
}
const BRANCH_SIGNATURE: &str = "fill=none
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#333333
fill=#cccccc
fill=#333333
fill=#cccccc
";
const LEFT_RIGHT_SIGNATURE: &str = "fill=none
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#333333
fill=#cccccc
fill=#333333
fill=#cccccc
";
const SUBGRAPH_SIGNATURE: &str = "fill=none
fill=#2b2b38
stroke=#8a8a8a
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#cccccc
";
const LONG_EDGE_SIGNATURE: &str = "fill=none
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d3d3d3
fill=#d3d3d3
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
";
const LINK_STYLE_SIGNATURE: &str = "fill=none
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#1f6feb
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#1f6feb
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#1f6feb
fill=#d3d3d3
d=M#,#L#,#C#,# #,# #,#C#,# #,# #,#L#,#
fill=none
stroke=#d4a017
fill=#d3d3d3
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
fill=#1f2020
stroke=#cccccc
fill=#cccccc
";
#[test]
fn splines_routing_signature_is_pinned() {
const CASES: &[(&str, &str)] = &[
("branch", BRANCH_SIGNATURE),
("left-right", LEFT_RIGHT_SIGNATURE),
("subgraph", SUBGRAPH_SIGNATURE),
("long-edge", LONG_EDGE_SIGNATURE),
("link-style-multi-index", LINK_STYLE_SIGNATURE),
];
for (name, expected) in CASES {
let (_, src) = CORPUS
.iter()
.find(|(n, _)| n == name)
.unwrap_or_else(|| panic!("{name}: not in CORPUS"));
let svg = render_flow(src, "dark", "basis", "splines")
.unwrap_or_else(|e| panic!("{name}: must render: {e}"));
assert_eq!(
&routing_signature(&svg),
expected,
"{name}: splines routing signature drifted — re-dump with \
dump_routing_signatures_for_pinning and inspect the diff before updating"
);
}
}
#[test]
fn unknown_routing_falls_back_to_splines_without_crashing() {
let src = "flowchart TD\n A --> B{cond}\n B --> C\n B --> D";
let splines = render_flow(src, "dark", "basis", "splines").expect("must render");
for unknown in ["xyz", "", " ", "Orthogonal", "orthogonal", "ORTHOGONAL"] {
let got = render_flow(src, "dark", "basis", unknown)
.unwrap_or_else(|e| panic!("routing={unknown:?} must still render: {e}"));
assert_eq!(
splines, got,
"routing={unknown:?} must resolve exactly like \"splines\""
);
}
}
#[test]
fn orthogonal_routing_draws_only_axis_parallel_segments() {
for (name, src) in orthogonal_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for (a, b) in edge_segments(&d) {
let dx = (b.x - a.x).abs();
let dy = (b.y - a.y).abs();
assert!(
dx < AXIS_EPS || dy < AXIS_EPS,
"{name}: diagonal segment {a:?} -> {b:?}"
);
}
}
}
#[test]
fn orthogonal_aligned_edge_is_a_straight_two_point_line() {
let d = laid_out_flow("flowchart TD\n A --> B", "basis", "konoma-orthogonal");
let e = &d.edges[0];
assert_eq!(e.points.len(), 2, "{:?}", e.points);
assert!(
(e.points[0].x - e.points[1].x).abs() < AXIS_EPS,
"a straight-down TD edge must not drift sideways: {:?}",
e.points
);
assert!(
e.straight,
"an orthogonal edge must draw as a polyline, not a curve"
);
}
#[test]
fn orthogonal_branch_and_merge_edges_bend_at_most_once() {
let branch = laid_out_flow(
"flowchart LR\n A --> B{cond}\n B --> C\n B --> D",
"basis",
"konoma-orthogonal",
);
let bc = branch
.edges
.iter()
.find(|e| e.from == "B" && e.to == "C")
.expect("edge B->C must exist");
assert_eq!(
bc.points.len(),
2,
"B->C wins the lane slot (smaller target cross-coordinate) and goes straight: {:?}",
bc.points
);
let bd = branch
.edges
.iter()
.find(|e| e.from == "B" && e.to == "D")
.expect("edge B->D must exist");
assert_eq!(
bd.points.len(),
3,
"B->D loses the lane slot to B->C and still bends exactly once: {:?}",
bd.points
);
let merge = laid_out_flow(
"flowchart LR\n A --> C\n B --> C\n C --> D",
"basis",
"konoma-orthogonal",
);
let ac = merge
.edges
.iter()
.find(|e| e.from == "A" && e.to == "C")
.expect("edge A->C must exist");
assert_eq!(
ac.points.len(),
2,
"A->C wins the lane slot (smaller source cross-coordinate) and goes straight: {:?}",
ac.points
);
let bc2 = merge
.edges
.iter()
.find(|e| e.from == "B" && e.to == "C")
.expect("edge B->C must exist");
assert_eq!(
bc2.points.len(),
3,
"B->C loses the lane slot to A->C and still bends exactly once: {:?}",
bc2.points
);
}
#[test]
fn decision_node_is_chamfered_under_orthogonal_and_a_diamond_under_splines() {
let src = "flowchart TD\n A --> B{cond}\n B --> C";
let splines = laid_out_curve(src, "basis");
let b_splines = splines.node("B").expect("B must exist");
assert_eq!(b_splines.shape, Glyph::Flow(Shape::Diamond));
let ortho = laid_out_flow(src, "basis", "konoma-orthogonal");
let b_ortho = ortho.node("B").expect("B must exist");
assert_eq!(b_ortho.shape, Glyph::ChamferedRect);
let polygon = shapes::polygon(b_ortho.shape, b_ortho.size);
assert_eq!(
polygon.len(),
8,
"a chamfered rectangle has eight vertices: {polygon:?}"
);
let rect_size = shapes::size(
Glyph::Flow(Shape::Rect),
Size::new(b_ortho.label.width, b_ortho.label.height),
);
assert_eq!(b_ortho.size.w, rect_size.w);
assert_eq!(b_ortho.size.h, rect_size.h);
let svg = render_flow(src, "dark", "basis", "konoma-orthogonal").expect("must render");
assert!(
svg.contains("<polygon"),
"an orthogonal decision node must draw a <polygon>, not a diamond outline"
);
}
#[test]
fn non_flowchart_diagrams_ignore_mermaid_routing() {
use crate::preview::markdown::mermaid_to_svg_flow;
let cases = [
"stateDiagram-v2\n [*] --> A\n A --> B\n B --> [*]",
"classDiagram\n A --|> B",
"erDiagram\n A ||--o{ B : has",
];
for src in cases {
let splines = mermaid_to_svg_flow(src, "dark", "basis", "splines")
.unwrap_or_else(|| panic!("{src}: must render under splines"));
let ortho = mermaid_to_svg_flow(src, "dark", "basis", "konoma-orthogonal")
.unwrap_or_else(|| panic!("{src}: must render under konoma-orthogonal"));
assert_eq!(
splines, ortho,
"{src}: a non-flowchart diagram must draw identically regardless of mermaid_routing"
);
}
}
#[test]
fn orthogonal_endpoints_sit_outside_the_node_and_arrive_perpendicular() {
for (name, src) in orthogonal_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for e in &d.edges {
if e.points.len() < 2 {
continue;
}
let n = e.points.len();
let ends = [
(&e.from, &e.points[0], &e.points[1]),
(&e.to, &e.points[n - 1], &e.points[n - 2]),
];
for (node_id, endpoint, neighbour) in ends {
let bounds = d
.node(node_id)
.map(|n| n.bounds())
.or_else(|| d.cluster(node_id).map(|c| c.bounds()));
let Some((l, t, r, b)) = bounds else {
continue;
};
let on_top = (endpoint.y - (t - orthogonal::PORT_INSET)).abs() < AXIS_EPS;
let on_bottom = (endpoint.y - (b + orthogonal::PORT_INSET)).abs() < AXIS_EPS;
let on_left = (endpoint.x - (l - orthogonal::PORT_INSET)).abs() < AXIS_EPS;
let on_right = (endpoint.x - (r + orthogonal::PORT_INSET)).abs() < AXIS_EPS;
assert!(
on_top || on_bottom || on_left || on_right,
"{name}: edge {}->{} endpoint at {node_id} {endpoint:?} is not {}px \
OUTSIDE its bounds {:?}",
e.from,
e.to,
orthogonal::PORT_INSET,
(l, t, r, b)
);
let dx = (endpoint.x - neighbour.x).abs();
let dy = (endpoint.y - neighbour.y).abs();
assert!(
dx < AXIS_EPS || dy < AXIS_EPS,
"{name}: edge {}->{} segment into {node_id} is not axis-parallel: \
{neighbour:?} -> {endpoint:?}",
e.from,
e.to
);
if on_top || on_bottom {
assert!(
dy > AXIS_EPS && dx < AXIS_EPS,
"{name}: edge {}->{} must meet {node_id} vertically at a top/bottom face: \
{neighbour:?} -> {endpoint:?}",
e.from,
e.to
);
} else {
assert!(
dx > AXIS_EPS && dy < AXIS_EPS,
"{name}: edge {}->{} must meet {node_id} horizontally at a left/right \
face: {neighbour:?} -> {endpoint:?}",
e.from,
e.to
);
}
}
}
}
}
#[test]
fn orthogonal_arrow_tip_has_a_visible_gap_from_the_node_in_real_pixels() {
let src = "flowchart TD\n A --> B";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let a = d.node("A").expect("A must exist");
let b = d.node("B").expect("B must exist");
assert!(
(a.center.x - b.center.x).abs() < AXIS_EPS,
"the fixture must be a straight vertical drop so one column crosses both the arrow and B's top edge"
);
let svg = render_flow(src, "dark", "basis", "konoma-orthogonal").expect("must render");
let scale = 4.0;
let img = crate::preview::svg::rasterize_bytes(
svg.as_bytes(),
FsPath::new("orthogonal-tip-gap.svg"),
(d.width.max(d.height) * scale).round() as u32,
)
.expect("must rasterize");
let rgba = img.to_rgba8();
let cx = (a.center.x * scale).round() as u32;
let (_, b_top, _, _) = b.bounds();
let scan_top = ((a.center.y) * scale).round() as u32;
let scan_bottom = ((b_top + 2.0) * scale).round() as u32;
assert_ne!(
rgba.get_pixel(cx, scan_top)[3],
0,
"the scan must start inside A's own fill, or it is not testing the right column"
);
let saw_transparent_gap = (scan_top..=scan_bottom).any(|y| rgba.get_pixel(cx, y)[3] == 0);
assert!(
saw_transparent_gap,
"no fully-transparent row between the arrow tip and B's top edge — the tip is touching \
or crossing into B rather than clearing it by ~1px"
);
let stroke_hex = theme::Theme::named("dark").node_stroke;
let (sr, sg, sb) = hex_to_rgb(stroke_hex);
let saw_node_stroke = (scan_top..=scan_bottom).any(|y| {
let p = rgba.get_pixel(cx, y);
p[3] == 255 && close(p[0], sr) && close(p[1], sg) && close(p[2], sb)
});
assert!(
saw_node_stroke,
"B's own stroke colour ({stroke_hex}) must appear in the scanned column"
);
}
fn hex_to_rgb(hex: &str) -> (u8, u8, u8) {
let hex = hex.trim_start_matches('#');
let r = u8::from_str_radix(&hex[0..2], 16).unwrap();
let g = u8::from_str_radix(&hex[2..4], 16).unwrap();
let b = u8::from_str_radix(&hex[4..6], 16).unwrap();
(r, g, b)
}
fn close(a: u8, b: u8) -> bool {
(a as i16 - b as i16).abs() <= 6
}
#[test]
fn orthogonal_arrow_head_matches_the_edges_resolved_stroke_colour() {
let src = "flowchart LR\n A --> B\n linkStyle 0 stroke:#1f6feb";
let ortho = render_flow(src, "dark", "basis", "konoma-orthogonal").expect("must render");
assert!(
ortho.contains("stroke=\"#1f6feb\""),
"the edge's own path must carry its linkStyle colour: {ortho}"
);
let arrow_at = ortho
.find("<polygon points=")
.expect("an arrow head polygon must be emitted");
let fill = attr(&ortho[arrow_at..], "fill").expect("the polygon must carry a fill");
assert_eq!(
fill, "#1f6feb",
"an orthogonal arrow head must be painted the edge's resolved linkStyle colour: {ortho}"
);
let splines = render_flow(src, "dark", "basis", "splines").expect("must render");
assert!(splines.contains("stroke=\"#1f6feb\""));
let arrow_at = splines
.find("<polygon points=")
.expect("an arrow head polygon must be emitted");
let fill = attr(&splines[arrow_at..], "fill").expect("the polygon must carry a fill");
let theme_arrowhead = theme::Theme::named("dark").arrowhead;
assert_eq!(
fill, theme_arrowhead,
"splines must keep mermaid's own fixed arrowhead colour regardless of linkStyle: {splines}"
);
assert_ne!(
fill, "#1f6feb",
"this assertion is only meaningful if the theme default actually differs from the \
linkStyle colour used above"
);
}
#[test]
fn orthogonal_arrow_heads_differ_per_edge_when_their_linkstyles_differ() {
let src = "flowchart TD\n A --> B\n A --> C\n \
linkStyle 0 stroke:#1f6feb\n linkStyle 1 stroke:#d4a017";
let svg = render_flow(src, "dark", "basis", "konoma-orthogonal").expect("must render");
let mut fills = Vec::new();
let mut rest = svg.as_str();
while let Some(at) = rest.find("<polygon points=") {
let slice = &rest[at..];
fills.push(
attr(slice, "fill")
.expect("every polygon here carries a fill")
.to_string(),
);
rest = &slice[1..];
}
assert!(
fills.contains(&"#1f6feb".to_string()) && fills.contains(&"#d4a017".to_string()),
"each edge's own linkStyle colour must reach its own arrow head: {fills:?}"
);
}
fn attr<'a>(haystack: &'a str, attr: &str) -> Option<&'a str> {
let needle = format!("{attr}=\"");
let start = haystack.find(&needle)? + needle.len();
let end = start + haystack[start..].find('"')?;
Some(&haystack[start..end])
}
#[test]
fn orthogonal_growth_widens_a_node_whose_face_cannot_fit_its_ports() {
let src = "flowchart TD\n A --> Z\n B --> Z\n C --> Z\n D --> Z\n E --> Z\n \
F --> Z\n G --> Z\n H --> Z\n I --> Z";
let splines = laid_out_curve(src, "basis");
let z_splines = splines.node("Z").expect("Z must exist");
assert_eq!(
z_splines.size.h, 45.400000000000006,
"splines must not grow Z at all — dumped once and pinned"
);
let ortho = laid_out_flow(src, "basis", "konoma-orthogonal");
let z_ortho = ortho.node("Z").expect("Z must exist");
assert_eq!(
z_ortho.size.h, 128.0,
"Z's height must grow to exactly (8-1)*16 + 2*8 = 128px — the busiest face's requirement"
);
assert_eq!(
z_ortho.size.w, z_splines.size.w,
"no face asked Z's width to grow, so it must not have"
);
let (_, z_top, _, _) = z_ortho.bounds();
let left_face_x = z_ortho.bounds().0 - orthogonal::PORT_INSET;
let right_face_x = z_ortho.bounds().2 + orthogonal::PORT_INSET;
let mut left_ys = Vec::new();
let mut right_ys = Vec::new();
let mut aligned_count = 0;
for e in &ortho.edges {
if e.to != "Z" {
continue;
}
let bends = e.points.len().saturating_sub(2);
let last = e.points.last().unwrap();
if (last.y - (z_top - orthogonal::PORT_INSET)).abs() < 1e-6 {
assert_eq!(bends, 0, "the Top-face edge must be the aligned one: {e:?}");
assert_eq!(
e.from, "A",
"A is the smallest-cross-coordinate source, so it wins Z's one lane slot: {e:?}"
);
aligned_count += 1;
} else if (last.x - left_face_x).abs() < 1e-6 {
assert_eq!(
bends, 1,
"a merge onto Z's Left face bends exactly once: {e:?}"
);
left_ys.push(last.y);
} else if (last.x - right_face_x).abs() < 1e-6 {
assert_eq!(
bends, 1,
"a merge onto Z's Right face bends exactly once: {e:?}"
);
right_ys.push(last.y);
} else {
panic!(
"edge {}->Z landed on none of Z's three occupied faces: {e:?}",
e.from
);
}
}
assert_eq!(
aligned_count, 1,
"exactly one edge must be the aligned A->Z"
);
assert_eq!(
left_ys.len(),
0,
"Z moved to A's own (leftmost) x, so nothing is left of it any more"
);
assert_eq!(
right_ys.len(),
8,
"every one of B..I now shares Z's Right face"
);
right_ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
for w in right_ys.windows(2) {
assert!(
(w[1] - w[0] - orthogonal::PORT_SPACING).abs() < 1e-6,
"ports on one face must be exactly 16px apart: {right_ys:?}"
);
}
let mid = (right_ys[0] + right_ys[7]) / 2.0;
assert!(
(mid - z_ortho.center.y).abs() < 1e-6,
"eight ports must be symmetric about the face's own centre: {right_ys:?} vs {}",
z_ortho.center.y
);
let half_h = z_ortho.size.h / 2.0;
for &y in right_ys.iter() {
let from_center = (y - z_ortho.center.y).abs();
assert!(
half_h - from_center >= orthogonal::PORT_CLEARANCE - 1e-6,
"port {from_center}px from centre must clear the corner by \
{}px (half-height {half_h}): {right_ys:?}",
orthogonal::PORT_CLEARANCE
);
}
}
#[test]
fn orthogonal_roomy_face_does_not_grow() {
let src = "flowchart TD\n A --> Z\n B --> Z\n C --> Z";
let splines = laid_out_curve(src, "basis");
let ortho = laid_out_flow(src, "basis", "konoma-orthogonal");
let z_splines = splines.node("Z").expect("Z must exist");
let z_ortho = ortho.node("Z").expect("Z must exist");
assert_eq!(
z_ortho.size, z_splines.size,
"a face that already fits its ports must leave the node exactly as it was"
);
}
#[test]
fn orthogonal_bend_count_never_exceeds_two() {
for (name, src) in orthogonal_dag_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for e in &d.edges {
let bends = e.points.len().saturating_sub(2);
assert!(
bends <= 2,
"{name}: edge {}->{} has {bends} bends, more than the rule 4 cap: {:?}",
e.from,
e.to,
e.points
);
}
}
}
fn assert_no_segment_crosses_a_foreign_node(name: &str, d: &Diagram) {
for e in &d.edges {
for w in e.points.windows(2) {
for n in &d.nodes {
if n.id == e.from || n.id == e.to {
continue;
}
assert!(
!orthogonal::segment_crosses_node(&w[0], &w[1], n),
"{name}: edge {}->{} segment {:?}->{:?} crosses {} {:?}",
e.from,
e.to,
w[0],
w[1],
n.id,
n.bounds()
);
}
}
}
}
#[test]
fn orthogonal_no_segment_crosses_a_foreign_node_across_the_whole_corpus() {
for (name, src) in orthogonal_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
assert_no_segment_crosses_a_foreign_node(name, &d);
}
}
#[test]
fn orthogonal_settings_rules_sample_no_longer_pierces_a_sibling_node() {
let src = "flowchart LR\n F[ファイル] --> C{設定のルール}\n \
C -->|テキスト| T[窓読み]\n C -->|コード| S[構文強調]\n \
C -->|Markdown| MD[ブロックモデル]\n C -->|CSV / TSV| TB[表]\n \
C -->|画像| IM[デコード]\n C -->|PDF| PD[ページ描画]\n C -->|SVG| SV[usvg]\n \
C -->|動画| VD[キーフレーム]\n C -->|書庫| AR[一覧]\n \
C -->|なし| NA[プレビュー不可]\n MD --> MM[mermaid]\n MD --> MA[数式]\n \
MM --> RS[ラスタライズ]\n MA --> RS\n SV --> RS\n PD --> RS\n \
IM --> FIT[セルに合わせる]\n RS --> FIT\n VD --> FIT\n FIT --> K{端末}\n \
K -->|kitty| KT[圧縮転送]\n K -->|sixel / iTerm2| RI[画像プロトコル]\n \
K -->|それ以外| HB[ハーフブロック]\n \
classDef pix fill:#132a3a,stroke:#1f6feb,color:#c9d1d9\n \
classDef txt fill:#12291c,stroke:#2da44e,color:#c9d1d9\n \
class IM,PD,SV,VD,MM,MA,RS,FIT,KT,RI,HB pix\n \
class T,S,MD,TB,AR txt\n \
style NA fill:#2d2418,stroke:#d4a017,color:#c9d1d9";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let na = d.node("NA").expect("NA must exist");
for id in ["MM", "MA"] {
let e = d
.edges
.iter()
.find(|e| e.from == "MD" && e.to == *id)
.unwrap_or_else(|| panic!("MD->{id} must exist"));
for w in e.points.windows(2) {
assert!(
!orthogonal::segment_crosses_node(&w[0], &w[1], na),
"MD->{id} must no longer pierce NA: segment {:?}->{:?}, NA bounds {:?}",
w[0],
w[1],
na.bounds()
);
}
}
assert_no_segment_crosses_a_foreign_node("settings-rules", &d);
}
#[test]
fn orthogonal_settings_rules_sample_has_no_perimeter_routed_forward_edges() {
let src = "flowchart LR\n F[ファイル] --> C{設定のルール}\n \
C -->|テキスト| T[窓読み]\n C -->|コード| S[構文強調]\n \
C -->|Markdown| MD[ブロックモデル]\n C -->|CSV / TSV| TB[表]\n \
C -->|画像| IM[デコード]\n C -->|PDF| PD[ページ描画]\n C -->|SVG| SV[usvg]\n \
C -->|動画| VD[キーフレーム]\n C -->|書庫| AR[一覧]\n \
C -->|なし| NA[プレビュー不可]\n MD --> MM[mermaid]\n MD --> MA[数式]\n \
MM --> RS[ラスタライズ]\n MA --> RS\n SV --> RS\n PD --> RS\n \
IM --> FIT[セルに合わせる]\n RS --> FIT\n VD --> FIT\n FIT --> K{端末}\n \
K -->|kitty| KT[圧縮転送]\n K -->|sixel / iTerm2| RI[画像プロトコル]\n \
K -->|それ以外| HB[ハーフブロック]\n \
classDef pix fill:#132a3a,stroke:#1f6feb,color:#c9d1d9\n \
classDef txt fill:#12291c,stroke:#2da44e,color:#c9d1d9\n \
class IM,PD,SV,VD,MM,MA,RS,FIT,KT,RI,HB pix\n \
class T,S,MD,TB,AR txt\n \
style NA fill:#2d2418,stroke:#d4a017,color:#c9d1d9";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let bounds = diagram_content_bounds(&d);
for e in &d.edges {
let max_excursion = e
.points
.iter()
.map(|p| excursion(bounds, p))
.fold(0.0_f64, f64::max);
assert!(
max_excursion < orthogonal::PERIMETER_MARGIN,
"{}->{}: excursion {max_excursion}px reaches perimeter-lane territory \
(PERIMETER_MARGIN={}) in a diagram with no back edge at all: {:?}",
e.from,
e.to,
orthogonal::PERIMETER_MARGIN,
e.points
);
}
}
#[test]
fn orthogonal_decision_retry_loop_does_not_span_the_whole_ring() {
let src = "flowchart TB\n A[入力] --> B[整形]\n B --> C{検査}\n C -->|合格| D[出力]\n \
C -->|再試行| B\n D -.-> A";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for (from, to) in [("C", "B"), ("D", "A")] {
let e = d
.edges
.iter()
.find(|e| e.from == from && e.to == to)
.unwrap_or_else(|| panic!("{from}->{to} must exist"));
let longest = e
.points
.windows(2)
.map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y))
.fold(0.0_f64, f64::max);
let a = d.node(from).expect("source node must exist");
let b = d.node(to).expect("target node must exist");
let needed = (a.center.y - b.center.y).abs();
assert!(
longest <= needed + 1.0,
"{from}->{to}: longest segment is {longest}px, more than the ~{needed}px the two \
ports actually need — the ring-spanning bug is back: {:?}",
e.points
);
}
}
#[test]
fn orthogonal_lane_alignment_never_leaves_nodes_overlapping() {
for (name, src) in orthogonal_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
check_nodes_do_not_overlap(name, &d);
}
}
#[test]
fn orthogonal_lone_merge_still_bends_at_its_targets_own_row() {
let src = "flowchart TD\n A ---> B\n A --> C\n B --> D\n C --> D";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let bd = d
.edges
.iter()
.find(|e| e.from == "B" && e.to == "D")
.expect("B->D must exist");
let cd = d
.edges
.iter()
.find(|e| e.from == "C" && e.to == "D")
.expect("C->D must exist");
assert_eq!(
bd.points.len(),
2,
"B->D is absorbed into the A-B-D lane and goes straight: {:?}",
bd.points
);
assert_eq!(
cd.points.len(),
3,
"C->D is the only remaining merge and still bends exactly once: {:?}",
cd.points
);
let d_node = d.node("D").expect("D must exist");
assert!(
(cd.points[1].y - d_node.center.y).abs() < 1e-6,
"alone on its own face, C->D's bend must land exactly on D's own row: {:?} vs {}",
cd.points[1],
d_node.center.y
);
}
fn labelled_plates(d: &Diagram) -> Vec<(&PlacedEdge, &super::PlacedEdgeLabel)> {
d.edges
.iter()
.filter_map(|e| e.label.as_ref().map(|l| (e, l)))
.collect()
}
fn own_segments(e: &PlacedEdge) -> impl Iterator<Item = (&Point, &Point)> {
e.points.windows(2).map(|w| (&w[0], &w[1]))
}
#[test]
fn every_label_plate_centre_sits_on_its_own_edges_line() {
for (name, src) in orthogonal_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for (e, l) in labelled_plates(&d) {
let on_a_segment = own_segments(e).any(|(a, b)| {
let on_seg = |p: &Point| {
let within_x = p.x >= a.x.min(b.x) - AXIS_EPS && p.x <= a.x.max(b.x) + AXIS_EPS;
let within_y = p.y >= a.y.min(b.y) - AXIS_EPS && p.y <= a.y.max(b.y) + AXIS_EPS;
let collinear =
((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)).abs() < 1e-3;
within_x && within_y && collinear
};
on_seg(&l.center)
});
assert!(
on_a_segment,
"{name}: label {:?} centre {:?} is not on any segment of {:?}",
l.label.lines, l.center, e.points
);
}
}
}
#[test]
fn labelled_flow_axis_segments_meet_their_minimum_length() {
for (name, src) in orthogonal_dag_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for (e, l) in labelled_plates(&d) {
let Some(slot) = orthogonal::label_slot(direction_of(src), &e.points) else {
continue;
};
if !slot.is_flow_axis {
continue;
}
let need = orthogonal::label_min_length(l.size, slot.horizontal);
assert!(
slot.length + 1e-6 >= need,
"{name}: labelled segment {:?} is {}px, short of the {}px minimum for plate {:?}",
e.points,
slot.length,
need,
l.size
);
}
}
}
fn direction_of(src: &str) -> crate::preview::mermaid::flowchart::Direction {
parse(src)
.unwrap_or_else(|e| panic!("corpus source must parse: {e}"))
.direction
}
#[test]
fn a_long_cjk_label_widens_the_rank_gap_and_a_short_one_does_not() {
let bare = laid_out_flow(
"flowchart LR\n A[Start] --> B[End]",
"basis",
"konoma-orthogonal",
);
let short = laid_out_flow(
"flowchart LR\n A[Start] -->|x| B[End]",
"basis",
"konoma-orthogonal",
);
let long = laid_out_flow(
"flowchart LR\n A[Start] -->|とても長いラベルのテキストです、これはとても長い| B[End]",
"basis",
"konoma-orthogonal",
);
let gap = |d: &Diagram| {
let e = &d.edges[0];
(e.points.last().unwrap().x - e.points[0].x).abs()
};
let (bare_gap, short_gap, long_gap) = (gap(&bare), gap(&short), gap(&long));
assert!(
short_gap > bare_gap,
"even a 1-char label must reserve some rank space: {short_gap} vs bare {bare_gap}"
);
assert!(
long_gap > short_gap + 50.0,
"a long multi-em CJK label must widen the gap far more than a 1-char label: \
long={long_gap} short={short_gap}"
);
}
#[test]
fn label_boosts_actually_widen_the_flow_axis_segment_dagre_lays_out() {
let node = |id: &str| super::SpecNode {
id: id.to_string(),
glyph: Glyph::Flow(Shape::Rect),
label: Label::measure(id),
size: Size::new(30.0, 20.0),
panel: None,
style: None,
};
let edge = super::SpecEdge {
id: "e1".to_string(),
from: "A".to_string(),
to: "B".to_string(),
label: Some(Label::measure("a fairly long edge label")),
tip_start: Tip::None,
tip_end: Tip::Arrow,
stroke: Stroke::Normal,
minlen: 1,
start_label: None,
end_label: None,
style: None,
curve: Curve::Basis,
};
let spec = super::GraphSpec {
direction: crate::preview::mermaid::flowchart::Direction::TopToBottom,
nodes: vec![node("A"), node("B")],
edges: vec![edge],
blocks: Vec::new(),
routing: orthogonal::Routing::Orthogonal,
};
let flow_gap = |boosts: &HashMap<String, f64>| {
let (diagram, _required, _shortfall) =
super::lay_out_spec_pass(&spec, &HashMap::new(), boosts)
.unwrap_or_else(|e| panic!("hand-built spec must lay out: {e}"));
let e = &diagram.edges[0];
(e.points.last().unwrap().y - e.points[0].y).abs()
};
let unboosted = flow_gap(&HashMap::new());
let mut boosts = HashMap::new();
boosts.insert("e1".to_string(), 300.0);
let boosted = flow_gap(&boosts);
assert!(
boosted > unboosted + 250.0,
"a 300px label_boosts entry must reach dagre and widen the rank gap by roughly that much: \
unboosted={unboosted} boosted={boosted}"
);
}
#[test]
fn apply_label_growth_accumulates_positive_shortfalls_and_ignores_the_rest() {
let mut boosts: HashMap<String, f64> = HashMap::new();
boosts.insert("already".to_string(), 10.0);
let mut shortfall: HashMap<String, f64> = HashMap::new();
shortfall.insert("already".to_string(), 5.0); shortfall.insert("fresh".to_string(), 20.0); shortfall.insert("met".to_string(), 0.0); shortfall.insert("negative".to_string(), -1.0);
let grew = super::apply_label_growth(&mut boosts, &shortfall);
assert!(grew, "a positive shortfall must report growth");
assert!((boosts["already"] - 15.0).abs() < 1e-9, "{:?}", boosts);
assert!((boosts["fresh"] - 20.0).abs() < 1e-9, "{:?}", boosts);
assert!(
!boosts.contains_key("met"),
"a zero shortfall adds no entry"
);
assert!(
!boosts.contains_key("negative"),
"a negative shortfall must never be applied"
);
let mut zero: HashMap<String, f64> = HashMap::new();
zero.insert("already".to_string(), 0.0);
let grew_again = super::apply_label_growth(&mut boosts, &zero);
assert!(!grew_again);
assert!((boosts["already"] - 15.0).abs() < 1e-9);
}
#[test]
fn no_label_plate_is_crossed_by_a_foreign_edges_segment() {
for (name, src) in orthogonal_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for (owner, l) in labelled_plates(&d) {
let (pl, pt, pr, pb) = (
l.center.x - l.size.w / 2.0,
l.center.y - l.size.h / 2.0,
l.center.x + l.size.w / 2.0,
l.center.y + l.size.h / 2.0,
);
for other in &d.edges {
if std::ptr::eq(other, owner) {
continue;
}
for (a, b) in own_segments(other) {
let crosses = if (a.y - b.y).abs() < AXIS_EPS {
let y = a.y;
let (x0, x1) = (a.x.min(b.x), a.x.max(b.x));
y >= pt && y <= pb && x1 >= pl && x0 <= pr
} else if (a.x - b.x).abs() < AXIS_EPS {
let x = a.x;
let (y0, y1) = (a.y.min(b.y), a.y.max(b.y));
x >= pl && x <= pr && y1 >= pt && y0 <= pb
} else {
false
};
assert!(
!crosses,
"{name}: {:?}->{:?}'s segment {a:?}->{b:?} runs through {:?}->{:?}'s \
label plate {:?}",
other.from, other.to, owner.from, owner.to, l.label.lines
);
}
}
}
}
}
#[test]
fn avoid_label_plates_pushes_only_the_port_that_actually_crosses_a_plate() {
use crate::preview::mermaid::flowchart::Direction;
use orthogonal::{avoid_label_plates, EligibleEdge};
let a = placed_node("A", 100.0, 0.0, 60.0, 40.0);
let b = placed_node("B", 100.0, 200.0, 60.0, 40.0);
let c = placed_node("C", 300.0, 0.0, 60.0, 40.0);
let d = placed_node("D", 300.0, 200.0, 60.0, 40.0);
let nodes = vec![a, b, c, d];
let edges = vec![
EligibleEdge {
id: "ab",
source: "A",
target: "B",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 1,
target_in_degree: 1,
},
EligibleEdge {
id: "cd",
source: "C",
target: "D",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 1,
target_in_degree: 1,
},
];
let routed = orthogonal::route_flowchart(Direction::TopToBottom, &nodes, &[], &edges);
let mut points = routed.points;
let ab_before = points["ab"].clone();
let cd_before = points["cd"].clone();
let mut plates: HashMap<String, super::PlacedEdgeLabel> = HashMap::new();
plates.insert(
"someone-elses-label".to_string(),
super::PlacedEdgeLabel {
center: Point::new(ab_before[0].x, (ab_before[0].y + ab_before[1].y) / 2.0),
size: Size::new(40.0, 14.0),
label: Label::measure("x"),
},
);
avoid_label_plates(
Direction::TopToBottom,
&nodes,
&[],
&edges,
&mut points,
&mut plates,
);
assert_ne!(points["ab"], ab_before, "ab's port must have been pushed");
assert_eq!(
(points["ab"][0].x - ab_before[0].x).abs(),
orthogonal::PORT_SPACING,
"the push must be exactly PORT_SPACING: {:?} vs {:?}",
points["ab"][0],
ab_before[0]
);
assert_eq!(
points["cd"], cd_before,
"cd shares no plate with anything and must be left exactly as routed"
);
}
fn diagram_content_bounds(d: &Diagram) -> (f64, f64, f64, f64) {
let mut l = f64::INFINITY;
let mut t = f64::INFINITY;
let mut r = f64::NEG_INFINITY;
let mut b = f64::NEG_INFINITY;
for n in &d.nodes {
let (nl, nt, nr, nb) = n.bounds();
l = l.min(nl);
t = t.min(nt);
r = r.max(nr);
b = b.max(nb);
}
for c in &d.clusters {
let (cl, ct, cr, cb) = c.bounds();
l = l.min(cl);
t = t.min(ct);
r = r.max(cr);
b = b.max(cb);
}
(l, t, r, b)
}
fn known_staircase_forward_edge(name: &str, from: &str, to: &str) -> bool {
matches!(
(name, from, to),
("strokes", "A", "F")
| ("strokes", "C", "E")
| ("long-edge", "A", "E")
| ("subgraph-bypass", "X", "Y")
)
}
fn excursion(bounds: (f64, f64, f64, f64), p: &Point) -> f64 {
let (l, t, r, b) = bounds;
let dx = (l - p.x).max(p.x - r).max(0.0);
let dy = (t - p.y).max(p.y - b).max(0.0);
dx.max(dy)
}
#[test]
fn orthogonal_perimeter_edges_clear_the_margin() {
for (name, src) in orthogonal_corpus()
.into_iter()
.chain(orthogonal_only_corpus())
{
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let bounds = diagram_content_bounds(&d);
for e in &d.edges {
if e.from == e.to {
continue; }
if known_staircase_forward_edge(name, &e.from, &e.to) {
continue; }
let max_excursion = e
.points
.iter()
.map(|p| excursion(bounds, p))
.fold(0.0_f64, f64::max);
if max_excursion < AXIS_EPS {
continue; }
assert!(
max_excursion + AXIS_EPS >= orthogonal::PERIMETER_MARGIN,
"{name}: edge {}->{} strays only {max_excursion}px outside the content box, \
short of the {}px margin: {:?}",
e.from,
e.to,
orthogonal::PERIMETER_MARGIN,
e.points
);
}
}
}
#[test]
fn orthogonal_frame_hugs_backedge_perimeter_lane_reads_the_frame_not_just_the_nodes() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_only_corpus()
.into_iter()
.find(|(name, _)| *name == "orthogonal-frame-hugs-backedge")
.unwrap()
.1;
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let frame = d.cluster("one").expect("cluster one must exist").bounds();
let bounds = diagram_content_bounds(&d);
assert!(
(bounds.1 - frame.1).abs() < 0.01,
"the fixture must make the frame's own top the diagram's minimum y: content.top={} \
frame.top={}",
bounds.1,
frame.1
);
let yx = d
.edges
.iter()
.find(|e| e.from == "Y" && e.to == "X")
.expect("Y->X must exist");
let topmost = yx.points.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
assert!(
(bounds.1 - topmost - orthogonal::PERIMETER_MARGIN).abs() < 1.0,
"Y->X's ring lane must sit exactly PERIMETER_MARGIN above content's own top (which \
already folds the frame in): topmost={topmost} content.top={} margin={}",
bounds.1,
orthogonal::PERIMETER_MARGIN
);
let out = frame.1 - topmost;
assert!(
out + AXIS_EPS >= orthogonal::PERIMETER_MARGIN,
"Y->X's topmost point clears frame `one`'s own top edge by only {out}px, short of the \
{}px margin",
orthogonal::PERIMETER_MARGIN
);
}
#[test]
fn orthogonal_perimeter_edges_stagger_8px_apart() {
for (name, src) in orthogonal_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let bounds = diagram_content_bounds(&d);
let mut lanes: Vec<i64> = Vec::new();
for e in &d.edges {
if e.from == e.to {
continue; }
if known_staircase_forward_edge(name, &e.from, &e.to) {
continue; }
let max_excursion = e
.points
.iter()
.map(|p| excursion(bounds, p))
.fold(0.0_f64, f64::max);
if max_excursion < AXIS_EPS {
continue;
}
let steps =
(max_excursion - orthogonal::PERIMETER_MARGIN) / orthogonal::PERIMETER_LANE_SPACING;
assert!(
(steps - steps.round()).abs() < 1e-6,
"{name}: edge {}->{} excursion {max_excursion}px is not \
PERIMETER_MARGIN + k*PERIMETER_LANE_SPACING for an integer k (k={steps}): {:?}",
e.from,
e.to,
e.points
);
let lane = steps.round() as i64;
assert!(
!lanes.contains(&lane),
"{name}: edge {}->{} shares lane {lane} with another perimeter edge already seen",
e.from,
e.to
);
lanes.push(lane);
}
}
}
#[test]
fn no_two_unrelated_edges_coincidentally_overlap_on_the_same_axis() {
for (name, src) in orthogonal_corpus() {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for i in 0..d.edges.len() {
for j in (i + 1)..d.edges.len() {
let (e1, e2) = (&d.edges[i], &d.edges[j]);
if e1.from == e2.from || e1.from == e2.to || e1.to == e2.from || e1.to == e2.to {
continue; }
for w1 in e1.points.windows(2) {
for w2 in e2.points.windows(2) {
let vert1 = (w1[0].x - w1[1].x).abs() < AXIS_EPS;
let vert2 = (w2[0].x - w2[1].x).abs() < AXIS_EPS;
if vert1 && vert2 && (w1[0].x - w2[0].x).abs() < AXIS_EPS {
let (y0a, y1a) = (w1[0].y.min(w1[1].y), w1[0].y.max(w1[1].y));
let (y0b, y1b) = (w2[0].y.min(w2[1].y), w2[0].y.max(w2[1].y));
assert!(
y0a >= y1b - AXIS_EPS || y0b >= y1a - AXIS_EPS,
"{name}: {}->{} and {}->{} overlap at x={} \
(y=[{y0a},{y1a}] vs [{y0b},{y1b}])",
e1.from,
e1.to,
e2.from,
e2.to,
w1[0].x
);
}
let horiz1 = (w1[0].y - w1[1].y).abs() < AXIS_EPS;
let horiz2 = (w2[0].y - w2[1].y).abs() < AXIS_EPS;
if horiz1 && horiz2 && (w1[0].y - w2[0].y).abs() < AXIS_EPS {
let (x0a, x1a) = (w1[0].x.min(w1[1].x), w1[0].x.max(w1[1].x));
let (x0b, x1b) = (w2[0].x.min(w2[1].x), w2[0].x.max(w2[1].x));
assert!(
x0a >= x1b - AXIS_EPS || x0b >= x1a - AXIS_EPS,
"{name}: {}->{} and {}->{} overlap at y={} \
(x=[{x0a},{x1a}] vs [{x0b},{x1b}])",
e1.from,
e1.to,
e2.from,
e2.to,
w1[0].y
);
}
}
}
}
}
}
}
#[test]
fn split_at_gaps_with_no_gaps_returns_the_whole_polyline_unchanged() {
let pts = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 100.0),
Point::new(50.0, 100.0),
];
let pieces = edges::split_at_gaps(&pts, &[]);
assert_eq!(pieces, vec![pts]);
}
#[test]
fn split_at_gaps_cuts_one_gap_into_two_pieces() {
let pts = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 100.0),
Point::new(50.0, 100.0),
];
let gap = (Point::new(0.0, 44.0), Point::new(0.0, 56.0));
let pieces = edges::split_at_gaps(&pts, &[gap]);
assert_eq!(
pieces,
vec![
vec![Point::new(0.0, 0.0), Point::new(0.0, 44.0)],
vec![
Point::new(0.0, 56.0),
Point::new(0.0, 100.0),
Point::new(50.0, 100.0)
],
]
);
}
#[test]
fn split_at_gaps_cuts_more_than_one_gap() {
let pts = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 100.0),
Point::new(80.0, 100.0),
];
let gaps = [
(Point::new(0.0, 44.0), Point::new(0.0, 56.0)),
(Point::new(30.0, 100.0), Point::new(42.0, 100.0)),
];
let pieces = edges::split_at_gaps(&pts, &gaps);
assert_eq!(
pieces,
vec![
vec![Point::new(0.0, 0.0), Point::new(0.0, 44.0)],
vec![
Point::new(0.0, 56.0),
Point::new(0.0, 100.0),
Point::new(30.0, 100.0)
],
vec![Point::new(42.0, 100.0), Point::new(80.0, 100.0)],
]
);
}
#[test]
fn split_at_gaps_ignores_a_gap_that_does_not_land_on_the_polyline() {
let pts = vec![Point::new(0.0, 0.0), Point::new(0.0, 100.0)];
let gap = (Point::new(500.0, 500.0), Point::new(500.0, 512.0));
let pieces = edges::split_at_gaps(&pts, &[gap]);
assert_eq!(pieces, vec![pts]);
}
#[test]
fn orthogonal_crossing_gaps_cut_the_spanning_edge_and_leave_the_crossed_one_whole() {
let src = "flowchart LR\n A & B --> C & D\n C --> E";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let edge = |from: &str, to: &str| {
d.edges
.iter()
.find(|e| e.from == from && e.to == to)
.unwrap_or_else(|| panic!("{from}->{to} must exist"))
};
let (ac, ad, bc, bd, ce) = (
edge("A", "C"),
edge("A", "D"),
edge("B", "C"),
edge("B", "D"),
edge("C", "E"),
);
assert!(ac.gaps.is_empty(), "A->C must stay whole: {:?}", ac.gaps);
assert!(bd.gaps.is_empty(), "B->D must stay whole: {:?}", bd.gaps);
assert!(ce.gaps.is_empty(), "C->E crosses nothing: {:?}", ce.gaps);
assert_eq!(
ad.gaps.len(),
1,
"A->D must carry exactly one gap, from crossing the ordinary A->C: {:?}",
ad.gaps
);
assert_eq!(
bc.gaps.len(),
1,
"B->C must carry exactly one gap, from crossing A->D (the both-detour tie-break): {:?}",
bc.gaps
);
for e in [ad, bc] {
for (g0, g1) in &e.gaps {
let arc = edges::arc_length_between(&e.points, g0, g1).unwrap_or_else(|| {
panic!(
"{}->{}: gap {g0:?}-{g1:?} is not on any of its own segments {:?}",
e.from, e.to, e.points
)
});
assert!(
(arc - orthogonal::CROSSING_GAP).abs() < 1e-6,
"{}->{}: gap {g0:?}-{g1:?} removes {arc}px of arc length, not CROSSING_GAP: {:?}",
e.from,
e.to,
orthogonal::CROSSING_GAP
);
}
}
}
#[test]
fn orthogonal_crossing_gap_splits_the_svg_path_of_the_spanning_edge_only() {
let src = "flowchart LR\n A & B --> C & D\n C --> E";
let svg =
crate::preview::markdown::mermaid_to_svg_flow(src, "dark", "basis", "konoma-orthogonal")
.expect("must render");
let path_count = svg.matches("<path").count();
assert_eq!(
path_count, 7,
"expected 3 uncut edges (1 path each) + A->D (2 pieces) + B->C (2 pieces) = 7: \
got {path_count}\n{svg}"
);
}
#[test]
fn normalise_moves_edge_gaps_along_with_points() {
let src = "flowchart LR\n A & B --> C & D\n C --> E";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let mut checked_at_least_one = false;
for e in &d.edges {
for (g0, g1) in &e.gaps {
checked_at_least_one = true;
let on_own_segments = edges::arc_length_between(&e.points, g0, g1).is_some();
assert!(
on_own_segments,
"{}->{}: gap {g0:?}-{g1:?} does not land on any of its own (normalised) \
segments {:?} — normalise() must have moved `points` without moving `gaps`",
e.from, e.to, e.points
);
}
}
assert!(
checked_at_least_one,
"fixture must actually produce at least one gap for this test to mean anything"
);
}
#[test]
fn normalise_translates_a_corner_straddling_gap_onto_its_own_two_segments() {
let mut d = Diagram {
width: 0.0,
height: 0.0,
..Diagram::default()
};
d.edges.push(PlacedEdge {
from: "a".to_string(),
to: "b".to_string(),
points: vec![
Point::new(0.0, 0.0),
Point::new(0.0, 20.0),
Point::new(30.0, 20.0),
],
gaps: vec![(Point::new(0.0, 11.0), Point::new(3.0, 20.0))],
tip_start: Tip::None,
tip_end: Tip::None,
stroke: Stroke::Normal,
label: None,
start_label: None,
end_label: None,
badge: None,
series: None,
straight: false,
overlay: false,
style: None,
curve: Curve::Basis,
tip_matches_line: false,
});
super::normalise(&mut d);
let e = &d.edges[0];
assert_eq!(
e.points,
vec![
Point::new(MARGIN, MARGIN),
Point::new(MARGIN, 20.0 + MARGIN),
Point::new(30.0 + MARGIN, 20.0 + MARGIN),
],
"normalise must shift every point of the polyline by (MARGIN, MARGIN): {:?}",
e.points
);
assert_eq!(e.gaps.len(), 1, "{:?}", e.gaps);
let (g0, g1) = &e.gaps[0];
assert_eq!(
(g0.clone(), g1.clone()),
(
Point::new(MARGIN, 11.0 + MARGIN),
Point::new(3.0 + MARGIN, 20.0 + MARGIN)
),
"the gap must be shifted by the exact same (MARGIN, MARGIN) as the points — a gap left in \
the pre-normalise coordinate space would still read (0,11)-(3,20) here"
);
let arc = edges::arc_length_between(&e.points, g0, g1).unwrap_or_else(|| {
panic!(
"gap {g0:?}-{g1:?} does not land on the translated polyline {:?}",
e.points
)
});
assert!(
(arc - orthogonal::CROSSING_GAP).abs() < 1e-9,
"arc length between the translated gap's own two endpoints must still be CROSSING_GAP: {arc}"
);
}
#[test]
fn orthogonal_frame_crossings_never_produce_a_gap() {
for (name, src) in orthogonal_corpus() {
if !name.contains("subgraph") {
continue;
}
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for e in &d.edges {
for (g0, g1) in &e.gaps {
let on_own_segments = edges::arc_length_between(&e.points, g0, g1).is_some();
assert!(
on_own_segments,
"{name}: {}->{}'s gap {g0:?}-{g1:?} is not on its own line — a frame must \
never be the reason a gap exists: {:?}",
e.from, e.to, e.points
);
}
}
}
}
#[test]
fn cluster_anchored_edges_are_actually_routed_orthogonally_not_by_the_spline_fallback() {
let src = "flowchart LR\n subgraph one [First]\n A --> B\n end\n \
subgraph two [Second]\n C --> D\n end\n one --> two\n E --> one";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let one_to_two = d
.edges
.iter()
.find(|e| e.from == "one" && e.to == "two")
.expect("one->two must exist");
let e_to_one = d
.edges
.iter()
.find(|e| e.from == "E" && e.to == "one")
.expect("E->one must exist");
for (name, e) in [("one->two", one_to_two), ("E->one", e_to_one)] {
assert!(
e.straight,
"{name}: must be drawn as a polyline, not a curve"
);
assert!(
e.tip_matches_line,
"{name}: the arrow tip must match the routed line, the same as every other \
orthogonal edge"
);
}
}
#[test]
fn cluster_face_shares_16px_ports_with_node_endpoint_claims_on_the_same_face() {
let src = "flowchart TD\n subgraph one [Group]\n A --> B\n A --> C\n end\n \
X --> one\n Y --> one\n Z --> one\n one --> D\n one --> E";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let one = d.cluster("one").expect("cluster one must exist");
let (l, _, _, _) = one.bounds();
let left_port = |from: &str, to: &str| -> f64 {
let e = d
.edges
.iter()
.find(|e| e.from == from && e.to == to)
.unwrap_or_else(|| panic!("{from}->{to} must exist"));
let p = if e.from == "one" {
&e.points[0]
} else {
e.points.last().expect("must have at least one point")
};
assert!(
(p.x - (l - orthogonal::PORT_INSET)).abs() < AXIS_EPS,
"{from}->{to}: expected this edge's own end at \"one\" to land on the LEFT face \
(x={}), got {p:?} — the fixture assumption behind this test's own port-order math \
no longer holds",
l - orthogonal::PORT_INSET
);
p.y
};
let d_y = left_port("one", "D");
let e_y = left_port("one", "E");
let x_y = left_port("X", "one");
assert!(
d_y < e_y && e_y < x_y,
"expected D < E < X by \"other cross coordinate\" order (rule 1), got D={d_y} E={e_y} \
X={x_y}"
);
assert!(
(e_y - d_y - orthogonal::PORT_SPACING).abs() < AXIS_EPS,
"D->E gap must be exactly PORT_SPACING: D={d_y} E={e_y}"
);
assert!(
(x_y - e_y - orthogonal::PORT_SPACING).abs() < AXIS_EPS,
"E->X gap must be exactly PORT_SPACING: E={e_y} X={x_y}"
);
}
#[test]
fn cluster_anchored_reverse_edge_routes_through_the_perimeter_lane_and_clears_its_own_members() {
let src = "flowchart TD\n subgraph one [Group]\n A --> B\n end\n one --> D\n D --> one";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let back = d
.edges
.iter()
.find(|e| e.from == "D" && e.to == "one")
.expect("D->one must exist");
assert!(
back.points.len() > 3,
"a perimeter route has more than one bend: {:?}",
back.points
);
for w in back.points.windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < AXIS_EPS || dy < AXIS_EPS,
"perimeter route must stay axis-parallel: {w:?}"
);
}
for member_id in ["A", "B"] {
let member = d.node(member_id).expect("member must exist");
for w in back.points.windows(2) {
assert!(
!orthogonal::segment_crosses_node(&w[0], &w[1], member),
"D->one's perimeter route crosses its own block's member {member_id}: {w:?}"
);
}
}
}
#[test]
fn cluster_to_cluster_edge_resolves_both_ends_against_the_frames_not_their_anchors() {
let src = "flowchart LR\n subgraph one [First]\n A --> B\n end\n \
subgraph two [Second]\n C --> D\n end\n two --> one";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let e = d
.edges
.iter()
.find(|e| e.from == "two" && e.to == "one")
.expect("two->one must exist");
assert!(
e.straight && e.tip_matches_line,
"must be orthogonal-routed"
);
let one = d.cluster("one").expect("cluster one must exist");
let two = d.cluster("two").expect("cluster two must exist");
let (start, end) = (
e.points.first().expect("must have a start point"),
e.points.last().expect("must have an end point"),
);
let on_bounds = |p: &Point, (l, t, r, b): (f64, f64, f64, f64)| {
let on_v = (p.y - (t - orthogonal::PORT_INSET)).abs() < AXIS_EPS
|| (p.y - (b + orthogonal::PORT_INSET)).abs() < AXIS_EPS;
let on_h = (p.x - (l - orthogonal::PORT_INSET)).abs() < AXIS_EPS
|| (p.x - (r + orthogonal::PORT_INSET)).abs() < AXIS_EPS;
on_v || on_h
};
assert!(
on_bounds(start, two.bounds()),
"two->one's own start {start:?} must sit on cluster \"two\"'s own bounds {:?}, not on \
one of its member nodes' bounds",
two.bounds()
);
assert!(
on_bounds(end, one.bounds()),
"two->one's own end {end:?} must sit on cluster \"one\"'s own bounds {:?}, not on one \
of its member nodes' bounds",
one.bounds()
);
}
#[test]
fn self_loop_routes_correctly_across_lane_alignment_stress_cases() {
let mut dense = String::from("flowchart TD\n");
for i in 0..12 {
dense.push_str(&format!(" d{i} --> d{}\n", i + 1));
dense.push_str(&format!(" d{i} --> e{i}\n e{i} --> d{}\n", i + 2));
}
dense.push_str(" d6 --> d6\n"); let dense_diamond_lattice_source = dense;
let sources: Vec<(&str, &str)> =
vec![
(
"wide-head",
"flowchart TD\n A[this label is deliberately very long to force a wide box] --> B\n \
B --> C\n A --> A",
),
(
"wide-tail",
"flowchart TD\n A --> B\n \
B --> C[this label is deliberately very long to force a wide box]\n C --> C",
),
(
"asymmetric-siblings",
"flowchart TD\n P --> A\n Q --> A\n R --> A\n A --> B\n B --> C\n A --> A",
),
(
"wide-sibling-pushes-loop-owner",
"flowchart TD\n A0 --> A1\n A1 --> A2\n A1 --> A1\n \
W[a very very very long wide sibling label to force a nodesep push] --> A2\n \
A0 --> W",
),
(
"z-competes-for-b",
"flowchart TD\n A --> B\n B --> C\n A --> A\n \
Z[a very very very long sibling label to push things around] --> B",
),
(
"deep-fanout-both-sides",
"flowchart TD\n W1 --> A\n W2 --> A\n W3 --> A\n A --> A\n A --> B\n \
B --> X1\n B --> X2\n B --> X3\n B --> C\n C --> D",
),
("dense-diamond-lattice", dense_diamond_lattice_source.as_str()),
(
"one-sided-huge-push",
"flowchart TD\n \
W1[extremely long sibling label number one to push things far to the right] --> A\n \
W2[extremely long sibling label number two to push things even further right] --> A\n \
W3[extremely long sibling label number three to push things still further right] --> A\n \
A --> A\n A --> B\n B --> C",
),
];
const SELF_LOOP_BUMP_MARGIN: f64 = 100.0;
for (name, src) in &sources {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for e in &d.edges {
if e.from != e.to {
continue;
}
let owner = d
.nodes
.iter()
.find(|n| n.id == e.from)
.unwrap_or_else(|| panic!("{name}: self-loop owner {} must be a node", e.from));
for w in e.points.windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < AXIS_EPS || dy < AXIS_EPS,
"{name}: self-loop {} must stay axis-parallel: {w:?}",
e.from
);
}
let (l, t, r, b) = owner.bounds();
for p in &e.points {
assert!(
p.x >= l - SELF_LOOP_BUMP_MARGIN
&& p.x <= r + SELF_LOOP_BUMP_MARGIN
&& p.y >= t - SELF_LOOP_BUMP_MARGIN
&& p.y <= b + SELF_LOOP_BUMP_MARGIN,
"{name}: self-loop {} point {p:?} sits far outside {}'s own current box \
({l},{t})-({r},{b}) (margin {SELF_LOOP_BUMP_MARGIN}) — exactly the drift \
review finding 3 asked about",
e.from,
e.from
);
}
}
}
}
fn orthogonal_only_corpus() -> Vec<(&'static str, &'static str)> {
vec![
(
"orthogonal-subgraph-growth",
"flowchart TD\n subgraph one [Group]\n W1 --> T\n W2 --> T\n W3 --> T\n \
W4 --> T\n W5 --> T\n end\n T --> X",
),
(
"orthogonal-frame-hugs-backedge",
"flowchart TD\n subgraph one [resolve the preview kind and delegate it]\n D[d]\n \
end\n X --> Y\n Y --> X",
),
(
"orthogonal-self-loop-crosses",
"flowchart TD\n A --> A\n A --> B\n C --> A\n C --> B",
),
]
}
#[test]
fn invariant_orthogonal_clusters_hold_their_members() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_corpus()
.into_iter()
.chain(orthogonal_only_corpus())
{
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let tree = tree_of_src(src);
check_clusters_hold_their_members(name, &d, &tree);
}
}
#[test]
fn invariant_orthogonal_nested_clusters_sit_inside_their_parent() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_corpus()
.into_iter()
.chain(orthogonal_only_corpus())
{
check_nested_clusters_sit_inside_their_parent(
name,
&laid_out_flow(src, "basis", "konoma-orthogonal"),
);
}
}
#[test]
fn invariant_orthogonal_unrelated_clusters_do_not_overlap() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_corpus()
.into_iter()
.chain(orthogonal_only_corpus())
{
check_unrelated_clusters_do_not_overlap(
name,
&laid_out_flow(src, "basis", "konoma-orthogonal"),
);
}
}
#[test]
fn invariant_orthogonal_cluster_titles_stay_inside_their_frame_and_off_every_node() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_corpus()
.into_iter()
.chain(orthogonal_only_corpus())
{
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let tree = tree_of_src(src);
check_cluster_titles(name, &d, &tree);
}
}
#[test]
fn orthogonal_eviction_growth_inside_a_subgraph_still_fits_the_frame() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_only_corpus()
.into_iter()
.find(|(name, _)| *name == "orthogonal-subgraph-growth")
.unwrap()
.1;
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let t = d.node("T").expect("T must exist");
let label_only = shapes::size(
Glyph::Flow(Shape::Rect),
Size::new(t.label.width, t.label.height),
);
assert!(
t.size.h > label_only.h + 1.0,
"T must actually grow past its label-only height to fit four merge ports on one face: \
grown={} label_only={}",
t.size.h,
label_only.h
);
let cluster = d.cluster("one").expect("cluster one must exist");
let out = escapes(t.bounds(), cluster.bounds());
assert!(
out <= 0.01,
"T's grown box must still sit fully inside its own frame: pokes {out:.2}px out"
);
}
#[test]
fn orthogonal_classdef_paints_a_chamfered_decision_node() {
let src =
"flowchart TD\n classDef hot fill:#f9f,stroke:#a00\n A --> B{cond}:::hot\n B --> C";
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let b = d.node("B").expect("B must exist");
assert_eq!(
b.shape,
Glyph::ChamferedRect,
"B must chamfer under orthogonal routing"
);
let style = b
.style
.as_ref()
.expect("B must carry the resolved `:::hot` paint");
assert_eq!(style.fill.as_deref(), Some("#f9f"));
assert_eq!(style.stroke.as_deref(), Some("#a00"));
let svg = render_flow(src, "dark", "basis", "konoma-orthogonal").expect("must render");
let vertex_count = |l: &str| -> usize {
l.split("points=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.map(|pts| pts.split_whitespace().count())
.unwrap_or(0)
};
let polygon = svg
.lines()
.filter(|l| l.starts_with("<polygon"))
.find(|l| vertex_count(l) == 8)
.unwrap_or_else(|| panic!("no 8-vertex chamfered-rect <polygon> element in:\n{svg}"));
assert!(
polygon.contains("fill=\"#f9f\""),
"the chamfered node's own polygon must carry the classDef fill: {polygon}"
);
assert!(
polygon.contains("stroke=\"#a00\""),
"the chamfered node's own polygon must carry the classDef stroke: {polygon}"
);
}
#[test]
fn orthogonal_back_edge_keeps_the_authors_own_line_style() {
let solid = render_flow(
"flowchart TD\n A --> B\n B --> A",
"dark",
"basis",
"konoma-orthogonal",
)
.expect("must render");
assert!(
!solid.contains("stroke-dasharray"),
"a solid back edge must not be drawn dashed just because it is routed on the perimeter \
lane: {solid}"
);
let dotted = render_flow(
"flowchart TD\n A --> B\n B -.-> A",
"dark",
"basis",
"konoma-orthogonal",
)
.expect("must render");
assert!(
dotted.contains(&format!("stroke-dasharray=\"{}\"", super::svg::DOTTED_DASH)),
"an author-dashed back edge must keep drawing dashed: {dotted}"
);
}
#[test]
fn orthogonal_routing_ignores_mermaid_curve_and_the_init_directive() {
for (name, src) in CORPUS {
let basis = render_flow(src, "dark", "basis", "konoma-orthogonal")
.unwrap_or_else(|e| panic!("{name}: basis must render: {e}"));
for curve in ["linear", "step", "monotoneX", "bumpX"] {
let other = render_flow(src, "dark", curve, "konoma-orthogonal")
.unwrap_or_else(|e| panic!("{name}/{curve}: must render: {e}"));
assert_eq!(
basis, other,
"{name}: konoma-orthogonal must ignore mermaid_curve={curve} entirely"
);
}
}
let plain = render_flow(
"flowchart TD\n A --> B --> C",
"dark",
"basis",
"konoma-orthogonal",
)
.expect("must render");
let with_init = render_flow(
"%%{init: {\"flowchart\": {\"curve\": \"stepBefore\"}}}%%\nflowchart TD\n A --> B --> C",
"dark",
"basis",
"konoma-orthogonal",
)
.expect("must render");
assert_eq!(
plain, with_init,
"konoma-orthogonal must ignore an `%%{{init}}%%` flowchart.curve directive too"
);
}
#[test]
fn orthogonal_self_loop_never_gets_cut_by_a_crossing_gap() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_only_corpus()
.into_iter()
.find(|(name, _)| *name == "orthogonal-self-loop-crosses")
.unwrap()
.1;
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
let loop_edge = d
.edges
.iter()
.find(|e| e.from == "A" && e.to == "A")
.expect("the self-loop must exist");
assert!(
loop_edge.gaps.is_empty(),
"a self-loop must never be the spanning side of a crossing gap: {:?}",
loop_edge.gaps
);
let detour = d
.edges
.iter()
.find(|e| e.from == "C" && e.to == "B")
.expect("C->B must exist");
assert!(
!detour.gaps.is_empty(),
"the fixture must actually reproduce a real crossing against the self-loop, so C->B must \
be the one carrying the gap: {:?}",
detour.gaps
);
let bbox = |pts: &[Point]| -> (f64, f64, f64, f64) {
let (mut l, mut t, mut r, mut b) = (
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
);
for p in pts {
l = l.min(p.x);
t = t.min(p.y);
r = r.max(p.x);
b = b.max(p.y);
}
(l, t, r, b)
};
let (dx, dy) = rect_overlap(bbox(&loop_edge.points), bbox(&detour.points));
assert!(
dx > 0.0 && dy > 0.0,
"fixture must reproduce a real overlap in space between the self-loop and C->B, not just \
a coincidental gap: loop_bbox={:?} detour_bbox={:?}",
bbox(&loop_edge.points),
bbox(&detour.points)
);
}
#[test]
fn orthogonal_self_loop_does_not_consume_a_perimeter_lane() {
if !text_metrics::fonts_available() {
return;
}
let with_loop = laid_out_flow(
"flowchart TD\n A --> A\n A --> B\n B --> C\n C --> A",
"basis",
"konoma-orthogonal",
);
let without_loop = laid_out_flow(
"flowchart TD\n A --> B\n B --> C\n C --> A",
"basis",
"konoma-orthogonal",
);
let ca_with = with_loop
.edges
.iter()
.find(|e| e.from == "C" && e.to == "A")
.expect("C->A must exist");
let ca_without = without_loop
.edges
.iter()
.find(|e| e.from == "C" && e.to == "A")
.expect("C->A must exist");
assert_eq!(
ca_with.points, ca_without.points,
"C->A's own route must be byte-identical whether or not A also carries a self-loop \
(the self-loop must never consume a perimeter lane slot): with={:?} without={:?}",
ca_with.points, ca_without.points
);
}
#[test]
fn orthogonal_heavy_growth_stress_renders_self_consistently() {
if !text_metrics::fonts_available() {
return;
}
let mut src = String::from("flowchart TD\n");
for i in 1..=12 {
src.push_str(&format!(
" W{i}[extremely long sibling label number {i} to force both node and label growth at once] --> T\n"
));
}
src.push_str(" T -- also a long label on the way out --> X\n");
let d = laid_out_flow(&src, "basis", "konoma-orthogonal");
for e in &d.edges {
for w in e.points.windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < AXIS_EPS || dy < AXIS_EPS,
"heavy-growth stress: {}->{} must stay axis-parallel: {w:?}",
e.from,
e.to
);
}
for n in &d.nodes {
if n.id == e.from || n.id == e.to {
continue;
}
for w in e.points.windows(2) {
assert!(
!orthogonal::segment_crosses_node(&w[0], &w[1], n),
"heavy-growth stress: {}->{} crosses foreign node {}",
e.from,
e.to,
n.id
);
}
}
}
render_flow(&src, "dark", "basis", "konoma-orthogonal").expect("must render");
}
#[test]
fn orthogonal_degenerate_diagrams_do_not_panic() {
if !text_metrics::fonts_available() {
return;
}
for src in ["flowchart TD\n A[only]", "flowchart TD\n A --> A"] {
let d = laid_out_flow(src, "basis", "konoma-orthogonal");
for e in &d.edges {
for w in e.points.windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < AXIS_EPS || dy < AXIS_EPS,
"{src:?}: {}->{} must stay axis-parallel: {w:?}",
e.from,
e.to
);
}
}
render_flow(src, "dark", "basis", "konoma-orthogonal")
.unwrap_or_else(|e| panic!("{src:?} must render under konoma-orthogonal: {e}"));
}
}
#[test]
fn orthogonal_route_edge_does_not_panic_on_coincident_centres() {
let a = placed_node("A", 100.0, 100.0, 60.0, 40.0);
let b = placed_node("B", 100.0, 100.0, 60.0, 40.0);
let pts = orthogonal::route_edge(
crate::preview::mermaid::flowchart::Direction::TopToBottom,
&a,
&b,
&[],
Some(0),
Some(1),
1,
1,
);
assert!(
!pts.is_empty(),
"must return a non-empty polyline, not panic"
);
for p in &pts {
assert!(
p.x.is_finite() && p.y.is_finite(),
"must never emit a NaN/infinite point: {p:?}"
);
}
}