using System.Text.RegularExpressions; using w4c_workflows.Services; using Xunit; namespace w4c_workflows.Tests; 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 language: typescript trigger: type: queue stream: orders entry: { file: index.ts, function: handler } tasks: - id: validate parent: root next: enrich onError: compensate language: python entry: { file: validate.py } - id: enrich parent: root next: persist language: csharp entry: { file: Enrich.cs } - id: persist parent: root language: shell entry: { file: persist.sh } - id: compensate parent: root language: python entry: { file: compensate.py } """; private static CompiledWorkflow Compile() => 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 source = Render(Diagram.Flowchart); Assert.Contains("flowchart TD", source); Assert.Contains("root --> validate", source); Assert.Contains("validate --> enrich", source); Assert.Contains("validate -.->|error| compensate", source); Assert.Contains("class root root", source); } [Fact] public void Sequence_renders_happy_path_and_alt_error_block() { var source = Render(Diagram.Sequence); Assert.Contains("sequenceDiagram", source); Assert.Contains("root->>validate", source); Assert.Contains("validate->>enrich", source); Assert.Contains("alt on error", source); Assert.Contains("validate->>compensate", source); } [Fact] public void State_renders_entry_terminal_and_error_transitions() { var compiled = Compile(); var source = Render(compiled, Diagram.State); var (aliasOf, edges) = ParseStateDiagram(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 source = Render(Diagram.Class); Assert.Contains("classDiagram", source); Assert.Contains("<>", source); // root language Assert.Contains("<>", source); // validate language Assert.Contains("entry: index.ts", source); Assert.DoesNotContain("runtime", source); } [Fact] public void Unknown_type_throws() { var compiled = Compile(); 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 ""(?