use std::collections::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, render, render_curve, 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}"))
}
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 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,
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,
});
}
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,
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,
}
}
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"
);
}