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:
@@ -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