This is an automated email from the ASF dual-hosted git repository.

davsclaus 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 1a1cab52430b CAMEL-26677: camel-chaos - expectations on the error 
handler (#29313)
1a1cab52430b is described below

commit 1a1cab52430b97e067616d0255c4a3c0b2215e4b
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 10:44:55 2026 +0100

    CAMEL-26677: camel-chaos - expectations on the error handler (#29313)
    
    * CAMEL-26675: camel-chaos - report accuracy
    
    - The send of the dead letter channel is a hook of its own (at=dlq): an
      endpoint= or component= rule no longer fails it by accident, and a rule
      with at=dlq that keeps a message from the dead letter channel counts it
      as lost, with a FAIL verdict.
    - A route counts an exchange once (the last pass), not once per pass:
      direct routes called in a loop or again by a redelivery, expectations
      and the entry faults.
    - An expectation of a removed or changed rule, or of a stopped context, is
      cancelled, so the report can be final again.
    - chaos-clear --reset (also reset=true of the MCP and camel ask tool, and
      ChaosManager.reset()) starts a new report.
    - The rules of camel.chaos.rules start their clock when the context has
      started.
    - The report lists the rules by id, and the versions of an id from the
      latest.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    * CAMEL-26676: camel-chaos - feedback while adding rules
    
    - A rule that matches nothing in the context now (no endpoint, route or
      step id, at=poll without a scheduled poll consumer, an expectation of a
      route that does not exist) is added with a warning that names some of
      what is there: logged, returned by ChaosManager.checkRule, printed by
      chaos-add and chaos-change, and passed through the MCP and camel ask
      tools.
    - ChaosManager.addRules adds rules separated by ; together, all or none;
      chaos-add takes --rule more than once, and the add tool several rules.
    - A skipped rule counts why (skippedReasons), in the report and the CLI.
    - camel get chaos has a NOTE column (what a lifecycle rule does to its
      route, why a rule was skipped), and says on stderr when there is
      nothing to show.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    * CAMEL-26677: camel-chaos - expectations on the error handler
    
    An expectation names what it counts in its route and how:
    expect=<route>:<kind><op><count>, with the kinds completed, handled (by
    the error handler, as the dead letter channel), failed and redeliveries,
    and the ops >= (at least), <= (at most) and = (exactly). expect=orders:100
    stays at least 100 completed exchanges.
    
    At least is met when the count is reached; at most and exactly are unmet
    as soon as the count goes over, and decided when the time is up. So
    expect=orders:handled<=0 checks that redelivery recovers every message,
    and expect=orders:handled>=5 that 5 reach the dead letter channel, also
    one that no route consumes. Only an unmet expectation of completed
    exchanges counts messages as lost; the others give their own reason.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    
    ---------
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/chaos.adoc       |  44 ++++-
 components/camel-chaos/src/main/docs/chaos.adoc    |  44 ++++-
 .../camel/component/chaos/ChaosEventNotifier.java  |   2 +-
 .../camel/component/chaos/ChaosExchangeState.java  |   7 +-
 .../apache/camel/component/chaos/ChaosManager.java |  35 +++-
 .../apache/camel/component/chaos/ChaosReport.java  |  46 +++--
 .../apache/camel/component/chaos/ChaosRule.java    | 136 +++++++++++----
 .../camel/component/chaos/ChaosRuleParser.java     |  49 +++++-
 .../chaos/ChaosErrorHandlerExpectationTest.java    | 185 +++++++++++++++++++++
 .../camel/component/chaos/ChaosRuleParserTest.java |  27 +++
 .../camel/component/chaos/ChaosRuleTest.java       |  14 +-
 .../dsl/jbang/core/commands/process/ListChaos.java |  12 +-
 .../jbang/core/commands/process/ListChaosTest.java |  15 ++
 13 files changed, 550 insertions(+), 66 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
index bb20880b7ede..6f83ea053c0f 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
@@ -175,7 +175,9 @@ at:      send | poll | entry | step             (optional)
 rate:    a probability (rate=0.3), or every Nth match (rate=every:5); without 
it every match fires
 until:   a number of matches (until=100) or a time (until=2m); without it 
camel.chaos.maxDuration
 time:    a number with a unit (ms, s, m, h, d), also combined as 1m30s; a 
number alone is milliseconds
-expect:  expect=<route id>:<count>, with an optional within=<time>
+expect:  expect=<route id>:<count> (at least that many completed exchanges), or
+         expect=<route id>:<kind><op><count> with kind completed | handled | 
failed | redeliveries
+         and op >= | <= | = (such as expect=orders:handled<=0); with an 
optional within=<time>
 ----
 
 The patterns of the targets are matched as text, with `*` for any characters; 
they are not regular expressions. The
@@ -463,6 +465,46 @@ with a pending expectation as not decided yet. An 
expectation is cancelled when
 when the context stops first (also when the context is started again): the 
messages may still arrive, so it is
 neither met nor unmet, and it does not keep the report from being final.
 
+=== Expectations on the error handler
+
+An exchange that the dead letter channel handled counts as completed, so 
`expect=orders:100` is met while every
+message goes to the dead letter channel. To check the error handler itself, an 
expectation names what it counts and how,
+`expect=<route id>:<kind><op><count>`:
+
+[cols="1,3"]
+|===
+|Kind |Counts, in the route, after the rule is added
+
+|`completed` |The exchanges that completed (also after the error handler 
handled a failure); `orders:100` is
+`orders:completed>=100`.
+|`handled` |The completed exchanges after the error handler handled a failure: 
the dead letter channel, or `onException`
+with `handled(true)`.
+|`failed` |The exchanges that failed (also marked rollback only).
+|`redeliveries` |The redelivery attempts of the error handler, in the route 
that runs the step it redelivers.
+|===
+
+The op is `>=` (at least), `<=` (at most) or `=` (exactly). At least is met as 
soon as the count is reached, and unmet
+when the time is up without it. At most and exactly are unmet as soon as the 
count goes over, and decided when the time
+is up otherwise: an at most is met then, an exactly when the count is that 
many. Each exchange counts once per kind (the
+first time it completes, is handled or fails in the route).
+
+For example, with redelivery that should recover from a broker that fails one 
send in ten:
+
+[source,properties]
+----
+camel.chaos.rules = endpoint=kafka:orders* error rate=0.1 until=2m 
expect=orders:handled<=0
+----
+
+The verdict is FAIL as soon as one message goes to the dead letter channel, 
with the reason
+(`route orders: 1 exchange handled by the error handler within 2m0s, at most 0 
expected (rule r1)`), and PASS when the
+2 minutes are up without one. `expect=orders:handled>=5` checks that 5 
messages reach the dead letter channel, also when
+it is an endpoint that no route consumes, and `expect=orders:redeliveries<=30` 
that redelivery does not retry too much.
+Only an unmet expectation of completed exchanges (at least or exactly) counts 
the missing exchanges as lost; the others
+give their reason only.
+
+In JSON an expectation has its `kind`, `op`, `count` and the `value` counted 
so far (and `completed`, the same value, for
+an expectation of completed exchanges).
+
 == Time and the seed
 
 The `delay` fault waits through `TimeSource` of camel-util, and the end of a 
rule, the time of an expectation and the
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc 
b/components/camel-chaos/src/main/docs/chaos.adoc
index bb20880b7ede..6f83ea053c0f 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -175,7 +175,9 @@ at:      send | poll | entry | step             (optional)
 rate:    a probability (rate=0.3), or every Nth match (rate=every:5); without 
it every match fires
 until:   a number of matches (until=100) or a time (until=2m); without it 
camel.chaos.maxDuration
 time:    a number with a unit (ms, s, m, h, d), also combined as 1m30s; a 
number alone is milliseconds
-expect:  expect=<route id>:<count>, with an optional within=<time>
+expect:  expect=<route id>:<count> (at least that many completed exchanges), or
+         expect=<route id>:<kind><op><count> with kind completed | handled | 
failed | redeliveries
+         and op >= | <= | = (such as expect=orders:handled<=0); with an 
optional within=<time>
 ----
 
 The patterns of the targets are matched as text, with `*` for any characters; 
they are not regular expressions. The
@@ -463,6 +465,46 @@ with a pending expectation as not decided yet. An 
expectation is cancelled when
 when the context stops first (also when the context is started again): the 
messages may still arrive, so it is
 neither met nor unmet, and it does not keep the report from being final.
 
+=== Expectations on the error handler
+
+An exchange that the dead letter channel handled counts as completed, so 
`expect=orders:100` is met while every
+message goes to the dead letter channel. To check the error handler itself, an 
expectation names what it counts and how,
+`expect=<route id>:<kind><op><count>`:
+
+[cols="1,3"]
+|===
+|Kind |Counts, in the route, after the rule is added
+
+|`completed` |The exchanges that completed (also after the error handler 
handled a failure); `orders:100` is
+`orders:completed>=100`.
+|`handled` |The completed exchanges after the error handler handled a failure: 
the dead letter channel, or `onException`
+with `handled(true)`.
+|`failed` |The exchanges that failed (also marked rollback only).
+|`redeliveries` |The redelivery attempts of the error handler, in the route 
that runs the step it redelivers.
+|===
+
+The op is `>=` (at least), `<=` (at most) or `=` (exactly). At least is met as 
soon as the count is reached, and unmet
+when the time is up without it. At most and exactly are unmet as soon as the 
count goes over, and decided when the time
+is up otherwise: an at most is met then, an exactly when the count is that 
many. Each exchange counts once per kind (the
+first time it completes, is handled or fails in the route).
+
+For example, with redelivery that should recover from a broker that fails one 
send in ten:
+
+[source,properties]
+----
+camel.chaos.rules = endpoint=kafka:orders* error rate=0.1 until=2m 
expect=orders:handled<=0
+----
+
+The verdict is FAIL as soon as one message goes to the dead letter channel, 
with the reason
+(`route orders: 1 exchange handled by the error handler within 2m0s, at most 0 
expected (rule r1)`), and PASS when the
+2 minutes are up without one. `expect=orders:handled>=5` checks that 5 
messages reach the dead letter channel, also when
+it is an endpoint that no route consumes, and `expect=orders:redeliveries<=30` 
that redelivery does not retry too much.
+Only an unmet expectation of completed exchanges (at least or exactly) counts 
the missing exchanges as lost; the others
+give their reason only.
+
+In JSON an expectation has its `kind`, `op`, `count` and the `value` counted 
so far (and `completed`, the same value, for
+an expectation of completed exchanges).
+
 == Time and the seed
 
 The `delay` fault waits through `TimeSource` of camel-util, and the end of a 
rule, the time of an expectation and the
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosEventNotifier.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosEventNotifier.java
index e96be10d638d..20146e55679a 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosEventNotifier.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosEventNotifier.java
@@ -61,7 +61,7 @@ final class ChaosEventNotifier extends EventNotifierSupport {
             Route route = uow != null ? uow.getRoute() : null;
             String routeId = route != null ? route.getRouteId() : 
exchange.getFromRouteId();
             if (routeId != null) {
-                manager.counts(routeId).redeliveries.increment();
+                manager.redelivered(routeId);
             }
         }
     }
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
index c0ad44e41f23..a11e49945e5b 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
@@ -58,8 +58,11 @@ final class ChaosExchangeState implements SafeCopyProperty {
         // the counts of the route the outcome is counted in: other counts 
(after a reset of the report) have not
         // counted the exchange
         ChaosManager.RouteCounts counts;
-        // the expectations of the route counted the exchange as completed 
(once, also when it completes again)
-        boolean expected;
+        // the expectations of the route counted the exchange as completed, 
handled or failed (each once, also when the
+        // exchange goes through the route again)
+        boolean expectedCompleted;
+        boolean expectedHandled;
+        boolean expectedFailed;
     }
 
     private final Map<String, RouteState> routes = new HashMap<>(4);
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
index 8882016fb92b..ba1daa0e85ef 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
@@ -391,7 +391,8 @@ public final class ChaosManager {
      * An exchange counts once per route, with how the route was done with it 
the last time: camel-core calls the route
      * policy each time the exchange goes through the route (a {@code direct} 
route called in a loop, or again by a
      * redelivery of the calling route), so a later pass moves the count (a 
failed pass and then a completed one count
-     * as one completed exchange). The expectations of the route count it the 
first time it completes.
+     * as one completed exchange). The expectations of the route count it the 
first time it completes, is handled by the
+     * error handler, or fails.
      */
     void routeDone(Route route, Exchange exchange) {
         String routeId = route.getRouteId();
@@ -406,12 +407,30 @@ public final class ChaosManager {
         counts.count(outcome);
         state.outcome = outcome;
         state.counts = counts;
-        if (outcome.completed() && !state.expected) {
-            state.expected = true;
-            for (ChaosRule.Expectation expectation : ruleSet.expectations) {
-                if (expectation.route.equals(routeId)) {
-                    expectation.complete();
-                }
+        if (outcome.completed() && !state.expectedCompleted) {
+            state.expectedCompleted = true;
+            record(routeId, ChaosRule.Expectation.Kind.COMPLETED);
+        }
+        if (outcome == ChaosExchangeState.Outcome.HANDLED && 
!state.expectedHandled) {
+            state.expectedHandled = true;
+            record(routeId, ChaosRule.Expectation.Kind.HANDLED);
+        }
+        if (!outcome.completed() && outcome != 
ChaosExchangeState.Outcome.ENTERED && !state.expectedFailed) {
+            state.expectedFailed = true;
+            record(routeId, ChaosRule.Expectation.Kind.FAILED);
+        }
+    }
+
+    /** The error handler redelivers a step of the route: counted for the 
report and the expectations. */
+    void redelivered(String routeId) {
+        counts(routeId).redeliveries.increment();
+        record(routeId, ChaosRule.Expectation.Kind.REDELIVERIES);
+    }
+
+    private void record(String routeId, ChaosRule.Expectation.Kind kind) {
+        for (ChaosRule.Expectation expectation : ruleSet.expectations) {
+            if (expectation.kind == kind && expectation.route.equals(routeId)) 
{
+                expectation.record();
             }
         }
     }
@@ -605,7 +624,7 @@ public final class ChaosManager {
             ChaosRule.Expectation.Outcome outcome = reading != null ? 
reading.outcome() : null;
             if (outcome == ChaosRule.Expectation.Outcome.UNMET) {
                 evictedUnmet++;
-                evictedLost += Math.max(0, expectation.count - 
reading.completed());
+                evictedLost += expectation.lost(reading);
             } else if (outcome == ChaosRule.Expectation.Outcome.PENDING) {
                 // a pending expectation that left the report keeps the report 
from being final
                 evictedPending++;
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
index 1cef8c19cbef..6cc1e99a1813 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
@@ -92,11 +92,17 @@ public final class ChaosReport {
             }
             ChaosRule rule = entry.rule;
             if (entry.outcome == Outcome.UNMET) {
-                long missing = Math.max(0, rule.expectation.count - 
entry.completed);
-                lostCount += missing;
-                reasons.add("route " + rule.expectation.route + " completed " 
+ entry.completed + " of "
-                            + rule.expectation.count + " exchanges within " + 
ChaosRuleParser.printTime(rule.within)
-                            + " (rule " + rule.id + "): " + missing + " lost");
+                ChaosRule.Expectation e = rule.expectation;
+                lostCount += entry.lost;
+                if (e.kind == ChaosRule.Expectation.Kind.COMPLETED && e.op == 
ChaosRule.Expectation.Op.AT_LEAST) {
+                    reasons.add("route " + e.route + " completed " + 
entry.value + " of " + e.count + " exchanges within "
+                                + ChaosRuleParser.printTime(rule.within) + " 
(rule " + rule.id + "): " + entry.lost
+                                + " lost");
+                } else {
+                    reasons.add("route " + e.route + ": " + entry.value + " " 
+ e.kind.words(entry.value) + " within "
+                                + ChaosRuleParser.printTime(rule.within) + ", 
" + e.op.words + " " + e.count
+                                + " expected (rule " + rule.id + ")" + 
(entry.lost > 0 ? ": " + entry.lost + " lost" : ""));
+                }
             } else if (entry.outcome == Outcome.PENDING) {
                 anyPending = true;
             }
@@ -208,9 +214,9 @@ public final class ChaosReport {
                 sb.append("\n      ").append(lifecycleText(r));
             }
             if (r.outcome != null) {
-                sb.append("\n      expect 
").append(r.expectRoute).append(':').append(r.expectCount).append(" within ")
+                sb.append("\n      expect ").append(r.expectSpec).append(" 
within ")
                         .append(ChaosRuleParser.printTime(r.within)).append(": 
").append(r.outcome.key)
-                        .append(", ").append(r.completed).append(" completed");
+                        .append(", ").append(r.value).append(' 
').append(r.expectKind.key);
                 if (r.outcome == Outcome.PENDING) {
                     sb.append(", 
").append(ChaosRuleParser.printTime(r.timeLeft)).append(" left");
                 } else if (r.outcome == Outcome.CANCELLED) {
@@ -307,9 +313,15 @@ public final class ChaosReport {
             if (r.outcome != null) {
                 JsonObject e = new JsonObject();
                 e.put("route", r.expectRoute);
+                e.put("kind", r.expectKind.key);
+                e.put("op", r.expectOp.key);
                 e.put("count", r.expectCount);
                 e.put("within", r.within);
-                e.put("completed", r.completed);
+                // the count of its kind so far (completed for an expectation 
of completed exchanges, as before)
+                e.put("value", r.value);
+                if (r.expectKind == ChaosRule.Expectation.Kind.COMPLETED) {
+                    e.put("completed", r.value);
+                }
                 e.put("outcome", r.outcome.key);
                 if (r.outcome == Outcome.PENDING) {
                     e.put("timeLeft", r.timeLeft);
@@ -408,10 +420,14 @@ public final class ChaosReport {
         final long lostAtDlq;
         final long endsIn;
         final String expectRoute;
+        final ChaosRule.Expectation.Kind expectKind;
+        final ChaosRule.Expectation.Op expectOp;
+        final String expectSpec;
         final long expectCount;
         final long within;
         final Outcome outcome;
-        final long completed;
+        final long value;
+        final long lost;
         final long timeLeft;
         // a lifecycle rule: its action, route, duration and what it did, else 
null
         final Action action;
@@ -447,16 +463,24 @@ public final class ChaosReport {
             ChaosRule.Expectation expectation = rule.expectation;
             if (expectation != null) {
                 expectRoute = expectation.route;
+                expectKind = expectation.kind;
+                expectOp = expectation.op;
+                expectSpec = expectation.spec();
                 expectCount = expectation.count;
                 ChaosRule.Expectation.Reading reading = expectation.read(now);
                 outcome = reading.outcome();
-                completed = reading.completed();
+                value = reading.value();
+                lost = expectation.lost(reading);
                 timeLeft = Math.max(0, expectation.deadline() - now);
             } else {
                 expectRoute = null;
+                expectKind = null;
+                expectOp = null;
+                expectSpec = null;
                 expectCount = 0;
                 outcome = null;
-                completed = 0;
+                value = 0;
+                lost = 0;
                 timeLeft = 0;
             }
         }
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
index 45115b5c2bc7..a79ac472d861 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
@@ -218,7 +218,8 @@ final class ChaosRule {
         this.runFor = untilDuration > 0 ? untilDuration : maxDuration;
         this.endsAt = plus(addedAt, runFor);
         this.within = b.within > 0 ? b.within : (untilDuration > 0 ? 
untilDuration : maxDuration);
-        this.expectation = expectRoute != null ? new Expectation(expectRoute, 
expectCount, plus(addedAt, within)) : null;
+        this.expectation = expectRoute != null
+                ? new Expectation(expectRoute, b.expectKind, b.expectOp, 
expectCount, plus(addedAt, within)) : null;
         for (int i = 0; i < fired.length; i++) {
             fired[i] = new LongAdder();
         }
@@ -392,10 +393,15 @@ final class ChaosRule {
     }
 
     /**
-     * An expectation of a rule: the route completes at least that many more 
exchanges after the rule is added, before
-     * the time is up. It is met when the count is reached in time, unmet when 
the time is up without it, and pending
-     * before that. It is cancelled when the rule is removed (or replaced), or 
the context stops, first: the messages
-     * may still arrive, so it is neither met nor unmet, and does not keep the 
report from being final.
+     * An expectation of a rule: a count of the route after the rule is added, 
before the time is up. Its kind is what
+     * it counts (the exchanges that completed, were handled by the error 
handler or failed, or the redeliveries), and
+     * its op how the count compares: at least ({@code >=}, as {@code 
expect=orders:100} is), at most ({@code <=}) or
+     * exactly ({@code =}) that many.
+     * <p/>
+     * At least is met as soon as the count is reached in time, and unmet when 
the time is up without it. At most and
+     * exactly are unmet as soon as the count goes over, and decided when the 
time is up otherwise. Before that it is
+     * pending. It is cancelled when the rule is removed (or replaced), or the 
context stops, first: the messages may
+     * still arrive, so it is neither met nor unmet, and does not keep the 
report from being final.
      */
     static final class Expectation {
 
@@ -413,35 +419,84 @@ final class ChaosRule {
             }
         }
 
+        /** What an expectation counts in its route. */
+        enum Kind {
+            COMPLETED("completed", "exchange completed", "exchanges 
completed"),
+            HANDLED("handled", "exchange handled by the error handler", 
"exchanges handled by the error handler"),
+            FAILED("failed", "exchange failed", "exchanges failed"),
+            REDELIVERIES("redeliveries", "redelivery", "redeliveries");
+
+            final String key;
+            // for the reason of an unmet expectation
+            private final String one;
+            private final String many;
+
+            Kind(String key, String one, String many) {
+                this.key = key;
+                this.one = one;
+                this.many = many;
+            }
+
+            String words(long n) {
+                return n == 1 ? one : many;
+            }
+        }
+
+        /** How the count of an expectation compares. */
+        enum Op {
+            AT_LEAST(">=", "at least"),
+            AT_MOST("<=", "at most"),
+            EXACTLY("=", "exactly");
+
+            final String key;
+            final String words;
+
+            Op(String key, String words) {
+                this.key = key;
+                this.words = words;
+            }
+        }
+
         final String route;
+        final Kind kind;
+        final Op op;
         final long count;
         private long deadline;
-        private long completed;
-        private long metAt = -1;
+        private long value;
+        // when it was decided before the time was up (met for at least, unmet 
for at most and exactly), and how
+        private long decidedAt = -1;
+        private Outcome decided;
         private long frozenAt = -1;
 
         Expectation(String route, long count, long deadline) {
+            this(route, Kind.COMPLETED, Op.AT_LEAST, count, deadline);
+        }
+
+        Expectation(String route, Kind kind, Op op, long count, long deadline) 
{
             this.route = route;
+            this.kind = kind;
+            this.op = op;
             this.count = count;
             this.deadline = deadline;
         }
 
         /**
-         * The route of this expectation completed an exchange. The time is 
read under the lock, so once a report saw
-         * the expectation unmet, a completion that comes later cannot make it 
met.
+         * The route of this expectation counted one more of its kind. The 
time is read under the lock, so once a report
+         * saw the expectation decided, a count that comes later cannot change 
it.
          */
-        synchronized void complete() {
-            completeAt(TimeSource.currentTimeMillis());
+        synchronized void record() {
+            recordAt(TimeSource.currentTimeMillis());
         }
 
-        /** The route of this expectation completed an exchange at the given 
time (the tests give the time). */
-        synchronized void completeAt(long now) {
-            if (metAt >= 0 || frozenAt >= 0 || now > deadline) {
+        /** The route of this expectation counted one more of its kind at the 
given time (the tests give the time). */
+        synchronized void recordAt(long now) {
+            if (decided != null || frozenAt >= 0 || now > deadline) {
                 return;
             }
-            completed++;
-            if (completed >= count) {
-                metAt = now;
+            value++;
+            if (op == Op.AT_LEAST ? value >= count : value > count) {
+                decided = op == Op.AT_LEAST ? Outcome.MET : Outcome.UNMET;
+                decidedAt = now;
             }
         }
 
@@ -451,7 +506,7 @@ final class ChaosRule {
 
         /** The clock of the rule starts again (see {@link 
ChaosRule#startClock(long)}). */
         synchronized void startClock(long deadline) {
-            if (metAt < 0 && frozenAt < 0) {
+            if (decided == null && frozenAt < 0) {
                 this.deadline = deadline;
             }
         }
@@ -462,32 +517,55 @@ final class ChaosRule {
             }
         }
 
-        synchronized long completed() {
-            return completed;
+        synchronized long value() {
+            return value;
         }
 
         synchronized Outcome outcome(long now) {
-            if (metAt >= 0) {
-                return Outcome.MET;
+            if (decided != null) {
+                return decided;
             }
             if (frozenAt >= 0 && frozenAt <= deadline) {
-                // removed before it was decided: the messages may still 
arrive, so they are not called lost
+                // removed before it was decided: the messages may still 
arrive, so it is neither met nor unmet
                 return Outcome.CANCELLED;
             }
-            return now > deadline ? Outcome.UNMET : Outcome.PENDING;
+            if (now <= deadline) {
+                return Outcome.PENDING;
+            }
+            // the time is up
+            return switch (op) {
+                case AT_LEAST -> Outcome.UNMET;
+                case AT_MOST -> Outcome.MET;
+                case EXACTLY -> value == count ? Outcome.MET : Outcome.UNMET;
+            };
         }
 
         synchronized boolean frozen() {
             return frozenAt >= 0;
         }
 
-        /** The outcome, the completed count and whether it is frozen, read 
together (for the report). */
+        /** The outcome, the counted value and whether it is frozen, read 
together (for the report). */
         synchronized Reading read(long now) {
-            return new Reading(outcome(now), completed, frozenAt >= 0);
+            return new Reading(outcome(now), value, frozenAt >= 0);
+        }
+
+        /**
+         * The messages an unmet expectation counts as lost: only one of 
completed exchanges, at least or exactly that
+         * many, misses the ones that did not complete.
+         */
+        long lost(Reading reading) {
+            return reading.outcome() == Outcome.UNMET && kind == 
Kind.COMPLETED && op != Op.AT_MOST
+                    ? Math.max(0, count - reading.value()) : 0;
+        }
+
+        /** The expectation as the rule writes it, such as orders:100 or 
orders:handled<=0. */
+        String spec() {
+            return kind == Kind.COMPLETED && op == Op.AT_LEAST
+                    ? route + ":" + count : route + ":" + kind.key + op.key + 
count;
         }
 
-        /** What the report reads of an expectation at one time. */
-        record Reading(Outcome outcome, long completed, boolean frozen) {
+        /** What an expectation reads at one time. */
+        record Reading(Outcome outcome, long value, boolean frozen) {
         }
     }
 
@@ -513,6 +591,8 @@ final class ChaosRule {
         long untilCount;
         long untilDuration;
         String expectRoute;
+        Expectation.Kind expectKind = Expectation.Kind.COMPLETED;
+        Expectation.Op expectOp = Expectation.Op.AT_LEAST;
         long expectCount;
         long within;
         Action action;
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
index 335c7f7f0682..4f812b68e961 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
@@ -769,26 +769,65 @@ final class ChaosRuleParser {
     private static void expectation(ChaosRule.Builder b, String expect, String 
within, long maxDuration) {
         if (expect == null) {
             if (within != null) {
-                throw new IllegalArgumentException("within= is the time of an 
expectation, and needs expect=<route>:<count>");
+                throw new IllegalArgumentException(
+                        "within= is the time of an expectation, and needs 
expect=<route>:<count> (or"
+                                                   + " 
<route>:<kind><op><count>)");
             }
             return;
         }
         int colon = expect.lastIndexOf(':');
         if (colon <= 0 || colon == expect.length() - 1) {
-            throw new IllegalArgumentException("expect=" + expect + " is not 
<route>:<count>, such as expect=orders:100");
+            throw new IllegalArgumentException(
+                    "expect=" + expect + " is not <route>:<count> or 
<route>:<kind><op><count>, such as expect=orders:100"
+                                               + " or 
expect=orders:handled<=0");
         }
         b.expectRoute = expect.substring(0, colon);
         if (b.expectRoute.indexOf('*') >= 0) {
             throw new IllegalArgumentException(
                     "expect=" + expect + " needs the id of one route, not a 
pattern, such as expect=orders:100");
         }
-        String count = expect.substring(colon + 1);
+        String spec = expect.substring(colon + 1);
+        String count = spec;
+        if (!isDigits(spec)) {
+            // <kind><op><count>, such as handled<=0
+            int op = 0;
+            while (op < spec.length() && Character.isLetter(spec.charAt(op))) {
+                op++;
+            }
+            String kind = spec.substring(0, op);
+            b.expectKind = null;
+            for (ChaosRule.Expectation.Kind k : 
ChaosRule.Expectation.Kind.values()) {
+                if (k.key.equals(kind)) {
+                    b.expectKind = k;
+                }
+            }
+            if (b.expectKind == null) {
+                throw new IllegalArgumentException(
+                        "expect=" + expect + " counts " + (kind.isEmpty() ? 
"nothing" : kind)
+                                                   + ": the kinds are 
completed, handled, failed and redeliveries,"
+                                                   + " such as 
expect=orders:handled<=0");
+            }
+            String rest = spec.substring(op);
+            if (rest.startsWith(">=")) {
+                b.expectOp = ChaosRule.Expectation.Op.AT_LEAST;
+            } else if (rest.startsWith("<=")) {
+                b.expectOp = ChaosRule.Expectation.Op.AT_MOST;
+            } else if (rest.startsWith("=")) {
+                b.expectOp = ChaosRule.Expectation.Op.EXACTLY;
+            } else {
+                throw new IllegalArgumentException(
+                        "expect=" + expect + " needs >=, <= or = after " + 
kind + ", such as expect=orders:" + kind
+                                                   + "<=0");
+            }
+            count = rest.substring(b.expectOp.key.length());
+        }
         if (!isDigits(count) || count.length() > 18) {
             throw new IllegalArgumentException(
-                    "expect=" + expect + " needs a count that is a whole 
number of at most 18 digits after the colon");
+                    "expect=" + expect + " needs a count that is a whole 
number of at most 18 digits");
         }
         b.expectCount = Long.parseLong(count);
-        if (b.expectCount < 1) {
+        if (b.expectCount < 1 && b.expectOp == 
ChaosRule.Expectation.Op.AT_LEAST) {
+            // at least 0 is always met
             throw new IllegalArgumentException("expect=" + expect + " needs a 
count of 1 or more");
         }
         if (within != null) {
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosErrorHandlerExpectationTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosErrorHandlerExpectationTest.java
new file mode 100644
index 000000000000..371ede856b55
--- /dev/null
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosErrorHandlerExpectationTest.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.component.chaos;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Expectations on the error handler of a route: how many exchanges it handled 
(the dead letter channel), failed, or
+ * redelivered, at least, at most or exactly. The time of the expectations 
runs on a virtual clock (it is static, so
+ * this class runs alone).
+ */
+@Isolated
+class ChaosErrorHandlerExpectationTest extends ChaosTestSupport {
+
+    private FakeClock clock;
+
+    @Override
+    protected void setupResources() throws Exception {
+        super.setupResources();
+        clock = FakeClock.install();
+    }
+
+    @Override
+    protected void cleanupResources() throws Exception {
+        clock.close();
+        super.cleanupResources();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:orders").routeId("orders")
+                        
.errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(2).redeliveryDelay(0))
+                        .to("mock:stock");
+                
from("direct:plain").routeId("plain").errorHandler(noErrorHandler()).to("mock:stock");
+            }
+        };
+    }
+
+    private void send(int n) {
+        for (int i = 0; i < n; i++) {
+            template.send("direct:orders", e -> 
e.getMessage().setBody("order"));
+        }
+    }
+
+    private JsonObject expectation(String id) {
+        return (JsonObject) ruleReport(id).get("expectation");
+    }
+
+    @Test
+    void redeliveryRecoversEverythingSoNothingReachesTheDeadLetterChannel() {
+        String id = chaos().addRule("endpoint=mock:stock error rate=every:3 
expect=orders:handled<=0 within=1m");
+        send(5);
+
+        // nothing handled yet, but at most is decided when the time is up
+        assertThat(expectation(id).getString("outcome")).isEqualTo("pending");
+        clock.advance(60_001);
+
+        JsonObject e = expectation(id);
+        assertThat(e.getString("outcome")).isEqualTo("met");
+        assertThat(e.getString("kind")).isEqualTo("handled");
+        assertThat(e.getString("op")).isEqualTo("<=");
+        assertThat(e.getLong("value")).isZero();
+        assertThat(e.containsKey("completed")).isFalse();
+        assertThat(chaos().report().getVerdict()).isEqualTo(ChaosReport.PASS);
+    }
+
+    @Test
+    void anAtMostIsUnmetAsSoonAsTheCountGoesOver() {
+        String id = chaos().addRule("endpoint=mock:stock error 
expect=orders:handled<=0 within=1m");
+        send(1);
+
+        // decided before the time is up
+        JsonObject e = expectation(id);
+        assertThat(e.getString("outcome")).isEqualTo("unmet");
+        assertThat(e.getLong("value")).isEqualTo(1);
+        ChaosReport report = chaos().report();
+        assertThat(report.getVerdict()).isEqualTo(ChaosReport.FAIL);
+        assertThat(report.isFinal()).isTrue();
+        assertThat(report.getReasons()).containsExactly(
+                "route orders: 1 exchange handled by the error handler within 
1m0s, at most 0 expected (rule " + id
+                                                        + ")");
+        // the message reached the dead letter channel: not lost
+        assertThat(report.getLost()).isZero();
+        assertThat(report.toText()).contains("expect orders:handled<=0 within 
1m0s: unmet, 1 handled");
+    }
+
+    @Test
+    void anAtLeastOfHandledExchangesIsMetWhenReached() {
+        String id = chaos().addRule("endpoint=mock:stock error 
expect=orders:handled>=2 within=1m");
+        send(1);
+        assertThat(expectation(id).getString("outcome")).isEqualTo("pending");
+        send(1);
+        assertThat(expectation(id).getString("outcome")).isEqualTo("met");
+
+        String unmet = chaos().addRule("endpoint=nothing error 
expect=orders:handled>=5 within=1m");
+        clock.advance(60_001);
+        assertThat(expectation(unmet).getString("outcome")).isEqualTo("unmet");
+        assertThat(chaos().report().getReasons()).containsExactly(
+                "route orders: 0 exchanges handled by the error handler within 
1m0s, at least 5 expected (rule " + unmet
+                                                                  + ")");
+    }
+
+    @Test
+    void redeliveriesAreCounted() {
+        String id = chaos().addRule("endpoint=mock:stock error 
expect=orders:redeliveries<=3 within=1m");
+        send(1);
+        assertThat(expectation(id).getLong("value")).isEqualTo(2);
+        assertThat(expectation(id).getString("outcome")).isEqualTo("pending");
+        send(1);
+        assertThat(expectation(id).getString("outcome")).isEqualTo("unmet");
+        assertThat(chaos().report().getReasons()).singleElement().asString()
+                .isEqualTo("route orders: 4 redeliveries within 1m0s, at most 
3 expected (rule " + id + ")");
+    }
+
+    @Test
+    void failedExchangesAreCounted() {
+        String handled = chaos().addRule("endpoint=nothing error 
expect=orders:failed=0 within=1m");
+        String failed = chaos().addRule("endpoint=mock:stock error 
expect=plain:failed>=1 within=1m");
+        send(1);
+        template.send("direct:plain", e -> e.getMessage().setBody("x"));
+
+        assertThat(expectation(failed).getString("outcome")).isEqualTo("met");
+        clock.advance(60_001);
+        // the dead letter channel handled the failure of orders: not failed
+        assertThat(expectation(handled).getString("outcome")).isEqualTo("met");
+    }
+
+    @Test
+    void anExactCountIsDecidedWhenTheTimeIsUp() {
+        String id = chaos().addRule("endpoint=nothing error 
expect=orders:completed=2 within=1m");
+        send(2);
+        assertThat(expectation(id).getString("outcome")).isEqualTo("pending");
+        clock.advance(60_001);
+        assertThat(expectation(id).getString("outcome")).isEqualTo("met");
+
+        String over = chaos().addRule("endpoint=nothing error 
expect=orders:completed=1 within=1m");
+        send(2);
+        assertThat(expectation(over).getString("outcome")).isEqualTo("unmet");
+        // more completed than expected: nothing lost
+        assertThat(chaos().report().getLost()).isZero();
+
+        String under = chaos().addRule("endpoint=nothing error 
expect=orders:completed=5 within=1m");
+        send(3);
+        clock.advance(60_001);
+        // fewer completed than expected: the missing ones are lost, as for at 
least
+        assertThat(expectation(under).getString("outcome")).isEqualTo("unmet");
+        assertThat(chaos().report().getLost()).isEqualTo(2);
+    }
+
+    @Test
+    void aCountAloneIsAtLeastThatManyCompletedExchanges() {
+        String id = chaos().addRule("endpoint=nothing error expect=orders:2 
within=1m");
+        send(2);
+
+        JsonObject e = expectation(id);
+        assertThat(e.getString("outcome")).isEqualTo("met");
+        assertThat(e.getString("kind")).isEqualTo("completed");
+        assertThat(e.getString("op")).isEqualTo(">=");
+        assertThat(e.getLong("value")).isEqualTo(2);
+        assertThat(e.getLong("completed")).isEqualTo(2);
+        assertThat(chaos().report().toText()).contains("expect orders:2 within 
1m0s: met, 2 completed");
+    }
+}
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
index 8fa82cc9ff27..d631b6f7ca00 100644
--- 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
@@ -173,6 +173,27 @@ class ChaosRuleParserTest {
         assertThat(parse("endpoint=x error 
expect=a:b:7").expectRoute).isEqualTo("a:b");
     }
 
+    @Test
+    void expectationKindsAndOps() {
+        ChaosRule.Builder b = parse("endpoint=x error expect=orders:handled<=0 
within=1m");
+        assertThat(b.expectRoute).isEqualTo("orders");
+        assertThat(b.expectKind).isEqualTo(ChaosRule.Expectation.Kind.HANDLED);
+        assertThat(b.expectOp).isEqualTo(ChaosRule.Expectation.Op.AT_MOST);
+        assertThat(b.expectCount).isZero();
+        b = parse("endpoint=x error expect=a:b:redeliveries>=30");
+        assertThat(b.expectRoute).isEqualTo("a:b");
+        
assertThat(b.expectKind).isEqualTo(ChaosRule.Expectation.Kind.REDELIVERIES);
+        assertThat(b.expectOp).isEqualTo(ChaosRule.Expectation.Op.AT_LEAST);
+        b = parse("endpoint=x error expect=orders:failed=0");
+        assertThat(b.expectKind).isEqualTo(ChaosRule.Expectation.Kind.FAILED);
+        assertThat(b.expectOp).isEqualTo(ChaosRule.Expectation.Op.EXACTLY);
+        // a count alone is at least that many completed exchanges, as before
+        b = parse("endpoint=x error expect=orders:20");
+        
assertThat(b.expectKind).isEqualTo(ChaosRule.Expectation.Kind.COMPLETED);
+        assertThat(b.expectOp).isEqualTo(ChaosRule.Expectation.Op.AT_LEAST);
+        assertThat(parse("endpoint=x error 
expect=orders:completed>=20").expectCount).isEqualTo(20);
+    }
+
     @Test
     void quotedValuesAndEscapes() {
         assertThat(parse("endpoint=x error message='it is 
\\'down\\''").message).isEqualTo("it is 'down'");
@@ -300,6 +321,12 @@ class ChaosRuleParserTest {
             "endpoint=x error expect=:5|is not <route>:<count>",
             "endpoint=x error expect=orders:|is not <route>:<count>",
             "endpoint=x error expect=orders:0|needs a count of 1 or more",
+            "endpoint=x error expect=orders:completed>=0|needs a count of 1 or 
more",
+            "endpoint=x error expect=orders:lost<=0|counts lost: the kinds are 
completed, handled, failed and redeliveries",
+            "endpoint=x error expect=orders:<=0|counts nothing",
+            "endpoint=x error expect=orders:handled<0|needs >=, <= or = after 
handled",
+            "endpoint=x error expect=orders:handled<=x|needs a count that is a 
whole number",
+            "endpoint=x error expect=orders:handled<=|needs a count that is a 
whole number",
             "endpoint=x error expect=orders:5 within=0s|must be longer than 0",
             "endpoint=x error expect=orders:5 within=11m|within=11m is longer 
than camel.chaos.maxDuration",
             "endpoint=x error expect=orders:5 within=106751991167d|is longer 
than camel.chaos.maxDuration",
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
index a087d702b0fd..20a1ab0e7305 100644
--- 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
@@ -150,23 +150,23 @@ class ChaosRuleTest {
     @Test
     void expectationMetUnmetPending() {
         ChaosRule.Expectation met = new ChaosRule.Expectation("orders", 2, T0 
+ 100);
-        met.completeAt(T0 + 10);
+        met.recordAt(T0 + 10);
         assertThat(met.outcome(T0 + 10)).isEqualTo(Outcome.PENDING);
-        met.completeAt(T0 + 100);
+        met.recordAt(T0 + 100);
         assertThat(met.outcome(T0 + 1000)).isEqualTo(Outcome.MET);
 
         ChaosRule.Expectation unmet = new ChaosRule.Expectation("orders", 2, 
T0 + 100);
-        unmet.completeAt(T0 + 10);
+        unmet.recordAt(T0 + 10);
         // a completion after the deadline does not count
-        unmet.completeAt(T0 + 101);
-        assertThat(unmet.completed()).isEqualTo(1);
+        unmet.recordAt(T0 + 101);
+        assertThat(unmet.value()).isEqualTo(1);
         assertThat(unmet.outcome(T0 + 100)).isEqualTo(Outcome.PENDING);
         assertThat(unmet.outcome(T0 + 101)).isEqualTo(Outcome.UNMET);
 
         ChaosRule.Expectation removed = new ChaosRule.Expectation("orders", 2, 
T0 + 100);
         removed.freeze(T0 + 50);
-        removed.completeAt(T0 + 60);
-        assertThat(removed.completed()).isZero();
+        removed.recordAt(T0 + 60);
+        assertThat(removed.value()).isZero();
         // removed before it was decided: cancelled, never unmet
         assertThat(removed.outcome(T0 + 1000)).isEqualTo(Outcome.CANCELLED);
 
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
index 46456d806aee..5d01776fc7c0 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
@@ -323,9 +323,17 @@ public class ListChaos extends ProcessWatchCommand {
         if (!(rule.get("expectation") instanceof JsonObject e)) {
             return "";
         }
+        // orders:100 counts completed exchanges (at least that many); 
orders:handled<=0 names its kind and op
+        String kind = e.getStringOrDefault("kind", "completed");
+        String op = e.getStringOrDefault("op", ">=");
+        Object value = e.get("value") != null ? e.get("value") : 
e.get("completed");
         StringBuilder sb = new StringBuilder();
-        
sb.append(e.getString("route")).append(':').append(e.get("count")).append(' 
').append(e.getString("outcome"))
-                .append(" (").append(e.get("completed")).append(" completed");
+        sb.append(e.getString("route")).append(':');
+        if (!"completed".equals(kind) || !">=".equals(op)) {
+            sb.append(kind).append(op);
+        }
+        sb.append(e.get("count")).append(' ').append(e.getString("outcome"))
+                .append(" (").append(value).append(' ').append(kind);
         if (e.get("timeLeft") instanceof Number n) {
             sb.append(", 
").append(TimeUtils.printDuration(n.longValue())).append(" left");
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
index 04c7c5abe6ef..98866d1252d8 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
@@ -207,6 +207,21 @@ class ListChaosTest extends ProcessCommandTestSupport {
         assertThat(printer.getStderr()).contains("orders (pid " + TEST_PID + 
"): no chaos rules");
     }
 
+    @Test
+    void reportShowsTheKindAndOpOfAnExpectation() throws Exception {
+        JsonObject chaos = chaosOn();
+        JsonObject report = (JsonObject) chaos.get("report");
+        JsonObject rule = (JsonObject) ((JsonArray) 
report.getCollection("rules")).get(0);
+        rule.put("expectation", new JsonObject(
+                Map.of("route", "orders", "kind", "handled", "op", "<=", 
"count", 0, "within", 60000, "value", 3,
+                        "outcome", "unmet")));
+        writeStatus(chaos);
+
+        assertThat(run("--report")).isZero();
+
+        assertThat(printer.getStdout()).contains("orders:handled<=0 unmet (3 
handled)");
+    }
+
     @Test
     void chaosOff() throws Exception {
         writeStatus(new JsonObject(Map.of("enabled", false, "reason", "Chaos 
is off: it stays off with profile prod")));

Reply via email to