From e0f250950de3aa34ef7e0a57671bedc5bcf3adce Mon Sep 17 00:00:00 2001 From: Alexey Loubyansky Date: Thu, 12 Dec 2024 11:42:46 +0100 Subject: [PATCH] More efficient dependency tree conflict resolver --- .../runnerjar/OptionalDepsTest.java | 70 ++++++++++-- .../maven/DependencyTreeConflictResolver.java | 101 ++++++++++++++++++ .../IncubatingApplicationModelResolver.java | 58 ++++++---- .../maven/OrderedDependencyVisitor.java | 59 ++++++++-- .../maven/OrderedDependencyVisitorTest.java | 13 +++ 5 files changed, 262 insertions(+), 39 deletions(-) create mode 100644 independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DependencyTreeConflictResolver.java diff --git a/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/OptionalDepsTest.java b/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/OptionalDepsTest.java index 949b145da1b7b..2d64701a4593c 100644 --- a/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/OptionalDepsTest.java +++ b/core/deployment/src/test/java/io/quarkus/deployment/runnerjar/OptionalDepsTest.java @@ -5,14 +5,16 @@ import java.util.HashSet; import java.util.Set; +import org.eclipse.aether.util.artifact.JavaScopes; + import io.quarkus.bootstrap.model.ApplicationModel; import io.quarkus.bootstrap.resolver.TsArtifact; import io.quarkus.bootstrap.resolver.TsDependency; import io.quarkus.bootstrap.resolver.TsQuarkusExt; +import io.quarkus.maven.dependency.ArtifactCoords; import io.quarkus.maven.dependency.ArtifactDependency; import io.quarkus.maven.dependency.Dependency; import io.quarkus.maven.dependency.DependencyFlags; -import io.quarkus.maven.dependency.GACTV; public class OptionalDepsTest extends BootstrapFromOriginalJarTestBase { @@ -70,17 +72,73 @@ protected TsArtifact composeApplication() { @Override protected void assertAppModel(ApplicationModel model) throws Exception { final Set expected = new HashSet<>(); - expected.add(new ArtifactDependency(new GACTV("io.quarkus.bootstrap.test", "ext-a-deployment", "1"), "compile", + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-a", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.DIRECT, + DependencyFlags.OPTIONAL, + DependencyFlags.RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.RUNTIME_CP, + DependencyFlags.DEPLOYMENT_CP)); + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-a-dep", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.OPTIONAL, + DependencyFlags.RUNTIME_CP, + DependencyFlags.DEPLOYMENT_CP)); + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-a-deployment", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.OPTIONAL, + DependencyFlags.DEPLOYMENT_CP)); + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "app-optional-dep", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, DependencyFlags.OPTIONAL, + DependencyFlags.DIRECT, + DependencyFlags.RUNTIME_CP, DependencyFlags.DEPLOYMENT_CP)); - expected.add(new ArtifactDependency(new GACTV("io.quarkus.bootstrap.test", "ext-b-deployment-dep", "1"), "compile", + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-b", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.OPTIONAL, + DependencyFlags.RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.RUNTIME_CP, + DependencyFlags.DEPLOYMENT_CP)); + + expected.add(new ArtifactDependency(ArtifactCoords.jar( + TsArtifact.DEFAULT_GROUP_ID, "ext-b-deployment", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, DependencyFlags.OPTIONAL, DependencyFlags.DEPLOYMENT_CP)); - expected.add(new ArtifactDependency(new GACTV("io.quarkus.bootstrap.test", "ext-b-deployment", "1"), "compile", + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-b-deployment-dep", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, DependencyFlags.OPTIONAL, DependencyFlags.DEPLOYMENT_CP)); - expected.add(new ArtifactDependency(new GACTV("io.quarkus.bootstrap.test", "ext-d-deployment", "1"), "compile", + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-d", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.DIRECT, + DependencyFlags.RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT, + DependencyFlags.RUNTIME_CP, DependencyFlags.DEPLOYMENT_CP)); - assertEquals(expected, getDeploymentOnlyDeps(model)); + + expected.add(new ArtifactDependency( + ArtifactCoords.jar(TsArtifact.DEFAULT_GROUP_ID, "ext-d-deployment", TsArtifact.DEFAULT_VERSION), + JavaScopes.COMPILE, + DependencyFlags.DEPLOYMENT_CP)); + + assertEquals(expected, getDependenciesWithFlag(model, DependencyFlags.DEPLOYMENT_CP)); } } diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DependencyTreeConflictResolver.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DependencyTreeConflictResolver.java new file mode 100644 index 0000000000000..999b9fb3d6cb2 --- /dev/null +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DependencyTreeConflictResolver.java @@ -0,0 +1,101 @@ +package io.quarkus.bootstrap.resolver.maven; + +import static io.quarkus.bootstrap.util.DependencyUtils.getKey; +import static io.quarkus.bootstrap.util.DependencyUtils.hasWinner; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.aether.collection.DependencyGraphTransformationContext; +import org.eclipse.aether.graph.DefaultDependencyNode; +import org.eclipse.aether.graph.Dependency; +import org.eclipse.aether.graph.DependencyNode; +import org.eclipse.aether.util.artifact.JavaScopes; +import org.eclipse.aether.util.graph.transformer.ConflictResolver; + +import io.quarkus.maven.dependency.ArtifactKey; + +/** + * Dependency tree conflict resolver. + *

+ * The idea is to have a more efficient implementation than the + * {@link org.eclipse.aether.util.graph.transformer.ConflictIdSorter#transformGraph(DependencyNode, DependencyGraphTransformationContext)} + * for the use-cases the Quarkus deployment dependency resolver is designed for. + *

+ * Specifically, this conflict resolver does not properly handle version ranges, that are not expected to be present in the + * phase it used. + */ +class DependencyTreeConflictResolver { + + /** + * Resolves dependency version conflicts in the given dependency tree. + * + * @param root the root of the dependency tree + */ + static void resolveConflicts(DependencyNode root) { + new DependencyTreeConflictResolver(root).run(); + } + + final OrderedDependencyVisitor visitor; + + private DependencyTreeConflictResolver(DependencyNode root) { + visitor = new OrderedDependencyVisitor(root); + } + + private void run() { + visitor.next();// skip the root + final Map visited = new HashMap<>(); + while (visitor.hasNext()) { + var node = visitor.next(); + if (!hasWinner(node)) { + visited.compute(getKey(node.getArtifact()), this::resolveConflict); + } + } + } + + private VisitedDependency resolveConflict(ArtifactKey key, VisitedDependency prev) { + if (prev == null) { + return new VisitedDependency(visitor); + } + prev.resolveConflict(visitor); + return prev; + } + + private static class VisitedDependency { + final DependencyNode node; + final int subtreeIndex; + + private VisitedDependency(OrderedDependencyVisitor visitor) { + this.node = visitor.getCurrent(); + this.subtreeIndex = visitor.getSubtreeIndex(); + } + + private void resolveConflict(OrderedDependencyVisitor visitor) { + var otherNode = visitor.getCurrent(); + if (subtreeIndex != visitor.getSubtreeIndex()) { + final Dependency currentDep = node.getDependency(); + final Dependency otherDep = otherNode.getDependency(); + if (!currentDep.getScope().equals(otherDep.getScope()) + && getScopePriority(currentDep.getScope()) > getScopePriority(otherDep.getScope())) { + node.setScope(otherDep.getScope()); + } + if (currentDep.isOptional() && !otherDep.isOptional()) { + node.setOptional(false); + } + } + otherNode.setChildren(List.of()); + otherNode.setData(ConflictResolver.NODE_DATA_WINNER, new DefaultDependencyNode(node.getDependency())); + } + } + + private static int getScopePriority(String scope) { + return switch (scope) { + case JavaScopes.COMPILE -> 0; + case JavaScopes.RUNTIME -> 1; + case JavaScopes.PROVIDED -> 2; + case JavaScopes.TEST -> 3; + default -> 4; + }; + } +} diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/IncubatingApplicationModelResolver.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/IncubatingApplicationModelResolver.java index 23fac1e5f4725..faf14cf8dc61e 100644 --- a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/IncubatingApplicationModelResolver.java +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/IncubatingApplicationModelResolver.java @@ -25,12 +25,9 @@ import java.util.function.BiConsumer; import org.eclipse.aether.DefaultRepositorySystemSession; -import org.eclipse.aether.RepositoryException; -import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.collection.CollectRequest; import org.eclipse.aether.collection.DependencyCollectionException; -import org.eclipse.aether.collection.DependencyGraphTransformationContext; import org.eclipse.aether.collection.DependencySelector; import org.eclipse.aether.graph.DefaultDependencyNode; import org.eclipse.aether.graph.Dependency; @@ -43,7 +40,6 @@ import org.eclipse.aether.util.artifact.JavaScopes; import org.eclipse.aether.util.graph.manager.DependencyManagerUtils; import org.eclipse.aether.util.graph.selector.ExclusionDependencySelector; -import org.eclipse.aether.util.graph.transformer.ConflictIdSorter; import org.eclipse.aether.util.graph.transformer.ConflictResolver; import org.jboss.logging.Logger; @@ -251,7 +247,7 @@ public void resolve(CollectRequest collectRtDepsRequest) throws AppModelResolver if (!runtimeModelOnly) { injectDeploymentDeps(); } - root = normalize(resolver.getSession(), root); + DependencyTreeConflictResolver.resolveConflicts(root); populateModelBuilder(root); // clear the reloadable flags @@ -464,18 +460,6 @@ private void clearReloadableFlag(ResolvedDependencyBuilder dep) { } } - private static DependencyNode normalize(RepositorySystemSession session, DependencyNode root) - throws AppModelResolverException { - final DependencyGraphTransformationContext context = new SimpleDependencyGraphTransformationContext(session); - try { - // resolves version conflicts - root = new ConflictIdSorter().transformGraph(root, context); - return session.getDependencyGraphTransformer().transformGraph(root, context); - } catch (RepositoryException e) { - throw new AppModelResolverException("Failed to resolve dependency graph conflicts", e); - } - } - /** * Resolves a project's runtime dependencies. This is the first step in the Quarkus application model resolution. * These dependencies do not include Quarkus conditional dependencies. @@ -977,6 +961,8 @@ private void collectDeploymentDeps() { + "or the artifact does not have any dependencies while at least a dependency on the runtime artifact " + info.runtimeArtifact + " is expected"); } + ensureScopeAndOptionality(deploymentNode, runtimeNode.getDependency().getScope(), + runtimeNode.getDependency().isOptional()); replaceRuntimeExtensionNodes(deploymentNode); if (!presentInTargetGraph) { @@ -1058,9 +1044,13 @@ void activate() { return; } activated = true; + final AppDep parent = conditionalDep.parent; final DependencyNode originalNode = collectDependencies(conditionalDep.node.getArtifact(), - conditionalDep.parent.ext.exclusions, - conditionalDep.parent.node.getRepositories()); + parent.ext.exclusions, + parent.node.getRepositories()); + ensureScopeAndOptionality(originalNode, parent.ext.runtimeNode.getDependency().getScope(), + parent.ext.runtimeNode.getDependency().isOptional()); + final DefaultDependencyNode rtNode = (DefaultDependencyNode) conditionalDep.node; rtNode.setRepositories(originalNode.getRepositories()); // if this node has conditional dependencies on its own, they may have been activated by this time @@ -1077,10 +1067,10 @@ void activate() { visitRuntimeDeps(); conditionalDep.setFlags( (byte) (COLLECT_DEPLOYMENT_INJECTION_POINTS | (collectReloadableModules ? COLLECT_RELOADABLE_MODULES : 0))); - if (conditionalDep.parent.resolvedDep != null) { - conditionalDep.parent.resolvedDep.addDependency(conditionalDep.resolvedDep.getArtifactCoords()); + if (parent.resolvedDep != null) { + parent.resolvedDep.addDependency(conditionalDep.resolvedDep.getArtifactCoords()); } - conditionalDep.parent.ext.runtimeNode.getChildren().add(rtNode); + parent.ext.runtimeNode.getChildren().add(rtNode); } private void visitRuntimeDeps() { @@ -1103,6 +1093,30 @@ boolean isSatisfied() { } } + /** + * Makes sure the node's dependency scope and optionality (including its children) match the expected values. + * + * @param node dependency node + * @param scope expected scope + * @param optional expected optionality + */ + private static void ensureScopeAndOptionality(DependencyNode node, String scope, boolean optional) { + var dep = node.getDependency(); + if (optional == dep.isOptional() && scope.equals(dep.getScope())) { + return; + } + var visitor = new OrderedDependencyVisitor(node); + while (visitor.hasNext()) { + dep = visitor.next().getDependency(); + if (optional != dep.isOptional()) { + visitor.getCurrent().setOptional(optional); + } + if (!scope.equals(dep.getScope())) { + visitor.getCurrent().setScope(scope); + } + } + } + private static boolean isSameKey(Artifact a1, Artifact a2) { return a2.getArtifactId().equals(a1.getArtifactId()) && a2.getGroupId().equals(a1.getGroupId()) diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitor.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitor.java index ae23a0f8c98c6..4c221dc96e370 100644 --- a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitor.java +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitor.java @@ -13,8 +13,8 @@ */ class OrderedDependencyVisitor { - private final Deque> stack = new ArrayDeque<>(); - private List currentList; + private final Deque stack = new ArrayDeque<>(); + private DependencyList currentList; private int currentIndex = -1; private int currentDistance; private int totalOnCurrentDistance = 1; @@ -26,7 +26,7 @@ class OrderedDependencyVisitor { * @param root the root of the dependency tree */ OrderedDependencyVisitor(DependencyNode root) { - currentList = List.of(root); + currentList = new DependencyList(0, List.of(root)); } /** @@ -36,7 +36,7 @@ class OrderedDependencyVisitor { */ DependencyNode getCurrent() { ensureNonNegativeIndex(); - return currentList.get(currentIndex); + return currentList.deps.get(currentIndex); } /** @@ -62,8 +62,8 @@ private void ensureNonNegativeIndex() { */ boolean hasNext() { return !stack.isEmpty() - || currentIndex + 1 < currentList.size() - || !currentList.get(currentIndex).getChildren().isEmpty(); + || currentIndex + 1 < currentList.deps.size() + || !currentList.deps.get(currentIndex).getChildren().isEmpty(); } /** @@ -76,9 +76,9 @@ DependencyNode next() { throw new NoSuchElementException(); } if (currentIndex >= 0) { - var children = currentList.get(currentIndex).getChildren(); + var children = currentList.deps.get(currentIndex).getChildren(); if (!children.isEmpty()) { - stack.addLast(children); + stack.addLast(new DependencyList(getSubtreeIndexForChildren(), children)); totalOnNextDistance += children.size(); } if (--totalOnCurrentDistance == 0) { @@ -87,11 +87,33 @@ DependencyNode next() { totalOnNextDistance = 0; } } - if (++currentIndex == currentList.size()) { + if (++currentIndex == currentList.deps.size()) { currentList = stack.removeFirst(); currentIndex = 0; } - return currentList.get(currentIndex); + return currentList.deps.get(currentIndex); + } + + private int getSubtreeIndexForChildren() { + return currentDistance < 2 ? currentIndex + 1 : currentList.subtreeIndex; + } + + /** + * A dependency subtree index the current dependency belongs to. + * + *

+ * A dependency subtree index is an index of a direct dependency of the root of the dependency tree + * from which the dependency subtree originates. All the dependencies from a subtree that originates + * from a direct dependency of the root of the dependency tree will share the same subtree index. + * + *

+ * A dependency subtree index starts from {@code 1}. An exception is the root of the dependency tree, + * which will have the subtree index of {@code 0}. + * + * @return dependency subtree index the current dependency belongs to + */ + int getSubtreeIndex() { + return currentDistance == 0 ? 0 : (currentDistance < 2 ? currentIndex + 1 : currentList.subtreeIndex); } /** @@ -100,6 +122,21 @@ DependencyNode next() { * @param newNode dependency node that should replace the current one in the tree */ void replaceCurrent(DependencyNode newNode) { - currentList.set(currentIndex, newNode); + currentList.deps.set(currentIndex, newNode); + } + + /** + * A list of dependencies that are children of a {@link DependencyNode} + * that are associated with a dependency subtree index. + */ + private static class DependencyList { + + private final int subtreeIndex; + private final List deps; + + public DependencyList(int branchIndex, List deps) { + this.subtreeIndex = branchIndex; + this.deps = deps; + } } } diff --git a/independent-projects/bootstrap/maven-resolver/src/test/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitorTest.java b/independent-projects/bootstrap/maven-resolver/src/test/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitorTest.java index b77d7ef1d1932..300df5904403a 100644 --- a/independent-projects/bootstrap/maven-resolver/src/test/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitorTest.java +++ b/independent-projects/bootstrap/maven-resolver/src/test/java/io/quarkus/bootstrap/resolver/maven/OrderedDependencyVisitorTest.java @@ -59,77 +59,90 @@ public void main() { assertThat(visitor.next()).isSameAs(root); assertThat(visitor.getCurrent()).isSameAs(root); assertThat(visitor.getCurrentDistance()).isEqualTo(0); + assertThat(visitor.getSubtreeIndex()).isEqualTo(0); assertThat(visitor.hasNext()).isTrue(); // distance 1, colors assertThat(visitor.next()).isSameAs(colors); assertThat(visitor.getCurrent()).isSameAs(colors); assertThat(visitor.getCurrentDistance()).isEqualTo(1); + assertThat(visitor.getSubtreeIndex()).isEqualTo(1); assertThat(visitor.hasNext()).isTrue(); // distance 1, pets assertThat(visitor.next()).isSameAs(pets); assertThat(visitor.getCurrent()).isSameAs(pets); assertThat(visitor.getCurrentDistance()).isEqualTo(1); + assertThat(visitor.getSubtreeIndex()).isEqualTo(2); assertThat(visitor.hasNext()).isTrue(); // distance 1, trees assertThat(visitor.next()).isSameAs(trees); assertThat(visitor.getCurrent()).isSameAs(trees); assertThat(visitor.getCurrentDistance()).isEqualTo(1); + assertThat(visitor.getSubtreeIndex()).isEqualTo(3); assertThat(visitor.hasNext()).isTrue(); // distance 2, colors, red assertThat(visitor.next()).isSameAs(red); assertThat(visitor.getCurrent()).isSameAs(red); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(1); assertThat(visitor.hasNext()).isTrue(); // distance 2, colors, green assertThat(visitor.next()).isSameAs(green); assertThat(visitor.getCurrent()).isSameAs(green); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(1); assertThat(visitor.hasNext()).isTrue(); // distance 2, colors, blue assertThat(visitor.next()).isSameAs(blue); assertThat(visitor.getCurrent()).isSameAs(blue); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(1); assertThat(visitor.hasNext()).isTrue(); // distance 2, pets, dog assertThat(visitor.next()).isSameAs(dog); assertThat(visitor.getCurrent()).isSameAs(dog); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(2); assertThat(visitor.hasNext()).isTrue(); // distance 2, pets, cat assertThat(visitor.next()).isSameAs(cat); assertThat(visitor.getCurrent()).isSameAs(cat); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(2); assertThat(visitor.hasNext()).isTrue(); // distance 2, trees, pine assertThat(visitor.next()).isSameAs(pine); assertThat(visitor.getCurrent()).isSameAs(pine); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(3); assertThat(visitor.hasNext()).isTrue(); // replace the current node visitor.replaceCurrent(oak); assertThat(visitor.getCurrent()).isSameAs(oak); assertThat(visitor.getCurrentDistance()).isEqualTo(2); + assertThat(visitor.getSubtreeIndex()).isEqualTo(3); assertThat(visitor.hasNext()).isTrue(); // distance 3, pets, dog, puppy assertThat(visitor.next()).isSameAs(puppy); assertThat(visitor.getCurrent()).isSameAs(puppy); assertThat(visitor.getCurrentDistance()).isEqualTo(3); + assertThat(visitor.getSubtreeIndex()).isEqualTo(2); assertThat(visitor.hasNext()).isTrue(); // distance 3, trees, oak, acorn assertThat(visitor.next()).isSameAs(acorn); assertThat(visitor.getCurrent()).isSameAs(acorn); assertThat(visitor.getCurrentDistance()).isEqualTo(3); + assertThat(visitor.getSubtreeIndex()).isEqualTo(3); assertThat(visitor.hasNext()).isFalse(); }