c80c6cad4c
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
251 lines
10 KiB
Rust
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: ¬escraft_diagrams::SequenceDiagram) -> Vec<¬escraft_diagrams::Message> {
|
|
collect(&s.events).into_iter().flatten().collect()
|
|
}
|
|
|
|
fn collect(events: &[Event]) -> Vec<Vec<¬escraft_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("<script>"), "{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:?}");
|
|
}
|