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 ba4901c4c6bb CAMEL-25429: camel-jbang - camel overview lists the error
handling apart from the flows (#27563)
ba4901c4c6bb is described below
commit ba4901c4c6bb54abab4681edf998fa3675de9706
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 8 15:50:43 2026 +0200
CAMEL-25429: camel-jbang - camel overview lists the error handling apart
from the flows (#27563)
camel overview listed a global error handler as a route that calls the
error route, such as "[email protected]:3 calls parked",
under Flows between routes. The flows now hold the calls only, and a new
Error handling section lists each global handler by what it is (dead
letter channel, onException with its exceptions, route configuration)
and where it is written, a route's own error handling, the routes the
failures go to, and the routes with no error handler, whose failures go
back to the caller. The same split is in the plain text the AI reads.
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../jbang/core/commands/ai/IntegrationSummary.java | 65 +++++++++-
.../jbang/core/commands/ai/JavaRouteReader.java | 26 +++-
.../dsl/jbang/core/commands/ai/ProjectRoutes.java | 133 +++++++++++++++++----
.../core/commands/ai/IntegrationSummaryTest.java | 82 +++++++++++++
4 files changed, 276 insertions(+), 30 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationSummary.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationSummary.java
index a9ca74ac783e..21cbac6b2aa1 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationSummary.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationSummary.java
@@ -450,9 +450,10 @@ public final class IntegrationSummary {
sb.append('\n');
}
- if (!overview.links().isEmpty()) {
+ List<Link> calls = overview.links().stream().filter(l ->
!isErrorPath(overview, l)).toList();
+ if (!calls.isEmpty()) {
sb.append("## Flows between routes\n\n");
- for (Link l : overview.links()) {
+ for (Link l : calls) {
sb.append("- `").append(l
.from()).append("`
").append(linkVerb(l.kind())).append(" `").append(l.to())
.append("` over `").append(l.endpoint()).append("`\n");
@@ -460,6 +461,14 @@ public final class IntegrationSummary {
sb.append('\n');
}
+ // how failures travel: not flows, so listed apart, by what handles
them
+ List<String> errorLines = errorHandlingLines(overview, true);
+ if (!errorLines.isEmpty()) {
+ sb.append("## Error handling\n\n");
+ errorLines.forEach(line -> sb.append("-
").append(line).append('\n'));
+ sb.append('\n');
+ }
+
if (!overview.systems().isEmpty()) {
sb.append("## External systems\n\n");
Map<String, Map<String, Set<String>[]>> byCategory = new
TreeMap<>();
@@ -623,6 +632,48 @@ public final class IntegrationSummary {
};
}
+ /** A link that carries a failure: sent to from an onException, a doCatch
or a dead letter channel. */
+ static boolean isErrorPath(Overview overview, Link l) {
+ if (l.onError()) {
+ return true;
+ }
+ Route from = overview.route(l.from());
+ return from != null && "errorHandler".equals(from.kind());
+ }
+
+ /**
+ * The error handling of the project, one line per handler: a global one
(onException, dead letter channel, route
+ * configuration) with where it is written, or a route's own, and the
routes its failures go to.
+ */
+ static List<String> errorHandlingLines(Overview overview, boolean
markdown) {
+ Map<String, Set<String>> targets = new LinkedHashMap<>();
+ for (Link l : overview.links()) {
+ if (isErrorPath(overview, l)) {
+ targets.computeIfAbsent(l.from(), k -> new
LinkedHashSet<>()).add(l.to());
+ }
+ }
+ String q = markdown ? "`" : "";
+ List<String> lines = new ArrayList<>();
+ targets.forEach((from, to) -> {
+ Route r = overview.route(from);
+ String what;
+ if (r != null && "errorHandler".equals(r.kind())) {
+ String label = r.description() != null ? r.description() :
"error handling";
+ what = label + " (global, " + r.file() + ":" + r.line() + ")";
+ } else {
+ what = q + from + q + " (its own error handling)";
+ }
+ lines.add(what + ": failures to " + String.join(", ",
to.stream().map(t -> q + t + q).toList()));
+ });
+ // a route with no error handler: the global one does not apply, its
failures go back to its caller
+ List<String> none =
overview.flows().stream().filter(Route::noErrorHandler).map(Route::key).toList();
+ if (!none.isEmpty()) {
+ lines.add("no error handler in " + String.join(", ",
none.stream().map(n -> q + n + q).toList())
+ + ": failures go back to the caller, whose error
handling acts");
+ }
+ return lines;
+ }
+
private static String linkVerb(String kind) {
return switch (kind) {
case "call" -> "calls";
@@ -737,13 +788,19 @@ public final class IntegrationSummary {
.append(e.route() != null ? " -> " + e.route() :
"").append('\n');
}
}
- if (!overview.links().isEmpty()) {
+ List<Link> callLinks = overview.links().stream().filter(l ->
!isErrorPath(overview, l)).toList();
+ if (!callLinks.isEmpty()) {
sb.append("\nFlows between routes:\n");
- for (Link l : overview.links()) {
+ for (Link l : callLinks) {
sb.append("- ").append(l.from()).append('
').append(linkVerb(l.kind())).append(' ').append(l.to())
.append('\n');
}
}
+ List<String> errorLines = errorHandlingLines(overview, false);
+ if (!errorLines.isEmpty()) {
+ sb.append("\nError handling:\n");
+ errorLines.forEach(line -> sb.append("-
").append(line).append('\n'));
+ }
if (!overview.systems().isEmpty()) {
sb.append("\nExternal systems:\n");
Set<String> seen = new LinkedHashSet<>();
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JavaRouteReader.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JavaRouteReader.java
index d0826e09eae3..e9b5817a45b3 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JavaRouteReader.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JavaRouteReader.java
@@ -55,6 +55,7 @@ import org.apache.camel.model.SwitchDefinition;
import org.apache.camel.model.ToDynamicDefinition;
import org.apache.camel.model.TryDefinition;
import org.apache.camel.model.errorhandler.DeadLetterChannelDefinition;
+import org.apache.camel.model.errorhandler.NoErrorHandlerDefinition;
import org.apache.camel.model.language.ConstantExpression;
import org.apache.camel.model.language.ExpressionDefinition;
import org.apache.camel.model.rest.RestDefinition;
@@ -150,10 +151,20 @@ final class JavaRouteReader {
for (ProcessorDefinition<?> p : r.getOutputs()) {
decisions(p, scope, decisions, visited, 0);
}
- routes.add(new Route(
+ Route route = new Route(
id, template != null ? "routeTemplate" : "route", description,
r.getGroup(), file, Math.max(1, line),
"java", partial, from, consumes, produces,
r.getOutputs().size(), -1, 0, null, null,
- logOnly(r, produces), note, decisions));
+ logOnly(r, produces), note, decisions);
+ // the route's own error handler, such as
errorHandler(noErrorHandler())
+ if (r.isErrorHandlerFactorySet()) {
+ ErrorHandlerFactory eh = r.getErrorHandlerFactory();
+ if (eh instanceof NoErrorHandlerDefinition) {
+ route = route.withErrorHandler("noErrorHandler");
+ } else if (eh instanceof DeadLetterChannelDefinition) {
+ route = route.withErrorHandler("deadLetterChannel");
+ }
+ }
+ routes.add(route);
}
/** The decision points below a step, with their paths (see {@link
RouteDecisions}). */
@@ -304,10 +315,15 @@ final class JavaRouteReader {
/** onException and dead letter channels outside a route, as one error
handler route of the file. */
private void errorHandlers(JavaParseResult result) {
List<Endpoint> produces = new ArrayList<>();
+ List<String> labels = new ArrayList<>();
int line = Integer.MAX_VALUE;
Set<Object> seen = Collections.newSetFromMap(new IdentityHashMap<>());
for (OnExceptionDefinition oe : result.routes().getOnExceptions()) {
walk(oe, true, produces, new ArrayList<>(), seen, 0);
+ labels.add(oe.getExceptions().isEmpty()
+ ? "onException"
+ : "onException " + String.join(", ",
+
oe.getExceptions().stream().map(ProjectRoutes::simpleName).toList()));
if (oe.getLineNumber() > 0) {
line = Math.min(line, oe.getLineNumber());
}
@@ -316,13 +332,17 @@ final class JavaRouteReader {
ErrorHandlerFactory eh = r.getErrorHandlerFactory();
if (eh instanceof DeadLetterChannelDefinition dlc &&
dlc.getDeadLetterUri() != null) {
add(produces, ProjectRoutes.endpoint(dlc.getDeadLetterUri(),
null, false, catalog).asOnError());
+ if (!labels.contains("dead letter channel")) {
+ labels.add("dead letter channel");
+ }
line = Math.min(line, Math.max(1, r.getLineNumber()));
}
}
if (!produces.isEmpty()) {
int at = line == Integer.MAX_VALUE ? 1 : line;
routes.add(new Route(
- ProjectRoutes.ERROR_HANDLER_PREFIX + file + ":" + at,
"errorHandler", null, null, file, at, "java",
+ ProjectRoutes.ERROR_HANDLER_PREFIX + file + ":" + at,
"errorHandler",
+ labels.isEmpty() ? null : String.join("; ", labels), null,
file, at, "java",
false, null, List.of(),
produces.stream().map(Endpoint::asOnError).toList(), 0, -1, 0, null, null,
false, null));
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java
index 5bd573654a33..3cbab6597102 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ProjectRoutes.java
@@ -75,6 +75,24 @@ public final class ProjectRoutes {
private static final Set<String> STEP_CONTAINERS = Set.of("steps", "when",
"doCatch", "doFinally");
private static final int MAX_DEPTH = 64;
private static final int MAX_PATH = 60;
+
+ /** The simple name of a class: IllegalStateException for
java.lang.IllegalStateException. */
+ static String simpleName(String className) {
+ String n = className.trim();
+ int dot = n.lastIndexOf('.');
+ return dot >= 0 ? n.substring(dot + 1) : n;
+ }
+
+ /** An error handler kind in words: a dead letter channel, the default
error handler, no error handler. */
+ static String errorHandlerKind(String name) {
+ return switch (name) {
+ case "deadLetterChannel" -> "dead letter channel";
+ case "defaultErrorHandler" -> "default error handler";
+ case "noErrorHandler" -> "no error handler";
+ default -> name;
+ };
+ }
+
/** The id given to an onException or error handler outside a route,
followed by where it is written. */
public static final String ERROR_HANDLER_PREFIX = "error-handler@";
@@ -97,29 +115,53 @@ public final class ProjectRoutes {
/**
* A route of the project.
*
- * @param id the route id, null when the source gives none
- * @param kind route, routeTemplate, templatedRoute, rest, or
errorHandler (an onException or error handler
- * outside a route, with the endpoints it sends failed
messages to)
- * @param file the file, relative to the project directory
- * @param line the 1-based line the route starts at
- * @param format yaml, xml or java
- * @param heuristic whether the route was found by pattern matching (Java
DSL) and may be incomplete
- * @param from what the route consumes from, null for a REST
operation without a route
- * @param consumes every endpoint the route consumes: its from, what it
polls, and the kamelet name of a template
- * @param produces the endpoints the route sends to, in order
- * @param steps roughly how many steps the route has
- * @param insertAt where a description line can be inserted (YAML), or -1
- * @param indent the indentation of that line (YAML)
- * @param restVerb the HTTP verb of a REST operation
- * @param restPath the path of a REST operation
- * @param logOnly whether every step of the route only logs: plumbing,
not business logic
- * @param note the route's note: a longer explanation beside the
short description, may be null
- * @param decisions the decision points of the route (choice, filter,
split, ...), in route order
+ * @param id the route id, null when the source gives none
+ * @param kind route, routeTemplate, templatedRoute, rest, or
errorHandler (an onException or error handler
+ * outside a route, with the endpoints it sends failed
messages to)
+ * @param file the file, relative to the project directory
+ * @param line the 1-based line the route starts at
+ * @param format yaml, xml or java
+ * @param heuristic whether the route was found by pattern matching
(Java DSL) and may be incomplete
+ * @param from what the route consumes from, null for a REST
operation without a route
+ * @param consumes every endpoint the route consumes: its from, what
it polls, and the kamelet name of a
+ * template
+ * @param produces the endpoints the route sends to, in order
+ * @param steps roughly how many steps the route has
+ * @param insertAt where a description line can be inserted (YAML), or
-1
+ * @param indent the indentation of that line (YAML)
+ * @param restVerb the HTTP verb of a REST operation
+ * @param restPath the path of a REST operation
+ * @param logOnly whether every step of the route only logs:
plumbing, not business logic
+ * @param note the route's note: a longer explanation beside the
short description, may be null
+ * @param decisions the decision points of the route (choice, filter,
split, ...), in route order
+ * @param errorHandler the route's own error handler (deadLetterChannel,
defaultErrorHandler, noErrorHandler, or a
+ * reference), null when the route uses the global one
*/
public record Route(String id, String kind, String description, String
group, String file, int line, String format,
boolean heuristic, Endpoint from, List<Endpoint> consumes,
List<Endpoint> produces, int steps,
int insertAt, int indent, String restVerb, String restPath,
boolean logOnly, String note,
- List<RouteDecisions.DecisionPoint> decisions) {
+ List<RouteDecisions.DecisionPoint> decisions, String errorHandler)
{
+
+ /** A route that uses the global error handler. */
+ public Route(String id, String kind, String description, String group,
String file, int line, String format,
+ boolean heuristic, Endpoint from, List<Endpoint>
consumes, List<Endpoint> produces, int steps,
+ int insertAt, int indent, String restVerb, String
restPath, boolean logOnly, String note,
+ List<RouteDecisions.DecisionPoint> decisions) {
+ this(id, kind, description, group, file, line, format, heuristic,
from, consumes, produces, steps, insertAt,
+ indent, restVerb, restPath, logOnly, note, decisions, null);
+ }
+
+ /** The same route with its own error handler. */
+ public Route withErrorHandler(String errorHandler) {
+ return new Route(
+ id, kind, description, group, file, line, format,
heuristic, from, consumes, produces, steps,
+ insertAt, indent, restVerb, restPath, logOnly, note,
decisions, errorHandler);
+ }
+
+ /** Whether the route has no error handler: its failures go back to
its caller. */
+ public boolean noErrorHandler() {
+ return "noErrorHandler".equals(errorHandler);
+ }
/** A route without decision points. */
public Route(String id, String kind, String description, String group,
String file, int line, String format,
@@ -429,7 +471,8 @@ public final class ProjectRoutes {
rest(m, line);
}
}
- case "onException", "errorHandler", "routeConfiguration",
"route-configuration" -> errorHandler(value, line);
+ case "onException", "errorHandler", "routeConfiguration",
"route-configuration" ->
+ errorHandler(key, value, line);
default -> {
// beans, restConfiguration and friends are not routes
}
@@ -482,6 +525,10 @@ public final class ProjectRoutes {
false, from, consumes, produces, steps[0], insertAt,
indent, null, null,
logOnly(fromNode instanceof MappingNode fm2 ? child(fm2,
"steps") : child(m, "steps")),
str(value(m, "note")), decisions);
+ // the route's own error handler, such as noErrorHandler
+ if (child(m, "errorHandler") instanceof MappingNode em &&
!em.getValue().isEmpty()) {
+ r =
r.withErrorHandler(scalar(em.getValue().get(0).getKeyNode()));
+ }
routes.add(r);
return r;
}
@@ -555,16 +602,45 @@ public final class ProjectRoutes {
}
/** Where an onException or error handler sends failed messages, as a
route that only error handling uses. */
- private void errorHandler(org.yaml.snakeyaml.nodes.Node value, int
line) {
+ private void errorHandler(String key, org.yaml.snakeyaml.nodes.Node
value, int line) {
List<Endpoint> produces = new ArrayList<>();
walk(value, produces, new ArrayList<>(), new int[] { 0 }, 0, true);
if (!produces.isEmpty()) {
routes.add(new Route(
- ERROR_HANDLER_PREFIX + file + ":" + line,
"errorHandler", null, null, file, line, "yaml",
+ ERROR_HANDLER_PREFIX + file + ":" + line,
"errorHandler", errorHandlerLabel(key, value), null,
+ file, line, "yaml",
false, null, List.of(), produces, 0, -1, 0, null,
null, false, null));
}
}
+ /**
+ * What a global error handler is, in words: onException with its
exceptions, a dead letter channel, or a route
+ * configuration.
+ */
+ private static String errorHandlerLabel(String key,
org.yaml.snakeyaml.nodes.Node value) {
+ if ("onException".equals(key)) {
+ List<String> exceptions = new ArrayList<>();
+ if (value instanceof MappingNode m) {
+ for (NodeTuple t : m.getValue()) {
+ if (t.getKeyNode() instanceof ScalarNode k &&
"exception".equals(k.getValue())
+ && t.getValueNode() instanceof SequenceNode
seq) {
+ for (org.yaml.snakeyaml.nodes.Node n :
seq.getValue()) {
+ if (n instanceof ScalarNode sn) {
+ exceptions.add(simpleName(sn.getValue()));
+ }
+ }
+ }
+ }
+ }
+ return exceptions.isEmpty() ? "onException" : "onException " +
String.join(", ", exceptions);
+ }
+ if ("errorHandler".equals(key) && value instanceof MappingNode m
&& !m.getValue().isEmpty()
+ && m.getValue().get(0).getKeyNode() instanceof ScalarNode
k) {
+ return errorHandlerKind(k.getValue());
+ }
+ return "route configuration";
+ }
+
/** Whether every top-level step only logs: a log EIP, or a to or
wireTap of a log endpoint. */
private boolean logOnly(org.yaml.snakeyaml.nodes.Node steps) {
if (!(steps instanceof SequenceNode seq) ||
seq.getValue().isEmpty()) {
@@ -935,6 +1011,17 @@ public final class ProjectRoutes {
private void errorHandler(Element e) {
List<Endpoint> produces = new ArrayList<>();
+ String label;
+ if ("onException".equals(localName(e))) {
+ List<String> exceptions = new ArrayList<>();
+ NodeList ex = e.getElementsByTagNameNS("*", "exception");
+ for (int i = 0; i < ex.getLength(); i++) {
+ exceptions.add(simpleName(ex.item(i).getTextContent()));
+ }
+ label = exceptions.isEmpty() ? "onException" : "onException "
+ String.join(", ", exceptions);
+ } else {
+ label = attr(e, "deadLetterUri") != null ? "dead letter
channel" : "error handler";
+ }
if (attr(e, "deadLetterUri") != null) {
add(produces, endpoint(attr(e, "deadLetterUri"), null, false,
catalog));
}
@@ -950,7 +1037,7 @@ public final class ProjectRoutes {
if (!produces.isEmpty()) {
int line = lineOf(localName(e), null);
routes.add(new Route(
- ERROR_HANDLER_PREFIX + file + ":" + line,
"errorHandler", null, null, file, line, "xml", false,
+ ERROR_HANDLER_PREFIX + file + ":" + line,
"errorHandler", label, null, file, line, "xml", false,
null, List.of(),
produces.stream().map(Endpoint::asOnError).toList(), 0, -1, 0, null, null,
false, null));
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationSummaryTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationSummaryTest.java
index ce427be76644..0bcb9f6bc50b 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationSummaryTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IntegrationSummaryTest.java
@@ -188,6 +188,88 @@ class IntegrationSummaryTest {
assertThat(back.descriptions()).isEqualTo(ai.descriptions());
}
+ @Test
+ void errorHandlingIsNotAFlow() {
+ // fail-well/error-handling: a global dead letter channel and
onException to parked, and payment-provider with
+ // no error handler
+ String yaml = """
+ - errorHandler:
+ deadLetterChannel:
+ deadLetterUri: direct:parked
+ - onException:
+ exception:
+ - java.lang.IllegalStateException
+ handled:
+ constant: "true"
+ steps:
+ - to:
+ uri: direct:parked
+ - route:
+ id: checkout
+ from:
+ uri: file:orders
+ steps:
+ - to:
+ uri: direct:charge
+ - route:
+ id: payment-provider
+ errorHandler:
+ noErrorHandler: {}
+ from:
+ uri: direct:charge
+ steps:
+ - log:
+ message: charging
+ - route:
+ id: parked
+ from:
+ uri: direct:parked
+ steps:
+ - to:
+ uri: file:parked
+ """;
+ Overview o = ProjectOverview.analyze(
+ Path.of("shop"), Map.of("error-handling.camel.yaml", yaml),
ProjectOverviewTest.CATALOG);
+ String md = IntegrationSummary.render(o, null, null, null,
"2026-10-08");
+
+ String flows = md.substring(md.indexOf("## Flows between routes"),
md.indexOf("## Error handling"));
+ assertThat(flows).contains("`checkout` calls
`payment-provider`").doesNotContain("parked")
+ .doesNotContain("error-handler@");
+ assertThat(md).contains("## Error handling")
+ .contains("- dead letter channel (global,
error-handling.camel.yaml:1): failures to `parked`")
+ .contains("- onException IllegalStateException (global,
error-handling.camel.yaml:4): failures to `parked`")
+ .contains("- no error handler in `payment-provider`: failures
go back to the caller");
+ }
+
+ @Test
+ void errorHandlingIsNotAFlowInJava() {
+ String java = """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class Shop extends RouteBuilder {
+ public void configure() {
+
onException(IllegalStateException.class).handled(true).to("direct:parked");
+
+ from("file:orders").routeId("checkout")
+ .to("direct:charge");
+
+
from("direct:charge").routeId("payment-provider").errorHandler(noErrorHandler())
+ .log("charging");
+
+ from("direct:parked").routeId("parked")
+ .to("file:parked");
+ }
+ }
+ """;
+ Overview o = ProjectOverview.analyze(
+ Path.of("shop"), Map.of("src/main/java/Shop.java", java),
ProjectOverviewTest.CATALOG);
+ String md = IntegrationSummary.render(o, null, null, null,
"2026-10-08");
+
+ assertThat(md).contains("`checkout` calls `payment-provider`")
+ .contains("- onException IllegalStateException (global,
src/main/java/Shop.java:")
+ .contains("- no error handler in `payment-provider`: failures
go back to the caller");
+ }
+
@Test
void factsOnlySummary() {
Overview o = ProjectOverviewTest.overview();