2026-09-07 08:15:03 +00:00
|
|
|
using System.Text.RegularExpressions;
|
2026-09-01 16:37:53 +00:00
|
|
|
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";
|
|
|
|
|
|
2026-09-07 08:15:03 +00:00
|
|
|
/// <summary>Diagram names accepted by <see cref="MermaidGeneratorService.Generate"/>.
|
|
|
|
|
/// Centralising them removes magic strings and keeps tests in sync with the service.</summary>
|
|
|
|
|
private static class Diagram
|
|
|
|
|
{
|
|
|
|
|
public const string Flowchart = "flowchart";
|
|
|
|
|
public const string Sequence = "sequence";
|
|
|
|
|
public const string State = "state";
|
|
|
|
|
public const string Class = "class";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary><see cref="MermaidGeneratorService"/> is stateless (its methods are all static), so a
|
|
|
|
|
/// single instance is shared instead of allocating one per <see cref="Render(string)"/> call.</summary>
|
|
|
|
|
private static readonly MermaidGeneratorService Generator = new();
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
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!;
|
|
|
|
|
|
2026-09-07 08:15:03 +00:00
|
|
|
private static string Render(string type) => Render(Compile(), type);
|
|
|
|
|
|
|
|
|
|
private static string Render(CompiledWorkflow compiled, string type)
|
|
|
|
|
=> Generator.Generate(type, compiled.Workflow, compiled.Tasks);
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
[Fact]
|
|
|
|
|
public void Flowchart_renders_next_solid_and_onError_dashed()
|
|
|
|
|
{
|
2026-09-07 08:15:03 +00:00
|
|
|
var source = Render(Diagram.Flowchart);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
{
|
2026-09-07 08:15:03 +00:00
|
|
|
var source = Render(Diagram.Sequence);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
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();
|
2026-09-07 08:15:03 +00:00
|
|
|
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);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
2026-09-07 08:15:03 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Class_renders_language_not_runtime()
|
|
|
|
|
{
|
2026-09-07 08:15:03 +00:00
|
|
|
var source = Render(Diagram.Class);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
Assert.Contains("classDiagram", source);
|
|
|
|
|
Assert.Contains("<<typescript>>", source); // root language
|
|
|
|
|
Assert.Contains("<<python>>", source); // validate language
|
|
|
|
|
Assert.Contains("entry: index.ts", source);
|
|
|
|
|
Assert.DoesNotContain("runtime", source);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Unknown_type_throws()
|
|
|
|
|
{
|
|
|
|
|
var compiled = Compile();
|
2026-09-07 08:15:03 +00:00
|
|
|
Assert.Throws<ArgumentException>(() => 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 ""(?<label>.+?)""\s+as\s+(?<alias>\w+)$", RegexOptions.Compiled);
|
|
|
|
|
|
|
|
|
|
private static readonly Regex StateEdgeRegex = new(
|
|
|
|
|
@"^(?<from>\[\*\]|\w+) --> (?<to>\[\*\]|\w+)(?:\s*:\s*(?<label>.+))?$", RegexOptions.Compiled);
|
|
|
|
|
|
|
|
|
|
private static (Dictionary<string, string> AliasOf, IReadOnlyList<StateEdge> Edges)
|
|
|
|
|
ParseStateDiagram(string source)
|
|
|
|
|
{
|
|
|
|
|
var aliasOf = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
|
var edges = new List<StateEdge>();
|
|
|
|
|
|
|
|
|
|
foreach (var rawLine in source.Split('\n'))
|
|
|
|
|
{
|
|
|
|
|
var line = rawLine.Trim();
|
|
|
|
|
if (line.Length == 0)
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
var declaration = StateDeclRegex.Match(line); // state "key" as sN
|
|
|
|
|
if (declaration.Success)
|
|
|
|
|
{
|
|
|
|
|
aliasOf.TryAdd(declaration.Groups["label"].Value, declaration.Groups["alias"].Value);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var edge = StateEdgeRegex.Match(line); // from --> to[: label]
|
|
|
|
|
if (edge.Success)
|
|
|
|
|
{
|
|
|
|
|
edges.Add(new StateEdge(
|
|
|
|
|
edge.Groups["from"].Value,
|
|
|
|
|
edge.Groups["to"].Value,
|
|
|
|
|
edge.Groups["label"].Success ? edge.Groups["label"].Value : null));
|
|
|
|
|
}
|
|
|
|
|
// Other lines (the "stateDiagram-v2" header) are intentionally ignored.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (aliasOf, edges);
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|
|
|
|
|
}
|