w4c-workflows-api/w4c-workflows-api.Tests/MermaidGeneratorServiceTests.cs

199 lines
7.6 KiB
C#
Raw Normal View History

2026-09-07 08:15:03 +00:00
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";
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();
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);
[Fact]
public void Flowchart_renders_next_solid_and_onError_dashed()
{
2026-09-07 08:15:03 +00:00
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()
{
2026-09-07 08:15:03 +00:00
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();
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-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);
}
}
[Fact]
public void Class_renders_language_not_runtime()
{
2026-09-07 08:15:03 +00:00
var source = Render(Diagram.Class);
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);
}
}