This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch quick-fix/switch-decision-table in repository https://gitbox.apache.org/repos/asf/camel.git
commit c308d49d1e4d951d888200268a667106075dc99e Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 1 18:30:25 2026 +0200 chore: camel-jbang - the Switch EIP drawn as a decision table in the route diagram Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/diagram/RouteDiagramLayoutEngine.java | 84 +++++++++- .../diagram/RouteDiagramLayoutEngineTest.java | 62 +++++++ .../jbang/core/commands/tui/DiagramSupport.java | 9 +- .../core/commands/tui/diagram/DiagramColors.java | 2 +- .../commands/tui/diagram/RouteDiagramWidget.java | 182 ++++++++++++++++++++- .../core/commands/tui/diagram/SwitchTableTest.java | 115 +++++++++++++ 6 files changed, 443 insertions(+), 11 deletions(-) diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java index 3a6e8298247a..c03320cd89ec 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java @@ -53,6 +53,7 @@ public class RouteDiagramLayoutEngine { private final int baseNodeHeight; private final FontMetrics fontMetrics; private final NodeLabelMode nodeLabelMode; + private boolean tableLayout; static final Set<String> BRANCHING_EIPS = Set.of( "choice", "multicast", "doTry", "loadBalance", "recipientList", "circuitBreaker"); @@ -63,6 +64,15 @@ public class RouteDiagramLayoutEngine { static final Set<String> STRUCTURAL_TYPES = Set.of( "route", "from"); + /** + * The EIPs drawn as a table, one row per child, with the table layout on: a Switch is a decision table of value and + * endpoint, its cases are alternatives (not steps after each other) and can be many. + */ + public static final Set<String> TABLE_EIPS = Set.of("switch"); + + /** The rows of a table shown at once; the rest are reached by scrolling the table. */ + public static final int MAX_TABLE_ROWS = 8; + public static class Bounds { public int minX, minY, maxX, maxY; @@ -119,6 +129,19 @@ public class RouteDiagramLayoutEngine { return nodeWidth; } + /** + * Draws the {@link #TABLE_EIPS} as one wide node with a row per child instead of a chain of boxes. Off by default: + * a renderer turns it on when it draws the rows ({@link LayoutNode#tableRow}). + */ + public void setTableLayout(boolean tableLayout) { + this.tableLayout = tableLayout; + } + + /** The width of a table node: two common nodes and the gap between them. */ + public int getTableWidth() { + return nodeWidth * 2 + hGap; + } + public int getBaseNodeHeight() { return baseNodeHeight; } @@ -178,6 +201,8 @@ public class RouteDiagramLayoutEngine { public final List<TreeNode> children = new ArrayList<>(); public int subtreeWidth; public LayoutNode layoutNode; + /** Drawn as a table of its children (the table layout is on and it is one of {@link #TABLE_EIPS}). */ + public boolean table; public TreeNode(NodeInfo info) { this.info = info; @@ -196,6 +221,11 @@ public class RouteDiagramLayoutEngine { public boolean connectFromMerge; public int mergeY; public int mergeCx; + /** The width of the node when it is wider than the others (a table); 0 for the common width. */ + public int width; + /** A row of a table node: drawn inside its parent's box, at its index among the rows. */ + public boolean tableRow; + public int tableRowIndex; } public static class LayoutRoute { @@ -349,6 +379,9 @@ public class RouteDiagramLayoutEngine { lr.labelY = startY; TreeNode tree = buildTree(route.nodes); + if (tree != null && tableLayout) { + markTables(tree); + } if (tree == null) { lr.maxX = PADDING + nodeWidth; lr.maxY = startY + LABEL_OFFSET; @@ -382,7 +415,18 @@ public class RouteDiagramLayoutEngine { return lr; } + private static void markTables(TreeNode node) { + node.table = TABLE_EIPS.contains(node.info.type) && !node.children.isEmpty(); + for (TreeNode child : node.children) { + markTables(child); + } + } + private int computeSubtreeWidth(TreeNode node) { + if (node.table) { + node.subtreeWidth = getTableWidth(); + return node.subtreeWidth; + } if (node.children.isEmpty()) { node.subtreeWidth = nodeWidth; return node.subtreeWidth; @@ -409,7 +453,9 @@ public class RouteDiagramLayoutEngine { private void assignPositions(TreeNode node, int x, int y, int parentWidth, LayoutRoute lr) { int availableWidth = Math.max(node.subtreeWidth, parentWidth); - int nodeX = x + (availableWidth - nodeWidth) / 2; + int ownWidth = node.table ? getTableWidth() : nodeWidth; + // a table stands centered where a common node would, so the arrows in and out stay straight + int nodeX = x + (availableWidth - nodeWidth) / 2 - (ownWidth - nodeWidth) / 2; LayoutNode ln = new LayoutNode(); ln.type = node.info.type; @@ -452,9 +498,30 @@ public class RouteDiagramLayoutEngine { } } + if (node.table) { + ln.width = ownWidth; + int rows = Math.min(node.children.size(), MAX_TABLE_ROWS) + + (node.children.size() > MAX_TABLE_ROWS ? 1 : 0); + ln.height = Math.max(ln.height, baseNodeHeight + (rows - 1) * fontMetrics.getHeight()); + for (int i = 0; i < node.children.size(); i++) { + TreeNode child = node.children.get(i); + LayoutNode row = new LayoutNode(); + row.type = child.info.type; + row.id = child.info.id; + row.x = nodeX; + row.y = y; + row.wrappedLines = resolveLabel(child.info, nodeLabelMode); + row.treeNode = child; + row.tableRow = true; + row.tableRowIndex = i; + child.layoutNode = row; + lr.nodes.add(row); + } + } + lr.maxY = Math.max(lr.maxY, y + ln.height); - if (node.children.isEmpty()) { + if (node.children.isEmpty() || node.table) { return; } @@ -492,7 +559,7 @@ public class RouteDiagramLayoutEngine { } private static LayoutNode findLastLayoutNode(TreeNode node) { - if (node.children.isEmpty()) { + if (node.children.isEmpty() || node.table) { return node.layoutNode; } if (isBranchingEip(node.info.type)) { @@ -503,6 +570,9 @@ public class RouteDiagramLayoutEngine { private static int findMaxY(TreeNode node) { int maxY = node.layoutNode != null ? node.layoutNode.y + node.layoutNode.height : 0; + if (node.table) { + return maxY; + } for (TreeNode child : node.children) { maxY = Math.max(maxY, findMaxY(child)); } @@ -516,6 +586,7 @@ public class RouteDiagramLayoutEngine { public static boolean hasScope(TreeNode node) { return node.parent != null && !node.children.isEmpty() + && !node.table && !BRANCH_CHILD_TYPES.contains(node.info.type) && !STRUCTURAL_TYPES.contains(node.info.type); } @@ -536,11 +607,16 @@ public class RouteDiagramLayoutEngine { bounds.maxY = Math.max(bounds.maxY, inner.maxY + SCOPE_BOX_PAD); } else { if (node.layoutNode != null) { + int w = node.layoutNode.width > 0 ? node.layoutNode.width : nodeWidth; bounds.minX = Math.min(bounds.minX, node.layoutNode.x); bounds.minY = Math.min(bounds.minY, node.layoutNode.y); - bounds.maxX = Math.max(bounds.maxX, node.layoutNode.x + nodeWidth); + bounds.maxX = Math.max(bounds.maxX, node.layoutNode.x + w); bounds.maxY = Math.max(bounds.maxY, node.layoutNode.y + node.layoutNode.height); } + if (node.table) { + // the rows are inside the table's box + return; + } for (TreeNode child : node.children) { expandBoundsForBox(child, bounds, nodeWidth); } diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java index c8a569a079f4..e6470930cd49 100644 --- a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java @@ -231,4 +231,66 @@ class RouteDiagramLayoutEngineTest { .as("maxY must equal the bottom of the deepest node") .isEqualTo(l1.y + l1.height); } + + // ─── Switch as a decision table (table layout) ────────────────────────── + + private static RouteDiagramLayoutEngine.RouteInfo switchRoute(int cases) { + RouteDiagramLayoutEngine.RouteInfo r = route( + node("route", "r", 0), + node("from", "f", 1), + node("switch", "s", 2)); + for (int i = 1; i <= cases; i++) { + r.nodes.add(node("case", "case" + i, 3)); + } + r.nodes.add(node("to", "s-otherwise", 3)); + r.nodes.add(node("log", "after", 2)); + return r; + } + + @Test + void withoutTheTableLayoutASwitchIsAChain() { + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute(switchRoute(4), 0); + assertThat(findNode(lr, "case2").y).isGreaterThan(findNode(lr, "case1").y); + assertThat(findNode(lr, "case1").tableRow).isFalse(); + } + + @Test + void aSwitchIsOneWideTableWithARowPerCase() { + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + engine.setTableLayout(true); + RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(switchRoute(4), 0); + + RouteDiagramLayoutEngine.LayoutNode table = findNode(lr, "s"); + assertThat(table.width).isEqualTo(engine.getTableWidth()); + assertThat(table.treeNode.table).isTrue(); + // the rows: inside the table, in order, without arrows of their own + for (int i = 1; i <= 4; i++) { + RouteDiagramLayoutEngine.LayoutNode row = findNode(lr, "case" + i); + assertThat(row.tableRow).isTrue(); + assertThat(row.tableRowIndex).isEqualTo(i - 1); + assertThat(row.parentNode).isNull(); + assertThat(row.y).isEqualTo(table.y); + } + assertThat(findNode(lr, "s-otherwise").tableRowIndex).isEqualTo(4); + // the next step comes after the table, from the table + RouteDiagramLayoutEngine.LayoutNode after = findNode(lr, "after"); + assertThat(after.parentNode).isSameAs(table); + assertThat(after.y).isGreaterThanOrEqualTo(table.y + table.height); + // centered where a common node is: the arrows stay straight + RouteDiagramLayoutEngine.LayoutNode from = findNode(lr, "f"); + assertThat(table.x + table.width / 2).isEqualTo(from.x + NODE_W / 2); + assertThat(RouteDiagramLayoutEngine.hasScope(table.treeNode)).as("no scope box around a table").isFalse(); + } + + @Test + void aLongTableShowsTheFirstRowsAndSaysThereAreMore() { + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + engine.setTableLayout(true); + RouteDiagramLayoutEngine.LayoutNode small = findNode(engine.layoutRoute(switchRoute(4), 0), "s"); + RouteDiagramLayoutEngine.LayoutNode big = findNode(engine.layoutRoute(switchRoute(30), 0), "s"); + RouteDiagramLayoutEngine.LayoutNode bigger = findNode(engine.layoutRoute(switchRoute(60), 0), "s"); + assertThat(big.height).isGreaterThan(small.height); + assertThat(bigger.height).as("as tall as " + RouteDiagramLayoutEngine.MAX_TABLE_ROWS + " rows and the more line") + .isEqualTo(big.height); + } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java index 593643b3c40c..388262078be2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java @@ -1153,7 +1153,7 @@ class DiagramSupport { return null; } // Only "to"-style nodes can link to other routes - if (!"to".equals(type) && !"toD".equals(type) && !"wireTap".equals(type) + if (!"to".equals(type) && !"toD".equals(type) && !"case".equals(type) && !"wireTap".equals(type) && !"enrich".equals(type) && !"pollEnrich".equals(type) && !"from".equals(type)) { return null; @@ -1206,7 +1206,7 @@ class DiagramSupport { private static boolean sendsTo(RouteDiagramLayoutEngine.LayoutRoute lr, String baseUri) { for (var node : lr.nodes) { if (node.treeNode != null && ("to".equals(node.type) || "toD".equals(node.type) - || "wireTap".equals(node.type) || "enrich".equals(node.type)) + || "case".equals(node.type) || "wireTap".equals(node.type) || "enrich".equals(node.type)) && baseUri.equals(getBaseUri(node.treeNode.info))) { return true; } @@ -1412,7 +1412,7 @@ class DiagramSupport { if (currentFromUri != null) { for (var node : lr.nodes) { String type = node.type; - if (("to".equals(type) || "toD".equals(type) || "wireTap".equals(type)) + if (("to".equals(type) || "toD".equals(type) || "case".equals(type) || "wireTap".equals(type)) && node.treeNode != null) { String uri = getBaseUri(node.treeNode.info); if (currentFromUri.equals(uri)) { @@ -1646,6 +1646,8 @@ class DiagramSupport { RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine( RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE, labelMode); + // a Switch is drawn as a decision table, a row per case + engine.setTableLayout(true); Map<String, RouteDiagramLayoutEngine.LayoutRoute> routeMap = new LinkedHashMap<>(); for (RouteDiagramLayoutEngine.RouteInfo r : routes) { @@ -2185,6 +2187,7 @@ class DiagramSupport { RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine( RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE, labelMode); + engine.setTableLayout(true); for (RouteDiagramLayoutEngine.RouteInfo r : routes) { RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(r, 0); normalizeRouteLayoutY(lr); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java index ffa37460d7c8..86416c408201 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java @@ -94,7 +94,7 @@ public final class DiagramColors { return switch (type) { case "from" -> Theme.diagramFrom(); case "to", "toD", "wireTap", "enrich", "pollEnrich" -> Theme.diagramTo(); - case "choice", "when", "otherwise" -> Theme.diagramChoice(); + case "choice", "when", "otherwise", "switch", "case" -> Theme.diagramChoice(); case "marshal", "unmarshal", "transform", "setBody", "setHeader", "setProperty", "convertBodyTo", "removeHeader", "removeHeaders", "removeProperty", "removeProperties" -> Theme.diagramTo(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java index 39387c557f30..ff92bbd4f79d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java @@ -172,7 +172,13 @@ public class RouteDiagramWidget implements Widget { // Nodes (on top, skip the structural "route" node) markers.clear(); for (LayoutNode ln : layoutRoute.nodes) { - if (!"route".equals(ln.type)) { + if (ln.tableRow) { + // drawn by its table + continue; + } + if (ln.treeNode != null && ln.treeNode.table) { + drawTable(buffer, area, ln); + } else if (!"route".equals(ln.type)) { drawNode(buffer, area, ln); } } @@ -488,13 +494,183 @@ public class RouteDiagramWidget implements Widget { } private int centerCol(LayoutNode node) { - return toCol(node.x + nodeWidth / 2); + return toCol(node.x + (node.width > 0 ? node.width : nodeWidth) / 2); } private int boxHeight(LayoutNode node) { + if (node.treeNode != null && node.treeNode.table) { + return 2 + tableVisibleRows(node.treeNode.children.size()); + } return 2 + rewrapText(node, boxWidth - 4).size(); } + /** The rows a table shows: its cases up to the limit, and a line that says there are more. */ + private static int tableVisibleRows(int rows) { + return Math.min(rows, RouteDiagramLayoutEngine.MAX_TABLE_ROWS) + + (rows > RouteDiagramLayoutEngine.MAX_TABLE_ROWS ? 1 : 0); + } + + /** + * A Switch as a decision table: the selector in the top border, one row per case (the value, where it sends, its + * count) and the otherwise. Its rows are nodes of their own (selected, highlighted, linked), drawn inside the box; + * a long table shows a window of its rows that follows the selection. + */ + private void drawTable(Buffer buffer, Rect area, LayoutNode table) { + List<TreeNode> rows = table.treeNode.children; + int col = toCol(table.x); + int width = Math.max(boxWidth * 2, toCol(table.x + table.width) - col); + int window = Math.min(rows.size(), RouteDiagramLayoutEngine.MAX_TABLE_ROWS); + boolean more = rows.size() > window; + int row = toRow(table.y); + int height = 2 + window + (more ? 1 : 0); + + int tableIdx = nodeBoxes.size(); + boolean selected = tableIdx == selectedNodeIndex; + int selectedRow = selectedNodeIndex - tableIdx - 1; + int offset = selectedRow >= window ? Math.min(selectedRow - window + 1, rows.size() - window) : 0; + + Color eipColor = getEipColor(table.type); + Style borderStyle = Style.EMPTY.fg(eipColor); + if (selected) { + borderStyle = borderStyle.patch(selectionStyle()); + } + int right = col + width - 1; + int bottom = row + height - 1; + setChar(buffer, area, row, col, TL, borderStyle); + setChar(buffer, area, row, right, TR, borderStyle); + setChar(buffer, area, bottom, col, BL, borderStyle); + setChar(buffer, area, bottom, right, BR, borderStyle); + for (int c = col + 1; c < right; c++) { + setChar(buffer, area, row, c, H, borderStyle); + setChar(buffer, area, bottom, c, H, borderStyle); + } + for (int r = row + 1; r < bottom; r++) { + setChar(buffer, area, r, col, V, borderStyle); + setChar(buffer, area, r, right, V, borderStyle); + for (int c = col + 1; c < right; c++) { + setChar(buffer, area, r, c, ' ', Style.EMPTY); + } + } + String header = " " + tableHeader(table) + " "; + if (header.length() > width - 4) { + header = header.substring(0, Math.max(1, width - 5)) + "… "; + } + writeText(buffer, area, row, col + 2, header, style(Style.EMPTY.fg(eipColor).bold(), selected)); + // the table itself: its top border, so a click on a row selects the row + nodeBoxes.add(new EipNodeBox(table.id, table.type, row, row, col, right, table)); + + int inner = width - 4; + int valueWidth = 4; + for (TreeNode r : rows) { + valueWidth = Math.max(valueWidth, caseValue(r).length()); + } + valueWidth = Math.min(valueWidth, Math.max(4, inner / 3)); + for (int i = 0; i < rows.size(); i++) { + LayoutNode rowNode = rows.get(i).layoutNode; + int visible = i - offset; + if (visible < 0 || visible >= window) { + // out of the window: still a node to select, the window moves to it + int at = visible < 0 ? row : bottom; + nodeBoxes.add(new EipNodeBox(rowNode.id, rowNode.type, at, at, col, right, rowNode)); + continue; + } + int r = row + 1 + visible; + int idx = nodeBoxes.size(); + boolean rowSelected = idx == selectedNodeIndex; + boolean highlighted = rowNode.id != null && highlightNodeIds.contains(rowNode.id); + StatInfo stat = showMetrics ? rows.get(i).info.stat : null; + long total = stat != null ? stat.exchangesTotal : 0; + + String count = stat != null ? String.valueOf(total) : ""; + String link = findLinkedRouteId(rowNode) != null ? "↵ " : ""; + String target = withoutOptions(caseTarget(rows.get(i))); + String value = caseValue(rows.get(i)); + if (value.length() > valueWidth) { + value = value.substring(0, Math.max(1, valueWidth - 1)) + "…"; + } + String tail = link + count; + int targetRoom = Math.max(1, inner - valueWidth - 3 - tail.length() - 1); + if (target.length() > targetRoom) { + target = target.substring(0, Math.max(1, targetRoom - 1)) + "…"; + } + + Style base = rowSelected ? selectionStyle() : Style.EMPTY; + for (int c = col + 1; c < right; c++) { + setChar(buffer, area, r, c, ' ', base); + } + Style valueStyle = highlighted + ? Style.EMPTY.fg(highlightFailed ? highlightFailColor() : highlightOkColor()).bold() + : Style.EMPTY.fg(eipColor); + // idle when its counter says so; a row without one (no statistics) is not dimmed + boolean idle = stat != null && total == 0 && !highlighted; + Style targetStyle = idle ? Theme.muted() : fromLabelStyle(); + writeText(buffer, area, r, col + 2, String.format("%-" + valueWidth + "s", value), + style(valueStyle, rowSelected)); + writeText(buffer, area, r, col + 2 + valueWidth, " → ", style(Theme.muted(), rowSelected)); + writeText(buffer, area, r, col + 5 + valueWidth, target, style(targetStyle, rowSelected)); + if (!tail.isEmpty()) { + writeText(buffer, area, r, right - 1 - tail.length(), tail, + style(link.isEmpty() ? metricsOkStyle() : Theme.label().bold(), rowSelected)); + } + nodeBoxes.add(new EipNodeBox(rowNode.id, rowNode.type, r, r, col, right, rowNode)); + } + if (more) { + String note = (offset > 0 ? "↑ " : "") + rows.size() + " cases, " + (offset + 1) + "-" + (offset + window) + + " shown" + (offset + window < rows.size() ? " ↓" : ""); + writeText(buffer, area, bottom - 1, col + 2, note, Theme.muted()); + } + } + + /** The selector of a Switch, as its header: switch exchangeProperty{specialist}. */ + private String tableHeader(LayoutNode table) { + var info = table.treeNode.info; + if (showDescription && info.description != null && !info.description.isBlank()) { + return info.description; + } + String code = info.code != null ? info.code : table.type; + int open = code.indexOf('['); + int close = code.lastIndexOf(']'); + return open > 0 && close > open ? code.substring(0, open) + " " + code.substring(open + 1, close) : code; + } + + /** The value of a case row (case[reservation -> a2a:...]), or otherwise. */ + static String caseValue(TreeNode row) { + String code = row.info.code != null ? row.info.code : ""; + if (code.startsWith("otherwise")) { + return "otherwise"; + } + int open = code.indexOf('['); + int arrow = code.indexOf(" -> "); + if (open >= 0 && arrow > open) { + return code.substring(open + 1, arrow); + } + return row.info.id != null ? row.info.id : ""; + } + + /** An endpoint without its options, which a table row has no room for: a2a:{{agents.weather.url}}. */ + static String withoutOptions(String uri) { + if (uri == null) { + return ""; + } + int q = uri.indexOf('?'); + return q >= 0 ? uri.substring(0, q) : uri; + } + + /** Where a case row sends to. */ + static String caseTarget(TreeNode row) { + if (row.info.uri != null) { + return row.info.uri; + } + String code = row.info.code != null ? row.info.code : ""; + int arrow = code.indexOf(" -> "); + int open = code.indexOf('['); + int close = code.lastIndexOf(']'); + if (arrow > 0 && close > arrow) { + return code.substring(arrow + 4, close); + } + return open >= 0 && close > open ? code.substring(open + 1, close) : code; + } + private List<String> rewrapText(LayoutNode node, int maxWidth) { String label = String.join("", node.wrappedLines); if (showDescription) { @@ -610,7 +786,7 @@ public class RouteDiagramWidget implements Widget { return null; } String type = node.type; - if (!"to".equals(type) && !"toD".equals(type) && !"wireTap".equals(type) + if (!"to".equals(type) && !"toD".equals(type) && !"case".equals(type) && !"wireTap".equals(type) && !"enrich".equals(type) && !"pollEnrich".equals(type) && !"from".equals(type)) { return null; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/SwitchTableTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/SwitchTableTest.java new file mode 100644 index 000000000000..7922dfcb1062 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/SwitchTableTest.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui.diagram; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import org.apache.camel.diagram.RouteDiagramLayoutEngine; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.StatInfo; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A Switch is drawn as a decision table: one box, a row per case with where it sends and its own count. + */ +class SwitchTableTest { + + private static NodeInfo node(String type, String id, int level, String code, String uri, Long total) { + NodeInfo n = new NodeInfo(); + n.type = type; + n.id = id; + n.level = level; + n.code = code; + n.uri = uri; + if (total != null) { + n.stat = new StatInfo(); + n.stat.exchangesTotal = total; + } + return n; + } + + private static RouteInfo route(int cases) { + RouteInfo r = new RouteInfo(); + r.routeId = "specialist-dispatch"; + r.nodes.add(node("route", "specialist-dispatch", 0, "route[specialist-dispatch]", null, null)); + r.nodes.add(node("from", "specialist-dispatch", 1, "from[direct:dispatch]", "direct:dispatch", 9L)); + r.nodes.add(node("switch", "switch1", 2, "switch[exchangeProperty{specialist}]", null, 9L)); + String[] names = { "reservation", "weather", "cost", "general" }; + for (int i = 0; i < cases; i++) { + String value = i < names.length ? names[i] : "value" + i; + String uri = "a2a:{{agents." + value + ".url}}?protocolBinding=JSONRPC"; + r.nodes.add(node("case", "case" + (i + 1), 3, "case[" + value + " -> " + uri + "]", uri, (long) i + 1)); + } + r.nodes.add(node("to", "switch1-otherwise", 3, "otherwise[direct:unsupported]", "direct:unsupported", 2L)); + return r; + } + + private static List<String> render(RouteInfo route, int selected) { + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + engine.setTableLayout(true); + LayoutRoute lr = engine.layoutRoute(route, 0); + RouteDiagramWidget widget = new RouteDiagramWidget( + lr, engine.getNodeWidth(), selected, 0, 0, true, + Map.of("direct:unsupported", "unsupported"), false, Map.of(), Set.of(), false); + Rect area = new Rect(0, 0, 120, 60); + Buffer buffer = Buffer.empty(area); + widget.render(area, buffer); + List<String> rows = new java.util.ArrayList<>(); + for (int y = 0; y < area.height(); y++) { + StringBuilder sb = new StringBuilder(); + for (int x = 0; x < area.width(); x++) { + sb.append(buffer.get(x, y).symbol()); + } + rows.add(sb.toString().stripTrailing()); + } + return rows; + } + + @Test + void aRowPerCaseWithItsCount() { + List<String> rows = render(route(4), -1); + String all = String.join("\n", rows); + + assertThat(all).contains("switch exchangeProperty{specialist}"); + assertThat(rows).anyMatch(r -> r.contains("reservation") && r.contains("→ a2a:{{agents.reservation.url}}") + && r.stripTrailing().endsWith("1 │")); + assertThat(rows).anyMatch(r -> r.contains("general") && r.stripTrailing().endsWith("4 │")); + // the otherwise sends to a route of the integration: a jump marker before its count + assertThat(rows).anyMatch(r -> r.contains("otherwise") && r.contains("↵ 2")); + // the options of an endpoint are left out, and the cases are not boxes of their own + assertThat(all).doesNotContain("protocolBinding").doesNotContain("case["); + } + + @Test + void aLongTableShowsAWindowThatFollowsTheSelection() { + // the boxes: from (0), the table (1), then its rows: row 20 is box 2 + 20 + List<String> rows = render(route(30), 2 + 20); + String all = String.join("\n", rows); + assertThat(all).contains("value20").doesNotContain("reservation ").doesNotContain("value21 "); + assertThat(all).contains("↑ 31 cases, 14-21 shown ↓"); + // without a selection in it, the table shows its first rows + assertThat(String.join("\n", render(route(30), -1))).contains("reservation").contains("31 cases, 1-8 shown ↓"); + } +}
