merman-core 0.8.0

Mermaid parser + semantic model (headless; parity-focused).
Documentation
use super::*;

fn meta() -> ParseMetadata {
    ParseMetadata {
        diagram_type: "usecase".to_string(),
        config: crate::MermaidConfig::empty_object(),
        effective_config: crate::MermaidConfig::empty_object(),
        title: None,
    }
}
fn model(source: &str) -> UsecaseDiagramRenderModel {
    parse_usecase_model_for_render_controlled(source, &meta(), &OperationControl::new())
        .unwrap()
        .unwrap()
}
fn rejected(body: &str) -> Error {
    parse_usecase_model_for_render_controlled(
        &format!("usecase-beta\n{body}"),
        &meta(),
        &OperationControl::new(),
    )
    .unwrap()
    .unwrap_err()
}

#[test]
fn declaration_resolution_is_independent_of_relation_order() {
    let a = model("usecase-beta\nUser --> Login\nactor User\nLogin[Sign in]");
    let b = model("usecase-beta\nactor User\nLogin[Sign in]\nUser --> Login");
    assert_eq!(a, b);
    assert_eq!(a.nodes[0].kind, UsecaseNodeKind::Actor);
    assert_eq!(a.nodes[1].label, "Sign in");
    assert_eq!(a.nodes[1].kind, UsecaseNodeKind::UseCaseRect);
}

#[test]
fn boundaries_merge_equivalent_declarations_in_source_order() {
    let parsed = model(
        "usecase-beta\nsystemBoundary Auth(Authentication)@{type: package}:::external\nactor User(Person)@{type:hollow} <<Human>>\nactor User(Person)@{type:hollow} <<Human>>\nLogin[Sign in]\nend\nsystemBoundary Auth(Authentication)\nLogout\nend",
    );
    assert_eq!(parsed.boundaries[0].members, ["User", "Login", "Logout"]);
    assert!(parsed.boundaries[0].package);
    assert_eq!(parsed.nodes[0].actor_type, UsecaseActorType::Hollow);
    assert_eq!(parsed.nodes[0].parent_id.as_deref(), Some("Auth"));
}

#[test]
fn all_arrow_types_preserve_written_endpoint_order_and_minlen() {
    let parsed = model(
        "usecase-beta\nA --> B\nA <-- B\nA --- B\nA --o B\nA --x B\nA o-- B\nA x-- B\nA -- label ---> B\nA <-- label ---- B\nA ..>:InClUdE B\nA ..>:extend B\nA --|> B",
    );
    assert_eq!(
        parsed
            .relationships
            .iter()
            .take(7)
            .map(|edge| u8::from(edge.arrow_type))
            .collect::<Vec<_>>(),
        [0, 1, 2, 3, 4, 5, 6]
    );
    assert!(
        parsed
            .relationships
            .iter()
            .all(|edge| edge.source == "A" && edge.target == "B")
    );
    assert_eq!(parsed.relationships[2].minlen, 2);
    assert_eq!(parsed.relationships[7].minlen, 2);
    assert_eq!(parsed.relationships[8].minlen, 3);
    assert_eq!(parsed.relationships[9].label.as_deref(), Some("include"));
    assert!(parsed.relationships[9].dotted);
    assert_eq!(
        parsed.relationships[11].relationship_type,
        UsecaseRelationshipType::Generalization
    );
}

#[test]
fn multiline_markdown_metadata_json_and_accessibility_are_structured() {
    let parsed = model(
        "usecase-beta\r\naccTitle: Sign in\r\naccDescr {\r\n  first line\r\n  second line\r\n}\r\nactor User@{\r\ntype: hollow\r\n,business: true,\r\n}\r\nLogin(\"`**Sign in**\nsecurely`\")\njson Data@{\"10\":1,\"2\":{\"b\":2,\"a\":1},\"list\":[false,null]}\nData --> Login\nnote for Login \"`Please\ntry again`\"\n",
    );
    assert_eq!(parsed.nodes[1].label_type, UsecaseLabelType::Markdown);
    assert_eq!(parsed.nodes[1].label, "**Sign in**\nsecurely");
    assert_eq!(parsed.json_nodes[0].property_order[""], ["10", "2", "list"]);
    assert_eq!(parsed.json_nodes[0].property_order["/2"], ["b", "a"]);
    assert_eq!(parsed.notes[0].label_type, UsecaseLabelType::Markdown);
    assert_eq!(parsed.acc_title.as_deref(), Some("Sign in"));
    assert_eq!(
        parsed.acc_description.as_deref(),
        Some("first line\r\n  second line")
    );
}

#[test]
fn deferred_metadata_classes_and_styles_resolve_explicit_edges() {
    let parsed = model(
        "usecase-beta\nlink@{animation:fast}\nlink@{animate:false}\nclass A,link important,selected\nstyle link stroke-width:2px,stroke-dasharray:2\\,3\nclassDef important fill:red,border:1px solid blue\nA link@--> B\nA --> C\nactor User\nUser@{type:hollow}\n",
    );
    assert_eq!(parsed.relationships[0].id, "link");
    assert_eq!(parsed.relationships[1].id, "edge-0");
    assert_eq!(
        parsed.relationships[0].animation,
        Some(UsecaseAnimation::Fast)
    );
    assert!(parsed.relationships[0].animate);
    assert_eq!(parsed.relationships[0].classes, ["important", "selected"]);
    assert_eq!(
        parsed.relationships[0].styles,
        ["stroke-width:2px", "stroke-dasharray:2,3"]
    );
    assert_eq!(parsed.class_defs[0].styles[1], "border:1px solid blue");
}

#[test]
fn rejects_invalid_grammar_and_semantics_without_silent_fallbacks() {
    for body in [
        "actor A[Wrong]",
        "A --> B --> C",
        "A; B",
        "direction XX",
        "actor A %% inline",
        "systemBoundary S\nA --> B\nend",
        "systemBoundary S\nsystemBoundary T\nend\nend",
        "A(\"unterminated)",
        "A((nested))",
        "A@{business:1.5}",
        "A(A)@{business:true business:false}",
        "A ..> B",
        "A <.. B",
        "A --> B: label",
        "package S",
        "note for Missing note",
        "actor Shared\nShared",
        "A(First)\nA(Second)",
        "\"A B\"\n\"A-B\"",
        "actor User@{icon:\"fa:user\",type:hollow}",
        "A[Rect]@{business:true}",
        "actor A\nA ..>:include B",
        "actor A\nA --|> B",
        "json J@{}\nA --o J",
        "systemBoundary S\nend\nA --> S",
        "A:::paint --> B",
        "Ghost@{business:true}",
        "A e@--> B\nB e@--> C",
        "actor e\nA e@--> B",
        "class Ghost paint",
        "style Ghost fill:red",
    ] {
        let error = rejected(body);
        assert!(
            matches!(error, Error::DiagramParse { .. }),
            "{body}: {error}"
        );
    }
}

#[test]
fn editor_facts_have_exact_declarations_references_and_failure_spans() {
    let source = "usecase-beta\nactor User\nUser --> Login\nnote for Login \"Try again\"";
    let construction = construct(source, &meta(), &OperationControl::new())
        .unwrap()
        .unwrap_or_else(|failure| panic!("{}", failure.into_error()));
    let user: Vec<_> = construction
        .editor_facts
        .symbols
        .iter()
        .filter(|symbol| symbol.name == "User")
        .collect();
    assert_eq!(user.len(), 2);
    assert_eq!(
        &source[user[0].selection.start..user[0].selection.end],
        "User"
    );
    assert_eq!(user[0].role, crate::EditorSemanticRole::Entity);
    assert_eq!(user[1].role, crate::EditorSemanticRole::Reference);
    assert_eq!(user[1].kind, crate::EditorSemanticKind::Variable);
    let login: Vec<_> = construction
        .editor_facts
        .symbols
        .iter()
        .filter(|symbol| symbol.name == "Login")
        .collect();
    assert_eq!(login[0].role, crate::EditorSemanticRole::Entity);
    assert_eq!(login[1].role, crate::EditorSemanticRole::Reference);
    let bad = "usecase-beta\nactor User\nA -->";
    let failure = construct(bad, &meta(), &OperationControl::new())
        .unwrap()
        .err()
        .unwrap();
    let (error, facts) = failure.into_parts();
    assert!(facts.symbols.iter().any(|symbol| symbol.name == "User"));
    assert_eq!(
        facts.completeness,
        crate::EditorSemanticCompleteness::Recovered
    );
    let Error::DiagramParse { diagnostic, .. } = error else {
        panic!("expected structured parse diagnostic")
    };
    assert_eq!(
        diagnostic.span(),
        Some(SourceSpan::new(bad.len(), bad.len()))
    );
}

#[test]
fn generated_names_follow_javascript_utf16_and_are_not_renameable() {
    let source = "usecase-beta\n\"😀 login\"";
    let parsed = construct(source, &meta(), &OperationControl::new())
        .unwrap()
        .unwrap_or_else(|failure| panic!("{}", failure.into_error()));
    assert_eq!(parsed.model.nodes[0].id, "___login");
    assert_eq!(
        parsed.editor_facts.symbols[0].rename_policy,
        crate::EditorRenamePolicy::None
    );
}

#[test]
fn cancelled_parse_does_not_publish_partial_model() {
    let control = OperationControl::new();
    control.cancel();
    assert!(
        parse_usecase_model_for_render_controlled("usecase-beta\nactor A", &meta(), &control)
            .is_err()
    );
}

#[test]
fn json_infinity_presentation_survives_typed_roundtrip_without_changing_compat_json() {
    let parsed = model(
        r#"usecase-beta
json Data@{"positive":1e309,"negative":-1e309,"null":null}
"#,
    );
    let encoded = serde_json::to_value(&parsed).unwrap();
    assert_eq!(
        encoded["json_nodes"][0]["nonFiniteNumbers"]["/positive"],
        "positive"
    );
    assert_eq!(
        encoded["json_nodes"][0]["nonFiniteNumbers"]["/negative"],
        "negative"
    );
    assert_eq!(
        serde_json::from_value::<UsecaseDiagramRenderModel>(encoded).unwrap(),
        parsed
    );
    let compat = render_model_to_compat_json(&parsed, &meta()).unwrap();
    assert_eq!(
        compat["jsonNodes"][0]["value"],
        serde_json::json!({"positive":null,"negative":null,"null":null})
    );
    assert!(compat["jsonNodes"][0].get("nonFiniteNumbers").is_none());
    let old: UsecaseJsonNode = serde_json::from_value(compat["jsonNodes"][0].clone()).unwrap();
    assert!(old.non_finite_numbers.is_empty());
    assert!(
        serde_json::to_value(old)
            .unwrap()
            .get("nonFiniteNumbers")
            .is_none()
    );
}

#[test]
fn stereotype_uses_ecmascript_trim_and_excludes_u0085() {
    let trimmed = model("usecase-beta\nA <<\u{feff} Human \u{3000}>>");
    assert_eq!(trimmed.nodes[0].stereotype.as_deref(), Some("Human"));
    let preserved = model("usecase-beta\nA <<\u{0085}>>");
    assert_eq!(preserved.nodes[0].stereotype.as_deref(), Some("\u{0085}"));
    assert!(
        parse_usecase_model_for_render_controlled(
            "usecase-beta\nA <<\u{feff}>>",
            &meta(),
            &OperationControl::new(),
        )
        .unwrap()
        .is_err()
    );
}