Skip to content

Commit 3b4205c

Browse files
Copilotjongalloway
andcommitted
Address PR review: fix edge regex, forward refs, add Cloud/layout/parser tests
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
1 parent 2e16f5a commit 3b4205c

4 files changed

Lines changed: 216 additions & 17 deletions

File tree

src/DiagramForge/Parsers/Mermaid/MermaidArchitectureParser.cs

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ internal sealed partial class MermaidArchitectureParser : IMermaidDiagramParser
2828
// The {group} suffix is optional and stripped from the node ID.
2929
// Arrow may be --> (directed) or -- (undirected).
3030
[GeneratedRegex(
31-
@"^(?<src>\w+)(?:\{group\})?:(?<srcPort>[LRTB])\s*(?<arrow>--?>?)\s*(?<dstPort>[LRTB]):(?<dst>\w+)(?:\{group\})?$",
31+
@"^(?<src>\w+)(?:\{group\})?:(?<srcPort>[LRTB])\s*(?<arrow>-->|--)\s*(?<dstPort>[LRTB]):(?<dst>\w+)(?:\{group\})?$",
3232
RegexOptions.CultureInvariant)]
3333
private static partial Regex EdgePattern();
3434

@@ -42,28 +42,42 @@ public Diagram Parse(MermaidDocument document)
4242

4343
var diagram = builder.Build();
4444

45-
var groups = new Dictionary<string, DiagramGroup>(StringComparer.Ordinal);
46-
45+
// ── Pass 1: register all groups and collect parent relationships ──────────
46+
// Pre-registering every group ID allows forward references in `in parentId`
47+
// declarations to resolve correctly in pass 2.
48+
var groupParents = new Dictionary<string, string>(StringComparer.Ordinal);
4749
for (int i = 1; i < document.Lines.Length; i++)
4850
{
49-
var line = document.Lines[i];
51+
var groupMatch = GroupPattern().Match(document.Lines[i]);
52+
if (!groupMatch.Success)
53+
continue;
5054

51-
var groupMatch = GroupPattern().Match(line);
52-
if (groupMatch.Success)
53-
{
54-
var id = groupMatch.Groups["id"].Value;
55-
var label = groupMatch.Groups["label"].Value;
56-
var parent = groupMatch.Groups["parent"].Value;
55+
var id = groupMatch.Groups["id"].Value;
56+
var label = groupMatch.Groups["label"].Value;
57+
var parent = groupMatch.Groups["parent"].Value;
5758

58-
var group = new DiagramGroup(id, label);
59-
groups[id] = group;
60-
diagram.AddGroup(group);
59+
diagram.AddGroup(new DiagramGroup(id, label));
60+
if (!string.IsNullOrEmpty(parent))
61+
groupParents[id] = parent;
62+
}
6163

62-
if (!string.IsNullOrEmpty(parent) && groups.TryGetValue(parent, out var parentGroup))
63-
parentGroup.ChildGroupIds.Add(id);
64+
var groups = diagram.Groups.ToDictionary(g => g.Id, StringComparer.Ordinal);
65+
66+
// Apply group nesting now that all group IDs are registered.
67+
foreach (var (childId, parentId) in groupParents)
68+
{
69+
if (groups.TryGetValue(parentId, out var parentGroup))
70+
parentGroup.ChildGroupIds.Add(childId);
71+
}
6472

73+
// ── Pass 2: services, junctions, and edges ────────────────────────────────
74+
for (int i = 1; i < document.Lines.Length; i++)
75+
{
76+
var line = document.Lines[i];
77+
78+
// Skip group lines — already handled in pass 1.
79+
if (GroupPattern().IsMatch(line))
6580
continue;
66-
}
6781

6882
var serviceMatch = ServicePattern().Match(line);
6983
if (serviceMatch.Success)
@@ -106,7 +120,7 @@ public Diagram Parse(MermaidDocument document)
106120

107121
var edge = new Edge(srcId, dstId)
108122
{
109-
ArrowHead = arrow.Contains('>') ? ArrowHeadStyle.Arrow : ArrowHeadStyle.None,
123+
ArrowHead = arrow == "-->" ? ArrowHeadStyle.Arrow : ArrowHeadStyle.None,
110124
LineStyle = EdgeLineStyle.Solid,
111125
};
112126
edge.Metadata["source:port"] = srcPort;

tests/DiagramForge.Tests/Layout/DefaultLayoutEngineTests.cs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,123 @@ public void Layout_BlockDiagram_SpaceGapMovesNodeToLaterColumn()
327327
Assert.True(right.X > left.X + left.Width + diagram.LayoutHints.HorizontalSpacing,
328328
$"Expected right node X {right.X} to reflect a skipped middle column after left node right edge {left.X + left.Width}");
329329
}
330+
331+
// ── Architecture diagram layout ───────────────────────────────────────────
332+
333+
private static Diagram BuildArchitectureDiagram(Action<Diagram> configure)
334+
{
335+
var diagram = new Diagram { DiagramType = "architecture" };
336+
configure(diagram);
337+
return diagram;
338+
}
339+
340+
private static Edge ArchEdge(string srcId, string srcPort, string dstPort, string dstId, bool directed = false)
341+
{
342+
var edge = new Edge(srcId, dstId)
343+
{
344+
ArrowHead = directed ? ArrowHeadStyle.Arrow : ArrowHeadStyle.None,
345+
};
346+
edge.Metadata["source:port"] = srcPort;
347+
edge.Metadata["target:port"] = dstPort;
348+
return edge;
349+
}
350+
351+
[Fact]
352+
public void Layout_Architecture_LREdge_PlacesSourceLeftOfTarget()
353+
{
354+
// db:R -- L:server → db is left of server (same row)
355+
var diagram = BuildArchitectureDiagram(d =>
356+
d.AddNode(new Node("db", "Database"))
357+
.AddNode(new Node("server", "Server"))
358+
.AddEdge(ArchEdge("db", "R", "L", "server")));
359+
360+
_engine.Layout(diagram, _theme);
361+
362+
var db = diagram.Nodes["db"];
363+
var srv = diagram.Nodes["server"];
364+
365+
Assert.True(db.X < srv.X, $"db.X ({db.X}) should be left of server.X ({srv.X}) for R--L edge");
366+
// Same row: Y coordinates should be equal
367+
Assert.Equal(db.Y, srv.Y);
368+
}
369+
370+
[Fact]
371+
public void Layout_Architecture_TBEdge_PlacesSourceAboveTarget()
372+
{
373+
// disk:B -- T:server → disk is above server (same column)
374+
var diagram = BuildArchitectureDiagram(d =>
375+
d.AddNode(new Node("disk", "Storage"))
376+
.AddNode(new Node("server", "Server"))
377+
.AddEdge(ArchEdge("disk", "B", "T", "server")));
378+
379+
_engine.Layout(diagram, _theme);
380+
381+
var disk = diagram.Nodes["disk"];
382+
var srv = diagram.Nodes["server"];
383+
384+
Assert.True(disk.Y < srv.Y, $"disk.Y ({disk.Y}) should be above server.Y ({srv.Y}) for B--T edge");
385+
// Same column: X coordinates should be equal
386+
Assert.Equal(disk.X, srv.X);
387+
}
388+
389+
[Fact]
390+
public void Layout_Architecture_DisconnectedNode_PlacedInSeparateRow()
391+
{
392+
// 'loner' has no port edges; it should end up in a different row from the connected pair
393+
var diagram = BuildArchitectureDiagram(d =>
394+
d.AddNode(new Node("a", "A"))
395+
.AddNode(new Node("b", "B"))
396+
.AddNode(new Node("loner", "Loner"))
397+
.AddEdge(ArchEdge("a", "R", "L", "b")));
398+
399+
_engine.Layout(diagram, _theme);
400+
401+
var a = diagram.Nodes["a"];
402+
var loner = diagram.Nodes["loner"];
403+
404+
// The disconnected node must be placed in a distinct row (different Y)
405+
Assert.NotEqual(a.Y, loner.Y);
406+
}
407+
408+
[Fact]
409+
public void Layout_Architecture_AllPositionsNonNegative()
410+
{
411+
var diagram = BuildArchitectureDiagram(d =>
412+
d.AddNode(new Node("db", "Database"))
413+
.AddNode(new Node("server", "Server"))
414+
.AddEdge(ArchEdge("db", "R", "L", "server")));
415+
416+
_engine.Layout(diagram, _theme);
417+
418+
Assert.All(diagram.Nodes.Values, n =>
419+
{
420+
Assert.True(n.X >= 0, $"{n.Id}.X = {n.X}");
421+
Assert.True(n.Y >= 0, $"{n.Id}.Y = {n.Y}");
422+
});
423+
}
424+
425+
[Fact]
426+
public void Layout_Architecture_WithGroup_GroupEnclosesMembers()
427+
{
428+
var diagram = BuildArchitectureDiagram(d =>
429+
{
430+
d.AddNode(new Node("db", "Database"));
431+
d.AddNode(new Node("server", "Server"));
432+
d.AddEdge(ArchEdge("db", "R", "L", "server"));
433+
var group = new Group("api", "API");
434+
group.ChildNodeIds.AddRange(["db", "server"]);
435+
d.AddGroup(group);
436+
});
437+
438+
_engine.Layout(diagram, _theme);
439+
440+
var group = diagram.Groups[0];
441+
foreach (var n in diagram.Nodes.Values)
442+
{
443+
Assert.True(group.X <= n.X, $"group.X ({group.X}) should be <= {n.Id}.X ({n.X})");
444+
Assert.True(group.Y <= n.Y, $"group.Y ({group.Y}) should be <= {n.Id}.Y ({n.Y})");
445+
Assert.True(group.X + group.Width >= n.X + n.Width, "group right edge should cover node right edge");
446+
Assert.True(group.Y + group.Height >= n.Y + n.Height, "group bottom edge should cover node bottom edge");
447+
}
448+
}
330449
}

tests/DiagramForge.Tests/Parsers/Mermaid/MermaidArchitectureParserTests.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,57 @@ service subnet(internet)[Subnet]
216216
Assert.Equal(ArrowHeadStyle.Arrow, edge.ArrowHead);
217217
}
218218

219+
[Fact]
220+
public void Parse_InvalidArrowSyntax_IsNotTreatedAsEdge()
221+
{
222+
// "->"-style (single dash + >) is not valid architecture-beta edge syntax.
223+
// The parser should not create an edge for it.
224+
const string text = """
225+
architecture-beta
226+
service db(database)[Database]
227+
service server(server)[Server]
228+
db:L -> R:server
229+
""";
230+
231+
var diagram = _parser.Parse(text);
232+
233+
// No edges should be created from the malformed line
234+
Assert.Empty(diagram.Edges);
235+
}
236+
237+
[Fact]
238+
public void Parse_ServiceBeforeGroup_ForwardRefResolvesCorrectly()
239+
{
240+
// Service declared before its parent group: forward reference must still resolve.
241+
const string text = """
242+
architecture-beta
243+
service db(database)[Database] in api
244+
group api(cloud)[API]
245+
""";
246+
247+
var diagram = _parser.Parse(text);
248+
249+
var group = Assert.Single(diagram.Groups);
250+
Assert.Contains("db", group.ChildNodeIds);
251+
}
252+
253+
[Fact]
254+
public void Parse_NestedGroupForwardRef_ResolvesCorrectly()
255+
{
256+
// Child group declared before parent group.
257+
const string text = """
258+
architecture-beta
259+
group private_api(cloud)[Private API] in public_api
260+
group public_api(cloud)[Public API]
261+
""";
262+
263+
var diagram = _parser.Parse(text);
264+
265+
Assert.Equal(2, diagram.Groups.Count);
266+
var parent = diagram.Groups.Single(g => g.Id == "public_api");
267+
Assert.Contains("private_api", parent.ChildGroupIds);
268+
}
269+
219270
// ── Comments ──────────────────────────────────────────────────────────────
220271

221272
[Fact]

tests/DiagramForge.Tests/Rendering/SvgRendererTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,21 @@ public void Render_NullTheme_ThrowsArgumentNullException()
200200
_renderer.Render(new Diagram(), null!));
201201
}
202202

203+
// ── Shape rendering ───────────────────────────────────────────────────────
204+
205+
[Fact]
206+
public void Render_CloudShape_ProducesPathElement()
207+
{
208+
var diagram = BuildAndLayout(new Diagram()
209+
.AddNode(new Node("A", "Cloud Service") { Shape = Shape.Cloud }));
210+
211+
string svg = _renderer.Render(diagram, _theme);
212+
213+
// Cloud is rendered as a <path d="..." />, not a <rect> or <polygon>.
214+
Assert.Contains("<path d=", svg);
215+
Assert.Contains("Cloud Service", svg);
216+
}
217+
203218
// ── Utilities (mirrors the production SvgRenderer.F() helper) ────────────
204219

205220
private static string F(double v) =>

0 commit comments

Comments
 (0)