use crate::*;
use futures::executor::block_on;
use serde_json::json;
#[test]
fn parse_diagram_state_v2_alias_and_colon_description() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
state "Small State 1" as namedState1"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(res.meta.diagram_type, "stateDiagram");
assert_eq!(
res.model["states"]["namedState1"]["descriptions"][0],
json!("Small State 1")
);
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
namedState1 : Small State 1"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["states"]["namedState1"]["descriptions"][0],
json!("Small State 1")
);
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
namedState1:Small State 1"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["states"]["namedState1"]["descriptions"][0],
json!("Small State 1")
);
}
#[test]
fn parse_diagram_state_v2_multibyte_ids_do_not_panic() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
顧客 --> 完了: 送信
"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(res.model["relations"][0]["id1"], json!("顧客"));
assert_eq!(res.model["relations"][0]["id2"], json!("完了"));
assert_eq!(res.model["edges"][0]["label"], json!("送信"));
}
#[test]
fn parse_diagram_state_v2_preserves_colons_in_transition_labels() {
let res = block_on(Engine::new().parse_diagram(
r#"stateDiagram-v2
Active --> Deleted: DELETE /users/:id"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(res.model["edges"][0]["label"], json!("DELETE /users/:id"));
}
#[test]
fn parse_diagram_state_v2_groups_and_unsafe_ids() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
state "Small State 1" as namedState1
state "Big State 1" as bigState1 {
bigState1InternalState
}
namedState1 --> bigState1: should point to \nBig State 1 container
state "Small State 2" as namedState2
state bigState2 {
bigState2InternalState
}
namedState2 --> bigState2: should point to \nbigState2 container"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["states"]["bigState1"]["doc"][0]["id"],
json!("bigState1InternalState")
);
assert_eq!(
res.model["states"]["bigState2"]["doc"][0]["id"],
json!("bigState2InternalState")
);
assert_eq!(res.model["relations"][0]["id1"], json!("namedState1"));
assert_eq!(res.model["relations"][0]["id2"], json!("bigState1"));
assert_eq!(res.model["relations"][1]["id1"], json!("namedState2"));
assert_eq!(res.model["relations"][1]["id2"], json!("bigState2"));
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
[*] --> __proto__
__proto__ --> [*]"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert!(res.model["states"]["__proto__"].is_object());
assert!(res.model["states"]["root_start"].is_object());
assert!(res.model["states"]["root_end"].is_object());
}
#[test]
fn parse_diagram_state_v2_tracks_explicit_composite_direction() {
let res = block_on(Engine::new().parse_diagram(
r#"stateDiagram-v2
state Implicit {
A
}
state Explicit {
direction LR
B
}
"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
let nodes = res.model["nodes"].as_array().expect("state render nodes");
let node = |id: &str| {
nodes
.iter()
.find(|node| node["id"] == id)
.unwrap_or_else(|| panic!("missing state node {id}"))
};
assert_eq!(node("Implicit")["dir"], json!("TB"));
assert_eq!(node("Implicit")["explicitDir"], json!(false));
assert_eq!(node("Explicit")["dir"], json!("LR"));
assert_eq!(node("Explicit")["explicitDir"], json!(true));
assert!(node("A").get("explicitDir").is_none());
assert!(node("B").get("explicitDir").is_none());
}
#[test]
fn parse_state_render_model_preserves_alias_trailing_description() {
let input = r#"stateDiagram-v2
state "Display label" as S1: Trailing description
"#;
let parsed = Engine::new()
.parse_diagram_for_render_model_sync(input, ParseOptions::strict())
.unwrap()
.unwrap();
let RenderSemanticModel::State(model) = parsed.model() else {
panic!("expected typed state render model");
};
let node = model
.nodes
.iter()
.find(|node| node.id == "S1")
.expect("state render node S1");
assert_eq!(node.label, Some(json!("Display label")));
assert_eq!(
node.description.as_deref(),
Some(["Trailing description".to_string()].as_slice())
);
assert_eq!(node.shape, "rectWithTitle");
let parsed = block_on(Engine::new().parse_diagram(input, ParseOptions::strict()))
.unwrap()
.unwrap();
let json_node = parsed.model["nodes"]
.as_array()
.expect("JSON state render nodes")
.iter()
.find(|node| node["id"] == "S1")
.expect("JSON state render node S1");
assert_eq!(json_node.get("label"), node.label.as_ref());
assert_eq!(
json_node["description"],
json!(node.description.as_ref().expect("typed descriptions"))
);
assert_eq!(json_node["shape"].as_str(), Some(node.shape.as_str()));
}
#[test]
fn parse_state_render_model_tracks_explicit_composite_direction() {
let parsed = Engine::new()
.parse_diagram_for_render_model_sync(
r#"stateDiagram-v2
state Implicit {
A
}
state Explicit {
direction LR
B
}
"#,
ParseOptions::strict(),
)
.unwrap()
.unwrap();
let RenderSemanticModel::State(model) = parsed.model() else {
panic!("expected typed state render model");
};
let node = |id: &str| {
model
.nodes
.iter()
.find(|node| node.id == id)
.unwrap_or_else(|| panic!("missing state render node {id}"))
};
assert_eq!(node("Implicit").dir.as_deref(), Some("TB"));
assert_eq!(node("Implicit").explicit_dir, Some(false));
assert_eq!(node("Explicit").dir.as_deref(), Some("LR"));
assert_eq!(node("Explicit").explicit_dir, Some(true));
assert_eq!(node("A").explicit_dir, None);
assert_eq!(node("B").explicit_dir, None);
}
#[test]
fn parse_diagram_state_rejects_same_line_multi_word_composite_state_name() {
let engine = Engine::new();
let text = r#"stateDiagram-v2
state Invalid Name {
Idle
}
"#;
crate::diagrams::state::reset_state_syntax_construction_count();
let Error::DiagramParse { diagnostic, .. } =
block_on(engine.parse_diagram(text, ParseOptions::default())).unwrap_err()
else {
panic!("expected state parse error");
};
let offset = text.find("Name").unwrap();
assert!(
diagnostic
.message()
.contains("State name must be a single word")
);
assert_eq!(
diagnostic.span(),
Some(SourceSpan::new(offset, offset + "Name".len()))
);
assert_eq!(diagnostic.span_kind(), ParseDiagnosticSpanKind::Exact);
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"strict malformed State parsing must construct one lexical event tape"
);
crate::diagrams::state::reset_state_syntax_construction_count();
let facts = engine
.parse_editor_semantic_facts_with_type_sync("stateDiagram", text)
.expect("malformed State editor parse returns recovery facts")
.expect("malformed State editor facts are available");
assert_eq!(facts.completeness, EditorSemanticCompleteness::Recovered);
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"malformed State recovery must reuse one lexical event tape"
);
assert!(facts.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == EditorSemanticDiagnosticKind::ParserRecovery
&& diagnostic.span == Some(SourceSpan::new(offset, offset + "Name".len()))
}));
}
#[test]
fn parse_diagram_state_keeps_newline_composite_block_compatibility() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
state Valid
{
Idle
}
"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(res.model["states"]["Valid"]["doc"][0]["id"], json!("Idle"));
}
#[test]
fn parse_diagram_state_v2_classdef_class_and_shorthand() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
classDef exampleStyleClass background:#bbb,border:1.5px solid red;
a --> b:::exampleStyleClass
class a exampleStyleClass"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["styleClasses"]["exampleStyleClass"]["styles"][0],
json!("background:#bbb")
);
assert_eq!(
res.model["styleClasses"]["exampleStyleClass"]["styles"][1],
json!("border:1.5px solid red")
);
assert_eq!(
res.model["states"]["a"]["classes"][0],
json!("exampleStyleClass")
);
assert_eq!(
res.model["states"]["b"]["classes"][0],
json!("exampleStyleClass")
);
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
classDef exampleStyleClass background:#bbb,border:1px solid red;
[*]:::exampleStyleClass --> b"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["states"]["root_start"]["classes"][0],
json!("exampleStyleClass")
);
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
classDef exampleStyleClass background:#bbb,border:1px solid red;
a-->b
class a,b,c, d, e exampleStyleClass"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
for id in ["a", "b", "c", "d", "e"] {
assert_eq!(
res.model["states"][id]["classes"][0],
json!("exampleStyleClass")
);
}
}
#[test]
fn parse_diagram_state_v2_style_statement_sets_node_styles_and_ignores_comments() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
id1
id2
style id1,id2 background:#bbb, font-weight:bold, font-style:italic;"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["nodes"][0]["cssStyles"],
json!(["background:#bbb", "font-weight:bold", "font-style:italic"])
);
assert_eq!(
res.model["nodes"][1]["cssStyles"],
json!(["background:#bbb", "font-weight:bold", "font-style:italic"])
);
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
[*] --> Moving
Moving --> Still
Moving --> Crash
state Moving {
%% comment inside state
slow --> fast
}"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["states"]["Moving"]["doc"]
.as_array()
.unwrap()
.len(),
1
);
}
#[test]
fn parse_diagram_state_v2_click_and_href_store_links() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
S1
click S1 "https://example.com" "Go to Example""#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["links"]["S1"]["url"],
json!("https://example.com")
);
assert_eq!(res.model["links"]["S1"]["tooltip"], json!("Go to Example"));
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
S0
click S0 "https://example.com/empty" """#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(res.model["links"]["S0"]["tooltip"], json!(""));
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
S2
click S2 href "https://example.com""#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["links"]["S2"]["url"],
json!("https://example.com")
);
assert_eq!(res.model["links"]["S2"]["tooltip"], json!(""));
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
S3
click S3 href "javascript:alert(1)""#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["links"]["S3"]["url"],
json!("jav&fl°x61¶ßscript:alert(1)")
);
}
#[test]
fn parse_diagram_state_v2_repeated_clicks_preserve_declaration_order() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
S1
click S1 "https://example.com/first" "First"
click S1 "https://example.com/last" "Last""#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["links"]["S1"],
json!([
{
"url": "https://example.com/first",
"tooltip": "First"
},
{
"url": "https://example.com/last",
"tooltip": "Last"
}
])
);
}
#[test]
fn typed_state_links_preserve_declaration_order_and_empty_tooltips() {
let parsed = Engine::new()
.parse_diagram_for_render_model_sync(
r#"stateDiagram-v2
S1
S2
click S1 "https://example.com/first" "First"
click S1 "https://example.com/last" ""
click S2 href "https://example.com/omitted"
"#,
ParseOptions::strict(),
)
.unwrap()
.unwrap();
let RenderSemanticModel::State(model) = parsed.model() else {
panic!("expected typed state render model");
};
let crate::diagrams::state::StateDiagramRenderLinks::Many(repeated) =
model.links.get("S1").expect("S1 links")
else {
panic!("expected repeated S1 links");
};
assert_eq!(repeated.len(), 2);
assert_eq!(repeated[0].url, "https://example.com/first");
assert_eq!(repeated[0].tooltip, "First");
assert_eq!(repeated[1].url, "https://example.com/last");
assert_eq!(repeated[1].tooltip, "");
let crate::diagrams::state::StateDiagramRenderLinks::One(omitted) =
model.links.get("S2").expect("S2 link")
else {
panic!("expected one S2 link");
};
assert_eq!(omitted.url, "https://example.com/omitted");
assert_eq!(omitted.tooltip, "");
}
#[test]
fn parse_diagram_state_v2_note_right_of_and_block_note() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
[*] --> A
note right of A : This is a note"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["states"]["A"]["note"]["position"],
json!("right of")
);
assert_eq!(
res.model["states"]["A"]["note"]["text"],
json!("This is a note")
);
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
[*] --> A
note right of A
line1
line2
end note"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
let note_text = res.model["states"]["A"]["note"]["text"].as_str().unwrap();
assert_eq!(note_text, "line1\nline2");
let res = block_on(engine.parse_diagram(
r#"stateDiagram
foo: bar
note "This is a floating note" as N1"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert!(res.model["states"].get("N1").is_none());
}
#[test]
fn parse_diagram_state_note_closes_only_on_a_dedicated_end_note_line() {
let engine = Engine::new();
let text = r#"stateDiagram-v2
State1
note right of State1
this sentence contains end note as part of the note text
end note
State1 --> State2"#;
let res = block_on(engine.parse_diagram(text, ParseOptions::strict()))
.unwrap()
.unwrap();
assert_eq!(
res.model["states"]["State1"]["note"]["text"],
json!("this sentence contains end note as part of the note text")
);
let relations = res.model["relations"].as_array().unwrap();
assert_eq!(relations.len(), 1);
assert_eq!(relations[0]["id1"], json!("State1"));
assert_eq!(relations[0]["id2"], json!("State2"));
let facts = engine
.parse_editor_semantic_facts_with_type_sync("stateDiagram", text)
.unwrap()
.expect("state editor facts");
assert_eq!(facts.completeness, EditorSemanticCompleteness::Complete);
let embedded_marker = text.find("end note as part").unwrap();
assert!(facts.lexemes().iter().any(|lexeme| {
let span = lexeme.span();
lexeme.kind() == EditorLexemeKind::String
&& span.start <= embedded_marker
&& span.end >= embedded_marker + "end note".len()
}));
let closing_marker = text.rfind("end note").unwrap();
assert!(facts.lexemes().iter().any(|lexeme| {
let span = lexeme.span();
lexeme.kind() == EditorLexemeKind::Keyword
&& span.start == closing_marker
&& &text[span.start..span.end] == "end"
}));
}
#[test]
fn parse_diagram_state_v2_getdata_edges_and_note_edges() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
A --> B: hello"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(res.model["edges"][0]["start"], json!("A"));
assert_eq!(res.model["edges"][0]["end"], json!("B"));
assert_eq!(res.model["edges"][0]["label"], json!("hello"));
assert_eq!(res.model["edges"][0]["arrowhead"], json!("normal"));
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
[*] --> A
note left of A : note text"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
let note_edge = res.model["edges"]
.as_array()
.unwrap()
.iter()
.find(|e| e["arrowhead"] == json!("none"))
.unwrap();
assert_eq!(note_edge["classes"], json!("transition note-edge"));
}
#[test]
fn parse_diagram_state_v2_uses_neo_arrow_type_when_look_is_neo() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"%%{init: {"look": "neo"}}%%
stateDiagram-v2
A --> B: hello"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(
res.model["edges"][0]["arrowTypeEnd"],
json!("arrow_barb_neo")
);
}
#[test]
fn parse_diagram_state_v2_sanitizes_edge_labels_like_mermaid_common() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
A --> B: hello<script>alert(1)</script>world"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
assert_eq!(res.model["edges"][0]["label"], json!("helloworld"));
}
#[test]
fn parse_diagram_state_v2_getdata_dom_id_counter_and_note_padding_match_mermaid() {
let engine = Engine::new();
let res = block_on(engine.parse_diagram(
r#"stateDiagram-v2
A --> B
note right of A : note text"#,
ParseOptions::default(),
))
.unwrap()
.unwrap();
let nodes = res.model["nodes"].as_array().unwrap();
let node_a = nodes.iter().find(|n| n["id"] == json!("A")).unwrap();
let node_b = nodes.iter().find(|n| n["id"] == json!("B")).unwrap();
let note_group = nodes
.iter()
.find(|n| n["id"] == json!("A----parent"))
.unwrap();
let note_node = nodes
.iter()
.find(|n| n["id"] == json!("A----note-1"))
.unwrap();
assert_eq!(node_a["domId"], json!("state-A-1"));
assert_eq!(node_b["domId"], json!("state-B-0"));
assert_eq!(note_group["domId"], json!("state-A----parent-1"));
assert_eq!(note_node["domId"], json!("state-A----note-1"));
assert_eq!(note_group["padding"], json!(16));
assert_eq!(note_node["padding"], json!(15));
assert_eq!(note_node["parentId"], json!("A----parent"));
}
fn deep_state_composite_chain(depth: usize) -> String {
let mut input = String::from("stateDiagram-v2\n");
for level in 0..depth {
input.push_str(&format!("state S{level} {{\n"));
}
input.push_str("Leaf\n");
for _ in 0..depth {
input.push_str("}\n");
}
input
}
#[test]
fn state_deep_composite_chain_semantic_and_render_model_use_heap_traversal() {
const DEPTH: usize = 1200;
let input = deep_state_composite_chain(DEPTH);
let engine = Engine::new();
let parsed = block_on(engine.parse_diagram(&input, ParseOptions::strict()))
.expect("parse ok")
.expect("diagram detected");
assert_eq!(parsed.meta.diagram_type, "stateDiagram");
assert!(parsed.model["states"]["S0"]["doc"].is_array());
assert!(
parsed.model["nodes"]
.as_array()
.expect("nodes array")
.iter()
.any(|node| node["id"] == json!("Leaf"))
);
let parsed = engine
.parse_diagram_for_render_model_sync(&input, ParseOptions::strict())
.expect("render model parse ok")
.expect("diagram detected");
assert_eq!(parsed.metadata().diagram_type, "stateDiagram");
}
#[test]
fn state_family_entrypoints_construct_one_lexical_event_tape() {
let engine = Engine::new();
let input = concat!(
"stateDiagram-v2\n",
"state Idle\n",
"Idle --> Running: starts\n",
"classDef active fill:#0f0\n",
"class Running active\n",
);
crate::diagrams::state::reset_state_syntax_construction_count();
engine
.parse_diagram_sync(input, ParseOptions::strict())
.expect("State JSON parse succeeds")
.expect("State JSON parse returns a diagram");
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"the State JSON entrypoint must construct one lexical event tape"
);
crate::diagrams::state::reset_state_syntax_construction_count();
engine
.parse_diagram_for_render_model_sync(input, ParseOptions::strict())
.expect("State typed parse succeeds")
.expect("State typed parse returns a diagram");
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"the State typed entrypoint must construct one lexical event tape"
);
crate::diagrams::state::reset_state_syntax_construction_count();
engine
.parse_editor_semantic_facts_with_type_sync("stateDiagram", input)
.expect("State editor parse succeeds")
.expect("State editor parse returns facts");
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"the State editor entrypoint must construct one lexical event tape"
);
}
#[test]
fn state_combined_projection_constructs_once_and_matches_standalone_entrypoints() {
let engine = Engine::new();
let input = concat!(
"stateDiagram-v2\n",
"accTitle: Lifecycle\n",
"accDescr: State transitions\n",
"state \"Waiting\" as Idle\n",
"Idle --> Running: starts\n",
"note right of Running : Active work\n",
"classDef active fill:#0f0,border:#333\n",
"class Running active\n",
"click Running \"https://example.com/run\" \"Run details\"\n",
);
let standalone = engine
.parse_diagram_sync(input, ParseOptions::strict())
.expect("standalone State JSON parse succeeds")
.expect("standalone State JSON parse returns a diagram");
let standalone_editor = engine
.parse_editor_semantic_facts_with_type_sync("stateDiagram", input)
.expect("standalone State editor parse succeeds")
.expect("standalone State editor parse returns facts");
crate::diagrams::state::reset_state_syntax_construction_count();
let (combined_json, mut combined_editor) = crate::family::test_support::into_result(
crate::diagrams::state::parse_state_json_and_editor_facts(
input,
&standalone.meta,
&crate::ParseControl::new(),
),
)
.expect("combined State parse succeeds");
let family = crate::family::diagram_type_family_id(&standalone.meta.diagram_type)
.expect("State belongs to a catalog family");
combined_editor.finalize_lexemes(family, &[]);
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"one combined State request must construct syntax once"
);
assert_eq!(
combined_json, standalone.model,
"State JSON projection drift"
);
assert_eq!(
combined_editor, standalone_editor,
"State editor projection drift"
);
}
#[test]
fn state_typed_render_model_projects_exact_compatibility_json() {
let engine = Engine::new();
let input = concat!(
"stateDiagram-v2\n",
"direction LR\n",
"accTitle: Lifecycle\n",
"accDescr: State transitions\n",
"state \"Waiting\" as Idle\n",
"state Running {\n",
" direction LR\n",
" [*] --> Working\n",
" Working --> [*]\n",
"}\n",
"Idle --> Running: starts\n",
"note right of Running : Active work\n",
"classDef active fill:#0f0,border:#333\n",
"class Running active\n",
"click Running \"https://example.com/run\" \"Run details\"\n",
);
let compat = engine
.parse_diagram_sync(input, ParseOptions::strict())
.expect("State JSON parse succeeds")
.expect("State JSON parse returns a diagram")
.model;
let typed = engine
.parse_diagram_for_render_model_sync(input, ParseOptions::strict())
.expect("State typed parse succeeds")
.expect("State typed parse returns a diagram");
let RenderSemanticModel::State(model) = typed.model() else {
panic!("State typed parse returned another family");
};
assert_eq!(
crate::diagrams::state::render_model_to_compat_json(model, typed.metadata()).unwrap(),
compat
);
}
#[test]
fn parse_state_editor_facts_preserve_parser_state_spans() {
let engine = Engine::new();
let text = r#"stateDiagram-v2
[*] --> Idle
Idle --> Running
Idle: Waiting state
Idle --> Running: starts
state Running {
[*] --> Active
}
state "Paused State" as Paused
note right of Running : Running details
note "Floating note" as note1
classDef activeStyle fill:#0f0,border:#333
class Idle, Running activeStyle
style Running fill:#f00
accTitle: Lifecycle chart
accDescr: Shows state transitions
click Running "https://example.com/run" "Run details""#;
let facts = engine
.parse_editor_semantic_facts_with_type_sync("stateDiagram", text)
.unwrap()
.expect("state editor facts");
assert_eq!(facts.completeness, EditorSemanticCompleteness::Complete);
let symbol_at = |name: &str, start: usize| {
facts
.symbols
.iter()
.find(|symbol| symbol.name == name && symbol.selection.start == start)
.unwrap_or_else(|| panic!("missing symbol {name} at {start}"))
};
let idle_start = text.find("Idle").unwrap();
assert_eq!(
symbol_at("Idle", idle_start).selection.end,
idle_start + "Idle".len()
);
let running_start = text.find("Running").unwrap();
assert_eq!(
symbol_at("Running", running_start).selection.end,
running_start + "Running".len()
);
let idle_relation_source_start = text.find("Idle --> Running").unwrap();
assert!(facts.symbols.iter().any(|symbol| {
symbol.name == "Idle"
&& symbol.detail.as_deref() == Some("state reference")
&& symbol.selection.start == idle_relation_source_start
}));
let running_relation_target_start = text.find("Idle --> Running").unwrap() + "Idle --> ".len();
assert!(facts.symbols.iter().any(|symbol| {
symbol.name == "Running"
&& symbol.detail.as_deref() == Some("state reference")
&& symbol.selection.start == running_relation_target_start
}));
let active_start = text.find("Active").unwrap();
assert_eq!(
symbol_at("Active", active_start).selection.end,
active_start + "Active".len()
);
let paused_start = text.rfind("Paused").unwrap();
assert_eq!(
symbol_at("Paused", paused_start).selection.end,
paused_start + "Paused".len()
);
let display_label = facts
.symbols
.iter()
.find(|symbol| {
symbol.name == "Paused State" && symbol.detail.as_deref() == Some("state display label")
})
.unwrap();
assert_eq!(display_label.role, EditorSemanticRole::Payload);
let state_description = facts
.symbols
.iter()
.find(|symbol| {
symbol.name == "Waiting state" && symbol.detail.as_deref() == Some("state description")
})
.unwrap();
assert_eq!(state_description.role, EditorSemanticRole::Payload);
let relation_label = facts
.symbols
.iter()
.find(|symbol| {
symbol.name == "starts" && symbol.detail.as_deref() == Some("state relation label")
})
.unwrap();
assert_eq!(relation_label.role, EditorSemanticRole::Payload);
let positioned_note = facts
.symbols
.iter()
.find(|symbol| {
symbol.name == "Running details" && symbol.detail.as_deref() == Some("state note")
})
.unwrap();
assert_eq!(positioned_note.role, EditorSemanticRole::Payload);
let floating_note = facts
.symbols
.iter()
.find(|symbol| {
symbol.name == "Floating note" && symbol.detail.as_deref() == Some("state note")
})
.unwrap();
assert_eq!(floating_note.role, EditorSemanticRole::Payload);
assert!(!facts.symbols.iter().any(|symbol| symbol.name == "note1"));
let active_style_start = text.find("activeStyle").unwrap();
let active_style = facts
.symbols
.iter()
.find(|symbol| {
symbol.name == "activeStyle"
&& symbol.detail.as_deref() == Some("state class definition")
})
.unwrap();
assert_eq!(active_style.role, EditorSemanticRole::Outline);
assert_eq!(active_style.selection.start, active_style_start);
let idle_class_target = facts
.symbols
.iter()
.find(|symbol| {
symbol.name == "Idle" && symbol.detail.as_deref() == Some("state class target")
})
.unwrap();
assert_eq!(idle_class_target.role, EditorSemanticRole::Entity);
let running_style = facts
.symbols
.iter()
.find(|symbol| {
symbol.name == "fill:#f00" && symbol.detail.as_deref() == Some("state style")
})
.unwrap();
assert_eq!(running_style.role, EditorSemanticRole::Payload);
assert!(running_style.selection.start > running_style.span.start);
let acc_title = facts
.symbols
.iter()
.find(|symbol| symbol.name == "Lifecycle chart")
.unwrap();
assert_eq!(acc_title.role, EditorSemanticRole::Payload);
assert_eq!(
acc_title.detail.as_deref(),
Some("state accessibility title")
);
let acc_descr = facts
.symbols
.iter()
.find(|symbol| symbol.name == "Shows state transitions")
.unwrap();
assert_eq!(acc_descr.role, EditorSemanticRole::Payload);
assert_eq!(
acc_descr.detail.as_deref(),
Some("state accessibility description")
);
let click_url = facts
.symbols
.iter()
.find(|symbol| symbol.name == "https://example.com/run")
.unwrap();
assert_eq!(click_url.role, EditorSemanticRole::Payload);
assert_eq!(click_url.detail.as_deref(), Some("state click url"));
let click_tooltip = facts
.symbols
.iter()
.find(|symbol| symbol.name == "Run details")
.unwrap();
assert_eq!(click_tooltip.role, EditorSemanticRole::Payload);
assert_eq!(click_tooltip.detail.as_deref(), Some("state click tooltip"));
}
#[test]
fn parse_state_editor_facts_record_expected_syntax_spans() {
let engine = Engine::new();
let text = concat!(
"stateDiagram-v2\n",
"state \"Small State\" as namedState\n",
"namedState: Waiting state\n",
"classDef exampleStyleClass background:#bbb,border:1px solid red\n",
"a --> b:::exampleStyleClass\n",
"class namedState exampleStyleClass\n",
"style namedState fill:#f00\n",
"click namedState \"https://example.com/run\" \"Run details\"",
);
let facts = engine
.parse_editor_semantic_facts_with_type_sync("stateDiagram", text)
.unwrap()
.expect("state editor facts");
assert_eq!(facts.completeness, EditorSemanticCompleteness::Complete);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::Payload,
text,
"state \"Small State\" as namedState",
"Small State",
"state display label payload",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::NodeIdentifier,
text,
"state \"Small State\" as namedState",
"namedState",
"state alias node identifier",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::Payload,
text,
"namedState: Waiting state",
"Waiting state",
"state description payload",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::Payload,
text,
"classDef exampleStyleClass background:#bbb,border:1px solid red",
"exampleStyleClass",
"state class definition payload",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::NodeIdentifier,
text,
"a --> b:::exampleStyleClass",
"b",
"state inline class node identifier",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::Payload,
text,
"a --> b:::exampleStyleClass",
"exampleStyleClass",
"state inline class payload",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::IdList,
text,
"class namedState exampleStyleClass",
"namedState",
"state class target list",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::IdList,
text,
"style namedState fill:#f00",
"namedState",
"state style target list",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::Payload,
text,
"style namedState fill:#f00",
"fill:#f00",
"state style payload",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::NodeIdentifier,
text,
"click namedState \"https://example.com/run\" \"Run details\"",
"namedState",
"state click target",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::Payload,
text,
"click namedState \"https://example.com/run\" \"Run details\"",
"https://example.com/run",
"state click url",
);
assert_expected_syntax_covers(
&facts,
EditorExpectedSyntaxKind::Payload,
text,
"click namedState \"https://example.com/run\" \"Run details\"",
"Run details",
"state click tooltip",
);
}
#[test]
fn parse_state_editor_facts_recovers_from_incomplete_input() {
let engine = Engine::new();
let text = "stateDiagram-v2\nIdle --> Running\nRunning -->";
crate::diagrams::state::reset_state_syntax_construction_count();
let facts = engine
.parse_editor_semantic_facts_with_type_sync("stateDiagram", text)
.unwrap()
.expect("state editor facts");
assert_eq!(facts.completeness, EditorSemanticCompleteness::Recovered);
assert_eq!(facts.diagnostics.len(), 1);
let diagnostic = &facts.diagnostics[0];
assert!(diagnostic.message.contains("state parser recovered"));
assert_eq!(
diagnostic.span,
Some(SourceSpan::new(text.len(), text.len()))
);
assert!(facts.symbols.iter().any(|symbol| symbol.name == "Idle"));
assert!(facts.symbols.iter().any(|symbol| symbol.name == "Running"));
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"incomplete State editor recovery must reuse one lexical event tape"
);
crate::diagrams::state::reset_state_syntax_construction_count();
let Error::DiagramParse { diagnostic, .. } = engine
.parse_diagram_sync(text, ParseOptions::strict())
.expect_err("strict State parsing rejects the incomplete relation")
else {
panic!("incomplete State relation returned a non-parse error");
};
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"strict incomplete State parsing must construct one lexical event tape"
);
assert_eq!(
diagnostic.span(),
Some(SourceSpan::new(text.len(), text.len()))
);
assert_eq!(
diagnostic.span_kind(),
ParseDiagnosticSpanKind::InsertionPoint
);
}
#[test]
fn parse_state_editor_facts_stop_after_non_advancing_lexer_error() {
let engine = Engine::new();
let text = "stateDiagram-v2\nstate {\nIdle --> Running\n";
crate::diagrams::state::reset_state_syntax_construction_count();
let facts = engine
.parse_editor_semantic_facts_with_type_sync("stateDiagram", text)
.unwrap()
.expect("state editor facts");
assert_eq!(facts.completeness, EditorSemanticCompleteness::Recovered);
assert!(
facts
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("state parser recovered"))
);
assert_eq!(
crate::diagrams::state::state_syntax_construction_count(),
1,
"a non-advancing State lexer error must terminate the one shared event tape"
);
}
fn assert_expected_syntax_covers(
facts: &EditorSemanticFacts,
kind: EditorExpectedSyntaxKind,
text: &str,
marker: &str,
target: &str,
label: &str,
) {
let marker_start = text
.find(marker)
.unwrap_or_else(|| panic!("missing {label} source text"));
let target_start = text[marker_start..]
.find(target)
.map(|offset| marker_start + offset)
.unwrap_or_else(|| panic!("missing {label} target"));
let end = target_start + target.len();
assert!(
facts.expected_syntax.iter().any(|expected| {
expected.kind == kind && expected.span.start <= target_start && expected.span.end >= end
}),
"missing {label}"
);
}