Files
Marcos Paulo c80c6cad4c feat(diagrams): wave 3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:45:22 -03:00

251 lines
10 KiB
Rust

//! Parse, layout and SVG behaviour of sequence diagrams, driven by the fixtures in `tests/fixtures`.
use std::fmt::Write as _;
use notescraft_diagrams::{ArrowKind, BlockKind, Diagram, Error, Event, NotePosition, Shape, layout, parse, to_svg};
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap_or_default()
}
fn seq(src: &str) -> notescraft_diagrams::SequenceDiagram {
match parse(src) {
Ok(Diagram::Sequence(s)) => s,
other => panic!("expected a sequence diagram, got {other:?}"),
}
}
fn messages(s: &notescraft_diagrams::SequenceDiagram) -> Vec<&notescraft_diagrams::Message> {
collect(&s.events).into_iter().flatten().collect()
}
fn collect(events: &[Event]) -> Vec<Vec<&notescraft_diagrams::Message>> {
events
.iter()
.map(|e| match e {
Event::Message(m) => vec![m],
Event::Block(b) => collect(&b.children).into_iter().flatten().collect(),
_ => Vec::new(),
})
.collect()
}
#[test]
fn participants_aliases_and_actors() {
let s = seq(&fixture("sequence_basics.mmd"));
assert_eq!(s.participants.len(), 3);
let names: Vec<&str> = s.participants.iter().map(|p| p.display.as_str()).collect();
assert_eq!(names, ["Alice", "Bob", "Carol"]);
assert!(s.participants.get(1).is_some_and(|p| p.actor));
}
#[test]
fn every_arrow_type_is_recognised() {
let s = seq(&fixture("sequence_basics.mmd"));
let arrows: Vec<ArrowKind> = messages(&s).iter().map(|m| m.arrow).collect();
for want in [
ArrowKind::SolidFilled,
ArrowKind::Dashed,
ArrowKind::Solid,
ArrowKind::DashedFilled,
ArrowKind::Cross,
ArrowKind::DashedCross,
ArrowKind::Open,
ArrowKind::DashedOpen,
] {
assert!(arrows.contains(&want), "missing {want:?} in {arrows:?}");
}
assert!(messages(&s).iter().any(|m| m.from == m.to), "self message is kept");
assert!(s.diagnostics.is_empty(), "{:?}", s.diagnostics);
}
#[test]
fn notes_and_activations() {
let s = seq(&fixture("sequence_notes.mmd"));
let notes: Vec<(&NotePosition, &str)> = s
.events
.iter()
.filter_map(|e| match e {
Event::Note(n) => Some((&n.position, n.text.as_str())),
_ => None,
})
.collect();
assert_eq!(notes, [(&NotePosition::Left, "single"), (&NotePosition::Over, "shared"), (&NotePosition::Right, "after")]);
if let Some(Event::Note(n)) = s.events.get(1) {
assert_eq!(n.targets, [0, 1]);
}
assert!(s.events.iter().any(|e| matches!(e, Event::Activate { participant: 0 })));
assert!(s.events.iter().any(|e| matches!(e, Event::Deactivate { participant: 0 })));
// The `+`/`-` markers on the message target activate then deactivate B.
for window in s.events.windows(2) {
if matches!(window[0], Event::Message(ref m) if m.text == "wake") {
assert!(matches!(window[1], Event::Activate { participant: 1 }));
}
if matches!(window[0], Event::Message(ref m) if m.text == "done") {
assert!(matches!(window[1], Event::Deactivate { participant: 1 }));
}
}
assert!(s.diagnostics.is_empty(), "{:?}", s.diagnostics);
}
#[test]
fn nested_blocks_and_autonumber() {
let s = seq(&fixture("sequence_blocks.mmd"));
let tops: Vec<&BlockKind> = s
.events
.iter()
.filter_map(|e| match e {
Event::Block(b) => Some(&b.kind),
_ => None,
})
.collect();
assert_eq!(tops.len(), 4);
let msg = messages(&s);
let numbers: Vec<Option<usize>> = msg.iter().map(|m| m.number).collect();
assert_eq!(numbers, [Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7)]);
let kinds = s
.events
.iter()
.filter_map(|e| match e {
Event::Block(b) => Some((b.kind.name(), b.label.as_str())),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(kinds, [("loop", "every second"), ("opt", "maybe"), ("critical", "must"), ("break", "on error")]);
}
#[test]
fn else_and_sections_nest_inside_blocks() {
let s = seq(&fixture("sequence_blocks.mmd"));
if let Some(Event::Block(loop_block)) = s.events.first() {
assert_eq!(loop_block.kind, BlockKind::Loop);
assert!(matches!(loop_block.children.get(1), Some(Event::Block(alt)) if alt.kind.name() == "alt"));
}
assert!(s.events.iter().any(|e| match e {
Event::Block(b) => b.children.iter().any(|c| match c {
Event::Block(inner) => inner.children.iter().any(|c2| matches!(c2, Event::Section(_))),
_ => false,
}),
_ => false,
}));
}
#[test]
fn autonumber_start_number() {
let s = seq("sequenceDiagram\nautonumber 10\nA->>B: x\nA->>B: y");
assert_eq!(messages(&s).iter().map(|m| m.number).collect::<Vec<_>>(), [Some(10), Some(11)]);
}
#[test]
fn malformed_lines_become_diagnostics_and_parsing_continues() {
let s = seq(&fixture("sequence_malformed.mmd"));
assert_ne!(s.diagnostics, []);
assert!(s.diagnostics.len() >= 6, "{:?}", s.diagnostics);
assert!(messages(&s).iter().any(|m| m.text == "fine"));
assert!(messages(&s).iter().any(|m| m.text == "after junk"), "lines after bad ones still parse");
assert!(s.diagnostics.iter().any(|d| d.message.contains("`end` without an open block")));
}
#[test]
fn too_many_participants_is_an_error() {
let mut src = String::from("sequenceDiagram\n");
for i in 0..=notescraft_diagrams::MAX_PARTICIPANTS {
let _ = writeln!(src, "participant P{i}");
}
assert!(matches!(parse(&src), Err(Error::LimitExceeded { what: "participants", .. })));
}
#[test]
fn too_many_events_is_an_error() {
let mut src = String::from("sequenceDiagram\nparticipant A\nparticipant B\n");
for i in 0..=notescraft_diagrams::MAX_EVENTS {
let _ = writeln!(src, "A->>B: m{i}");
}
assert!(matches!(parse(&src), Err(Error::LimitExceeded { what: "sequence events", .. })));
}
#[test]
fn nesting_beyond_the_cap_keeps_contents_and_warns() {
let mut src = String::from("sequenceDiagram\nA->>B: outside\n");
for _ in 0..=notescraft_diagrams::MAX_BLOCK_DEPTH {
src.push_str("loop deep\n");
}
src.push_str("A->>B: inside\n");
for _ in 0..=notescraft_diagrams::MAX_BLOCK_DEPTH {
src.push_str("end\n");
}
let s = seq(&src);
assert!(s.diagnostics.iter().any(|d| d.message.contains("nest at most")));
assert!(messages(&s).iter().any(|m| m.text == "outside"), "prefix lines stay where they were");
assert!(messages(&s).iter().any(|m| m.text == "inside"), "contents past the cap are kept");
}
#[test]
fn unbalanced_activation_is_diagnosed() {
let s = seq("sequenceDiagram\nloop forever\nA->>B: hi\nactivate A");
assert!(s.diagnostics.iter().any(|d| d.message.contains("never followed by")));
assert!(s.diagnostics.iter().any(|d| d.message.contains("never closed with `end`")));
}
#[test]
fn layout_is_deterministic_and_structured() {
let d = parse(&fixture("sequence_blocks.mmd")).unwrap_or_else(|e| panic!("{e}"));
let (a, b) = (layout(&d).unwrap_or_else(|e| panic!("{e}")), layout(&d).unwrap_or_else(|e| panic!("{e}")));
assert_eq!(a, b);
assert!(a.shapes.iter().any(|s| matches!(s, Shape::Edge { style: notescraft_diagrams::EdgeStyle::Plain, .. })), "dotted lifelines");
assert!(a.shapes.iter().any(|s| matches!(s, Shape::Group { title, .. } if title.content.contains("loop every second"))));
assert!(a.shapes.iter().any(|s| matches!(s, Shape::Edge { style: notescraft_diagrams::EdgeStyle::Dotted, label: Some(t), .. } if t.content == "2. ack")));
let last_node = a.shapes.iter().rposition(|s| matches!(s, Shape::Box { .. }));
let first_group = a.shapes.iter().position(|s| matches!(s, Shape::Group { .. }));
assert!(first_group.is_some_and(|g| last_node.is_some_and(|n| g < n)), "blocks draw before the participant boxes");
assert!(a.shapes.iter().any(|s| matches!(s, Shape::Box { text, .. } if text.content == "A")), "participant box keeps its id");
assert!(last_node.is_some_and(|n| n + 1 == a.shapes.len()), "participant boxes draw last");
assert!(a.width > 0.0 && a.height > 0.0);
}
fn frame_width(shape: &Shape) -> f64 {
match shape {
Shape::Box { frame, .. } => frame.width,
_ => f64::INFINITY,
}
}
#[test]
fn svg_escapes_script_tags_in_messages() {
let d = parse("sequenceDiagram\nA->>B: <script>alert(1)</script>").unwrap_or_else(|e| panic!("{e}"));
let svg = to_svg(&layout(&d).unwrap_or_else(|e| panic!("{e}")));
assert!(!svg.contains("<script"), "{svg}");
assert!(svg.contains("&lt;script&gt;"), "{svg}");
assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"));
}
#[test]
fn notes_lay_out_as_boxes() {
let d = parse(&fixture("sequence_notes.mmd")).unwrap_or_else(|e| panic!("{e}"));
let l = layout(&d).unwrap_or_else(|e| panic!("{e}"));
let note_boxes = l.shapes.iter().filter(|s| matches!(s, Shape::Box { text, .. } if text.content == "shared")).count();
assert_eq!(note_boxes, 1);
assert!(l.shapes.iter().any(|s| matches!(s, Shape::Box { text, .. } if text.content == "single")));
assert!(l.shapes.iter().any(|s| matches!(s, Shape::Box { text, .. } if text.content.is_empty() && frame_width(s) < 20.0)), "activation bars");
}
#[test]
fn self_message_lays_out_as_a_loop() {
let d = parse(&fixture("sequence_basics.mmd")).unwrap_or_else(|e| panic!("{e}"));
let l = layout(&d).unwrap_or_else(|e| panic!("{e}"));
assert!(l.shapes.iter().any(|s| matches!(s, Shape::Edge { points, label: Some(t), .. } if points.len() > 2 && t.content == "self note")));
}
#[test]
#[ignore = "perf: run with --release -- --ignored"]
fn five_hundred_messages_lay_out_under_a_second() {
let mut src = String::from("sequenceDiagram\nparticipant A\nparticipant B\nparticipant C\n");
for i in 0..500 {
let _ = writeln!(src, "A->>B: m{i}\nB-->>C: r{i}");
}
let d = parse(&src).unwrap_or_else(|e| panic!("{e}"));
let start = std::time::Instant::now();
let l = layout(&d).unwrap_or_else(|e| panic!("{e}"));
let took = start.elapsed();
eprintln!("1000-event sequence: {took:?}, {} shapes, {}x{}", l.shapes.len(), l.width, l.height);
assert!(took.as_secs_f64() < 1.0, "{took:?}");
}