Merge pull request #35 from fernando88to/main

Go To Test: reach tests of sibling artefacts kept in another package
diff --git a/plugin/src/main/java/org/apache/grails/intellij/plugin/editor/toolbar/GoToTestAction.java b/plugin/src/main/java/org/apache/grails/intellij/plugin/editor/toolbar/GoToTestAction.java
index 39d4b45..98944f8 100644
--- a/plugin/src/main/java/org/apache/grails/intellij/plugin/editor/toolbar/GoToTestAction.java
+++ b/plugin/src/main/java/org/apache/grails/intellij/plugin/editor/toolbar/GoToTestAction.java
@@ -55,9 +55,11 @@
     List<VirtualFile> result = new ArrayList<>();
 
     for (GrailsArtifact artefactType : GrailsEditorToolbar.DECORATED_ARTEFACT_TYPES) {
-      for (GrClassDefinition artifact : artefactType.getInstances(artefactData.getModule(),
-                                                                 artefactData.getPackageName(),
-                                                                 artefactData.getArtefactName())) {
+      // Same package-preferring lookup the Go To <artefact> actions use, so the tests of a sibling
+      // artefact kept in another package stay reachable.
+      for (GrClassDefinition artifact : artefactType.getInstancesPreferringPackage(artefactData.getModule(),
+                                                                                  artefactData.getPackageName(),
+                                                                                  artefactData.getArtefactName())) {
         for (PsiClass testClass : GrailsTestUtils.getTestsForArtifact(artifact, true)) {
           ContainerUtil.addIfNotNull(result, testClass.getContainingFile().getVirtualFile());
         }
diff --git a/plugin/src/main/java/org/apache/grails/intellij/plugin/editor/toolbar/GrailsGoToArtefactActionBase.java b/plugin/src/main/java/org/apache/grails/intellij/plugin/editor/toolbar/GrailsGoToArtefactActionBase.java
index 8cfe12c..ca23441 100644
--- a/plugin/src/main/java/org/apache/grails/intellij/plugin/editor/toolbar/GrailsGoToArtefactActionBase.java
+++ b/plugin/src/main/java/org/apache/grails/intellij/plugin/editor/toolbar/GrailsGoToArtefactActionBase.java
@@ -51,14 +51,9 @@
 
   @Override
   public @NotNull Collection<GrClassDefinition> getNavigateTargets(@NotNull ArtefactData artefactData) {
-    // Prefer an artefact in the same package as the current one, but fall back to matching by name
-    // alone: in multi-project builds a shared artefact (e.g. a domain in an upstream project) often
-    // lives in a different package than the controller/service that uses it.
-    Collection<GrClassDefinition> samePackage =
-      myArtefactType.getInstances(artefactData.getModule(), artefactData.getPackageName(), artefactData.getArtefactName());
-    return samePackage.isEmpty()
-           ? myArtefactType.getInstances(artefactData.getModule(), artefactData.getArtefactName())
-           : samePackage;
+    return myArtefactType.getInstancesPreferringPackage(artefactData.getModule(),
+                                                        artefactData.getPackageName(),
+                                                        artefactData.getArtefactName());
   }
 
   @Override
diff --git a/plugin/src/main/java/org/apache/grails/intellij/plugin/util/GrailsArtifact.java b/plugin/src/main/java/org/apache/grails/intellij/plugin/util/GrailsArtifact.java
index ce96cca..cb5ae3c 100644
--- a/plugin/src/main/java/org/apache/grails/intellij/plugin/util/GrailsArtifact.java
+++ b/plugin/src/main/java/org/apache/grails/intellij/plugin/util/GrailsArtifact.java
@@ -279,6 +279,23 @@
            });
   }
 
+  /**
+   * The artefacts named {@code artefactName} that the "Go To &lt;artefact&gt;" actions navigate to:
+   * the ones in {@code packageName} when there are any, otherwise every artefact with that name
+   * regardless of package.
+   *
+   * <p>Co-locating the artefacts of one concept is a convention, not a rule - a domain shared by an
+   * upstream project, or a controller kept under a {@code web} package, would otherwise be
+   * unreachable from the toolbar. Every caller must share this policy, so navigation from an
+   * artefact does not depend on which action is used.
+   */
+  public @NotNull Collection<GrClassDefinition> getInstancesPreferringPackage(@NotNull Module module,
+                                                                             @Nullable String packageName,
+                                                                             @NotNull String artefactName) {
+    Collection<GrClassDefinition> samePackage = getInstances(module, packageName, artefactName);
+    return samePackage.isEmpty() ? getInstances(module, artefactName) : samePackage;
+  }
+
   private GrailsArtifactCache getCache(final @NotNull Module module) {
     final Project project = module.getProject();
     return CachedValuesManager.getManager(project).getCachedValue(module, cacheKey, () -> {
diff --git a/plugin/src/test/java/org/apache/grails/intellij/plugin/action/GoToTestActionTest.java b/plugin/src/test/java/org/apache/grails/intellij/plugin/action/GoToTestActionTest.java
new file mode 100644
index 0000000..2504686
--- /dev/null
+++ b/plugin/src/test/java/org/apache/grails/intellij/plugin/action/GoToTestActionTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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
+ *
+ *   https://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.grails.intellij.plugin.action;
+
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.PsiFile;
+import org.apache.grails.intellij.lib.testFramework.GrailsTestCase;
+import org.apache.grails.intellij.plugin.actions.ArtefactData;
+import org.apache.grails.intellij.plugin.editor.toolbar.GoToTestAction;
+import org.apache.grails.intellij.plugin.structure.GrailsApplication;
+import org.apache.grails.intellij.plugin.structure.GrailsApplicationManager;
+import org.apache.grails.intellij.plugin.util.GrailsArtifact;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * "Go To Test" collects the tests of every artefact that shares the current artefact's name, and used
+ * to require them to share its package too - so a domain kept under {@code model} was invisible from a
+ * controller under {@code web}, even though "Go To Domain" reaches it.
+ */
+public class GoToTestActionTest extends GrailsTestCase {
+
+  private void addProjectWithSplitPackages() {
+    addDomain("""
+
+package com.example.model
+
+class Book {
+  String title
+}
+""");
+    addController("""
+
+package com.example.web
+
+class BookController {
+  def index() {}
+}
+""");
+    myFixture.addFileToProject("test/unit/com/example/model/BookSpec.groovy", """
+
+package com.example.model
+
+class BookSpec {
+}
+""");
+    myFixture.addFileToProject("test/unit/com/example/web/BookControllerSpec.groovy", """
+
+package com.example.web
+
+class BookControllerSpec {
+}
+""");
+  }
+
+  private @NotNull ArtefactData artefactDataForController(@NotNull PsiFile controller) {
+    GrailsApplication application = GrailsApplicationManager.findApplication(controller);
+    assertNotNull("Grails application not found for " + controller.getName(), application);
+
+    VirtualFile file = controller.getVirtualFile();
+    assertNotNull(file);
+
+    return new ArtefactData(getProject(), getModule(), file, "com.example.web", "book", application, false);
+  }
+
+  private static @NotNull List<String> fileNames(@NotNull Collection<VirtualFile> files) {
+    List<String> names = new ArrayList<>();
+    for (VirtualFile file : files) names.add(file.getName());
+    return names;
+  }
+
+  /** The reported gap: the sibling artefact lives in another package, so its spec was dropped. */
+  public void testCollectsTestsOfSiblingArtefactInAnotherPackage() {
+    addProjectWithSplitPackages();
+    PsiFile controller = addController("""
+
+package com.example.web
+
+class OtherController {
+  def index() {}
+}
+""");
+
+    List<String> targets = fileNames(new GoToTestAction().getNavigateTargets(artefactDataForController(controller)));
+
+    assertTrue("the controller's own spec must be offered, got " + targets, targets.contains("BookControllerSpec.groovy"));
+    assertTrue("the domain's spec must be offered even from another package, got " + targets,
+               targets.contains("BookSpec.groovy"));
+  }
+
+  /** What made the tests unreachable: the strict lookup finds nothing outside the current package. */
+  public void testStrictLookupFindsNothingAcrossPackages() {
+    addProjectWithSplitPackages();
+
+    assertTrue("no domain named Book exists in com.example.web",
+               GrailsArtifact.DOMAIN.getInstances(getModule(), "com.example.web", "book").isEmpty());
+    assertFalse("the package-preferring lookup must reach com.example.model.Book",
+                GrailsArtifact.DOMAIN.getInstancesPreferringPackage(getModule(), "com.example.web", "book").isEmpty());
+  }
+
+  /** An artefact in the current package still wins, so co-located projects keep the narrower result. */
+  public void testSamePackageWins() {
+    addDomain("""
+
+package com.example.model
+
+class Book {
+  String title
+}
+""");
+    addDomain("""
+
+package com.example.web
+
+class Book {
+  String title
+}
+""");
+
+    Collection<?> targets = GrailsArtifact.DOMAIN.getInstancesPreferringPackage(getModule(), "com.example.web", "book");
+    assertEquals("only the same-package domain must be offered, got " + targets, 1, targets.size());
+  }
+}
\ No newline at end of file