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, render, 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",
),
];
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}"))
}
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,
};
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");
}
#[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,
});
}
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,
});
}
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"
);
}
}
}
}