diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactoryTest.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactoryTest.java index 3c991a98..e875c6f0 100644 --- a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactoryTest.java +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/graphbuilder/Java25ParserFactoryTest.java @@ -103,6 +103,22 @@ void versionedVariantCreatesWorkingJava25Parser() throws Exception { + parsed.get(0).getClass().getName()); } + @DisplayName("T2: versioned variant falls back when the Java 25 parser is unavailable") + @EnabledForJreRange(min = JRE.JAVA_25) + @Test + void versionedVariantFallsBackWhenJava25ParserIsUnavailable() throws Exception { + Class versionedFactory = loadVersionedFactoryWithoutJava25Parser(); + + Object result = assertDoesNotThrow( + () -> versionedFactory.getMethod("createJava25Parser").invoke(null), + "The versioned factory must contain parser linkage failures"); + + assertInstanceOf(Optional.class, result); + assertTrue( + ((Optional) result).isEmpty(), + "A missing Java25Parser must return an empty Optional so the caller can use its standard fallback"); + } + @DisplayName("T3: versioned and base variants expose identical public API (JEP 238 parity)") @EnabledForJreRange(min = JRE.JAVA_25) @Test @@ -122,6 +138,14 @@ void versionedVariantExposesIdenticalPublicApi() throws Exception { * inactive). */ private static Class loadVersionedFactory() throws Exception { + return loadVersionedFactory(false); + } + + private static Class loadVersionedFactoryWithoutJava25Parser() throws Exception { + return loadVersionedFactory(true); + } + + private static Class loadVersionedFactory(boolean hideJava25Parser) throws Exception { assumeTrue( Files.exists(VERSIONED_CLASSES_DIR.resolve(VERSIONED_CLASS_RELATIVE_PATH)), "Versioned factory class only exists when built on JDK 25+"); @@ -138,7 +162,15 @@ protected Class loadClass(String name, boolean resolve) throws ClassNotFoundE } }; URLClassLoader loader = - new URLClassLoader(new URL[] {VERSIONED_CLASSES_DIR.toUri().toURL()}, filteredParent); + new URLClassLoader(new URL[] {VERSIONED_CLASSES_DIR.toUri().toURL()}, filteredParent) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (hideJava25Parser && name.equals("org.openrewrite.java.Java25Parser")) { + throw new ClassNotFoundException(name); + } + return super.loadClass(name, resolve); + } + }; return Class.forName(FACTORY_FQN, false, loader); } diff --git a/cost-benefit-calculator/src/test/java/org/hjug/cbc/CycleRankerGraphGenerationTest.java b/cost-benefit-calculator/src/test/java/org/hjug/cbc/CycleRankerGraphGenerationTest.java index f743d0e4..02e1b6e5 100644 --- a/cost-benefit-calculator/src/test/java/org/hjug/cbc/CycleRankerGraphGenerationTest.java +++ b/cost-benefit-calculator/src/test/java/org/hjug/cbc/CycleRankerGraphGenerationTest.java @@ -9,6 +9,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; /** * Verifies {@link CycleRanker#generateClassReferencesGraph(boolean, String)} @@ -41,4 +44,46 @@ public void hello() {} assertNotNull(dto); assertTrue(dto.getClassReferencesGraph().containsVertex("com.example.Plain")); } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("generateClassReferencesGraph rejects a missing source directory") + void rejectsMissingSourceDirectory(String repositoryPath) { + CycleRanker ranker = new CycleRanker(repositoryPath, tempDir.toString()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> ranker.generateClassReferencesGraph(false, "src/test")); + + assertEquals("Source directory cannot be null or empty", exception.getMessage()); + assertNull(ranker.getCodebaseGraphDTO(), "A rejected request must not populate the graph DTO"); + } + + @ParameterizedTest(name = "excludeTests={0} should include custom test source={1}") + @CsvSource({"true, false", "false, true"}) + @DisplayName("generateClassReferencesGraph forwards test exclusion configuration") + void forwardsTestExclusionConfiguration(boolean excludeTests, boolean expectTestSource) throws IOException { + writeJava(tempDir.resolve("src/main/java/com/example/ProductionClass.java"), "com.example", "ProductionClass"); + writeJava(tempDir.resolve("custom-tests/com/example/FixtureClass.java"), "com.example", "FixtureClass"); + + CycleRanker ranker = new CycleRanker(tempDir.toString(), tempDir.toString()); + CodebaseGraphDTO dto = ranker.generateClassReferencesGraph(excludeTests, "custom-tests"); + + assertTrue(dto.getClassReferencesGraph().containsVertex("com.example.ProductionClass")); + assertEquals( + expectTestSource, + dto.getClassReferencesGraph().containsVertex("com.example.FixtureClass"), + "The custom test source directory must follow the caller's exclusion setting"); + assertSame(dto, ranker.getCodebaseGraphDTO(), "The generated DTO must remain available to cycle ranking"); + } + + private static void writeJava(Path path, String packageName, String className) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString( + path, + """ + package %s; + public class %s {} + """ + .formatted(packageName, className)); + } }