use super::model::{Line, Marker, MemberKind};
use super::parser::{parse_generic_types, parse_member, MAX_RELATIONS_FOR_TESTS};
use super::*;
fn ok(src: &str) -> ClassDiagram {
parse(src).unwrap_or_else(|e| panic!("should parse: {e}\n{src}"))
}
fn rows(d: &ClassDiagram, id: &str) -> (Vec<String>, Vec<String>) {
let c = d.class(id).unwrap_or_else(|| panic!("no class {id}"));
(
c.attributes.iter().map(|m| m.display()).collect(),
c.methods.iter().map(|m| m.display()).collect(),
)
}
#[test]
fn the_header_decides_and_only_the_header() {
assert!(parse("classDiagram\n A : +x").is_ok());
assert!(parse("classDiagram-v2\n A : +x").is_ok());
assert!(parse(" \n\n classDiagram\n A : +x").is_ok());
assert!(parse("%% a comment\nclassDiagram\n A : +x").is_ok());
assert!(parse("---\ntitle: t\n---\nclassDiagram\n A : +x").is_ok());
for (src, header) in [
("flowchart TD\n A --> B", "flowchart"),
("stateDiagram-v2\n [*] --> A", "stateDiagram-v2"),
("erDiagram\n A ||--|| B : x", "erDiagram"),
("sequenceDiagram\n A->>B: hi", "sequenceDiagram"),
("classDiagrams --> B", "classDiagrams"),
] {
assert_eq!(
parse(src).unwrap_err(),
ParseError::NotAClassDiagram {
header: header.to_string()
},
"{src:?}"
);
}
assert_eq!(parse("").unwrap_err(), ParseError::Empty);
assert_eq!(parse(" \n\n ").unwrap_err(), ParseError::Empty);
assert_eq!(parse("classDiagram").unwrap_err(), ParseError::NoClasses);
}
#[test]
fn front_matter_is_lifted_out_and_line_numbers_survive_it() {
let d = ok("---\ntitle: Animal example\n---\nclassDiagram\n A : +x");
assert_eq!(d.title.as_deref(), Some("Animal example"));
let e = parse("---\ntitle: t\n---\nclassDiagram\n A : +x\n class B {\n +y").unwrap_err();
assert_eq!(e.to_string(), "unclosed `class B {` opened at line 6");
}
#[test]
fn a_class_can_be_declared_in_every_documented_form() {
assert_eq!(ok("classDiagram\n class Animal").class_ids(), ["Animal"]);
let d = ok("classDiagram\n class Animal[\"Animal with a label\"]\n class Car[\"Car with *! symbols\"]\n Animal --> Car");
assert_eq!(d.class("Animal").unwrap().label, "Animal with a label");
assert_eq!(d.class("Car").unwrap().label, "Car with *! symbols");
let d = ok("classDiagram\n class `Animal Class!`\n class `Car Class`\n `Animal Class!` --> `Car Class`");
assert_eq!(d.class_ids(), ["Animal Class!", "Car Class"]);
assert_eq!(d.relations.len(), 1);
assert_eq!(d.relations[0].from, "Animal Class!");
let d = ok("classDiagram\n class Animal:::someclass\n classDef someclass fill:#f96");
assert_eq!(d.class("Animal").unwrap().css_classes, ["someclass"]);
let d = ok("classDiagram\n class A { +x\n +y() }");
assert_eq!(rows(&d, "A"), (vec!["+x".into()], vec!["+y()".into()]));
}
#[test]
fn a_generic_is_split_off_the_name_and_kept_in_the_label() {
let d = ok("classDiagram\n class Square~Shape~{\n int id\n }\n Square --> Foo");
assert_eq!(d.class_ids(), ["Square", "Foo"]);
let square = d.class("Square").unwrap();
assert_eq!(square.generic, "Shape");
assert_eq!(square.label, "Square<Shape>");
assert_eq!(d.relations[0].from, "Square");
}
#[test]
fn an_annotation_is_recognised_in_all_three_documented_places() {
let d = ok("classDiagram\n class Shape <<interface>>");
assert_eq!(d.class("Shape").unwrap().annotations, ["interface"]);
let d = ok("classDiagram\nclass Shape\n<<interface>> Shape\nShape : draw()");
assert_eq!(d.class("Shape").unwrap().annotations, ["interface"]);
assert_eq!(rows(&d, "Shape").1, ["draw()"]);
let d = ok("classDiagram\nclass Color{\n <<enumeration>>\n RED\n BLUE\n}");
assert_eq!(d.class("Color").unwrap().annotations, ["enumeration"]);
assert_eq!(rows(&d, "Color").0, ["RED", "BLUE"]);
let d = ok("classDiagram\n class Shape <<service>> {\n +run()\n }");
assert_eq!(d.class("Shape").unwrap().annotations, ["service"]);
assert_eq!(rows(&d, "Shape").1, ["+run()"]);
}
#[test]
fn a_label_may_contain_the_characters_an_annotation_is_made_of() {
let d = ok("classDiagram\n class A[\"a << b >> c\"]");
assert_eq!(d.class("A").unwrap().label, "a << b >> c");
assert!(d.class("A").unwrap().annotations.is_empty());
}
#[test]
fn a_member_reaches_the_same_class_by_either_route() {
let one_line = ok("classDiagram\nclass BankAccount\nBankAccount : +String owner\nBankAccount : +deposit(amount)");
let body = ok("classDiagram\nclass BankAccount{\n +String owner\n +deposit(amount)\n}");
assert_eq!(rows(&one_line, "BankAccount"), rows(&body, "BankAccount"));
assert_eq!(
rows(&body, "BankAccount"),
(
vec!["+String owner".into()],
vec!["+deposit(amount)".into()]
)
);
}
#[test]
fn every_visibility_marker_is_read_and_drawn() {
let d = ok("classDiagram\nclass A{\n +pub\n -priv\n #prot\n ~pkg\n none\n}");
let a = d.class("A").unwrap();
let seen: Vec<Option<char>> = a.attributes.iter().map(|m| m.visibility).collect();
assert_eq!(
seen,
[Some('+'), Some('-'), Some('#'), Some('~'), None],
"the four UML visibilities, plus a member that declares none"
);
assert_eq!(
rows(&d, "A").0,
["+pub", "-priv", "#prot", "~pkg", "none"],
"and the marker is part of what is drawn — dropping it loses the meaning"
);
}
#[test]
fn a_method_is_taken_apart_and_rebuilt() {
let d = ok("classDiagram\nclass A{\n +deposit(amount) bool\n +withdrawal(amount) int\n draw()\n getPoints() List~int~\n}");
assert_eq!(
rows(&d, "A").1,
[
"+deposit(amount) : bool",
"+withdrawal(amount) : int",
"draw()",
"getPoints() : List<int>"
],
"mermaid puts a ` : ` before the return type; printing the line as written does not"
);
let m = &d.class("A").unwrap().methods[0];
assert_eq!(m.kind, MemberKind::Method);
assert_eq!(m.name, "deposit");
assert_eq!(m.parameters, "amount");
assert_eq!(m.return_type, "bool");
}
#[test]
fn what_counts_as_a_method_is_the_bracket_and_where_it_is() {
let d = ok("classDiagram\nclass A{\n int amount\n draw()\n )odd\n}");
assert_eq!(rows(&d, "A").0, ["int amount", ")odd"]);
assert_eq!(rows(&d, "A").1, ["draw()"]);
}
#[test]
fn both_classifiers_are_read_in_both_documented_positions() {
let d = ok("classDiagram\nclass A{\n +run()*\n +run2() int*\n +go()$\n +go2() String$\n String field$\n}");
let a = d.class("A").unwrap();
let method_classifiers: Vec<Option<char>> = a.methods.iter().map(|m| m.classifier).collect();
assert_eq!(
method_classifiers,
[Some('*'), Some('*'), Some('$'), Some('$')],
"`someAbstractMethod()*` and `someAbstractMethod() int*` are the two documented spellings"
);
assert_eq!(a.attributes[0].classifier, Some('$'));
assert_eq!(a.attributes[0].name, "String field");
assert_eq!(
rows(&d, "A").1,
["+run()*", "+run2() : int*", "+go()$", "+go2() : String$"]
);
assert_eq!(rows(&d, "A").0, ["String field$"]);
}
#[test]
fn a_parameter_list_with_brackets_splits_where_upstream_splits_it() {
let m = parse_member("f(g(x))", true);
assert_eq!(m.name, "f(g");
assert_eq!(m.parameters, "x)");
let m = parse_member("setPoints(List~int~ points)", true);
assert_eq!(m.name, "setPoints");
assert_eq!(m.parameters, "List<int> points");
}
#[test]
fn generic_types_pair_from_the_outside_in() {
assert_eq!(parse_generic_types("List~List~int~~"), "List<List<int>>");
assert_eq!(parse_generic_types("List~int~"), "List<int>");
assert_eq!(parse_generic_types("plain"), "plain");
assert_eq!(parse_generic_types("~K~V~"), "~K<V>");
assert_eq!(parse_generic_types("Map~K, V~"), "Map<K, V>");
}
#[test]
fn every_documented_relationship_reads_as_the_grammar_says() {
let d = ok(
"classDiagram\nclassA <|-- classB\nclassC *-- classD\nclassE o-- classF\n\
classG <-- classH\nclassI -- classJ\nclassK <.. classL\nclassM <|.. classN\n\
classO .. classP",
);
let seen: Vec<(Marker, Marker, Line)> = d
.relations
.iter()
.map(|r| (r.start, r.end, r.line))
.collect();
assert_eq!(
seen,
[
(Marker::Extension, Marker::None, Line::Solid),
(Marker::Composition, Marker::None, Line::Solid),
(Marker::Aggregation, Marker::None, Line::Solid),
(Marker::Dependency, Marker::None, Line::Solid),
(Marker::None, Marker::None, Line::Solid),
(Marker::Dependency, Marker::None, Line::Dotted),
(Marker::Extension, Marker::None, Line::Dotted),
(Marker::None, Marker::None, Line::Dotted),
]
);
let d = ok(
"classDiagram\nclassA --|> classB\nclassC --* classD\nclassE --o classF\n\
classG --> classH\nclassK ..> classL\nclassM ..|> classN",
);
let seen: Vec<(Marker, Marker, Line)> = d
.relations
.iter()
.map(|r| (r.start, r.end, r.line))
.collect();
assert_eq!(
seen,
[
(Marker::None, Marker::Extension, Line::Solid),
(Marker::None, Marker::Composition, Line::Solid),
(Marker::None, Marker::Aggregation, Line::Solid),
(Marker::None, Marker::Dependency, Line::Solid),
(Marker::None, Marker::Dependency, Line::Dotted),
(Marker::None, Marker::Extension, Line::Dotted),
]
);
}
#[test]
fn a_relationship_can_carry_a_mark_at_both_ends() {
let d = ok("classDiagram\n Animal <|--|> Zebra\n A *--* B\n C o--o D");
let seen: Vec<(Marker, Marker)> = d.relations.iter().map(|r| (r.start, r.end)).collect();
assert_eq!(
seen,
[
(Marker::Extension, Marker::Extension),
(Marker::Composition, Marker::Composition),
(Marker::Aggregation, Marker::Aggregation),
]
);
}
#[test]
fn a_lollipop_is_read_at_either_end() {
let d = ok("classDiagram\n bar ()-- foo\n Class01 --() baz");
assert_eq!(d.relations[0].start, Marker::Lollipop);
assert_eq!(d.relations[0].end, Marker::None);
assert_eq!(d.relations[1].start, Marker::None);
assert_eq!(d.relations[1].end, Marker::Lollipop);
assert_eq!(d.class_ids(), ["bar", "foo", "Class01", "baz"]);
}
#[test]
fn a_cardinality_is_read_at_either_end_and_a_label_after_the_colon() {
let d = ok("classDiagram\n Customer \"1\" --> \"*\" Ticket\n \
Student \"1\" --> \"1..*\" Course\n Galaxy --> \"many\" Star : Contains");
let seen: Vec<(Option<&str>, Option<&str>, Option<&str>)> = d
.relations
.iter()
.map(|r| {
(
r.start_cardinality.as_deref(),
r.end_cardinality.as_deref(),
r.label.as_deref(),
)
})
.collect();
assert_eq!(
seen,
[
(Some("1"), Some("*"), None),
(Some("1"), Some("1..*"), None),
(None, Some("many"), Some("Contains")),
]
);
assert_eq!(
d.class_ids(),
["Customer", "Ticket", "Student", "Course", "Galaxy", "Star"]
);
}
#[test]
fn a_relationship_label_may_hold_the_characters_a_relationship_is_made_of() {
let d = ok(
"classDiagram\n classI -- classJ : Link(Solid)\n A --> B : 50%% done\n C --> D : a--b",
);
let labels: Vec<Option<&str>> = d.relations.iter().map(|r| r.label.as_deref()).collect();
assert_eq!(
labels,
[Some("Link(Solid)"), Some("50%% done"), Some("a--b")]
);
assert_eq!(d.class_ids(), ["classI", "classJ", "A", "B", "C", "D"]);
}
#[test]
fn the_o_of_an_aggregation_is_also_a_letter() {
let d = ok("classDiagram\n A --o orange");
assert_eq!(d.relations[0].end, Marker::Aggregation);
assert_eq!(
d.relations[0].to, "orange",
"konoma keeps the whole word; mermaid's longest-match lexer takes the `o` and leaves `range`"
);
let d = ok("classDiagram\n A --oops");
assert_eq!(
d.relations[0].end,
Marker::None,
"an `o` followed by a word character is part of the name"
);
assert_eq!(d.relations[0].to, "oops");
let d = ok("classDiagram\n Zoo -- Bar");
assert_eq!(d.relations[0].start, Marker::None);
assert_eq!(d.relations[0].from, "Zoo");
}
#[test]
fn a_long_run_of_dashes_is_one_line() {
let d = ok("classDiagram\n A --- B\n C ... D");
assert_eq!(d.class_ids(), ["A", "B", "C", "D"]);
assert_eq!(d.relations[0].line, Line::Solid);
assert_eq!(d.relations[1].line, Line::Dotted);
}
#[test]
fn the_relation_ceiling_holds() {
let mut src = String::from("classDiagram\n");
for i in 0..(MAX_RELATIONS_FOR_TESTS + 50) {
src.push_str(&format!(" n{i} --> m{i}\n"));
}
let d = ok(&src);
assert_eq!(d.relations.len(), MAX_RELATIONS_FOR_TESTS);
}
#[test]
fn a_namespace_holds_the_classes_written_inside_it() {
let d = ok("classDiagram\nnamespace BaseShapes {\n class Triangle\n class Rectangle {\n double width\n }\n}");
assert_eq!(d.namespaces.len(), 1);
assert_eq!(d.namespaces[0].id, "BaseShapes");
assert_eq!(d.namespaces[0].members, ["Triangle", "Rectangle"]);
assert_eq!(
d.class("Triangle").unwrap().parent.as_deref(),
Some("BaseShapes")
);
assert_eq!(rows(&d, "Rectangle").0, ["double width"]);
}
#[test]
fn a_namespace_can_carry_a_label() {
let d = ok("classDiagram\n namespace Auth[\"Authentication Service\"] {\n class UserService {\n +login()\n }\n }");
assert_eq!(d.namespaces[0].id, "Auth");
assert_eq!(d.namespaces[0].label, "Authentication Service");
}
#[test]
fn a_dotted_namespace_becomes_a_chain_of_frames() {
let d = ok("classDiagram\n namespace Company.Engineering.Backend {\n class Developer\n }\n namespace Company.Engineering {\n class TechLead\n }\n TechLead --> Developer : leads");
let ids: Vec<&str> = d.namespaces.iter().map(|n| n.id.as_str()).collect();
assert_eq!(
ids,
[
"Company",
"Company.Engineering",
"Company.Engineering.Backend"
]
);
assert_eq!(d.namespaces[0].label, "Company");
assert_eq!(d.namespaces[1].label, "Engineering");
assert_eq!(d.namespaces[2].label, "Backend");
assert_eq!(d.namespaces[1].parent.as_deref(), Some("Company"));
assert!(d.namespaces[1]
.members
.contains(&"Company.Engineering.Backend".to_string()));
assert!(d.namespaces[1].members.contains(&"TechLead".to_string()));
assert_eq!(
d.class("Developer").unwrap().parent.as_deref(),
Some("Company.Engineering.Backend")
);
}
#[test]
fn a_namespace_written_inside_another_is_qualified_by_it() {
let d = ok("classDiagram\n namespace Platform {\n namespace Auth {\n class UserService\n }\n class Gateway\n }");
let ids: Vec<&str> = d.namespaces.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, ["Platform", "Platform.Auth"]);
assert_eq!(
d.class("UserService").unwrap().parent.as_deref(),
Some("Platform.Auth")
);
assert_eq!(
d.class("Gateway").unwrap().parent.as_deref(),
Some("Platform")
);
}
#[test]
fn a_namespace_with_no_body_is_not_a_frame() {
let d = ok("classDiagram\n namespace Empty\n class A");
assert!(d.namespaces.is_empty());
assert_eq!(d.class_ids(), ["A"]);
}
#[test]
fn both_note_forms_are_read_and_a_target_binds_in_either_order() {
let d = ok("classDiagram\n note \"This is a general note\"\n note for MyClass \"This is a note for a class\"\n class MyClass{\n }");
assert_eq!(d.notes.len(), 2);
assert_eq!(d.notes[0].text, "This is a general note");
assert_eq!(d.notes[0].target, None);
assert_eq!(d.notes[1].target.as_deref(), Some("MyClass"));
let d = ok("classDiagram\n class MyClass\n note for MyClass \"after\"");
assert_eq!(d.notes[0].target.as_deref(), Some("MyClass"));
}
#[test]
fn a_note_for_a_class_that_never_appears_still_draws_but_ties_to_nothing() {
let d = ok("classDiagram\n class A\n note for Nobody \"orphan\"");
assert_eq!(d.notes.len(), 1);
assert_eq!(
d.notes[0].target, None,
"no tie-line to a class that is not there"
);
assert_eq!(d.class_ids(), ["A"], "and no class was invented for it");
}
#[test]
fn a_note_carries_line_breaks_the_way_every_other_label_does() {
let d = ok("classDiagram\n class Duck\n note for Duck \"can fly<br>can swim\"");
assert_eq!(d.notes[0].text, "can fly\ncan swim");
}
#[test]
fn nothing_that_is_not_a_class_becomes_one() {
let d = ok("classDiagram\n\
class Real\n\
classDef someclass fill:#f96\n\
classDef default fill:#f96,color:red\n\
cssClass \"Real\" someclass\n\
style Real fill:#f9f,stroke:#333,stroke-width:4px\n\
click Real href \"https://example.com\" \"tip\"\n\
click Real call callbackFunction() \"tip\"\n\
callback Real \"callbackFunction\" \"tip\"\n\
link Real \"https://example.com\" \"tip\"\n\
accTitle: an accessible title\n\
accDescr: an accessible description\n\
%% a whole-line comment\n\
direction LR\n\
Stray\n\
namespace Empty\n");
assert_eq!(
d.class_ids(),
["Real"],
"only the `class Real` line declares anything"
);
assert_eq!(d.acc_title.as_deref(), Some("an accessible title"));
assert_eq!(d.acc_descr.as_deref(), Some("an accessible description"));
assert_eq!(d.direction, Direction::LeftToRight);
assert!(d.relations.is_empty());
assert!(d.notes.is_empty());
}
#[test]
fn a_bare_word_declares_nothing() {
assert_eq!(
parse("classDiagram\n Animal").unwrap_err(),
ParseError::NoClasses
);
assert_eq!(
ok("classDiagram\n class Real\n Animal\n Vehicle").class_ids(),
["Real"]
);
}
#[test]
fn acc_descr_in_braces_is_swallowed_whole() {
let d = ok("classDiagram\n accDescr {\n a description\n over two lines\n }\n class A");
assert_eq!(
d.acc_descr.as_deref(),
Some("a description\nover two lines")
);
assert_eq!(d.class_ids(), ["A"]);
}
#[test]
fn a_comment_ends_a_statement_but_not_a_label() {
let d =
ok("classDiagram\n class A %% this is a comment\n A : +x %% and this\n B : 50%% done");
assert_eq!(d.class_ids(), ["A", "B"]);
assert_eq!(rows(&d, "A").0, ["+x %% and this"]);
assert_eq!(rows(&d, "B").0, ["50%% done"]);
}
#[test]
fn direction_is_a_statement_only_at_the_start_of_one() {
let d = ok("classDiagram\n A : go direction TB\n direction LR");
assert_eq!(d.direction, Direction::LeftToRight);
assert_eq!(rows(&d, "A").0, ["go direction TB"]);
}
#[test]
fn a_direction_inside_a_namespace_does_not_turn_the_diagram() {
let d = ok("classDiagram\n direction TB\n namespace N {\n direction LR\n class A\n }");
assert_eq!(d.direction, Direction::TopToBottom);
}
#[test]
fn errors_say_what_was_wrong() {
assert_eq!(
parse("classDiagram\n class A {\n +x")
.unwrap_err()
.to_string(),
"unclosed `class A {` opened at line 2"
);
assert_eq!(
parse("classDiagram\n namespace N {\n class A")
.unwrap_err()
.to_string(),
"unclosed `namespace N {` opened at line 2"
);
assert_eq!(
parse("classDiagram\n class A[\"unclosed")
.unwrap_err()
.to_string(),
"unclosed `\"` at line 2"
);
assert_eq!(
parse("classDiagram").unwrap_err().to_string(),
"class diagram declares no classes"
);
}
#[test]
fn awkward_sources_produce_a_model_or_an_error_and_never_a_panic() {
let mut deep = String::from("classDiagram\n");
for i in 0..40 {
deep.push_str(&format!("namespace n{i} {{\n"));
}
deep.push_str("class A\n");
for _ in 0..40 {
deep.push_str("}\n");
}
let cases: &[&str] = &[
"classDiagram\n class",
"classDiagram\n class ",
"classDiagram\n class ~~~",
"classDiagram\n class A~",
"classDiagram\n class ``",
"classDiagram\n class A[]",
"classDiagram\n class A[\"\"]",
"classDiagram\n --",
"classDiagram\n -->",
"classDiagram\n A -->",
"classDiagram\n --> B",
"classDiagram\n A --> A",
"classDiagram\n A <|--|> A",
"classDiagram\n }",
"classDiagram\n }}}}",
"classDiagram\n { }",
"classDiagram\n class A { }",
"classDiagram\n class A {}",
"classDiagram\n A : ",
"classDiagram\n A : ()",
"classDiagram\n A : )(",
"classDiagram\n A : +f(",
"classDiagram\n A : +f)",
"classDiagram\n A : ~",
"classDiagram\n A : $",
"classDiagram\n A : *",
"classDiagram\n <<>>",
"classDiagram\n <<x>>",
"classDiagram\n note",
"classDiagram\n note \"",
"classDiagram\n note for",
"classDiagram\n note for X",
"classDiagram\n 一 --> 二 : 🎉",
"classDiagram\n class 全画面プレビュー {\n +種別を解決()\n }",
"classDiagram\n namespace N { }",
"classDiagram\n A : #35;hash#quot;quote",
&deep,
];
for src in cases {
let _ = parse(src);
}
}