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 ""(?