MermaidSerialiserTests.cs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. using RackPeek.Domain.Graph;
  2. using RackPeek.Domain.Graph.Serialisers;
  3. namespace Tests.Graph;
  4. public sealed class MermaidSerialiserTests {
  5. private readonly MermaidSerialiser _serialiser = new();
  6. [Fact]
  7. public void Empty_Graph_Renders_Header_And_ClassDef_Only() {
  8. var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
  9. Assert.Contains("flowchart TD", output);
  10. Assert.Contains("classDef rpknode", output);
  11. // No node lines because the only content after the classDef is blank.
  12. Assert.DoesNotContain("[\"", output);
  13. }
  14. [Fact]
  15. public void Renders_Step_Curve_Init_Directive() {
  16. // Right-angle (Manhattan) edge routing — the convention for network
  17. // diagrams. Anything else (linear/curved) reads as a flowchart.
  18. var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
  19. Assert.Contains("'curve': 'step'", output);
  20. }
  21. [Fact]
  22. public void Direction_Override_Is_Honoured() {
  23. var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty, "LR");
  24. Assert.Contains("flowchart LR", output);
  25. }
  26. [Fact]
  27. public void Node_Renders_Name_Only_When_No_Subtitle() {
  28. var graph = new RackPeek.Domain.Graph.Graph(
  29. [new GraphNode("srv-01", "srv-01", "Server")],
  30. []);
  31. var output = _serialiser.Serialise(graph);
  32. Assert.Contains("n_srv_01[(\"srv-01\")]:::rpknode", output);
  33. Assert.DoesNotContain("srv-01<br/>", output);
  34. }
  35. [Fact]
  36. public void Subtitle_Renders_As_Second_Label_Line() {
  37. // The serialiser is agnostic — each use case decides what's useful
  38. // as a subtitle (kind for topology, ip[:port] for logical view).
  39. var graph = new RackPeek.Domain.Graph.Graph(
  40. [new GraphNode("srv-01", "srv-01", "Server", Subtitle: "192.168.0.10:8080")],
  41. []);
  42. var output = _serialiser.Serialise(graph);
  43. Assert.Contains("n_srv_01[(\"srv-01<br/>192.168.0.10:8080\")]:::rpknode", output);
  44. }
  45. [Theory]
  46. [InlineData("Firewall", "{{\"", "\"}}")] // hexagon — security boundary
  47. [InlineData("Router", "([\"", "\"])")] // stadium — gateway
  48. [InlineData("Switch", "[[\"", "\"]]")] // subroutine — distribution
  49. [InlineData("Server", "[(\"", "\")]")] // cylinder — compute/storage
  50. [InlineData("AccessPoint", "((\"", "\"))")] // circle — radio
  51. [InlineData("Ups", "{\"", "\"}")] // rhombus — utility
  52. [InlineData("Desktop", "(\"", "\")")] // rounded rect — endpoint
  53. [InlineData("Laptop", "(\"", "\")")] // rounded rect — endpoint
  54. public void Kind_Maps_To_Documented_Mermaid_Shape(string kind, string openBracket, string closeBracket) {
  55. // Shape conveys role at a glance without colour or icons — pin the
  56. // mapping so a future refactor can't silently change diagrams.
  57. var graph = new RackPeek.Domain.Graph.Graph(
  58. [new GraphNode("x", "x", kind)],
  59. []);
  60. var output = _serialiser.Serialise(graph);
  61. Assert.Contains($"n_x{openBracket}x{closeBracket}", output);
  62. }
  63. [Fact]
  64. public void Unknown_Kind_Falls_Back_To_Plain_Rectangle() {
  65. var graph = new RackPeek.Domain.Graph.Graph(
  66. [new GraphNode("x", "x", "Toaster")],
  67. []);
  68. var output = _serialiser.Serialise(graph);
  69. Assert.Contains("n_x[\"x\"]:::rpknode", output);
  70. }
  71. [Fact]
  72. public void All_Nodes_Share_A_Single_Visual_Class_Regardless_Of_Kind() {
  73. var graph = new RackPeek.Domain.Graph.Graph(
  74. [
  75. new GraphNode("a", "a", "Firewall"),
  76. new GraphNode("b", "b", "Server"),
  77. new GraphNode("c", "c", "Mystery")
  78. ],
  79. []);
  80. var output = _serialiser.Serialise(graph);
  81. // All three classed identically — no per-kind colour.
  82. Assert.Equal(3, CountOccurrences(output, ":::rpknode"));
  83. }
  84. [Fact]
  85. public void No_Emoji_Or_Icon_Appears_In_Output() {
  86. var graph = new RackPeek.Domain.Graph.Graph(
  87. [
  88. new GraphNode("srv-01", "srv-01", "Server"),
  89. new GraphNode("fw-01", "fw-01", "Firewall"),
  90. new GraphNode("sw-01", "sw-01", "Switch")
  91. ],
  92. []);
  93. var output = _serialiser.Serialise(graph);
  94. // Pin against the previous emoji-y design.
  95. Assert.DoesNotContain("🖥", output);
  96. Assert.DoesNotContain("🛡", output);
  97. Assert.DoesNotContain("🔀", output);
  98. }
  99. [Fact]
  100. public void Edge_With_Label_Renders_With_Pipe_Syntax() {
  101. var graph = new RackPeek.Domain.Graph.Graph(
  102. [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
  103. [new GraphEdge("a", "b", "eth0 ↔ port1", "connection")]);
  104. var output = _serialiser.Serialise(graph);
  105. Assert.Contains("n_a ---|\"eth0 ↔ port1\"| n_b", output);
  106. }
  107. [Fact]
  108. public void Edge_Without_Label_Renders_Plain_Line() {
  109. var graph = new RackPeek.Domain.Graph.Graph(
  110. [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
  111. [new GraphEdge("a", "b", null, "connection")]);
  112. var output = _serialiser.Serialise(graph);
  113. Assert.Contains("n_a --- n_b", output);
  114. }
  115. [Fact]
  116. public void RunsOn_Edge_Gets_An_Arrow() {
  117. // Directional relationships need a visible arrowhead so the reader
  118. // can tell which side depends on which.
  119. var graph = new RackPeek.Domain.Graph.Graph(
  120. [new GraphNode("svc", "svc", "Service"), new GraphNode("vm", "vm", "Vm")],
  121. [new GraphEdge("svc", "vm", null, "runsOn")]);
  122. var output = _serialiser.Serialise(graph);
  123. Assert.Contains("n_svc --> n_vm", output);
  124. Assert.DoesNotContain("n_svc --- n_vm", output);
  125. }
  126. [Fact]
  127. public void Connection_Edge_Stays_Plain_Line() {
  128. // Port-to-port physical connections are symmetric; no arrow.
  129. var graph = new RackPeek.Domain.Graph.Graph(
  130. [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
  131. [new GraphEdge("a", "b", null, "connection")]);
  132. var output = _serialiser.Serialise(graph);
  133. Assert.Contains("n_a --- n_b", output);
  134. Assert.DoesNotContain("n_a --> n_b", output);
  135. }
  136. [Fact]
  137. public void Edges_Get_Muted_Stroke_Via_LinkStyle() {
  138. // A single linkStyle default rule keeps edges visually quiet so they
  139. // never compete with node labels.
  140. var graph = new RackPeek.Domain.Graph.Graph(
  141. [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
  142. [new GraphEdge("a", "b", null, "connection")]);
  143. var output = _serialiser.Serialise(graph);
  144. Assert.Contains("linkStyle default stroke:", output);
  145. }
  146. [Fact]
  147. public void Node_Borders_Are_Dotted() {
  148. // Pin the dashed-border styling — keeps the look intentionally light.
  149. var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
  150. Assert.Contains("stroke-dasharray:3 3", output);
  151. }
  152. [Fact]
  153. public void Connection_Lines_Are_Dotted() {
  154. var graph = new RackPeek.Domain.Graph.Graph(
  155. [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
  156. [new GraphEdge("a", "b", null, "connection")]);
  157. var output = _serialiser.Serialise(graph);
  158. Assert.Contains("linkStyle default ", output);
  159. Assert.Contains("stroke-dasharray:4 4", output);
  160. }
  161. [Fact]
  162. public void Edge_Label_Background_Is_Transparent() {
  163. // Solid label boxes feel chunky and clip the connection line. A
  164. // transparent background lets labels read as floating annotations.
  165. var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
  166. Assert.Contains("'edgeLabelBackground': 'transparent'", output);
  167. }
  168. private static int CountOccurrences(string haystack, string needle) {
  169. var count = 0;
  170. var idx = 0;
  171. while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0) {
  172. count++;
  173. idx += needle.Length;
  174. }
  175. return count;
  176. }
  177. [Fact]
  178. public void Duplicate_Slugs_Get_Disambiguated() {
  179. // "srv-01" and "srv_01" both slug to "srv_01" — the serialiser must
  180. // produce distinct IDs so Mermaid doesn't collapse them into one node.
  181. var graph = new RackPeek.Domain.Graph.Graph(
  182. [
  183. new GraphNode("srv-01", "srv-01", "Server"),
  184. new GraphNode("srv_01", "srv_01", "Server")
  185. ],
  186. []);
  187. var output = _serialiser.Serialise(graph);
  188. Assert.Contains("n_srv_01[", output);
  189. Assert.Contains("n_srv_01_2[", output);
  190. }
  191. [Fact]
  192. public void Special_Characters_In_Labels_Are_Escaped() {
  193. var graph = new RackPeek.Domain.Graph.Graph(
  194. [new GraphNode("x", "host \"with\" quotes", "Server")],
  195. []);
  196. var output = _serialiser.Serialise(graph);
  197. Assert.Contains("host \\\"with\\\" quotes", output);
  198. }
  199. [Fact]
  200. public void Edge_Referencing_Missing_Node_Is_Dropped() {
  201. var graph = new RackPeek.Domain.Graph.Graph(
  202. [new GraphNode("a", "a", "Server")],
  203. [new GraphEdge("a", "ghost", null, "connection")]);
  204. var output = _serialiser.Serialise(graph);
  205. // Edge with a missing target must be silently dropped — Mermaid would
  206. // otherwise emit a syntax error and the whole diagram would fail.
  207. Assert.DoesNotContain("ghost", output);
  208. }
  209. }