use std::collections::{HashMap, HashSet};
use super::state::{lay_out, render, render_flow, spec_of, NOTE_GAP};
use super::tests::{
assert_snapshot, boundary, check_cluster_titles, check_clusters_hold_their_members,
check_edge_that_names_a_block_stops_on_its_frame, check_edges_keep_out_of_foreign_frames,
check_edges_stay_out_of_shapes, check_endpoints_land_on_the_outline,
check_foreign_nodes_stay_out_of_clusters, check_labels_ride_their_edge,
check_nested_clusters_sit_inside_their_parent, check_nodes_do_not_overlap,
check_unrelated_clusters_do_not_overlap, check_view_box_contains_everything, dist_to_boundary,
mask_numbers, path_boxes, text_widths, tree_of,
};
use super::theme;
use super::Routing;
use super::{
clusters, labels, orthogonal, shapes, svg, Diagram, Glyph, Label, PlacedCluster, PlacedEdge,
PlacedNode, RenderError, Size,
};
use crate::preview::mermaid::flowchart::{Shape, Stroke};
use crate::preview::mermaid::layout::Point;
use crate::preview::mermaid::state::{self, Kind};
use crate::preview::mermaid::text_metrics;
pub const CASES: &[(&str, &str)] = &[
(
"basic",
"stateDiagram-v2\n [*] --> Still\n Still --> [*]\n\n Still --> Moving\n \
Moving --> Still\n Moving --> Crash\n Crash --> [*]",
),
(
"v1-header",
"stateDiagram\n [*] --> Still\n Still --> [*]\n Still --> Moving",
),
(
"described",
"stateDiagram-v2\n state \"This is a state description\" as s2\n s3 : Another\n \
[*] --> s2\n s2 --> s3 : A transition\n s3 --> [*]",
),
(
"titled",
"stateDiagram-v2\n s2 : Waiting for input\n s2 : press any key\n [*] --> s2\n \
s2 --> [*]",
),
(
"composite",
"stateDiagram-v2\n [*] --> First\n state First {\n [*] --> second\n \
second --> [*]\n }\n First --> [*]",
),
(
"composite-named",
"stateDiagram-v2\n [*] --> NamedComposite\n NamedComposite: Another Composite\n \
state NamedComposite {\n [*] --> namedSimple\n namedSimple --> [*]\n \
namedSimple: Another simple\n }",
),
(
"nested",
"stateDiagram-v2\n [*] --> First\n state First {\n [*] --> Second\n \
state Second {\n [*] --> second\n second --> Third\n \
state Third {\n [*] --> third\n third --> [*]\n \
}\n }\n }",
),
(
"composite-siblings",
"stateDiagram-v2\n [*] --> First\n First --> Second\n First --> Third\n\n \
state First {\n [*] --> fir\n fir --> [*]\n }\n state Second {\n \
[*] --> sec\n sec --> [*]\n }\n state Third {\n [*] --> thi\n \
thi --> [*]\n }",
),
(
"choice",
"stateDiagram-v2\n state if_state <<choice>>\n [*] --> IsPositive\n \
IsPositive --> if_state\n if_state --> False: if n < 0\n if_state --> True : if n >= 0",
),
(
"fork",
"stateDiagram-v2\n state fork_state <<fork>>\n [*] --> fork_state\n \
fork_state --> State2\n fork_state --> State3\n\n state join_state <<join>>\n \
State2 --> join_state\n State3 --> join_state\n join_state --> State4\n \
State4 --> [*]",
),
(
"fork-lr",
"stateDiagram-v2\n direction LR\n state f <<fork>>\n [*] --> f\n f --> A\n \
f --> B\n state j <<join>>\n A --> j\n B --> j\n j --> [*]",
),
(
"notes",
"stateDiagram-v2\n State1: The state with a note\n note right of State1\n \
Important information! You can write\n notes.\n end note\n \
State1 --> State2\n note left of State2 : This is the note to the left.",
),
(
"notes-crowded",
"stateDiagram-v2\n A --> B\n A --> C\n B --> D\n C --> D\n \
note right of A : first\n note right of B : second\n note left of C : third",
),
(
"note-lr",
"stateDiagram-v2\n direction LR\n A --> B\n A --> C\n B --> D\n C --> D\n \
note right of B : second",
),
(
"concurrent",
"stateDiagram-v2\n [*] --> Active\n\n state Active {\n [*] --> NumLockOff\n \
NumLockOff --> NumLockOn : EvNumLockPressed\n NumLockOn --> NumLockOff : EvNumLockPressed\n \
--\n [*] --> CapsLockOff\n CapsLockOff --> CapsLockOn : EvCapsLockPressed\n \
CapsLockOn --> CapsLockOff : EvCapsLockPressed\n }",
),
(
"direction-lr",
"stateDiagram\n direction LR\n [*] --> A\n A --> B\n B --> C\n \
state B {\n direction LR\n a --> b\n }\n B --> D",
),
(
"styled",
"stateDiagram\n direction TB\n\n accTitle: This is the accessible title\n \
accDescr: This is an accessible description\n\n classDef notMoving fill:white\n \
classDef movement font-style:italic\n\n [*]--> Still\n Still --> [*]\n \
Still --> Moving\n Moving --> Still\n Moving --> Crash\n Crash --> [*]\n\n \
class Still notMoving\n class Moving movement",
),
(
"style-separator",
"stateDiagram\n classDef notMoving fill:white\n [*] --> Still:::notMoving\n \
Still --> [*]\n Still --> Moving:::movement\n Moving --> Still",
),
(
"spaces-in-names",
"stateDiagram\n yswsii: Your state with spaces in it\n [*] --> yswsii\n \
[*] --> SomeOtherState\n SomeOtherState --> YetAnotherState\n \
yswsii --> YetAnotherState\n YetAnotherState --> [*]",
),
(
"cjk",
"stateDiagram-v2\n [*] --> ツリー\n ツリー --> プレビュー : Enter\n \
プレビュー --> ツリー : q\n プレビュー --> [*]\n state プレビュー {\n \
[*] --> 種別を解決\n 種別を解決 --> 全画面描画\n }",
),
(
"cjk-note",
"stateDiagram-v2\n direction LR\n [*] --> ツリー\n ツリー --> 全画面\n \
note right of ツリー : 全画面のファイル一覧",
),
(
"multiline-label",
"stateDiagram-v2\n [*] --> A\n A --> B : first<br>second\n \
B : one<br/>two<br />three",
),
(
"self-loop",
"stateDiagram-v2\n [*] --> Retry\n Retry --> Retry : again\n Retry --> [*]",
),
(
"block-endpoint",
"stateDiagram-v2\n state Inner {\n a --> b\n }\n Outside --> Inner\n \
Inner --> Done",
),
];
fn laid_out(src: &str) -> Diagram {
let model = state::parse(src).unwrap_or_else(|e| panic!("corpus source must parse: {e}"));
lay_out(&model, Routing::Splines).unwrap_or_else(|e| panic!("corpus source must lay out: {e}"))
}
fn tree_of_src(src: &str) -> clusters::Tree {
let model = state::parse(src).expect("parses");
let spec = spec_of(&model, Routing::Splines);
let ids: HashSet<String> = spec.nodes.iter().map(|n| n.id.clone()).collect();
clusters::Tree::from_blocks(&spec.blocks, |id| ids.contains(id))
}
fn glyph_of(d: &Diagram, id: &str) -> Glyph {
d.node(id).unwrap_or_else(|| panic!("no node {id}")).shape
}
#[test]
fn state_box_width_matches_resvg() {
if !text_metrics::fonts_available() {
eprintln!("no sans-serif face — skipping (the renderer refuses to draw here too)");
return;
}
for label in [
"Still",
"AVATAR To Wa",
"illicit lilli",
"全画面プレビュー",
"開始、処理。「確認」",
"build 🚀 ship",
"0123456789",
"loop Every minute",
] {
let src = format!("stateDiagram-v2\n s : {label}\n [*] --> s");
let d = laid_out(&src);
let svg = render(&src, "dark").expect("renders");
let node = d.node("s").expect("the state");
let mut widths = Vec::new();
text_widths(tree_of(&svg).root(), &mut widths);
assert_eq!(
widths.len(),
1,
"{label:?}: exactly one <text> should survive usvg"
);
let slack = node.size.w - widths[0] as f64;
assert!(
(slack - shapes::PADDING * 2.0).abs() <= 1.0,
"{label:?}: box {} wide, resvg drew {} → {} of padding, shape declares {}",
svg::num(node.size.w),
svg::num(widths[0] as f64),
svg::num(slack),
svg::num(shapes::PADDING * 2.0)
);
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),
"{label:?}: no drawn path has the width the model declares ({})",
svg::num(node.size.w)
);
}
}
#[test]
fn note_box_width_matches_resvg() {
if !text_metrics::fonts_available() {
return;
}
let src = "stateDiagram-v2\n A --> B\n note right of A : 全画面の ファイル一覧";
let d = laid_out(src);
let svg = render(src, "dark").expect("renders");
let note = d
.nodes
.iter()
.find(|n| n.shape == Glyph::Note)
.expect("a note was drawn");
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 = note.size.w - drawn;
assert!(
(slack - shapes::PADDING * 2.0).abs() <= 1.0,
"note box {} vs resvg's {} → {} of padding, declared {}",
svg::num(note.size.w),
svg::num(drawn),
svg::num(slack),
svg::num(shapes::PADDING * 2.0)
);
}
#[test]
fn the_geometry_invariants_hold_for_state_diagrams() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CASES {
let d = laid_out(src);
let tree = tree_of_src(src);
check_nodes_do_not_overlap(name, &d);
check_edges_stay_out_of_shapes(name, &d);
check_labels_ride_their_edge(name, &d);
check_endpoints_land_on_the_outline(name, &d);
check_view_box_contains_everything(name, &d);
check_clusters_hold_their_members(name, &d, &tree);
check_nested_clusters_sit_inside_their_parent(name, &d);
check_unrelated_clusters_do_not_overlap(name, &d);
check_edges_keep_out_of_foreign_frames(name, &d, &tree);
check_edge_that_names_a_block_stops_on_its_frame(name, &d, &tree);
check_cluster_titles(name, &d, &tree);
}
}
#[test]
fn every_state_and_transition_survives() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CASES {
let model = state::parse(src).expect("parses");
let d = laid_out(src);
let boxes = model.boxes().count();
assert_eq!(d.nodes.len(), boxes, "{name}: box count");
let tree = tree_of_src(src);
let blocks: HashSet<&str> = tree.iter().map(|c| c.id.as_str()).collect();
let drawn: HashSet<&str> = d.clusters.iter().map(|c| c.id.as_str()).collect();
assert_eq!(drawn, blocks, "{name}: frames and blocks disagree");
assert_eq!(
d.edges.len(),
model.transitions.len(),
"{name}: transition count"
);
}
}
#[test]
fn a_start_and_an_end_are_told_apart() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out("stateDiagram-v2\n [*] --> A\n A --> [*]");
assert_eq!(glyph_of(&d, "root_start"), Glyph::StateStart);
assert_eq!(glyph_of(&d, "root_end"), Glyph::StateEnd);
let start = shapes::outline(
Glyph::StateStart,
d.node("root_start").expect("start").size,
None,
);
let end = shapes::outline(Glyph::StateEnd, d.node("root_end").expect("end").size, None);
assert_ne!(start, end, "the two markers must not be the same outline");
assert!(
matches!(start, shapes::Outline::Disc { .. }),
"a start is a solid dot, was {start:?}"
);
assert!(
matches!(end, shapes::Outline::Target { .. }),
"an end is a ring round a dot, was {end:?}"
);
let svg = render("stateDiagram-v2\n [*] --> A\n A --> [*]", "dark").expect("renders");
assert_eq!(
svg.matches("<circle").count(),
3,
"one circle for the start, two for the end:\n{svg}"
);
}
#[test]
fn a_choice_is_a_textless_diamond() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out(
"stateDiagram-v2\n state c <<choice>>\n c : ignored\n A --> c\n c --> B\n c --> C",
);
let c = d.node("c").expect("the choice");
assert_eq!(c.shape, Glyph::Choice);
assert!(c.label.is_blank(), "a choice draws no text");
assert_eq!(c.size, Size::new(shapes::CHOICE_SIZE, shapes::CHOICE_SIZE));
assert_eq!(shapes::polygon(Glyph::Choice, c.size).len(), 4);
}
#[test]
fn a_fork_bar_turns_with_the_diagram() {
if !text_metrics::fonts_available() {
return;
}
for (dir, horizontal) in [("TB", true), ("BT", true), ("LR", false), ("RL", false)] {
let src = format!(
"stateDiagram-v2\n direction {dir}\n state f <<fork>>\n state j <<join>>\n \
A --> f\n f --> B\n f --> C\n B --> j\n C --> j\n j --> D"
);
let d = laid_out(&src);
for id in ["f", "j"] {
let bar = d.node(id).expect("the bar");
assert_eq!(bar.shape, Glyph::Bar { horizontal }, "{dir} {id}");
let (long, short) = if horizontal {
(bar.size.w, bar.size.h)
} else {
(bar.size.h, bar.size.w)
};
assert_eq!(long, shapes::BAR_LENGTH, "{dir} {id}: long axis");
assert_eq!(short, shapes::BAR_THICKNESS, "{dir} {id}: short axis");
}
}
}
#[test]
fn two_descriptions_get_a_rule_under_the_first() {
if !text_metrics::fonts_available() {
return;
}
let one = laid_out("stateDiagram-v2\n s : only\n [*] --> s");
assert_eq!(glyph_of(&one, "s"), Glyph::Flow(Shape::RoundedRect));
let src = "stateDiagram-v2\n s : first\n s : second\n [*] --> s";
let two = laid_out(src);
let node = two.node("s").expect("the state");
assert_eq!(node.shape, Glyph::TitledBox);
assert_eq!(node.label.lines, vec!["first", "second"]);
let svg = render(src, "dark").expect("renders");
let rule_y = node.center.y - node.label.height / 2.0 + labels::line_height();
assert!(
svg.contains(&format!("y1=\"{}\"", svg::num(rule_y))),
"the rule should sit where the first line ends ({}):\n{svg}",
svg::num(rule_y)
);
}
#[test]
fn composites_and_regions_are_frames() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out(
"stateDiagram-v2\n [*] --> Active\n state Active {\n [*] --> A\n --\n \
[*] --> B\n }",
);
assert!(d.node("Active").is_none(), "a composite is not a box");
let outer = d.cluster("Active").expect("the composite's frame");
assert_eq!(outer.title.lines, vec!["Active".to_string()]);
assert!(!outer.dashed, "a composite has a title and needs no hint");
let regions: Vec<&super::PlacedCluster> = d
.clusters
.iter()
.filter(|c| c.id.starts_with("divider"))
.collect();
assert_eq!(regions.len(), 2, "one frame per concurrent region");
for r in regions {
assert!(r.dashed, "a region is dashed");
assert!(r.title.is_blank(), "a region has no title");
assert_eq!(r.parent.as_deref(), Some("Active"));
}
let svg = render(
"stateDiagram-v2\n [*] --> Active\n state Active {\n [*] --> A\n --\n \
[*] --> B\n }",
"dark",
)
.expect("renders");
assert_eq!(
svg.matches(&format!("stroke-dasharray=\"{}\"", svg::CLUSTER_DASH))
.count(),
2,
"both regions are drawn dashed:\n{svg}"
);
}
#[test]
fn a_note_is_drawn_on_the_side_it_was_written_on() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in CASES {
let model = state::parse(src).expect("parses");
let d = laid_out(src);
for note in model.states.iter().filter(|s| s.kind == Kind::Note) {
let position = note.note_position.expect("a note knows its side");
let link = model
.transitions
.iter()
.find(|t| t.is_note_link && (t.from == note.id || t.to == note.id))
.expect("a note has a tie-line");
let anchor_id = if link.from == note.id {
&link.to
} else {
&link.from
};
let anchor = d
.node(anchor_id)
.map(PlacedNode::bounds)
.or_else(|| d.cluster(anchor_id).map(|c| c.bounds()))
.expect("the state the note belongs to");
let placed = d.node(¬e.id).expect("the note is drawn");
let (nl, _, nr, _) = placed.bounds();
match position {
state::NotePosition::Right => assert!(
nl >= anchor.2 + NOTE_GAP - 0.01,
"{name}: `note right of {anchor_id}` starts at x={} but the state ends at {}",
svg::num(nl),
svg::num(anchor.2)
),
state::NotePosition::Left => assert!(
nr <= anchor.0 - NOTE_GAP + 0.01,
"{name}: `note left of {anchor_id}` ends at x={} but the state starts at {}",
svg::num(nr),
svg::num(anchor.0)
),
}
}
}
}
#[test]
fn a_notes_connector_is_dotted_headless_and_lands_on_both_boxes() {
if !text_metrics::fonts_available() {
return;
}
let src = "stateDiagram-v2\n A --> B\n note right of A : hi\n note left of B : there";
let d = laid_out(src);
let ties: Vec<&super::PlacedEdge> = d
.edges
.iter()
.filter(|e| e.from.contains("note") || e.to.contains("note"))
.collect();
assert_eq!(ties.len(), 2);
for e in ties {
assert_eq!(e.tip_end, super::Tip::None, "a note is not a transition");
assert_eq!(e.tip_start, super::Tip::None);
assert_eq!(e.stroke, Stroke::Dotted);
assert_eq!(e.points.len(), 2, "a straight connector");
for (end, p) in [("start", &e.points[0]), ("end", &e.points[1])] {
let id = if end == "start" { &e.from } else { &e.to };
let node = d.node(id).expect("both ends are boxes");
let off = dist_to_boundary(p, &boundary(node));
assert!(
off <= 0.05,
"the {end} of the connector is {}px off {}'s outline",
svg::num(off),
id
);
}
}
}
#[test]
fn notes_that_collide_are_stacked() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out(
"stateDiagram-v2\n A --> B\n note right of A : first\n note right of A : second",
);
let notes: Vec<&PlacedNode> = d.nodes.iter().filter(|n| n.shape == Glyph::Note).collect();
assert_eq!(notes.len(), 2);
let (a, b) = (notes[0].bounds(), notes[1].bounds());
let (dx, dy) = (a.2.min(b.2) - a.0.max(b.0), a.3.min(b.3) - a.1.max(b.1));
assert!(dx <= 0.01 || dy <= 0.01, "the two notes overlap");
assert!(
(a.0 - b.0).abs() < 0.01,
"stacking moves a note down, never sideways"
);
}
#[test]
#[ignore = "known limitation: a note is placed on the side that was written, and on a crowded diagram no row on that side has a clear line to it. Fixing it means moving the note off the side it was asked for, or routing the connector rather than drawing it straight."]
fn a_notes_connector_can_cross_a_state_in_a_crowded_diagram() {
if !text_metrics::fonts_available() {
return;
}
let src = CASES
.iter()
.find(|(n, _)| *n == "note-lr")
.expect("the case is in the corpus")
.1;
let d = laid_out(src);
let outlines: Vec<Vec<Point>> = d.nodes.iter().map(boundary).collect();
for e in &d.edges {
for w in e.drawn_points().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 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),
);
for (node, poly) in d.nodes.iter().zip(&outlines) {
let inside_by = super::tests::depth(&p, poly);
assert!(
inside_by <= 1.0,
"edge {} -> {} runs {}px inside {}",
e.from,
e.to,
svg::num(inside_by),
node.id
);
}
}
}
}
}
#[test]
fn note_over_is_dropped_rather_than_guessed_at() {
let model =
state::parse("stateDiagram-v2\n A --> B\n note over A : hi\n note over A, B : x")
.expect("parses");
assert_eq!(model.state_ids(), vec!["A", "B"]);
assert!(model.transitions.iter().all(|t| !t.is_note_link));
}
#[test]
fn another_diagram_kind_is_refused() {
assert!(matches!(
render("flowchart TD\n A --> B", "dark"),
Err(RenderError::StateParse(_))
));
assert!(matches!(
render("sequenceDiagram\n A->>B: hi", "dark"),
Err(RenderError::StateParse(_))
));
}
#[test]
fn a_state_diagram_with_no_states_is_refused() {
let e = render("stateDiagram-v2", "dark").unwrap_err();
assert!(matches!(e, RenderError::StateParse(_)));
assert_eq!(e.to_string(), "state diagram declares no states");
}
#[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("stateDiagram-v2\n");
for i in 0..120 {
wide.push_str(&format!(" n{i} --> n{}\n", i + 1));
}
wide.push_str(" n0 --> n120\n");
let mut deep = String::from("stateDiagram-v2\n");
for i in 0..12 {
deep.push_str(&format!(" state s{i} {{\n"));
}
deep.push_str(" [*] --> A\n A --> [*]\n");
for _ in 0..12 {
deep.push_str(" }\n");
}
deep.push_str(" s0 --> C\n");
let cases: &[&str] = &[
"",
" \n\n ",
"stateDiagram-v2",
"stateDiagram-v2\n [*]",
"stateDiagram-v2\n [*] --> [*]",
"stateDiagram-v2\n A --> A",
"stateDiagram-v2\n s : \"\"\n [*] --> s",
"stateDiagram-v2\n s : <br><br><br>\n [*] --> s",
"stateDiagram-v2\n A[\"<script>&\"] --> B",
"stateDiagram-v2\n 一 --> 二 : 🎉",
"stateDiagram-v2\n state S {}\n A --> B",
"stateDiagram-v2\n state S {}\n S --> A",
"stateDiagram-v2\n state S { A --> B }\n S --> S",
"stateDiagram-v2\n state S {\n A --> B\n }\n S --> A",
"stateDiagram-v2\n state S {\n --\n }\n A --> B",
"stateDiagram-v2\n note right of Nobody : orphan\n A --> B",
"stateDiagram-v2\n state S { a --> b }\n note right of S : on a frame",
"stateDiagram-v2\n A --> B\n note right of A : x\n note right of A : y\n \
note right of A : z",
"stateDiagram-v2\n direction LR\n A --> B\n note left of A : far left",
"stateDiagram-v2\n state c <<choice>>\n c --> c",
"stateDiagram-v2\n state f <<fork>>\n f --> f",
&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 it 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",
svg::num(d.width),
svg::num(d.height)
);
assert!(
crate::preview::svg::rasterize_bytes(
svg.as_bytes(),
std::path::Path::new("m.svg"),
300
)
.is_some(),
"{src:?}: the output did not rasterise"
);
}
}
}
}
#[test]
fn state_corpus_golden() {
if !text_metrics::fonts_available() {
return;
}
let mut out = String::new();
for (name, src) in CASES {
out.push_str(&format!("=== {name} ===\n"));
out.push_str(&mask_numbers(&render(src, "dark").expect("renders")));
out.push('\n');
}
assert_snapshot("mermaid_state", &out);
}
#[test]
fn state_emit_golden() {
let d = synthetic_state_diagram();
let mut out = String::new();
for t in super::theme::ALL {
out.push_str(&format!("=== {} ===\n", t.name));
out.push_str(&svg::emit(&d, t));
out.push('\n');
}
assert_snapshot("mermaid_state_emit", &out);
}
fn synthetic_state_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 * labels::line_height(),
line_pitch: labels::line_height(),
font_size: crate::preview::mermaid::text_metrics::FONT_SIZE as f64,
};
let glyphs = [
Glyph::StateStart,
Glyph::StateEnd,
Glyph::Choice,
Glyph::Bar { horizontal: true },
Glyph::Bar { horizontal: false },
Glyph::Note,
Glyph::TitledBox,
Glyph::Flow(Shape::RoundedRect),
];
let mut nodes = Vec::new();
for (i, glyph) in glyphs.into_iter().enumerate() {
let text = match glyph {
Glyph::TitledBox => "title\nbody",
Glyph::Note => "a note",
_ => "state",
};
let l = label(text, 50.0);
let size = shapes::size(glyph, Size::new(l.width, l.height));
nodes.push(PlacedNode {
id: format!("n{i}"),
shape: glyph,
center: Point::new(90.0 + (i % 4) as f64 * 180.0, 90.0 + (i / 4) as f64 * 180.0),
size,
label: l,
panel: None,
series: None,
mark: None,
style: None,
});
}
let edges = vec![
super::PlacedEdge {
from: "n0".to_string(),
to: "n7".to_string(),
points: vec![Point::new(60.0, 420.0), Point::new(260.0, 420.0)],
gaps: Vec::new(),
tip_start: super::Tip::None,
tip_end: super::Tip::Arrow,
stroke: Stroke::Normal,
label: Some(super::PlacedEdgeLabel {
center: Point::new(160.0, 420.0),
size: Size::new(50.0 + super::LABEL_PAD_X * 2.0, 20.0),
label: label("on", 50.0),
}),
start_label: None,
end_label: None,
badge: None,
series: None,
straight: false,
overlay: false,
style: None,
curve: super::Curve::Basis,
tip_matches_line: false,
},
super::PlacedEdge {
from: "n7".to_string(),
to: "n5".to_string(),
points: vec![Point::new(60.0, 460.0), Point::new(260.0, 460.0)],
gaps: Vec::new(),
tip_start: super::Tip::None,
tip_end: super::Tip::None,
stroke: Stroke::Dotted,
label: None,
start_label: None,
end_label: None,
badge: None,
series: None,
straight: false,
overlay: false,
style: None,
curve: super::Curve::Basis,
tip_matches_line: false,
},
];
let clusters = vec![
super::PlacedCluster {
id: "block".to_string(),
title: label("A composite", 110.0),
center: Point::new(300.0, 560.0),
size: Size::new(400.0, 140.0),
parent: None,
depth: 0,
dashed: false,
filled: true,
sections: Vec::new(),
title_strip: false,
},
super::PlacedCluster {
id: "region".to_string(),
title: label("", 0.0),
center: Point::new(300.0, 580.0),
size: Size::new(300.0, 80.0),
parent: Some("block".to_string()),
depth: 1,
dashed: true,
filled: true,
sections: Vec::new(),
title_strip: false,
},
];
Diagram {
width: 800.0,
height: 660.0,
nodes,
edges,
clusters,
lifelines: Vec::new(),
}
}
fn orthogonal_design_reference_corpus() -> Vec<(&'static str, &'static str)> {
vec![
(
"zz-design-4a",
"stateDiagram-v2\n [*] --> ツリー\n ツリー --> プレビュー : Enter\n \
プレビュー --> ツリー : q\n state プレビュー {\n [*] --> デコード中\n \
デコード中 --> 表示 : 画像が届く\n }\n ツリー --> [*] : Q",
),
(
"zz-design-4b",
"stateDiagram-v2\n direction LR\n 待機 --> 監視 : 開始\n 監視 --> 監視 : ポーリング\n \
state 分岐 <<choice>>\n 監視 --> 分岐 : 変化\n 分岐 --> 更新 : 差分あり\n \
分岐 --> 休止 : 差分なし\n 更新 --> 通知 : 適用\n 通知 --> 待機 : 完了",
),
(
"zz-design-4c",
"stateDiagram-v2\n state fork_state <<fork>>\n state join_state <<join>>\n \
[*] --> 初期化\n 初期化 --> fork_state\n fork_state --> 取得\n \
fork_state --> 監査\n 取得 --> 処理\n state 処理 {\n [*] --> 整形\n \
整形 --> 解析\n state 解析 {\n 走査 --> 集計\n }\n }\n \
処理 --> join_state\n 監査 --> join_state : 監査済\n join_state --> 完了\n \
完了 --> [*]",
),
]
}
fn orthogonal_only_corpus() -> Vec<(&'static str, &'static str)> {
vec![(
"orthogonal-fork-inside-a-composite",
"stateDiagram-v2\n state C {\n state f <<fork>>\n [*] --> f\n f --> a\n \
a --> [*]\n }\n f --> X\n C --> Y",
)]
}
fn orthogonal_full_corpus() -> Vec<(&'static str, &'static str)> {
CASES
.iter()
.copied()
.chain(orthogonal_design_reference_corpus())
.chain(orthogonal_only_corpus())
.chain(
orthogonal_reversed_direction_corpus()
.iter()
.map(|(name, src)| (name.as_str(), src.as_str())),
)
.collect()
}
#[test]
#[ignore = "writes PNG/SVG files for a person to look at: cargo test -- --ignored orthogonal_design_reference_dump"]
fn orthogonal_design_reference_dump() {
let dir = std::path::Path::new("docs/render-check");
std::fs::create_dir_all(dir).expect("create docs/render-check");
for (name, src) in orthogonal_design_reference_corpus() {
let svg = render_flow(src, "dark", "konoma-orthogonal")
.unwrap_or_else(|e| panic!("{name}: must render under konoma-orthogonal: {e}"));
let svg_path = dir.join(format!("{name}-ours.svg"));
std::fs::write(&svg_path, &svg).unwrap_or_else(|e| panic!("{name}: write svg: {e}"));
let img = crate::preview::svg::rasterize_bytes(svg.as_bytes(), &svg_path, 1600)
.unwrap_or_else(|| panic!("{name}: must rasterise"));
img.save(dir.join(format!("{name}-ours.png")))
.unwrap_or_else(|e| panic!("{name}: write png: {e}"));
}
}
fn laid_out_orthogonal(src: &str) -> Diagram {
let model = state::parse(src).unwrap_or_else(|e| panic!("corpus source must parse: {e}"));
lay_out(&model, Routing::Orthogonal)
.unwrap_or_else(|e| panic!("corpus source must lay out under orthogonal: {e}"))
}
#[test]
fn orthogonal_cluster_anchored_edge_ranks_the_outside_end_past_the_whole_block() {
if !text_metrics::fonts_available() {
return;
}
let block = "state P {\n [*] --> p1\n p1 --> p2\n p1 --> p3\n p3 --> p4\n }";
let cases: [(&str, &str, &str, bool); 3] = [
(
"exit",
&format!("stateDiagram-v2\n {block}\n P --> Z"),
"Z",
true,
),
(
"exit-into-a-join-bar",
&format!("stateDiagram-v2\n {block}\n state j <<join>>\n P --> j\n j --> Z"),
"j",
true,
),
(
"entry",
"stateDiagram-v2\n Q --> P\n state P {\n w --> z\n x --> y\n y --> z\n }",
"Q",
false,
),
];
for (name, src, outside, downstream) in cases {
let d = laid_out_orthogonal(src);
let frame = d.cluster("P").expect("P's frame");
let (_, ft, _, fb) = frame.bounds();
let n = d
.node(outside)
.unwrap_or_else(|| panic!("{name}: {outside}"));
let (_, nt, _, nb) = n.bounds();
if downstream {
assert!(
nt > fb,
"{name}: {outside} must sit past the whole block (frame ends at {fb:.2}), \
not at {nt:.2}..{nb:.2}"
);
} else {
assert!(
nb < ft,
"{name}: {outside} must sit before the whole block (frame starts at {ft:.2}), \
not at {nt:.2}..{nb:.2}"
);
}
for m in ["p1", "p2", "p3", "p4", "w", "x", "y", "z"] {
let Some(member) = d.node(m) else { continue };
assert!(
(member.center.y - n.center.y).abs() > 1.0,
"{name}: {outside} shares {m}'s own row at y={:.2}",
member.center.y
);
}
}
let d = super::tests::laid_out_flow(
"flowchart TB\n subgraph P\n p1 --> p2\n p1 --> p3\n p3 --> p4\n end\n P --> Z",
"basis",
"konoma-orthogonal",
);
let frame = d.cluster("P").expect("P's frame");
let (_, _, _, fb) = frame.bounds();
let z = d.node("Z").expect("Z");
assert!(
z.bounds().1 > fb,
"flowchart: Z must sit past the whole subgraph (frame ends at {fb:.2}), not at {:.2}",
z.bounds().1
);
let d = laid_out_orthogonal(
"stateDiagram-v2\n Q --> P\n state P {\n w --> z\n x --> y\n y --> z\n }",
);
let frame = d.cluster("P").expect("P's frame");
let (_, ft, _, _) = frame.bounds();
let e = d
.edges
.iter()
.find(|e| e.from == "Q" && e.to == "P")
.expect("Q -> P");
let end = e.points.last().expect("non-empty");
assert!(
(end.y - (ft - orthogonal::PORT_INSET)).abs() < 0.01,
"Q -> P must enter P's own top face at {:.2}, not {end:?}",
ft - orthogonal::PORT_INSET
);
}
#[test]
fn orthogonal_state_edges_leave_no_dangling_fragment() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
super::tests::assert_no_dangling_fragment(name, &d);
}
}
#[test]
fn orthogonal_join_output_is_straight_when_one_input_is_a_block() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(
"stateDiagram-v2\n state P {\n [*] --> p1\n p1 --> p2\n p1 --> p3\n p3 --> p4\n }\n \
state j <<join>>\n P --> j\n R --> j\n j --> Z",
);
let e = d
.edges
.iter()
.find(|e| e.from == "j" && e.to == "Z")
.expect("j -> Z");
assert_eq!(
e.points.len(),
2,
"a join's own output must be 0-bend: {:?}",
e.points
);
let z = d.node("Z").expect("Z");
assert!(
(e.points[0].x - z.center.x).abs() < 0.01,
"the bar's output port at {:.2} must sit on Z's own centre {:.2}",
e.points[0].x,
z.center.x
);
let frame = d.cluster("P").expect("P's frame");
let r = d.node("R").expect("R");
let want = (frame.center.x + r.center.x) / 2.0;
assert!(
(z.center.x - want).abs() < 1.0,
"Z must sit on the mean of P's frame ({:.2}) and R ({:.2}) = {want:.2}, not {:.2}",
frame.center.x,
r.center.x,
z.center.x
);
}
#[test]
fn orthogonal_state_edges_draw_only_axis_parallel_segments() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
for e in &d.edges {
if e.from.contains("note") || e.to.contains("note") {
continue;
}
for w in e.points.windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < 1e-6 || dy < 1e-6,
"{name}: edge {}->{} draws a diagonal segment {:?} -> {:?}",
e.from,
e.to,
w[0],
w[1]
);
}
}
}
}
#[test]
fn orthogonal_state_endpoints_sit_outside_and_perpendicular() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
for e in &d.edges {
if e.from.contains("note") || e.to.contains("note") {
continue;
}
if e.points.len() < 2 {
continue;
}
let n = e.points.len();
let ends = [
(&e.from, &e.points[0], &e.points[1]),
(&e.to, &e.points[n - 1], &e.points[n - 2]),
];
for (node_id, endpoint, neighbour) in ends {
let bounds = d
.node(node_id)
.map(|nd| nd.bounds())
.or_else(|| d.cluster(node_id).map(|c| c.bounds()));
let Some((l, t, r, b)) = bounds else {
continue;
};
let inset = orthogonal::PORT_INSET;
let on_top = (endpoint.y - (t - inset)).abs() < 1e-6;
let on_bottom = (endpoint.y - (b + inset)).abs() < 1e-6;
let on_left = (endpoint.x - (l - inset)).abs() < 1e-6;
let on_right = (endpoint.x - (r + inset)).abs() < 1e-6;
assert!(
on_top || on_bottom || on_left || on_right,
"{name}: edge {}->{} endpoint at {node_id} {endpoint:?} is not {inset}px \
outside its bounds {:?}",
e.from,
e.to,
(l, t, r, b)
);
let dx = (endpoint.x - neighbour.x).abs();
let dy = (endpoint.y - neighbour.y).abs();
assert!(
dx < 1e-6 || dy < 1e-6,
"{name}: edge {}->{} segment into {node_id} is not axis-parallel",
e.from,
e.to
);
}
}
}
}
#[test]
fn orthogonal_state_no_edge_crosses_a_foreign_node() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
for e in &d.edges {
for w in e.points.windows(2) {
for n in &d.nodes {
if n.id == e.from || n.id == e.to {
continue;
}
assert!(
!orthogonal::segment_crosses_node(&w[0], &w[1], n),
"{name}: edge {}->{} segment {:?}->{:?} crosses {} {:?}",
e.from,
e.to,
w[0],
w[1],
n.id,
n.bounds()
);
}
}
}
}
}
#[test]
fn orthogonal_state_no_edge_crosses_its_own_endpoint() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
for e in &d.edges {
if e.from.contains("note") || e.to.contains("note") {
continue;
}
let (Some(source), Some(target)) = (d.node(&e.from), d.node(&e.to)) else {
continue; };
assert!(
!orthogonal::staircase_punctures_its_own_endpoint(
&e.points,
Some(source),
Some(target)
),
"{name}: edge {}->{} re-enters its own endpoint node's interior: {:?}",
e.from,
e.to,
e.points
);
}
}
}
#[test]
fn orthogonal_state_nodes_and_clusters_stay_correct_after_growth() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
let tree = tree_of_src(src);
check_nodes_do_not_overlap(name, &d);
check_view_box_contains_everything(name, &d);
check_clusters_hold_their_members(name, &d, &tree);
check_foreign_nodes_stay_out_of_clusters(name, &d, &tree);
check_nested_clusters_sit_inside_their_parent(name, &d);
check_unrelated_clusters_do_not_overlap(name, &d);
}
}
#[test]
fn orthogonal_state_markers_are_pole_ports_and_never_grow() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
for n in &d.nodes {
if !matches!(n.shape, Glyph::StateStart | Glyph::StateEnd) {
continue;
}
let fixed = shapes::size(n.shape, Size::new(0.0, 0.0));
assert_eq!(
n.size, fixed,
"{name}: marker {} grew from its fixed size under orthogonal routing",
n.id
);
}
for e in &d.edges {
for (node_id, endpoint) in [(&e.from, e.points.first()), (&e.to, e.points.last())] {
let Some(endpoint) = endpoint else { continue };
let Some(node) = d.node(node_id) else {
continue;
};
if !matches!(node.shape, Glyph::StateStart | Glyph::StateEnd) {
continue;
}
let on_pole = (endpoint.x - node.center.x).abs() < 1e-6
|| (endpoint.y - node.center.y).abs() < 1e-6;
assert!(
on_pole,
"{name}: marker {node_id}'s port at {endpoint:?} is not on its pole \
(centre {:?})",
node.center
);
}
}
}
}
#[test]
fn orthogonal_state_shared_end_marker_is_duplicated_per_transition() {
if !text_metrics::fonts_available() {
return;
}
let src = CASES
.iter()
.find(|(n, _)| *n == "basic")
.expect("`basic` is in the corpus")
.1;
let splines = laid_out(src);
let ortho = laid_out_orthogonal(src);
let end_count = |d: &Diagram| {
d.nodes
.iter()
.filter(|n| n.shape == Glyph::StateEnd)
.count()
};
assert_eq!(
end_count(&splines),
1,
"splines must keep mermaid's own single shared end dot"
);
assert_eq!(
end_count(&ortho),
2,
"orthogonal must draw one end marker per transition (`basic` has 2 into `[*]`)"
);
let mut in_degree: HashMap<String, usize> = HashMap::new();
for e in &ortho.edges {
*in_degree.entry(e.to.clone()).or_insert(0) += 1;
}
for n in ortho.nodes.iter().filter(|n| n.shape == Glyph::StateEnd) {
assert_eq!(
in_degree.get(&n.id).copied().unwrap_or(0),
1,
"{}: a duplicated end marker must carry exactly one edge",
n.id
);
}
}
#[test]
fn orthogonal_state_choice_is_a_28px_chamfered_square() {
if !text_metrics::fonts_available() {
return;
}
let src = "stateDiagram-v2\n state c <<choice>>\n A --> c\n c --> B\n c --> C";
let splines = laid_out(src);
let ortho = laid_out_orthogonal(src);
assert_eq!(
splines.node("c").expect("the choice").shape,
Glyph::Choice,
"splines must keep drawing a choice as the plain diamond glyph"
);
let c = ortho.node("c").expect("the choice");
assert_eq!(
c.shape,
Glyph::ChamferedRect,
"orthogonal must draw a choice as a chamfered rectangle, not a diamond"
);
assert_eq!(
c.size,
Size::new(28.0, 28.0),
"orthogonal's choice must be the fixed 28x28 S4 size"
);
}
#[test]
fn orthogonal_state_q_leaves_the_composite_state_locally_not_via_the_perimeter() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_design_reference_corpus()
.into_iter()
.find(|(n, _)| *n == "zz-design-4a")
.expect("zz-design-4a is in the design-reference corpus")
.1;
let d = laid_out_orthogonal(src);
let q = d
.edges
.iter()
.find(|e| e.from == "プレビュー" && e.to == "ツリー")
.expect("プレビュー->ツリー (q) must exist");
assert!(
q.points.len() <= 4,
"q must take a local route (at most 2 bends), not the perimeter lane: {:?}",
q.points
);
let cluster = d
.cluster("プレビュー")
.expect("プレビュー cluster must exist");
let (_, _, cluster_right, _) = cluster.bounds();
let max_x = q.points.iter().map(|p| p.x).fold(f64::MIN, f64::max);
assert!(
max_x <= cluster_right + 1.0,
"q must not swing out past プレビュー's own right edge ({cluster_right}) the way the old \
perimeter detour did: max_x={max_x} points={:?}",
q.points
);
}
#[test]
fn orthogonal_state_self_transition_draws_the_fixed_loop_from_the_canonical_face() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_design_reference_corpus()
.into_iter()
.find(|(n, _)| *n == "zz-design-4b")
.expect("zz-design-4b is in the design-reference corpus")
.1;
let d = laid_out_orthogonal(src);
let monitor = d.node("監視").expect("監視 must exist");
let loop_edge = d
.edges
.iter()
.find(|e| e.from == "監視" && e.to == "監視")
.expect("監視->監視 (ポーリング) must exist");
assert_eq!(
loop_edge.points.len(),
4,
"the fixed loop is exactly [out port, out corner, in corner, in port]: {:?}",
loop_edge.points
);
let p = &loop_edge.points;
let top = monitor.center.y - monitor.size.h / 2.0;
for (i, pt) in p.iter().enumerate() {
assert!(
pt.y <= top + 0.5,
"every point of the loop must sit at or above 監視's own top edge ({top}): index {i} \
= {pt:?} in {p:?}"
);
}
assert!(
(top - p[1].y) > (top - p[0].y) + 5.0,
"the outward corner must sit meaningfully further from the face than the port does: \
port_y={} corner_y={}",
p[0].y,
p[1].y
);
let dx0 = (p[0].x - monitor.center.x).abs();
let dx3 = (p[3].x - monitor.center.x).abs();
assert!(
(dx0 - 8.0).abs() < 0.5 && (dx3 - 8.0).abs() < 0.5,
"both ports must sit exactly 8px either side of 監視's own centre x ({}): {p:?}",
monitor.center.x
);
let label = loop_edge
.label
.as_ref()
.expect("the self-transition must carry its own label");
assert!(
label.center.y < p[1].y.min(p[2].y) - 0.5,
"the label must float above (outside) the loop's own outward leg, not sit on it: \
label_y={} outward_y={:?}",
label.center.y,
(p[1].y, p[2].y)
);
}
#[test]
fn orthogonal_self_transition_retreats_when_its_canonical_face_is_taken() {
let placed = |id: &str, cx: f64, cy: f64, w: f64, h: f64| PlacedNode {
id: id.to_string(),
shape: Glyph::default(),
center: Point::new(cx, cy),
size: Size::new(w, h),
label: Label::measure(""),
panel: None,
series: None,
mark: None,
style: None,
};
let x = placed("X", 0.0, 0.0, 100.0, 60.0);
let above = placed("Above", 0.0, -300.0, 60.0, 30.0);
let nodes = vec![x.clone(), above.clone()];
let occupier = super::orthogonal::EligibleEdge {
id: "occupy",
source: "X",
target: "Above",
raw: &[],
source_rank: Some(0),
target_rank: Some(0),
source_out_degree: 2,
target_in_degree: 1,
aside: false,
};
let self_loop = super::orthogonal::EligibleEdge {
id: "loop",
source: "X",
target: "X",
raw: &[],
source_rank: Some(0),
target_rank: Some(0),
source_out_degree: 2,
target_in_degree: 1,
aside: false,
};
let edges = [occupier, self_loop];
let chain_next = HashMap::new();
let fixed = super::orthogonal::route_flowchart(
crate::preview::mermaid::flowchart::Direction::LeftToRight,
&nodes,
&[],
&edges,
&chain_next,
true,
);
let loop_points = &fixed.points["loop"];
let top = x.center.y - x.size.h / 2.0;
let bottom = x.center.y + x.size.h / 2.0;
assert!(
loop_points.iter().all(|p| p.y >= bottom - 0.5),
"with the canonical top face already occupied, the loop must retreat to the bottom face \
(every point at/after y={bottom}): {loop_points:?}"
);
let unfixed = super::orthogonal::route_flowchart(
crate::preview::mermaid::flowchart::Direction::LeftToRight,
&nodes,
&[],
&edges,
&chain_next,
false,
);
assert_ne!(
unfixed.points["loop"].len(),
4,
"a flowchart-style self-loop with empty `raw` degenerates to the two-point defensive \
fallback, never the S3 4-point fixed loop: {:?}",
unfixed.points["loop"]
);
let _ = top;
}
#[test]
fn orthogonal_state_bar_ports_match_the_connected_trunk_exactly_not_a_distribution() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_design_reference_corpus()
.into_iter()
.find(|(n, _)| *n == "zz-design-4c")
.expect("zz-design-4c is in the design-reference corpus")
.1;
let d = laid_out_orthogonal(src);
for target_id in ["取得", "監査"] {
let target = d.node(target_id).unwrap_or_else(|| panic!("{target_id}"));
let edge = d
.edges
.iter()
.find(|e| e.from == "fork_state" && e.to == target_id)
.unwrap_or_else(|| panic!("fork_state->{target_id} must exist"));
let last = edge.points.last().expect("at least one point");
assert!(
(last.x - target.center.x).abs() < 0.5,
"fork_state's own port toward {target_id} must sit at its exact centre x \
({}), not distributed: {:?}",
target.center.x,
edge.points
);
}
}
#[test]
fn orthogonal_state_bar_grows_to_span_every_connected_trunk_plus_end_padding() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_design_reference_corpus()
.into_iter()
.find(|(n, _)| *n == "zz-design-4c")
.expect("zz-design-4c is in the design-reference corpus")
.1;
let d = laid_out_orthogonal(src);
let get = d.node("取得").expect("取得");
let audit = d.node("監査").expect("監査");
let bar = d.node("fork_state").expect("fork_state");
let span = (get.center.x - audit.center.x).abs();
let expected = span + 2.0 * orthogonal::BAR_PORT_PAD;
assert!(
(bar.size.w - expected).abs() < 0.5,
"fork_state's own width ({}) must be exactly the 取得/監査 span ({span}) plus both \
BAR_PORT_PAD ends ({expected}) now that straddle_bar_ports sets the rectangle directly \
from the final ports, not merely grown toward it by the layout retry loop",
bar.size.w
);
assert!(
bar.size.w > 2.0 * orthogonal::BAR_PORT_PAD + 0.5,
"fork_state must have grown well past its own {}-px minimum — a mutation that dropped \
the growth-retry wiring entirely would leave it at exactly that floor: {}",
2.0 * orthogonal::BAR_PORT_PAD,
bar.size.w
);
assert!(
(bar.size.h - 6.0).abs() < 0.5,
"fork_state's own thickness must be the fixed S4 6px, not splines' 10px: {}",
bar.size.h
);
}
#[test]
fn orthogonal_state_bar_rect_straddles_every_port_it_carries() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
for bar in d
.nodes
.iter()
.filter(|n| matches!(n.shape, Glyph::Bar { .. }))
{
let horizontal = matches!(bar.shape, Glyph::Bar { horizontal: true });
let (lo, hi) = if horizontal {
(
bar.center.x - bar.size.w / 2.0,
bar.center.x + bar.size.w / 2.0,
)
} else {
(
bar.center.y - bar.size.h / 2.0,
bar.center.y + bar.size.h / 2.0,
)
};
for e in &d.edges {
let p = if e.from == bar.id {
e.points.first()
} else if e.to == bar.id {
e.points.last()
} else {
continue;
};
let Some(p) = p else { continue };
let coord = if horizontal { p.x } else { p.y };
assert!(
coord >= lo - 0.5 && coord <= hi + 0.5,
"{name}: {}->{}'s port on bar {} sits at {coord:.2}, outside the bar's own \
rectangle [{lo:.2}, {hi:.2}]",
e.from,
e.to,
bar.id
);
assert!(
coord - lo >= orthogonal::BAR_PORT_PAD - 0.5
&& hi - coord >= orthogonal::BAR_PORT_PAD - 0.5,
"{name}: {}->{}'s port on bar {} sits {:.2}px from the near end of \
[{lo:.2}, {hi:.2}] — every port must clear BAR_PORT_PAD ({}) from *both* \
ends, the direct consequence of the bar's own rectangle being built from \
[min_port - PAD, max_port + PAD]",
e.from,
e.to,
bar.id,
(coord - lo).min(hi - coord),
orthogonal::BAR_PORT_PAD
);
}
}
}
}
#[test]
fn orthogonal_state_bar_touching_edges_are_zero_bend_and_match_the_trunks_own_port() {
if !text_metrics::fonts_available() {
return;
}
fn check(name: &str, d: &Diagram) {
let horizontal_by_id: HashMap<&str, bool> = d
.nodes
.iter()
.filter_map(|n| match n.shape {
Glyph::Bar { horizontal } => Some((n.id.as_str(), horizontal)),
_ => None,
})
.collect();
let mut downstream_count: HashMap<&str, usize> = HashMap::new();
let mut upstream_count: HashMap<&str, usize> = HashMap::new();
for e in &d.edges {
if horizontal_by_id.contains_key(e.from.as_str()) {
*downstream_count.entry(e.from.as_str()).or_insert(0) += 1;
}
if horizontal_by_id.contains_key(e.to.as_str()) {
*upstream_count.entry(e.to.as_str()).or_insert(0) += 1;
}
}
for e in &d.edges {
let horizontal = horizontal_by_id
.get(e.from.as_str())
.or_else(|| horizontal_by_id.get(e.to.as_str()));
let Some(&horizontal) = horizontal else {
continue;
};
if horizontal_by_id.contains_key(e.from.as_str())
&& downstream_count.get(e.from.as_str()).copied().unwrap_or(0) == 1
&& upstream_count.get(e.from.as_str()).copied().unwrap_or(0) > 1
{
continue; }
let (bar_id, other_id) = if horizontal_by_id.contains_key(e.from.as_str()) {
(e.from.as_str(), e.to.as_str())
} else {
(e.to.as_str(), e.from.as_str())
};
if let (Some(bar), Some(other)) = (d.node(bar_id), d.node(other_id)) {
let (bl, bt, br, bb) = bar.bounds();
let (lo, hi, c) = if horizontal {
(bl, br, other.center.x)
} else {
(bt, bb, other.center.y)
};
if c < lo - 0.5 || c > hi + 0.5 {
continue;
}
}
let coords: Vec<f64> = e
.points
.iter()
.map(|p| if horizontal { p.x } else { p.y })
.collect();
let first = coords[0];
for (i, &c) in coords.iter().enumerate() {
assert!(
(c - first).abs() < 0.5,
"{name}: {}->{} (bar-anchored) must be zero-bend — point {i} reads {c:.2}, \
not {first:.2}: {:?}",
e.from,
e.to,
e.points
);
}
}
}
for (name, src) in orthogonal_full_corpus() {
check(name, &laid_out_orthogonal(src));
}
let synthetic = "stateDiagram-v2\n state f <<fork>>\n state j <<join>>\n [*] --> f\n \
f --> A\n f --> B\n X --> B\n A --> j\n B --> j\n j --> [*]";
let d = laid_out_orthogonal(synthetic);
check("bar-multi-claim-synthetic", &d);
let b = d.node("B").expect("B");
let f_to_b = d
.edges
.iter()
.find(|e| e.from == "f" && e.to == "B")
.expect("f->B must exist");
let arrival_x = f_to_b.points.last().expect("at least one point").x;
assert!(
(arrival_x - b.center.x).abs() > 4.0,
"bar-multi-claim-synthetic: f->B lands at x={arrival_x:.2}, indistinguishable from B's \
own plain centre x={:.2} — this fixture only proves the fix when the evicted claim \
differs from the raw centre; a mutation that fell back to `edge.raw`'s stale waypoint \
(which happened to be near-centre here too) would pass the zero-bend check above but \
land on the wrong number, which this assertion is the one to catch",
b.center.x
);
}
#[test]
fn orthogonal_state_composite_exit_anchor_lands_beyond_its_own_frame_on_4c() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_design_reference_corpus()
.into_iter()
.find(|(n, _)| *n == "zz-design-4c")
.expect("zz-design-4c is in the design-reference corpus")
.1;
let d = laid_out_orthogonal(src);
let frame = d.cluster("処理").expect("処理's own frame");
let (_, _, _, frame_bottom) = frame.bounds();
let edge = d
.edges
.iter()
.find(|e| e.from == "処理" && e.to == "join_state")
.expect("処理->join_state must exist");
let start_y = edge.points.first().expect("at least one point").y;
assert!(
start_y >= frame_bottom - 0.5,
"処理 -> join_state must start at or below 処理's own frame bottom ({frame_bottom:.2}), \
not partway inside it: starts at y={start_y:.2} — a regression to the pre-fix \
declared-first anchor (整形) would land this well above the frame bottom, inside the \
box",
);
}
#[test]
fn orthogonal_state_composite_exit_anchor_resolves_through_a_nested_block_lr() {
if !text_metrics::fonts_available() {
return;
}
let src = "stateDiagram-v2\n direction LR\n state C {\n [*] --> M1\n M1 --> N\n \
state N {\n P --> Q\n }\n }\n C --> D";
let d = laid_out_orthogonal(src);
let frame = d.cluster("C").expect("C's own frame");
let (_, _, frame_right, _) = frame.bounds();
let edge = d
.edges
.iter()
.find(|e| e.from == "C" && e.to == "D")
.expect("C->D must exist");
let start_x = edge.points.first().expect("at least one point").x;
assert!(
start_x >= frame_right - 0.5,
"C -> D must start at or beyond C's own frame right edge ({frame_right:.2}) in this LR \
diagram, not partway inside it: starts at x={start_x:.2} — anchoring at C's own first \
member (M1) or at N's own first member (P) instead of N's own sink (Q) would both land \
this inside the frame",
);
}
#[test]
fn orthogonal_state_composite_with_an_internal_cycle_falls_back_to_a_valid_route() {
if !text_metrics::fonts_available() {
return;
}
let src = "stateDiagram-v2\n [*] --> X\n X --> C\n state C {\n M1 --> M2\n \
M2 --> M1\n }\n C --> D\n D --> [*]";
let d = laid_out_orthogonal(src);
check_nodes_do_not_overlap("cycle-fallback", &d);
check_unrelated_clusters_do_not_overlap("cycle-fallback", &d);
check_edges_stay_out_of_shapes("cycle-fallback", &d);
let edge = d
.edges
.iter()
.find(|e| e.from == "C" && e.to == "D")
.expect("C->D must still exist after the cycle fallback");
assert!(
edge.points.len() >= 2,
"cycle-fallback: C->D must still be a real polyline: {:?}",
edge.points
);
}
#[test]
#[ignore = "writes SVG files for a person to look at: KONOMA_GALLERY=<dir> cargo test -- --ignored gallery"]
fn gallery() {
let dir = std::env::var("KONOMA_GALLERY").expect("set KONOMA_GALLERY to a directory");
std::fs::create_dir_all(&dir).expect("create the gallery directory");
for (name, src) in CASES {
let svg = render(src, "dark").unwrap_or_else(|e| panic!("{name}: {e}"));
std::fs::write(format!("{dir}/{name}.svg"), svg).expect("write the SVG");
}
}
#[test]
fn orthogonal_state_cross_cluster_internal_edge_cycle_does_not_overflow_the_stack() {
if !text_metrics::fonts_available() {
return;
}
let src = "stateDiagram-v2\n [*] --> X\n state X {\n XM --> Y\n }\n \
state Y {\n YM --> X\n }\n X --> Z\n Z --> [*]";
let d = laid_out_orthogonal(src);
check_nodes_do_not_overlap("cross-cluster-cycle", &d);
check_unrelated_clusters_do_not_overlap("cross-cluster-cycle", &d);
}
fn design_reference_source(name: &str) -> &'static str {
orthogonal_design_reference_corpus()
.into_iter()
.find(|(n, _)| *n == name)
.unwrap_or_else(|| panic!("{name} is in the design-reference corpus"))
.1
}
fn node_of<'a>(d: &'a Diagram, id: &str) -> &'a PlacedNode {
d.node(id)
.unwrap_or_else(|| panic!("the fixture must place a node called {id}"))
}
fn cluster_of<'a>(d: &'a Diagram, id: &str) -> &'a PlacedCluster {
d.cluster(id)
.unwrap_or_else(|| panic!("the fixture must place a frame called {id}"))
}
fn edge_of<'a>(d: &'a Diagram, from: &str, to: &str) -> &'a PlacedEdge {
d.edges
.iter()
.find(|e| e.from == from && e.to == to)
.unwrap_or_else(|| panic!("the fixture must draw {from} -> {to}"))
}
fn assert_vertical(name: &str, e: &PlacedEdge, at: f64) {
for p in e.drawn_points() {
assert!(
(p.x - at).abs() <= 0.51,
"{name}: {} -> {} bends — point ({:.2},{:.2}) is off the x={at:.2} lane",
e.from,
e.to,
p.x,
p.y
);
}
}
#[test]
fn orthogonal_a_composite_is_re_ranked_as_one_unit_beside_a_slack_node() {
if !text_metrics::fonts_available() {
return;
}
let src = "stateDiagram-v2\n [*] --> A\n A --> B\n A --> C\n state C {\n \
[*] --> C1\n C1 --> C2\n C2 --> C3\n }\n B --> D\n C --> D";
let d = laid_out_orthogonal(src);
let (b, entry) = (node_of(&d, "B"), node_of(&d, "C_start"));
assert!(
(b.center.y - entry.center.y).abs() <= 0.51,
"B (y={:.2}) must share the composite's own entry row (y={:.2}), not sink to a later rank",
b.center.y,
entry.center.y
);
let interior: Vec<f64> = ["C_start", "C1", "C2", "C3"]
.iter()
.map(|id| node_of(&d, id).center.y)
.collect();
assert!(
interior.windows(2).all(|w| w[1] > w[0]),
"the composite's own members must keep their order down the flow: {interior:?}"
);
}
#[test]
fn orthogonal_a_lane_runs_straight_through_a_composite_frame() {
if !text_metrics::fonts_available() {
return;
}
let src = "stateDiagram-v2\n [*] --> A\n A --> C\n state C {\n [*] --> C1\n \
C1 --> C2\n }\n C --> Z\n A --> W\n W --> Z\n W : a deliberately wide sibling";
let d = laid_out_orthogonal(src);
let lane = node_of(&d, "A").center.x;
for id in ["C_start", "C1", "C2", "Z"] {
assert!(
(node_of(&d, id).center.x - lane).abs() <= 0.51,
"{id} (x={:.2}) must sit on the lane A starts (x={lane:.2})",
node_of(&d, id).center.x
);
}
assert!(
(cluster_of(&d, "C").center.x - lane).abs() <= 0.51,
"the frame's own centre (x={:.2}) is what a cluster-anchored edge aligns against — it has \
to be on the lane too (x={lane:.2})",
cluster_of(&d, "C").center.x
);
assert_vertical("lane-through-composite", edge_of(&d, "A", "C"), lane);
assert_vertical("lane-through-composite", edge_of(&d, "C", "Z"), lane);
}
#[test]
fn orthogonal_design_4c_fork_targets_share_the_row_below_the_bar() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(design_reference_source("zz-design-4c"));
let (get, audit) = (node_of(&d, "取得"), node_of(&d, "監査"));
assert!(
(get.center.y - audit.center.y).abs() <= 0.51,
"取得 (y={:.2}) and 監査 (y={:.2}) are the fork's own two branches: same row",
get.center.y,
audit.center.y
);
let bar = node_of(&d, "fork_state");
assert!(
get.center.y > bar.center.y && get.center.y < node_of(&d, "処理_start").center.y,
"that row sits between the fork bar and the composite's own first member"
);
let (bl, br) = (
bar.center.x - bar.size.w / 2.0,
bar.center.x + bar.size.w / 2.0,
);
for n in [get, audit] {
assert!(
n.center.x > bl && n.center.x < br,
"the fork bar ({bl:.2}..{br:.2}) must straddle {}'s trunk (x={:.2})",
n.id,
n.center.x
);
}
}
#[test]
fn orthogonal_design_4c_trunk_runs_straight_through_the_composite_to_the_join() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(design_reference_source("zz-design-4c"));
let lane = node_of(&d, "取得").center.x;
for id in ["処理_start", "整形", "走査", "集計"] {
assert!(
(node_of(&d, id).center.x - lane).abs() <= 0.51,
"{id} (x={:.2}) must sit on 取得's own trunk (x={lane:.2})",
node_of(&d, id).center.x
);
}
for id in ["処理", "解析"] {
assert!(
(cluster_of(&d, id).center.x - lane).abs() <= 0.51,
"frame {id} (x={:.2}) must sit on the trunk too (x={lane:.2})",
cluster_of(&d, id).center.x
);
}
assert_vertical("zz-design-4c", edge_of(&d, "取得", "処理"), lane);
assert_vertical("zz-design-4c", edge_of(&d, "処理", "join_state"), lane);
let other = node_of(&d, "監査").center.x;
assert_vertical("zz-design-4c", edge_of(&d, "監査", "join_state"), other);
}
#[test]
fn orthogonal_design_4c_join_output_lane_sits_on_the_bar_centroid() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(design_reference_source("zz-design-4c"));
let centroid = (node_of(&d, "集計").center.x + node_of(&d, "監査").center.x) / 2.0;
for id in ["完了", "root_end"] {
assert!(
(node_of(&d, id).center.x - centroid).abs() <= 0.51,
"{id} (x={:.2}) must sit on the join bar's own input centroid (x={centroid:.2})",
node_of(&d, id).center.x
);
}
assert_vertical("zz-design-4c", edge_of(&d, "join_state", "完了"), centroid);
assert_vertical("zz-design-4c", edge_of(&d, "完了", "root_end"), centroid);
let d = laid_out_orthogonal(
"stateDiagram-v2\n state f <<fork>>\n state j <<join>>\n [*] --> f\n f --> A\n \
f --> B\n X --> B\n A --> j\n B --> j\n j --> [*]",
);
let centroid = (node_of(&d, "A").center.x + node_of(&d, "B").center.x) / 2.0;
assert!(
(node_of(&d, "root_end").center.x - centroid).abs() <= 0.51,
"the node after a lopsided join must sit on its input centroid (x={centroid:.2}), not on \
wherever dagre's barycentre put it (x={:.2})",
node_of(&d, "root_end").center.x
);
assert_vertical("lopsided-join", edge_of(&d, "j", "root_end"), centroid);
}
#[test]
fn orthogonal_design_4a_the_frame_aligns_with_the_node_above_it() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(design_reference_source("zz-design-4a"));
let (tree_node, frame) = (node_of(&d, "ツリー"), cluster_of(&d, "プレビュー"));
assert!(
(tree_node.center.x - frame.center.x).abs() <= 0.51,
"ツリー (x={:.2}) and the プレビュー frame (x={:.2}) must share a lane",
tree_node.center.x,
frame.center.x
);
for (from, to) in [("ツリー", "プレビュー"), ("プレビュー", "ツリー")] {
let e = edge_of(&d, from, to);
let at = e.points[0].x;
assert_vertical("zz-design-4a", e, at);
let off = (at - frame.center.x).abs();
assert!(
off <= orthogonal::PORT_SPACING * 2.0 + 0.51,
"{from} -> {to} runs at x={at:.2}, {off:.2}px off the shared face centre — the two \
transitions are meant to sit on adjacent slots of the same retreat grid"
);
}
for id in ["プレビュー_start", "デコード中", "表示"] {
assert!(
(node_of(&d, id).center.x - frame.center.x).abs() <= 0.51,
"{id} must sit at the head of the frame's own internal lane"
);
}
}
#[test]
fn orthogonal_design_4a_back_edge_label_room_stays_outside_the_frame() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(design_reference_source("zz-design-4a"));
let frame = cluster_of(&d, "プレビュー").bounds();
let q = edge_of(&d, "プレビュー", "ツリー")
.label
.as_ref()
.expect("the q transition carries a label");
assert!(
q.center.y < frame.1,
"the q label sits at y={:.2}, inside the frame that starts at y={:.2} — its room belongs \
in the gap above the frame",
q.center.y,
frame.1
);
let (start, first) = (node_of(&d, "プレビュー_start"), node_of(&d, "デコード中"));
let gap = (first.center.y - first.size.h / 2.0) - (start.center.y + start.size.h / 2.0);
assert!(
(gap - super::RANK_SEP).abs() <= 0.51,
"● → デコード中 carries no label, so its gap should be exactly RANK_SEP ({:.2}), not \
{gap:.2}",
super::RANK_SEP
);
}
#[test]
fn orthogonal_design_4a_the_end_marker_sits_right_of_the_trunk_one_bend_away() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(design_reference_source("zz-design-4a"));
let (tree_node, marker) = (node_of(&d, "ツリー"), node_of(&d, "root_end"));
let entry = node_of(&d, "プレビュー_start");
assert!(
marker.center.x > entry.center.x,
"the end marker (x={:.2}) is the dead-end branch, so it takes the right of the プレビュー \
trunk (x={:.2})",
marker.center.x,
entry.center.x
);
let q = edge_of(&d, "ツリー", "root_end");
assert_eq!(
q.points.len(),
3,
"Q leaves ツリー's own cross-axis face and bends once into the marker's pole: {:?}",
q.points
);
let (_, _, tree_right, _) = tree_node.bounds();
assert!(
(q.points[0].x - (tree_right + orthogonal::PORT_INSET)).abs() <= 0.51
&& (q.points[0].y - tree_node.center.y).abs() <= 0.51,
"Q exits ツリー's right face, centred on it: {:?} vs right edge {tree_right:.2}, centre y \
{:.2}",
q.points[0],
tree_node.center.y
);
}
#[test]
fn orthogonal_design_4b_the_dead_end_branch_sits_below_the_trunk() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(design_reference_source("zz-design-4b"));
let (update, pause) = (node_of(&d, "更新"), node_of(&d, "休止"));
assert!(
pause.center.y > update.center.y,
"休止 (y={:.2}) is the dead end and belongs below the 更新 trunk (y={:.2})",
pause.center.y,
update.center.y
);
for (from, to) in [("待機", "監視"), ("監視", "分岐"), ("分岐", "更新")] {
let e = edge_of(&d, from, to);
assert_eq!(
e.points.len(),
2,
"{from} -> {to} is a spine segment — a straight 2-point line: {:?}",
e.points
);
}
}
#[test]
fn state_design_reference_renders_are_byte_stable_per_theme() {
if !text_metrics::fonts_available() {
return;
}
let pinned: &[(&str, &str, &str, u64)] = &[
("zz-design-4a", "dark", "splines", 6945086091217365677),
("zz-design-4a", "light", "splines", 17865779921064957116),
("zz-design-4a", "classic", "splines", 2374771864539773089),
("zz-design-4a", "forest", "splines", 3478882518047204662),
("zz-design-4a", "neutral", "splines", 14396774170046225880),
("zz-design-4b", "dark", "splines", 13664536395431744492),
("zz-design-4b", "light", "splines", 12935933493011207262),
("zz-design-4b", "classic", "splines", 5915862964357765746),
("zz-design-4b", "forest", "splines", 17218757109032054782),
("zz-design-4b", "neutral", "splines", 11666605071335975094),
("zz-design-4c", "dark", "splines", 4879018428627726240),
("zz-design-4c", "light", "splines", 14316294563286093084),
("zz-design-4c", "classic", "splines", 15012355425714036991),
("zz-design-4c", "forest", "splines", 13652035469580789214),
("zz-design-4c", "neutral", "splines", 1528042515392761697),
];
let corpus = orthogonal_design_reference_corpus();
for (name, theme_name, routing, want) in pinned {
let (_, src) = corpus
.iter()
.find(|(n, _)| n == name)
.unwrap_or_else(|| panic!("{name} must still be in the design corpus"));
let svg = render_flow(src, theme_name, routing).expect("renders");
let masked = mask_numbers(&svg);
assert_eq!(
super::tests::fnv1a(&masked),
*want,
"{name}/{theme_name}/{routing} is no longer byte-identical (number-masked) to its \
pinned render"
);
}
}
#[test]
fn state_konoma_orthogonal_theme_is_inert() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_design_reference_corpus() {
let base = render_flow(src, "dark", "konoma-orthogonal").expect("renders");
assert!(
base.contains(theme::KONOMA.node_stroke),
"{name}: an orthogonal render must draw the palette's own outline colour"
);
for theme_name in ["light", "modern", "classic", "mermaid", "forest", "neutral"] {
let other = render_flow(src, theme_name, "konoma-orthogonal").expect("renders");
assert_eq!(
base, other,
"{name}: mermaid_theme={theme_name} changed a konoma-orthogonal render"
);
}
let splines = render_flow(src, "dark", "splines").expect("renders");
assert!(
!splines.contains(theme::KONOMA.node_stroke),
"{name}: this comparison is only meaningful if the two palettes differ"
);
}
}
#[test]
fn state_konoma_orthogonal_draws_the_design_reference_look() {
if !text_metrics::fonts_available() {
return;
}
for (name, src) in orthogonal_design_reference_corpus() {
let svg = render_flow(src, "dark", "konoma-orthogonal").expect("renders");
for stale in ["#2b2b38", "#1f2020", "#cccccc", "#d3d3d3", "#8a8a8a"] {
assert!(
!svg.contains(stale),
"{name}: {stale} is a mermaid-theme colour and must not appear: {svg}"
);
}
let frames: Vec<&str> = svg
.lines()
.filter(|l| l.starts_with("<rect") && l.contains("stroke=\"#6e7681\""))
.collect();
for line in &frames {
assert!(
line.contains("fill=\"none\"")
&& line.contains("stroke-width=\"1\"")
&& line.contains("rx=\"3\"")
&& !line.contains("stroke-dasharray"),
"{name}: a composite frame must be an unfilled solid 1px rx=3 outline: {line}"
);
}
if src.contains("state ") && src.contains('{') {
assert!(
!frames.is_empty(),
"{name}: the source declares a composite state"
);
assert!(
svg.contains(&format!("fill=\"{}\"/>", theme::KONOMA.node_fill)),
"{name}: the title strip must be filled in the node colour: {svg}"
);
assert!(
svg.contains(&format!(
"stroke=\"{}\" stroke-width=\"1\"",
theme::KONOMA_TOKENS.composite_rule
)),
"{name}: the strip's own rule must be drawn: {svg}"
);
assert!(
svg.contains(&format!(
"font-size=\"11\" fill=\"{}\"",
theme::KONOMA_TOKENS.composite_text
)),
"{name}: the strip title must be 11px in the palette's own colour: {svg}"
);
}
assert!(
svg.contains(&format!("fill=\"{}\"/>", theme::KONOMA.state_marker)),
"{name}: a state marker must be a solid fill in the palette's marker colour: {svg}"
);
for line in svg.lines() {
if line.starts_with("<rect") && line.contains("stroke-width=\"1.5\"") {
assert!(
line.contains("rx=\"3\"") && line.contains(theme::KONOMA.node_fill),
"{name}: a state box must be the palette's own box: {line}"
);
}
}
assert!(
svg.contains("font-size=\"14\""),
"{name}: body text must stay at the measured size: {svg}"
);
if svg.contains("<g class=\"edge-labels\">\n<") {
assert!(
svg.contains("font-size=\"11\""),
"{name}: an edge label must be drawn at the reference's 11px: {svg}"
);
}
}
}
#[test]
fn state_title_strip_is_drawn_under_the_frame_outline_with_matching_corners() {
if !text_metrics::fonts_available() {
return;
}
let mut saw_a_strip = false;
for (name, src) in orthogonal_design_reference_corpus() {
let rendered = render_flow(src, "dark", "konoma-orthogonal").expect("renders");
let lines: Vec<&str> = rendered.lines().collect();
for (i, line) in lines.iter().enumerate() {
let is_strip = line.starts_with("<path")
&& line.ends_with(&format!("fill=\"{}\"/>", theme::KONOMA.node_fill));
if !is_strip {
continue;
}
saw_a_strip = true;
let rule = *lines
.get(i + 1)
.unwrap_or_else(|| panic!("{name}: a strip must be followed by its rule: {line}"));
assert!(
rule.starts_with("<line"),
"{name}: line right after the strip must be its dividing rule, not: {rule}"
);
let frame = *lines.get(i + 2).unwrap_or_else(|| {
panic!("{name}: a strip's rule must be followed by the frame outline: {line}")
});
assert!(
frame.starts_with("<rect") && frame.contains("stroke=\"#6e7681\""),
"{name}: the frame outline must be drawn right after its own strip, not: {frame}"
);
let rx: f64 = frame
.split("rx=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or_else(|| panic!("{name}: frame must carry rx: {frame}"))
.parse()
.unwrap_or_else(|_| panic!("{name}: frame rx must be numeric: {frame}"));
let want_r = rx - clusters::STROKE_WIDTH / 2.0;
let arc = format!("A{},{}", svg::num(want_r), svg::num(want_r));
assert_eq!(
line.matches(&arc).count(),
2,
"{name}: the strip must round both its top corners to {arc}, matching the \
frame's own rx={rx}: {line}"
);
}
}
assert!(
saw_a_strip,
"the design corpus must contain at least one composite state to exercise this"
);
}
#[test]
fn state_title_strip_corner_arcs_survive_rasterisation() {
if !text_metrics::fonts_available() {
return;
}
let requested_scale = 8.0_f64;
let (stroke_r, stroke_g, stroke_b) =
super::tests::hex_to_rgb(theme::KONOMA_TOKENS.composite_stroke);
let (fill_r, fill_g, fill_b) = super::tests::hex_to_rgb(theme::KONOMA.node_fill);
let radius = theme::KONOMA_TOKENS.frame_radius;
let tolerance_raw = radius * 0.15;
let mut checked_a_corner = false;
for (name, src) in orthogonal_design_reference_corpus() {
let d = laid_out_orthogonal(src);
let rendered = render_flow(src, "dark", "konoma-orthogonal").expect("renders");
let img = crate::preview::svg::rasterize_bytes(
rendered.as_bytes(),
std::path::Path::new("title-strip-corner.svg"),
(d.width.max(d.height) * requested_scale).round() as u32,
)
.unwrap_or_else(|| panic!("{name}: must rasterise"));
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as i64, rgba.height() as i64);
let scale = if d.width >= d.height {
rgba.width() as f64 / d.width
} else {
rgba.height() as f64 / d.height
};
let tolerance_px = ((tolerance_raw * scale).ceil() as i64).max(1);
let is_close = |x: i64, y: i64, want: (u8, u8, u8)| {
x >= 0 && y >= 0 && x < w && y < h && {
let p = rgba.get_pixel(x as u32, y as u32);
p[3] == 255
&& super::tests::close(p[0], want.0)
&& super::tests::close(p[1], want.1)
&& super::tests::close(p[2], want.2)
}
};
let any_close_near = |cx: f64, cy: f64, want: (u8, u8, u8)| {
let px = (cx * scale).round() as i64;
let py = (cy * scale).round() as i64;
(-tolerance_px..=tolerance_px)
.any(|dy| (-tolerance_px..=tolerance_px).any(|dx| is_close(px + dx, py + dy, want)))
};
for cluster in d
.clusters
.iter()
.filter(|c| c.title_strip && !c.title.is_blank())
{
checked_a_corner = true;
let (l, t, right, _) = cluster.bounds();
let half = radius / std::f64::consts::SQRT_2;
let center_y = t + radius;
for (edge_x, center_x, sign) in
[(l, l + radius, -1.0_f64), (right, right - radius, 1.0)]
{
let diag_x = center_x + sign * half;
let diag_y = center_y - half;
assert!(
any_close_near(diag_x, diag_y, (stroke_r, stroke_g, stroke_b)),
"{name}: no frame stroke at the 45° arc point of the corner at x={edge_x} \
(checked near ({diag_x},{diag_y})) — the corner arc is missing or covered"
);
}
let inside_x = l + radius + 4.0;
let inside_y = t + 3.0;
assert!(
any_close_near(inside_x, inside_y, (fill_r, fill_g, fill_b)),
"{name}: no strip fill colour just inside the top-left corner (checked near \
({inside_x},{inside_y})) — the strip is cut short of its own corner"
);
}
}
assert!(
checked_a_corner,
"the design corpus must contain at least one composite state to exercise this"
);
}
#[test]
fn orthogonal_state_markers_choice_and_bars_keep_their_own_sizes_under_n8() {
if !text_metrics::fonts_available() {
return;
}
let d = laid_out_orthogonal(
"stateDiagram-v2\n state c <<choice>>\n state f <<fork>>\n [*] --> A\n A --> c\n \
c --> B\n c --> C\n B --> f\n f --> D\n D --> [*]",
);
for n in &d.nodes {
match n.shape {
Glyph::StateStart | Glyph::StateEnd => assert_eq!(
(n.size.w, n.size.h),
(
shapes::STATE_MARKER_RADIUS * 2.0,
shapes::STATE_MARKER_RADIUS * 2.0
),
"S1: {} is a marker dot, never a 96x36 box",
n.id
),
Glyph::ChamferedRect if n.id == "c" => assert_eq!(
(n.size.w, n.size.h),
(28.0, 28.0),
"S4: a choice is a fixed 28x28 square"
),
Glyph::Bar { .. } => assert!(
n.size.w.min(n.size.h) <= 6.0,
"S4: {} is a 6px-thick bar, not a box: {:?}",
n.id,
n.size
),
_ => assert_eq!(
n.size.h,
shapes::ortho_height(n.label.lines.len()),
"N1/N5: {} is an ordinary state box, nested or not",
n.id
),
}
}
}
#[test]
fn orthogonal_design_4b_back_edge_leaves_through_the_face_its_lane_is_on() {
if !text_metrics::fonts_available() {
return;
}
let src = orthogonal_design_reference_corpus()
.into_iter()
.find(|(n, _)| *n == "zz-design-4b")
.expect("4b is in the design-reference corpus")
.1;
let d = laid_out_orthogonal(src);
let back = d
.edges
.iter()
.find(|e| e.from == "通知" && e.to == "待機")
.expect("通知 --> 待機 must exist");
let loop_edge = d
.edges
.iter()
.find(|e| e.from == "監視" && e.to == "監視")
.expect("監視's self-transition must exist");
assert!(
(back.points[0].x - back.points[1].x).abs() < 1e-6,
"通知 leaves through its Top or Bottom face, not sideways: {:?}",
back.points
);
let n = back.points.len();
assert!(
(back.points[n - 1].x - back.points[n - 2].x).abs() < 1e-6,
"待機 is entered through its Top or Bottom face: {:?}",
back.points
);
assert_eq!(
n - 2,
2,
"two corners, the way the design draws it: {:?}",
back.points
);
let crosses = |a: &Point, b: &Point, c: &Point, dd: &Point| {
let o = |p: &Point, q: &Point, r: &Point| {
let v = (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
if v > 1e-9 {
1
} else if v < -1e-9 {
-1
} else {
0
}
};
o(a, b, c) != o(a, b, dd) && o(c, dd, a) != o(c, dd, b)
};
for w1 in back.points.windows(2) {
for w2 in loop_edge.points.windows(2) {
assert!(
!crosses(&w1[0], &w1[1], &w2[0], &w2[1]),
"the back edge must not cut the self-loop: {:?} vs {:?}",
back.points,
loop_edge.points
);
}
}
}
#[test]
fn orthogonal_state_boxes_follow_n1_at_every_nesting_depth() {
if !text_metrics::fonts_available() {
return;
}
let mut nested = 0usize;
for (name, src) in orthogonal_full_corpus() {
let d = laid_out_orthogonal(src);
for n in &d.nodes {
let is_choice = n.shape == Glyph::ChamferedRect
&& n.size.w == super::state::STATE_CHOICE_ORTHO_SIZE
&& n.size.h == super::state::STATE_CHOICE_ORTHO_SIZE;
if !shapes::orthogonal_covers(n.shape) || is_choice {
continue;
}
let label_box = shapes::orthogonal_label_box(n.shape, &n.label);
assert!(
n.size.h >= label_box.h - 1e-6 && n.size.w >= label_box.w - 1e-6,
"{name}: {} is smaller than N1/N2 asks for: {:?} vs {label_box:?}",
n.id,
n.size
);
if d.clusters.iter().any(|c| {
let (l, t, r, b) = c.bounds();
n.center.x > l && n.center.x < r && n.center.y > t && n.center.y < b
}) {
nested += 1;
assert_eq!(
label_box.h,
shapes::ortho_height(n.label.lines.len()),
"{name}: {} is inside a frame and must NOT be shrunk for it",
n.id
);
}
}
}
assert!(
nested > 5,
"the corpus must actually contain nested states: only {nested} seen"
);
}
const SAMPLE_STATE_DIAGRAM: &str =
"stateDiagram-v2\n [*] --> Tree\n Tree --> Preview : Enter\n \
Preview --> Tree : q\n state Preview {\n [*] --> Decoding\n \
Decoding --> Ready : image arrives\n }\n Tree --> [*] : Q";
pub(super) fn state_with_direction(src: &str, direction: &str) -> String {
let out = format!("{src}\n direction {direction}");
assert_eq!(
state::parse(&out)
.expect("a state fixture parses")
.direction,
crate::preview::mermaid::flowchart::Direction::parse(direction)
.expect("a direction keyword this module wrote itself"),
"appending `direction {direction}` has to be what the parser ends up with"
);
out
}
fn direction_fixture_sources() -> Vec<(&'static str, &'static str)> {
std::iter::once(("sample-state-diagram", SAMPLE_STATE_DIAGRAM))
.chain(orthogonal_design_reference_corpus())
.collect()
}
fn orthogonal_reversed_direction_corpus() -> &'static [(String, String)] {
static CACHE: std::sync::OnceLock<Vec<(String, String)>> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
let mut out: Vec<(String, String)> = Vec::new();
for (name, src) in direction_fixture_sources() {
for direction in ["LR", "RL", "TB", "BT"] {
out.push((
format!("{name}-{direction}"),
state_with_direction(src, direction),
));
}
}
out
})
}
#[test]
fn orthogonal_state_rl_is_the_exact_mirror_of_lr() {
for (name, src) in direction_fixture_sources() {
let lr = lay_out(
&state::parse(&state_with_direction(src, "LR")).expect("parses"),
Routing::Orthogonal,
)
.expect("lays out");
let rl = lay_out(
&state::parse(&state_with_direction(src, "RL")).expect("parses"),
Routing::Orthogonal,
)
.expect("lays out");
super::tests::assert_mirrored_diagrams(&format!("{name} LR/RL"), &lr, &rl, false);
}
}
#[test]
fn orthogonal_state_bt_is_the_exact_mirror_of_tb() {
for (name, src) in direction_fixture_sources() {
let tb = lay_out(
&state::parse(&state_with_direction(src, "TB")).expect("parses"),
Routing::Orthogonal,
)
.expect("lays out");
let bt = lay_out(
&state::parse(&state_with_direction(src, "BT")).expect("parses"),
Routing::Orthogonal,
)
.expect("lays out");
super::tests::assert_mirrored_diagrams(&format!("{name} TB/BT"), &tb, &bt, true);
}
}
#[test]
fn orthogonal_state_lr_and_tb_stay_two_different_layouts() {
let lr = lay_out(
&state::parse(&state_with_direction(SAMPLE_STATE_DIAGRAM, "LR")).expect("parses"),
Routing::Orthogonal,
)
.expect("lays out");
let tb = lay_out(
&state::parse(&state_with_direction(SAMPLE_STATE_DIAGRAM, "TB")).expect("parses"),
Routing::Orthogonal,
)
.expect("lays out");
let flow_gap = |d: &Diagram, vertical: bool| {
let of = |id: &str| {
let n = d.nodes.iter().find(|n| n.id == id).expect("state exists");
if vertical {
n.center.y
} else {
n.center.x
}
};
of("Decoding") - of("Tree")
};
assert!(
flow_gap(&lr, false) > 0.0,
"LR runs the flow along x: Decoding sits to the right of Tree"
);
assert!(
flow_gap(&tb, true) > 0.0,
"TB runs the flow along y: Decoding sits below Tree"
);
assert!(
lr.width > lr.height && tb.height > tb.width,
"the two directions are two layouts: LR came out {}x{} and TB {}x{}",
lr.width,
lr.height,
tb.width,
tb.height
);
}