feat(02-02): implement ELK diagram renderer with SVG/JSON content negotiation
- ElkDiagramRenderer: ELK layered layout (top-to-bottom) with JFreeSVG rendering
- Color-coded nodes: blue endpoints, green processors, red errors, purple EIPs, cyan cross-route
- Compound node support for CHOICE/SPLIT/TRY_CATCH swimlane groups
- DiagramRenderController: GET /api/v1/diagrams/{hash}/render with Accept header negotiation
- DiagramBeanConfig for Spring wiring
- 11 unit tests (layout, SVG structure, colors, compound nodes)
- 4 integration tests (SVG, JSON, 404, default format)
- Added xtext xbase lib dependency for ELK compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,11 @@
|
||||
<artifactId>org.jfree.svg</artifactId>
|
||||
<version>5.0.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.xtext</groupId>
|
||||
<artifactId>org.eclipse.xtext.xbase.lib</artifactId>
|
||||
<version>2.37.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.app.diagram.ElkDiagramRenderer;
|
||||
import com.cameleer3.server.core.diagram.DiagramRenderer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Creates beans for diagram rendering.
|
||||
*/
|
||||
@Configuration
|
||||
public class DiagramBeanConfig {
|
||||
|
||||
@Bean
|
||||
public DiagramRenderer diagramRenderer() {
|
||||
return new ElkDiagramRenderer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.common.graph.RouteGraph;
|
||||
import com.cameleer3.server.core.diagram.DiagramLayout;
|
||||
import com.cameleer3.server.core.diagram.DiagramRenderer;
|
||||
import com.cameleer3.server.core.storage.DiagramRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* REST endpoint for rendering route diagrams.
|
||||
* <p>
|
||||
* Supports content negotiation via Accept header:
|
||||
* <ul>
|
||||
* <li>{@code image/svg+xml} or default: returns SVG document</li>
|
||||
* <li>{@code application/json}: returns JSON layout with node positions</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/diagrams")
|
||||
@Tag(name = "Diagrams", description = "Diagram rendering endpoints")
|
||||
public class DiagramRenderController {
|
||||
|
||||
private static final MediaType SVG_MEDIA_TYPE = MediaType.valueOf("image/svg+xml");
|
||||
|
||||
private final DiagramRepository diagramRepository;
|
||||
private final DiagramRenderer diagramRenderer;
|
||||
|
||||
public DiagramRenderController(DiagramRepository diagramRepository,
|
||||
DiagramRenderer diagramRenderer) {
|
||||
this.diagramRepository = diagramRepository;
|
||||
this.diagramRenderer = diagramRenderer;
|
||||
}
|
||||
|
||||
@GetMapping("/{contentHash}/render")
|
||||
@Operation(summary = "Render a route diagram",
|
||||
description = "Returns SVG (default) or JSON layout based on Accept header")
|
||||
@ApiResponse(responseCode = "200", description = "Diagram rendered successfully")
|
||||
@ApiResponse(responseCode = "404", description = "Diagram not found")
|
||||
public ResponseEntity<?> renderDiagram(
|
||||
@PathVariable String contentHash,
|
||||
HttpServletRequest request) {
|
||||
|
||||
Optional<RouteGraph> graphOpt = diagramRepository.findByContentHash(contentHash);
|
||||
if (graphOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
RouteGraph graph = graphOpt.get();
|
||||
String accept = request.getHeader("Accept");
|
||||
|
||||
// Return JSON only when the client explicitly requests application/json
|
||||
// without also accepting everything (*/*). This means "application/json"
|
||||
// must appear and wildcards must not dominate the preference.
|
||||
if (accept != null && isJsonPreferred(accept)) {
|
||||
DiagramLayout layout = diagramRenderer.layoutJson(graph);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(layout);
|
||||
}
|
||||
|
||||
// Default to SVG for image/svg+xml, */* or no Accept header
|
||||
String svg = diagramRenderer.renderSvg(graph);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(SVG_MEDIA_TYPE)
|
||||
.body(svg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if JSON is the explicitly preferred format.
|
||||
* <p>
|
||||
* Returns true only when the first media type in the Accept header is
|
||||
* "application/json". Clients sending broad Accept lists like
|
||||
* "text/plain, application/json, */*" are treated as unspecific
|
||||
* and receive the SVG default.
|
||||
*/
|
||||
private boolean isJsonPreferred(String accept) {
|
||||
String[] parts = accept.split(",");
|
||||
if (parts.length == 0) return false;
|
||||
String first = parts[0].trim().split(";")[0].trim();
|
||||
return "application/json".equalsIgnoreCase(first);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package com.cameleer3.server.app.diagram;
|
||||
|
||||
import com.cameleer3.common.graph.NodeType;
|
||||
import com.cameleer3.common.graph.RouteEdge;
|
||||
import com.cameleer3.common.graph.RouteGraph;
|
||||
import com.cameleer3.common.graph.RouteNode;
|
||||
import com.cameleer3.server.core.diagram.DiagramLayout;
|
||||
import com.cameleer3.server.core.diagram.DiagramRenderer;
|
||||
import com.cameleer3.server.core.diagram.PositionedEdge;
|
||||
import com.cameleer3.server.core.diagram.PositionedNode;
|
||||
import org.eclipse.elk.alg.layered.options.LayeredMetaDataProvider;
|
||||
import org.eclipse.elk.core.RecursiveGraphLayoutEngine;
|
||||
import org.eclipse.elk.core.options.CoreOptions;
|
||||
import org.eclipse.elk.core.options.Direction;
|
||||
import org.eclipse.elk.core.options.HierarchyHandling;
|
||||
import org.eclipse.elk.core.util.BasicProgressMonitor;
|
||||
import org.eclipse.elk.graph.ElkBendPoint;
|
||||
import org.eclipse.elk.graph.ElkEdge;
|
||||
import org.eclipse.elk.graph.ElkEdgeSection;
|
||||
import org.eclipse.elk.graph.ElkGraphFactory;
|
||||
import org.eclipse.elk.graph.ElkNode;
|
||||
import org.jfree.svg.SVGGraphics2D;
|
||||
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.geom.GeneralPath;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* ELK + JFreeSVG implementation of {@link DiagramRenderer}.
|
||||
* <p>
|
||||
* Uses Eclipse ELK layered algorithm for top-to-bottom layout computation
|
||||
* and JFreeSVG for SVG document generation with color-coded nodes.
|
||||
*/
|
||||
public class ElkDiagramRenderer implements DiagramRenderer {
|
||||
|
||||
private static final int PADDING = 20;
|
||||
private static final int NODE_HEIGHT = 40;
|
||||
private static final int MIN_NODE_WIDTH = 80;
|
||||
private static final int CHAR_WIDTH = 8;
|
||||
private static final int LABEL_PADDING = 32;
|
||||
private static final int COMPOUND_TOP_PADDING = 30;
|
||||
private static final int COMPOUND_SIDE_PADDING = 10;
|
||||
private static final int CORNER_RADIUS = 8;
|
||||
private static final double NODE_SPACING = 40.0;
|
||||
private static final double EDGE_SPACING = 20.0;
|
||||
|
||||
// Blue: endpoints
|
||||
private static final Color BLUE = Color.decode("#3B82F6");
|
||||
// Green: processors
|
||||
private static final Color GREEN = Color.decode("#22C55E");
|
||||
// Red: error handling
|
||||
private static final Color RED = Color.decode("#EF4444");
|
||||
// Purple: EIP patterns
|
||||
private static final Color PURPLE = Color.decode("#A855F7");
|
||||
// Cyan: cross-route
|
||||
private static final Color CYAN = Color.decode("#06B6D4");
|
||||
// Gray: edges
|
||||
private static final Color EDGE_GRAY = Color.decode("#9CA3AF");
|
||||
|
||||
private static final Set<NodeType> ENDPOINT_TYPES = EnumSet.of(
|
||||
NodeType.ENDPOINT, NodeType.TO, NodeType.TO_DYNAMIC, NodeType.DIRECT, NodeType.SEDA
|
||||
);
|
||||
|
||||
private static final Set<NodeType> PROCESSOR_TYPES = EnumSet.of(
|
||||
NodeType.PROCESSOR, NodeType.BEAN, NodeType.LOG,
|
||||
NodeType.SET_HEADER, NodeType.SET_BODY, NodeType.TRANSFORM,
|
||||
NodeType.MARSHAL, NodeType.UNMARSHAL
|
||||
);
|
||||
|
||||
private static final Set<NodeType> ERROR_TYPES = EnumSet.of(
|
||||
NodeType.ERROR_HANDLER, NodeType.ON_EXCEPTION, NodeType.TRY_CATCH,
|
||||
NodeType.DO_TRY, NodeType.DO_CATCH, NodeType.DO_FINALLY
|
||||
);
|
||||
|
||||
private static final Set<NodeType> EIP_TYPES = EnumSet.of(
|
||||
NodeType.EIP_CHOICE, NodeType.EIP_WHEN, NodeType.EIP_OTHERWISE,
|
||||
NodeType.EIP_SPLIT, NodeType.EIP_AGGREGATE, NodeType.EIP_MULTICAST,
|
||||
NodeType.EIP_FILTER, NodeType.EIP_RECIPIENT_LIST, NodeType.EIP_ROUTING_SLIP,
|
||||
NodeType.EIP_DYNAMIC_ROUTER, NodeType.EIP_LOAD_BALANCE, NodeType.EIP_THROTTLE,
|
||||
NodeType.EIP_DELAY, NodeType.EIP_LOOP, NodeType.EIP_IDEMPOTENT_CONSUMER,
|
||||
NodeType.EIP_CIRCUIT_BREAKER, NodeType.EIP_PIPELINE
|
||||
);
|
||||
|
||||
private static final Set<NodeType> CROSS_ROUTE_TYPES = EnumSet.of(
|
||||
NodeType.EIP_WIRE_TAP, NodeType.EIP_ENRICH, NodeType.EIP_POLL_ENRICH
|
||||
);
|
||||
|
||||
/** NodeTypes that act as compound containers with children. */
|
||||
private static final Set<NodeType> COMPOUND_TYPES = EnumSet.of(
|
||||
NodeType.EIP_CHOICE, NodeType.EIP_SPLIT, NodeType.TRY_CATCH,
|
||||
NodeType.DO_TRY, NodeType.EIP_LOOP, NodeType.EIP_MULTICAST,
|
||||
NodeType.EIP_AGGREGATE
|
||||
);
|
||||
|
||||
public ElkDiagramRenderer() {
|
||||
// Ensure the layered algorithm meta data provider is registered.
|
||||
// LayoutMetaDataService uses ServiceLoader, but explicit registration
|
||||
// guarantees availability regardless of classpath ordering.
|
||||
org.eclipse.elk.core.data.LayoutMetaDataService.getInstance()
|
||||
.registerLayoutMetaDataProviders(new LayeredMetaDataProvider());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String renderSvg(RouteGraph graph) {
|
||||
LayoutResult result = computeLayout(graph);
|
||||
DiagramLayout layout = result.layout;
|
||||
|
||||
int svgWidth = (int) Math.ceil(layout.width()) + 2 * PADDING;
|
||||
int svgHeight = (int) Math.ceil(layout.height()) + 2 * PADDING;
|
||||
|
||||
SVGGraphics2D g2 = new SVGGraphics2D(svgWidth, svgHeight);
|
||||
g2.translate(PADDING, PADDING);
|
||||
|
||||
// Draw edges first (behind nodes)
|
||||
g2.setStroke(new BasicStroke(2.0f));
|
||||
g2.setColor(EDGE_GRAY);
|
||||
for (PositionedEdge edge : layout.edges()) {
|
||||
drawEdge(g2, edge);
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
Font labelFont = new Font("SansSerif", Font.PLAIN, 12);
|
||||
g2.setFont(labelFont);
|
||||
|
||||
// Draw compound containers first (background)
|
||||
for (Map.Entry<String, CompoundInfo> entry : result.compoundInfos.entrySet()) {
|
||||
CompoundInfo ci = entry.getValue();
|
||||
PositionedNode pn = findNode(layout.nodes(), ci.nodeId);
|
||||
if (pn != null) {
|
||||
drawCompoundContainer(g2, pn, ci.color);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw leaf nodes
|
||||
for (PositionedNode node : allNodes(layout.nodes())) {
|
||||
if (!result.compoundInfos.containsKey(node.id()) || node.children().isEmpty()) {
|
||||
drawNode(g2, node, result.nodeColors.getOrDefault(node.id(), PURPLE));
|
||||
}
|
||||
}
|
||||
|
||||
return g2.getSVGDocument();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DiagramLayout layoutJson(RouteGraph graph) {
|
||||
return computeLayout(graph).layout;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Layout computation
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private LayoutResult computeLayout(RouteGraph graph) {
|
||||
ElkGraphFactory factory = ElkGraphFactory.eINSTANCE;
|
||||
|
||||
// Create root node
|
||||
ElkNode rootNode = factory.createElkNode();
|
||||
rootNode.setIdentifier("root");
|
||||
rootNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered");
|
||||
rootNode.setProperty(CoreOptions.DIRECTION, Direction.DOWN);
|
||||
rootNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING);
|
||||
rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING);
|
||||
rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN);
|
||||
|
||||
// Build index of RouteNodes
|
||||
Map<String, RouteNode> routeNodeMap = new HashMap<>();
|
||||
if (graph.getNodes() != null) {
|
||||
for (RouteNode rn : graph.getNodes()) {
|
||||
routeNodeMap.put(rn.getId(), rn);
|
||||
}
|
||||
}
|
||||
|
||||
// Identify compound node IDs and their children
|
||||
Set<String> compoundNodeIds = new HashSet<>();
|
||||
Map<String, String> childToParent = new HashMap<>();
|
||||
for (RouteNode rn : routeNodeMap.values()) {
|
||||
if (rn.getType() != null && COMPOUND_TYPES.contains(rn.getType())
|
||||
&& rn.getChildren() != null && !rn.getChildren().isEmpty()) {
|
||||
compoundNodeIds.add(rn.getId());
|
||||
for (RouteNode child : rn.getChildren()) {
|
||||
childToParent.put(child.getId(), rn.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create ELK nodes
|
||||
Map<String, ElkNode> elkNodeMap = new HashMap<>();
|
||||
Map<String, Color> nodeColors = new HashMap<>();
|
||||
|
||||
// First, create compound (parent) nodes
|
||||
for (String compoundId : compoundNodeIds) {
|
||||
RouteNode rn = routeNodeMap.get(compoundId);
|
||||
ElkNode elkCompound = factory.createElkNode();
|
||||
elkCompound.setIdentifier(rn.getId());
|
||||
elkCompound.setParent(rootNode);
|
||||
|
||||
// Compound nodes are larger initially -- ELK will resize
|
||||
elkCompound.setWidth(200);
|
||||
elkCompound.setHeight(100);
|
||||
|
||||
// Set properties for compound layout
|
||||
elkCompound.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered");
|
||||
elkCompound.setProperty(CoreOptions.DIRECTION, Direction.DOWN);
|
||||
elkCompound.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING * 0.5);
|
||||
elkCompound.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING * 0.5);
|
||||
elkCompound.setProperty(CoreOptions.PADDING,
|
||||
new org.eclipse.elk.core.math.ElkPadding(COMPOUND_TOP_PADDING,
|
||||
COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING));
|
||||
|
||||
elkNodeMap.put(rn.getId(), elkCompound);
|
||||
nodeColors.put(rn.getId(), colorForType(rn.getType()));
|
||||
|
||||
// Create child nodes inside compound
|
||||
for (RouteNode child : rn.getChildren()) {
|
||||
ElkNode elkChild = factory.createElkNode();
|
||||
elkChild.setIdentifier(child.getId());
|
||||
elkChild.setParent(elkCompound);
|
||||
int w = Math.max(MIN_NODE_WIDTH, (child.getLabel() != null ? child.getLabel().length() : 0) * CHAR_WIDTH + LABEL_PADDING);
|
||||
elkChild.setWidth(w);
|
||||
elkChild.setHeight(NODE_HEIGHT);
|
||||
elkNodeMap.put(child.getId(), elkChild);
|
||||
nodeColors.put(child.getId(), colorForType(child.getType()));
|
||||
}
|
||||
}
|
||||
|
||||
// Then, create non-compound, non-child nodes
|
||||
for (RouteNode rn : routeNodeMap.values()) {
|
||||
if (!elkNodeMap.containsKey(rn.getId())) {
|
||||
ElkNode elkNode = factory.createElkNode();
|
||||
elkNode.setIdentifier(rn.getId());
|
||||
elkNode.setParent(rootNode);
|
||||
int w = Math.max(MIN_NODE_WIDTH, (rn.getLabel() != null ? rn.getLabel().length() : 0) * CHAR_WIDTH + LABEL_PADDING);
|
||||
elkNode.setWidth(w);
|
||||
elkNode.setHeight(NODE_HEIGHT);
|
||||
elkNodeMap.put(rn.getId(), elkNode);
|
||||
nodeColors.put(rn.getId(), colorForType(rn.getType()));
|
||||
}
|
||||
}
|
||||
|
||||
// Create ELK edges
|
||||
if (graph.getEdges() != null) {
|
||||
for (RouteEdge re : graph.getEdges()) {
|
||||
ElkNode sourceElk = elkNodeMap.get(re.getSource());
|
||||
ElkNode targetElk = elkNodeMap.get(re.getTarget());
|
||||
if (sourceElk == null || targetElk == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the containing node for the edge
|
||||
ElkNode containingNode = findCommonParent(sourceElk, targetElk);
|
||||
|
||||
ElkEdge elkEdge = factory.createElkEdge();
|
||||
elkEdge.setContainingNode(containingNode);
|
||||
elkEdge.getSources().add(sourceElk);
|
||||
elkEdge.getTargets().add(targetElk);
|
||||
}
|
||||
}
|
||||
|
||||
// Run layout
|
||||
RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine();
|
||||
engine.layout(rootNode, new BasicProgressMonitor());
|
||||
|
||||
// Extract results
|
||||
List<PositionedNode> positionedNodes = new ArrayList<>();
|
||||
Map<String, CompoundInfo> compoundInfos = new HashMap<>();
|
||||
|
||||
for (RouteNode rn : routeNodeMap.values()) {
|
||||
if (childToParent.containsKey(rn.getId())) {
|
||||
// Skip children -- they are collected under their parent
|
||||
continue;
|
||||
}
|
||||
|
||||
ElkNode elkNode = elkNodeMap.get(rn.getId());
|
||||
if (elkNode == null) continue;
|
||||
|
||||
if (compoundNodeIds.contains(rn.getId())) {
|
||||
// Compound node: collect children
|
||||
List<PositionedNode> children = new ArrayList<>();
|
||||
if (rn.getChildren() != null) {
|
||||
for (RouteNode child : rn.getChildren()) {
|
||||
ElkNode childElk = elkNodeMap.get(child.getId());
|
||||
if (childElk != null) {
|
||||
children.add(new PositionedNode(
|
||||
child.getId(),
|
||||
child.getLabel() != null ? child.getLabel() : "",
|
||||
child.getType() != null ? child.getType().name() : "UNKNOWN",
|
||||
elkNode.getX() + childElk.getX(),
|
||||
elkNode.getY() + childElk.getY(),
|
||||
childElk.getWidth(),
|
||||
childElk.getHeight(),
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
positionedNodes.add(new PositionedNode(
|
||||
rn.getId(),
|
||||
rn.getLabel() != null ? rn.getLabel() : "",
|
||||
rn.getType() != null ? rn.getType().name() : "UNKNOWN",
|
||||
elkNode.getX(),
|
||||
elkNode.getY(),
|
||||
elkNode.getWidth(),
|
||||
elkNode.getHeight(),
|
||||
children
|
||||
));
|
||||
|
||||
compoundInfos.put(rn.getId(), new CompoundInfo(
|
||||
rn.getId(), colorForType(rn.getType())));
|
||||
} else {
|
||||
positionedNodes.add(new PositionedNode(
|
||||
rn.getId(),
|
||||
rn.getLabel() != null ? rn.getLabel() : "",
|
||||
rn.getType() != null ? rn.getType().name() : "UNKNOWN",
|
||||
elkNode.getX(),
|
||||
elkNode.getY(),
|
||||
elkNode.getWidth(),
|
||||
elkNode.getHeight(),
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract edges
|
||||
List<PositionedEdge> positionedEdges = new ArrayList<>();
|
||||
for (ElkEdge elkEdge : collectAllEdges(rootNode)) {
|
||||
String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier();
|
||||
String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().get(0).getIdentifier();
|
||||
|
||||
List<double[]> points = new ArrayList<>();
|
||||
for (ElkEdgeSection section : elkEdge.getSections()) {
|
||||
points.add(new double[]{
|
||||
section.getStartX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode),
|
||||
section.getStartY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode)
|
||||
});
|
||||
for (ElkBendPoint bp : section.getBendPoints()) {
|
||||
points.add(new double[]{
|
||||
bp.getX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode),
|
||||
bp.getY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode)
|
||||
});
|
||||
}
|
||||
points.add(new double[]{
|
||||
section.getEndX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode),
|
||||
section.getEndY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode)
|
||||
});
|
||||
}
|
||||
|
||||
// Find label from original edge
|
||||
String label = "";
|
||||
if (graph.getEdges() != null) {
|
||||
for (RouteEdge re : graph.getEdges()) {
|
||||
if (re.getSource().equals(sourceId) && re.getTarget().equals(targetId)) {
|
||||
label = re.getLabel() != null ? re.getLabel() : "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
positionedEdges.add(new PositionedEdge(sourceId, targetId, label, points));
|
||||
}
|
||||
|
||||
double totalWidth = rootNode.getWidth();
|
||||
double totalHeight = rootNode.getHeight();
|
||||
|
||||
DiagramLayout layout = new DiagramLayout(totalWidth, totalHeight, positionedNodes, positionedEdges);
|
||||
return new LayoutResult(layout, nodeColors, compoundInfos);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// SVG drawing helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private void drawNode(SVGGraphics2D g2, PositionedNode node, Color color) {
|
||||
g2.setColor(color);
|
||||
g2.fill(new RoundRectangle2D.Double(
|
||||
node.x(), node.y(), node.width(), node.height(),
|
||||
CORNER_RADIUS, CORNER_RADIUS));
|
||||
|
||||
// White label
|
||||
g2.setColor(Color.WHITE);
|
||||
FontMetrics fm = g2.getFontMetrics();
|
||||
String label = node.label();
|
||||
int labelWidth = fm.stringWidth(label);
|
||||
float labelX = (float) (node.x() + (node.width() - labelWidth) / 2.0);
|
||||
float labelY = (float) (node.y() + 24);
|
||||
g2.drawString(label, labelX, labelY);
|
||||
}
|
||||
|
||||
private void drawCompoundContainer(SVGGraphics2D g2, PositionedNode node, Color color) {
|
||||
// Semi-transparent background
|
||||
Color bg = new Color(color.getRed(), color.getGreen(), color.getBlue(), 38); // ~15% alpha
|
||||
g2.setColor(bg);
|
||||
g2.fill(new RoundRectangle2D.Double(
|
||||
node.x(), node.y(), node.width(), node.height(),
|
||||
CORNER_RADIUS, CORNER_RADIUS));
|
||||
|
||||
// Border
|
||||
g2.setColor(color);
|
||||
g2.setStroke(new BasicStroke(1.5f));
|
||||
g2.draw(new RoundRectangle2D.Double(
|
||||
node.x(), node.y(), node.width(), node.height(),
|
||||
CORNER_RADIUS, CORNER_RADIUS));
|
||||
|
||||
// Label at top
|
||||
g2.setColor(color);
|
||||
FontMetrics fm = g2.getFontMetrics();
|
||||
float labelX = (float) (node.x() + COMPOUND_SIDE_PADDING);
|
||||
float labelY = (float) (node.y() + 18);
|
||||
g2.drawString(node.label(), labelX, labelY);
|
||||
|
||||
// Draw children inside
|
||||
for (PositionedNode child : node.children()) {
|
||||
Color childColor = colorForTypeName(child.type());
|
||||
drawNode(g2, child, childColor);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawEdge(SVGGraphics2D g2, PositionedEdge edge) {
|
||||
List<double[]> points = edge.points();
|
||||
if (points.size() < 2) return;
|
||||
|
||||
GeneralPath path = new GeneralPath();
|
||||
path.moveTo(points.get(0)[0], points.get(0)[1]);
|
||||
for (int i = 1; i < points.size(); i++) {
|
||||
path.lineTo(points.get(i)[0], points.get(i)[1]);
|
||||
}
|
||||
g2.draw(path);
|
||||
|
||||
// Arrowhead at the last point
|
||||
if (points.size() >= 2) {
|
||||
double[] end = points.get(points.size() - 1);
|
||||
double[] prev = points.get(points.size() - 2);
|
||||
drawArrowhead(g2, prev[0], prev[1], end[0], end[1]);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawArrowhead(SVGGraphics2D g2, double fromX, double fromY, double toX, double toY) {
|
||||
double angle = Math.atan2(toY - fromY, toX - fromX);
|
||||
int arrowSize = 8;
|
||||
|
||||
GeneralPath arrow = new GeneralPath();
|
||||
arrow.moveTo(toX, toY);
|
||||
arrow.lineTo(toX - arrowSize * Math.cos(angle - Math.PI / 6),
|
||||
toY - arrowSize * Math.sin(angle - Math.PI / 6));
|
||||
arrow.lineTo(toX - arrowSize * Math.cos(angle + Math.PI / 6),
|
||||
toY - arrowSize * Math.sin(angle + Math.PI / 6));
|
||||
arrow.closePath();
|
||||
g2.fill(arrow);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Color mapping
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private Color colorForType(NodeType type) {
|
||||
if (type == null) return PURPLE;
|
||||
if (ENDPOINT_TYPES.contains(type)) return BLUE;
|
||||
if (PROCESSOR_TYPES.contains(type)) return GREEN;
|
||||
if (ERROR_TYPES.contains(type)) return RED;
|
||||
if (EIP_TYPES.contains(type)) return EIP_TYPES.contains(type) ? PURPLE : PURPLE;
|
||||
if (CROSS_ROUTE_TYPES.contains(type)) return CYAN;
|
||||
return PURPLE;
|
||||
}
|
||||
|
||||
private Color colorForTypeName(String typeName) {
|
||||
try {
|
||||
NodeType type = NodeType.valueOf(typeName);
|
||||
return colorForType(type);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return PURPLE;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// ELK graph helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private ElkNode findCommonParent(ElkNode a, ElkNode b) {
|
||||
if (a.getParent() == b.getParent()) {
|
||||
return a.getParent();
|
||||
}
|
||||
// If one is the parent of the other
|
||||
if (a.getParent() != null && a.getParent() == b) return b;
|
||||
if (b.getParent() != null && b.getParent() == a) return a;
|
||||
// Default: root (grandparent)
|
||||
ElkNode parent = a.getParent();
|
||||
while (parent != null && parent.getParent() != null) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return parent != null ? parent : a.getParent();
|
||||
}
|
||||
|
||||
private double getAbsoluteX(ElkNode node, ElkNode root) {
|
||||
double x = 0;
|
||||
ElkNode current = node;
|
||||
while (current != null && current != root) {
|
||||
x += current.getX();
|
||||
current = current.getParent();
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
private double getAbsoluteY(ElkNode node, ElkNode root) {
|
||||
double y = 0;
|
||||
ElkNode current = node;
|
||||
while (current != null && current != root) {
|
||||
y += current.getY();
|
||||
current = current.getParent();
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
private List<ElkEdge> collectAllEdges(ElkNode node) {
|
||||
List<ElkEdge> edges = new ArrayList<>(node.getContainedEdges());
|
||||
for (ElkNode child : node.getChildren()) {
|
||||
edges.addAll(collectAllEdges(child));
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
private PositionedNode findNode(List<PositionedNode> nodes, String id) {
|
||||
for (PositionedNode n : nodes) {
|
||||
if (n.id().equals(id)) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<PositionedNode> allNodes(List<PositionedNode> nodes) {
|
||||
List<PositionedNode> all = new ArrayList<>();
|
||||
for (PositionedNode n : nodes) {
|
||||
all.add(n);
|
||||
if (n.children() != null) {
|
||||
all.addAll(n.children());
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal data classes
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private record LayoutResult(
|
||||
DiagramLayout layout,
|
||||
Map<String, Color> nodeColors,
|
||||
Map<String, CompoundInfo> compoundInfos
|
||||
) {}
|
||||
|
||||
private record CompoundInfo(String nodeId, Color color) {}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.AbstractClickHouseIT;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static java.util.concurrent.TimeUnit.SECONDS;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* Integration tests for {@link DiagramRenderController}.
|
||||
* Seeds a diagram via the ingestion endpoint, then tests rendering.
|
||||
*/
|
||||
class DiagramRenderControllerIT extends AbstractClickHouseIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
private String contentHash;
|
||||
|
||||
/**
|
||||
* Seed a diagram and compute its content hash for render tests.
|
||||
*/
|
||||
@BeforeEach
|
||||
void seedDiagram() {
|
||||
String json = """
|
||||
{
|
||||
"routeId": "render-test-route",
|
||||
"description": "Render test",
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "n1", "type": "ENDPOINT", "label": "timer:tick"},
|
||||
{"id": "n2", "type": "BEAN", "label": "myBean"},
|
||||
{"id": "n3", "type": "TO", "label": "log:out"}
|
||||
],
|
||||
"edges": [
|
||||
{"source": "n1", "target": "n2", "edgeType": "FLOW"},
|
||||
{"source": "n2", "target": "n3", "edgeType": "FLOW"}
|
||||
],
|
||||
"processorNodeMapping": {}
|
||||
}
|
||||
""";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
|
||||
restTemplate.postForEntity(
|
||||
"/api/v1/data/diagrams",
|
||||
new HttpEntity<>(json, headers),
|
||||
String.class);
|
||||
|
||||
// Wait for flush to ClickHouse and retrieve the content hash
|
||||
await().atMost(10, SECONDS).untilAsserted(() -> {
|
||||
String hash = jdbcTemplate.queryForObject(
|
||||
"SELECT content_hash FROM route_diagrams WHERE route_id = 'render-test-route' LIMIT 1",
|
||||
String.class);
|
||||
assertThat(hash).isNotNull();
|
||||
contentHash = hash;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSvg_withAcceptHeader_returnsSvg() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Accept", "image/svg+xml");
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/diagrams/{hash}/render",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
String.class,
|
||||
contentHash);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType().toString()).contains("svg");
|
||||
assertThat(response.getBody()).contains("<svg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getJson_withAcceptHeader_returnsJson() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Accept", "application/json");
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/diagrams/{hash}/render",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
String.class,
|
||||
contentHash);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).contains("nodes");
|
||||
assertThat(response.getBody()).contains("edges");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNonExistentHash_returns404() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Accept", "image/svg+xml");
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/diagrams/{hash}/render",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
String.class,
|
||||
"nonexistent-hash-12345");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWithNoAcceptHeader_defaultsToSvg() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/diagrams/{hash}/render",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
String.class,
|
||||
contentHash);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).contains("<svg");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.cameleer3.server.app.diagram;
|
||||
|
||||
import com.cameleer3.common.graph.NodeType;
|
||||
import com.cameleer3.common.graph.RouteEdge;
|
||||
import com.cameleer3.common.graph.RouteGraph;
|
||||
import com.cameleer3.common.graph.RouteNode;
|
||||
import com.cameleer3.server.core.diagram.DiagramLayout;
|
||||
import com.cameleer3.server.core.diagram.PositionedNode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ElkDiagramRenderer}.
|
||||
* No Spring context needed -- pure unit test.
|
||||
*/
|
||||
class ElkDiagramRendererTest {
|
||||
|
||||
private ElkDiagramRenderer renderer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
renderer = new ElkDiagramRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a simple 3-node route: from(endpoint) -> process(bean) -> to(endpoint)
|
||||
*/
|
||||
private RouteGraph buildSimpleGraph() {
|
||||
RouteGraph graph = new RouteGraph("test-route");
|
||||
graph.setExtractedAt(Instant.now());
|
||||
graph.setVersion(1);
|
||||
|
||||
RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "timer:tick");
|
||||
RouteNode process = new RouteNode("node-2", NodeType.BEAN, "myProcessor");
|
||||
RouteNode to = new RouteNode("node-3", NodeType.TO, "log:output");
|
||||
|
||||
graph.setNodes(List.of(from, process, to));
|
||||
graph.setEdges(List.of(
|
||||
new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW),
|
||||
new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW)
|
||||
));
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compound graph: from -> choice -> (when, otherwise) -> to
|
||||
*/
|
||||
private RouteGraph buildCompoundGraph() {
|
||||
RouteGraph graph = new RouteGraph("compound-route");
|
||||
graph.setExtractedAt(Instant.now());
|
||||
graph.setVersion(1);
|
||||
|
||||
RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "direct:start");
|
||||
RouteNode choice = new RouteNode("node-2", NodeType.EIP_CHOICE, "choice");
|
||||
RouteNode when = new RouteNode("node-3", NodeType.EIP_WHEN, "when(simple)");
|
||||
RouteNode otherwise = new RouteNode("node-4", NodeType.EIP_OTHERWISE, "otherwise");
|
||||
RouteNode to = new RouteNode("node-5", NodeType.TO, "log:result");
|
||||
|
||||
// Set children on the choice node
|
||||
choice.setChildren(List.of(when, otherwise));
|
||||
|
||||
graph.setNodes(List.of(from, choice, when, otherwise, to));
|
||||
graph.setEdges(List.of(
|
||||
new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW),
|
||||
new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW),
|
||||
new RouteEdge("node-2", "node-4", RouteEdge.EdgeType.FLOW),
|
||||
new RouteEdge("node-3", "node-5", RouteEdge.EdgeType.FLOW),
|
||||
new RouteEdge("node-4", "node-5", RouteEdge.EdgeType.FLOW)
|
||||
));
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSvg_simpleGraph_producesValidSvg() {
|
||||
String svg = renderer.renderSvg(buildSimpleGraph());
|
||||
|
||||
assertNotNull(svg);
|
||||
assertTrue(svg.contains("<svg"), "SVG should contain <svg element");
|
||||
assertTrue(svg.contains("</svg>"), "SVG should be properly closed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSvg_simpleGraph_containsNodeShapes() {
|
||||
String svg = renderer.renderSvg(buildSimpleGraph());
|
||||
|
||||
// Should contain rect elements for nodes
|
||||
assertTrue(svg.contains("<rect") || svg.contains("<path"),
|
||||
"SVG should contain rect or path elements for nodes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSvg_simpleGraph_containsNodeLabels() {
|
||||
String svg = renderer.renderSvg(buildSimpleGraph());
|
||||
|
||||
assertTrue(svg.contains("timer:tick"), "SVG should contain endpoint label");
|
||||
assertTrue(svg.contains("myProcessor"), "SVG should contain processor label");
|
||||
assertTrue(svg.contains("log:output"), "SVG should contain to label");
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSvg_endpointNodes_haveBlueColor() {
|
||||
String svg = renderer.renderSvg(buildSimpleGraph());
|
||||
|
||||
// Endpoint nodes should have blue fill (#3B82F6)
|
||||
assertTrue(svg.contains("#3B82F6") || svg.contains("#3b82f6") ||
|
||||
svg.contains("rgb(59,130,246)") || svg.contains("rgb(59, 130, 246)"),
|
||||
"Endpoint nodes should have blue fill color (#3B82F6)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSvg_containsEdgeLines() {
|
||||
String svg = renderer.renderSvg(buildSimpleGraph());
|
||||
|
||||
// Edges should be drawn as lines or paths
|
||||
assertTrue(svg.contains("<line") || svg.contains("<polyline") || svg.contains("<path"),
|
||||
"SVG should contain line/path elements for edges");
|
||||
}
|
||||
|
||||
@Test
|
||||
void layoutJson_simpleGraph_returnsCorrectNodeCount() {
|
||||
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
|
||||
|
||||
assertNotNull(layout);
|
||||
assertEquals(3, layout.nodes().size(), "Should have 3 positioned nodes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void layoutJson_simpleGraph_nodesHavePositiveCoordinates() {
|
||||
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
|
||||
|
||||
for (PositionedNode node : layout.nodes()) {
|
||||
assertTrue(node.x() >= 0, "Node x should be >= 0: " + node.id());
|
||||
assertTrue(node.y() >= 0, "Node y should be >= 0: " + node.id());
|
||||
assertTrue(node.width() > 0, "Node width should be > 0: " + node.id());
|
||||
assertTrue(node.height() > 0, "Node height should be > 0: " + node.id());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void layoutJson_simpleGraph_hasPositiveDimensions() {
|
||||
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
|
||||
|
||||
assertTrue(layout.width() > 0, "Layout width should be positive");
|
||||
assertTrue(layout.height() > 0, "Layout height should be positive");
|
||||
}
|
||||
|
||||
@Test
|
||||
void layoutJson_simpleGraph_hasEdges() {
|
||||
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
|
||||
|
||||
assertEquals(2, layout.edges().size(), "Should have 2 edges");
|
||||
}
|
||||
|
||||
@Test
|
||||
void layoutJson_compoundGraph_choiceNodeHasChildren() {
|
||||
DiagramLayout layout = renderer.layoutJson(buildCompoundGraph());
|
||||
|
||||
PositionedNode choiceNode = layout.nodes().stream()
|
||||
.filter(n -> "node-2".equals(n.id()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("Choice node not found"));
|
||||
|
||||
assertNotNull(choiceNode.children(), "Choice node should have children");
|
||||
assertFalse(choiceNode.children().isEmpty(), "Choice node should have non-empty children");
|
||||
assertEquals(2, choiceNode.children().size(), "Choice node should have 2 children (when, otherwise)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSvg_compoundGraph_producesValidSvg() {
|
||||
String svg = renderer.renderSvg(buildCompoundGraph());
|
||||
|
||||
assertNotNull(svg);
|
||||
assertTrue(svg.contains("<svg"), "Compound SVG should contain <svg element");
|
||||
assertTrue(svg.contains("choice"), "SVG should contain choice label");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user