use std::collections::{HashMap, HashSet};
use super::labels::Label;
use super::shapes::{Glyph, Mark};
use super::{class, er, sequence, state, Diagram, PlacedCluster, PlacedNode, RenderError};
use crate::preview::mermaid::chart;
use crate::preview::mermaid::layout::Point;
use crate::preview::mermaid::text_metrics;
use super::chart as draw_chart;
use super::class_tests::CASES as CLASS_CASES;
use super::er_tests::CASES as ER_CASES;
use super::sequence_tests::CASES as SEQUENCE_CASES;
use super::state_tests::CASES as STATE_CASES;
use super::tests::CORPUS as FLOW_CASES;
use crate::preview::mermaid::chart::tests::CASES as CHART_CASES;
use super::kinds_tests::{cases_of as kind_cases_of, laid_out as kind_diagram};
fn cases_of(is_ours: fn(&str) -> bool) -> Vec<(&'static str, &'static str)> {
let found: Vec<(&str, &str)> = CHART_CASES
.iter()
.filter(|(_, src)| is_ours(src))
.copied()
.collect();
assert!(!found.is_empty(), "the corpus has no case of this kind");
found
}
fn chart_diagram(src: &str) -> Diagram {
fn go(src: &str) -> Result<Diagram, RenderError> {
if chart::pie::is_pie(src) {
return draw_chart::pie::lay_out(&chart::pie::parse(src)?);
}
if chart::xychart::is_xychart(src) {
return draw_chart::xychart::lay_out(&chart::xychart::parse(src)?);
}
if chart::quadrant::is_quadrant_chart(src) {
return draw_chart::quadrant::lay_out(&chart::quadrant::parse(src)?);
}
if chart::radar::is_radar(src) {
return draw_chart::radar::lay_out(&chart::radar::parse(src)?);
}
if chart::treemap::is_treemap(src) {
return draw_chart::treemap::lay_out(&chart::treemap::parse(src)?);
}
if chart::packet::is_packet(src) {
return draw_chart::packet::lay_out(&chart::packet::parse(src)?);
}
if chart::sankey::is_sankey(src) {
return draw_chart::sankey::lay_out(&chart::sankey::parse(src)?);
}
panic!("no chart parser claims this source")
}
go(src).unwrap_or_else(|e| panic!("corpus source must lay out: {e}\n{src}"))
}
fn words(label: &Label) -> String {
label.lines.join(" ")
}
fn as_drawn(text: &str) -> String {
words(&Label::measure(text))
}
fn labels_with_prefix(d: &Diagram, prefix: &str) -> Vec<(Point, String)> {
d.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartLabel && n.id.starts_with(prefix))
.map(|n| (n.center.clone(), words(&n.label)))
.collect()
}
fn read_across(d: &Diagram, prefix: &str) -> Vec<String> {
let mut v = labels_with_prefix(d, prefix);
v.sort_by(|a, b| {
a.0.x
.partial_cmp(&b.0.x)
.unwrap_or(std::cmp::Ordering::Equal)
});
v.into_iter().map(|(_, t)| t).collect()
}
fn read_down(d: &Diagram, prefix: &str) -> Vec<String> {
let mut v = labels_with_prefix(d, prefix);
v.sort_by(|a, b| {
a.0.y
.partial_cmp(&b.0.y)
.unwrap_or(std::cmp::Ordering::Equal)
});
v.into_iter().map(|(_, t)| t).collect()
}
fn legend_down(d: &Diagram) -> Vec<String> {
read_down(d, "legend#")
}
fn panel_rows(node: &PlacedNode) -> Vec<String> {
let Some(p) = &node.panel else {
return Vec::new();
};
p.rows
.iter()
.map(|r| {
let mut cells: Vec<(f64, String)> =
r.cells.iter().map(|c| (c.x, words(&c.label))).collect();
cells.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
cells
.into_iter()
.map(|(_, t)| t)
.filter(|t| !t.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
})
.filter(|t| !t.trim().is_empty())
.collect()
}
fn nearest(p: &Point, anchors: &[(usize, Point)]) -> usize {
anchors
.iter()
.min_by(|a, b| {
let da = (a.1.x - p.x).hypot(a.1.y - p.y);
let db = (b.1.x - p.x).hypot(b.1.y - p.y);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| *i)
.expect("at least one anchor")
}
fn contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> bool {
inner.0 >= outer.0 - 0.5
&& inner.1 >= outer.1 - 0.5
&& inner.2 <= outer.2 + 0.5
&& inner.3 <= outer.3 + 0.5
}
fn cmp(a: f64, b: f64) -> std::cmp::Ordering {
a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal)
}
fn node_at<'a>(d: &'a Diagram, p: &Point) -> Option<&'a PlacedNode> {
let mut hit = d.nodes.iter().filter(|n| {
let (l, t, r, b) = n.bounds();
p.x >= l - 0.75 && p.x <= r + 0.75 && p.y >= t - 0.75 && p.y <= b + 0.75
});
let first = hit.next()?;
hit.next().is_none().then_some(first)
}
fn node_text(n: &PlacedNode) -> String {
match n.panel {
Some(_) => panel_rows(n).join(" "),
None => words(&n.label),
}
}
fn node_lines(n: &PlacedNode) -> Vec<String> {
match n.panel {
Some(_) => panel_rows(n),
None => n
.label
.lines
.iter()
.filter(|l| !l.trim().is_empty())
.cloned()
.collect(),
}
}
fn frame_depth(d: &Diagram, i: usize) -> usize {
let me = d.clusters[i].bounds();
d.clusters
.iter()
.enumerate()
.filter(|(j, o)| *j != i && contains(o.bounds(), me))
.count()
}
fn grid_order<'a>(cells: &[&'a PlacedNode]) -> Vec<&'a PlacedNode> {
let mut sorted: Vec<&PlacedNode> = cells.to_vec();
sorted.sort_by(|a, b| cmp(a.bounds().1, b.bounds().1));
let mut out: Vec<&PlacedNode> = Vec::new();
let mut row: Vec<&PlacedNode> = Vec::new();
let mut floor = f64::INFINITY;
for n in sorted {
let (_, t, _, b) = n.bounds();
if !row.is_empty() && t > floor - 0.5 {
row.sort_by(|a, b| cmp(a.center.x, b.center.x));
out.append(&mut row);
floor = f64::INFINITY;
}
floor = floor.min(b);
row.push(n);
}
row.sort_by(|a, b| cmp(a.center.x, b.center.x));
out.append(&mut row);
out
}
fn bands(names: &[String]) -> Vec<(String, Vec<usize>)> {
let mut runs: Vec<(String, Vec<usize>)> = Vec::new();
for (i, name) in names.iter().enumerate() {
match runs.last_mut() {
Some((open, members)) if open == name => members.push(i),
_ => runs.push((name.clone(), vec![i])),
}
}
runs.retain(|(name, _)| !Label::measure(name).is_blank());
runs
}
fn check_bands(
name: &str,
d: &Diagram,
marks: &[&PlacedNode],
runs: &[(String, Vec<usize>)],
along: impl Fn(&Point) -> f64,
) {
let mut frames: Vec<&PlacedCluster> = d.clusters.iter().collect();
frames.sort_by(|a, b| cmp(along(&a.center), along(&b.center)));
assert_eq!(
frames.len(),
runs.len(),
"{name}: {} frames were drawn for {} bands",
frames.len(),
runs.len()
);
for (frame, (band, members)) in frames.iter().zip(runs.iter()) {
assert_eq!(
words(&frame.title),
as_drawn(band),
"{name}: a frame is titled with another band's words"
);
let inside: Vec<usize> = marks
.iter()
.enumerate()
.filter(|(_, m)| contains(frame.bounds(), m.bounds()))
.map(|(k, _)| k)
.collect();
assert_eq!(
&inside, members,
"{name}: the frame reading {band:?} holds the marks at {inside:?}, and that band's \
own are at {members:?}"
);
}
}
#[test]
fn a_pies_share_is_drawn_on_its_own_slice_and_its_legend_names_the_slices_in_order() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in cases_of(chart::pie::is_pie) {
let model = chart::pie::parse(src).expect("parses");
let d = chart_diagram(src);
let expected: Vec<String> = model
.slices
.iter()
.map(|s| {
if model.show_data {
as_drawn(&s.label)
} else {
as_drawn(&s.label)
}
})
.collect();
let drawn = legend_down(&d);
assert_eq!(
drawn.len(),
expected.len(),
"{name}: the legend has {} rows for {} slices",
drawn.len(),
expected.len()
);
for (i, (got, want)) in drawn.iter().zip(expected.iter()).enumerate() {
assert!(
got.starts_with(want.as_str()),
"{name}: legend row {i} reads {got:?} where slice {i} is {want:?} — the rows are \
{drawn:?}"
);
}
let mut checked = 0usize;
for (i, node) in d.nodes.iter().enumerate() {
let _ = i;
if node.shape != Glyph::ChartLabel || !node.id.ends_with("#pct") {
continue;
}
let owner = node
.id
.trim_start_matches("slice#")
.trim_end_matches("#pct")
.to_string();
let wedge = d
.nodes
.iter()
.find(|n| n.shape == Glyph::Wedge && n.id == format!("slice#{owner}"))
.unwrap_or_else(|| panic!("{name}: a share labelled for a slice with no wedge"));
let Some(Mark::Wedge { start, sweep }) = wedge.mark else {
panic!("{name}: a wedge with no angles");
};
let dx = node.center.x - wedge.center.x;
let dy = node.center.y - wedge.center.y;
let mut deg = dy.atan2(dx).to_degrees() + 90.0;
while deg < start {
deg += 360.0;
}
assert!(
deg <= start + sweep + 1e-6,
"{name}: the share {:?} is drawn at {deg:.2}°, outside its own slice's \
{start:.2}°..{:.2}°",
words(&node.label),
start + sweep
);
checked += 1;
}
assert!(
checked > 0 || model.slices.len() > 8,
"{name}: no share label was checked — a case where every share is dropped proves \
nothing about placement"
);
}
}
#[test]
fn an_xy_charts_categories_ticks_and_bars_each_carry_their_own_datums_text() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in cases_of(chart::xychart::is_xychart) {
let model = chart::xychart::parse(src).expect("parses");
let d = chart_diagram(src);
let categories: Vec<String> = match &model.x {
chart::xychart::XAxis::Band(v) if !v.is_empty() => {
v.iter().map(|c| as_drawn(c)).collect()
}
_ => model
.plots
.first()
.map(|p| p.data.iter().map(|(k, _)| as_drawn(k)).collect())
.unwrap_or_default(),
};
let drawn = read_across(&d, "xtick#");
let expected: Vec<String> = categories
.iter()
.filter(|c| !c.trim().is_empty())
.cloned()
.collect();
assert_eq!(
drawn, expected,
"{name}: the x axis reads {drawn:?} left to right, but the source's categories are \
{expected:?} — every category label must draw its own words"
);
let mut ticks: Vec<(Point, String)> = labels_with_prefix(&d, "ytick#");
ticks.sort_by(|a, b| {
b.0.y
.partial_cmp(&a.0.y)
.unwrap_or(std::cmp::Ordering::Equal)
});
let values: Vec<f64> = ticks
.iter()
.map(|(_, t)| {
t.parse::<f64>()
.unwrap_or_else(|_| panic!("{name}: a y tick reading {t:?} is not a number"))
})
.collect();
for w in values.windows(2) {
assert!(
w[1] > w[0],
"{name}: the y ticks read {values:?} from the bottom up — a tick carrying its \
neighbour's number reads as a different chart"
);
}
let anchors: Vec<(usize, Point)> = {
let mut v = labels_with_prefix(&d, "xtick#");
v.sort_by(|a, b| {
a.0.x
.partial_cmp(&b.0.x)
.unwrap_or(std::cmp::Ordering::Equal)
});
v.into_iter()
.enumerate()
.map(|(i, (p, _))| (i, Point::new(p.x, 0.0)))
.collect()
};
if anchors.is_empty() {
continue;
}
for node in d.nodes.iter().filter(|n| n.shape == Glyph::ChartBar) {
if node.id.starts_with("legend#") {
continue;
}
let slot: usize = node
.id
.rsplit('#')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| panic!("{name}: a bar with no slot in its id: {}", node.id));
let got = nearest(&Point::new(node.center.x, 0.0), &anchors);
assert_eq!(
got, slot,
"{name}: the bar for category {slot} stands nearest the label for category {got}"
);
}
}
}
#[test]
fn a_quadrant_charts_names_are_drawn_in_the_regions_and_beside_the_points_they_name() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in cases_of(chart::quadrant::is_quadrant_chart) {
let model = chart::quadrant::parse(src).expect("parses");
let d = chart_diagram(src);
let frame = d
.nodes
.iter()
.find(|n| n.shape == Glyph::PlotFrame)
.unwrap_or_else(|| panic!("{name}: no quadrant frame"));
let (fl, ft, fr, fb) = frame.bounds();
let (mx, my) = ((fl + fr) / 2.0, (ft + fb) / 2.0);
for (text, id, want_right, want_top) in [
(&model.quadrant1, "quadrant#1", true, true),
(&model.quadrant2, "quadrant#0", false, true),
(&model.quadrant3, "quadrant#2", false, false),
(&model.quadrant4, "quadrant#3", true, false),
] {
let drawn = as_drawn(text);
if drawn.trim().is_empty() {
continue;
}
let node = d
.nodes
.iter()
.find(|n| n.shape == Glyph::ChartLabel && n.id == id)
.unwrap_or_else(|| panic!("{name}: {id} was not drawn at all"));
assert_eq!(
words(&node.label),
drawn,
"{name}: {id} is where {drawn:?} belongs and it names another quadrant"
);
assert_eq!(
node.center.x > mx,
want_right,
"{name}: {drawn:?} is on the wrong side of the vertical divide"
);
assert_eq!(
node.center.y < my,
want_top,
"{name}: {drawn:?} is in the wrong half of the square"
);
}
let dots: Vec<(usize, Point)> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartPoint)
.map(|n| {
let i: usize = n.id.trim_start_matches("point#").parse().expect("point id");
(i, n.center.clone())
})
.collect();
for node in d.nodes.iter() {
if node.shape != Glyph::ChartLabel || !node.id.ends_with("#label") {
continue;
}
let i: usize = node
.id
.trim_start_matches("point#")
.trim_end_matches("#label")
.parse()
.expect("point label id");
assert_eq!(
words(&node.label),
as_drawn(&model.points[i].label),
"{name}: the label beside point {i} does not read that point's own name"
);
assert_eq!(
nearest(&node.center, &dots),
i,
"{name}: the label {:?} is nearer to another point's dot than to its own",
words(&node.label)
);
}
let at = |id: &str| {
d.nodes
.iter()
.find(|n| n.shape == Glyph::ChartLabel && n.id == id)
.map(|n| (n.center.clone(), words(&n.label)))
};
if let (Some((l, lt)), Some((r, rt))) = (at("xaxis#left"), at("xaxis#right")) {
assert_eq!(lt, as_drawn(&model.x_left), "{name}: the left x word");
assert_eq!(rt, as_drawn(&model.x_right), "{name}: the right x word");
assert!(
l.x < r.x,
"{name}: the x axis words are the wrong way round"
);
}
if let (Some((b, bt)), Some((t, tt))) = (at("yaxis#bottom"), at("yaxis#top")) {
assert_eq!(bt, as_drawn(&model.y_bottom), "{name}: the bottom y word");
assert_eq!(tt, as_drawn(&model.y_top), "{name}: the top y word");
assert!(b.y > t.y, "{name}: the y axis words are upside down");
}
}
}
#[test]
fn a_radar_charts_axis_labels_stand_beyond_their_own_spokes() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in cases_of(chart::radar::is_radar) {
let model = chart::radar::parse(src).expect("parses");
let d = chart_diagram(src);
let centre = d
.nodes
.iter()
.find(|n| n.shape == Glyph::Graticule)
.map(|n| n.center.clone())
.unwrap_or_else(|| panic!("{name}: no graticule"));
let curve = d
.edges
.iter()
.find(|e| e.series.is_some() && e.points.len() > model.axes.len())
.unwrap_or_else(|| panic!("{name}: no curve was drawn"));
let angle = |p: &Point| (p.y - centre.y).atan2(p.x - centre.x);
let spokes: Vec<(usize, f64)> = curve
.points
.iter()
.take(model.axes.len())
.enumerate()
.filter(|(_, p)| (p.x - centre.x).hypot(p.y - centre.y) > 1.0)
.map(|(k, p)| (k, angle(p)))
.collect();
assert!(
!spokes.is_empty(),
"{name}: every curve vertex is at the centre, so no spoke direction can be read"
);
for (k, axis) in model.axes.iter().enumerate() {
let Some((_, want)) = spokes.iter().find(|(i, _)| *i == k) else {
continue;
};
let drawn = as_drawn(&axis.label);
if drawn.trim().is_empty() {
continue;
}
let node = d
.nodes
.iter()
.find(|n| n.shape == Glyph::ChartLabel && n.id == format!("axis#{}", axis.name))
.unwrap_or_else(|| panic!("{name}: axis {:?} has no label", axis.name));
assert_eq!(
words(&node.label),
drawn,
"{name}: the label on axis {:?} does not read that axis's own words",
axis.name
);
let got = spokes
.iter()
.min_by(|a, b| {
let da = angular_gap(angle(&node.center), a.1);
let db = angular_gap(angle(&node.center), b.1);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| *i)
.expect("a spoke");
assert_eq!(
got,
k,
"{name}: the words {drawn:?} name axis {k} but stand beyond spoke {got} \
(at {:.1}°, the spoke is at {:.1}°)",
angle(&node.center).to_degrees(),
want.to_degrees()
);
}
if model.options.show_legend && !model.curves.is_empty() {
let expected: Vec<String> = model.curves.iter().map(|c| as_drawn(&c.label)).collect();
assert_eq!(
legend_down(&d),
expected,
"{name}: the legend does not name the curves in the order they were written"
);
}
}
}
const SELF_CALL_REACH: f64 = 120.0;
fn angular_gap(a: f64, b: f64) -> f64 {
let mut d = (a - b).abs() % (std::f64::consts::PI * 2.0);
if d > std::f64::consts::PI {
d = std::f64::consts::PI * 2.0 - d;
}
d
}
#[test]
fn a_treemaps_tiles_name_their_own_data_and_nest_the_way_the_source_nests() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in cases_of(chart::treemap::is_treemap) {
let model = chart::treemap::parse(src).expect("parses");
let d = chart_diagram(src);
fn walk(
name: &str,
items: &[chart::treemap::Node],
base: usize,
outer: Option<(f64, f64, f64, f64)>,
d: &Diagram,
hit: &mut usize,
) {
let mut index = base;
for item in items {
let me = index;
index += draw_chart::treemap::subtree_len(item);
let Some(tile) = d.nodes.iter().find(|n| n.id == format!("tile#{me}")) else {
walk(name, &item.children, me + 1, None, d, hit);
continue;
};
let mine = tile.bounds();
if let Some(o) = outer {
assert!(
contains(o, mine),
"{name}: tile#{me} is the tile for {:?}, and it is drawn outside the \
section that owns it",
item.name
);
}
let rows = panel_rows(tile);
if let Some(first) = rows.first() {
assert_eq!(
*first,
as_drawn(&item.name),
"{name}: tile#{me} is where {:?} belongs and it reads {first:?}",
item.name
);
*hit += 1;
}
if let (Some(v), Some(second)) = (item.value, rows.get(1)) {
assert_eq!(
*second,
as_drawn(&draw_chart::tick_text(v)),
"{name}: tile#{me} is where {:?} belongs and the number under its name \
reads {second:?}",
item.name
);
}
walk(name, &item.children, me + 1, Some(mine), d, hit);
}
}
let mut hit = 0usize;
walk(name, &model.roots, 0, None, &d, &mut hit);
assert!(
hit > 0,
"{name}: not one tile carried words, so nothing was read back"
);
}
}
#[test]
fn a_packet_diagrams_fields_are_labelled_with_their_own_bits_and_names_in_bit_order() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in cases_of(chart::packet::is_packet) {
let model = chart::packet::parse(src).expect("parses");
let d = chart_diagram(src);
let mut blocks: Vec<(f64, f64, Vec<String>)> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartBar && n.id.starts_with("field#"))
.map(|n| (n.center.y, n.center.x, panel_rows(n)))
.collect();
blocks.sort_by(|a, b| {
a.0.partial_cmp(&b.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
});
let fields: Vec<&chart::packet::Field> = model.rows.iter().flatten().collect();
assert_eq!(
blocks.len(),
fields.len(),
"{name}: {} blocks drawn for {} fields",
blocks.len(),
fields.len()
);
for (i, (field, (_, _, rows))) in fields.iter().zip(blocks.iter()).enumerate() {
let range = if field.start == field.end {
format!("{}", field.start)
} else {
format!("{}-{}", field.start, field.end)
};
let label = as_drawn(&field.label);
for row in rows {
assert!(
*row == range || *row == label,
"{name}: the block in position {i} — which is bits {range} — is labelled \
{row:?}, which belongs to another field"
);
}
}
}
}
#[test]
fn a_sankey_diagrams_names_stand_beside_their_own_bars() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in cases_of(chart::sankey::is_sankey) {
let model = chart::sankey::parse(src).expect("parses");
let d = chart_diagram(src);
let bars: Vec<(usize, Point)> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartBar && n.id.starts_with("node#"))
.map(|n| {
let i: usize = n.id.trim_start_matches("node#").parse().expect("node id");
(i, n.center.clone())
})
.collect();
assert_eq!(bars.len(), model.nodes.len(), "{name}: one bar per node");
let mut checked = 0usize;
for node in d.nodes.iter() {
if node.shape != Glyph::ChartLabel || !node.id.ends_with("#label") {
continue;
}
let i: usize = node
.id
.trim_start_matches("node#")
.trim_end_matches("#label")
.parse()
.expect("node label id");
assert_eq!(
words(&node.label),
as_drawn(&model.nodes[i]),
"{name}: the name drawn for node {i} is not that node's own"
);
let bar = bars
.iter()
.find(|(j, _)| *j == i)
.map(|(_, p)| p.clone())
.expect("its bar");
assert!(
(node.center.y - bar.y).abs() <= 0.5,
"{name}: {:?} is not on the centre line of its own bar",
words(&node.label)
);
assert_eq!(
nearest(&node.center, &bars),
i,
"{name}: {:?} stands nearer to another node's bar than to its own",
words(&node.label)
);
checked += 1;
}
assert_eq!(checked, model.nodes.len(), "{name}: every node is named");
}
}
#[test]
fn a_flowcharts_boxes_lines_and_frames_each_carry_their_own_source_text() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in FLOW_CASES {
let model = crate::preview::mermaid::flowchart::parse(src).expect("parses");
let d = super::lay_out(&model).expect("lays out");
for node in &model.nodes {
let placed = d
.node(&node.id)
.unwrap_or_else(|| panic!("{name}: node {:?} was not drawn", node.id));
assert_eq!(
words(&placed.label),
as_drawn(&node.label),
"{name}: the box for {:?} holds another node's words",
node.id
);
}
let drawable: Vec<&crate::preview::mermaid::flowchart::Edge> = model
.edges
.iter()
.filter(|e| {
let known = |id: &str| {
model.node(id).is_some()
|| model.subgraphs.iter().any(|s| s.id == id)
|| d.cluster(id).is_some()
};
known(&e.from) && known(&e.to)
})
.collect();
assert_eq!(drawable.len(), d.edges.len(), "{name}: edge count");
for (k, (want, got)) in drawable.iter().zip(d.edges.iter()).enumerate() {
assert_eq!(
(got.from.as_str(), got.to.as_str()),
(want.from.as_str(), want.to.as_str()),
"{name}: drawn edge {k} joins the wrong pair"
);
let expected = want
.label
.as_deref()
.map(as_drawn)
.filter(|t| !t.trim().is_empty());
let drawn = got.label.as_ref().map(|l| words(&l.label));
assert_eq!(
drawn, expected,
"{name}: the words on {}->{} are not that link's own",
want.from, want.to
);
}
for block in &model.subgraphs {
let Some(frame) = d.cluster(&block.id) else {
continue;
};
assert_eq!(
words(&frame.title),
as_drawn(&block.title),
"{name}: the frame {:?} is titled with another block's words",
block.id
);
}
}
}
#[test]
fn a_state_diagrams_boxes_arrows_and_notes_each_carry_their_own_source_text() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in STATE_CASES {
let model = crate::preview::mermaid::state::parse(src).expect("parses");
let d = state::lay_out(&model).expect("lays out");
for s in &model.states {
if s.kind.is_block() {
if let Some(frame) = d.cluster(&s.id) {
assert_eq!(
words(&frame.title),
as_drawn(&s.label),
"{name}: the frame {:?} is titled with another state's words",
s.id
);
}
continue;
}
let Some(placed) = d.node(&s.id) else {
continue;
};
assert_eq!(
words(&placed.label),
as_drawn(&s.label),
"{name}: the box for {:?} holds another state's words",
s.id
);
}
let drawn: Vec<&super::PlacedEdge> = d
.edges
.iter()
.filter(|e| {
let is_note = |id: &str| d.node(id).is_some_and(|n| n.shape == Glyph::Note);
!is_note(&e.from) && !is_note(&e.to)
})
.collect();
let wanted: Vec<&crate::preview::mermaid::state::Transition> = model
.transitions
.iter()
.filter(|t| !t.is_note_link)
.filter(|t| {
let known = |id: &str| d.node(id).is_some() || d.cluster(id).is_some();
known(&t.from) && known(&t.to)
})
.collect();
assert_eq!(drawn.len(), wanted.len(), "{name}: transition count");
for (t, e) in wanted.iter().zip(drawn.iter()) {
let expected = t
.label
.as_deref()
.map(as_drawn)
.filter(|s| !s.trim().is_empty());
assert_eq!(
e.label.as_ref().map(|l| words(&l.label)),
expected,
"{name}: the words on {}->{} are not that transition's own",
t.from,
t.to
);
}
for note in model
.states
.iter()
.filter(|s| s.kind == crate::preview::mermaid::state::Kind::Note)
{
let Some(placed) = d.node(¬e.id) else {
continue;
};
assert_eq!(
words(&placed.label),
as_drawn(¬e.label),
"{name}: the note {:?} holds another note's words",
note.id
);
let Some(link) = model
.transitions
.iter()
.find(|t| t.is_note_link && (t.from == note.id || t.to == note.id))
else {
continue;
};
let anchor_id = if link.from == note.id {
&link.to
} else {
&link.from
};
let anchor = d
.node(anchor_id)
.map(|n| n.center.clone())
.or_else(|| d.cluster(anchor_id).map(|c| c.center.clone()));
let (Some(anchor), Some(position)) = (anchor, note.note_position) else {
continue;
};
let right = placed.center.x > anchor.x;
assert_eq!(
right,
position == crate::preview::mermaid::state::NotePosition::Right,
"{name}: the note {:?} was written {position:?} of {anchor_id} and is drawn on \
the other side",
words(&placed.label)
);
}
}
}
#[test]
fn a_class_boxs_compartments_hold_that_classs_own_members_in_order() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CLASS_CASES {
let model = crate::preview::mermaid::class::parse(src).expect("parses");
let d = class::lay_out(&model).expect("lays out");
for c in &model.classes {
let placed = d
.node(&c.id)
.unwrap_or_else(|| panic!("{name}: class {:?} was not drawn", c.id));
let mut want: Vec<String> = c
.annotations
.iter()
.map(|a| as_drawn(&format!("«{a}»")))
.collect();
want.push(as_drawn(&c.label));
want.extend(c.attributes.iter().map(|m| as_drawn(&m.display())));
want.extend(c.methods.iter().map(|m| as_drawn(&m.display())));
assert_eq!(
panel_rows(placed),
want,
"{name}: the box for {:?} does not hold that class's own rows in order",
c.id
);
}
for note in &model.notes {
let Some(placed) = d.node(¬e.id) else {
continue;
};
assert_eq!(
words(&placed.label),
as_drawn(¬e.text),
"{name}: the note {:?} holds another note's words",
note.id
);
}
for ns in &model.namespaces {
let Some(frame) = d.cluster(&ns.id) else {
continue;
};
assert_eq!(
words(&frame.title),
as_drawn(&ns.label),
"{name}: the namespace frame {:?} is titled with other words",
ns.id
);
}
}
}
#[test]
fn an_entitys_grid_holds_that_entitys_own_attribute_rows_in_order() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in ER_CASES {
let model = crate::preview::mermaid::er::parse(src).expect("parses");
let d = er::lay_out(&model).expect("lays out");
for e in &model.entities {
let placed = d
.node(&e.id)
.unwrap_or_else(|| panic!("{name}: entity {:?} was not drawn", e.id));
let mut want = vec![as_drawn(&e.label)];
for a in &e.attributes {
let cells = [
as_drawn(&a.kind),
as_drawn(&a.name),
as_drawn(&a.keys.join(", ")),
as_drawn(&a.comment),
];
want.push(
cells
.iter()
.filter(|c| !c.trim().is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" "),
);
}
assert_eq!(
panel_rows(placed),
want,
"{name}: the grid for {:?} does not hold that entity's own rows in order",
e.id
);
}
for r in &model.relationships {
let Some(edge) = d
.edges
.iter()
.find(|edge| edge.from == r.from && edge.to == r.to)
else {
continue;
};
let expected = r
.label
.as_deref()
.map(as_drawn)
.filter(|s| !s.trim().is_empty());
assert_eq!(
edge.label.as_ref().map(|l| words(&l.label)),
expected,
"{name}: the verb on {} -- {} is not that relationship's own",
r.from,
r.to
);
}
}
}
#[test]
fn a_sequence_diagrams_messages_participants_and_notes_each_carry_their_own_text() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in SEQUENCE_CASES {
let model = crate::preview::mermaid::sequence::parse(src).expect("parses");
let d = sequence::lay_out(&model).expect("lays out");
check_sequence_text_placement(name, &model, &d);
}
}
fn check_sequence_text_placement(
name: &str,
model: &crate::preview::mermaid::sequence::SequenceDiagram,
d: &Diagram,
) {
for p in &model.participants {
let Some(node) = d.nodes.iter().find(|n| n.id == p.id) else {
continue;
};
let drawn = match &node.panel {
Some(_) => panel_rows(node).join(" "),
None => words(&node.label),
};
assert_eq!(
drawn,
as_drawn(&p.label),
"{name}: the head box for {:?} holds another participant's name",
p.id
);
let line = d
.lifelines
.iter()
.find(|l| l.id == p.id)
.unwrap_or_else(|| panic!("{name}: {:?} has no lifeline", p.id));
assert!(
(node.center.x - line.x).abs() <= 0.5,
"{name}: the box naming {:?} does not stand on that participant's own lifeline",
p.id
);
}
let messages: Vec<&crate::preview::mermaid::sequence::Message> = model
.events
.iter()
.filter_map(|e| match e {
crate::preview::mermaid::sequence::Event::Message(m) => Some(m),
_ => None,
})
.collect();
assert_eq!(messages.len(), d.edges.len(), "{name}: message count");
let x_of: HashMap<&str, f64> = d.lifelines.iter().map(|l| (l.id.as_str(), l.x)).collect();
for (k, (m, e)) in messages.iter().zip(d.edges.iter()).enumerate() {
let expected = as_drawn(&m.text);
let drawn = e.label.as_ref().map(|l| words(&l.label));
if expected.trim().is_empty() {
assert!(drawn.is_none(), "{name}: message {k} grew words of its own");
continue;
}
assert_eq!(
drawn.as_deref(),
Some(expected.as_str()),
"{name}: the words on message {k} ({} -> {}) are not that message's own",
m.from,
m.to
);
let label = e.label.as_ref().expect("a label");
let lo = e.points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
let hi = e
.points
.iter()
.map(|p| p.x)
.fold(f64::NEG_INFINITY, f64::max);
assert!(
label.center.x >= lo - label.size.w && label.center.x <= hi + label.size.w,
"{name}: the words on message {k} are drawn away from the arrow they name"
);
let line_y = e.points.first().map(|p| p.y).unwrap_or(0.0);
if m.from == m.to {
let foot = e
.points
.iter()
.map(|p| p.y)
.fold(f64::NEG_INFINITY, f64::max);
assert!(
label.center.y >= line_y - 0.5 && label.center.y <= foot + 0.5,
"{name}: the words on the self-call {k} are drawn off its own loop"
);
assert!(
label.center.x > hi,
"{name}: the words on the self-call {k} are drawn over its own loop"
);
} else {
assert!(
label.center.y < line_y + 0.5,
"{name}: the words on message {k} are drawn below their own arrow"
);
}
if let (Some(&a), Some(&b)) = (x_of.get(m.from.as_str()), x_of.get(m.to.as_str())) {
assert!(
lo >= a.min(b) - 0.5 && hi <= a.max(b) + SELF_CALL_REACH,
"{name}: message {k} ({} -> {}) is not drawn between its own participants",
m.from,
m.to
);
}
}
let notes: Vec<&crate::preview::mermaid::sequence::Note> = model
.events
.iter()
.filter_map(|e| match e {
crate::preview::mermaid::sequence::Event::Note(n) => Some(n),
_ => None,
})
.collect();
let mut drawn_notes: Vec<&PlacedNode> =
d.nodes.iter().filter(|n| n.shape == Glyph::Note).collect();
drawn_notes.sort_by_key(|n| {
n.id.trim_start_matches("note#")
.parse::<usize>()
.unwrap_or(usize::MAX)
});
assert_eq!(drawn_notes.len(), notes.len(), "{name}: note count");
for (k, (want, got)) in notes.iter().zip(drawn_notes.iter()).enumerate() {
assert_eq!(
words(&got.label),
as_drawn(&want.text),
"{name}: note {k} holds another note's words"
);
let named: HashSet<&str> = want.actors.iter().map(|s| s.as_str()).collect();
let mut mine: Vec<f64> = x_of
.iter()
.filter(|(id, _)| named.contains(*id))
.map(|(_, x)| *x)
.collect();
if mine.is_empty() {
continue;
}
mine.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let (lo, hi) = (mine[0], mine[mine.len() - 1]);
let reach = got.size.w.max(hi - lo) + 40.0;
assert!(
got.center.x >= lo - reach && got.center.x <= hi + reach,
"{name}: note {k} is drawn away from the participants it names ({:?})",
want.actors
);
}
}
#[test]
fn a_gantt_charts_rows_ticks_and_sections_each_carry_their_own_tasks_words() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::gantt::{self, time};
for (name, src) in kind_cases_of(gantt::is_gantt) {
let model = gantt::parse(src).expect("parses");
let d = kind_diagram(src);
let mut marks: Vec<&PlacedNode> = d
.nodes
.iter()
.filter(|n| n.id.starts_with("task#") && !n.id.ends_with("#name"))
.collect();
marks.sort_by(|a, b| cmp(a.center.y, b.center.y));
assert_eq!(
marks.len(),
model.tasks.len(),
"{name}: {} marks were drawn for {} tasks",
marks.len(),
model.tasks.len()
);
let mut rows: Vec<String> = Vec::new();
for mark in &marks {
let beside: Vec<&PlacedNode> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartLabel)
.filter(|n| {
(n.center.y - mark.center.y).abs() < 0.5 && n.center.x < mark.bounds().0
})
.collect();
assert!(
beside.len() <= 1,
"{name}: {} labels share one row, so no one of them is that row's name",
beside.len()
);
rows.push(beside.first().map(|n| words(&n.label)).unwrap_or_default());
}
let wanted: Vec<String> = model.tasks.iter().map(|t| as_drawn(&t.name)).collect();
assert_eq!(
rows, wanted,
"{name}: the rows read {rows:?} top to bottom, but the source's tasks are {wanted:?} \
— the name beside a bar has to be that bar's own task"
);
let mut ticks: Vec<(f64, time::Instant, String)> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartLabel && n.id.starts_with("tick#"))
.map(|n| {
let iso = n.id.trim_start_matches("tick#");
let at = time::parse(iso, "YYYY-MM-DDTHH:mm")
.unwrap_or_else(|| panic!("{name}: a tick standing on no date: {}", n.id));
(n.center.x, at, words(&n.label))
})
.collect();
ticks.sort_by(|a, b| cmp(a.0, b.0));
assert!(!ticks.is_empty(), "{name}: the time axis carries no ticks");
let drawn: Vec<String> = ticks.iter().map(|(_, _, t)| t.clone()).collect();
let expected: Vec<String> = ticks
.iter()
.map(|(_, at, _)| as_drawn(&time::format(*at, &model.axis_format)))
.collect();
assert_eq!(
drawn, expected,
"{name}: the axis reads {drawn:?} left to right, and the dates its ticks stand on are \
{expected:?}"
);
for w in ticks.windows(2) {
assert!(
w[1].1 > w[0].1,
"{name}: the axis reads {drawn:?} left to right, which is not date order"
);
}
let sections: Vec<String> = model.tasks.iter().map(|t| t.section.clone()).collect();
check_bands(name, &d, &marks, &bands(§ions), |p| p.y);
}
}
#[test]
fn a_git_graphs_captions_and_lane_names_belong_to_their_own_commits_and_branches() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::gitgraph::{self, Commit, Direction};
for (name, src) in kind_cases_of(gitgraph::is_git_graph) {
let model = gitgraph::parse(src).expect("parses");
let d = kind_diagram(src);
let across = |p: &Point| match model.direction {
Direction::LeftToRight => p.y,
_ => p.x,
};
let says = |c: &Commit| -> String {
if !c.tags.is_empty() {
as_drawn(&c.tags.join(", "))
} else if c.explicit_id {
as_drawn(&c.id)
} else {
String::new()
}
};
let dots: Vec<&PlacedNode> = model
.commits
.iter()
.map(|c| {
d.node(&c.id)
.unwrap_or_else(|| panic!("{name}: commit {:?} was not drawn", c.id))
})
.collect();
let captions: Vec<&PlacedNode> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartLabel && n.id.ends_with("#caption"))
.collect();
for caption in &captions {
let owner = dots
.iter()
.enumerate()
.filter(|(_, dot)| {
(dot.center.x - caption.center.x).abs() < 0.5 && dot.center.y < caption.center.y
})
.max_by(|a, b| cmp(a.1.center.y, b.1.center.y))
.map(|(i, _)| i)
.unwrap_or_else(|| {
panic!(
"{name}: the caption {:?} stands under no commit at all",
words(&caption.label)
)
});
assert_eq!(
words(&caption.label),
says(&model.commits[owner]),
"{name}: the caption under commit {:?} says something another commit said",
model.commits[owner].id
);
}
let carried = model.commits.iter().filter(|c| !says(c).is_empty()).count();
assert_eq!(
captions.len(),
carried,
"{name}: {} captions were drawn, and {carried} commits carry one",
captions.len()
);
let lanes: Vec<&str> = model.lanes().iter().map(|b| b.name.as_str()).collect();
let mut named: Vec<(f64, String)> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartLabel && n.id.starts_with("branch#"))
.map(|n| (across(&n.center), words(&n.label)))
.collect();
named.sort_by(|a, b| cmp(a.0, b.0));
let drawn: Vec<String> = named.iter().map(|(_, t)| t.clone()).collect();
let expected: Vec<String> = lanes.iter().map(|b| as_drawn(b)).collect();
assert_eq!(
drawn, expected,
"{name}: the lanes are named {drawn:?} across the page, and the source's branches are \
{expected:?}"
);
for (k, lane) in lanes.iter().enumerate() {
let Some(sample) = model
.commits
.iter()
.find(|c| c.branch == **lane)
.and_then(|c| d.node(&c.id))
else {
continue;
};
assert!(
(named[k].0 - across(&sample.center)).abs() < 0.5,
"{name}: {:?} names branch {lane:?} and does not stand on the lane its commits \
are drawn on",
named[k].1
);
}
}
}
#[test]
fn a_mindmaps_boxes_hold_their_own_words_and_hang_off_their_own_parents() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::mindmap;
for (name, src) in kind_cases_of(mindmap::is_mindmap) {
let model = mindmap::parse(src).expect("parses");
let d = kind_diagram(src);
for (i, node) in model.nodes.iter().enumerate() {
let placed = d
.node(&format!("n{i}"))
.unwrap_or_else(|| panic!("{name}: node {i} was not drawn"));
assert_eq!(
words(&placed.label),
as_drawn(&node.label),
"{name}: n{i} is the box for {:?} and it holds another node's words",
node.label
);
}
let mut drawn: Vec<String> = d.nodes.iter().map(|n| words(&n.label)).collect();
let mut wanted: Vec<String> = model.nodes.iter().map(|n| as_drawn(&n.label)).collect();
drawn.sort();
wanted.sort();
assert_eq!(
drawn, wanted,
"{name}: the boxes read {drawn:?}, and the source's nodes are {wanted:?}"
);
let mut joined: Vec<(String, String)> = Vec::new();
for e in &d.edges {
let ends = (e.points.first(), e.points.last());
let (Some(from), Some(to)) = ends else {
panic!("{name}: a line with no ends")
};
let a =
node_at(&d, from).unwrap_or_else(|| panic!("{name}: a line starts on no one box"));
let b = node_at(&d, to).unwrap_or_else(|| panic!("{name}: a line ends on no one box"));
joined.push((words(&a.label), words(&b.label)));
}
let mut branches: Vec<(String, String)> = model
.nodes
.iter()
.filter_map(|n| {
let parent = n.parent?;
Some((as_drawn(&model.nodes[parent].label), as_drawn(&n.label)))
})
.collect();
joined.sort();
branches.sort();
assert_eq!(
joined, branches,
"{name}: the lines join {joined:?}, and the source's branches are {branches:?} — a \
child has to hang off its own parent's box"
);
}
}
#[test]
fn a_kanban_boards_cards_are_in_their_own_columns_in_source_order() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::kanban;
for (name, src) in kind_cases_of(kanban::is_kanban) {
let model = kanban::parse(src).expect("parses");
let d = kind_diagram(src);
let mut frames: Vec<&PlacedCluster> = d.clusters.iter().collect();
frames.sort_by(|a, b| cmp(a.center.x, b.center.x));
assert_eq!(
frames.len(),
model.columns.len(),
"{name}: {} frames for {} columns",
frames.len(),
model.columns.len()
);
for (frame, column) in frames.iter().zip(model.columns.iter()) {
let title = match column.ticket.as_deref().filter(|t| !t.trim().is_empty()) {
Some(t) => format!("{} ({t})", column.label),
None => column.label.clone(),
};
assert_eq!(
words(&frame.title),
as_drawn(&title),
"{name}: a column's frame is titled with another column's words"
);
let mut inside: Vec<&PlacedNode> = d
.nodes
.iter()
.filter(|n| contains(frame.bounds(), n.bounds()))
.collect();
inside.sort_by(|a, b| cmp(a.center.y, b.center.y));
let drawn: Vec<Vec<String>> = inside.iter().map(|n| panel_rows(n)).collect();
let wanted: Vec<Vec<String>> = column
.cards
.iter()
.map(|c| {
let mut rows: Vec<String> = Label::measure(&c.label).lines.clone();
for (key, value) in [
("assigned", &c.assigned),
("ticket", &c.ticket),
("priority", &c.priority),
] {
if let Some(v) = value.as_deref().filter(|v| !v.trim().is_empty()) {
rows.push(as_drawn(&format!("{key}: {v}")));
}
}
rows.retain(|r| !r.trim().is_empty());
rows
})
.collect();
assert_eq!(
drawn, wanted,
"{name}: the column titled {title:?} holds {drawn:?} top to bottom, and its own \
cards are {wanted:?}"
);
}
}
}
#[test]
fn a_journeys_steps_carry_their_own_names_scores_and_people_in_their_own_sections() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::flowchart::Shape;
use crate::preview::mermaid::journey;
for (name, src) in kind_cases_of(journey::is_journey) {
let model = journey::parse(src).expect("parses");
let d = kind_diagram(src);
let mut boxes: Vec<&PlacedNode> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::Flow(Shape::RoundedRect))
.collect();
boxes.sort_by(|a, b| cmp(a.center.x, b.center.x));
assert_eq!(
boxes.len(),
model.tasks.len(),
"{name}: {} boxes were drawn for {} steps",
boxes.len(),
model.tasks.len()
);
let drawn: Vec<String> = boxes.iter().map(|n| words(&n.label)).collect();
let wanted: Vec<String> = model.tasks.iter().map(|t| as_drawn(&t.name)).collect();
assert_eq!(
drawn, wanted,
"{name}: the steps read {drawn:?} left to right, and the source's are {wanted:?}"
);
for (k, step) in boxes.iter().enumerate() {
let task = &model.tasks[k];
let column = |n: &PlacedNode| (n.center.x - step.center.x).abs() < 0.5;
let face = d
.nodes
.iter()
.find(|n| n.shape == Glyph::Face && column(n) && n.center.y < step.center.y);
match task.score {
Some(score) => {
let face = face.unwrap_or_else(|| {
panic!(
"{name}: the step {:?} has no face over it",
words(&step.label)
)
});
assert_eq!(
face.mark,
Some(Mark::Face { score }),
"{name}: the face over {:?} is drawn at another step's score",
words(&step.label)
);
}
None => assert!(
face.is_none(),
"{name}: the step {:?} has no readable score and grew a face anyway",
words(&step.label)
),
}
let below: Vec<&PlacedNode> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::ChartLabel && column(n) && n.center.y > step.center.y)
.collect();
let people = as_drawn(&task.people.join(", "));
let said = below.first().map(|n| words(&n.label)).unwrap_or_default();
assert!(
below.len() <= 1,
"{name}: {} names stand under the step {:?}",
below.len(),
words(&step.label)
);
assert_eq!(
said,
people,
"{name}: {said:?} is drawn under the step {:?}, whose own people are {people:?}",
words(&step.label)
);
}
let sections: Vec<String> = model.tasks.iter().map(|t| t.section.clone()).collect();
check_bands(name, &d, &boxes, &bands(§ions), |p| p.x);
}
}
#[test]
fn a_timelines_events_sit_under_their_own_periods_in_their_own_sections() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::flowchart::Shape;
use crate::preview::mermaid::timeline::{self, Direction};
for (name, src) in kind_cases_of(timeline::is_timeline) {
let model = timeline::parse(src).expect("parses");
let d = kind_diagram(src);
let along = |p: &Point| match model.direction {
Direction::LeftToRight => p.x,
Direction::TopToBottom => p.y,
};
let mut periods: Vec<&PlacedNode> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::Flow(Shape::RoundedRect))
.collect();
periods.sort_by(|a, b| cmp(along(&a.center), along(&b.center)));
assert_eq!(
periods.len(),
model.periods.len(),
"{name}: {} periods were drawn for {}",
periods.len(),
model.periods.len()
);
let drawn: Vec<String> = periods.iter().map(|n| words(&n.label)).collect();
let wanted: Vec<String> = model.periods.iter().map(|p| as_drawn(&p.name)).collect();
assert_eq!(
drawn, wanted,
"{name}: the spine reads {drawn:?} along the axis, and the source's periods are \
{wanted:?}"
);
let events: Vec<&PlacedNode> = d
.nodes
.iter()
.filter(|n| n.shape == Glyph::Flow(Shape::Rect))
.collect();
let owner = |e: &PlacedNode| -> usize {
match model.direction {
Direction::LeftToRight => periods
.iter()
.position(|p| (p.center.x - e.center.x).abs() < 0.5)
.unwrap_or_else(|| {
panic!(
"{name}: the event {:?} hangs in no period's column",
words(&e.label)
)
}),
Direction::TopToBottom => periods
.iter()
.enumerate()
.min_by(|a, b| {
cmp(
(a.1.center.y - e.center.y).abs(),
(b.1.center.y - e.center.y).abs(),
)
})
.map(|(i, _)| i)
.expect("a timeline has periods"),
}
};
for (k, period) in model.periods.iter().enumerate() {
let mut mine: Vec<&PlacedNode> =
events.iter().copied().filter(|e| owner(e) == k).collect();
mine.sort_by(|a, b| cmp(a.center.y, b.center.y));
let drawn: Vec<String> = mine.iter().map(|e| words(&e.label)).collect();
let wanted: Vec<String> = period.events.iter().map(|e| as_drawn(e)).collect();
assert_eq!(
drawn, wanted,
"{name}: the period {:?} carries {drawn:?}, and its own events are {wanted:?}",
period.name
);
}
let sections: Vec<String> = model.periods.iter().map(|p| p.section.clone()).collect();
check_bands(name, &d, &periods, &bands(§ions), along);
}
}
#[test]
fn a_requirement_boxs_rows_and_a_verbs_line_belong_to_their_own_datum() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::requirement;
for (name, src) in kind_cases_of(requirement::is_requirement_diagram) {
let model = requirement::parse(src).expect("parses");
let d = kind_diagram(src);
let rows_of = |stereotype: &str, subject: &str, fields: &[(&str, &str)]| -> Vec<String> {
let mut rows = vec![as_drawn(&format!("«{stereotype}»")), as_drawn(subject)];
rows.extend(
fields
.iter()
.filter(|(_, v)| !v.trim().is_empty())
.map(|(k, v)| as_drawn(&format!("{k}: {v}"))),
);
rows
};
let mut declared: Vec<(&str, Vec<String>)> = model
.requirements
.iter()
.map(|r| {
(
r.name.as_str(),
rows_of(
r.kind.title(),
&r.name,
&[
("Id", &r.id),
("Text", &r.text),
("Risk", &r.risk),
("Verification", &r.verify_method),
],
),
)
})
.collect();
declared.extend(model.elements.iter().map(|e| {
(
e.name.as_str(),
rows_of(
"Element",
&e.name,
&[("Type", &e.kind), ("Doc Ref", &e.doc_ref)],
),
)
}));
for (id, want) in &declared {
let placed = d
.node(id)
.unwrap_or_else(|| panic!("{name}: the box {id:?} was not drawn"));
assert_eq!(
&panel_rows(placed),
want,
"{name}: the box {id:?} does not hold that datum's own rows in order"
);
}
let mut wanted: Vec<Vec<String>> = declared.into_iter().map(|(_, rows)| rows).collect();
let mut drawn: Vec<Vec<String>> = d.nodes.iter().map(panel_rows).collect();
drawn.sort();
wanted.sort();
assert_eq!(
drawn, wanted,
"{name}: the boxes hold {drawn:?}, and the source declares {wanted:?}"
);
let subject = |n: &PlacedNode| panel_rows(n).get(1).cloned().unwrap_or_default();
let mut joined: Vec<(String, String, String)> = Vec::new();
for e in &d.edges {
let (Some(from), Some(to)) = (e.points.first(), e.points.last()) else {
panic!("{name}: a line with no ends")
};
let a =
node_at(&d, from).unwrap_or_else(|| panic!("{name}: a line starts on no one box"));
let b = node_at(&d, to).unwrap_or_else(|| panic!("{name}: a line ends on no one box"));
joined.push((
subject(a),
subject(b),
e.label
.as_ref()
.map(|l| words(&l.label))
.unwrap_or_default(),
));
}
let known = |id: &str| {
model.requirements.iter().any(|r| r.name == id)
|| model.elements.iter().any(|e| e.name == id)
};
let mut links: Vec<(String, String, String)> = model
.links
.iter()
.filter(|l| known(&l.src) && known(&l.dst))
.map(|l| {
(
as_drawn(&l.src),
as_drawn(&l.dst),
as_drawn(l.relation.word()),
)
})
.collect();
joined.sort();
links.sort();
assert_eq!(
joined, links,
"{name}: the lines say {joined:?}, and the source's relationships are {links:?}"
);
}
}
#[test]
fn a_c4_boxs_caption_and_a_boundarys_title_belong_to_their_own_datum() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::c4;
for (name, src) in kind_cases_of(c4::is_c4) {
let model = c4::parse(src).expect("parses");
let d = kind_diagram(src);
let caption = |e: &c4::Element| -> Vec<String> {
let mut lines = vec![e.label.clone()];
if !e.kind_line.trim().is_empty() {
let external = if e.external { ", external" } else { "" };
lines.push(format!("[{}{external}]", e.kind_line));
}
if !e.descr.trim().is_empty() {
lines.push(e.descr.clone());
}
Label::measure(&lines.join("\n")).lines
};
for e in &model.elements {
let placed = d
.node(&e.alias)
.unwrap_or_else(|| panic!("{name}: the element {:?} was not drawn", e.alias));
assert_eq!(
node_lines(placed),
caption(e),
"{name}: the box {:?} reads another element's caption",
e.alias
);
}
let mut wanted: Vec<Vec<String>> = model.elements.iter().map(caption).collect();
let mut drawn: Vec<Vec<String>> = d.nodes.iter().map(node_lines).collect();
drawn.sort();
wanted.sort();
assert_eq!(
drawn, wanted,
"{name}: the boxes read {drawn:?}, and the source's elements are {wanted:?}"
);
let head = |n: &PlacedNode| node_lines(n).first().cloned().unwrap_or_default();
let mut joined: Vec<(String, String, String)> = Vec::new();
for e in &d.edges {
let (Some(from), Some(to)) = (e.points.first(), e.points.last()) else {
panic!("{name}: a line with no ends")
};
let a =
node_at(&d, from).unwrap_or_else(|| panic!("{name}: a line starts on no one box"));
let b = node_at(&d, to).unwrap_or_else(|| panic!("{name}: a line ends on no one box"));
joined.push((
head(a),
head(b),
e.label
.as_ref()
.map(|l| words(&l.label))
.unwrap_or_default(),
));
}
let named = |alias: &str| {
model
.elements
.iter()
.find(|e| e.alias == alias)
.map(|e| as_drawn(&e.label))
.unwrap_or_default()
};
let drawable = |id: &str| model.elements.iter().any(|e| e.alias == id);
let mut rels: Vec<(String, String, String)> = model
.rels
.iter()
.filter(|r| drawable(&r.from) && drawable(&r.to))
.map(|r| {
let text = if r.techn.trim().is_empty() {
r.label.clone()
} else {
format!("{}\n[{}]", r.label, r.techn)
};
(named(&r.from), named(&r.to), as_drawn(&text))
})
.collect();
joined.sort();
rels.sort();
assert_eq!(
joined, rels,
"{name}: the lines say {joined:?}, and the source's relationships are {rels:?}"
);
let frame_title = |b: &c4::Boundary| -> Vec<String> {
let title = if b.kind_line.trim().is_empty() {
b.label.clone()
} else {
format!("{}\n[{}]", b.label, b.kind_line)
};
Label::measure(&title).lines
};
for b in &model.boundaries {
let Some(frame) = d.cluster(&b.alias) else {
continue;
};
assert_eq!(
frame.title.lines,
frame_title(b),
"{name}: the frame {:?} is titled with another boundary's words",
b.alias
);
}
let head = |n: &PlacedNode| node_lines(n).first().cloned().unwrap_or_default();
let mut frames: Vec<(Vec<String>, usize, Vec<String>)> = d
.clusters
.iter()
.enumerate()
.map(|(i, c)| {
let mut held: Vec<String> = d
.nodes
.iter()
.filter(|n| contains(c.bounds(), n.bounds()))
.map(head)
.collect();
held.sort();
(held, frame_depth(&d, i), c.title.lines.clone())
})
.collect();
fn under(model: &c4::C4, alias: &str, out: &mut Vec<String>, guard: usize) {
if guard == 0 {
return;
}
for m in model
.boundaries
.iter()
.filter(|b| b.alias == alias)
.flat_map(|b| b.members.iter())
{
if let Some(e) = model.elements.iter().find(|e| e.alias == *m) {
out.push(as_drawn(&e.label));
} else {
under(model, m, out, guard - 1);
}
}
}
let depth_of = |b: &c4::Boundary| -> usize {
let mut at = b.parent.clone();
let mut n = 0usize;
for _ in 0..model.boundaries.len() + 1 {
let Some(id) = at else { break };
n += 1;
at = model
.boundaries
.iter()
.find(|o| o.alias == id)
.and_then(|o| o.parent.clone());
}
n
};
let mut wanted: Vec<(Vec<String>, usize, Vec<String>)> = model
.boundaries
.iter()
.map(|b| {
let mut held = Vec::new();
under(&model, &b.alias, &mut held, model.boundaries.len() + 1);
held.sort();
(held, depth_of(b), frame_title(b))
})
.collect();
frames.sort();
wanted.sort();
assert_eq!(
frames, wanted,
"{name}: the frames hold and say {frames:?}, and the source's boundaries are {wanted:?}"
);
}
}
#[test]
fn a_block_grids_cells_hold_their_own_words_in_grid_order() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::block::{self, Item};
for (name, src) in kind_cases_of(block::is_block_diagram) {
let model = block::parse(src).expect("parses");
let d = kind_diagram(src);
let deepest = |bounds: (f64, f64, f64, f64), not: Option<usize>| -> Option<usize> {
d.clusters
.iter()
.enumerate()
.filter(|(i, c)| Some(*i) != not && contains(c.bounds(), bounds))
.max_by_key(|(i, _)| frame_depth(&d, *i))
.map(|(i, _)| i)
};
let owner: Vec<Option<usize>> = d.nodes.iter().map(|n| deepest(n.bounds(), None)).collect();
let holder: Vec<Option<usize>> = (0..d.clusters.len())
.map(|i| deepest(d.clusters[i].bounds(), Some(i)))
.collect();
fn check(
name: &str,
where_: &str,
items: &[Item],
region: Option<usize>,
d: &Diagram,
owner: &[Option<usize>],
holder: &[Option<usize>],
) {
let cells: Vec<&PlacedNode> = d
.nodes
.iter()
.enumerate()
.filter(|(i, _)| owner[*i] == region)
.map(|(_, n)| n)
.collect();
let drawn: Vec<String> = grid_order(&cells).iter().map(|n| words(&n.label)).collect();
let wanted: Vec<String> = items
.iter()
.filter_map(|i| match i {
Item::Node(n) => Some(as_drawn(&n.label)),
_ => None,
})
.collect();
assert_eq!(
drawn, wanted,
"{name}: {where_} reads {drawn:?} in grid order, and its own blocks are {wanted:?}"
);
let frames: Vec<usize> = {
let mut v: Vec<usize> = (0..d.clusters.len())
.filter(|i| holder[*i] == region)
.collect();
v.sort_by(|a, b| {
cmp(d.clusters[*a].bounds().1, d.clusters[*b].bounds().1)
.then(cmp(d.clusters[*a].center.x, d.clusters[*b].center.x))
});
v
};
let nested: Vec<&block::Composite> = items
.iter()
.filter_map(|i| match i {
Item::Composite(c) => Some(c),
_ => None,
})
.collect();
assert_eq!(
frames.len(),
nested.len(),
"{name}: {where_} drew {} frames for {} nested blocks",
frames.len(),
nested.len()
);
for (frame, c) in frames.iter().zip(nested.iter()) {
assert_eq!(
words(&d.clusters[*frame].title),
as_drawn(if c.named { c.id.as_str() } else { "" }),
"{name}: a nested block's frame is titled with other words"
);
check(
name,
&format!("the block {:?}", c.id),
&c.children,
Some(*frame),
d,
owner,
holder,
);
}
}
check(name, "the grid", &model.items, None, &d, &owner, &holder);
}
}
#[test]
fn an_architectures_boxes_and_group_titles_belong_to_their_own_services_and_groups() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::architecture;
for (name, src) in kind_cases_of(architecture::is_architecture) {
let model = architecture::parse(src).expect("parses");
let d = kind_diagram(src);
let says = |s: &architecture::Service| -> String {
if s.junction {
String::new()
} else if s.title.trim().is_empty() {
as_drawn(&s.id)
} else {
as_drawn(&s.title)
}
};
for s in &model.services {
let placed = d
.node(&s.id)
.unwrap_or_else(|| panic!("{name}: the service {:?} was not drawn", s.id));
assert_eq!(
words(&placed.label),
says(s),
"{name}: the box for {:?} reads another service's words",
s.id
);
}
let mut drawn: Vec<String> = d.nodes.iter().map(|n| words(&n.label)).collect();
let mut wanted: Vec<String> = model.services.iter().map(says).collect();
drawn.sort();
wanted.sort();
assert_eq!(
drawn, wanted,
"{name}: the boxes read {drawn:?}, and the source's services are {wanted:?}"
);
let mut joined: Vec<(String, String)> = Vec::new();
for e in &d.edges {
let (Some(from), Some(to)) = (e.points.first(), e.points.last()) else {
panic!("{name}: a line with no ends")
};
let a =
node_at(&d, from).unwrap_or_else(|| panic!("{name}: a line starts on no one box"));
let b = node_at(&d, to).unwrap_or_else(|| panic!("{name}: a line ends on no one box"));
joined.push((words(&a.label), words(&b.label)));
}
let known = |id: &str| model.services.iter().any(|s| s.id == id);
let service = |id: &str| {
model
.services
.iter()
.find(|s| s.id == id)
.map(says)
.unwrap_or_default()
};
let mut wired: Vec<(String, String)> = model
.edges
.iter()
.filter(|e| known(&e.from) && known(&e.to))
.map(|e| (service(&e.from), service(&e.to)))
.collect();
joined.sort();
wired.sort();
assert_eq!(
joined, wired,
"{name}: the lines join {joined:?}, and the source wires {wired:?}"
);
for g in &model.groups {
let Some(frame) = d.cluster(&g.id) else {
continue;
};
assert_eq!(
words(&frame.title),
as_drawn(&g.title),
"{name}: the frame for {:?} is titled with another group's words",
g.id
);
}
let mut frames: Vec<(Vec<String>, usize, String)> = d
.clusters
.iter()
.enumerate()
.map(|(i, c)| {
let mut held: Vec<String> = d
.nodes
.iter()
.filter(|n| contains(c.bounds(), n.bounds()))
.map(|n| words(&n.label))
.collect();
held.sort();
(held, frame_depth(&d, i), words(&c.title))
})
.collect();
let inside = |group: Option<&str>, want: &str| -> bool {
let mut at = group;
for _ in 0..model.groups.len() + 1 {
let Some(id) = at else { return false };
if id == want {
return true;
}
at = model
.groups
.iter()
.find(|g| g.id == id)
.and_then(|g| g.parent.as_deref());
}
false
};
let mut wanted: Vec<(Vec<String>, usize, String)> = model
.groups
.iter()
.filter_map(|g| {
let mut held: Vec<String> = model
.services
.iter()
.filter(|s| inside(s.group.as_deref(), &g.id))
.map(says)
.collect();
if held.is_empty() {
return None;
}
held.sort();
Some((held, inside_depth(&model, &g.id), as_drawn(&g.title)))
})
.collect();
frames.sort();
wanted.sort();
assert_eq!(
frames, wanted,
"{name}: the frames hold and say {frames:?}, and the source's groups are {wanted:?}"
);
}
}
fn inside_depth(model: &crate::preview::mermaid::architecture::Architecture, id: &str) -> usize {
let mut at = model
.groups
.iter()
.find(|g| g.id == id)
.and_then(|g| g.parent.as_deref());
let mut n = 0usize;
for _ in 0..model.groups.len() + 1 {
let Some(parent) = at else { break };
n += 1;
at = model
.groups
.iter()
.find(|g| g.id == parent)
.and_then(|g| g.parent.as_deref());
}
n
}
#[test]
fn a_zenuml_diagrams_participants_messages_and_notes_carry_their_own_translated_text() {
if !text_metrics::fonts_available() {
return;
}
use crate::preview::mermaid::zenuml;
for (name, src) in kind_cases_of(zenuml::is_zenuml) {
let model = zenuml::parse(src).expect("parses");
let d = super::zenuml::lay_out(src).expect("lays out");
check_sequence_text_placement(name, &model, &d);
let lines: Vec<&str> = src.lines().collect();
let mut cursor = 0usize;
let mut read = 0usize;
for e in &d.edges {
let Some(label) = e.label.as_ref() else {
continue;
};
let text = words(&label.label);
if text.trim().is_empty() || !src.contains(text.as_str()) {
continue;
}
let at = lines
.iter()
.enumerate()
.skip(cursor)
.find(|(_, line)| line.contains(text.as_str()))
.map(|(i, _)| i)
.unwrap_or_else(|| {
panic!(
"{name}: the arrow reading {text:?} is drawn after one whose words the \
source writes on a later line — the messages have changed places"
)
});
cursor = at;
read += 1;
}
assert!(
read > 0,
"{name}: not one arrow's words could be found in the source, so nothing was read \
back and this half of the test asserted nothing"
);
}
}