diff --git a/w4c-workflows-api.Tests/MermaidGeneratorServiceTests.cs b/w4c-workflows-api.Tests/MermaidGeneratorServiceTests.cs
index 670c1e3..25efd15 100644
--- a/w4c-workflows-api.Tests/MermaidGeneratorServiceTests.cs
+++ b/w4c-workflows-api.Tests/MermaidGeneratorServiceTests.cs
@@ -1,4 +1,4 @@
-using w4c_workflows.Models;
+using System.Text.RegularExpressions;
using w4c_workflows.Services;
using Xunit;
@@ -9,6 +9,20 @@ public class MermaidGeneratorServiceTests
private const string Tenant = "t1";
private const string Path = "workflows/main-task.yaml";
+ /// Diagram names accepted by .
+ /// Centralising them removes magic strings and keeps tests in sync with the service.
+ private static class Diagram
+ {
+ public const string Flowchart = "flowchart";
+ public const string Sequence = "sequence";
+ public const string State = "state";
+ public const string Class = "class";
+ }
+
+ /// is stateless (its methods are all static), so a
+ /// single instance is shared instead of allocating one per call.
+ private static readonly MermaidGeneratorService Generator = new();
+
private static readonly string ExampleYaml = """
name: main-task
mode: handler
@@ -43,11 +57,15 @@ public class MermaidGeneratorServiceTests
=> new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry()))
.Compile(ExampleYaml, Path, Tenant).Workflow!;
+ private static string Render(string type) => Render(Compile(), type);
+
+ private static string Render(CompiledWorkflow compiled, string type)
+ => Generator.Generate(type, compiled.Workflow, compiled.Tasks);
+
[Fact]
public void Flowchart_renders_next_solid_and_onError_dashed()
{
- var compiled = Compile();
- var source = new MermaidGeneratorService().Generate("flowchart", compiled.Workflow, compiled.Tasks);
+ var source = Render(Diagram.Flowchart);
Assert.Contains("flowchart TD", source);
Assert.Contains("root --> validate", source);
@@ -59,8 +77,7 @@ public class MermaidGeneratorServiceTests
[Fact]
public void Sequence_renders_happy_path_and_alt_error_block()
{
- var compiled = Compile();
- var source = new MermaidGeneratorService().Generate("sequence", compiled.Workflow, compiled.Tasks);
+ var source = Render(Diagram.Sequence);
Assert.Contains("sequenceDiagram", source);
Assert.Contains("root->>validate", source);
@@ -73,19 +90,51 @@ public class MermaidGeneratorServiceTests
public void State_renders_entry_terminal_and_error_transitions()
{
var compiled = Compile();
- var source = new MermaidGeneratorService().Generate("state", compiled.Workflow, compiled.Tasks);
+ var source = Render(compiled, Diagram.State);
+ var (aliasOf, edges) = ParseStateDiagram(source);
- Assert.Contains("stateDiagram-v2", source);
- Assert.Contains("[*] --> root", source);
- Assert.Contains("persist --> [*]", source);
- Assert.Contains("validate --> compensate: error", source);
+ Assert.StartsWith("stateDiagram-v2", source.TrimStart(), StringComparison.Ordinal);
+
+ // Every task (including the synthesized "root") is declared exactly once as a
+ // labelled state. We must not assert literal transition text such as
+ // "[*] --> root": State() aliases every key to a reserved-word-safe id (s0,
+ // s1, ...) and prints the human key only inside the quoted label. Comparing
+ // the declared labels against the compiled keys is order- and index-agnostic.
+ Assert.Equal(
+ compiled.Tasks.Select(t => t.Key).OrderBy(k => k, StringComparer.Ordinal),
+ aliasOf.Keys.OrderBy(k => k, StringComparer.Ordinal));
+
+ // Entry: exactly one initial transition, and it starts at "root".
+ var entryEdges = edges.Where(e => e.From == "[*]").ToList();
+ Assert.Single(entryEdges);
+ Assert.Equal(aliasOf["root"], entryEdges[0].To);
+
+ // Terminal: each task with no success/error successor ends at "[*]"
+ // (persist and compensate in this fixture), so no other task is terminal.
+ Assert.Equal(
+ compiled.Tasks.Where(t => t.NextId == null && t.OnErrorId == null)
+ .Select(t => aliasOf[t.Key]).OrderBy(a => a, StringComparer.Ordinal),
+ edges.Where(e => e.To == "[*]").Select(e => e.From).OrderBy(a => a, StringComparer.Ordinal));
+
+ // Error path: validate's onError: compensate is rendered as a labelled edge.
+ var errorEdge = Assert.Single(edges.Where(e => e.Label == "error"));
+ Assert.Equal(aliasOf["validate"], errorEdge.From);
+ Assert.Equal(aliasOf["compensate"], errorEdge.To);
+
+ // Structural integrity: no edge may reference an undeclared state, which would
+ // render as a raw id instead of a labelled node.
+ var declared = aliasOf.Values.Append("[*]").ToHashSet(StringComparer.Ordinal);
+ foreach (var edge in edges)
+ {
+ Assert.Contains(edge.From, declared);
+ Assert.Contains(edge.To, declared);
+ }
}
[Fact]
public void Class_renders_language_not_runtime()
{
- var compiled = Compile();
- var source = new MermaidGeneratorService().Generate("class", compiled.Workflow, compiled.Tasks);
+ var source = Render(Diagram.Class);
Assert.Contains("classDiagram", source);
Assert.Contains("<>", source); // root language
@@ -98,7 +147,52 @@ public class MermaidGeneratorServiceTests
public void Unknown_type_throws()
{
var compiled = Compile();
- var generator = new MermaidGeneratorService();
- Assert.Throws(() => generator.Generate("pie", compiled.Workflow, compiled.Tasks));
+ Assert.Throws(() => Render(compiled, "pie"));
+ }
+
+ // ------------------------------------------------------------------ state helpers
+ // State() is the only generator that aliases task keys to generated ids, so its output
+ // is verified structurally (labelled declarations + edges) rather than by exact
+ // substrings, which would silently couple the test to the alias numbering.
+
+ private sealed record StateEdge(string From, string To, string? Label);
+
+ private static readonly Regex StateDeclRegex = new(
+ @"^state ""(?