// Copyright (c) Microsoft. All rights reserved. using System; using FluentAssertions; namespace Microsoft.Agents.AI.Workflows.UnitTests; public class WorkflowVisualizerTests { private sealed class MockExecutor(string id) : Executor(id) { protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler((msg, ctx) => ctx.SendMessageAsync(msg))); } private sealed class ListStrTargetExecutor(string id) : Executor(id) { protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler((msgs, ctx) => ctx.SendMessageAsync(string.Join(",", msgs)))); } [Fact] public void Test_WorkflowViz_ToDotString_Basic() { // Create a simple workflow var executor1 = new MockExecutor("executor1"); var executor2 = new MockExecutor("executor2"); var workflow = new WorkflowBuilder("executor1") .AddEdge(executor1, executor2) .Build(); var dotContent = workflow.ToDotString(); // Check that the DOT content contains expected elements dotContent.Should().Contain("digraph Workflow {"); dotContent.Should().Contain("\"executor1\""); dotContent.Should().Contain("\"executor2\""); dotContent.Should().Contain("\"executor1\" -> \"executor2\""); dotContent.Should().Contain("fillcolor=lightgreen"); // Start executor styling dotContent.Should().Contain("(Start)"); } [Fact] public void Test_WorkflowViz_Complex_Workflow() { // Test visualization of a more complex workflow var executor1 = new MockExecutor("start"); var executor2 = new MockExecutor("middle1"); var executor3 = new MockExecutor("middle2"); var executor4 = new MockExecutor("end"); var workflow = new WorkflowBuilder("start") .AddEdge(executor1, executor2) .AddEdge(executor1, executor3) .AddEdge(executor2, executor4) .AddEdge(executor3, executor4) .Build(); var dotContent = workflow.ToDotString(); // Check all executors are present dotContent.Should().Contain("\"start\""); dotContent.Should().Contain("\"middle1\""); dotContent.Should().Contain("\"middle2\""); dotContent.Should().Contain("\"end\""); // Check all edges are present dotContent.Should().Contain("\"start\" -> \"middle1\""); dotContent.Should().Contain("\"start\" -> \"middle2\""); dotContent.Should().Contain("\"middle1\" -> \"end\""); dotContent.Should().Contain("\"middle2\" -> \"end\""); // Check start executor has special styling dotContent.Should().Contain("fillcolor=lightgreen"); } [Fact] public void Test_WorkflowViz_Conditional_Edge() { // Test that conditional edges are rendered dashed with a label var start = new MockExecutor("start"); var mid = new MockExecutor("mid"); var end = new MockExecutor("end"); // Condition that is never used during viz, but presence should mark the edge static bool OnlyIfFoo(string? msg) => msg == "foo"; var workflow = new WorkflowBuilder("start") .AddEdge(start, mid, OnlyIfFoo) .AddEdge(mid, end) .Build(); var dotContent = workflow.ToDotString(); // Conditional edge should be dashed and labeled dotContent.Should().Contain("\"start\" -> \"mid\" [style=dashed, label=\"conditional\"];"); // Non-conditional edge should be plain dotContent.Should().Contain("\"mid\" -> \"end\""); dotContent.Should().NotContain("\"mid\" -> \"end\" [style=dashed"); } [Fact] public void Test_WorkflowViz_FanIn_EdgeGroup() { // Test that fan-in edges render an intermediate node with label and routed edges var start = new MockExecutor("start"); var s1 = new MockExecutor("s1"); var s2 = new MockExecutor("s2"); var t = new ListStrTargetExecutor("t"); // Build a connected workflow: start fans out to s1 and s2, which then fan-in to t var workflow = new WorkflowBuilder("start") .AddFanOutEdge(start, [s1, s2]) .AddFanInBarrierEdge([s1, s2], t) // AddFanInBarrierEdge(target, sources) .Build(); var dotContent = workflow.ToDotString(); // There should be a single fan-in node with special styling and label var lines = dotContent.Split('\n'); var fanInLines = Array.FindAll(lines, line => line.Contains("shape=ellipse") && line.Contains("label=\"fan-in\"")); fanInLines.Should().HaveCount(1); // Extract the intermediate node id from the line var fanInLine = fanInLines[0]; var firstQuote = fanInLine.IndexOf('"'); var secondQuote = fanInLine.IndexOf('"', firstQuote + 1); firstQuote.Should().BeGreaterThan(-1); secondQuote.Should().BeGreaterThan(-1); var fanInNodeId = fanInLine.Substring(firstQuote + 1, secondQuote - firstQuote - 1); fanInNodeId.Should().NotBeNullOrEmpty(); // Edges should be routed through the intermediate node, not direct to target dotContent.Should().Contain($"\"s1\" -> \"{fanInNodeId}\";"); dotContent.Should().Contain($"\"s2\" -> \"{fanInNodeId}\";"); dotContent.Should().Contain($"\"{fanInNodeId}\" -> \"t\";"); // Ensure direct edges are not present dotContent.Should().NotContain("\"s1\" -> \"t\""); dotContent.Should().NotContain("\"s2\" -> \"t\""); } // Note: Sub-workflow tests are commented out as the current implementation // of TryGetNestedWorkflow returns false. These can be enabled once // WorkflowExecutor detection is implemented. /* [Fact] public void Test_WorkflowViz_SubWorkflow_Digraph() { // Test that WorkflowViz can visualize sub-workflows in DOT format // This test would require WorkflowExecutor implementation // Currently TryGetNestedWorkflow always returns false } [Fact] public void Test_WorkflowViz_Nested_SubWorkflows() { // Test visualization of deeply nested sub-workflows // This test would require WorkflowExecutor implementation // Currently TryGetNestedWorkflow always returns false } */ [Fact] public void Test_WorkflowViz_FanOut_Edges() { // Test fan-out edge visualization var start = new MockExecutor("start"); var target1 = new MockExecutor("target1"); var target2 = new MockExecutor("target2"); var target3 = new MockExecutor("target3"); var workflow = new WorkflowBuilder("start") .AddFanOutEdge(start, [target1, target2, target3]) .Build(); var dotContent = workflow.ToDotString(); // Check all fan-out edges are present dotContent.Should().Contain("\"start\" -> \"target1\""); dotContent.Should().Contain("\"start\" -> \"target2\""); dotContent.Should().Contain("\"start\" -> \"target3\""); } [Fact] public void Test_WorkflowViz_Mixed_EdgeTypes() { // Test workflow with mixed edge types (direct, conditional, fan-out, fan-in) var start = new MockExecutor("start"); var a = new MockExecutor("a"); var b = new MockExecutor("b"); var c = new MockExecutor("c"); var end = new ListStrTargetExecutor("end"); static bool Condition(string? msg) => msg?.Contains("test") ?? false; var workflow = new WorkflowBuilder("start") .AddEdge(start, a, Condition) // Conditional edge .AddFanOutEdge(a, [b, c]) // Fan-out .AddFanInBarrierEdge([b, c], end) // Fan-in - AddFanInEdge(target, sources) .Build(); var dotContent = workflow.ToDotString(); // Check conditional edge dotContent.Should().Contain("\"start\" -> \"a\" [style=dashed, label=\"conditional\"];"); // Check fan-out edges dotContent.Should().Contain("\"a\" -> \"b\""); dotContent.Should().Contain("\"a\" -> \"c\""); // Check fan-in (should have intermediate node) dotContent.Should().Contain("shape=ellipse"); dotContent.Should().Contain("label=\"fan-in\""); } [Fact] public void Test_WorkflowViz_SingleNode_Workflow() { // Test visualization of a single-node workflow var executor = new MockExecutor("single"); var workflow = new WorkflowBuilder("single") .BindExecutor(executor) .Build(); var dotContent = workflow.ToDotString(); // Check single node is present with start styling dotContent.Should().Contain("\"single\""); dotContent.Should().Contain("fillcolor=lightgreen"); dotContent.Should().Contain("(Start)"); } [Fact] public void Test_WorkflowViz_SelfLoop_Edge() { // Test visualization of self-loop edge var executor = new MockExecutor("loop"); static bool LoopCondition(string? msg) => (msg?.Length ?? 0) < 10; var workflow = new WorkflowBuilder("loop") .AddEdge(executor, executor, LoopCondition) .Build(); var dotContent = workflow.ToDotString(); // Check self-loop edge is present and conditional dotContent.Should().Contain("\"loop\" -> \"loop\" [style=dashed, label=\"conditional\"];"); } [Fact] public void Test_WorkflowViz_ToMermaidString_Basic() { // Test that WorkflowViz can generate a Mermaid diagram var executor1 = new MockExecutor("executor1"); var executor2 = new MockExecutor("executor2"); var workflow = new WorkflowBuilder("executor1") .AddEdge(executor1, executor2) .Build(); var mermaidContent = workflow.ToMermaidString(); // Check that the Mermaid content contains expected elements mermaidContent.Should().Contain("flowchart TD"); mermaidContent.Should().Contain("executor1[\"executor1 (Start)\"]"); mermaidContent.Should().Contain("executor2[\"executor2\"]"); mermaidContent.Should().Contain("executor1 --> executor2"); } [Fact] public void Test_WorkflowViz_Mermaid_Conditional_Edge() { // Test that conditional edges are rendered with dotted lines and labels in Mermaid var start = new MockExecutor("start"); var mid = new MockExecutor("mid"); var end = new MockExecutor("end"); static bool OnlyIfFoo(string? msg) => msg == "foo"; var workflow = new WorkflowBuilder("start") .AddEdge(start, mid, OnlyIfFoo) .AddEdge(mid, end) .Build(); var mermaidContent = workflow.ToMermaidString(); // Conditional edge should be dotted with label (using .-> not .-->) mermaidContent.Should().Contain("-. conditional .-> "); // Non-conditional edge should be a specific solid arrow mermaidContent.Should().Contain("mid --> end"); // Display labels should be present mermaidContent.Should().Contain("\"start (Start)\""); mermaidContent.Should().Contain("\"mid\""); mermaidContent.Should().Contain("\"end\""); } [Fact] public void Test_WorkflowViz_Mermaid_FanIn_EdgeGroup() { // Test that fan-in edges render an intermediate node with label and routed edges in Mermaid var start = new MockExecutor("start"); var s1 = new MockExecutor("s1"); var s2 = new MockExecutor("s2"); var t = new ListStrTargetExecutor("t"); var workflow = new WorkflowBuilder("start") .AddFanOutEdge(start, [s1, s2]) .AddFanInBarrierEdge([s1, s2], t) .Build(); var mermaidContent = workflow.ToMermaidString(); // There should be a fan-in node with special styling var lines = mermaidContent.Split('\n'); var fanInLines = Array.FindAll(lines, line => line.Contains("((fan-in))")); fanInLines.Should().HaveCount(1); // Extract the intermediate fan-in node id from the line var fanInLine = fanInLines[0].Trim(); var fanInNodeId = fanInLine.Substring(0, fanInLine.IndexOf("((fan-in))", StringComparison.Ordinal)).Trim(); fanInNodeId.Should().NotBeNullOrEmpty(); // Edges should be routed through the intermediate node mermaidContent.Should().Contain($"s1 --> {fanInNodeId}"); mermaidContent.Should().Contain($"s2 --> {fanInNodeId}"); mermaidContent.Should().Contain($"{fanInNodeId} --> t"); // Ensure direct edges are not present mermaidContent.Should().NotContain("s1 --> t"); mermaidContent.Should().NotContain("s2 --> t"); // Display labels should be present mermaidContent.Should().Contain("\"start (Start)\""); mermaidContent.Should().Contain("\"s1\""); mermaidContent.Should().Contain("\"s2\""); mermaidContent.Should().Contain("\"t\""); // All node IDs should be safe aliases (ASCII-only identifiers) foreach (var line in mermaidContent.Split('\n')) { var trimmed = line.Trim(); if (trimmed.Contains("[\"") || trimmed.Contains("((")) { var bracketIdx = trimmed.IndexOfAny(['[', '(']); var nodeId = trimmed.Substring(0, bracketIdx); nodeId.Should().MatchRegex("^[a-zA-Z_][a-zA-Z0-9_]*$"); } } } [Fact] public void Test_WorkflowViz_Mermaid_Complex_Workflow() { // Test Mermaid visualization of a more complex workflow var executor1 = new MockExecutor("start"); var executor2 = new MockExecutor("middle1"); var executor3 = new MockExecutor("middle2"); var executor4 = new MockExecutor("end"); var workflow = new WorkflowBuilder("start") .AddEdge(executor1, executor2) .AddEdge(executor1, executor3) .AddEdge(executor2, executor4) .AddEdge(executor3, executor4) .Build(); var mermaidContent = workflow.ToMermaidString(); // Check display labels are present mermaidContent.Should().Contain("\"start (Start)\""); mermaidContent.Should().Contain("\"middle1\""); mermaidContent.Should().Contain("\"middle2\""); mermaidContent.Should().Contain("\"end\""); // Check that sanitized IDs are used and all edges connect them mermaidContent.Should().Contain("start[\"start (Start)\"]"); mermaidContent.Should().Contain("start --> middle1"); mermaidContent.Should().Contain("start --> middle2"); mermaidContent.Should().Contain("middle1 --> end"); mermaidContent.Should().Contain("middle2 --> end"); } [Fact] public void Test_WorkflowViz_Mermaid_Mixed_EdgeTypes() { // Test Mermaid workflow with mixed edge types (direct, conditional, fan-out, fan-in) var start = new MockExecutor("start"); var a = new MockExecutor("a"); var b = new MockExecutor("b"); var c = new MockExecutor("c"); var end = new ListStrTargetExecutor("end"); static bool Condition(string? msg) => msg?.Contains("test") ?? false; var workflow = new WorkflowBuilder("start") .AddEdge(start, a, Condition) // Conditional edge .AddFanOutEdge(a, [b, c]) // Fan-out .AddFanInBarrierEdge([b, c], end) // Fan-in .Build(); var mermaidContent = workflow.ToMermaidString(); // Check conditional edge uses correct syntax (.-> not .-->) mermaidContent.Should().Contain("-. conditional .->"); mermaidContent.Should().NotContain(".-->"); // Check fan-in (should have intermediate node) mermaidContent.Should().Contain("((fan-in))"); // Display labels should be present mermaidContent.Should().Contain("\"start (Start)\""); mermaidContent.Should().Contain("\"a\""); mermaidContent.Should().Contain("\"b\""); mermaidContent.Should().Contain("\"c\""); mermaidContent.Should().Contain("\"end\""); } [Fact] public void Test_WorkflowViz_Mermaid_Edge_Label_With_Pipe() { // Test that pipe characters in labels are properly escaped var start = new MockExecutor("start"); var end = new MockExecutor("end"); var workflow = new WorkflowBuilder("start") .AddEdge(start, end, label: "High | Low Priority") .Build(); var mermaidContent = workflow.ToMermaidString(); // Should escape pipe character mermaidContent.Should().Contain("-->|High | Low Priority|"); // Should not contain unescaped pipe that would break syntax mermaidContent.Should().NotContain("-->|High | Low"); } [Fact] public void Test_WorkflowViz_Mermaid_Edge_Label_With_Special_Chars() { // Test that special characters are properly escaped var start = new MockExecutor("start"); var end = new MockExecutor("end"); var workflow = new WorkflowBuilder("start") .AddEdge(start, end, label: "Score >= 90 & < 100") .Build(); var mermaidContent = workflow.ToMermaidString(); // Should escape special characters mermaidContent.Should().Contain("&"); mermaidContent.Should().Contain(">"); mermaidContent.Should().Contain("<"); } [Fact] public void Test_WorkflowViz_Mermaid_Edge_Label_With_Newline() { // Test that newlines are converted to
var start = new MockExecutor("start"); var end = new MockExecutor("end"); var workflow = new WorkflowBuilder("start") .AddEdge(start, end, label: "Line 1\nLine 2") .Build(); var mermaidContent = workflow.ToMermaidString(); // Should convert newline to
mermaidContent.Should().Contain("Line 1
Line 2"); // Should not contain literal newline in the label (but the overall output has newlines between statements) mermaidContent.Should().NotContain("Line 1\nLine 2"); } [Fact] public void Test_WorkflowViz_Mermaid_ConditionalEdge_ArrowSyntax() { // Conditional edges must use "-. label .->" (not ".-->") which is the correct // Mermaid syntax for dotted arrows with labels. var start = new MockExecutor("start"); var mid = new MockExecutor("mid"); static bool Condition(string? msg) => msg == "foo"; var workflow = new WorkflowBuilder("start") .AddEdge(start, mid, Condition) .Build(); var mermaidContent = workflow.ToMermaidString(); // The output should use ".->" not ".-->" for conditional (dotted) edges mermaidContent.Should().NotContain(".-->", because: "'.-->' is invalid Mermaid syntax for dotted arrows; should be '.->'"); mermaidContent.Should().Contain("-. conditional .->", because: "'-. label .->' is the correct Mermaid syntax for dotted arrows with labels"); } [Fact] public void Test_WorkflowViz_Mermaid_IdentifiersWithSpaces() { // Identifiers with spaces must not be used directly as Mermaid node IDs // because spaces cause rendering errors. var executor1 = new MockExecutor("1. User input"); var executor2 = new MockExecutor("2. Process data"); var workflow = new WorkflowBuilder("1. User input") .AddEdge(executor1, executor2) .Build(); var mermaidContent = workflow.ToMermaidString(); // Node definitions should use safe aliases as IDs (no spaces), with display names in quotes // Bad: '1. User input["1. User input (Start)"]' — spaces in ID break Mermaid // Good: 'n_1_User_input["1. User input (Start)"]' — alias ID is safe and sanitized // Each node definition line (containing ["..."]) should have a space-free ID before the bracket foreach (var line in mermaidContent.Split('\n')) { var trimmed = line.Trim(); if (trimmed.Contains("[\"")) { var bracketIdx = trimmed.IndexOf('['); var nodeId = trimmed.Substring(0, bracketIdx); nodeId.Should().NotContain(" ", because: $"Mermaid node IDs must not contain spaces, but got '{nodeId}'"); } } } [Fact] public void Test_WorkflowViz_Mermaid_IdentifiersWithUnicode() { // Non-ASCII characters (e.g. Japanese) in identifiers cause Mermaid rendering errors. var executor1 = new MockExecutor("ユーザー入力"); var executor2 = new MockExecutor("データ処理"); var workflow = new WorkflowBuilder("ユーザー入力") .AddEdge(executor1, executor2) .Build(); var mermaidContent = workflow.ToMermaidString(); // The display labels should contain the original names mermaidContent.Should().Contain("ユーザー入力"); mermaidContent.Should().Contain("データ処理"); // But node IDs (before the bracket) should be safe ASCII-only identifiers foreach (var line in mermaidContent.Split('\n')) { var trimmed = line.Trim(); if (trimmed.Contains("[\"")) { var bracketIdx = trimmed.IndexOf('['); var nodeId = trimmed.Substring(0, bracketIdx); // Node ID should start with a letter or underscore, followed by ASCII alphanumeric or underscores nodeId.Should().MatchRegex("^[a-zA-Z_][a-zA-Z0-9_]*$", because: $"Mermaid node IDs should be ASCII-safe, but got '{nodeId}'"); } } } }