From b9f1225e33687c45f2a2126e224ace36e9322546 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Mon, 18 May 2026 07:18:15 +0200 Subject: [PATCH] feat(mermaid): classDiagram + requirementDiagram emitters + CLI flags (v0.10.x M3) Co-Authored-By: Claude Opus 4.7 --- artifacts/requirements.yaml | 28 ++ artifacts/verification.yaml | 46 ++ crates/spar-cli/src/main.rs | 53 ++- crates/spar-cli/tests/emit_mermaid.rs | 91 +++- crates/spar-mermaid/src/lib.rs | 582 +++++++++++++++++++++++++- 5 files changed, 782 insertions(+), 18 deletions(-) diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index 586bb5e..894bfb8 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -2118,3 +2118,31 @@ artifacts: tags: [ci, rivet, verification, v0100] # Research findings tracked separately in research/findings.yaml + + # ── Mermaid M3 (classDiagram + requirementDiagram) ───────────────────── + + - id: REQ-MERMAID-CLASS-001 + type: requirement + title: classDiagram emitter for AADL component types + description: > + spar-mermaid shall provide an emit_class_diagram function that walks a + SystemInstance and produces a Mermaid classDiagram with one class block + per distinct component type, showing the AADL category as a Mermaid + stereotype and listing each FeatureInstance as a class attribute. + The emitter shall honour the categories and max_depth filters from + MermaidOptions. + status: implemented + tags: [mermaid, emission, v0100] + + - id: REQ-MERMAID-REQ-001 + type: requirement + title: requirementDiagram emitter from rivet artifacts + description: > + spar-mermaid shall provide an emit_requirement_diagram function that + reads artifacts/requirements.yaml and produces a Mermaid + requirementDiagram with one block per requirement/feature artifact and + relationship edges (satisfies, verifies, derives) derived from the + artifact links section. Artifacts with type design-decision or other + non-requirement types shall be silently skipped. + status: implemented + tags: [mermaid, emission, v0100] diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index a83f026..78d5048 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -2771,3 +2771,49 @@ artifacts: links: - type: satisfies target: REQ-VERIFY-GATE-001 + + # ── Mermaid M3 ────────────────────────────────────────────────────────── + + - id: TEST-MERMAID-CLASS + type: feature + title: classDiagram emitter unit + integration tests + description: > + Unit tests in crates/spar-mermaid/src/lib.rs verify: (a) one class + block is emitted per distinct component type_name; (b) the AADL category + appears as a Mermaid stereotype (<>, <>, etc.); (c) the + categories filter excludes components whose category is not listed. + Integration test in crates/spar-cli/tests/emit_mermaid.rs invokes + spar emit --format mermaid-class and asserts classDiagram header plus + at least one stereotype. + fields: + method: automated-test + steps: + - run: cargo test -p spar-mermaid + - run: cargo test -p spar --test emit_mermaid emit_mermaid_class_happy_path + status: passing + tags: [mermaid, emission, v0100] + links: + - type: satisfies + target: REQ-MERMAID-CLASS-001 + + - id: TEST-MERMAID-REQ + type: feature + title: requirementDiagram emitter unit + integration tests + description: > + Unit tests in crates/spar-mermaid/src/lib.rs verify: (a) the + requirementDiagram header and at least one requirement block are present; + (b) satisfies edges appear in output for linked requirements; (c) + artifacts with type design-decision are excluded from the diagram. + Integration test in crates/spar-cli/tests/emit_mermaid.rs invokes + spar emit --format mermaid-req from the workspace root and asserts + requirementDiagram header plus at least one REQ_*_001 block. + fields: + method: automated-test + steps: + - run: cargo test -p spar-mermaid + - run: cargo test -p spar --test emit_mermaid emit_mermaid_req_happy_path + status: passing + tags: [mermaid, emission, v0100] + links: + - type: satisfies + target: REQ-MERMAID-REQ-001 diff --git a/crates/spar-cli/src/main.rs b/crates/spar-cli/src/main.rs index f231304..fec2131 100644 --- a/crates/spar-cli/src/main.rs +++ b/crates/spar-cli/src/main.rs @@ -106,8 +106,13 @@ fn print_usage() { ); eprintln!(" modes --root Package::Type.Impl [--format text|smv|dot] "); eprintln!( - " emit --root Package::Type.Impl [--format mermaid] [--category ] \ - [--max-depth N] [--no-connections] [-o output.md] " + " emit [--format mermaid|mermaid-class|mermaid-req] \ + [--root Package::Type.Impl] [--category ] \ + [--max-depth N] [--no-connections] [-o output.md] []\n\ + \n\ + \t\t mermaid flowchart TD (default)\n\ + \t\t mermaid-class classDiagram of component types\n\ + \t\t mermaid-req requirementDiagram from artifacts/requirements.yaml" ); eprintln!(" render --root Package::Type.Impl [-o output.svg] "); eprintln!( @@ -1316,7 +1321,9 @@ fn parse_category(s: &str) -> spar_hir_def::item_tree::ComponentCategory { fn cmd_emit(args: &[String]) { let mut root: Option = None; - let mut format: Option = None; + // Default format is "mermaid" (flowchart). Also accepts "mermaid-class" + // and "mermaid-req". + let mut format: String = "mermaid".to_string(); let mut categories: Vec = Vec::new(); let mut max_depth: Option = None; let mut include_connections = true; @@ -1338,14 +1345,20 @@ fn cmd_emit(args: &[String]) { "--format" => { i += 1; if i < args.len() { - let f = args[i].clone(); - if f != "mermaid" { - eprintln!("--format only supports 'mermaid' (got '{f}')"); - process::exit(1); + let f = args[i].as_str(); + match f { + "mermaid" | "mermaid-class" | "mermaid-req" => { + format = f.to_string(); + } + other => { + eprintln!( + "--format only supports 'mermaid' | 'mermaid-class' | 'mermaid-req' (got '{other}')" + ); + process::exit(1); + } } - format = Some(f); } else { - eprintln!("--format requires a value (mermaid)"); + eprintln!("--format requires a value (mermaid|mermaid-class|mermaid-req)"); process::exit(1); } } @@ -1399,8 +1412,17 @@ fn cmd_emit(args: &[String]) { i += 1; } - let _ = format; // "mermaid" is the only valid value; validated above. + // ── mermaid-req: no AADL files needed ─────────────────────────────────── + if format == "mermaid-req" { + let req_path = std::path::Path::new("artifacts/requirements.yaml"); + let diagram = spar_mermaid::emit_requirement_diagram(req_path).unwrap_or_else(|e| { + eprintln!("error: {e}"); + process::exit(1); + }); + return emit_output(diagram, output); + } + // ── mermaid / mermaid-class: require --root and AADL files ────────────── let root = root.unwrap_or_else(|| { eprintln!("--root Package::Type.Impl is required"); process::exit(1); @@ -1450,8 +1472,17 @@ fn cmd_emit(args: &[String]) { include_connections, }; - let diagram = spar_mermaid::emit_flowchart(&inst, &opts); + let diagram = if format == "mermaid-class" { + spar_mermaid::emit_class_diagram(&inst, &opts) + } else { + spar_mermaid::emit_flowchart(&inst, &opts) + }; + + emit_output(diagram, output); +} +/// Write `diagram` to `output` path or stdout. +fn emit_output(diagram: String, output: Option) { match output { Some(path) => { fs::write(&path, &diagram).unwrap_or_else(|e| { diff --git a/crates/spar-cli/tests/emit_mermaid.rs b/crates/spar-cli/tests/emit_mermaid.rs index c7ecd63..e5ec1a6 100644 --- a/crates/spar-cli/tests/emit_mermaid.rs +++ b/crates/spar-cli/tests/emit_mermaid.rs @@ -1,6 +1,5 @@ -//! Integration tests for `spar emit --format mermaid`. -//! -//! Tests the happy-path flowchart emission and the `--category` filter. +//! Integration tests for `spar emit --format mermaid` (M2) and the M3 +//! extensions `mermaid-class` and `mermaid-req`. use std::env; use std::fs; @@ -145,3 +144,89 @@ fn emit_mermaid_category_filter_excludes_non_thread() { let _ = fs::remove_file(&path); } + +// ── M3: mermaid-class ─────────────────────────────────────────────────────── + +/// `spar emit --format mermaid-class --root ...` should produce a Mermaid +/// classDiagram containing the header and at least one stereotype. +#[test] +fn emit_mermaid_class_happy_path() { + let path = write_model("class_happy"); + + let output = spar() + .arg("emit") + .arg("--root") + .arg("Emit_Test::TestSys.Impl") + .arg("--format") + .arg("mermaid-class") + .arg(&path) + .output() + .expect("failed to run spar emit --format mermaid-class"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "spar emit --format mermaid-class failed; stderr:\n{stderr}" + ); + + assert!( + stdout.starts_with("classDiagram\n"), + "expected 'classDiagram' header; got:\n{stdout}" + ); + + // At least one stereotype should be present. + assert!( + stdout.contains("<<"), + "expected at least one stereotype (<<...>>) in output; got:\n{stdout}" + ); + + let _ = fs::remove_file(&path); +} + +// ── M3: mermaid-req ───────────────────────────────────────────────────────── + +/// `spar emit --format mermaid-req` should produce a Mermaid requirementDiagram +/// containing the header and at least one of the well-known REQ-* IDs from +/// artifacts/requirements.yaml (present in repo root). +#[test] +fn emit_mermaid_req_happy_path() { + // mermaid-req does not require AADL files or --root. + let output = spar() + .arg("emit") + .arg("--format") + .arg("mermaid-req") + .current_dir( + // Run from the workspace root so "artifacts/requirements.yaml" resolves. + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(), + ) + .output() + .expect("failed to run spar emit --format mermaid-req"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "spar emit --format mermaid-req failed; stderr:\n{stderr}" + ); + + assert!( + stdout.starts_with("requirementDiagram\n"), + "expected 'requirementDiagram' header; got:\n{stdout}" + ); + + // At least one well-known REQ should appear (sanitised hyphens → underscores). + let has_known_req = stdout.contains("REQ_PARSE_001") + || stdout.contains("REQ_MODEL_001") + || stdout.contains("REQ_MERMAID"); + assert!( + has_known_req, + "expected at least one well-known REQ_* block; got:\n{stdout}" + ); +} diff --git a/crates/spar-mermaid/src/lib.rs b/crates/spar-mermaid/src/lib.rs index 695facc..601b7d2 100644 --- a/crates/spar-mermaid/src/lib.rs +++ b/crates/spar-mermaid/src/lib.rs @@ -1,9 +1,13 @@ //! Mermaid diagram emission for spar AADL instance models. //! -//! This crate provides a foundation-level `flowchart TD` emitter that walks a -//! [`spar_hir_def::instance::SystemInstance`] and produces Mermaid markup. -//! It intentionally covers only `flowchart` emission; `classDiagram`, -//! `requirementDiagram`, and `block-beta` are follow-on work. +//! This crate provides: +//! - A `flowchart TD` emitter ([`emit_flowchart`]) that walks a +//! [`spar_hir_def::instance::SystemInstance`] and produces Mermaid markup. +//! - A `classDiagram` emitter ([`emit_class_diagram`]) that produces one class +//! per component type (with stereotypes and feature attributes). +//! - A `requirementDiagram` emitter ([`emit_requirement_diagram`]) that parses +//! a rivet `requirements.yaml` and produces Mermaid requirement blocks and +//! relationship edges. //! //! # Cyclic-containment assumption //! @@ -19,6 +23,7 @@ use spar_hir_def::instance::{ComponentInstanceIdx, SystemInstance}; use spar_hir_def::item_tree::ComponentCategory; use spar_hir_def::name::Name; use std::collections::HashMap; +use std::path::Path; /// Options controlling which parts of a [`SystemInstance`] are emitted. #[derive(Debug, Clone)] @@ -163,6 +168,398 @@ pub fn emit_flowchart(instance: &SystemInstance, opts: &MermaidOptions) -> Strin out } +// ── classDiagram emitter ───────────────────────────────────────────────────── + +/// Emit a `classDiagram` Mermaid diagram for the given [`SystemInstance`]. +/// +/// Each component that passes the filters in `opts` becomes one Mermaid `class` +/// block. The class name is the component's `type_name`. The AADL category is +/// shown as a stereotype (`<>`, `<>`, etc.). Each feature on +/// the component instance is listed as an attribute of the form +/// `+direction kind name` (direction is omitted when `None`). +/// +/// Only components whose `type_name` has not yet been emitted are output; if +/// multiple instances share the same type they produce a single class block. +/// Inheritance arrows (`--|>`) are emitted when `impl_name` is `None` (type +/// reference only) and the parent component has a different `type_name`, +/// mirroring an "implements / extends" relationship. +pub fn emit_class_diagram(instance: &SystemInstance, opts: &MermaidOptions) -> String { + let depths = compute_depths(instance); + + // Determine which components pass the filters. + let mut seen_types: std::collections::HashSet = std::collections::HashSet::new(); + let mut class_lines: Vec = Vec::new(); + let mut relation_lines: Vec = Vec::new(); + + // Collect filtered components sorted for deterministic output. + let mut filtered: Vec = instance + .all_components() + .filter(|(idx, comp)| { + if !opts.categories.is_empty() && !opts.categories.contains(&comp.category) { + return false; + } + if let Some(max_d) = opts.max_depth { + let d = depths.get(idx).copied().unwrap_or(0); + if d > max_d { + return false; + } + } + true + }) + .map(|(idx, _)| idx) + .collect(); + + // Sort by type_name for deterministic output. + filtered.sort_by_key(|idx| instance.component(*idx).type_name.as_str().to_string()); + + for idx in &filtered { + let comp = instance.component(*idx); + let type_name = comp.type_name.as_str().to_string(); + + if seen_types.contains(&type_name) { + continue; + } + seen_types.insert(type_name.clone()); + + let stereotype = category_stereotype(comp.category); + let mut block = format!(" class {} {{\n", sanitize_id(&type_name)); + block.push_str(&format!(" <<{}>>\n", stereotype)); + + // Emit features as attributes. + for &feat_idx in &comp.features { + let feat = &instance.features[feat_idx]; + let dir_str = match feat.direction { + Some(dir) => format!("{} ", dir), + None => String::new(), + }; + block.push_str(&format!( + " +{}{} {}\n", + dir_str, + feat.kind, + feat.name.as_str() + )); + } + block.push_str(" }"); + class_lines.push(block); + + // Emit inheritance arrow from child type to parent type when the parent + // has a distinct type. + if let Some(parent_idx) = comp.parent { + let parent_comp = instance.component(parent_idx); + let parent_type = sanitize_id(parent_comp.type_name.as_str()); + let child_type = sanitize_id(&type_name); + if parent_type != child_type { + let arrow = format!(" {} --|> {}", child_type, parent_type); + if !relation_lines.contains(&arrow) { + relation_lines.push(arrow); + } + } + } + } + + let mut out = String::from("classDiagram\n"); + for line in &class_lines { + out.push_str(line); + out.push('\n'); + } + relation_lines.sort(); + relation_lines.dedup(); + for line in &relation_lines { + out.push_str(line); + out.push('\n'); + } + out +} + +/// Map a [`ComponentCategory`] to the Mermaid stereotype string (without angle brackets). +fn category_stereotype(cat: ComponentCategory) -> &'static str { + match cat { + ComponentCategory::System => "system", + ComponentCategory::Process => "process", + ComponentCategory::Thread => "thread", + ComponentCategory::ThreadGroup => "thread group", + ComponentCategory::Processor => "processor", + ComponentCategory::VirtualProcessor => "virtual processor", + ComponentCategory::Memory => "memory", + ComponentCategory::Bus => "bus", + ComponentCategory::VirtualBus => "virtual bus", + ComponentCategory::Device => "device", + ComponentCategory::Subprogram => "subprogram", + ComponentCategory::SubprogramGroup => "subprogram group", + ComponentCategory::Data => "data", + ComponentCategory::Abstract => "abstract", + } +} + +// ── requirementDiagram emitter ─────────────────────────────────────────────── + +/// Error type for [`emit_requirement_diagram`]. +#[derive(Debug)] +pub enum MermaidError { + /// The requirements YAML file could not be read. + Io(std::io::Error), +} + +impl std::fmt::Display for MermaidError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MermaidError::Io(e) => write!(f, "IO error reading requirements YAML: {e}"), + } + } +} + +impl std::error::Error for MermaidError {} + +impl From for MermaidError { + fn from(e: std::io::Error) -> Self { + MermaidError::Io(e) + } +} + +/// Emit a `requirementDiagram` Mermaid diagram from a rivet requirements YAML file. +/// +/// Parses `yaml_path` using the same line-oriented parser as +/// `spar_sysml2::generate::parse_rivet_yaml`. Only artifacts whose `type` is +/// one of `requirement`, `feature`, `functional-requirement`, or +/// `performance-requirement` are emitted as requirement blocks. All other +/// artifact types (e.g. `design-decision`) are skipped. +/// +/// Relationship edges (`satisfies`, `verifies`, `derives`, `refines`, `traces`) +/// found in the `links:` section are emitted as Mermaid requirement relationship +/// lines: ` - -> `. +/// +/// The Mermaid `requirementDiagram` grammar used here: +/// ```text +/// requirementDiagram +/// +/// requirement REQ_ID { +/// id: "REQ-ID" +/// text: "Title" +/// risk: undefined +/// verifymethod: analysis +/// } +/// +/// REQ_A - satisfies -> REQ_B +/// ``` +/// +/// Note: requirement `id` node names must be valid Mermaid identifiers (no +/// hyphens). This function sanitises IDs by replacing `-` with `_`. +pub fn emit_requirement_diagram(yaml_path: &Path) -> Result { + let content = std::fs::read_to_string(yaml_path)?; + Ok(emit_requirement_diagram_from_str(&content)) +} + +/// Inner implementation that works on an already-loaded YAML string. +/// Exposed for unit-test convenience (no file I/O). +pub fn emit_requirement_diagram_from_str(yaml: &str) -> String { + // ── 1. Parse artifacts ────────────────────────────────────────────────── + let artifacts = parse_req_yaml(yaml); + + // ── 2. Determine which artifact types produce a requirement block ─────── + let is_req_type = |t: &str| { + matches!( + t, + "requirement" + | "feature" + | "functional-requirement" + | "performance-requirement" + | "functionalRequirement" + | "performanceRequirement" + ) + }; + + // Collect the set of emitted IDs (sanitised) for edge validation. + let emitted_ids: std::collections::HashSet = artifacts + .iter() + .filter(|a| is_req_type(&a.artifact_type)) + .map(|a| sanitize_req_id(&a.id)) + .collect(); + + let mut out = String::from("requirementDiagram\n\n"); + + // ── 3. Emit requirement blocks ────────────────────────────────────────── + for artifact in &artifacts { + if !is_req_type(&artifact.artifact_type) { + continue; + } + let req_keyword = match artifact.artifact_type.as_str() { + "feature" | "functional-requirement" | "functionalRequirement" => { + "functionalRequirement" + } + "performance-requirement" | "performanceRequirement" => "performanceRequirement", + _ => "requirement", + }; + let node_id = sanitize_req_id(&artifact.id); + // Truncate description to first sentence for the `text` field. + let text = artifact + .title + .replace('"', "'") + .chars() + .take(120) + .collect::(); + out.push_str(&format!(" {} {} {{\n", req_keyword, node_id)); + out.push_str(&format!(" id: \"{}\"\n", artifact.id)); + out.push_str(&format!(" text: \"{}\"\n", text)); + out.push_str(" risk: undefined\n"); + out.push_str(" verifymethod: analysis\n"); + out.push_str(" }\n\n"); + } + + // ── 4. Emit relationship edges ────────────────────────────────────────── + let mut edges: Vec = Vec::new(); + for artifact in &artifacts { + if !is_req_type(&artifact.artifact_type) { + continue; + } + let src_id = sanitize_req_id(&artifact.id); + for link in &artifact.links { + let rel = match link.link_type.as_str() { + "satisfies" => "satisfies", + "verifies" => "verifies", + "derives" => "derives", + "refines" => "refines", + "traces" => "traces", + _ => continue, + }; + let dst_id = sanitize_req_id(&link.target); + // Only emit if both endpoints were emitted. + if emitted_ids.contains(&dst_id) { + edges.push(format!(" {} - {} -> {}", src_id, rel, dst_id)); + } + } + } + edges.sort(); + edges.dedup(); + for edge in &edges { + out.push_str(edge); + out.push('\n'); + } + + out +} + +/// Sanitise a rivet artifact ID for use as a Mermaid requirement node name. +/// +/// Replaces `-` with `_` so that `REQ-PARSE-001` becomes `REQ_PARSE_001`. +fn sanitize_req_id(id: &str) -> String { + id.replace('-', "_") +} + +// ── Minimal YAML parser (requirement artifacts only) ────────────────────────── + +/// A minimal parsed artifact (id, type, title, links). +struct ReqArtifact { + id: String, + artifact_type: String, + title: String, + links: Vec, +} + +struct ReqLink { + link_type: String, + target: String, +} + +/// Parse rivet YAML for requirement-diagram purposes. +/// +/// This is a line-oriented parser that mirrors the logic in +/// `spar_sysml2::generate::parse_rivet_yaml` but is local to this crate to +/// avoid a cross-crate dependency. +fn parse_req_yaml(yaml: &str) -> Vec { + let mut artifacts: Vec = Vec::new(); + let mut id: Option = None; + let mut artifact_type = String::new(); + let mut title = String::new(); + let mut links: Vec = Vec::new(); + let mut in_links = false; + let mut pending_link_type: Option = None; + + let flush = |id: &mut Option, + artifact_type: &mut String, + title: &mut String, + links: &mut Vec, + in_links: &mut bool, + pending_link_type: &mut Option, + artifacts: &mut Vec| { + if let Some(pending_lt) = pending_link_type.take() { + links.push(ReqLink { + link_type: pending_lt, + target: String::new(), + }); + } + if let Some(artifact_id) = id.take() { + artifacts.push(ReqArtifact { + id: artifact_id, + artifact_type: std::mem::take(artifact_type), + title: std::mem::take(title), + links: std::mem::take(links), + }); + } + *in_links = false; + }; + + for line in yaml.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("- id:") { + flush( + &mut id, + &mut artifact_type, + &mut title, + &mut links, + &mut in_links, + &mut pending_link_type, + &mut artifacts, + ); + id = Some(trimmed.trim_start_matches("- id:").trim().to_string()); + continue; + } + + if id.is_none() { + continue; + } + + if trimmed.starts_with("type:") && !in_links { + artifact_type = trimmed.trim_start_matches("type:").trim().to_string(); + } else if trimmed.starts_with("title:") { + let val = trimmed.trim_start_matches("title:").trim(); + title = val.trim_matches('"').to_string(); + } else if trimmed == "links:" { + in_links = true; + } else if in_links && trimmed.starts_with("- type:") { + // Flush pending link. + if let Some(plt) = pending_link_type.take() { + links.push(ReqLink { + link_type: plt, + target: String::new(), + }); + } + pending_link_type = Some(trimmed.trim_start_matches("- type:").trim().to_string()); + } else if in_links && trimmed.starts_with("target:") { + let tgt = trimmed.trim_start_matches("target:").trim().to_string(); + if let Some(plt) = pending_link_type.take() { + links.push(ReqLink { + link_type: plt, + target: tgt, + }); + } + } + } + + // Flush last artifact. + flush( + &mut id, + &mut artifact_type, + &mut title, + &mut links, + &mut in_links, + &mut pending_link_type, + &mut artifacts, + ); + + artifacts +} + // ── Helpers ───────────────────────────────────────────────────────────────── /// Compute the depth (parent-hop distance from root) for every component. @@ -425,4 +822,181 @@ mod tests { "expected 'processor: cpu' in label" ); } + + // ── classDiagram tests ─────────────────────────────────────────────────── + + /// Build an instance with distinct type_name per component for classDiagram tests. + fn class_instance() -> SystemInstance { + let mut components: Arena = Arena::default(); + let mut comp_idxs: Vec = Vec::new(); + + let specs: &[(&str, &str, ComponentCategory)] = &[ + ("root", "RootSys", ComponentCategory::System), + ("worker", "WorkerThread", ComponentCategory::Thread), + ("cpu", "MainCpu", ComponentCategory::Processor), + ]; + + for (name, type_name, cat) in specs { + let idx = components.alloc(ComponentInstance { + name: Name::new(name), + category: *cat, + type_name: Name::new(type_name), + impl_name: None, + package: Name::new("Pkg"), + parent: None, + children: Vec::new(), + features: Vec::new(), + connections: Vec::new(), + flows: Vec::new(), + modes: Vec::new(), + mode_transitions: Vec::new(), + array_index: None, + in_modes: Vec::new(), + }); + comp_idxs.push(idx); + } + + // Wire root → worker, root → cpu + for i in 1..3 { + components[comp_idxs[i]].parent = Some(comp_idxs[0]); + components[comp_idxs[0]].children.push(comp_idxs[i]); + } + + let root = comp_idxs[0]; + SystemInstance { + root, + components, + features: Arena::default(), + connections: Arena::default(), + flow_instances: Arena::default(), + end_to_end_flows: Arena::default(), + mode_instances: Arena::default(), + mode_transition_instances: Arena::default(), + diagnostics: Vec::new(), + property_maps: FxHashMap::default(), + semantic_connections: Vec::new(), + system_operation_modes: Vec::new(), + } + } + + #[test] + fn test_class_diagram_one_class_per_component() { + let instance = class_instance(); + let opts = MermaidOptions::default(); + let out = emit_class_diagram(&instance, &opts); + + assert!( + out.starts_with("classDiagram\n"), + "expected 'classDiagram' header; got:\n{out}" + ); + // Each distinct type name should appear once as a class block. + assert!(out.contains("class RootSys"), "expected class RootSys"); + assert!( + out.contains("class WorkerThread"), + "expected class WorkerThread" + ); + assert!(out.contains("class MainCpu"), "expected class MainCpu"); + } + + #[test] + fn test_class_diagram_stereotype_shows_category() { + let instance = class_instance(); + let opts = MermaidOptions::default(); + let out = emit_class_diagram(&instance, &opts); + + assert!(out.contains("<>"), "expected <> stereotype"); + assert!(out.contains("<>"), "expected <> stereotype"); + assert!( + out.contains("<>"), + "expected <> stereotype" + ); + } + + #[test] + fn test_class_diagram_category_filter_excludes_processor() { + let instance = class_instance(); + let opts = MermaidOptions { + categories: vec![ComponentCategory::System, ComponentCategory::Thread], + ..Default::default() + }; + let out = emit_class_diagram(&instance, &opts); + + assert!( + !out.contains("MainCpu"), + "processor class MainCpu should be absent when filtered" + ); + assert!( + out.contains("WorkerThread"), + "thread class should be present" + ); + } + + // ── requirementDiagram tests ───────────────────────────────────────────── + + const REQ_YAML: &str = r#" +artifacts: + + - id: REQ-ALPHA-001 + type: requirement + title: Alpha requirement + description: First requirement. + status: implemented + tags: [alpha] + links: + - type: satisfies + target: REQ-BETA-001 + + - id: REQ-BETA-001 + type: requirement + title: Beta requirement + description: Second requirement. + status: implemented + tags: [beta] + + - id: DEC-IGNORE-001 + type: design-decision + title: Ignored decision + description: Should not appear in diagram. + status: implemented + tags: [design] +"#; + + #[test] + fn test_req_diagram_header_and_block() { + let out = emit_requirement_diagram_from_str(REQ_YAML); + + assert!( + out.starts_with("requirementDiagram\n"), + "expected 'requirementDiagram' header; got:\n{out}" + ); + // At least one requirement block should be present. + assert!( + out.contains("requirement REQ_ALPHA_001"), + "expected REQ_ALPHA_001 block; got:\n{out}" + ); + assert!( + out.contains("requirement REQ_BETA_001"), + "expected REQ_BETA_001 block; got:\n{out}" + ); + } + + #[test] + fn test_req_diagram_satisfies_edge() { + let out = emit_requirement_diagram_from_str(REQ_YAML); + + assert!( + out.contains("REQ_ALPHA_001 - satisfies -> REQ_BETA_001"), + "expected satisfies edge; got:\n{out}" + ); + } + + #[test] + fn test_req_diagram_skips_design_decisions() { + let out = emit_requirement_diagram_from_str(REQ_YAML); + + assert!( + !out.contains("DEC_IGNORE_001"), + "design-decision artifact should be excluded; got:\n{out}" + ); + } }