This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new dfec203070fd chore: add comprehensive test coverage for
camel-jbang-plugin-tui (#24279)
dfec203070fd is described below
commit dfec203070fdfe0cddab5cebecfdd2c06f21afd0
Author: Guillaume Nodet <[email protected]>
AuthorDate: Mon Jun 29 14:57:01 2026 +0200
chore: add comprehensive test coverage for camel-jbang-plugin-tui (#24279)
- Add 231 unit tests covering StatusParser, TuiHelper, SyntaxHighlighter,
and all tab classes
- Add 17 rendering tests (BorderRenderTest) verifying border characters in
virtual terminal buffer
- Add higher-level rendering tests for TUI tabs using
Frame.forTesting(Buffer)
---
.../dsl/jbang/core/commands/tui/ShellPanel.java | 6 +-
.../commands/tui/CamelMonitorParseKeyTest.java | 182 ++++++++
.../core/commands/tui/ErrorsTabRenderTest.java | 288 ++++++++++++
.../jbang/core/commands/tui/FuzzyFilterTest.java | 185 ++++++++
.../core/commands/tui/HealthTabRenderTest.java | 330 ++++++++++++++
.../dsl/jbang/core/commands/tui/LoadAvgTest.java | 84 ++++
.../commands/tui/MonitorContextRenderTest.java | 218 ++++++++++
.../core/commands/tui/MonitorContextTest.java | 235 ++++++++++
.../core/commands/tui/RoutesTabRenderTest.java | 328 ++++++++++++++
.../core/commands/tui/SearchHighlighterTest.java | 233 ++++++++++
.../jbang/core/commands/tui/ShellPanelTest.java | 324 ++++++++++++++
.../jbang/core/commands/tui/StatusParserTest.java | 482 +++++++++++++++++++++
.../dsl/jbang/core/commands/tui/TuiHelperTest.java | 242 +++++++++++
13 files changed, 3134 insertions(+), 3 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
index d92c7af8c92b..135548fc2eda 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
@@ -317,7 +317,7 @@ class ShellPanel {
return lines;
}
- private static Line convertRow(long[] buffer, int offset, int width) {
+ static Line convertRow(long[] buffer, int offset, int width) {
List<Span> spans = new ArrayList<>();
int col = 0;
while (col < width) {
@@ -488,7 +488,7 @@ class ShellPanel {
// Y: Bit 0=FG set, Bit 1=BG set, Bit 2=Dim, Bit 3=Italic
// F: Foreground r-g-b (3 hex nibbles)
// B: Background r-g-b (3 hex nibbles)
- private static Style convertAttrToStyle(long attr) {
+ static Style convertAttrToStyle(long attr) {
Style style = Style.EMPTY;
int x = (int) ((attr >> 24) & 0xF);
@@ -571,7 +571,7 @@ class ShellPanel {
};
}
- private static byte[] encodeKeyEvent(KeyEvent ke) {
+ static byte[] encodeKeyEvent(KeyEvent ke) {
if (ke.code() == KeyCode.CHAR) {
char ch = ke.character();
if (ke.hasCtrl()) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorParseKeyTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorParseKeyTest.java
new file mode 100644
index 000000000000..656ab3167490
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorParseKeyTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CamelMonitorParseKeyTest {
+
+ @Test
+ void parseKeySingleChar() {
+ KeyEvent ke = CamelMonitor.parseKey("a");
+ assertNotNull(ke);
+ assertEquals(KeyCode.CHAR, ke.code());
+ assertEquals('a', ke.character());
+ }
+
+ @Test
+ void parseKeyEnter() {
+ KeyEvent ke = CamelMonitor.parseKey("enter");
+ assertNotNull(ke);
+ assertEquals(KeyCode.ENTER, ke.code());
+ }
+
+ @Test
+ void parseKeyReturn() {
+ KeyEvent ke = CamelMonitor.parseKey("return");
+ assertNotNull(ke);
+ assertEquals(KeyCode.ENTER, ke.code());
+ }
+
+ @Test
+ void parseKeyEscape() {
+ KeyEvent ke = CamelMonitor.parseKey("esc");
+ assertNotNull(ke);
+ assertEquals(KeyCode.ESCAPE, ke.code());
+ }
+
+ @Test
+ void parseKeyEscapeFull() {
+ KeyEvent ke = CamelMonitor.parseKey("escape");
+ assertNotNull(ke);
+ assertEquals(KeyCode.ESCAPE, ke.code());
+ }
+
+ @Test
+ void parseKeyTab() {
+ KeyEvent ke = CamelMonitor.parseKey("tab");
+ assertNotNull(ke);
+ assertEquals(KeyCode.TAB, ke.code());
+ }
+
+ @Test
+ void parseKeyBackspace() {
+ KeyEvent ke = CamelMonitor.parseKey("backspace");
+ assertNotNull(ke);
+ assertEquals(KeyCode.BACKSPACE, ke.code());
+ }
+
+ @Test
+ void parseKeyDelete() {
+ KeyEvent ke = CamelMonitor.parseKey("delete");
+ assertNotNull(ke);
+ assertEquals(KeyCode.DELETE, ke.code());
+ }
+
+ @Test
+ void parseKeyDeleteShort() {
+ KeyEvent ke = CamelMonitor.parseKey("del");
+ assertNotNull(ke);
+ assertEquals(KeyCode.DELETE, ke.code());
+ }
+
+ @Test
+ void parseKeyArrows() {
+ assertEquals(KeyCode.UP, CamelMonitor.parseKey("up").code());
+ assertEquals(KeyCode.DOWN, CamelMonitor.parseKey("down").code());
+ assertEquals(KeyCode.LEFT, CamelMonitor.parseKey("left").code());
+ assertEquals(KeyCode.RIGHT, CamelMonitor.parseKey("right").code());
+ }
+
+ @Test
+ void parseKeyHomeEnd() {
+ assertEquals(KeyCode.HOME, CamelMonitor.parseKey("home").code());
+ assertEquals(KeyCode.END, CamelMonitor.parseKey("end").code());
+ }
+
+ @Test
+ void parseKeyPageUpDown() {
+ assertEquals(KeyCode.PAGE_UP, CamelMonitor.parseKey("pageup").code());
+ assertEquals(KeyCode.PAGE_UP, CamelMonitor.parseKey("pgup").code());
+ assertEquals(KeyCode.PAGE_DOWN,
CamelMonitor.parseKey("pagedown").code());
+ assertEquals(KeyCode.PAGE_DOWN, CamelMonitor.parseKey("pgdn").code());
+ }
+
+ @Test
+ void parseKeyFKeys() {
+ assertEquals(KeyCode.F1, CamelMonitor.parseKey("f1").code());
+ assertEquals(KeyCode.F6, CamelMonitor.parseKey("f6").code());
+ assertEquals(KeyCode.F12, CamelMonitor.parseKey("f12").code());
+ }
+
+ @Test
+ void parseKeySpace() {
+ KeyEvent ke = CamelMonitor.parseKey("space");
+ assertNotNull(ke);
+ assertEquals(KeyCode.CHAR, ke.code());
+ assertEquals(' ', ke.character());
+ }
+
+ @Test
+ void parseKeyCtrlModifier() {
+ KeyEvent ke = CamelMonitor.parseKey("Ctrl+c");
+ assertNotNull(ke);
+ assertEquals(KeyCode.CHAR, ke.code());
+ assertEquals('c', ke.character());
+ assertTrue(ke.hasCtrl());
+ }
+
+ @Test
+ void parseKeyShiftModifier() {
+ KeyEvent ke = CamelMonitor.parseKey("Shift+F6");
+ assertNotNull(ke);
+ assertEquals(KeyCode.F6, ke.code());
+ assertTrue(ke.hasShift());
+ }
+
+ @Test
+ void parseKeyCtrlAndShift() {
+ KeyEvent ke = CamelMonitor.parseKey("Ctrl+Shift+a");
+ assertNotNull(ke);
+ assertTrue(ke.hasCtrl());
+ assertTrue(ke.hasShift());
+ }
+
+ @Test
+ void parseKeyNullReturnsNull() {
+ assertNull(CamelMonitor.parseKey(null));
+ }
+
+ @Test
+ void parseKeyEmptyReturnsNull() {
+ assertNull(CamelMonitor.parseKey(""));
+ }
+
+ @Test
+ void parseKeyCaseInsensitive() {
+ KeyEvent ke1 = CamelMonitor.parseKey("ENTER");
+ assertNotNull(ke1);
+ assertEquals(KeyCode.ENTER, ke1.code());
+
+ KeyEvent ke2 = CamelMonitor.parseKey("Enter");
+ assertNotNull(ke2);
+ assertEquals(KeyCode.ENTER, ke2.code());
+ }
+
+ @Test
+ void parseKeyUnknownMultiCharReturnsNull() {
+ // Multi-character string that is not a known key name
+ assertNull(CamelMonitor.parseKey("xyz"));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
new file mode 100644
index 000000000000..8305970033d5
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
@@ -0,0 +1,288 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Higher-level rendering tests for {@link ErrorsTab}. Renders the errors
table into a virtual terminal buffer and
+ * inspects the rendered output.
+ */
+class ErrorsTabRenderTest {
+
+ private MonitorContext ctx;
+ private IntegrationInfo info;
+
+ @BeforeEach
+ void setUp() {
+ info = new IntegrationInfo();
+ info.pid = "9999";
+ info.name = "error-test-app";
+
+ AtomicReference<List<IntegrationInfo>> data = new
AtomicReference<>(List.of(info));
+ AtomicReference<List<InfraInfo>> infraData = new
AtomicReference<>(List.of());
+ ctx = new MonitorContext(data, infraData);
+ ctx.selectedPid = "9999";
+ }
+
+ @Test
+ void renderErrorsShowsTableHeaders() {
+ addError("ID-001", "route1", "to1", "IOException", "Connection
refused", false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 160, 30);
+
+ assertTrue(rendered.contains("ID"), "Should show ID header");
+ assertTrue(rendered.contains("ROUTE"), "Should show ROUTE header");
+ assertTrue(rendered.contains("NODE"), "Should show NODE header");
+ assertTrue(rendered.contains("HANDLED"), "Should show HANDLED header");
+ assertTrue(rendered.contains("EXCEPTION"), "Should show EXCEPTION
header");
+ assertTrue(rendered.contains("MESSAGE"), "Should show MESSAGE header");
+ }
+
+ @Test
+ void renderErrorShowsExchangeId() {
+ addError("ID-myhost-1234", "route1", "to1", "IOException", "timeout",
false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 160, 30);
+
+ assertTrue(rendered.contains("ID-myhost-1234"), "Should render the
exchange ID");
+ }
+
+ @Test
+ void renderErrorShowsShortExceptionName() {
+ addError("ID-001", "route1", "to1", "java.io.IOException", "Connection
refused", false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 160, 30);
+
+ assertTrue(rendered.contains("IOException"), "Should show short
exception type name");
+ }
+
+ @Test
+ void renderErrorRouteIdInCyan() {
+ addError("ID-001", "my-route", "to1", "Exception", "fail", false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+
+ Rect area = new Rect(0, 0, 160, 30);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ boolean foundCyan = findCellWithColor(buffer, "m", Color.CYAN);
+ assertTrue(foundCyan, "Route ID should use CYAN color");
+ }
+
+ @Test
+ void renderHandledTrueUsesGreenColor() {
+ addError("ID-001", "route1", "to1", "Exception", "handled", true);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+
+ Rect area = new Rect(0, 0, 160, 30);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ boolean foundGreen = findCellWithColor(buffer, "t", Color.GREEN);
+ assertTrue(foundGreen, "handled=true should be rendered in GREEN");
+ }
+
+ @Test
+ void renderHandledFalseUsesRedColor() {
+ addError("ID-001", "route1", "to1", "Exception", "unhandled", false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+
+ Rect area = new Rect(0, 0, 160, 30);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ boolean foundRed = findCellWithColor(buffer, "f", Color.LIGHT_RED);
+ assertTrue(foundRed, "handled=false should be rendered in LIGHT_RED");
+ }
+
+ @Test
+ void renderMultipleErrorsAllAppear() {
+ addError("ID-AAA", "route-a", "to1", "IOException", "fail1", false);
+ addError("ID-BBB", "route-b", "to2", "NullPointerException", "fail2",
true);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 160, 30);
+
+ assertTrue(rendered.contains("ID-AAA"), "Should render first error");
+ assertTrue(rendered.contains("ID-BBB"), "Should render second error");
+ assertTrue(rendered.contains("IOException"), "Should render first
exception type");
+ assertTrue(rendered.contains("NullPointerException"), "Should render
second exception type");
+ }
+
+ @Test
+ void renderEmptyErrorsShowsPlaceholder() {
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 160, 20);
+
+ assertTrue(rendered.contains("No errors captured"),
+ "Should show placeholder when no errors exist");
+ }
+
+ @Test
+ void renderShowsErrorCount() {
+ addError("ID-001", "r1", "n1", "Ex", "m1", false);
+ addError("ID-002", "r2", "n2", "Ex", "m2", false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 160, 30);
+
+ assertTrue(rendered.contains("Errors (2)"), "Title should show error
count");
+ }
+
+ @Test
+ void renderNoSelectionShowsPrompt() {
+ ctx.selectedPid = null;
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 120, 15);
+
+ assertTrue(rendered.contains("No integration selected") ||
rendered.contains("Select an integration"),
+ "Should show selection prompt when no integration selected");
+ }
+
+ @Test
+ void renderShowsSortColumn() {
+ addError("ID-001", "r1", "n1", "Ex", "msg", false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 160, 30);
+
+ assertTrue(rendered.contains("sort:id"), "Title should show current
sort column");
+ }
+
+ @Test
+ void sortCycleChangesSortIndicator() {
+ addError("ID-001", "r1", "n1", "Ex", "msg", false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+
+ // Press 's' to cycle sort
+ tab.handleKeyEvent(KeyEvent.ofChar('s', KeyModifiers.NONE));
+
+ String rendered = renderToString(tab, 160, 30);
+ assertTrue(rendered.contains("sort:age"), "Sort should cycle to
'age'");
+ }
+
+ @Test
+ void handledFilterCycleFiltersErrors() {
+ addError("ID-001", "r1", "n1", "Ex", "unhandled", false);
+ addError("ID-002", "r2", "n2", "Ex", "handled", true);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+
+ // Default shows all
+ String all = renderToString(tab, 160, 30);
+ assertTrue(all.contains("ID-001"), "All filter should show unhandled
errors");
+ assertTrue(all.contains("ID-002"), "All filter should show handled
errors");
+
+ // Press 'f' to filter to handled=true
+ tab.handleKeyEvent(KeyEvent.ofChar('f', KeyModifiers.NONE));
+ String handledOnly = renderToString(tab, 160, 30);
+ assertTrue(handledOnly.contains("ID-002"), "handled filter should show
handled error");
+ assertFalse(handledOnly.contains("ID-001"), "handled filter should
hide unhandled error");
+ }
+
+ @Test
+ void renderFooterHintsContainExpectedKeys() {
+ ErrorsTab tab = new ErrorsTab(ctx);
+ List<Span> footerSpans = new ArrayList<>();
+ tab.renderFooter(footerSpans);
+
+ String footer = footerSpans.stream()
+ .map(Span::content)
+ .reduce("", String::concat);
+
+ assertTrue(footer.contains("Esc"), "Footer should contain Esc hint");
+ assertTrue(footer.contains("sort"), "Footer should contain sort hint");
+ assertTrue(footer.contains("handled"), "Footer should contain handled
filter hint");
+ assertTrue(footer.contains("wrap"), "Footer should contain wrap hint");
+ }
+
+ @Test
+ void renderErrorMessageContent() {
+ addError("ID-001", "route1", "to1", "Exception", "Connection refused
to host:8080", false);
+
+ ErrorsTab tab = new ErrorsTab(ctx);
+ String rendered = renderToString(tab, 160, 30);
+
+ assertTrue(rendered.contains("Connection refused"), "Should render the
error message");
+ }
+
+ // ---- Helper methods ----
+
+ private void addError(
+ String exchangeId, String routeId, String nodeId,
+ String exceptionType, String message, boolean handled) {
+ ErrorInfo ei = new ErrorInfo();
+ ei.exchangeId = exchangeId;
+ ei.routeId = routeId;
+ ei.nodeId = nodeId;
+ ei.exceptionType = exceptionType;
+ ei.exceptionMessage = message;
+ ei.handled = handled;
+ ei.timestamp = System.currentTimeMillis();
+ info.errors.add(ei);
+ }
+
+ private static String renderToString(MonitorTab tab, int width, int
height) {
+ Rect area = new Rect(0, 0, width, height);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+ return HealthTabRenderTest.bufferToString(buffer);
+ }
+
+ private static boolean findCellWithColor(Buffer buffer, String symbol,
Color expectedFg) {
+ for (int y = 0; y < buffer.height(); y++) {
+ for (int x = 0; x < buffer.width(); x++) {
+ var cell = buffer.get(x, y);
+ if (symbol.equals(cell.symbol())) {
+ var fg = cell.style().fg().orElse(null);
+ if (expectedFg.equals(fg)) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
new file mode 100644
index 000000000000..d75bcb1255f2
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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;
+
+import java.util.List;
+
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FuzzyFilterTest {
+
+ // ---- fuzzyMatch tests ----
+
+ @Test
+ void fuzzyMatchExactMatch() {
+ int[] positions = FuzzyFilter.fuzzyMatch("hello", "hello");
+ assertNotNull(positions);
+ assertArrayEquals(new int[] { 0, 1, 2, 3, 4 }, positions);
+ }
+
+ @Test
+ void fuzzyMatchScatteredMatch() {
+ // Pattern "hlo" should match positions of 'h', 'l', 'o' in "hello"
+ int[] positions = FuzzyFilter.fuzzyMatch("hello", "hlo");
+ assertNotNull(positions);
+ assertArrayEquals(new int[] { 0, 2, 4 }, positions);
+ }
+
+ @Test
+ void fuzzyMatchNoMatch() {
+ int[] positions = FuzzyFilter.fuzzyMatch("hello", "xyz");
+ assertNull(positions);
+ }
+
+ @Test
+ void fuzzyMatchEmptyPattern() {
+ int[] positions = FuzzyFilter.fuzzyMatch("hello", "");
+ assertNotNull(positions);
+ assertEquals(0, positions.length);
+ }
+
+ @Test
+ void fuzzyMatchNullPattern() {
+ int[] positions = FuzzyFilter.fuzzyMatch("hello", null);
+ assertNotNull(positions);
+ assertEquals(0, positions.length);
+ }
+
+ @Test
+ void fuzzyMatchCaseInsensitive() {
+ int[] positions = FuzzyFilter.fuzzyMatch("Hello World", "hw");
+ assertNotNull(positions);
+ assertEquals(2, positions.length);
+ assertEquals(0, positions[0]); // 'H' matches 'h'
+ assertEquals(6, positions[1]); // 'W' matches 'w'
+ }
+
+ // ---- highlightLine tests ----
+
+ @Test
+ void highlightLineMatchAtStart() {
+ Style normal = Style.EMPTY;
+ Style match = Style.EMPTY.fg(Color.RED);
+
+ Line line = FuzzyFilter.highlightLine("ABC", new int[] { 0 }, normal,
match);
+ List<Span> spans = line.spans();
+ assertEquals(2, spans.size());
+ assertEquals("A", spans.get(0).content());
+ assertEquals(match, spans.get(0).style());
+ assertEquals("BC", spans.get(1).content());
+ assertEquals(normal, spans.get(1).style());
+ }
+
+ @Test
+ void highlightLineMatchAtEnd() {
+ Style normal = Style.EMPTY;
+ Style match = Style.EMPTY.fg(Color.RED);
+
+ Line line = FuzzyFilter.highlightLine("ABC", new int[] { 2 }, normal,
match);
+ List<Span> spans = line.spans();
+ assertEquals(2, spans.size());
+ assertEquals("AB", spans.get(0).content());
+ assertEquals("C", spans.get(1).content());
+ assertEquals(match, spans.get(1).style());
+ }
+
+ @Test
+ void highlightLineConsecutiveMatches() {
+ Style normal = Style.EMPTY;
+ Style match = Style.EMPTY.fg(Color.RED);
+
+ Line line = FuzzyFilter.highlightLine("ABC", new int[] { 0, 1, 2 },
normal, match);
+ List<Span> spans = line.spans();
+ // Each matched char is its own span
+ assertEquals(3, spans.size());
+ for (Span span : spans) {
+ assertEquals(match, span.style());
+ }
+ }
+
+ @Test
+ void highlightLineNullPositions() {
+ Style normal = Style.EMPTY;
+ Style match = Style.EMPTY.fg(Color.RED);
+
+ Line line = FuzzyFilter.highlightLine("hello", null, normal, match);
+ List<Span> spans = line.spans();
+ assertEquals(1, spans.size());
+ assertEquals("hello", spans.get(0).content());
+ assertEquals(normal, spans.get(0).style());
+ }
+
+ @Test
+ void highlightLineEmptyPositions() {
+ Style normal = Style.EMPTY;
+ Style match = Style.EMPTY.fg(Color.RED);
+
+ Line line = FuzzyFilter.highlightLine("hello", new int[0], normal,
match);
+ List<Span> spans = line.spans();
+ assertEquals(1, spans.size());
+ assertEquals("hello", spans.get(0).content());
+ }
+
+ // ---- appendChar / deleteChar / clearFilter state management ----
+
+ @Test
+ void appendCharDeleteCharClearFilterRoundTrip() {
+ FuzzyFilter filter = new FuzzyFilter();
+ assertFalse(filter.hasFilter());
+ assertEquals("", filter.filter());
+
+ filter.appendChar('A');
+ filter.appendChar('B');
+ assertTrue(filter.hasFilter());
+ assertEquals("ab", filter.filter()); // appendChar lowercases
+
+ filter.deleteChar();
+ assertEquals("a", filter.filter());
+
+ filter.clearFilter();
+ assertFalse(filter.hasFilter());
+ assertEquals("", filter.filter());
+ }
+
+ @Test
+ void deleteCharOnEmptyFilterIsNoop() {
+ FuzzyFilter filter = new FuzzyFilter();
+ filter.deleteChar(); // should not throw
+ assertEquals("", filter.filter());
+ }
+
+ @Test
+ void matchDelegatesToFuzzyMatch() {
+ FuzzyFilter filter = new FuzzyFilter();
+ filter.appendChar('h');
+ filter.appendChar('l');
+ int[] positions = filter.match("hello");
+ assertNotNull(positions);
+ assertArrayEquals(new int[] { 0, 2 }, positions);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTabRenderTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTabRenderTest.java
new file mode 100644
index 000000000000..00c007fce4f9
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HealthTabRenderTest.java
@@ -0,0 +1,330 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Higher-level rendering tests for {@link HealthTab}. These tests render the
tab into a virtual terminal buffer via
+ * {@link Frame#forTesting(Buffer)} and inspect the rendered cell content.
+ */
+class HealthTabRenderTest {
+
+ private MonitorContext ctx;
+ private IntegrationInfo info;
+
+ @BeforeEach
+ void setUp() {
+ info = new IntegrationInfo();
+ info.pid = "1234";
+ info.name = "test-app";
+
+ AtomicReference<List<IntegrationInfo>> data = new
AtomicReference<>(List.of(info));
+ AtomicReference<List<InfraInfo>> infraData = new
AtomicReference<>(List.of());
+ ctx = new MonitorContext(data, infraData);
+ ctx.selectedPid = "1234";
+ }
+
+ @Test
+ void renderHealthChecksShowsUpStatusInGreen() {
+ HealthCheckInfo hc = new HealthCheckInfo();
+ hc.group = "camel";
+ hc.name = "context";
+ hc.state = "UP";
+ hc.readiness = true;
+ hc.liveness = true;
+ info.healthChecks.add(hc);
+
+ HealthTab tab = new HealthTab(ctx);
+ String rendered = renderToString(tab, 100, 20);
+
+ assertTrue(rendered.contains("context"), "Should render health check
name 'context'");
+ assertTrue(rendered.contains("UP"), "Should render UP status");
+ assertTrue(rendered.contains("camel"), "Should render group name");
+ }
+
+ @Test
+ void renderHealthChecksShowsDownStatusWithMessage() {
+ HealthCheckInfo hc = new HealthCheckInfo();
+ hc.group = "routes";
+ hc.name = "timer-route";
+ hc.state = "DOWN";
+ hc.readiness = true;
+ hc.message = "Route stopped";
+ info.healthChecks.add(hc);
+
+ HealthTab tab = new HealthTab(ctx);
+ String rendered = renderToString(tab, 120, 20);
+
+ assertTrue(rendered.contains("timer-route"), "Should render health
check name");
+ assertTrue(rendered.contains("DOWN"), "Should render DOWN status");
+ assertTrue(rendered.contains("Route stopped"), "Should render failure
message");
+ }
+
+ @Test
+ void renderDownStatusCellUsesRedColor() {
+ HealthCheckInfo hc = new HealthCheckInfo();
+ hc.group = "routes";
+ hc.name = "failing-route";
+ hc.state = "DOWN";
+ hc.readiness = true;
+ info.healthChecks.add(hc);
+
+ HealthTab tab = new HealthTab(ctx);
+
+ Rect area = new Rect(0, 0, 100, 20);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ // Find the DOWN text cell and verify it uses red foreground
+ boolean foundRedDown = false;
+ for (int y = 0; y < buffer.height(); y++) {
+ for (int x = 0; x < buffer.width(); x++) {
+ var cell = buffer.get(x, y);
+ if ("✖".equals(cell.symbol()) || "D".equals(cell.symbol())) {
+ var fg = cell.style().fg().orElse(null);
+ if (Color.LIGHT_RED.equals(fg)) {
+ foundRedDown = true;
+ break;
+ }
+ }
+ }
+ if (foundRedDown) {
+ break;
+ }
+ }
+ assertTrue(foundRedDown, "DOWN status should be rendered in
LIGHT_RED");
+ }
+
+ @Test
+ void renderUpStatusCellUsesGreenColor() {
+ HealthCheckInfo hc = new HealthCheckInfo();
+ hc.group = "camel";
+ hc.name = "ctx";
+ hc.state = "UP";
+ hc.readiness = true;
+ hc.liveness = true;
+ info.healthChecks.add(hc);
+
+ HealthTab tab = new HealthTab(ctx);
+
+ Rect area = new Rect(0, 0, 100, 20);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ // Find a green-colored cell in the UP row
+ boolean foundGreenUp = false;
+ for (int y = 0; y < buffer.height(); y++) {
+ for (int x = 0; x < buffer.width(); x++) {
+ var cell = buffer.get(x, y);
+ if ("✔".equals(cell.symbol())) {
+ var fg = cell.style().fg().orElse(null);
+ if (Color.GREEN.equals(fg)) {
+ foundGreenUp = true;
+ break;
+ }
+ }
+ }
+ if (foundGreenUp) {
+ break;
+ }
+ }
+ assertTrue(foundGreenUp, "UP status should be rendered in GREEN");
+ }
+
+ @Test
+ void renderEmptyHealthChecksShowsPlaceholder() {
+ // No health checks added
+ HealthTab tab = new HealthTab(ctx);
+ // Use wider terminal to avoid column truncation of the placeholder
text
+ String rendered = renderToString(tab, 140, 10);
+
+ assertTrue(rendered.contains("No health checks"),
+ "Should show placeholder when no health checks exist");
+ }
+
+ @Test
+ void renderMultipleHealthChecksAllAppear() {
+ addHealthCheck("camel", "context", "UP");
+ addHealthCheck("camel", "route-controller", "UP");
+ addHealthCheck("routes", "timer-route", "DOWN");
+
+ HealthTab tab = new HealthTab(ctx);
+ String rendered = renderToString(tab, 120, 20);
+
+ assertTrue(rendered.contains("context"), "Should render 'context'
check");
+ assertTrue(rendered.contains("route-controller"), "Should render
'route-controller' check");
+ assertTrue(rendered.contains("timer-route"), "Should render
'timer-route' check");
+ }
+
+ @Test
+ void renderShowsHeaderColumns() {
+ addHealthCheck("camel", "context", "UP");
+
+ HealthTab tab = new HealthTab(ctx);
+ String rendered = renderToString(tab, 120, 20);
+
+ assertTrue(rendered.contains("GROUP"), "Should show GROUP header");
+ assertTrue(rendered.contains("NAME"), "Should show NAME header");
+ assertTrue(rendered.contains("STATUS"), "Should show STATUS header");
+ assertTrue(rendered.contains("KIND"), "Should show KIND header");
+ assertTrue(rendered.contains("MESSAGE"), "Should show MESSAGE header");
+ }
+
+ @Test
+ void renderShowsBorderWithTitle() {
+ addHealthCheck("camel", "context", "UP");
+
+ HealthTab tab = new HealthTab(ctx);
+ String rendered = renderToString(tab, 120, 20);
+
+ assertTrue(rendered.contains("Health"), "Should show 'Health' in the
block title");
+ }
+
+ @Test
+ void renderNoSelectionShowsPrompt() {
+ ctx.selectedPid = null;
+
+ HealthTab tab = new HealthTab(ctx);
+ String rendered = renderToString(tab, 100, 10);
+
+ assertTrue(rendered.contains("No integration selected") ||
rendered.contains("Select an integration"),
+ "Should show selection prompt when no integration selected");
+ }
+
+ @Test
+ void renderReadinessLivenessKindColumn() {
+ HealthCheckInfo hc = new HealthCheckInfo();
+ hc.group = "camel";
+ hc.name = "context";
+ hc.state = "UP";
+ hc.readiness = true;
+ hc.liveness = true;
+ info.healthChecks.add(hc);
+
+ HealthTab tab = new HealthTab(ctx);
+ String rendered = renderToString(tab, 120, 20);
+
+ assertTrue(rendered.contains("R/L"), "Should show R/L for
readiness+liveness");
+ }
+
+ @Test
+ void toggleDownOnlyFilterChangesTitle() {
+ addHealthCheck("camel", "context", "UP");
+ addHealthCheck("routes", "failing", "DOWN");
+
+ HealthTab tab = new HealthTab(ctx);
+
+ // Press 'd' to toggle DOWN-only filter
+ tab.handleKeyEvent(KeyEvent.ofChar('d', KeyModifiers.NONE));
+ assertTrue(tab.isShowOnlyDown());
+
+ String rendered = renderToString(tab, 120, 20);
+ assertTrue(rendered.contains("DOWN only"), "Title should indicate
DOWN-only filter");
+
+ // "context" (UP) should be filtered out
+ assertFalse(rendered.contains("context"), "UP check should be filtered
out");
+ assertTrue(rendered.contains("failing"), "DOWN check should still
appear");
+ }
+
+ @Test
+ void renderFooterHintsContainExpectedKeys() {
+ HealthTab tab = new HealthTab(ctx);
+ List<Span> footerSpans = new ArrayList<>();
+ tab.renderFooter(footerSpans);
+
+ String footer = footerSpans.stream()
+ .map(Span::content)
+ .reduce("", String::concat);
+
+ assertTrue(footer.contains("Esc"), "Footer should contain Esc hint");
+ assertTrue(footer.contains("sort"), "Footer should contain sort hint");
+ assertTrue(footer.contains("DOWN"), "Footer should contain DOWN toggle
hint");
+ }
+
+ @Test
+ void sortCycleChangesHeaderAppearance() {
+ addHealthCheck("camel", "context", "UP");
+
+ HealthTab tab = new HealthTab(ctx);
+
+ // Default sort is "name" — header should have a sort indicator on NAME
+ String rendered1 = renderToString(tab, 120, 20);
+ assertTrue(rendered1.contains("NAME▼") || rendered1.contains("NAME▲"),
+ "Default sort should show indicator on NAME column");
+
+ // Press 's' to cycle to next sort column
+ tab.handleKeyEvent(KeyEvent.ofChar('s', KeyModifiers.NONE));
+ String rendered2 = renderToString(tab, 120, 20);
+ assertTrue(rendered2.contains("STATUS▼") ||
rendered2.contains("STATUS▲"),
+ "After one sort cycle, indicator should move to STATUS");
+ }
+
+ // ---- Helper methods ----
+
+ private void addHealthCheck(String group, String name, String state) {
+ HealthCheckInfo hc = new HealthCheckInfo();
+ hc.group = group;
+ hc.name = name;
+ hc.state = state;
+ hc.readiness = true;
+ info.healthChecks.add(hc);
+ }
+
+ /**
+ * Renders a tab into a virtual buffer and extracts the full text content.
+ */
+ private static String renderToString(MonitorTab tab, int width, int
height) {
+ Rect area = new Rect(0, 0, width, height);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+ return bufferToString(buffer);
+ }
+
+ /**
+ * Extracts all text from a buffer row by row.
+ */
+ static String bufferToString(Buffer buffer) {
+ StringBuilder sb = new StringBuilder();
+ for (int y = 0; y < buffer.height(); y++) {
+ for (int x = 0; x < buffer.width(); x++) {
+ String sym = buffer.get(x, y).symbol();
+ sb.append(sym.isEmpty() ? " " : sym);
+ }
+ sb.append('\n');
+ }
+ return sb.toString();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvgTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvgTest.java
new file mode 100644
index 000000000000..45781a7449b9
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvgTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LoadAvgTest {
+
+ @Test
+ void noDataReturnsDash() {
+ LoadAvg avg = new LoadAvg();
+ assertEquals("-", avg.format("%.2f/%.2f/%.2f"));
+ }
+
+ @Test
+ void firstUpdateInitializes() {
+ LoadAvg avg = new LoadAvg();
+ avg.update(10.0);
+ String result = avg.format("%.2f/%.2f/%.2f");
+ assertNotEquals("-", result);
+ // After first update, all three loads should equal the initial value
+ assertEquals("10.00/10.00/10.00", result);
+ }
+
+ @Test
+ void multipleUpdatesConvergeViaEwma() {
+ LoadAvg avg = new LoadAvg();
+ avg.update(100.0);
+ // After initial value, update with 0 multiple times
+ // The EWMA should decay toward 0, with load1 decaying fastest
+ for (int i = 0; i < 100; i++) {
+ avg.update(0.0);
+ }
+ String result = avg.format("%.2f/%.2f/%.2f");
+ String[] parts = result.split("/");
+ double load1 = Double.parseDouble(parts[0]);
+ double load5 = Double.parseDouble(parts[1]);
+ double load15 = Double.parseDouble(parts[2]);
+
+ // load1 should have decayed more than load5, which decayed more than
load15
+ assertTrue(load1 < load5, "load1 should decay faster than load5");
+ assertTrue(load5 < load15, "load5 should decay faster than load15");
+ // All should be less than the original 100
+ assertTrue(load15 < 100.0);
+ }
+
+ @Test
+ void steadyStateConverges() {
+ LoadAvg avg = new LoadAvg();
+ // Feed a constant value; all loads should converge to that value
+ for (int i = 0; i < 1000; i++) {
+ avg.update(50.0);
+ }
+ String result = avg.format("%.1f/%.1f/%.1f");
+ assertEquals("50.0/50.0/50.0", result);
+ }
+
+ @Test
+ void formatStringIsUsed() {
+ LoadAvg avg = new LoadAvg();
+ avg.update(1.23456);
+ // Check that the format string controls the output format
+ String result = avg.format("%.1f %.1f %.1f");
+ assertEquals("1.2 1.2 1.2", result);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextRenderTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextRenderTest.java
new file mode 100644
index 000000000000..875b1fd43e93
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextRenderTest.java
@@ -0,0 +1,218 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for {@link MonitorContext} rendering helpers that write to the
terminal buffer. These exercise the actual
+ * Frame/Buffer rendering pipeline to verify that text and styles appear
correctly.
+ */
+class MonitorContextRenderTest {
+
+ @Test
+ void renderNoSelectionShowsPromptText() {
+ Rect area = new Rect(0, 0, 80, 10);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+
+ MonitorContext.renderNoSelection(frame, area);
+
+ String rendered = HealthTabRenderTest.bufferToString(buffer);
+ assertTrue(rendered.contains("No integration selected"),
+ "Should render 'No integration selected' in the block title");
+ assertTrue(rendered.contains("Select an integration"),
+ "Should render the selection prompt text");
+ }
+
+ @Test
+ void renderNoSelectionRendersContentInBlock() {
+ Rect area = new Rect(0, 0, 80, 10);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+
+ MonitorContext.renderNoSelection(frame, area);
+
+ String rendered = HealthTabRenderTest.bufferToString(buffer);
+ // The block title should appear in the buffer
+ assertTrue(rendered.contains("No integration selected"),
+ "Should render the block title");
+ // The prompt text should appear somewhere in the buffer
+ assertTrue(rendered.contains("Select an integration"),
+ "Should render the prompt text");
+ }
+
+ @Test
+ void renderNoSelectionPromptIsDimmed() {
+ Rect area = new Rect(0, 0, 80, 10);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+
+ MonitorContext.renderNoSelection(frame, area);
+
+ // Check that the "Select" text cell has DIM modifier
+ boolean foundDim = false;
+ for (int y = 0; y < buffer.height(); y++) {
+ for (int x = 0; x < buffer.width(); x++) {
+ var cell = buffer.get(x, y);
+ if ("S".equals(cell.symbol()) &&
cell.style().effectiveModifiers()
+ .contains(dev.tamboui.style.Modifier.DIM)) {
+ foundDim = true;
+ break;
+ }
+ }
+ if (foundDim) {
+ break;
+ }
+ }
+ assertTrue(foundDim, "Prompt text should use DIM modifier");
+ }
+
+ @Test
+ void hintAddsKeyAndLabel() {
+ List<Span> spans = new ArrayList<>();
+ MonitorContext.hint(spans, "Esc", "back");
+
+ assertEquals(2, spans.size(), "hint should add exactly 2 spans");
+ assertTrue(spans.get(0).content().contains("Esc"), "First span should
contain the key");
+ assertTrue(spans.get(1).content().contains("back"), "Second span
should contain the label");
+ }
+
+ @Test
+ void hintKeyUsesYellowBoldStyle() {
+ List<Span> spans = new ArrayList<>();
+ MonitorContext.hint(spans, "s", "sort");
+
+ Span keySpan = spans.get(0);
+ assertTrue(keySpan.style().fg().isPresent(), "Key span should have a
foreground color");
+ assertEquals(Color.YELLOW, keySpan.style().fg().get(), "Key span
should be YELLOW");
+
assertTrue(keySpan.style().effectiveModifiers().contains(dev.tamboui.style.Modifier.BOLD),
+ "Key span should be BOLD");
+ }
+
+ @Test
+ void hintLabelHasTrailingSpaces() {
+ List<Span> spans = new ArrayList<>();
+ MonitorContext.hint(spans, "x", "action");
+
+ Span labelSpan = spans.get(1);
+ assertTrue(labelSpan.content().endsWith(" "),
+ "hint label should end with trailing spaces for separation");
+ }
+
+ @Test
+ void hintLastDoesNotHaveTrailingSpaces() {
+ List<Span> spans = new ArrayList<>();
+ MonitorContext.hintLast(spans, "q", "quit");
+
+ Span labelSpan = spans.get(1);
+ assertEquals(" quit", labelSpan.content(),
+ "hintLast label should not have trailing spaces");
+ }
+
+ @Test
+ void rightCellRendersRightAligned() {
+ var cell = MonitorContext.rightCell("42", 8);
+ // rightCell uses String.format("%8s", "42") → " 42"
+ String content = extractCellContent(cell);
+ assertTrue(content.endsWith("42"), "Content should end with the
value");
+ assertEquals(8, content.length(), "Content should be padded to width
8");
+ }
+
+ @Test
+ void rightCellWithStyleAppliesStyle() {
+ var cell = MonitorContext.rightCell("5", 6,
dev.tamboui.style.Style.EMPTY.fg(Color.LIGHT_RED));
+ // The cell should have styled content
+ String content = extractCellContent(cell);
+ assertTrue(content.contains("5"), "Should contain the value");
+ }
+
+ @Test
+ void centerCellCentersText() {
+ var cell = MonitorContext.centerCell("x", 6);
+ String content = extractCellContent(cell);
+ // "x" centered in width 6: " x" (leftPad = (6-1)/2 = 2)
+ assertTrue(content.startsWith(" "), "Content should have leading
padding");
+ assertTrue(content.contains("x"), "Content should contain the value");
+ }
+
+ @Test
+ void sortLabelShowsIndicatorForActiveColumn() {
+ String label = MonitorContext.sortLabel("NAME", "name", "name", false);
+ assertEquals("NAME▼", label, "Active sort column should have
descending indicator");
+
+ String reversed = MonitorContext.sortLabel("NAME", "name", "name",
true);
+ assertEquals("NAME▲", reversed, "Reversed sort should have ascending
indicator");
+ }
+
+ @Test
+ void sortLabelNoIndicatorForInactiveColumn() {
+ String label = MonitorContext.sortLabel("NAME", "name", "status",
false);
+ assertEquals("NAME", label, "Inactive column should have no
indicator");
+ }
+
+ @Test
+ void sortStyleActiveColumnIsYellowBold() {
+ var style = MonitorContext.sortStyle("name", "name");
+ assertEquals(Color.YELLOW, style.fg().orElse(null), "Active sort
column should be YELLOW");
+
assertTrue(style.effectiveModifiers().contains(dev.tamboui.style.Modifier.BOLD),
+ "Active sort column should be BOLD");
+ }
+
+ @Test
+ void sortStyleInactiveColumnIsBoldOnly() {
+ var style = MonitorContext.sortStyle("name", "status");
+ assertTrue(style.fg().isEmpty() ||
!Color.YELLOW.equals(style.fg().get()),
+ "Inactive column should not be YELLOW");
+
assertTrue(style.effectiveModifiers().contains(dev.tamboui.style.Modifier.BOLD),
+ "Inactive column should still be BOLD");
+ }
+
+ private static String extractCellContent(dev.tamboui.widgets.table.Cell
cell) {
+ // Render the cell into a small buffer to extract its text
+ Rect area = new Rect(0, 0, 20, 1);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ // Render via a simple single-row table
+ var table = dev.tamboui.widgets.table.Table.builder()
+ .rows(List.of(dev.tamboui.widgets.table.Row.from(cell)))
+ .widths(dev.tamboui.layout.Constraint.fill())
+ .build();
+ frame.renderStatefulWidget(table, area, new
dev.tamboui.widgets.table.TableState());
+ return rowText(buffer, 0);
+ }
+
+ private static String rowText(Buffer buffer, int row) {
+ StringBuilder sb = new StringBuilder();
+ for (int col = 0; col < buffer.width(); col++) {
+ String sym = buffer.get(col, row).symbol();
+ sb.append(sym.isEmpty() ? " " : sym);
+ }
+ return sb.toString().stripTrailing();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextTest.java
new file mode 100644
index 000000000000..58cbf0b67b4b
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextTest.java
@@ -0,0 +1,235 @@
+/*
+ * 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;
+
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Modifier;
+import dev.tamboui.style.Style;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MonitorContextTest {
+
+ // ---- formatSinceLast tests ----
+
+ @Test
+ void formatSinceLastAllThreePresent() {
+ String result = MonitorContext.formatSinceLast("1s", "2s", "3s");
+ assertEquals("1s/2s/3s", result);
+ }
+
+ @Test
+ void formatSinceLastOnlyStarted() {
+ String result = MonitorContext.formatSinceLast("5s", null, null);
+ assertEquals("5s", result);
+ }
+
+ @Test
+ void formatSinceLastOnlyCompleted() {
+ String result = MonitorContext.formatSinceLast(null, "10s", null);
+ assertEquals("10s", result);
+ }
+
+ @Test
+ void formatSinceLastOnlyFailed() {
+ String result = MonitorContext.formatSinceLast(null, null, "7s");
+ assertEquals("7s", result);
+ }
+
+ @Test
+ void formatSinceLastNonePresent() {
+ String result = MonitorContext.formatSinceLast(null, null, null);
+ assertEquals("", result);
+ }
+
+ @Test
+ void formatSinceLastStartedAndFailed() {
+ String result = MonitorContext.formatSinceLast("1s", null, "3s");
+ assertEquals("1s/3s", result);
+ }
+
+ // ---- formatLoad tests ----
+
+ @Test
+ void formatLoadNonZeroValues() {
+ String result = MonitorContext.formatLoad("1.23", "4.56", "7.89");
+ assertEquals("1.23/4.56/7.89", result);
+ }
+
+ @Test
+ void formatLoadZeroCollapse() {
+ String result = MonitorContext.formatLoad("0.00", "0.00", "0.00");
+ assertEquals("0/0/0", result);
+ }
+
+ @Test
+ void formatLoadNullHandling() {
+ String result = MonitorContext.formatLoad(null, null, null);
+ assertEquals("0/0/0", result);
+ }
+
+ @Test
+ void formatLoadMixedZeroAndNonZero() {
+ String result = MonitorContext.formatLoad("1.50", "0.00", "0.75");
+ assertEquals("1.50/0/0.75", result);
+ }
+
+ // ---- formatMemory tests ----
+
+ @Test
+ void formatMemoryBothPositive() {
+ // 512MB used, 1GB max
+ String result = MonitorContext.formatMemory(536870912L, 1073741824L);
+ assertEquals("512M/1024M", result);
+ }
+
+ @Test
+ void formatMemoryMaxZeroShowsUsedOnly() {
+ // 1048576 bytes = 1M (1024 * 1024)
+ String result = MonitorContext.formatMemory(1048576L, 0L);
+ assertEquals("1M", result);
+ }
+
+ @Test
+ void formatMemoryUsedZeroReturnsEmpty() {
+ String result = MonitorContext.formatMemory(0L, 1048576L);
+ assertEquals("", result);
+ }
+
+ // ---- formatBytes tests ----
+
+ @Test
+ void formatBytesRange() {
+ assertEquals("500B", MonitorContext.formatBytes(500));
+ }
+
+ @Test
+ void formatBytesKilobytes() {
+ assertEquals("1K", MonitorContext.formatBytes(1024));
+ assertEquals("10K", MonitorContext.formatBytes(10240));
+ }
+
+ @Test
+ void formatBytesMegabytes() {
+ assertEquals("1M", MonitorContext.formatBytes(1048576));
+ assertEquals("100M", MonitorContext.formatBytes(104857600));
+ }
+
+ // ---- buildBar tests ----
+
+ @Test
+ void buildBarFull() {
+ String bar = MonitorContext.buildBar(100, 100, 10);
+ assertEquals(10, bar.length());
+ }
+
+ @Test
+ void buildBarPartial() {
+ String bar = MonitorContext.buildBar(50, 100, 10);
+ assertEquals(5, bar.length());
+ }
+
+ @Test
+ void buildBarZeroValue() {
+ String bar = MonitorContext.buildBar(0, 100, 10);
+ assertEquals("", bar);
+ }
+
+ @Test
+ void buildBarZeroMax() {
+ String bar = MonitorContext.buildBar(50, 0, 10);
+ assertEquals("", bar);
+ }
+
+ @Test
+ void buildBarSmallValueRoundsCorrectly() {
+ // 1/1000 * 10 rounds to 0, buildBar returns empty for zero-length
+ String bar = MonitorContext.buildBar(1, 1000, 10);
+ assertEquals("", bar);
+
+ // A value large enough to produce at least one block (>= 1/maxWidth
ratio)
+ String bar2 = MonitorContext.buildBar(100, 1000, 10);
+ assertTrue(bar2.length() >= 1);
+ }
+
+ // ---- topTimeStyle tests ----
+
+ @Test
+ void topTimeStyleOver1000ms() {
+ Style style = MonitorContext.topTimeStyle(1000);
+ assertTrue(style.effectiveModifiers().contains(Modifier.BOLD));
+ assertEquals(Color.LIGHT_RED, style.fg().orElse(null));
+ }
+
+ @Test
+ void topTimeStyleOver100ms() {
+ Style style = MonitorContext.topTimeStyle(500);
+ assertEquals(Color.YELLOW, style.fg().orElse(null));
+ }
+
+ @Test
+ void topTimeStyleUnder100ms() {
+ Style style = MonitorContext.topTimeStyle(50);
+ assertEquals(Style.EMPTY, style);
+ }
+
+ // ---- topDeltaStyle tests ----
+
+ @Test
+ void topDeltaStylePositive() {
+ Style style = MonitorContext.topDeltaStyle(10);
+ assertEquals(Color.LIGHT_RED, style.fg().orElse(null));
+ }
+
+ @Test
+ void topDeltaStyleNegative() {
+ Style style = MonitorContext.topDeltaStyle(-5);
+ assertEquals(Color.GREEN, style.fg().orElse(null));
+ }
+
+ @Test
+ void topDeltaStyleZero() {
+ Style style = MonitorContext.topDeltaStyle(0);
+ assertEquals(Style.EMPTY, style);
+ }
+
+ // ---- compareStr tests ----
+
+ @Test
+ void compareStrBothNull() {
+ assertEquals(0, MonitorContext.compareStr(null, null));
+ }
+
+ @Test
+ void compareStrFirstNull() {
+ assertEquals(1, MonitorContext.compareStr(null, "b"));
+ }
+
+ @Test
+ void compareStrSecondNull() {
+ assertEquals(-1, MonitorContext.compareStr("a", null));
+ }
+
+ @Test
+ void compareStrCaseInsensitive() {
+ assertEquals(0, MonitorContext.compareStr("ABC", "abc"));
+ assertTrue(MonitorContext.compareStr("abc", "xyz") < 0);
+ assertTrue(MonitorContext.compareStr("xyz", "abc") > 0);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTabRenderTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTabRenderTest.java
new file mode 100644
index 000000000000..a17a9d000b80
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTabRenderTest.java
@@ -0,0 +1,328 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Higher-level rendering tests for {@link RoutesTab}. Renders the routes
table into a virtual terminal buffer and
+ * inspects the rendered output (text content, colors, layout).
+ */
+class RoutesTabRenderTest {
+
+ private MonitorContext ctx;
+ private IntegrationInfo info;
+
+ @BeforeEach
+ void setUp() {
+ info = new IntegrationInfo();
+ info.pid = "5678";
+ info.name = "my-integration";
+
+ AtomicReference<List<IntegrationInfo>> data = new
AtomicReference<>(List.of(info));
+ AtomicReference<List<InfraInfo>> infraData = new
AtomicReference<>(List.of());
+ ctx = new MonitorContext(data, infraData);
+ ctx.selectedPid = "5678";
+ }
+
+ @Test
+ void renderShowsRouteTableHeaders() {
+ addRoute("route1", "timer://tick?period=1000", "Started");
+
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 140, 30);
+
+ assertTrue(rendered.contains("ROUTE"), "Should show ROUTE header");
+ assertTrue(rendered.contains("FROM"), "Should show FROM header");
+ assertTrue(rendered.contains("STATUS"), "Should show STATUS header");
+ assertTrue(rendered.contains("TOTAL"), "Should show TOTAL header");
+ assertTrue(rendered.contains("FAIL"), "Should show FAIL header");
+ }
+
+ @Test
+ void renderShowsRouteIdAndFrom() {
+ addRoute("timer-to-log", "timer://hello?period=2000", "Started");
+
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 140, 30);
+
+ assertTrue(rendered.contains("timer-to-log"), "Should render route
ID");
+ assertTrue(rendered.contains("timer://hello"), "Should render FROM
endpoint");
+ }
+
+ @Test
+ void renderStartedRouteUsesGreenColor() {
+ addRoute("my-route", "direct://start", "Started");
+
+ RoutesTab tab = new RoutesTab(ctx);
+
+ Rect area = new Rect(0, 0, 140, 30);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ boolean foundGreenStarted = findCellWithColorContaining(buffer, "S",
Color.GREEN);
+ assertTrue(foundGreenStarted, "Started status should be rendered in
GREEN");
+ }
+
+ @Test
+ void renderStoppedRouteUsesRedColor() {
+ addRoute("stopped-route", "seda://queue", "Stopped");
+
+ RoutesTab tab = new RoutesTab(ctx);
+
+ Rect area = new Rect(0, 0, 140, 30);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ boolean foundRedStopped = findCellWithColorContaining(buffer, "S",
Color.LIGHT_RED);
+ assertTrue(foundRedStopped, "Stopped status should be rendered in
LIGHT_RED");
+ }
+
+ @Test
+ void renderRouteIdUsesCyanColor() {
+ addRoute("my-cyan-route", "timer://tick", "Started");
+
+ RoutesTab tab = new RoutesTab(ctx);
+
+ Rect area = new Rect(0, 0, 140, 30);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ boolean foundCyanRouteId = findCellWithColorContaining(buffer, "m",
Color.CYAN);
+ assertTrue(foundCyanRouteId, "Route ID should be rendered in CYAN");
+ }
+
+ @Test
+ void renderMultipleRoutesAllAppear() {
+ addRoute("route-alpha", "timer://a", "Started");
+ addRoute("route-beta", "seda://b", "Started");
+ addRoute("route-gamma", "direct://c", "Stopped");
+
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 140, 30);
+
+ assertTrue(rendered.contains("route-alpha"), "Should render
route-alpha");
+ assertTrue(rendered.contains("route-beta"), "Should render
route-beta");
+ assertTrue(rendered.contains("route-gamma"), "Should render
route-gamma");
+ }
+
+ @Test
+ void renderFailedCountHighlightedInRed() {
+ RouteInfo route = addRoute("failing-route", "timer://fail", "Started");
+ route.total = 100;
+ route.failed = 5;
+
+ RoutesTab tab = new RoutesTab(ctx);
+
+ Rect area = new Rect(0, 0, 140, 30);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+
+ boolean foundRedFailed = findCellWithColorContaining(buffer, "5",
Color.LIGHT_RED);
+ assertTrue(foundRedFailed, "Failed count should be rendered in
LIGHT_RED");
+ }
+
+ @Test
+ void renderShowsProcessorsSection() {
+ RouteInfo route = addRoute("my-route", "timer://tick", "Started");
+ ProcessorInfo proc = new ProcessorInfo();
+ proc.id = "log1";
+ proc.processor = "log";
+ proc.level = 1;
+ proc.total = 50;
+ route.processors.add(proc);
+
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 140, 30);
+
+ assertTrue(rendered.contains("Processors"), "Should show Processors
section");
+ assertTrue(rendered.contains("log"), "Should show processor type");
+ }
+
+ @Test
+ void renderNoSelectionShowsPrompt() {
+ ctx.selectedPid = null;
+
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 100, 20);
+
+ assertTrue(rendered.contains("No integration selected") ||
rendered.contains("Select an integration"),
+ "Should show selection prompt when no integration selected");
+ }
+
+ @Test
+ void renderNoRoutesShowsEmpty() {
+ // info has no routes
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 140, 30);
+
+ assertTrue(rendered.contains("No routes") ||
rendered.contains("Routes"),
+ "Should show Routes section even with no routes");
+ }
+
+ @Test
+ void toggleTopModeChangesHeaders() {
+ addRoute("route1", "timer://tick", "Started");
+
+ RoutesTab tab = new RoutesTab(ctx);
+
+ // Render in normal mode first
+ String normalRender = renderToString(tab, 140, 30);
+ assertTrue(normalRender.contains("AGE"), "Normal mode should show AGE
header");
+
+ // Press 't' to toggle top mode
+ tab.handleKeyEvent(KeyEvent.ofChar('t', KeyModifiers.NONE));
+ assertTrue(tab.isTopMode(), "Should be in top mode after pressing
't'");
+
+ String topRender = renderToString(tab, 140, 30);
+ assertTrue(topRender.contains("MEAN"), "Top mode should show MEAN
header");
+ assertTrue(topRender.contains("MAX"), "Top mode should show MAX
header");
+ assertTrue(topRender.contains("LOAD"), "Top mode should show LOAD
header");
+ }
+
+ @Test
+ void renderTopModeShowsTimingData() {
+ RouteInfo route = addRoute("route1", "timer://tick", "Started");
+ route.total = 1000;
+ route.meanTime = 42;
+ route.maxTime = 500;
+ route.minTime = 1;
+ route.lastTime = 35;
+
+ RoutesTab tab = new RoutesTab(ctx);
+ tab.handleKeyEvent(KeyEvent.ofChar('t', KeyModifiers.NONE));
+
+ String rendered = renderToString(tab, 140, 30);
+ assertTrue(rendered.contains("42"), "Should show mean time");
+ assertTrue(rendered.contains("500"), "Should show max time");
+ }
+
+ @Test
+ void renderShowsTitleWithSortColumn() {
+ addRoute("route1", "timer://tick", "Started");
+
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 140, 30);
+
+ assertTrue(rendered.contains("sort:name"), "Title should show current
sort column");
+ }
+
+ @Test
+ void sortCycleChangesSortIndicator() {
+ addRoute("route1", "timer://tick", "Started");
+
+ RoutesTab tab = new RoutesTab(ctx);
+
+ // Press 's' to cycle sort
+ tab.handleKeyEvent(KeyEvent.ofChar('s', KeyModifiers.NONE));
+ String rendered = renderToString(tab, 140, 30);
+ assertTrue(rendered.contains("sort:from"), "Sort should cycle to
'from'");
+ }
+
+ @Test
+ void renderFooterHintsContainExpectedKeys() {
+ RoutesTab tab = new RoutesTab(ctx);
+ List<Span> footerSpans = new ArrayList<>();
+ tab.renderFooter(footerSpans);
+
+ String footer = footerSpans.stream()
+ .map(Span::content)
+ .reduce("", String::concat);
+
+ assertTrue(footer.contains("Esc"), "Footer should contain Esc hint");
+ assertTrue(footer.contains("sort"), "Footer should contain sort hint");
+ assertTrue(footer.contains("topology"), "Footer should contain
topology hint");
+ }
+
+ @Test
+ void renderRouteWithCoverageShowsCoverage() {
+ RouteInfo route = addRoute("route1", "timer://tick", "Started");
+ route.coverage = "5/5";
+
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 140, 30);
+
+ assertTrue(rendered.contains("5/5"), "Should show coverage value");
+ }
+
+ @Test
+ void renderRouteWithThroughputShowsValue() {
+ RouteInfo route = addRoute("route1", "timer://tick", "Started");
+ route.throughput = "1.50";
+
+ RoutesTab tab = new RoutesTab(ctx);
+ String rendered = renderToString(tab, 140, 30);
+
+ assertTrue(rendered.contains("1.50"), "Should show throughput value");
+ }
+
+ // ---- Helper methods ----
+
+ private RouteInfo addRoute(String routeId, String from, String state) {
+ RouteInfo route = new RouteInfo();
+ route.routeId = routeId;
+ route.from = from;
+ route.state = state;
+ route.uptime = "10s";
+ info.routes.add(route);
+ return route;
+ }
+
+ private static String renderToString(MonitorTab tab, int width, int
height) {
+ Rect area = new Rect(0, 0, width, height);
+ Buffer buffer = Buffer.empty(area);
+ Frame frame = Frame.forTesting(buffer);
+ tab.render(frame, area);
+ return HealthTabRenderTest.bufferToString(buffer);
+ }
+
+ /**
+ * Search the buffer for any cell containing the given symbol text
rendered with the specified foreground color.
+ */
+ private static boolean findCellWithColorContaining(Buffer buffer, String
symbol, Color expectedFg) {
+ for (int y = 0; y < buffer.height(); y++) {
+ for (int x = 0; x < buffer.width(); x++) {
+ var cell = buffer.get(x, y);
+ if (symbol.equals(cell.symbol())) {
+ var fg = cell.style().fg().orElse(null);
+ if (expectedFg.equals(fg)) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighterTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighterTest.java
new file mode 100644
index 000000000000..5459b7fec9de
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighterTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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;
+
+import java.util.Arrays;
+import java.util.List;
+
+import dev.tamboui.style.Style;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SearchHighlighterTest {
+
+ // ---- applyHighlights tests ----
+
+ @Test
+ void applyHighlightsNoMatchReturnsOriginal() {
+ SearchHighlighter sh = new SearchHighlighter();
+ Line original = Line.from(Span.styled("no match here", Style.EMPTY));
+
+ // No find or highlight patterns set, so should return original
unchanged
+ Line result = sh.applyHighlights(original, 0, -1);
+ assertEquals(original.rawContent(), result.rawContent());
+ assertEquals(original.spans().size(), result.spans().size());
+ }
+
+ @Test
+ void applyHighlightsEmptyLineReturnsOriginal() {
+ SearchHighlighter sh = new SearchHighlighter();
+ Line empty = Line.from(Span.raw(""));
+
+ Line result = sh.applyHighlights(empty, 0, -1);
+ assertEquals("", result.rawContent());
+ }
+
+ @Test
+ void applyHighlightsWithHighlightTerm() {
+ SearchHighlighter sh = new SearchHighlighter();
+ // Activate highlight mode and set a term
+ sh.handleKeyEvent(KeyEvent.ofChar('h', KeyModifiers.NONE)); //
activate highlight input
+ // Type "err"
+ sh.handleKeyEvent(KeyEvent.ofChar('e', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.NONE));
+ // Confirm with Enter
+ sh.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ assertTrue(sh.hasHighlightTerm());
+
+ Line line = Line.from(Span.styled("An error occurred", Style.EMPTY));
+ Line result = sh.applyHighlights(line, 0, -1);
+
+ // The word "err" should be highlighted
+ boolean foundHighlight = false;
+ for (Span span : result.spans()) {
+ if (span.content().equals("err")) {
+ assertEquals(SearchHighlighter.HIGHLIGHT_STYLE, span.style());
+ foundHighlight = true;
+ }
+ }
+ assertTrue(foundHighlight, "Expected 'err' to be highlighted");
+ }
+
+ @Test
+ void applyHighlightsWithFindCurrentLine() {
+ SearchHighlighter sh = new SearchHighlighter();
+ // Activate find mode
+ sh.handleKeyEvent(KeyEvent.ofChar('/', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('f', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('o', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('o', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ assertTrue(sh.hasFindTerm());
+
+ Line line = Line.from(Span.styled("foo bar foo", Style.EMPTY));
+ // lineIndex = 0, currentMatchLine = 0 means this IS the current match
line
+ Line result = sh.applyHighlights(line, 0, 0);
+
+ // "foo" occurrences should use FIND_CURRENT_STYLE
+ boolean foundCurrent = false;
+ for (Span span : result.spans()) {
+ if (span.content().equals("foo")) {
+ assertEquals(SearchHighlighter.FIND_CURRENT_STYLE,
span.style());
+ foundCurrent = true;
+ }
+ }
+ assertTrue(foundCurrent, "Expected 'foo' to use FIND_CURRENT_STYLE on
current match line");
+ }
+
+ @Test
+ void applyHighlightsWithFindNonCurrentLine() {
+ SearchHighlighter sh = new SearchHighlighter();
+ // Activate find mode
+ sh.handleKeyEvent(KeyEvent.ofChar('/', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('b', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('a', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ Line line = Line.from(Span.styled("foo bar baz", Style.EMPTY));
+ // lineIndex = 5, currentMatchLine = 0 → NOT the current line
+ Line result = sh.applyHighlights(line, 5, 0);
+
+ boolean foundMatch = false;
+ for (Span span : result.spans()) {
+ if (span.content().equals("bar")) {
+ assertEquals(SearchHighlighter.FIND_MATCH_STYLE, span.style());
+ foundMatch = true;
+ }
+ }
+ assertTrue(foundMatch, "Expected 'bar' to use FIND_MATCH_STYLE on
non-current line");
+ }
+
+ // ---- buildFindMatches + navigation tests ----
+
+ @Test
+ void buildFindMatchesFindsMatchingLines() {
+ SearchHighlighter sh = new SearchHighlighter();
+ sh.handleKeyEvent(KeyEvent.ofChar('/', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('e', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ List<String> lines = Arrays.asList(
+ "line one",
+ "error here",
+ "line three",
+ "another error");
+
+ sh.buildFindMatches(lines);
+ int first = sh.jumpToNearestMatch(0);
+ assertEquals(1, first); // line index 1 has "error"
+
+ sh.navigateToNextMatch();
+ assertEquals(3, sh.currentMatchLine()); // line index 3 has "error"
+
+ // Wrap around
+ sh.navigateToNextMatch();
+ assertEquals(1, sh.currentMatchLine());
+ }
+
+ @Test
+ void navigatePrevMatchWrapsBackward() {
+ SearchHighlighter sh = new SearchHighlighter();
+ sh.handleKeyEvent(KeyEvent.ofChar('/', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('x', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ List<String> lines = Arrays.asList("x1", "y2", "x3");
+ sh.buildFindMatches(lines);
+ sh.jumpToNearestMatch(0);
+
+ // At match 0 (line 0), going prev should wrap to last match
+ sh.navigateToPrevMatch();
+ assertEquals(2, sh.currentMatchLine()); // last match
+ }
+
+ @Test
+ void jumpToNearestMatchJumpsForward() {
+ SearchHighlighter sh = new SearchHighlighter();
+ sh.handleKeyEvent(KeyEvent.ofChar('/', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('z', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ List<String> lines = Arrays.asList("aaa", "bbb", "zzz", "ddd");
+ sh.buildFindMatches(lines);
+ int nearest = sh.jumpToNearestMatch(1);
+ assertEquals(2, nearest); // Jump to the nearest match at or after
position 1
+ }
+
+ @Test
+ void noFindPatternReturnsEmptyMatches() {
+ SearchHighlighter sh = new SearchHighlighter();
+ sh.buildFindMatches(Arrays.asList("a", "b", "c"));
+ assertEquals(-1, sh.currentMatchLine());
+ }
+
+ // ---- handleEscape tests ----
+
+ @Test
+ void handleEscapeClearsFindTerm() {
+ SearchHighlighter sh = new SearchHighlighter();
+ sh.handleKeyEvent(KeyEvent.ofChar('/', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofChar('x', KeyModifiers.NONE));
+ sh.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertTrue(sh.hasFindTerm());
+
+ boolean handled = sh.handleEscape();
+ assertTrue(handled);
+ assertFalse(sh.hasFindTerm());
+ }
+
+ @Test
+ void handleEscapeDuringInputCancelsInput() {
+ SearchHighlighter sh = new SearchHighlighter();
+ sh.handleKeyEvent(KeyEvent.ofChar('/', KeyModifiers.NONE));
+ assertTrue(sh.isSearchInputActive());
+
+ boolean handled = sh.handleEscape();
+ assertTrue(handled);
+ assertFalse(sh.isSearchInputActive());
+ }
+
+ @Test
+ void handleEscapeNoActiveSearchReturnsFalse() {
+ SearchHighlighter sh = new SearchHighlighter();
+ assertFalse(sh.handleEscape());
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanelTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanelTest.java
new file mode 100644
index 000000000000..18f5c5ab0cc9
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanelTest.java
@@ -0,0 +1,324 @@
+/*
+ * 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;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import dev.tamboui.style.Modifier;
+import dev.tamboui.style.Style;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ShellPanelTest {
+
+ // ---- convertRow tests ----
+
+ @Test
+ void convertRowPlainAscii() {
+ // Build a buffer with plain ASCII chars and zeroed attributes
+ long[] buffer = new long[3];
+ buffer[0] = 'H';
+ buffer[1] = 'i';
+ buffer[2] = '!';
+
+ Line line = ShellPanel.convertRow(buffer, 0, 3);
+ assertEquals("Hi!", rawContent(line));
+ }
+
+ @Test
+ void convertRowNullCodepointBecomesSpace() {
+ // A zero codepoint (null cell) should be rendered as a space
+ long[] buffer = new long[3];
+ buffer[0] = 'A';
+ buffer[1] = 0; // null codepoint
+ buffer[2] = 'B';
+
+ Line line = ShellPanel.convertRow(buffer, 0, 3);
+ assertEquals("A B", rawContent(line));
+ }
+
+ @Test
+ void convertRowBoldAttr() {
+ // Bold is bit 3 of X nibble → set bit 27 (attr bit 3 in X at position
24+3=27)
+ long attr = 0x8L << 24; // Bold
+ long[] buffer = new long[] { 'B' | (attr << 32) };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 1);
+ List<Span> spans = line.spans();
+ assertEquals(1, spans.size());
+
assertTrue(spans.get(0).style().effectiveModifiers().contains(Modifier.BOLD));
+ }
+
+ @Test
+ void convertRowUnderlineAttr() {
+ // Underline is bit 0 of X nibble → bit 24
+ long attr = 0x1L << 24; // Underline
+ long[] buffer = new long[] { 'U' | (attr << 32) };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 1);
+ List<Span> spans = line.spans();
+ assertEquals(1, spans.size());
+
assertTrue(spans.get(0).style().effectiveModifiers().contains(Modifier.UNDERLINED));
+ }
+
+ @Test
+ void convertRowFgColor() {
+ // FG set flag: bit 0 of Y (bit 28), FG color in bits 12-23
+ // FG = 0xF00 → red=0xF, green=0x0, blue=0x0
+ // Y=0x1 (FG set), X=0x0, FFF=0xF00, BBB=0x000
+ // attr = 0x10F00000L
+ long attr = 0x10F00000L;
+ long[] buffer = new long[] { 'R' | (attr << 32) };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 1);
+ List<Span> spans = line.spans();
+ assertEquals(1, spans.size());
+ assertTrue(spans.get(0).style().fg().isPresent());
+ }
+
+ @Test
+ void convertRowBgColor() {
+ // BG set flag: bit 1 of Y (bit 29), BG color in bits 0-11
+ // Y=0x2 (BG set), BBB=0x080 (green)
+ long attr = 0x20000080L;
+ long[] buffer = new long[] { 'G' | (attr << 32) };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 1);
+ List<Span> spans = line.spans();
+ assertEquals(1, spans.size());
+ assertTrue(spans.get(0).style().bg().isPresent());
+ }
+
+ @Test
+ void convertRowMergesContiguousSameAttr() {
+ // Two cells with the same attribute should be merged into one span
+ long attr = 0x8L << 24; // Bold
+ long[] buffer = new long[] {
+ 'A' | (attr << 32),
+ 'B' | (attr << 32)
+ };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 2);
+ List<Span> spans = line.spans();
+ assertEquals(1, spans.size());
+ assertEquals("AB", spans.get(0).content());
+ }
+
+ @Test
+ void convertRowSplitsDifferentAttrs() {
+ // Two cells with different attributes should become two spans
+ long boldAttr = 0x8L << 24;
+ long ulAttr = 0x1L << 24;
+ long[] buffer = new long[] {
+ 'A' | (boldAttr << 32),
+ 'B' | (ulAttr << 32)
+ };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 2);
+ List<Span> spans = line.spans();
+ assertEquals(2, spans.size());
+ assertEquals("A", spans.get(0).content());
+ assertEquals("B", spans.get(1).content());
+ }
+
+ @Test
+ void convertRowWithOffset() {
+ // Test that offset is correctly used to read from the middle of the
buffer
+ long[] buffer = new long[] { 'X', 'Y', 'A', 'B', 'C' };
+
+ Line line = ShellPanel.convertRow(buffer, 2, 3);
+ assertEquals("ABC", rawContent(line));
+ }
+
+ // ---- encodeKeyEvent tests ----
+
+ @Test
+ void encodeEnter() {
+ KeyEvent ke = KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE);
+ byte[] result = ShellPanel.encodeKeyEvent(ke);
+ assertArrayEquals(new byte[] { '\r' }, result);
+ }
+
+ @Test
+ void encodeBackspace() {
+ KeyEvent ke = KeyEvent.ofKey(KeyCode.BACKSPACE, KeyModifiers.NONE);
+ byte[] result = ShellPanel.encodeKeyEvent(ke);
+ assertArrayEquals(new byte[] { 0x7f }, result);
+ }
+
+ @Test
+ void encodeTab() {
+ KeyEvent ke = KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE);
+ byte[] result = ShellPanel.encodeKeyEvent(ke);
+ assertArrayEquals(new byte[] { '\t' }, result);
+ }
+
+ @Test
+ void encodeArrowKeys() {
+ assertArrayEquals("\033OA".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.UP,
KeyModifiers.NONE)));
+ assertArrayEquals("\033OB".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.DOWN,
KeyModifiers.NONE)));
+ assertArrayEquals("\033OC".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT,
KeyModifiers.NONE)));
+ assertArrayEquals("\033OD".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.LEFT,
KeyModifiers.NONE)));
+ }
+
+ @Test
+ void encodeHomeEnd() {
+ assertArrayEquals("\033OH".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.HOME,
KeyModifiers.NONE)));
+ assertArrayEquals("\033OF".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.END,
KeyModifiers.NONE)));
+ }
+
+ @Test
+ void encodeFKeys() {
+ assertArrayEquals("\033OP".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.F1,
KeyModifiers.NONE)));
+ assertArrayEquals("\033OQ".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.F2,
KeyModifiers.NONE)));
+ assertArrayEquals("\033[15~".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.F5,
KeyModifiers.NONE)));
+ assertArrayEquals("\033[24~".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.F12,
KeyModifiers.NONE)));
+ }
+
+ @Test
+ void encodeCtrlLetter() {
+ // Ctrl+c should produce byte 3 (0x03)
+ KeyEvent ke = KeyEvent.ofChar('c', KeyModifiers.of(true, false,
false));
+ byte[] result = ShellPanel.encodeKeyEvent(ke);
+ assertArrayEquals(new byte[] { 3 }, result);
+ }
+
+ @Test
+ void encodeCtrlUpperCaseLetter() {
+ // Ctrl+A should also produce byte 1
+ KeyEvent ke = KeyEvent.ofChar('A', KeyModifiers.of(true, false,
false));
+ byte[] result = ShellPanel.encodeKeyEvent(ke);
+ assertArrayEquals(new byte[] { 1 }, result);
+ }
+
+ @Test
+ void encodeRegularChar() {
+ KeyEvent ke = KeyEvent.ofChar('x', KeyModifiers.NONE);
+ byte[] result = ShellPanel.encodeKeyEvent(ke);
+ assertArrayEquals("x".getBytes(StandardCharsets.UTF_8), result);
+ }
+
+ @Test
+ void encodePageUpDown() {
+ assertArrayEquals("\033[5~".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_UP,
KeyModifiers.NONE)));
+ assertArrayEquals("\033[6~".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.PAGE_DOWN,
KeyModifiers.NONE)));
+ }
+
+ @Test
+ void encodeInsertDelete() {
+ assertArrayEquals("\033[2~".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.INSERT,
KeyModifiers.NONE)));
+ assertArrayEquals("\033[3~".getBytes(StandardCharsets.UTF_8),
+ ShellPanel.encodeKeyEvent(KeyEvent.ofKey(KeyCode.DELETE,
KeyModifiers.NONE)));
+ }
+
+ @Test
+ void encodeUnhandledKeyReturnsNull() {
+ // F11 is not handled by encodeKeyEvent
+ KeyEvent ke = KeyEvent.ofKey(KeyCode.F11, KeyModifiers.NONE);
+ byte[] result = ShellPanel.encodeKeyEvent(ke);
+ assertNull(result);
+ }
+
+ // ---- convertAttrToStyle tests ----
+
+ @Test
+ void convertAttrToStyleNoFlags() {
+ Style style = ShellPanel.convertAttrToStyle(0);
+ assertTrue(style.effectiveModifiers().isEmpty());
+ }
+
+ @Test
+ void convertAttrToStyleBold() {
+ // Bold = bit 3 of X nibble (bits 24-27)
+ long attr = 0x08000000L;
+ Style style = ShellPanel.convertAttrToStyle(attr);
+ assertTrue(style.effectiveModifiers().contains(Modifier.BOLD));
+ }
+
+ @Test
+ void convertAttrToStyleUnderline() {
+ long attr = 0x01000000L;
+ Style style = ShellPanel.convertAttrToStyle(attr);
+ assertTrue(style.effectiveModifiers().contains(Modifier.UNDERLINED));
+ }
+
+ @Test
+ void convertAttrToStyleReversed() {
+ long attr = 0x02000000L;
+ Style style = ShellPanel.convertAttrToStyle(attr);
+ assertTrue(style.effectiveModifiers().contains(Modifier.REVERSED));
+ }
+
+ @Test
+ void convertAttrToStyleDim() {
+ // Dim = bit 2 of Y nibble (bits 28-31) → 0x4 << 28
+ long attr = 0x40000000L;
+ Style style = ShellPanel.convertAttrToStyle(attr);
+ assertTrue(style.effectiveModifiers().contains(Modifier.DIM));
+ }
+
+ @Test
+ void convertAttrToStyleItalic() {
+ // Italic = bit 3 of Y nibble → 0x8 << 28
+ long attr = 0x80000000L;
+ Style style = ShellPanel.convertAttrToStyle(attr);
+ assertTrue(style.effectiveModifiers().contains(Modifier.ITALIC));
+ }
+
+ @Test
+ void convertAttrToStyleCombinedFgBgBoldItalic() {
+ // Y = 0xB (FG set + BG set + italic: bits 0+1+3), X = 0x8 (bold)
+ // FFF = 0xF00 (red FG), BBB = 0x080 (green BG)
+ long attr = 0xB8F00080L;
+ Style style = ShellPanel.convertAttrToStyle(attr);
+ assertTrue(style.effectiveModifiers().contains(Modifier.BOLD));
+ assertTrue(style.effectiveModifiers().contains(Modifier.ITALIC));
+ assertTrue(style.fg().isPresent());
+ assertTrue(style.bg().isPresent());
+ }
+
+ private static String rawContent(Line line) {
+ StringBuilder sb = new StringBuilder();
+ for (Span span : line.spans()) {
+ sb.append(span.content());
+ }
+ return sb.toString();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
new file mode 100644
index 000000000000..ad690a17c6e0
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
@@ -0,0 +1,482 @@
+/*
+ * 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;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StatusParserTest {
+
+ // ---- parseIntegration tests ----
+
+ @Test
+ void parseIntegrationMinimal() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "myIntegration");
+ context.put("version", "4.21.0");
+ context.put("phase", 5);
+ root.put("context", context);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertEquals("myIntegration", info.name);
+ assertEquals("4.21.0", info.camelVersion);
+ assertEquals(5, info.state);
+ }
+
+ @Test
+ void parseIntegrationReturnsNullWhenNoContext() {
+ JsonObject root = new JsonObject();
+ ProcessHandle ph = ProcessHandle.current();
+
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+ assertNull(info);
+ }
+
+ @Test
+ void parseIntegrationWithRoutes() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "routeTest");
+ root.put("context", context);
+
+ JsonArray routes = new JsonArray();
+ JsonObject route = new JsonObject();
+ route.put("routeId", "route1");
+ route.put("from", "timer:tick");
+ route.put("state", "Started");
+ routes.add(route);
+ root.put("routes", routes);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertEquals(1, info.routes.size());
+ assertEquals("route1", info.routes.get(0).routeId);
+ assertEquals("timer:tick", info.routes.get(0).from);
+ assertEquals("Started", info.routes.get(0).state);
+ assertEquals(1, info.routeTotal);
+ assertEquals(1, info.routeStarted);
+ }
+
+ @Test
+ void parseIntegrationWithHealth() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "healthTest");
+ root.put("context", context);
+
+ JsonObject healthChecks = new JsonObject();
+ healthChecks.put("ready", true);
+ JsonArray checks = new JsonArray();
+ JsonObject check = new JsonObject();
+ check.put("group", "readiness");
+ check.put("id", "context");
+ check.put("state", "UP");
+ check.put("readiness", true);
+ checks.add(check);
+ healthChecks.put("checks", checks);
+ root.put("healthChecks", healthChecks);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertEquals("1/1", info.ready);
+ assertEquals(1, info.healthChecks.size());
+ assertEquals("context", info.healthChecks.get(0).name);
+ assertEquals("UP", info.healthChecks.get(0).state);
+ }
+
+ @Test
+ void parseIntegrationWithEndpoints() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "endpointTest");
+ root.put("context", context);
+
+ JsonObject endpointsObj = new JsonObject();
+ JsonArray endpointList = new JsonArray();
+ JsonObject ep = new JsonObject();
+ ep.put("uri", "timer:tick");
+ ep.put("direction", "in");
+ ep.put("routeId", "route1");
+ ep.put("hits", 42L);
+ endpointList.add(ep);
+ endpointsObj.put("endpoints", endpointList);
+ root.put("endpoints", endpointsObj);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertEquals(1, info.endpoints.size());
+ assertEquals("timer:tick", info.endpoints.get(0).uri);
+ assertEquals("timer", info.endpoints.get(0).component);
+ assertEquals(42L, info.endpoints.get(0).hits);
+ }
+
+ @Test
+ void parseIntegrationWithCircuitBreakers() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "cbTest");
+ root.put("context", context);
+
+ JsonObject r4j = new JsonObject();
+ JsonArray breakers = new JsonArray();
+ JsonObject cb = new JsonObject();
+ cb.put("routeId", "route1");
+ cb.put("id", "cb1");
+ cb.put("state", "CLOSED");
+ cb.put("failureRate", 0.5);
+ breakers.add(cb);
+ r4j.put("circuitBreakers", breakers);
+ root.put("resilience4j", r4j);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertEquals(1, info.circuitBreakers.size());
+ assertEquals("resilience4j", info.circuitBreakers.get(0).component);
+ assertEquals("CLOSED", info.circuitBreakers.get(0).state);
+ assertEquals(0.5, info.circuitBreakers.get(0).failureRate);
+ }
+
+ @Test
+ void parseIntegrationWithErrors() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "errorTest");
+ root.put("context", context);
+
+ JsonObject errorsObj = new JsonObject();
+ errorsObj.put("size", 3);
+ root.put("errors", errorsObj);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertEquals(3, info.errorCount);
+ }
+
+ @Test
+ void parseIntegrationWithInflight() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "inflightTest");
+ root.put("context", context);
+
+ JsonObject inflightObj = new JsonObject();
+ inflightObj.put("inflightBrowseEnabled", true);
+ inflightObj.put("inflight", 1);
+ JsonArray exchanges = new JsonArray();
+ JsonObject ex = new JsonObject();
+ ex.put("exchangeId", "ID-1");
+ ex.put("fromRouteId", "route1");
+ ex.put("atRouteId", "route1");
+ ex.put("nodeId", "node1");
+ ex.put("elapsed", 100L);
+ ex.put("duration", 200L);
+ exchanges.add(ex);
+ inflightObj.put("exchanges", exchanges);
+ root.put("inflight", inflightObj);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertTrue(info.inflightBrowseEnabled);
+ assertEquals(1, info.inflightExchanges.size());
+ assertEquals("ID-1", info.inflightExchanges.get(0).exchangeId);
+ assertFalse(info.inflightExchanges.get(0).blocked);
+ }
+
+ @Test
+ void parseIntegrationWithHttpEndpoints() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "httpTest");
+ root.put("context", context);
+
+ JsonObject restsObj = new JsonObject();
+ JsonArray restList = new JsonArray();
+ JsonObject rest = new JsonObject();
+ rest.put("url", "http://localhost:8080/api/hello");
+ rest.put("method", "get");
+ rest.put("routeId", "route1");
+ rest.put("hits", 10L);
+ restList.add(rest);
+ restsObj.put("rests", restList);
+ root.put("rests", restsObj);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertEquals(1, info.httpEndpoints.size());
+ assertEquals("GET", info.httpEndpoints.get(0).method);
+ assertTrue(info.httpEndpoints.get(0).fromRest);
+ }
+
+ @Test
+ void parseIntegrationMissingSectionsDoNotNpe() {
+ // A minimal JSON with only context — all other sections missing
should not NPE
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "minimal");
+ root.put("context", context);
+
+ ProcessHandle ph = ProcessHandle.current();
+ IntegrationInfo info = StatusParser.parseIntegration(ph, root);
+
+ assertNotNull(info);
+ assertEquals("minimal", info.name);
+ assertTrue(info.routes.isEmpty());
+ assertTrue(info.healthChecks.isEmpty());
+ assertTrue(info.endpoints.isEmpty());
+ assertTrue(info.circuitBreakers.isEmpty());
+ assertTrue(info.inflightExchanges.isEmpty());
+ }
+
+ // ---- parseTraceEntry tests ----
+
+ @Test
+ void parseTraceEntryFirstDirection() {
+ JsonObject json = new JsonObject();
+ json.put("uid", "uid1");
+ json.put("exchangeId", "EX-1");
+ json.put("routeId", "route1");
+ json.put("nodeId", "node1");
+ json.put("first", true);
+ json.put("last", false);
+ json.put("done", false);
+ json.put("failed", false);
+ json.put("endpointUri", "timer:tick");
+ json.put("nodeLevel", 1);
+ json.put("elapsed", 42L);
+ json.put("timestamp", 1700000000000L);
+
+ TraceEntry entry = StatusParser.parseTraceEntry(json, "12345");
+ assertEquals("12345", entry.pid);
+ assertEquals("*-> ", entry.direction);
+ assertEquals("Processing", entry.status);
+ assertTrue(entry.processor.contains("from[timer:tick]"));
+ assertEquals(42L, entry.elapsed);
+ }
+
+ @Test
+ void parseTraceEntryLastDirection() {
+ JsonObject json = new JsonObject();
+ json.put("uid", "uid2");
+ json.put("exchangeId", "EX-2");
+ json.put("last", true);
+ json.put("first", false);
+ json.put("done", true);
+ json.put("failed", false);
+ json.put("nodeLabel", "log:result");
+ json.put("nodeLevel", 1);
+ json.put("timestamp", 1700000000000L);
+
+ TraceEntry entry = StatusParser.parseTraceEntry(json, "12345");
+ assertEquals("<-* ", entry.direction);
+ assertEquals("Done", entry.status);
+ }
+
+ @Test
+ void parseTraceEntryRemoteDirection() {
+ JsonObject json = new JsonObject();
+ json.put("uid", "uid3");
+ json.put("first", true);
+ json.put("last", false);
+ json.put("done", false);
+ json.put("failed", false);
+ json.put("remoteEndpoint", true);
+ json.put("endpointUri", "http://example.com");
+ json.put("nodeLevel", 1);
+ json.put("timestamp", 1700000000000L);
+
+ TraceEntry entry = StatusParser.parseTraceEntry(json, "12345");
+ assertEquals("*-->", entry.direction);
+ }
+
+ @Test
+ void parseTraceEntryFailedStatus() {
+ JsonObject json = new JsonObject();
+ json.put("uid", "uid4");
+ json.put("first", false);
+ json.put("last", false);
+ json.put("done", true);
+ json.put("failed", true);
+ json.put("nodeLabel", "process");
+ json.put("nodeLevel", 0);
+
+ TraceEntry entry = StatusParser.parseTraceEntry(json, "12345");
+ assertEquals("Failed", entry.status);
+ assertTrue(entry.failed);
+ }
+
+ @Test
+ void parseTraceEntryWithException() {
+ JsonObject json = new JsonObject();
+ json.put("uid", "uid5");
+ json.put("first", false);
+ json.put("last", false);
+ json.put("done", true);
+ json.put("failed", true);
+ json.put("nodeLabel", "process");
+ json.put("nodeLevel", 0);
+
+ JsonObject exc = new JsonObject();
+ exc.put("message", "NullPointerException");
+ exc.put("stackTrace", "at Foo.bar(Foo.java:42)");
+ json.put("exception", exc);
+
+ TraceEntry entry = StatusParser.parseTraceEntry(json, "12345");
+ assertNotNull(entry.exception);
+ assertTrue(entry.exception.contains("NullPointerException"));
+ assertTrue(entry.exception.contains("at Foo.bar"));
+ }
+
+ @Test
+ void parseTraceEntryStubDirection() {
+ JsonObject json = new JsonObject();
+ json.put("uid", "uid6");
+ json.put("first", true);
+ json.put("last", false);
+ json.put("done", false);
+ json.put("failed", false);
+ json.put("stubEndpoint", true);
+ json.put("endpointUri", "stub:test");
+ json.put("nodeLevel", 1);
+
+ TraceEntry entry = StatusParser.parseTraceEntry(json, "12345");
+ assertEquals("~-->", entry.direction);
+ }
+
+ // ---- parseMessage tests ----
+
+ @Test
+ void parseMessageWithHeadersAsListOfKv() {
+ JsonObject message = new JsonObject();
+ JsonArray headers = new JsonArray();
+ JsonObject h1 = new JsonObject();
+ h1.put("key", "Content-Type");
+ h1.put("value", "text/plain");
+ h1.put("type", "java.lang.String");
+ headers.add(h1);
+ message.put("headers", headers);
+
+ StatusParser.MessageData md = StatusParser.parseMessage(message);
+ assertNotNull(md.headers());
+ assertEquals("text/plain", md.headers().get("Content-Type"));
+ assertEquals("String", md.headerTypes().get("Content-Type"));
+ }
+
+ @Test
+ void parseMessageWithHeadersAsMap() {
+ JsonObject message = new JsonObject();
+ Map<String, Object> headers = new LinkedHashMap<>();
+ headers.put("Accept", "application/json");
+ message.put("headers", headers);
+
+ StatusParser.MessageData md = StatusParser.parseMessage(message);
+ assertNotNull(md.headers());
+ assertEquals("application/json", md.headers().get("Accept"));
+ }
+
+ @Test
+ void parseMessageWithBodyAsJsonObject() {
+ JsonObject message = new JsonObject();
+ JsonObject body = new JsonObject();
+ body.put("value", "Hello World");
+ body.put("type", "java.lang.String");
+ message.put("body", body);
+
+ StatusParser.MessageData md = StatusParser.parseMessage(message);
+ assertEquals("Hello World", md.body());
+ assertEquals("String", md.bodyType());
+ }
+
+ @Test
+ void parseMessageWithBodyAsRawString() {
+ JsonObject message = new JsonObject();
+ message.put("body", "plain text body");
+
+ StatusParser.MessageData md = StatusParser.parseMessage(message);
+ assertEquals("plain text body", md.body());
+ }
+
+ @Test
+ void parseMessageWithExchangeProperties() {
+ JsonObject message = new JsonObject();
+ JsonArray props = new JsonArray();
+ JsonObject p1 = new JsonObject();
+ p1.put("key", "CamelToEndpoint");
+ p1.put("value", "direct:end");
+ p1.put("type", "java.lang.String");
+ props.add(p1);
+ message.put("exchangeProperties", props);
+
+ StatusParser.MessageData md = StatusParser.parseMessage(message);
+ assertNotNull(md.exchangeProperties());
+ assertEquals("direct:end",
md.exchangeProperties().get("CamelToEndpoint"));
+ }
+
+ @Test
+ void parseMessageWithExchangeVariables() {
+ JsonObject message = new JsonObject();
+ JsonArray vars = new JsonArray();
+ JsonObject v1 = new JsonObject();
+ v1.put("key", "myVar");
+ v1.put("value", "varValue");
+ v1.put("type", "java.lang.String");
+ vars.add(v1);
+ message.put("exchangeVariables", vars);
+
+ StatusParser.MessageData md = StatusParser.parseMessage(message);
+ assertNotNull(md.exchangeVariables());
+ assertEquals("varValue", md.exchangeVariables().get("myVar"));
+ }
+
+ @Test
+ void parseMessageEmptyReturnsNulls() {
+ JsonObject message = new JsonObject();
+
+ StatusParser.MessageData md = StatusParser.parseMessage(message);
+ assertNull(md.headers());
+ assertNull(md.body());
+ assertNull(md.exchangeProperties());
+ assertNull(md.exchangeVariables());
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
new file mode 100644
index 000000000000..f389fd6395dd
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
@@ -0,0 +1,242 @@
+/*
+ * 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;
+
+import java.util.List;
+
+import dev.tamboui.style.AnsiColor;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Modifier;
+import dev.tamboui.style.Style;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TuiHelperTest {
+
+ // ---- stripAnsi tests ----
+
+ @Test
+ void stripAnsiCsiColorCodes() {
+ // ESC[31m = red foreground, ESC[0m = reset
+ String input = "[31mHello[0m";
+ assertEquals("Hello", TuiHelper.stripAnsi(input));
+ }
+
+ @Test
+ void stripAnsiTwoCharFeSequences() {
+ // ESC M = Reverse Index (a 2-char Fe sequence)
+ String input = "BeforeMAfter";
+ assertEquals("BeforeAfter", TuiHelper.stripAnsi(input));
+ }
+
+ @Test
+ void stripAnsiTabToEightSpaces() {
+ String input = "A\tB";
+ assertEquals("A B", TuiHelper.stripAnsi(input));
+ }
+
+ @Test
+ void stripAnsiCarriageReturnRemoved() {
+ String input = "Hello\rWorld";
+ assertEquals("HelloWorld", TuiHelper.stripAnsi(input));
+ }
+
+ @Test
+ void stripAnsiPlainTextUnchanged() {
+ String input = "No escape codes here";
+ assertEquals("No escape codes here", TuiHelper.stripAnsi(input));
+ }
+
+ @Test
+ void stripAnsiNullAndEmpty() {
+ assertNull(TuiHelper.stripAnsi(null));
+ assertEquals("", TuiHelper.stripAnsi(""));
+ }
+
+ // ---- ansiToLine tests ----
+
+ @Test
+ void ansiToLineColorCodesProduceStyledSpans() {
+ // ESC[31m = ANSI Red foreground
+ String raw = "[31mError[0m text";
+ Line line = TuiHelper.ansiToLine(raw, 0);
+ List<Span> spans = line.spans();
+ // Should have at least 2 spans: colored "Error" and unstyled " text"
+ assertTrue(spans.size() >= 2);
+ assertEquals("Error", spans.get(0).content());
+ assertEquals(Color.ansi(AnsiColor.RED),
spans.get(0).style().fg().orElse(null));
+ }
+
+ @Test
+ void ansiToLineBoldItalicUnderline() {
+ // ESC[1m = bold, ESC[3m = italic, ESC[4m = underline
+ String raw = "[1;3;4mStyled[0m";
+ Line line = TuiHelper.ansiToLine(raw, 0);
+ List<Span> spans = line.spans();
+ assertTrue(spans.size() >= 1);
+ Style style = spans.get(0).style();
+ assertTrue(style.effectiveModifiers().contains(Modifier.BOLD));
+ assertTrue(style.effectiveModifiers().contains(Modifier.ITALIC));
+ assertTrue(style.effectiveModifiers().contains(Modifier.UNDERLINED));
+ }
+
+ @Test
+ void ansiToLine256Color() {
+ // ESC[38;5;196m = 256-color index 196 (red)
+ String raw = "[38;5;196mIndexed[0m";
+ Line line = TuiHelper.ansiToLine(raw, 0);
+ List<Span> spans = line.spans();
+ assertTrue(spans.size() >= 1);
+ assertEquals("Indexed", spans.get(0).content());
+ assertTrue(spans.get(0).style().fg().isPresent());
+ }
+
+ @Test
+ void ansiToLineRgbColor() {
+ // ESC[38;2;255;128;0m = RGB color
+ String raw = "[38;2;255;128;0mRGB[0m";
+ Line line = TuiHelper.ansiToLine(raw, 0);
+ List<Span> spans = line.spans();
+ assertTrue(spans.size() >= 1);
+ assertEquals("RGB", spans.get(0).content());
+ assertEquals(Color.rgb(255, 128, 0),
spans.get(0).style().fg().orElse(null));
+ }
+
+ @Test
+ void ansiToLineResetClearsStyle() {
+ String raw = "[1mBold[0m Normal";
+ Line line = TuiHelper.ansiToLine(raw, 0);
+ List<Span> spans = line.spans();
+ assertTrue(spans.size() >= 2);
+
assertTrue(spans.get(0).style().effectiveModifiers().contains(Modifier.BOLD));
+
assertFalse(spans.get(1).style().effectiveModifiers().contains(Modifier.BOLD));
+ }
+
+ @Test
+ void ansiToLineHSkipColumnsSkipped() {
+ // With hSkip=3, the first 3 visible columns should be omitted
+ String raw = "ABCDEF";
+ Line line = TuiHelper.ansiToLine(raw, 3);
+ String content = rawContent(line);
+ assertEquals("DEF", content);
+ }
+
+ @Test
+ void ansiToLineNullReturnsEmpty() {
+ Line line = TuiHelper.ansiToLine(null, 0);
+ assertEquals("", rawContent(line));
+ }
+
+ // ---- shortTypeName tests ----
+
+ @Test
+ void shortTypeNameJavaLangPrefix() {
+ assertEquals("String", TuiHelper.shortTypeName("java.lang.String"));
+ assertEquals("Integer", TuiHelper.shortTypeName("java.lang.Integer"));
+ }
+
+ @Test
+ void shortTypeNameJavaUtilPrefix() {
+ assertEquals("HashMap", TuiHelper.shortTypeName("java.util.HashMap"));
+ assertEquals("ArrayList",
TuiHelper.shortTypeName("java.util.ArrayList"));
+ }
+
+ @Test
+ void shortTypeNameJavaUtilConcurrentPrefix() {
+ assertEquals("ConcurrentHashMap",
TuiHelper.shortTypeName("java.util.concurrent.ConcurrentHashMap"));
+ }
+
+ @Test
+ void shortTypeNameCamelLongType() {
+ // This specific type is mapped to a short name
+ assertEquals("WrappedInputStream",
+
TuiHelper.shortTypeName("org.apache.camel.converter.stream.CachedOutputStream.WrappedInputStream"));
+ }
+
+ @Test
+ void shortTypeNameCamelSupportPrefix() {
+ assertEquals("DefaultExchange",
TuiHelper.shortTypeName("org.apache.camel.support.DefaultExchange"));
+ }
+
+ @Test
+ void shortTypeNameLongCustomType() {
+ // Types longer than 34 chars are shortened to the last segment
+ String longType = "com.example.very.long.package.name.MyClass";
+ assertEquals("MyClass", TuiHelper.shortTypeName(longType));
+ }
+
+ @Test
+ void shortTypeNameShortNamePassedThrough() {
+ assertEquals("int", TuiHelper.shortTypeName("int"));
+ assertEquals("byte[]", TuiHelper.shortTypeName("byte[]"));
+ }
+
+ @Test
+ void shortTypeNameNull() {
+ assertEquals("null", TuiHelper.shortTypeName(null));
+ }
+
+ @Test
+ void shortTypeNameGroupedExchangeList() {
+ assertEquals("GroupedExchangeList",
+ TuiHelper.shortTypeName(
+
"org.apache.camel.processor.aggregate.AbstractListAggregationStrategy.GroupedExchangeList"));
+ }
+
+ @Test
+ void shortTypeNameCachedOutputStream() {
+ assertEquals("CachedOutputStream",
+
TuiHelper.shortTypeName("org.apache.camel.converter.stream.CachedOutputStream"));
+ }
+
+ // ---- objToLong tests ----
+
+ @Test
+ void objToLongFromNumber() {
+ assertEquals(42L, TuiHelper.objToLong(42));
+ assertEquals(100L, TuiHelper.objToLong(100L));
+ }
+
+ @Test
+ void objToLongFromString() {
+ assertEquals(99L, TuiHelper.objToLong("99"));
+ }
+
+ @Test
+ void objToLongFromNull() {
+ assertEquals(0L, TuiHelper.objToLong(null));
+ }
+
+ @Test
+ void objToLongFromInvalidString() {
+ assertEquals(0L, TuiHelper.objToLong("not-a-number"));
+ }
+
+ private static String rawContent(Line line) {
+ StringBuilder sb = new StringBuilder();
+ for (Span span : line.spans()) {
+ sb.append(span.content());
+ }
+ return sb.toString();
+ }
+}