mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
db48f8ce09
* Fix Mermaid rendering errors in WorkflowVisualizer.ToMermaidString Fix two bugs in the Mermaid diagram output: 1. Use safe node aliases (node_0, node_1, ...) instead of raw executor IDs as Mermaid node identifiers. Raw IDs containing spaces, dots, or non-ASCII characters (e.g. Japanese) caused Mermaid parse errors. 2. Fix conditional edge arrow syntax from '.--> ' (invalid) to '.-> ' (valid Mermaid dotted arrow syntax). Fixes #1406 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use recognizable sanitized IDs for Mermaid node identifiers\n\nReplace generic node_0/node_1 aliases with IDs derived from the original\nexecutor names. ASCII letters, digits, and underscores are preserved;\nother characters become underscores (collapsed, trimmed). Leading digits\nget an n_ prefix. Collisions are resolved with a numeric suffix.\n\nThis keeps node IDs readable in the Mermaid source while the display\nlabels continue to show the full original names." * Remove issue number references from test names and comments" * Address PR review feedback from Copilot\n\n- Add Throw.IfNull(id) guard to SanitizeMermaidNodeId\n- Add safety limit (10,000) to collision resolution loop\n- Restore missing edge assertions (middle1/middle2 --> end)\n- Fix comment to show actual sanitized ID (n_1_User_input)\n- Use stricter regex in Unicode test (must start with letter/underscore)" * Address second round of PR review feedback\n\n- Escape node display labels via EscapeMermaidLabel to handle quotes,\n brackets, and newlines in executor IDs\n- Fix XML doc on SanitizeMermaidNodeId to accurately describe that\n existing consecutive underscores in input are preserved\n- Restore specific edge assertion (mid --> end) in conditional edge test\n- Restore fan-in routing assertions (s1/s2 through intermediate node,\n no direct edges to t) in fan-in test" --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
567 lines
22 KiB
C#
567 lines
22 KiB
C#
// 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<string>((msg, ctx) => ctx.SendMessageAsync(msg)));
|
|
}
|
|
|
|
private sealed class ListStrTargetExecutor(string id) : Executor(id)
|
|
{
|
|
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
|
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
|
|
routeBuilder.AddHandler<string[]>((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<string>(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<string>(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<string>(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<string>(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<string>(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 <br/>
|
|
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 <br/>
|
|
mermaidContent.Should().Contain("Line 1<br/>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<string>(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}'");
|
|
}
|
|
}
|
|
}
|
|
}
|