using RackPeek.Domain.Graph;
using RackPeek.Domain.Graph.Serialisers;
namespace Tests.Graph;
public sealed class MermaidSerialiserTests {
private readonly MermaidSerialiser _serialiser = new();
[Fact]
public void Empty_Graph_Renders_Header_And_ClassDef_Only() {
var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
Assert.Contains("flowchart TD", output);
Assert.Contains("classDef rpknode", output);
// No node lines because the only content after the classDef is blank.
Assert.DoesNotContain("[\"", output);
}
[Fact]
public void Renders_Step_Curve_Init_Directive() {
// Right-angle (Manhattan) edge routing — the convention for network
// diagrams. Anything else (linear/curved) reads as a flowchart.
var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
Assert.Contains("'curve': 'step'", output);
}
[Fact]
public void Direction_Override_Is_Honoured() {
var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty, "LR");
Assert.Contains("flowchart LR", output);
}
[Fact]
public void Node_Renders_Name_Only_When_No_Subtitle() {
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("srv-01", "srv-01", "Server")],
[]);
var output = _serialiser.Serialise(graph);
Assert.Contains("n_srv_01[(\"srv-01\")]:::rpknode", output);
Assert.DoesNotContain("srv-01
", output);
}
[Fact]
public void Subtitle_Renders_As_Second_Label_Line() {
// The serialiser is agnostic — each use case decides what's useful
// as a subtitle (kind for topology, ip[:port] for logical view).
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("srv-01", "srv-01", "Server", Subtitle: "192.168.0.10:8080")],
[]);
var output = _serialiser.Serialise(graph);
Assert.Contains("n_srv_01[(\"srv-01
192.168.0.10:8080\")]:::rpknode", output);
}
[Theory]
[InlineData("Firewall", "{{\"", "\"}}")] // hexagon — security boundary
[InlineData("Router", "([\"", "\"])")] // stadium — gateway
[InlineData("Switch", "[[\"", "\"]]")] // subroutine — distribution
[InlineData("Server", "[(\"", "\")]")] // cylinder — compute/storage
[InlineData("AccessPoint", "((\"", "\"))")] // circle — radio
[InlineData("Ups", "{\"", "\"}")] // rhombus — utility
[InlineData("Desktop", "(\"", "\")")] // rounded rect — endpoint
[InlineData("Laptop", "(\"", "\")")] // rounded rect — endpoint
public void Kind_Maps_To_Documented_Mermaid_Shape(string kind, string openBracket, string closeBracket) {
// Shape conveys role at a glance without colour or icons — pin the
// mapping so a future refactor can't silently change diagrams.
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("x", "x", kind)],
[]);
var output = _serialiser.Serialise(graph);
Assert.Contains($"n_x{openBracket}x{closeBracket}", output);
}
[Fact]
public void Unknown_Kind_Falls_Back_To_Plain_Rectangle() {
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("x", "x", "Toaster")],
[]);
var output = _serialiser.Serialise(graph);
Assert.Contains("n_x[\"x\"]:::rpknode", output);
}
[Fact]
public void All_Nodes_Share_A_Single_Visual_Class_Regardless_Of_Kind() {
var graph = new RackPeek.Domain.Graph.Graph(
[
new GraphNode("a", "a", "Firewall"),
new GraphNode("b", "b", "Server"),
new GraphNode("c", "c", "Mystery")
],
[]);
var output = _serialiser.Serialise(graph);
// All three classed identically — no per-kind colour.
Assert.Equal(3, CountOccurrences(output, ":::rpknode"));
}
[Fact]
public void No_Emoji_Or_Icon_Appears_In_Output() {
var graph = new RackPeek.Domain.Graph.Graph(
[
new GraphNode("srv-01", "srv-01", "Server"),
new GraphNode("fw-01", "fw-01", "Firewall"),
new GraphNode("sw-01", "sw-01", "Switch")
],
[]);
var output = _serialiser.Serialise(graph);
// Pin against the previous emoji-y design.
Assert.DoesNotContain("🖥", output);
Assert.DoesNotContain("🛡", output);
Assert.DoesNotContain("🔀", output);
}
[Fact]
public void Edge_With_Label_Renders_With_Pipe_Syntax() {
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
[new GraphEdge("a", "b", "eth0 ↔ port1", "connection")]);
var output = _serialiser.Serialise(graph);
Assert.Contains("n_a ---|\"eth0 ↔ port1\"| n_b", output);
}
[Fact]
public void Edge_Without_Label_Renders_Plain_Line() {
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
[new GraphEdge("a", "b", null, "connection")]);
var output = _serialiser.Serialise(graph);
Assert.Contains("n_a --- n_b", output);
}
[Fact]
public void RunsOn_Edge_Gets_An_Arrow() {
// Directional relationships need a visible arrowhead so the reader
// can tell which side depends on which.
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("svc", "svc", "Service"), new GraphNode("vm", "vm", "Vm")],
[new GraphEdge("svc", "vm", null, "runsOn")]);
var output = _serialiser.Serialise(graph);
Assert.Contains("n_svc --> n_vm", output);
Assert.DoesNotContain("n_svc --- n_vm", output);
}
[Fact]
public void Connection_Edge_Stays_Plain_Line() {
// Port-to-port physical connections are symmetric; no arrow.
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
[new GraphEdge("a", "b", null, "connection")]);
var output = _serialiser.Serialise(graph);
Assert.Contains("n_a --- n_b", output);
Assert.DoesNotContain("n_a --> n_b", output);
}
[Fact]
public void Edges_Get_Muted_Stroke_Via_LinkStyle() {
// A single linkStyle default rule keeps edges visually quiet so they
// never compete with node labels.
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
[new GraphEdge("a", "b", null, "connection")]);
var output = _serialiser.Serialise(graph);
Assert.Contains("linkStyle default stroke:", output);
}
[Fact]
public void Node_Borders_Are_Dotted() {
// Pin the dashed-border styling — keeps the look intentionally light.
var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
Assert.Contains("stroke-dasharray:3 3", output);
}
[Fact]
public void Connection_Lines_Are_Dotted() {
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
[new GraphEdge("a", "b", null, "connection")]);
var output = _serialiser.Serialise(graph);
Assert.Contains("linkStyle default ", output);
Assert.Contains("stroke-dasharray:4 4", output);
}
[Fact]
public void Edge_Label_Background_Is_Transparent() {
// Solid label boxes feel chunky and clip the connection line. A
// transparent background lets labels read as floating annotations.
var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
Assert.Contains("'edgeLabelBackground': 'transparent'", output);
}
private static int CountOccurrences(string haystack, string needle) {
var count = 0;
var idx = 0;
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0) {
count++;
idx += needle.Length;
}
return count;
}
[Fact]
public void Duplicate_Slugs_Get_Disambiguated() {
// "srv-01" and "srv_01" both slug to "srv_01" — the serialiser must
// produce distinct IDs so Mermaid doesn't collapse them into one node.
var graph = new RackPeek.Domain.Graph.Graph(
[
new GraphNode("srv-01", "srv-01", "Server"),
new GraphNode("srv_01", "srv_01", "Server")
],
[]);
var output = _serialiser.Serialise(graph);
Assert.Contains("n_srv_01[", output);
Assert.Contains("n_srv_01_2[", output);
}
[Fact]
public void Special_Characters_In_Labels_Are_Escaped() {
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("x", "host \"with\" quotes", "Server")],
[]);
var output = _serialiser.Serialise(graph);
Assert.Contains("host \\\"with\\\" quotes", output);
}
[Fact]
public void Edge_Referencing_Missing_Node_Is_Dropped() {
var graph = new RackPeek.Domain.Graph.Graph(
[new GraphNode("a", "a", "Server")],
[new GraphEdge("a", "ghost", null, "connection")]);
var output = _serialiser.Serialise(graph);
// Edge with a missing target must be silently dropped — Mermaid would
// otherwise emit a syntax error and the whole diagram would fail.
Assert.DoesNotContain("ghost", output);
}
}