use std::collections::HashSet;
use super::state::{lay_out, render, 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_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::{clusters, labels, shapes, svg, Diagram, Glyph, Label, 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).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);
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(),
};
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,
});
}
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)],
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,
},
super::PlacedEdge {
from: "n7".to_string(),
to: "n5".to_string(),
points: vec![Point::new(60.0, 460.0), Point::new(260.0, 460.0)],
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,
},
];
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(),
},
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(),
},
];
Diagram {
width: 800.0,
height: 660.0,
nodes,
edges,
clusters,
lifelines: Vec::new(),
}
}
#[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");
}
}