From 58c55f6608af14b68054d87e1ae29ec16737e0e8 Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 5 Jul 2026 21:43:34 +0200 Subject: [PATCH 1/5] [Feature] Add MSAGL/SVG inheritance and association diagram renderers Add Microsoft.Msagl and Svg package references and the ECoreNetto.Reporting.Drawing renderers (inheritance + association) that produce self-contained SVG, and register them for DI in the Tools host. --- Directory.Packages.props | 2 + .../Drawing/AssociationDiagramRenderer.cs | 650 ++++++++++++++++++ .../Drawing/IAssociationDiagramRenderer.cs | 36 + .../Drawing/IInheritanceDiagramRenderer.cs | 45 ++ .../Drawing/InheritanceDiagramRenderer.cs | 507 ++++++++++++++ .../Drawing/SvgDrawingHelper.cs | 63 ++ .../ECoreNetto.Reporting.csproj | 2 + ECoreNetto.Tools/Program.cs | 5 +- 8 files changed, 1309 insertions(+), 1 deletion(-) create mode 100644 ECoreNetto.Reporting/Drawing/AssociationDiagramRenderer.cs create mode 100644 ECoreNetto.Reporting/Drawing/IAssociationDiagramRenderer.cs create mode 100644 ECoreNetto.Reporting/Drawing/IInheritanceDiagramRenderer.cs create mode 100644 ECoreNetto.Reporting/Drawing/InheritanceDiagramRenderer.cs create mode 100644 ECoreNetto.Reporting/Drawing/SvgDrawingHelper.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index cfa2044..d214d32 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + @@ -28,6 +29,7 @@ + diff --git a/ECoreNetto.Reporting/Drawing/AssociationDiagramRenderer.cs b/ECoreNetto.Reporting/Drawing/AssociationDiagramRenderer.cs new file mode 100644 index 0000000..d90dfa7 --- /dev/null +++ b/ECoreNetto.Reporting/Drawing/AssociationDiagramRenderer.cs @@ -0,0 +1,650 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Reporting.Drawing +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + + using ECoreNetto.Extensions; + using ECoreNetto.Reporting.Payload; + + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Msagl.Core.Geometry.Curves; + using Microsoft.Msagl.Core.Layout; + using Microsoft.Msagl.Core.Routing; + using Microsoft.Msagl.Layout.Layered; + + using Svg; + using Svg.DataTypes; + using Svg.Pathing; + + /// + /// The purpose of the is to render an Ecore association diagram + /// that shows a class and all its first-order neighbours connected by typed references. + /// + public class AssociationDiagramRenderer : IAssociationDiagramRenderer + { + /// + /// The used to log + /// + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class + /// + /// + /// The (injected) used to set up logging + /// + public AssociationDiagramRenderer(ILoggerFactory? loggerFactory = null) + { + this.logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); + } + + /// + /// Renders a per-class association SVG diagram that shows the target class and all classes connected + /// to it via typed references. + /// + /// + /// The for which to render the association diagram. + /// + /// + /// The that contains the Ecore content. + /// + /// + /// a string that contains the per-class association diagram in SVG format, or an empty string when + /// the class has no associations. + /// + public string SvgRenderForClass(EClass targetClass, HandlebarsPayload payload) + { + var classSet = new HashSet(payload.Classes); + + var associationEdges = CollectAssociationEdges(targetClass, payload.Classes, classSet); + + if (associationEdges.Count == 0) + { + return string.Empty; + } + + var involvedClasses = new HashSet { targetClass }; + + foreach (var edge in associationEdges) + { + involvedClasses.Add(edge.OwnerClass); + involvedClasses.Add(edge.TypeClass); + } + + var geometryGraph = this.GenerateGeometryGraph(involvedClasses.ToList(), associationEdges); + + var svgDocument = this.GenerateSvg(geometryGraph, targetClass); + + using var ms = new MemoryStream(); + svgDocument.Write(ms); + ms.Position = 0; + using var reader = new StreamReader(ms); + return reader.ReadToEnd(); + } + + /// + /// Collects all association edges (reference-based relationships) for the target class. + /// + /// + /// The for which to collect associations. + /// + /// + /// All classes in the payload. + /// + /// + /// A of all classes for fast lookup. + /// + /// + /// A list of representing all associations. + /// + private static List CollectAssociationEdges(EClass targetClass, IEnumerable allClasses, HashSet classSet) + { + var edges = new List(); + + foreach (var reference in targetClass.EStructuralFeatures.OfType()) + { + if (reference.EType is EClass typeClass && classSet.Contains(typeClass)) + { + edges.Add(new AssociationEdgeInfo(targetClass, typeClass, reference)); + } + } + + foreach (var otherClass in allClasses) + { + if (otherClass == targetClass) + { + continue; + } + + foreach (var reference in otherClass.EStructuralFeatures.OfType()) + { + if (reference.EType is EClass typeClass && typeClass == targetClass) + { + edges.Add(new AssociationEdgeInfo(otherClass, targetClass, reference)); + } + } + } + + return edges; + } + + /// + /// Generates a laid-out for the association diagram. + /// + /// + /// The classes to include in the graph. + /// + /// + /// The association edges to include. + /// + /// + /// an instance of . + /// + private GeometryGraph GenerateGeometryGraph(List classes, List associationEdges) + { + var geometryGraph = new GeometryGraph(); + + foreach (var @class in classes) + { + var (height, width) = SvgDrawingHelper.EstimateBoxSize(@class.Name); + + var curve = CurveFactory.CreateRectangle(width, height, new Microsoft.Msagl.Core.Geometry.Point()); + + var node = new Node(curve, @class); + + geometryGraph.Nodes.Add(node); + } + + foreach (var assocEdge in associationEdges) + { + var sourceNode = geometryGraph.FindNodeByUserData(assocEdge.OwnerClass); + var targetNode = geometryGraph.FindNodeByUserData(assocEdge.TypeClass); + + if (sourceNode != null && targetNode != null) + { + var edge = new Edge(sourceNode, targetNode) + { + UserData = assocEdge + }; + + geometryGraph.Edges.Add(edge); + } + } + + var settings = new SugiyamaLayoutSettings + { + LayerSeparation = 180, + NodeSeparation = 120, + EdgeRoutingSettings = new EdgeRoutingSettings + { + EdgeRoutingMode = EdgeRoutingMode.Rectilinear, + }, + }; + + var layoutEngine = new LayeredLayout(geometryGraph, settings); + layoutEngine.Run(); + + return geometryGraph; + } + + /// + /// Generates an SVG document for the association diagram. + /// + /// + /// The subject . + /// + /// + /// The that should be highlighted in the diagram. + /// + /// + /// The generated . + /// + private SvgDocument GenerateSvg(GeometryGraph geometryGraph, EClass targetClass) + { + const float padding = 40f; + + var bbox = geometryGraph.BoundingBox; + + var width = (float)(bbox.Width + 2 * padding); + var height = (float)(bbox.Height + 2 * padding); + + var anchor = targetClass.QueryAnchorId(); + + var svgDocument = new SvgDocument + { + Width = width, + Height = height, + ViewBox = new SvgViewBox( + (float)(bbox.Left - padding), + (float)(bbox.Bottom - padding), + width, + height), + ID = $"association-diagram-{anchor}" + }; + + svgDocument.Children.Add(new SvgRectangle + { + X = (float)(bbox.Left - padding), + Y = (float)(bbox.Bottom - padding), + Width = width, + Height = height, + Fill = SvgPaintServer.None, + Stroke = new SvgColourServer(System.Drawing.Color.Black), + StrokeWidth = 1 + }); + + this.AddMarkerDefinitions(svgDocument, anchor); + + foreach (var node in geometryGraph.Nodes) + { + var @class = (EClass)node.UserData; + svgDocument.Children.Add(this.ConvertNodeToRectangleAndLabel(node, @class == targetClass)); + } + + foreach (var edge in geometryGraph.Edges) + { + var group = this.ConvertEdgeToSvgGroup(edge, anchor); + if (group != null) + { + svgDocument.Children.Add(group); + } + } + + return svgDocument; + } + + /// + /// Adds SVG marker definitions for the composition diamond and the navigability arrow. + /// + /// + /// The to add markers to. + /// + /// + /// A prefix for marker ids to ensure uniqueness. + /// + private void AddMarkerDefinitions(SvgDocument svgDocument, string idPrefix) + { + var compositionMarker = new SvgMarker + { + ID = $"composition-diamond-{idPrefix}", + MarkerUnits = SvgMarkerUnits.StrokeWidth, + MarkerWidth = 12, + MarkerHeight = 8, + RefX = 0, + RefY = 4, + Orient = new SvgOrient { IsAuto = true } + }; + + compositionMarker.Children.Add(new SvgPath + { + Stroke = new SvgColourServer(System.Drawing.Color.Black), + Fill = new SvgColourServer(System.Drawing.Color.Black), + StrokeWidth = 1, + PathData = SvgPathBuilder.Parse("M0,4 L6,0 L12,4 L6,8 Z".AsSpan()) + }); + + svgDocument.Children.Add(compositionMarker); + + var arrowMarker = new SvgMarker + { + ID = $"navigable-arrow-{idPrefix}", + MarkerUnits = SvgMarkerUnits.StrokeWidth, + MarkerWidth = 10, + MarkerHeight = 10, + RefX = 10, + RefY = 5, + Orient = new SvgOrient { IsAuto = true } + }; + + arrowMarker.Children.Add(new SvgPath + { + Stroke = new SvgColourServer(System.Drawing.Color.Black), + Fill = SvgPaintServer.None, + StrokeWidth = 1, + PathData = SvgPathBuilder.Parse("M0,0 L10,5 L0,10".AsSpan()) + }); + + svgDocument.Children.Add(arrowMarker); + } + + /// + /// Converts a to an containing a rectangle, label and tooltip. + /// + /// + /// The that represents an in the association diagram. + /// + /// + /// Whether this node represents the target class that should be highlighted. + /// + /// + /// the . + /// + private SvgGroup ConvertNodeToRectangleAndLabel(Node node, bool isTarget) + { + var box = node.BoundingBox; + var @class = (EClass)node.UserData; + + var fillColor = isTarget ? System.Drawing.Color.FromArgb(5, 166, 229) : System.Drawing.Color.White; + var textColor = isTarget ? System.Drawing.Color.White : System.Drawing.Color.Black; + + var anchor = new SvgAnchor + { + Href = $"#{@class.QueryAnchorId()}" + }; + + var rectangle = new SvgRectangle + { + X = (float)box.Left, + Y = (float)box.Bottom, + Width = (float)box.Width, + Height = (float)box.Height, + Fill = new SvgColourServer(fillColor), + Stroke = new SvgColourServer(System.Drawing.Color.Black) + }; + + var label = new SvgText(@class.Name) + { + X = { (float)box.Center.X }, + Y = { (float)box.Center.Y + 4 }, + TextAnchor = SvgTextAnchor.Middle, + FontSize = 12, + FontFamily = "sans-serif", + Fill = new SvgColourServer(textColor), + FontStyle = @class.Abstract ? SvgFontStyle.Italic : SvgFontStyle.Normal + }; + + anchor.Children.Add(rectangle); + anchor.Children.Add(label); + anchor.Children.Add(new SvgTitle { Content = $"Name: {@class.Name}\nIs Abstract: {@class.Abstract}" }); + + var group = new SvgGroup(); + group.Children.Add(anchor); + + return group; + } + + /// + /// Converts an into an containing the edge path, markers, + /// multiplicity labels and role name. + /// + /// + /// The subject that is to be converted. + /// + /// + /// A prefix for marker reference ids. + /// + /// + /// the resulting , or null when the edge has no curve. + /// + private SvgGroup? ConvertEdgeToSvgGroup(Edge edge, string idPrefix) + { + var curve = edge.Curve; + if (curve == null) + { + return null; + } + + var assocEdge = (AssociationEdgeInfo)edge.UserData; + var reference = assocEdge.Reference; + + var segments = new SvgPathSegmentList(); + + segments.Add(new SvgMoveToSegment(false, SvgDrawingHelper.ToPointF(curve.Start))); + + switch (curve) + { + case Curve compound: + foreach (var segment in compound.Segments) + { + this.AddSegment(segments, segment); + } + + break; + + case LineSegment line: + segments.Add(new SvgLineSegment(false, SvgDrawingHelper.ToPointF(line.End))); + break; + + case CubicBezierSegment bezier: + segments.Add(new SvgCubicCurveSegment( + false, + SvgDrawingHelper.ToPointF(bezier.B(0)), + SvgDrawingHelper.ToPointF(bezier.B(1)), + SvgDrawingHelper.ToPointF(bezier.B(3)))); + break; + + case Polyline polyline: + foreach (var point in polyline.Skip(1)) + { + segments.Add(new SvgLineSegment(false, SvgDrawingHelper.ToPointF(point))); + } + + break; + + default: + this.logger.LogWarning("Unsupported Curve type encountered: {CurveType}", curve.GetType().FullName); + return null; + } + + var svgPath = new SvgPath + { + Stroke = new SvgColourServer(System.Drawing.Color.Black), + Fill = SvgPaintServer.None, + PathData = segments, + MarkerEnd = new Uri($"url(#navigable-arrow-{idPrefix})", UriKind.Relative) + }; + + if (reference.IsContainment) + { + svgPath.MarkerStart = new Uri($"url(#composition-diamond-{idPrefix})", UriKind.Relative); + } + + var group = new SvgGroup(); + + var hitArea = new SvgPath + { + Stroke = new SvgColourServer(System.Drawing.Color.Transparent), + Fill = SvgPaintServer.None, + PathData = segments, + StrokeWidth = 12 + }; + + group.Children.Add(hitArea); + group.Children.Add(svgPath); + + var multiplicity = FormatMultiplicity(reference.LowerBound, reference.UpperBound); + + group.Children.Add(new SvgTitle + { + Content = $"Source: {assocEdge.OwnerClass.Name}\n" + + $"Target: {assocEdge.TypeClass.Name}\n" + + $"Reference: {reference.Name}\n" + + $"Multiplicity: {multiplicity}\n" + + $"Containment: {reference.IsContainment}" + }); + + var endPoint = curve.End; + var penultimatePoint = GetPenultimatePoint(curve); + var (labelOffsetX, labelOffsetY, textAnchor) = ComputeLabelOffset(endPoint, penultimatePoint); + + group.Children.Add(new SvgText(multiplicity) + { + X = { (float)(endPoint.X + labelOffsetX) }, + Y = { (float)(endPoint.Y + labelOffsetY - 10) }, + TextAnchor = textAnchor, + FontSize = 10, + FontFamily = "sans-serif", + Fill = new SvgColourServer(System.Drawing.Color.DarkBlue) + }); + + if (!string.IsNullOrEmpty(reference.Name)) + { + group.Children.Add(new SvgText(reference.Name) + { + X = { (float)(endPoint.X + labelOffsetX) }, + Y = { (float)(endPoint.Y + labelOffsetY + 4) }, + TextAnchor = textAnchor, + FontSize = 10, + FontFamily = "sans-serif", + Fill = new SvgColourServer(System.Drawing.Color.DarkGray) + }); + } + + return group; + } + + /// + /// Adds a segment to the given based on the specified MSAGL curve segment. + /// + /// + /// The to which the SVG path segment will be added. + /// + /// + /// The MSAGL segment to convert into an SVG path segment. + /// + private void AddSegment(SvgPathSegmentList segments, ICurve segment) + { + switch (segment) + { + case LineSegment line: + segments.Add(new SvgLineSegment(false, SvgDrawingHelper.ToPointF(line.End))); + break; + + case CubicBezierSegment bezier: + segments.Add(new SvgCubicCurveSegment( + false, + SvgDrawingHelper.ToPointF(bezier.B(0)), + SvgDrawingHelper.ToPointF(bezier.B(1)), + SvgDrawingHelper.ToPointF(bezier.B(3)))); + break; + + default: + this.logger.LogWarning("Unsupported segment type encountered: {SegmentType}", segment.GetType().FullName); + break; + } + } + + /// + /// Gets the penultimate point on the curve (the point just before the end), used to determine the + /// direction the edge approaches its target. + /// + /// + /// The from which to extract the penultimate point. + /// + /// + /// The penultimate . + /// + private static Microsoft.Msagl.Core.Geometry.Point GetPenultimatePoint(ICurve curve) + { + switch (curve) + { + case Curve compound when compound.Segments.Count > 0: + return compound.Segments[compound.Segments.Count - 1].Start; + + case Polyline polyline: + var points = polyline.ToArray(); + return points.Length >= 2 ? points[points.Length - 2] : curve.Start; + + default: + return curve.Start; + } + } + + /// + /// Computes the label offset and text anchor based on the direction the edge approaches the target node. + /// + /// + /// The endpoint of the edge curve. + /// + /// + /// The point just before the endpoint on the curve. + /// + /// + /// A tuple of (offsetX, offsetY, textAnchor) for positioning the label. + /// + private static (double OffsetX, double OffsetY, SvgTextAnchor Anchor) ComputeLabelOffset( + Microsoft.Msagl.Core.Geometry.Point endPoint, + Microsoft.Msagl.Core.Geometry.Point penultimatePoint) + { + var dx = endPoint.X - penultimatePoint.X; + var dy = endPoint.Y - penultimatePoint.Y; + + if (Math.Abs(dx) > Math.Abs(dy)) + { + if (dx > 0) + { + return (-20, -14, SvgTextAnchor.End); + } + + return (20, -14, SvgTextAnchor.Start); + } + + if (dy > 0) + { + return (10, -24, SvgTextAnchor.Start); + } + + return (10, 28, SvgTextAnchor.Start); + } + + /// + /// Formats the multiplicity of a reference as [lower..upper], using * for the unbounded + /// upper bound (-1). + /// + /// the lower bound. + /// the upper bound (-1 means unbounded). + /// the formatted multiplicity string. + private static string FormatMultiplicity(int lowerBound, int upperBound) + { + var upper = upperBound == -1 ? "*" : upperBound.ToString(); + + return $"[{lowerBound}..{upper}]"; + } + + /// + /// Represents an association edge between two classes via a reference. + /// + private class AssociationEdgeInfo + { + /// + /// Initializes a new instance of the class. + /// + /// The class that owns the reference. + /// The class that is the type of the reference. + /// The reference that defines the relationship. + public AssociationEdgeInfo(EClass ownerClass, EClass typeClass, EReference reference) + { + this.OwnerClass = ownerClass; + this.TypeClass = typeClass; + this.Reference = reference; + } + + /// + /// Gets the class that owns the reference. + /// + public EClass OwnerClass { get; } + + /// + /// Gets the class that is the type of the reference. + /// + public EClass TypeClass { get; } + + /// + /// Gets the reference that defines the relationship. + /// + public EReference Reference { get; } + } + } +} diff --git a/ECoreNetto.Reporting/Drawing/IAssociationDiagramRenderer.cs b/ECoreNetto.Reporting/Drawing/IAssociationDiagramRenderer.cs new file mode 100644 index 0000000..f5920f4 --- /dev/null +++ b/ECoreNetto.Reporting/Drawing/IAssociationDiagramRenderer.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Reporting.Drawing +{ + using ECoreNetto.Reporting.Payload; + + /// + /// Definition of a service that renders a per-class Ecore association diagram as SVG. + /// + public interface IAssociationDiagramRenderer + { + /// + /// Renders a per-class association SVG diagram that shows the target class and all classes connected + /// to it via typed references, with multiplicity labels and UML notation (composition diamonds and + /// navigability arrows). + /// + /// + /// The for which to render the association diagram. + /// + /// + /// The that contains the Ecore content. + /// + /// + /// a string that contains the per-class association diagram in SVG format, or an empty string when + /// the class has no associations. + /// + string SvgRenderForClass(EClass targetClass, HandlebarsPayload payload); + } +} diff --git a/ECoreNetto.Reporting/Drawing/IInheritanceDiagramRenderer.cs b/ECoreNetto.Reporting/Drawing/IInheritanceDiagramRenderer.cs new file mode 100644 index 0000000..78c6ce4 --- /dev/null +++ b/ECoreNetto.Reporting/Drawing/IInheritanceDiagramRenderer.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Reporting.Drawing +{ + using ECoreNetto.Reporting.Payload; + + /// + /// Definition of a service that renders an Ecore class inheritance diagram as SVG. + /// + public interface IInheritanceDiagramRenderer + { + /// + /// Renders a model-wide inheritance diagram for the classes in the provided . + /// + /// + /// The that contains the Ecore content. + /// + /// + /// a string that contains the diagram in SVG format. + /// + string SvgRender(HandlebarsPayload payload); + + /// + /// Renders a per-class inheritance tree SVG diagram that highlights the target class and shows all + /// its ancestors and descendants. + /// + /// + /// The for which to render the inheritance tree. + /// + /// + /// The that contains the Ecore content. + /// + /// + /// a string that contains the per-class inheritance diagram in SVG format. + /// + string SvgRenderForClass(EClass targetClass, HandlebarsPayload payload); + } +} diff --git a/ECoreNetto.Reporting/Drawing/InheritanceDiagramRenderer.cs b/ECoreNetto.Reporting/Drawing/InheritanceDiagramRenderer.cs new file mode 100644 index 0000000..33866e2 --- /dev/null +++ b/ECoreNetto.Reporting/Drawing/InheritanceDiagramRenderer.cs @@ -0,0 +1,507 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Reporting.Drawing +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + + using ECoreNetto.Extensions; + using ECoreNetto.Reporting.Payload; + + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Msagl.Core.Geometry.Curves; + using Microsoft.Msagl.Core.Layout; + using Microsoft.Msagl.Core.Routing; + using Microsoft.Msagl.Layout.Layered; + + using Svg; + using Svg.DataTypes; + using Svg.Pathing; + + /// + /// The purpose of the is to render an Ecore class diagram + /// that shows the inheritance of the classes in an Ecore model. + /// + public class InheritanceDiagramRenderer : IInheritanceDiagramRenderer + { + /// + /// The used to log + /// + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class + /// + /// + /// The (injected) used to set up logging + /// + public InheritanceDiagramRenderer(ILoggerFactory? loggerFactory = null) + { + this.logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); + } + + /// + /// Renders a model-wide inheritance diagram for the classes in the provided . + /// + /// + /// The that contains the Ecore content. + /// + /// + /// a string that contains the diagram in SVG format. + /// + public string SvgRender(HandlebarsPayload payload) + { + var geometryGraph = this.GenerateGeometryGraphForClasses(payload.Classes.ToList()); + + var svgDocument = this.GenerateSvg(geometryGraph); + + return Serialize(svgDocument); + } + + /// + /// Renders a per-class inheritance tree SVG diagram that highlights the target class and shows all + /// its ancestors and descendants. + /// + /// + /// The for which to render the inheritance tree. + /// + /// + /// The that contains the Ecore content. + /// + /// + /// a string that contains the per-class inheritance diagram in SVG format. + /// + public string SvgRenderForClass(EClass targetClass, HandlebarsPayload payload) + { + var ancestors = targetClass.QueryTypeHierarchy().ToList(); + var descendants = targetClass.QueryAllDescendantSpecializations(payload.Classes).ToList(); + + var treeClasses = new HashSet(ancestors) { targetClass }; + + foreach (var descendant in descendants) + { + treeClasses.Add(descendant); + } + + var filteredClasses = payload.Classes.Where(c => treeClasses.Contains(c)).ToList(); + + var geometryGraph = this.GenerateGeometryGraphForClasses(filteredClasses); + + var svgDocument = this.GenerateSvgForClass(geometryGraph, targetClass); + + return Serialize(svgDocument); + } + + /// + /// Serializes an to an SVG string. + /// + /// + /// The subject . + /// + /// + /// the SVG document as a string. + /// + private static string Serialize(SvgDocument svgDocument) + { + using var ms = new MemoryStream(); + svgDocument.Write(ms); + ms.Position = 0; + using var reader = new StreamReader(ms); + return reader.ReadToEnd(); + } + + /// + /// Generate a laid-out for the provided classes, with an inheritance + /// edge for each super type. + /// + /// + /// The classes to include in the graph. + /// + /// + /// an instance of . + /// + private GeometryGraph GenerateGeometryGraphForClasses(List classes) + { + var geometryGraph = new GeometryGraph(); + + foreach (var @class in classes) + { + var (height, width) = SvgDrawingHelper.EstimateBoxSize(@class.Name); + + var curve = CurveFactory.CreateRectangle(width, height, new Microsoft.Msagl.Core.Geometry.Point()); + + var node = new Node(curve, @class); + + geometryGraph.Nodes.Add(node); + } + + foreach (var @class in classes) + { + foreach (var superClass in @class.ESuperTypes) + { + var sourceNode = geometryGraph.FindNodeByUserData(@class); + var targetNode = geometryGraph.FindNodeByUserData(superClass); + + if (sourceNode != null && targetNode != null) + { + var edge = new Edge(sourceNode, targetNode); + + geometryGraph.Edges.Add(edge); + } + } + } + + this.logger.LogInformation("inheritance edges count: {Edges}", geometryGraph.Edges.Count); + + var settings = new SugiyamaLayoutSettings + { + LayerSeparation = 30, + NodeSeparation = 10, + EdgeRoutingSettings = new EdgeRoutingSettings + { + EdgeRoutingMode = EdgeRoutingMode.Rectilinear, + }, + }; + + var layoutEngine = new LayeredLayout(geometryGraph, settings); + layoutEngine.Run(); + + return geometryGraph; + } + + /// + /// Generates an SVG document for the whole-model inheritance diagram. + /// + /// + /// the subject . + /// + /// + /// The generated . + /// + private SvgDocument GenerateSvg(GeometryGraph geometryGraph) + { + const float padding = 10f; + + var bbox = geometryGraph.BoundingBox; + + var width = (float)(bbox.Width + 2 * padding); + var height = (float)(bbox.Height + 2 * padding); + + var svgDocument = new SvgDocument + { + Width = width, + Height = height, + ViewBox = new SvgViewBox( + (float)(bbox.Left - padding), + (float)(bbox.Bottom - padding), + width, + height), + ID = "inheritance-diagram" + }; + + svgDocument.Children.Add(new SvgTitle { Content = "Ecore Inheritance Diagram" }); + svgDocument.Children.Add(new SvgDescription + { + Content = "An Ecore diagram showing class inheritance relationships - generated with EcoreNetto" + }); + + svgDocument.Children.Add(CreateBorder(bbox, padding, width, height)); + + svgDocument.Children.Add(CreateArrowMarker("generalization-arrow")); + + foreach (var node in geometryGraph.Nodes) + { + svgDocument.Children.Add(this.ConvertNodeToRectangleAndLabel(node, false)); + } + + foreach (var edge in geometryGraph.Edges) + { + var svgPath = this.ConvertEdgeToSvgPath(edge, "generalization-arrow"); + if (svgPath != null) + { + svgDocument.Children.Add(svgPath); + } + } + + return svgDocument; + } + + /// + /// Generates an SVG document for a per-class inheritance tree with the target class highlighted. + /// + /// + /// the subject . + /// + /// + /// The that should be highlighted in the diagram. + /// + /// + /// The generated . + /// + private SvgDocument GenerateSvgForClass(GeometryGraph geometryGraph, EClass targetClass) + { + const float padding = 10f; + + var bbox = geometryGraph.BoundingBox; + + var width = (float)(bbox.Width + 2 * padding); + var height = (float)(bbox.Height + 2 * padding); + + var anchor = targetClass.QueryAnchorId(); + var markerId = $"gen-arrow-{anchor}"; + + var svgDocument = new SvgDocument + { + Width = width, + Height = height, + ViewBox = new SvgViewBox( + (float)(bbox.Left - padding), + (float)(bbox.Bottom - padding), + width, + height), + ID = $"inheritance-tree-{anchor}" + }; + + svgDocument.Children.Add(CreateBorder(bbox, padding, width, height)); + svgDocument.Children.Add(CreateArrowMarker(markerId)); + + foreach (var node in geometryGraph.Nodes) + { + var @class = (EClass)node.UserData; + svgDocument.Children.Add(this.ConvertNodeToRectangleAndLabel(node, @class == targetClass)); + } + + foreach (var edge in geometryGraph.Edges) + { + var svgPath = this.ConvertEdgeToSvgPath(edge, markerId); + if (svgPath != null) + { + svgDocument.Children.Add(svgPath); + } + } + + return svgDocument; + } + + /// + /// Creates the diagram border rectangle. + /// + /// the bounding box of the graph. + /// the diagram padding. + /// the diagram width. + /// the diagram height. + /// the border . + private static SvgRectangle CreateBorder(Microsoft.Msagl.Core.Geometry.Rectangle bbox, float padding, float width, float height) + { + return new SvgRectangle + { + X = (float)(bbox.Left - padding), + Y = (float)(bbox.Bottom - padding), + Width = width, + Height = height, + Fill = SvgPaintServer.None, + Stroke = new SvgColourServer(System.Drawing.Color.Black), + StrokeWidth = 1 + }; + } + + /// + /// Creates a hollow-triangle generalization arrow marker with the provided id. + /// + /// the marker id. + /// the . + private static SvgMarker CreateArrowMarker(string markerId) + { + var arrowMarker = new SvgMarker + { + ID = markerId, + MarkerUnits = SvgMarkerUnits.StrokeWidth, + MarkerWidth = 10, + MarkerHeight = 10, + RefX = 10, + RefY = 5, + Orient = new SvgOrient { IsAuto = true } + }; + + arrowMarker.Children.Add(new SvgPath + { + Stroke = new SvgColourServer(System.Drawing.Color.Black), + Fill = new SvgColourServer(System.Drawing.Color.White), + StrokeWidth = 1, + PathData = SvgPathBuilder.Parse("M0,0 L10,5 L0,10 Z".AsSpan()) + }); + + return arrowMarker; + } + + /// + /// Converts a to an containing a rectangle, label and tooltip. + /// + /// + /// The that represents an in the inheritance diagram. + /// + /// + /// Whether this node represents the target class that should be highlighted. + /// + /// + /// the . + /// + private SvgGroup ConvertNodeToRectangleAndLabel(Node node, bool isTarget) + { + var box = node.BoundingBox; + var @class = (EClass)node.UserData; + + var fillColor = isTarget ? System.Drawing.Color.FromArgb(5, 166, 229) : System.Drawing.Color.White; + var textColor = isTarget ? System.Drawing.Color.White : System.Drawing.Color.Black; + + var anchor = new SvgAnchor + { + Href = $"#{@class.QueryAnchorId()}" + }; + + var rectangle = new SvgRectangle + { + X = (float)box.Left, + Y = (float)box.Bottom, + Width = (float)box.Width, + Height = (float)box.Height, + Fill = new SvgColourServer(fillColor), + Stroke = new SvgColourServer(System.Drawing.Color.Black) + }; + + var label = new SvgText(@class.Name) + { + X = { (float)box.Center.X }, + Y = { (float)box.Center.Y + 4 }, + TextAnchor = SvgTextAnchor.Middle, + FontSize = 12, + FontFamily = "sans-serif", + Fill = new SvgColourServer(textColor), + FontStyle = @class.Abstract ? SvgFontStyle.Italic : SvgFontStyle.Normal + }; + + var tooltipText = $"Name: {@class.Name}\n" + + $"Is Abstract: {@class.Abstract}\n" + + $"Superclasses: {string.Join(", ", @class.ESuperTypes.Select(c => c.Name))}\n" + + $"Description: {@class.QueryRawDocumentation()}"; + + anchor.Children.Add(rectangle); + anchor.Children.Add(label); + anchor.Children.Add(new SvgTitle { Content = tooltipText }); + + var group = new SvgGroup(); + group.Children.Add(anchor); + + return group; + } + + /// + /// Converts an into an using the specified marker id. + /// + /// + /// The subject that is to be converted. + /// + /// + /// The id of the arrow marker to use. + /// + /// + /// the resulting , or null when the edge has no curve. + /// + private SvgPath? ConvertEdgeToSvgPath(Edge edge, string markerId) + { + var curve = edge.Curve; + if (curve == null) + { + return null; + } + + var segments = new SvgPathSegmentList(); + + segments.Add(new SvgMoveToSegment(false, SvgDrawingHelper.ToPointF(curve.Start))); + + switch (curve) + { + case Curve compound: + foreach (var segment in compound.Segments) + { + this.AddSegment(segments, segment); + } + + break; + + case LineSegment line: + segments.Add(new SvgLineSegment(false, SvgDrawingHelper.ToPointF(line.End))); + break; + + case CubicBezierSegment bezier: + segments.Add(new SvgCubicCurveSegment( + false, + SvgDrawingHelper.ToPointF(bezier.B(0)), + SvgDrawingHelper.ToPointF(bezier.B(1)), + SvgDrawingHelper.ToPointF(bezier.B(3)))); + break; + + case Polyline polyline: + foreach (var point in polyline.Skip(1)) + { + segments.Add(new SvgLineSegment(false, SvgDrawingHelper.ToPointF(point))); + } + + break; + + default: + this.logger.LogWarning("Unsupported Curve type encountered: {CurveType}", curve.GetType().FullName); + return null; + } + + return new SvgPath + { + Stroke = new SvgColourServer(System.Drawing.Color.Black), + Fill = SvgPaintServer.None, + PathData = segments, + MarkerEnd = new Uri($"url(#{markerId})", UriKind.Relative) + }; + } + + /// + /// Adds a segment to the given based on the specified MSAGL curve segment. + /// + /// + /// The to which the SVG path segment will be added. + /// + /// + /// The MSAGL segment to convert into an SVG path segment. + /// + private void AddSegment(SvgPathSegmentList segments, ICurve segment) + { + switch (segment) + { + case LineSegment line: + segments.Add(new SvgLineSegment(false, SvgDrawingHelper.ToPointF(line.End))); + break; + + case CubicBezierSegment bezier: + segments.Add(new SvgCubicCurveSegment( + false, + SvgDrawingHelper.ToPointF(bezier.B(0)), + SvgDrawingHelper.ToPointF(bezier.B(1)), + SvgDrawingHelper.ToPointF(bezier.B(3)))); + break; + + default: + this.logger.LogWarning("Unsupported segment type encountered: {SegmentType}", segment.GetType().FullName); + break; + } + } + } +} diff --git a/ECoreNetto.Reporting/Drawing/SvgDrawingHelper.cs b/ECoreNetto.Reporting/Drawing/SvgDrawingHelper.cs new file mode 100644 index 0000000..2574227 --- /dev/null +++ b/ECoreNetto.Reporting/Drawing/SvgDrawingHelper.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Reporting.Drawing +{ + using System.Drawing; + + /// + /// static class that provides helper methods for SVG drawing + /// + public static class SvgDrawingHelper + { + /// + /// Estimates the size of a box based on the text that is used as its label + /// + /// + /// the content of the label + /// + /// + /// The size of the used font + /// + /// + /// the padding surrounding the text + /// + /// + /// the estimated height and width of the box + /// + /// + /// The average character width is around 60% of the font size + /// + public static (double Height, double Width) EstimateBoxSize(string label, double fontSize = 12, double padding = 10) + { + var avgCharWidth = 0.6 * fontSize; + + var width = (label.Length * avgCharWidth) + 2 * padding; + + var height = fontSize + 2 * padding; + + return (height, width); + } + + /// + /// Converts a to a . + /// + /// + /// The to convert. + /// + /// + /// A with the same X and Y coordinates as the input point, + /// cast to . + /// + public static PointF ToPointF(Microsoft.Msagl.Core.Geometry.Point pt) + { + return new PointF((float)pt.X, (float)pt.Y); + } + } +} diff --git a/ECoreNetto.Reporting/ECoreNetto.Reporting.csproj b/ECoreNetto.Reporting/ECoreNetto.Reporting.csproj index e57f5e0..9d88d54 100644 --- a/ECoreNetto.Reporting/ECoreNetto.Reporting.csproj +++ b/ECoreNetto.Reporting/ECoreNetto.Reporting.csproj @@ -41,6 +41,8 @@ + + diff --git a/ECoreNetto.Tools/Program.cs b/ECoreNetto.Tools/Program.cs index f07a098..8ba58e8 100644 --- a/ECoreNetto.Tools/Program.cs +++ b/ECoreNetto.Tools/Program.cs @@ -24,8 +24,9 @@ namespace ECoreNetto.Tools using Serilog.Events; using ECoreNetto.Tools.Commands; + using ECoreNetto.Reporting.Drawing; using ECoreNetto.Reporting.Generators; - + using ECoreNetto.Tools.Services; /// @@ -94,6 +95,8 @@ private static IHostBuilder CreateHostBuilder(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); From cfe95c0fa7b1d85308b6ac7453cda8bcaa916fa7 Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 5 Jul 2026 21:43:49 +0200 Subject: [PATCH 2/5] [Feature] Add report extensions, payload split and Handlebars helpers for parity Add stable anchor slugs, constraint/container/descendant queries and ConstraintInfo; new Anchor/Parameter/Class/IEnumerable helpers and id/opposite helpers; split the payload into primitive types, other data types and interfaces. --- ECoreNetto.Extensions/AnchorExtensions.cs | 55 ++++++++ ECoreNetto.Extensions/ClassExtensions.cs | 123 ++++++++++++++++++ ECoreNetto.Extensions/ConstraintInfo.cs | 52 ++++++++ ECoreNetto.HandleBars/AnchorHelper.cs | 43 ++++++ ECoreNetto.HandleBars/ClassHelper.cs | 123 ++++++++++++++++++ ECoreNetto.HandleBars/IEnumerableHelper.cs | 42 ++++++ ECoreNetto.HandleBars/ParameterHelper.cs | 93 +++++++++++++ .../StructuralFeatureHelper.cs | 24 ++++ .../Generators/HandleBarsReportGenerator.cs | 18 ++- .../Payload/HandlebarsPayload.cs | 26 +++- 10 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 ECoreNetto.Extensions/AnchorExtensions.cs create mode 100644 ECoreNetto.Extensions/ConstraintInfo.cs create mode 100644 ECoreNetto.HandleBars/AnchorHelper.cs create mode 100644 ECoreNetto.HandleBars/ClassHelper.cs create mode 100644 ECoreNetto.HandleBars/IEnumerableHelper.cs create mode 100644 ECoreNetto.HandleBars/ParameterHelper.cs diff --git a/ECoreNetto.Extensions/AnchorExtensions.cs b/ECoreNetto.Extensions/AnchorExtensions.cs new file mode 100644 index 0000000..bc07d9b --- /dev/null +++ b/ECoreNetto.Extensions/AnchorExtensions.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Extensions +{ + using System; + using System.Text.RegularExpressions; + + /// + /// Extension methods that produce stable, collision-free HTML anchors for s. + /// + public static class AnchorExtensions + { + /// + /// Queries a stable, unique HTML anchor identifier for the provided , derived + /// from its (unique) by replacing every run of characters that are + /// not valid in an HTML fragment with a single dash. + /// + /// + /// The for which the anchor is computed. + /// + /// + /// A sanitized anchor slug (e.g. recipe-ecore-Recipe), suitable for use as an id + /// attribute and as an href="#..." target. + /// + /// + /// Thrown when is null. + /// + public static string QueryAnchorId(this EObject eObject) + { + if (eObject == null) + { + throw new ArgumentNullException(nameof(eObject)); + } + + // A classifier that is referenced across resources (e.g. a super-type, an inherited feature's + // containing class or a feature type defined in another .ecore) may be an unattached proxy with + // no EContainer. Computing its Identifier would dereference a null EPackage, so fall back to the + // simple name for such detached classifiers. + var identifier = eObject is EClassifier { EContainer: null } detachedClassifier + ? detachedClassifier.Name ?? string.Empty + : eObject.Identifier ?? string.Empty; + + var sanitized = Regex.Replace(identifier, "[^A-Za-z0-9]+", "-").Trim('-'); + + return string.IsNullOrEmpty(sanitized) ? "anchor" : sanitized; + } + } +} diff --git a/ECoreNetto.Extensions/ClassExtensions.cs b/ECoreNetto.Extensions/ClassExtensions.cs index 9bc7aa3..187b166 100644 --- a/ECoreNetto.Extensions/ClassExtensions.cs +++ b/ECoreNetto.Extensions/ClassExtensions.cs @@ -43,6 +43,129 @@ public static IEnumerable QuerySpecializations(this EClass @class, IEnum return result; } + /// + /// The annotation source under which Ecore declares the names of a classifier's constraints. + /// + private const string EcoreAnnotationSource = "http://www.eclipse.org/emf/2002/Ecore"; + + /// + /// The annotation source under which Ecore stores the OCL body of each named constraint. + /// + private const string OclAnnotationSource = "http://www.eclipse.org/emf/2002/Ecore/OCL"; + + /// + /// Queries all (transitive) specializations (direct and indirect sub classes) of the subject . + /// + /// + /// The for which the descendant specializations are computed. + /// + /// + /// The from which the specializations are computed. + /// + /// + /// An that contains all direct and indirect sub classes of the subject . + /// + public static IEnumerable QueryAllDescendantSpecializations(this EClass @class, IEnumerable allClasses) + { + if (@class == null) + { + throw new ArgumentNullException(nameof(@class)); + } + + var classes = allClasses.ToList(); + + var result = new List(); + var queue = new Queue(@class.QuerySpecializations(classes)); + + while (queue.Count > 0) + { + var specialization = queue.Dequeue(); + + if (result.Contains(specialization) || specialization == @class) + { + continue; + } + + result.Add(specialization); + + foreach (var deeper in specialization.QuerySpecializations(classes)) + { + queue.Enqueue(deeper); + } + } + + return result; + } + + /// + /// Queries the classes that contain the subject through a containment . + /// + /// + /// The for which the containers are computed. + /// + /// + /// The from which the containers are computed. + /// + /// + /// An that owns a containment reference whose type is the subject . + /// + public static IEnumerable QueryContainers(this EClass @class, IEnumerable allClasses) + { + if (@class == null) + { + throw new ArgumentNullException(nameof(@class)); + } + + return allClasses + .Where(candidate => candidate.EStructuralFeatures + .OfType() + .Any(reference => reference.IsContainment && reference.EType == @class)); + } + + /// + /// Queries the constraints (rules) declared on the subject through its Ecore + /// constraint annotation, pairing each constraint name with its OCL body when present. + /// + /// + /// The whose constraints are read. + /// + /// + /// An , one per declared constraint name; empty when the + /// class declares no constraints. + /// + public static IEnumerable QueryConstraints(this EClass @class) + { + if (@class == null) + { + throw new ArgumentNullException(nameof(@class)); + } + + var result = new List(); + + var constraintsAnnotation = @class.EAnnotations.FirstOrDefault(a => a.Source == EcoreAnnotationSource); + if (constraintsAnnotation == null || !constraintsAnnotation.Details.TryGetValue("constraints", out var names)) + { + return result; + } + + var oclAnnotation = @class.EAnnotations.FirstOrDefault(a => a.Source == OclAnnotationSource); + + foreach (var name in names.Split(' ')) + { + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + string? body = null; + oclAnnotation?.Details.TryGetValue(name, out body); + + result.Add(new ConstraintInfo(name, body ?? string.Empty, oclAnnotation != null ? "OCL" : string.Empty)); + } + + return result; + } + /// /// Queries the type hierarchy (chain of super classes) of the provided /// diff --git a/ECoreNetto.Extensions/ConstraintInfo.cs b/ECoreNetto.Extensions/ConstraintInfo.cs new file mode 100644 index 0000000..d91e811 --- /dev/null +++ b/ECoreNetto.Extensions/ConstraintInfo.cs @@ -0,0 +1,52 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Extensions +{ + /// + /// Represents a named constraint (rule) declared on an , together with its + /// specification body and language, as read from the Ecore constraint/OCL annotations. + /// + public class ConstraintInfo + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The name of the constraint. + /// + /// + /// The specification body (e.g. the OCL expression), or an empty string when none is declared. + /// + /// + /// The specification language (e.g. OCL), or an empty string when unknown. + /// + public ConstraintInfo(string name, string body, string language) + { + this.Name = name; + this.Body = body; + this.Language = language; + } + + /// + /// Gets the name of the constraint. + /// + public string Name { get; } + + /// + /// Gets the specification body of the constraint (e.g. the OCL expression). + /// + public string Body { get; } + + /// + /// Gets the specification language of the constraint (e.g. OCL). + /// + public string Language { get; } + } +} diff --git a/ECoreNetto.HandleBars/AnchorHelper.cs b/ECoreNetto.HandleBars/AnchorHelper.cs new file mode 100644 index 0000000..f3eb520 --- /dev/null +++ b/ECoreNetto.HandleBars/AnchorHelper.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars +{ + using ECoreNetto; + using ECoreNetto.Extensions; + + using HandlebarsDotNet; + + /// + /// A handlebars helper that writes a stable, unique HTML anchor for an . + /// + public static class AnchorHelper + { + /// + /// Registers the + /// + /// + /// The context with which the helper needs to be registered + /// + public static void RegisterAnchorHelper(this IHandlebars handlebars) + { + handlebars.RegisterHelper("Anchor", (writer, _, arguments) => + { + if (arguments.Length != 1) + { + throw new HandlebarsException("{{Anchor}} helper must have exactly one argument"); + } + + var eObject = (EObject)arguments[0]!; + + writer.WriteSafeString(eObject.QueryAnchorId()); + }); + } + } +} diff --git a/ECoreNetto.HandleBars/ClassHelper.cs b/ECoreNetto.HandleBars/ClassHelper.cs new file mode 100644 index 0000000..53c9470 --- /dev/null +++ b/ECoreNetto.HandleBars/ClassHelper.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars +{ + using System.Collections.Generic; + using System.Linq; + + using ECoreNetto; + using ECoreNetto.Extensions; + + using HandlebarsDotNet; + + /// + /// Handlebars helpers for the class: specialization/container/constraint queries + /// and per-class diagram rendering. + /// + public static class ClassHelper + { + /// + /// Registers the + /// + /// + /// The context with which the helper needs to be registered + /// + public static void RegisterClassHelper(this IHandlebars handlebars) + { + handlebars.RegisterHelper("Class.QuerySpecializations", (_, arguments) => + { + if (arguments.Length != 2) + { + throw new HandlebarsException("{{Class.QuerySpecializations}} helper must have exactly two arguments"); + } + + var @class = (EClass)arguments[0]!; + var allClasses = ((IEnumerable)arguments[1]!).ToList(); + + return @class.QuerySpecializations(allClasses).OrderBy(x => x.Name); + }); + + handlebars.RegisterHelper("Class.QueryContainers", (_, arguments) => + { + if (arguments.Length != 2) + { + throw new HandlebarsException("{{Class.QueryContainers}} helper must have exactly two arguments"); + } + + var @class = (EClass)arguments[0]!; + var allClasses = ((IEnumerable)arguments[1]!).ToList(); + + return @class.QueryContainers(allClasses).OrderBy(x => x.Name); + }); + + handlebars.RegisterHelper("Class.QueryConstraints", (_, arguments) => + { + if (arguments.Length != 1) + { + throw new HandlebarsException("{{Class.QueryConstraints}} helper must have exactly one argument"); + } + + var @class = (EClass)arguments[0]!; + + return @class.QueryConstraints(); + }); + + handlebars.RegisterHelper("Class.RenderInheritanceDiagram", (output, options, _, arguments) => + { + if (!TryGetDiagram(arguments, out var svg)) + { + return; + } + + options.Template(output, svg); + }); + + handlebars.RegisterHelper("Class.RenderAssociationDiagram", (output, options, _, arguments) => + { + if (!TryGetDiagram(arguments, out var svg)) + { + return; + } + + options.Template(output, svg); + }); + } + + /// + /// Attempts to resolve the pre-rendered diagram SVG for the class passed as the first argument, from + /// the anchor-keyed dictionary passed as the second argument. + /// + /// + /// The block-helper arguments: an and an anchor-keyed diagram dictionary. + /// + /// + /// The resolved SVG string when present. + /// + /// + /// True when a diagram exists for the class; otherwise false. + /// + private static bool TryGetDiagram(Arguments arguments, out string? svg) + { + svg = null; + + if (arguments.Length < 2) + { + return false; + } + + if (!(arguments[0] is EClass @class) || !(arguments[1] is IDictionary diagrams)) + { + return false; + } + + return diagrams.TryGetValue(@class.QueryAnchorId(), out svg); + } + } +} diff --git a/ECoreNetto.HandleBars/IEnumerableHelper.cs b/ECoreNetto.HandleBars/IEnumerableHelper.cs new file mode 100644 index 0000000..025b4c9 --- /dev/null +++ b/ECoreNetto.HandleBars/IEnumerableHelper.cs @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars +{ + using System.Collections; + using System.Linq; + + using HandlebarsDotNet; + + /// + /// A handlebars helper that reports whether an is empty, used to guard the + /// empty-state messages of the report sections. + /// + public static class IEnumerableHelper + { + /// + /// Registers the + /// + /// + /// The context with which the helper needs to be registered + /// + public static void RegisterIEnumerableHelper(this IHandlebars handlebars) + { + handlebars.RegisterHelper("IEnumerable.IsEmpty", (_, arguments) => + { + if (arguments.Length != 1) + { + throw new HandlebarsException("{{#IEnumerable.IsEmpty}} helper must have exactly one argument"); + } + + return !(arguments[0] is IEnumerable enumerable) || !enumerable.Cast().Any(); + }); + } + } +} diff --git a/ECoreNetto.HandleBars/ParameterHelper.cs b/ECoreNetto.HandleBars/ParameterHelper.cs new file mode 100644 index 0000000..593c48a --- /dev/null +++ b/ECoreNetto.HandleBars/ParameterHelper.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars +{ + using ECoreNetto; + using ECoreNetto.Extensions; + + using HandlebarsDotNet; + + /// + /// Handlebars helpers that render s and return + /// types (type link, name and multiplicity). + /// + public static class ParameterHelper + { + /// + /// Registers the + /// + /// + /// The context with which the helper needs to be registered + /// + public static void RegisterParameterHelper(this IHandlebars handlebars) + { + handlebars.RegisterHelper("Parameter.WriteTypeAndName", (writer, _, arguments) => + { + if (arguments.Length != 1) + { + throw new HandlebarsException("{{Parameter.WriteTypeAndName}} helper must have exactly one argument"); + } + + var parameter = (EParameter)arguments[0]!; + + if (parameter.EType == null) + { + writer.WriteSafeString("void"); + } + else + { + writer.WriteSafeString($"{parameter.EType.Name}"); + } + + writer.WriteSafeString($" {parameter.Name}"); + writer.WriteSafeString($" {FormatMultiplicity(parameter.LowerBound, parameter.UpperBound)}"); + }); + + handlebars.RegisterHelper("TypedElement.WriteReturnType", (writer, _, arguments) => + { + if (arguments.Length != 1) + { + throw new HandlebarsException("{{TypedElement.WriteReturnType}} helper must have exactly one argument"); + } + + var typedElement = (ETypedElement)arguments[0]!; + + if (typedElement.EType == null) + { + writer.WriteSafeString("void"); + return; + } + + writer.WriteSafeString($"{typedElement.EType.Name}"); + writer.WriteSafeString($" {FormatMultiplicity(typedElement.LowerBound, typedElement.UpperBound)}"); + }); + } + + /// + /// Formats the multiplicity of a typed element as [lower..upper], using * for the + /// unbounded upper bound (-1). + /// + /// + /// The lower bound. + /// + /// + /// The upper bound (-1 means unbounded). + /// + /// + /// The formatted multiplicity string. + /// + private static string FormatMultiplicity(int lowerBound, int upperBound) + { + var upper = upperBound == -1 ? "*" : upperBound.ToString(); + + return $"[{lowerBound}..{upper}]"; + } + } +} diff --git a/ECoreNetto.HandleBars/StructuralFeatureHelper.cs b/ECoreNetto.HandleBars/StructuralFeatureHelper.cs index cd2b8e5..a8f3c69 100644 --- a/ECoreNetto.HandleBars/StructuralFeatureHelper.cs +++ b/ECoreNetto.HandleBars/StructuralFeatureHelper.cs @@ -212,6 +212,30 @@ public static void RegisterStructuralFeatureHelper(this IHandlebars handlebars) writer.WriteSafeString($"{typeName}"); }); + + handlebars.RegisterHelper("StructuralFeature.QueryIsId", (_, arguments) => + { + if (arguments.Length != 1) + { + throw new HandlebarsException("{{#StructuralFeature.QueryIsId}} helper must have exactly one argument"); + } + + return arguments.Single() is EAttribute eAttribute && eAttribute.ID; + }); + + handlebars.RegisterHelper("StructuralFeature.WriteOpposite", (writer, _, arguments) => + { + if (arguments.Length != 1) + { + throw new HandlebarsException("{{StructuralFeature.WriteOpposite}} helper must have exactly one argument"); + } + + if (arguments.Single() is EReference eReference && eReference.EOpposite != null) + { + var opposite = eReference.EOpposite; + writer.WriteSafeString($" {{opposite: {opposite.Name}}}"); + } + }); } } } diff --git a/ECoreNetto.Reporting/Generators/HandleBarsReportGenerator.cs b/ECoreNetto.Reporting/Generators/HandleBarsReportGenerator.cs index e9d3258..f078a0b 100644 --- a/ECoreNetto.Reporting/Generators/HandleBarsReportGenerator.cs +++ b/ECoreNetto.Reporting/Generators/HandleBarsReportGenerator.cs @@ -63,6 +63,11 @@ protected virtual void RegisterHelpers() ECoreNetto.HandleBars.StructuralFeatureHelper.RegisterStructuralFeatureHelper(this.Handlebars); ECoreNetto.HandleBars.GeneralizationHelper.RegisterGeneralizationHelper(this.Handlebars); ECoreNetto.HandleBars.DocumentationHelper.RegisteredDocumentationHelper(this.Handlebars); + ECoreNetto.HandleBars.AnchorHelper.RegisterAnchorHelper(this.Handlebars); + ECoreNetto.HandleBars.ParameterHelper.RegisterParameterHelper(this.Handlebars); + ECoreNetto.HandleBars.ClassHelper.RegisterClassHelper(this.Handlebars); + ECoreNetto.HandleBars.BooleanHelper.RegisterBooleanHelper(this.Handlebars); + ECoreNetto.HandleBars.IEnumerableHelper.RegisterIEnumerableHelper(this.Handlebars); } /// @@ -110,17 +115,22 @@ protected static HandlebarsPayload CreateHandlebarsPayload(EPackage rootPackage) dataTypes.AddRange(package.EClassifiers .OfType() - .Where(x => !(x is EEnum)) - .OrderBy(x => x.Name)); + .Where(x => !(x is EEnum))); eClasses.AddRange(package.EClassifiers.OfType()); } var orderedEnums = enums.OrderBy(x => x.Name); - var orderedDataTypes = dataTypes.OrderBy(x => x.Name); + + // a data type backed by an instance class (e.g. 'java.lang.String', 'int') is treated as a + // primitive type; a modeled data type without an instance class is an "other" data type. + var orderedPrimitiveTypes = dataTypes.Where(x => !string.IsNullOrEmpty(x.InstanceClassName)).OrderBy(x => x.Name); + var orderedDataTypes = dataTypes.Where(x => string.IsNullOrEmpty(x.InstanceClassName)).OrderBy(x => x.Name); + var orderedClasses = eClasses.OrderBy(x => x.Name); + var orderedInterfaces = eClasses.Where(x => x.Interface).OrderBy(x => x.Name); - var payload = new HandlebarsPayload(rootPackage, orderedEnums, orderedDataTypes, orderedClasses); + var payload = new HandlebarsPayload(rootPackage, orderedEnums, orderedPrimitiveTypes, orderedDataTypes, orderedClasses, orderedInterfaces); return payload; } diff --git a/ECoreNetto.Reporting/Payload/HandlebarsPayload.cs b/ECoreNetto.Reporting/Payload/HandlebarsPayload.cs index 4b70a9e..e8a5e25 100644 --- a/ECoreNetto.Reporting/Payload/HandlebarsPayload.cs +++ b/ECoreNetto.Reporting/Payload/HandlebarsPayload.cs @@ -30,18 +30,26 @@ public class HandlebarsPayload /// /// the s in the ECore model /// + /// + /// the primitive s in the ECore model (those backed by an instance class) + /// /// - /// the s in the ECore model + /// the non-primitive s in the ECore model /// /// /// the es in the ECore model /// - public HandlebarsPayload(EPackage rootPackage, IEnumerable enums, IEnumerable dataTypes, IEnumerable classes) + /// + /// the es in the ECore model that are marked as interfaces + /// + public HandlebarsPayload(EPackage rootPackage, IEnumerable enums, IEnumerable primitiveTypes, IEnumerable dataTypes, IEnumerable classes, IEnumerable interfaces) { this.RootPackage = rootPackage; this.Enums = enums.ToArray(); + this.PrimitiveTypes = primitiveTypes.ToArray(); this.DataTypes = dataTypes.ToArray(); this.Classes = classes.ToArray(); + this.Interfaces = interfaces.ToArray(); } /// @@ -55,15 +63,25 @@ public HandlebarsPayload(EPackage rootPackage, IEnumerable enums, IEnumer public EEnum[] Enums { get; private set; } /// - /// Gets the array of + /// Gets the array of primitive (data types backed by an instance class) + /// + public EDataType[] PrimitiveTypes { get; private set; } + + /// + /// Gets the array of non-primitive /// public EDataType[] DataTypes { get; private set; } /// - /// Gets the array of + /// Gets the array of (including those marked as interfaces) /// public EClass[] Classes { get; private set; } + /// + /// Gets the array of that are marked as interfaces + /// + public EClass[] Interfaces { get; private set; } + /// /// Gets the version of the reporting library /// From 054f09eed7b31d11ac72c5536b6ca57e79c4b32f Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 5 Jul 2026 21:43:56 +0200 Subject: [PATCH 3/5] [Feature] Rewrite HTML report template with collapsible UX, diagrams and custom HTML Render operations, feature-flag chips, attribute/reference split, specializations/containers, OCL rules, collapsible sections with expand/collapse-all, embedded inheritance and association SVG with download, a custom-HTML injection point and stable anchors; clean up dead CSS. --- .../Generators/HtmlReportGenerator.cs | 95 +++- .../Generators/IHtmlReportGenerator.cs | 22 +- .../Templates/ecore-to-html-docs.hbs | 438 +++++++++++++----- 3 files changed, 423 insertions(+), 132 deletions(-) diff --git a/ECoreNetto.Reporting/Generators/HtmlReportGenerator.cs b/ECoreNetto.Reporting/Generators/HtmlReportGenerator.cs index 2a2018a..dd35988 100644 --- a/ECoreNetto.Reporting/Generators/HtmlReportGenerator.cs +++ b/ECoreNetto.Reporting/Generators/HtmlReportGenerator.cs @@ -10,8 +10,13 @@ namespace ECoreNetto.Reporting.Generators { using System; + using System.Collections.Generic; using System.Diagnostics; using System.IO; + using System.Linq; + + using ECoreNetto.Extensions; + using ECoreNetto.Reporting.Drawing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -27,15 +32,45 @@ public class HtmlReportGenerator : HandleBarsReportGenerator, IHtmlReportGenerat /// private readonly ILogger logger; + /// + /// The used to render the inheritance diagrams + /// + private readonly IInheritanceDiagramRenderer inheritanceDiagramRenderer; + + /// + /// The used to render the per-class association diagrams + /// + private readonly IAssociationDiagramRenderer associationDiagramRenderer; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The (injected) used to set up logging + /// + public HtmlReportGenerator(ILoggerFactory? loggerFactory = null) + : this(null, null, loggerFactory) + { + } + /// /// Initializes a new instance of the class. /// + /// + /// The (injected) ; a default instance is used when null. + /// + /// + /// The (injected) ; a default instance is used when null. + /// /// /// The (injected) used to set up logging /// - public HtmlReportGenerator(ILoggerFactory? loggerFactory = null) : base(loggerFactory) + public HtmlReportGenerator(IInheritanceDiagramRenderer? inheritanceDiagramRenderer, IAssociationDiagramRenderer? associationDiagramRenderer, ILoggerFactory? loggerFactory = null) : base(loggerFactory) { this.logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); + + this.inheritanceDiagramRenderer = inheritanceDiagramRenderer ?? new InheritanceDiagramRenderer(loggerFactory); + this.associationDiagramRenderer = associationDiagramRenderer ?? new AssociationDiagramRenderer(loggerFactory); } /// @@ -53,12 +88,15 @@ public string QueryReportType() /// Generates a table that contains all classes, attributes and their documentation /// /// - /// /// the path to the Ecore model of which the report is to be generated. + /// the path to the Ecore model of which the report is to be generated. + /// + /// + /// optional custom HTML that is injected into the report at the custom-HTML injection point. /// /// /// the content of an HTML report in a string /// - public string GenerateReport(FileInfo modelPath) + public string GenerateReport(FileInfo modelPath, string customHtml = "") { if (modelPath == null) { @@ -75,7 +113,36 @@ public string GenerateReport(FileInfo modelPath) var payload = CreateHandlebarsPayload(rootPackage); - var generatedHtml = template(payload); + var inheritanceDiagramSvg = this.inheritanceDiagramRenderer.SvgRender(payload); + + var classInheritanceDiagrams = new Dictionary(); + var classAssociationDiagrams = new Dictionary(); + + foreach (var eClass in payload.Classes) + { + var anchor = eClass.QueryAnchorId(); + + // a per-class inheritance tree is only meaningful when the class participates in a hierarchy + if (eClass.ESuperTypes.Any() || eClass.QuerySpecializations(payload.Classes).Any()) + { + classInheritanceDiagrams[anchor] = this.inheritanceDiagramRenderer.SvgRenderForClass(eClass, payload); + } + + var associationSvg = this.associationDiagramRenderer.SvgRenderForClass(eClass, payload); + if (!string.IsNullOrEmpty(associationSvg)) + { + classAssociationDiagrams[anchor] = associationSvg; + } + } + + var generatedHtml = template(new + { + Payload = payload, + InheritanceDiagramSvg = inheritanceDiagramSvg, + ClassInheritanceDiagrams = classInheritanceDiagrams, + ClassAssociationDiagrams = classAssociationDiagrams, + CustomHtml = customHtml + }); this.logger.LogInformation("Generated HTML report in {ElapsedTime} [ms]", sw.ElapsedMilliseconds); @@ -92,6 +159,24 @@ public string GenerateReport(FileInfo modelPath) /// the path, including filename, where the output is to be generated. /// public void GenerateReport(FileInfo modelPath, FileInfo outputPath) + { + this.GenerateReport(modelPath, outputPath, string.Empty); + } + + /// + /// Generates an HTML report and writes it to the provided . + /// + /// + /// the path to the Ecore model of which the report is to be generated. + /// + /// + /// the path, including filename, where the output is to be generated. + /// + /// + /// custom HTML that is injected into the report at the custom-HTML injection point; pass + /// when none is required. + /// + public void GenerateReport(FileInfo modelPath, FileInfo outputPath, string customHtml) { if (modelPath == null) { @@ -105,7 +190,7 @@ public void GenerateReport(FileInfo modelPath, FileInfo outputPath) var sw = Stopwatch.StartNew(); - var generatedHtml = this.GenerateReport(modelPath); + var generatedHtml = this.GenerateReport(modelPath, customHtml); if (outputPath.Exists) { diff --git a/ECoreNetto.Reporting/Generators/IHtmlReportGenerator.cs b/ECoreNetto.Reporting/Generators/IHtmlReportGenerator.cs index 6301f50..8e1cf52 100644 --- a/ECoreNetto.Reporting/Generators/IHtmlReportGenerator.cs +++ b/ECoreNetto.Reporting/Generators/IHtmlReportGenerator.cs @@ -21,11 +21,29 @@ public interface IHtmlReportGenerator : IReportGenerator /// Generates a table that contains all classes, attributes and their documentation /// /// - /// /// the path to the Ecore model of which the report is to be generated. + /// the path to the Ecore model of which the report is to be generated. + /// + /// + /// optional custom HTML that is injected into the report at the custom-HTML injection point. /// /// /// the content of an HTML report in a string /// - public string GenerateReport(FileInfo modelPath); + public string GenerateReport(FileInfo modelPath, string customHtml = ""); + + /// + /// Generates an HTML report and writes it to the provided . + /// + /// + /// the path to the Ecore model of which the report is to be generated. + /// + /// + /// the path, including filename, where the output is to be generated. + /// + /// + /// custom HTML that is injected into the report at the custom-HTML injection point; pass + /// when none is required. + /// + public void GenerateReport(FileInfo modelPath, FileInfo outputPath, string customHtml); } } diff --git a/ECoreNetto.Reporting/Templates/ecore-to-html-docs.hbs b/ECoreNetto.Reporting/Templates/ecore-to-html-docs.hbs index d158242..260a9bd 100644 --- a/ECoreNetto.Reporting/Templates/ecore-to-html-docs.hbs +++ b/ECoreNetto.Reporting/Templates/ecore-to-html-docs.hbs @@ -52,8 +52,6 @@ flex: 3; } - @charset 'UTF-8'; - .starion-table { font-family: "Lucida Sans Unicode", "Lucida Grande", Sans-Serif; text-align: left; @@ -93,7 +91,6 @@ ol { counter-reset: li; /* Initiate a counter */ list-style: none; /* Remove default numbering */ - *list-style: decimal; /* Keep using default numbering for IE6/7 */ padding: 0; margin-bottom: 4em; text-shadow: 0 1px 0 rgba(255,255,255,.5); @@ -111,86 +108,6 @@ margin: 0 0 0 2em; /* Add some left margin for inner lists */ } - .rectangle-list li{ - position: relative; - *padding: .4em; - margin: .5em 0 2.5em 0em; - color: #444; - text-decoration: none; - transition: all .3s ease-out; - letter-spacing: 1px; - font-size: 6pt; - } - - .rectangle-list a{ - position: relative; - padding: .4em .4em .4em .8em; - *padding: .4em; - margin: .5em 0 .5em 2.5em; - background: #fff; - color: #00A4E4; - font-weight: bold; - text-decoration: none; - transition: all .3s ease-out; - font-size: 8pt; - } - - .rectangle-list a:hover{ - background: #eee; - } - - .rectangle-list a:before{ - content: counter(li); - counter-increment: li; - position: absolute; - left: -2.5em; - top: 50%; - margin-top: -1em; - background: #BFD9FF; - height: 2em; - width: 2em; - line-height: 2em; - text-align: center; - font-weight: bold; - } - - ul.rectangle-list a:before{ - content:""; - font-family: FontAwesome; - counter-increment: li; - position: absolute; - left: -2.5em; - top: 50%; - margin-top: -1em; - background: #BFD9FF; - height: 2em; - width: 2em; - line-height: 2em; - text-align: center; - font-weight: bold; - - text-decoration: none; - font-style: normal; - - -webkit-font-smoothing:antialiased; - -moz-osx-font-smoothing:grayscale; - } - - .rectangle-list a:after{ - position: absolute; - content: ''; - border: .5em solid transparent; - left: -1em; - top: 50%; - margin-top: -.5em; - transition: all .3s ease-out; - } - - .rectangle-list a:hover:after{ - left: -.5em; - border-left-color: #BFD9FF; - } - h2 { font-size: 1.8em; font-weight: bold; @@ -205,76 +122,130 @@ font-size: 1.2em; font-weight: bold; } + /* ---- collapsible sections, diagrams and navigation (issue #91) ---- */ + details.collapsible-section { margin: 0.5em 0; } + details.collapsible-section > summary { cursor: pointer; list-style: none; font-weight: bold; padding: 4px 0; } + details.collapsible-section > summary::-webkit-details-marker { display: none; } + details.collapsible-section > summary::before { content: "\25B6"; display: inline-block; margin-right: 6px; transition: transform 0.15s ease; } + details.collapsible-section[open] > summary::before { transform: rotate(90deg); } + details.collapsible-section > summary > H4 { display: inline; } + .section-toolbar { margin: 0.5em 0; } + .section-toolbar button { margin-right: 6px; cursor: pointer; } + .svg-scroll-container { overflow-x: auto; max-width: 100%; border: 1px solid #ddd; padding: 4px; } + .svg-scroll-container svg { max-width: none; } + .chip { display: inline-block; color: #05a6e5; font-size: 0.85em; white-space: nowrap; } + .download-svg { display: inline-block; margin-top: 6px; font-size: 0.9em; } + .empty-state { color: #666; font-style: italic; } + .nav-fab { position: fixed; right: 1.5em; width: 2.6em; height: 2.6em; border-radius: 50%; border: none; background: #05a6e5; color: #fff; font-size: 1em; cursor: pointer; display: none; z-index: 100; } + .back-to-top { bottom: 2em; } + .go-to-diagram { bottom: 5.5em; } + .sidebar-toggle { position: fixed; top: 0.6em; left: 0.6em; z-index: 101; border: none; background: #05a6e5; color: #fff; font-size: 1.1em; cursor: pointer; padding: 4px 8px; border-radius: 4px; } + aside.collapsed { display: none; } + .page-footer { margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 0.85em; color: #666; } - {{ this.RootPackage.Name }} - Ecore Model Documentation + {{ Payload.RootPackage.Name }} - Ecore Model Documentation + + + + + +
-
+ \ No newline at end of file From 0eebac559e5904f9ea11a557ad05645f22fc968b Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 5 Jul 2026 21:44:09 +0200 Subject: [PATCH 4/5] [Test] Cover the HTML report parity features, renderers, helpers and extensions Add a feature-rich synthetic ecore asset and tests for the new helpers, extensions and diagram renderers; extend the HTML report fixtures with feature, custom-HTML, overwrite and Open Graph assertions. fixes #91 --- .../AnchorExtensionsTestFixture.cs | 63 +++++++ .../ClassExtensionsFeaturesTestFixture.cs | 128 +++++++++++++++ .../ECoreNetto.Extensions.Tests.csproj | 3 + .../AnchorHelperTestFixture.cs | 70 ++++++++ .../ClassHelperTestFixture.cs | 155 ++++++++++++++++++ .../ECoreNetto.HandleBars.Tests.csproj | 3 + .../IEnumerableHelperTestFixture.cs | 78 +++++++++ .../ParameterHelperTestFixture.cs | 137 ++++++++++++++++ ...ucturalFeatureHelperFeaturesTestFixture.cs | 120 ++++++++++++++ .../Drawing/DiagramRendererTestFixture.cs | 112 +++++++++++++ .../ECoreNetto.Reporting.Tests.csproj | 3 + .../HtmlReportGeneratorTestFixture.cs | 90 ++++++++++ TestData/report-features.ecore | 48 ++++++ 13 files changed, 1010 insertions(+) create mode 100644 ECoreNetto.Extensions.Tests/AnchorExtensionsTestFixture.cs create mode 100644 ECoreNetto.Extensions.Tests/ClassExtensionsFeaturesTestFixture.cs create mode 100644 ECoreNetto.HandleBars.Tests/AnchorHelperTestFixture.cs create mode 100644 ECoreNetto.HandleBars.Tests/ClassHelperTestFixture.cs create mode 100644 ECoreNetto.HandleBars.Tests/IEnumerableHelperTestFixture.cs create mode 100644 ECoreNetto.HandleBars.Tests/ParameterHelperTestFixture.cs create mode 100644 ECoreNetto.HandleBars.Tests/StructuralFeatureHelperFeaturesTestFixture.cs create mode 100644 ECoreNetto.Reporting.Tests/Drawing/DiagramRendererTestFixture.cs create mode 100644 TestData/report-features.ecore diff --git a/ECoreNetto.Extensions.Tests/AnchorExtensionsTestFixture.cs b/ECoreNetto.Extensions.Tests/AnchorExtensionsTestFixture.cs new file mode 100644 index 0000000..22d4e0a --- /dev/null +++ b/ECoreNetto.Extensions.Tests/AnchorExtensionsTestFixture.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Extensions.Tests +{ + using System.IO; + using System.Linq; + + using ECoreNetto.Extensions; + using ECoreNetto.Resource; + + using NUnit.Framework; + + /// + /// Suite of tests for the class + /// + [TestFixture] + public class AnchorExtensionsTestFixture + { + private EPackage rootPackage = null!; + + [SetUp] + public void SetUp() + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "recipe.ecore"); + var uri = new System.Uri(Path.GetFullPath(path)); + + var resourceSet = new ResourceSet(); + var resource = resourceSet.CreateResource(uri); + + this.rootPackage = resource.Load(null); + } + + [Test] + public void Verify_that_QueryAnchorId_returns_a_sanitized_slug() + { + var recipe = this.rootPackage.EClassifiers.OfType().Single(x => x.Name == "Recipe"); + + var anchor = recipe.QueryAnchorId(); + + Assert.Multiple(() => + { + // the slug contains only URL-safe characters and ends with the class name + Assert.That(anchor, Does.Match("^[A-Za-z0-9-]+$")); + Assert.That(anchor, Does.EndWith("Recipe")); + }); + } + + [Test] + public void Verify_that_QueryAnchorId_throws_when_argument_is_null() + { + EObject? eObject = null; + + Assert.That(() => eObject!.QueryAnchorId(), Throws.ArgumentNullException); + } + } +} diff --git a/ECoreNetto.Extensions.Tests/ClassExtensionsFeaturesTestFixture.cs b/ECoreNetto.Extensions.Tests/ClassExtensionsFeaturesTestFixture.cs new file mode 100644 index 0000000..8aabe2e --- /dev/null +++ b/ECoreNetto.Extensions.Tests/ClassExtensionsFeaturesTestFixture.cs @@ -0,0 +1,128 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Extensions.Tests +{ + using System.Collections.Generic; + using System.IO; + using System.Linq; + + using ECoreNetto.Extensions; + using ECoreNetto.Resource; + + using NUnit.Framework; + + /// + /// Suite of tests for the report-parity extension methods on that are + /// exercised against the feature-rich synthetic model. + /// + [TestFixture] + public class ClassExtensionsFeaturesTestFixture + { + private EPackage rootPackage = null!; + private List allClasses = null!; + + [SetUp] + public void SetUp() + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "report-features.ecore"); + var uri = new System.Uri(Path.GetFullPath(path)); + + var resourceSet = new ResourceSet(); + var resource = resourceSet.CreateResource(uri); + + this.rootPackage = resource.Load(null); + this.allClasses = this.rootPackage.EClassifiers.OfType().ToList(); + } + + [Test] + public void Verify_that_QueryContainers_returns_the_containing_classes() + { + var address = this.allClasses.Single(x => x.Name == "Address"); + + var containers = address.QueryContainers(this.allClasses).ToList(); + + // Person owns a containment reference (addresses) whose type is Address + Assert.That(containers.Select(x => x.Name), Is.EquivalentTo(new[] { "Person" })); + } + + [Test] + public void Verify_that_QueryContainers_returns_empty_when_no_containment_reference_targets_the_class() + { + var company = this.allClasses.Single(x => x.Name == "Company"); + + var containers = company.QueryContainers(this.allClasses); + + Assert.That(containers, Is.Empty); + } + + [Test] + public void Verify_that_QueryContainers_throws_when_argument_is_null() + { + EClass? @class = null; + + Assert.That(() => @class!.QueryContainers(this.allClasses), Throws.ArgumentNullException); + } + + [Test] + public void Verify_that_QueryAllDescendantSpecializations_returns_all_subclasses() + { + var describable = this.allClasses.Single(x => x.Name == "Describable"); + + var descendants = describable.QueryAllDescendantSpecializations(this.allClasses).ToList(); + + Assert.That(descendants.Select(x => x.Name), Is.EquivalentTo(new[] { "Person" })); + } + + [Test] + public void Verify_that_QueryAllDescendantSpecializations_throws_when_argument_is_null() + { + EClass? @class = null; + + Assert.That(() => @class!.QueryAllDescendantSpecializations(this.allClasses), Throws.ArgumentNullException); + } + + [Test] + public void Verify_that_QueryConstraints_reads_the_constraint_and_its_ocl_body() + { + var person = this.allClasses.Single(x => x.Name == "Person"); + + var constraints = person.QueryConstraints().ToList(); + + Assert.That(constraints, Has.Count.EqualTo(1)); + + var constraint = constraints.Single(); + + Assert.Multiple(() => + { + Assert.That(constraint.Name, Is.EqualTo("hasFullName")); + Assert.That(constraint.Language, Is.EqualTo("OCL")); + Assert.That(constraint.Body, Is.EqualTo("self.fullName.notEmpty()")); + }); + } + + [Test] + public void Verify_that_QueryConstraints_returns_empty_when_no_constraints_are_declared() + { + var address = this.allClasses.Single(x => x.Name == "Address"); + + var constraints = address.QueryConstraints(); + + Assert.That(constraints, Is.Empty); + } + + [Test] + public void Verify_that_QueryConstraints_throws_when_argument_is_null() + { + EClass? @class = null; + + Assert.That(() => @class!.QueryConstraints(), Throws.ArgumentNullException); + } + } +} diff --git a/ECoreNetto.Extensions.Tests/ECoreNetto.Extensions.Tests.csproj b/ECoreNetto.Extensions.Tests/ECoreNetto.Extensions.Tests.csproj index 486aa41..ae95243 100644 --- a/ECoreNetto.Extensions.Tests/ECoreNetto.Extensions.Tests.csproj +++ b/ECoreNetto.Extensions.Tests/ECoreNetto.Extensions.Tests.csproj @@ -40,6 +40,9 @@ Always + + Always + diff --git a/ECoreNetto.HandleBars.Tests/AnchorHelperTestFixture.cs b/ECoreNetto.HandleBars.Tests/AnchorHelperTestFixture.cs new file mode 100644 index 0000000..b7bf121 --- /dev/null +++ b/ECoreNetto.HandleBars.Tests/AnchorHelperTestFixture.cs @@ -0,0 +1,70 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars.Tests +{ + using System.IO; + using System.Linq; + + using ECoreNetto; + + using HandlebarsDotNet; + + using NUnit.Framework; + + /// + /// Suite of tests for the class + /// + [TestFixture] + public class AnchorHelperTestFixture + { + private IHandlebars handlebarsContext = null!; + + private EPackage root = null!; + + [SetUp] + public void Setup() + { + this.handlebarsContext = Handlebars.Create(); + + AnchorHelper.RegisterAnchorHelper(this.handlebarsContext); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "report-features.ecore"); + this.root = ModelLoader.Load(path); + } + + [Test] + public void Verify_that_Anchor_writes_the_sanitized_slug() + { + var template = "{{ Anchor this }}"; + var action = this.handlebarsContext.Compile(template); + + var person = this.root.EClassifiers.OfType().Single(x => x.Name == "Person"); + + var result = action(person); + + Assert.Multiple(() => + { + Assert.That(result, Does.Match("^[A-Za-z0-9-]+$")); + Assert.That(result, Does.EndWith("Person")); + }); + } + + [Test] + public void Verify_that_Anchor_throws_when_not_exactly_one_argument() + { + var template = "{{ Anchor this that }}"; + var action = this.handlebarsContext.Compile(template); + + var person = this.root.EClassifiers.OfType().Single(x => x.Name == "Person"); + + Assert.Throws(() => action(new { this_ = person, that = person })); + } + } +} diff --git a/ECoreNetto.HandleBars.Tests/ClassHelperTestFixture.cs b/ECoreNetto.HandleBars.Tests/ClassHelperTestFixture.cs new file mode 100644 index 0000000..8c90755 --- /dev/null +++ b/ECoreNetto.HandleBars.Tests/ClassHelperTestFixture.cs @@ -0,0 +1,155 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars.Tests +{ + using System.Collections.Generic; + using System.IO; + using System.Linq; + + using ECoreNetto; + using ECoreNetto.Extensions; + + using HandlebarsDotNet; + using HandlebarsDotNet.Helpers; + + using NUnit.Framework; + + /// + /// Suite of tests for the class + /// + [TestFixture] + public class ClassHelperTestFixture + { + private IHandlebars handlebarsContext = null!; + + private EPackage root = null!; + + private List allClasses = null!; + + [SetUp] + public void Setup() + { + this.handlebarsContext = Handlebars.Create(); + HandlebarsHelpers.Register(this.handlebarsContext); + + ClassHelper.RegisterClassHelper(this.handlebarsContext); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "report-features.ecore"); + this.root = ModelLoader.Load(path); + this.allClasses = this.root.EClassifiers.OfType().ToList(); + } + + private EClass Class(string name) + { + return this.allClasses.Single(x => x.Name == name); + } + + [Test] + public void Verify_that_QuerySpecializations_returns_the_subclasses() + { + var template = "{{#each (Class.QuerySpecializations subject all) as | c |}}{{c.Name}};{{/each}}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(new { subject = this.Class("Describable"), all = this.allClasses }); + + Assert.That(result, Is.EqualTo("Person;")); + } + + [Test] + public void Verify_that_QueryContainers_returns_the_containing_classes() + { + var template = "{{#each (Class.QueryContainers subject all) as | c |}}{{c.Name}};{{/each}}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(new { subject = this.Class("Address"), all = this.allClasses }); + + Assert.That(result, Is.EqualTo("Person;")); + } + + [Test] + public void Verify_that_QueryConstraints_returns_the_constraints() + { + var template = "{{#each (Class.QueryConstraints subject) as | c |}}{{c.Name}}:{{c.Language}};{{/each}}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(new { subject = this.Class("Person") }); + + Assert.That(result, Is.EqualTo("hasFullName:OCL;")); + } + + [Test] + public void Verify_that_RenderInheritanceDiagram_renders_the_diagram_when_present() + { + var template = "{{#Class.RenderInheritanceDiagram subject diagrams}}{{{this}}}{{/Class.RenderInheritanceDiagram}}"; + var action = this.handlebarsContext.Compile(template); + + var person = this.Class("Person"); + var diagrams = new Dictionary { { person.QueryAnchorId(), "DIAGRAM" } }; + + var result = action(new { subject = person, diagrams }); + + Assert.That(result, Does.Contain("DIAGRAM")); + } + + [Test] + public void Verify_that_RenderInheritanceDiagram_renders_nothing_when_absent() + { + var template = "{{#Class.RenderInheritanceDiagram subject diagrams}}{{{this}}}{{/Class.RenderInheritanceDiagram}}"; + var action = this.handlebarsContext.Compile(template); + + var diagrams = new Dictionary(); + + var result = action(new { subject = this.Class("Person"), diagrams }); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Verify_that_RenderAssociationDiagram_renders_the_diagram_when_present() + { + var template = "{{#Class.RenderAssociationDiagram subject diagrams}}{{{this}}}{{/Class.RenderAssociationDiagram}}"; + var action = this.handlebarsContext.Compile(template); + + var person = this.Class("Person"); + var diagrams = new Dictionary { { person.QueryAnchorId(), "ASSOC" } }; + + var result = action(new { subject = person, diagrams }); + + Assert.That(result, Does.Contain("ASSOC")); + } + + [Test] + public void Verify_that_QuerySpecializations_throws_when_not_exactly_two_arguments() + { + var template = "{{#each (Class.QuerySpecializations subject) as | c |}}{{c.Name}}{{/each}}"; + var action = this.handlebarsContext.Compile(template); + + Assert.Throws(() => action(new { subject = this.Class("Describable") })); + } + + [Test] + public void Verify_that_QueryContainers_throws_when_not_exactly_two_arguments() + { + var template = "{{#each (Class.QueryContainers subject) as | c |}}{{c.Name}}{{/each}}"; + var action = this.handlebarsContext.Compile(template); + + Assert.Throws(() => action(new { subject = this.Class("Address") })); + } + + [Test] + public void Verify_that_QueryConstraints_throws_when_not_exactly_one_argument() + { + var template = "{{#each (Class.QueryConstraints subject all) as | c |}}{{c.Name}}{{/each}}"; + var action = this.handlebarsContext.Compile(template); + + Assert.Throws(() => action(new { subject = this.Class("Person"), all = this.allClasses })); + } + } +} diff --git a/ECoreNetto.HandleBars.Tests/ECoreNetto.HandleBars.Tests.csproj b/ECoreNetto.HandleBars.Tests/ECoreNetto.HandleBars.Tests.csproj index 0618e9f..a304e6b 100644 --- a/ECoreNetto.HandleBars.Tests/ECoreNetto.HandleBars.Tests.csproj +++ b/ECoreNetto.HandleBars.Tests/ECoreNetto.HandleBars.Tests.csproj @@ -36,6 +36,9 @@ Always + + Always + \ No newline at end of file diff --git a/ECoreNetto.HandleBars.Tests/IEnumerableHelperTestFixture.cs b/ECoreNetto.HandleBars.Tests/IEnumerableHelperTestFixture.cs new file mode 100644 index 0000000..ecc5fb5 --- /dev/null +++ b/ECoreNetto.HandleBars.Tests/IEnumerableHelperTestFixture.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars.Tests +{ + using System.Collections.Generic; + + using HandlebarsDotNet; + using HandlebarsDotNet.Helpers; + + using NUnit.Framework; + + /// + /// Suite of tests for the class + /// + [TestFixture] + public class IEnumerableHelperTestFixture + { + private IHandlebars handlebarsContext = null!; + + [SetUp] + public void Setup() + { + this.handlebarsContext = Handlebars.Create(); + HandlebarsHelpers.Register(this.handlebarsContext); + + IEnumerableHelper.RegisterIEnumerableHelper(this.handlebarsContext); + } + + [Test] + public void Verify_that_IsEmpty_returns_true_for_an_empty_enumerable() + { + var template = "{{#if (IEnumerable.IsEmpty items)}}EMPTY{{else}}NOT{{/if}}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(new { items = new List() }); + + Assert.That(result, Is.EqualTo("EMPTY")); + } + + [Test] + public void Verify_that_IsEmpty_returns_false_for_a_non_empty_enumerable() + { + var template = "{{#if (IEnumerable.IsEmpty items)}}EMPTY{{else}}NOT{{/if}}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(new { items = new List { "one" } }); + + Assert.That(result, Is.EqualTo("NOT")); + } + + [Test] + public void Verify_that_IsEmpty_returns_true_when_the_argument_is_not_an_enumerable() + { + var template = "{{#if (IEnumerable.IsEmpty item)}}EMPTY{{else}}NOT{{/if}}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(new { item = 42 }); + + Assert.That(result, Is.EqualTo("EMPTY")); + } + + [Test] + public void Verify_that_IsEmpty_throws_when_not_exactly_one_argument() + { + var template = "{{#if (IEnumerable.IsEmpty a b)}}EMPTY{{else}}NOT{{/if}}"; + var action = this.handlebarsContext.Compile(template); + + Assert.Throws(() => action(new { a = new List(), b = new List() })); + } + } +} diff --git a/ECoreNetto.HandleBars.Tests/ParameterHelperTestFixture.cs b/ECoreNetto.HandleBars.Tests/ParameterHelperTestFixture.cs new file mode 100644 index 0000000..306b37a --- /dev/null +++ b/ECoreNetto.HandleBars.Tests/ParameterHelperTestFixture.cs @@ -0,0 +1,137 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars.Tests +{ + using System.IO; + using System.Linq; + + using ECoreNetto; + + using HandlebarsDotNet; + using HandlebarsDotNet.Helpers; + + using NUnit.Framework; + + /// + /// Suite of tests for the class + /// + [TestFixture] + public class ParameterHelperTestFixture + { + private IHandlebars handlebarsContext = null!; + + private EPackage root = null!; + + [SetUp] + public void Setup() + { + this.handlebarsContext = Handlebars.Create(); + HandlebarsHelpers.Register(this.handlebarsContext); + + ParameterHelper.RegisterParameterHelper(this.handlebarsContext); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "report-features.ecore"); + this.root = ModelLoader.Load(path); + } + + private EOperation Operation(string className, string operationName) + { + return this.root.EClassifiers.OfType().Single(x => x.Name == className) + .EOperations.Single(x => x.Name == operationName); + } + + [Test] + public void Verify_that_WriteTypeAndName_renders_type_name_and_multiplicity() + { + var template = "{{ Parameter.WriteTypeAndName this }}"; + var action = this.handlebarsContext.Compile(template); + + var greeting = this.Operation("Person", "greet").EParameters.Single(x => x.Name == "greeting"); + + var result = action(greeting); + + Assert.Multiple(() => + { + Assert.That(result, Does.Contain("EString")); + Assert.That(result, Does.Contain("greeting")); + Assert.That(result, Does.Contain("[0..*]")); + }); + } + + [Test] + public void Verify_that_WriteTypeAndName_renders_void_for_a_typeless_parameter() + { + var template = "{{ Parameter.WriteTypeAndName this }}"; + var action = this.handlebarsContext.Compile(template); + + var target = this.Operation("Company", "clear").EParameters.Single(x => x.Name == "target"); + + var result = action(target); + + Assert.Multiple(() => + { + Assert.That(result, Does.Contain("void")); + Assert.That(result, Does.Contain("target")); + }); + } + + [Test] + public void Verify_that_WriteReturnType_renders_type_name_and_multiplicity() + { + var template = "{{ TypedElement.WriteReturnType this }}"; + var action = this.handlebarsContext.Compile(template); + + var greet = this.Operation("Person", "greet"); + + var result = action(greet); + + Assert.Multiple(() => + { + Assert.That(result, Does.Contain("EString")); + Assert.That(result, Does.Contain("[1..1]")); + }); + } + + [Test] + public void Verify_that_WriteReturnType_renders_void_when_there_is_no_return_type() + { + var template = "{{ TypedElement.WriteReturnType this }}"; + var action = this.handlebarsContext.Compile(template); + + var clear = this.Operation("Company", "clear"); + + var result = action(clear); + + Assert.That(result, Is.EqualTo("void")); + } + + [Test] + public void Verify_that_WriteTypeAndName_throws_when_not_exactly_one_argument() + { + var template = "{{ Parameter.WriteTypeAndName this that }}"; + var action = this.handlebarsContext.Compile(template); + + var greeting = this.Operation("Person", "greet").EParameters.Single(x => x.Name == "greeting"); + + Assert.Throws(() => action(new { that = greeting })); + } + + [Test] + public void Verify_that_WriteReturnType_throws_when_not_exactly_one_argument() + { + var template = "{{ TypedElement.WriteReturnType this that }}"; + var action = this.handlebarsContext.Compile(template); + + var greet = this.Operation("Person", "greet"); + + Assert.Throws(() => action(new { that = greet })); + } + } +} diff --git a/ECoreNetto.HandleBars.Tests/StructuralFeatureHelperFeaturesTestFixture.cs b/ECoreNetto.HandleBars.Tests/StructuralFeatureHelperFeaturesTestFixture.cs new file mode 100644 index 0000000..6039116 --- /dev/null +++ b/ECoreNetto.HandleBars.Tests/StructuralFeatureHelperFeaturesTestFixture.cs @@ -0,0 +1,120 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.HandleBars.Tests +{ + using System.IO; + using System.Linq; + + using ECoreNetto; + + using HandlebarsDotNet; + using HandlebarsDotNet.Helpers; + + using NUnit.Framework; + + /// + /// Suite of tests for the identity and opposite helpers on , exercised + /// against the feature-rich synthetic model. + /// + [TestFixture] + public class StructuralFeatureHelperFeaturesTestFixture + { + private IHandlebars handlebarsContext = null!; + + private EPackage root = null!; + + [SetUp] + public void Setup() + { + this.handlebarsContext = Handlebars.Create(); + HandlebarsHelpers.Register(this.handlebarsContext); + + StructuralFeatureHelper.RegisterStructuralFeatureHelper(this.handlebarsContext); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "report-features.ecore"); + this.root = ModelLoader.Load(path); + } + + private EStructuralFeature Feature(string className, string featureName) + { + return this.root.EClassifiers.OfType().Single(x => x.Name == className) + .EStructuralFeatures.Single(x => x.Name == featureName); + } + + [Test] + public void Verify_that_QueryIsId_returns_true_for_an_id_attribute() + { + var template = "{{ #StructuralFeature.QueryIsId this }}"; + var action = this.handlebarsContext.Compile(template); + + Assert.Multiple(() => + { + Assert.That(action(this.Feature("Person", "identifier")), Is.EqualTo("True")); + Assert.That(action(this.Feature("Person", "tags")), Is.EqualTo("False")); + Assert.That(action(this.Feature("Person", "employer")), Is.EqualTo("False")); + }); + } + + [Test] + public void Verify_that_QueryIsId_throws_when_not_exactly_one_argument() + { + var template = "{{ #StructuralFeature.QueryIsId this that }}"; + var action = this.handlebarsContext.Compile(template); + + Assert.Throws(() => action(new { that = this.Feature("Person", "identifier") })); + } + + [Test] + public void Verify_that_WriteOpposite_renders_the_opposite_reference() + { + var template = "{{ StructuralFeature.WriteOpposite this }}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(this.Feature("Person", "employer")); + + Assert.Multiple(() => + { + Assert.That(result, Does.Contain("{opposite:")); + Assert.That(result, Does.Contain("employees")); + }); + } + + [Test] + public void Verify_that_WriteOpposite_renders_nothing_for_a_reference_without_an_opposite() + { + var template = "{{ StructuralFeature.WriteOpposite this }}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(this.Feature("Person", "addresses")); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Verify_that_WriteOpposite_renders_nothing_for_an_attribute() + { + var template = "{{ StructuralFeature.WriteOpposite this }}"; + var action = this.handlebarsContext.Compile(template); + + var result = action(this.Feature("Person", "tags")); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Verify_that_WriteOpposite_throws_when_not_exactly_one_argument() + { + var template = "{{ StructuralFeature.WriteOpposite this that }}"; + var action = this.handlebarsContext.Compile(template); + + Assert.Throws(() => action(new { that = this.Feature("Person", "employer") })); + } + } +} diff --git a/ECoreNetto.Reporting.Tests/Drawing/DiagramRendererTestFixture.cs b/ECoreNetto.Reporting.Tests/Drawing/DiagramRendererTestFixture.cs new file mode 100644 index 0000000..7f43067 --- /dev/null +++ b/ECoreNetto.Reporting.Tests/Drawing/DiagramRendererTestFixture.cs @@ -0,0 +1,112 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Tools.Tests.Drawing +{ + using System.IO; + using System.Linq; + + using ECoreNetto; + using ECoreNetto.Reporting.Drawing; + using ECoreNetto.Reporting.Payload; + using ECoreNetto.Resource; + + using NUnit.Framework; + + /// + /// Suite of smoke tests for the and + /// classes. + /// + [TestFixture] + public class DiagramRendererTestFixture + { + private HandlebarsPayload payload = null!; + + [SetUp] + public void SetUp() + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "report-features.ecore"); + var uri = new System.Uri(Path.GetFullPath(path)); + + var resourceSet = new ResourceSet(); + var resource = resourceSet.CreateResource(uri); + var root = resource.Load(null); + + var enums = root.EClassifiers.OfType(); + var dataTypes = root.EClassifiers.OfType().Where(x => !(x is EEnum)).ToList(); + var classes = root.EClassifiers.OfType().ToList(); + + this.payload = new HandlebarsPayload( + root, + enums, + dataTypes.Where(x => !string.IsNullOrEmpty(x.InstanceClassName)), + dataTypes.Where(x => string.IsNullOrEmpty(x.InstanceClassName)), + classes, + classes.Where(x => x.Interface)); + } + + [Test] + public void Verify_that_the_inheritance_renderer_renders_the_whole_model() + { + var renderer = new InheritanceDiagramRenderer(); + + var svg = renderer.SvgRender(this.payload); + + Assert.Multiple(() => + { + Assert.That(svg, Does.Contain(" x.Name == "Person"); + + var svg = renderer.SvgRenderForClass(person, this.payload); + + Assert.Multiple(() => + { + Assert.That(svg, Does.Contain(" x.Name == "Person"); + + var svg = renderer.SvgRenderForClass(person, this.payload); + + Assert.Multiple(() => + { + Assert.That(svg, Does.Contain(" x.Name == "Describable"); + + var svg = renderer.SvgRenderForClass(describable, this.payload); + + Assert.That(svg, Is.Empty); + } + } +} diff --git a/ECoreNetto.Reporting.Tests/ECoreNetto.Reporting.Tests.csproj b/ECoreNetto.Reporting.Tests/ECoreNetto.Reporting.Tests.csproj index cdb683e..2d52a20 100644 --- a/ECoreNetto.Reporting.Tests/ECoreNetto.Reporting.Tests.csproj +++ b/ECoreNetto.Reporting.Tests/ECoreNetto.Reporting.Tests.csproj @@ -36,6 +36,9 @@ Always + + Always + Always diff --git a/ECoreNetto.Reporting.Tests/Generators/HtmlReportGeneratorTestFixture.cs b/ECoreNetto.Reporting.Tests/Generators/HtmlReportGeneratorTestFixture.cs index ae50df4..116517c 100644 --- a/ECoreNetto.Reporting.Tests/Generators/HtmlReportGeneratorTestFixture.cs +++ b/ECoreNetto.Reporting.Tests/Generators/HtmlReportGeneratorTestFixture.cs @@ -83,6 +83,96 @@ public void Verify_that_the_generated_html_contains_the_expected_content() }); } + [Test] + public void Verify_that_the_generated_html_renders_the_rich_feature_set() + { + var modelFileInfo = new FileInfo(Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "report-features.ecore")); + + var html = this.htmlReportGenerator.GenerateReport(modelFileInfo); + + Assert.Multiple(() => + { + // sections and type classification + Assert.That(html, Does.Contain("[Primitive Type]")); + Assert.That(html, Does.Contain("[Data Type]")); + Assert.That(html, Does.Contain("[Interface]")); + Assert.That(html, Does.Contain("[Enumeration]")); + + // enum literal value + literal columns + Assert.That(html, Does.Contain("active")); + + // feature flag chips + Assert.That(html, Does.Contain("{ordered}")); + Assert.That(html, Does.Contain("{unique}")); + Assert.That(html, Does.Contain("{id}")); + Assert.That(html, Does.Contain("{readonly}")); + Assert.That(html, Does.Contain("{transient}")); + Assert.That(html, Does.Contain("{volatile}")); + Assert.That(html, Does.Contain("{unsettable}")); + Assert.That(html, Does.Contain("{derived}")); + Assert.That(html, Does.Contain("{composite}")); + Assert.That(html, Does.Contain("{opposite:")); + + // specializations / containers + Assert.That(html, Does.Contain("Specializations")); + Assert.That(html, Does.Contain("Containers")); + + // operations table with a rendered parameter and return type + Assert.That(html, Does.Contain("Operations")); + Assert.That(html, Does.Contain("greet")); + Assert.That(html, Does.Contain("greeting")); + + // OCL constraint (rule) + Assert.That(html, Does.Contain("hasFullName")); + Assert.That(html, Does.Contain("self.fullName")); + + // collapsible UX, diagrams, custom-HTML injection point and Open Graph metadata + Assert.That(html, Does.Contain("collapsible-section")); + Assert.That(html, Does.Contain("expand-all")); + Assert.That(html, Does.Contain("inheritance-diagram")); + Assert.That(html, Does.Contain("inheritance-tree-")); + Assert.That(html, Does.Contain("association-diagram-")); + Assert.That(html, Does.Contain("download-svg")); + Assert.That(html, Does.Contain("og:title")); + }); + } + + [Test] + public void Verify_that_an_existing_report_file_is_overwritten() + { + var modelFileInfo = new FileInfo(Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "recipe.ecore")); + + var reportFileInfo = new FileInfo(Path.Combine(TestContext.CurrentContext.TestDirectory, "html-report.overwrite.html")); + + this.htmlReportGenerator.GenerateReport(modelFileInfo, reportFileInfo); + + // a second generation to the same path must overwrite the existing file without throwing + Assert.That(() => this.htmlReportGenerator.GenerateReport(modelFileInfo, reportFileInfo), Throws.Nothing); + Assert.That(reportFileInfo.Exists, Is.True); + } + + [Test] + public void Verify_that_the_custom_html_is_injected_into_the_report() + { + var modelFileInfo = new FileInfo(Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "recipe.ecore")); + + const string customHtml = "
custom-content-marker
"; + + var html = this.htmlReportGenerator.GenerateReport(modelFileInfo, customHtml); + + Assert.That(html, Does.Contain("custom-content-marker")); + } + + [Test] + public void Verify_that_the_default_constructor_generates_a_report() + { + var generator = new HtmlReportGenerator(); + + var modelFileInfo = new FileInfo(Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "recipe.ecore")); + + Assert.That(() => generator.GenerateReport(modelFileInfo), Throws.Nothing); + } + [Test] public void Verify_that_when_modelpath_is_null_exception_is_thrown() { diff --git a/TestData/report-features.ecore b/TestData/report-features.ecore new file mode 100644 index 0000000..67fb7f6 --- /dev/null +++ b/TestData/report-features.ecore @@ -0,0 +1,48 @@ + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + From 67e9e76dca976c3ea7bbaba8d5658dedda34bebc Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 5 Jul 2026 22:42:38 +0200 Subject: [PATCH 5/5] [Fix] Add a match timeout to the anchor sanitization regex (S6444) Use a static Regex with a one-second match timeout instead of the static Regex.Replace helper to guard against pathological input, addressing the SonarCloud DoS hotspot. --- ECoreNetto.Extensions/AnchorExtensions.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ECoreNetto.Extensions/AnchorExtensions.cs b/ECoreNetto.Extensions/AnchorExtensions.cs index bc07d9b..e796f29 100644 --- a/ECoreNetto.Extensions/AnchorExtensions.cs +++ b/ECoreNetto.Extensions/AnchorExtensions.cs @@ -17,6 +17,12 @@ namespace ECoreNetto.Extensions /// public static class AnchorExtensions { + /// + /// The that matches every run of characters that are not valid in an HTML + /// fragment identifier. A match timeout guards against pathological input. + /// + private static readonly Regex NonAnchorCharacters = new Regex("[^A-Za-z0-9]+", RegexOptions.None, TimeSpan.FromSeconds(1)); + /// /// Queries a stable, unique HTML anchor identifier for the provided , derived /// from its (unique) by replacing every run of characters that are @@ -47,7 +53,7 @@ public static string QueryAnchorId(this EObject eObject) ? detachedClassifier.Name ?? string.Empty : eObject.Identifier ?? string.Empty; - var sanitized = Regex.Replace(identifier, "[^A-Za-z0-9]+", "-").Trim('-'); + var sanitized = NonAnchorCharacters.Replace(identifier, "-").Trim('-'); return string.IsNullOrEmpty(sanitized) ? "anchor" : sanitized; }