MiningCandidateTestGenerator.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.mining.core.candidate;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Auto-generates lightweight JUnit 5 test classes for {@link MiningCandidate}
* instances.
*
* <p>The generated tests use {@code HintFileParser} and
* {@code BatchTransformationProcessor} to verify:</p>
* <ol>
* <li>The DSL rule parses successfully</li>
* <li>The {@code beforeExample} matches the rule</li>
* <li>The matched replacement equals {@code afterExample} (if provided)</li>
* <li>The {@code negativeExample} does NOT match the rule</li>
* </ol>
*
* <p>Generated tests are placed in:</p>
* <pre>
* sandbox_common_core/src/test/java/org/sandbox/jdt/triggerpattern/generated/
* </pre>
*/
public class MiningCandidateTestGenerator {
private static final String GENERATED_PACKAGE = "org.sandbox.jdt.triggerpattern.generated"; //$NON-NLS-1$
/**
* Generates a JUnit 5 test class for the given candidate and writes it to
* the specified output directory.
*
* @param candidate the mining candidate to generate a test for
* @param outputDir the directory to write the generated test to
* (e.g. {@code sandbox_common_core/src/test/java/org/sandbox/jdt/triggerpattern/generated})
* @return the path to the generated test file
* @throws IOException if the file cannot be written
*/
public Path generateTest(MiningCandidate candidate, Path outputDir) throws IOException {
Files.createDirectories(outputDir);
String className = buildClassName(candidate);
String source = buildTestSource(candidate, className);
Path outputFile = outputDir.resolve(className + ".java"); //$NON-NLS-1$
Files.writeString(outputFile, source, StandardCharsets.UTF_8);
return outputFile;
}
/**
* Builds a valid Java class name from the candidate's commit hash and category.
*
* @param candidate the candidate
* @return a valid Java class name
*/
static String buildClassName(MiningCandidate candidate) {
String hash = (candidate.getSourceCommit() != null && !candidate.getSourceCommit().isBlank())
? candidate.getSourceCommit().substring(0, Math.min(7, candidate.getSourceCommit().length()))
: "unknown"; //$NON-NLS-1$
String category = (candidate.getCategory() != null && !candidate.getCategory().isBlank())
? candidate.getCategory().replaceAll("[^a-zA-Z0-9]", "_") //$NON-NLS-1$ //$NON-NLS-2$
: "unknown"; //$NON-NLS-1$
// Capitalize first letter of category
if (!category.isEmpty()) {
category = Character.toUpperCase(category.charAt(0)) + category.substring(1);
}
return "Generated_" + hash + "_" + category + "_CandidateTest"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
}
/**
* Builds the Java source code for the generated test class.
*
* @param candidate the candidate
* @param className the class name
* @return the Java source code
*/
private String buildTestSource(MiningCandidate candidate, String className) {
String dslRule = candidate.getDslRule() != null ? candidate.getDslRule() : ""; //$NON-NLS-1$
String beforeExample = candidate.getBeforeExample() != null ? candidate.getBeforeExample() : ""; //$NON-NLS-1$
String afterExample = candidate.getAfterExample() != null ? candidate.getAfterExample() : ""; //$NON-NLS-1$
String negativeExample = candidate.getNegativeExample() != null ? candidate.getNegativeExample() : ""; //$NON-NLS-1$
String summary = candidate.getSummary() != null ? candidate.getSummary() : "(no summary)"; //$NON-NLS-1$
String sourceCommit = candidate.getSourceCommit() != null ? candidate.getSourceCommit() : "unknown"; //$NON-NLS-1$
StringBuilder sb = new StringBuilder();
sb.append("/*******************************************************************************\n"); //$NON-NLS-1$
sb.append(" * Auto-generated test for mining candidate from commit ").append(sourceCommit).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" * Summary: ").append(summary).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" * DO NOT EDIT — regenerate by running MiningCandidateTestGenerator\n"); //$NON-NLS-1$
sb.append(" *******************************************************************************/\n"); //$NON-NLS-1$
sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append("import static org.junit.jupiter.api.Assertions.assertFalse;\n"); //$NON-NLS-1$
sb.append("import static org.junit.jupiter.api.Assertions.assertTrue;\n\n"); //$NON-NLS-1$
sb.append("import java.util.List;\n"); //$NON-NLS-1$
sb.append("import java.util.Map;\n\n"); //$NON-NLS-1$
sb.append("import org.eclipse.jdt.core.JavaCore;\n"); //$NON-NLS-1$
sb.append("import org.eclipse.jdt.core.dom.AST;\n"); //$NON-NLS-1$
sb.append("import org.eclipse.jdt.core.dom.ASTParser;\n"); //$NON-NLS-1$
sb.append("import org.eclipse.jdt.core.dom.CompilationUnit;\n"); //$NON-NLS-1$
sb.append("import org.junit.jupiter.api.Test;\n"); //$NON-NLS-1$
sb.append("import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor;\n"); //$NON-NLS-1$
sb.append("import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor.TransformationResult;\n"); //$NON-NLS-1$
sb.append("import org.sandbox.jdt.triggerpattern.api.HintFile;\n"); //$NON-NLS-1$
sb.append("import org.sandbox.jdt.triggerpattern.internal.HintFileParser;\n\n"); //$NON-NLS-1$
sb.append("/**\n"); //$NON-NLS-1$
sb.append(" * Auto-generated lightweight candidate test.\n"); //$NON-NLS-1$
sb.append(" * Source commit: ").append(sourceCommit).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" * Summary: ").append(summary).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" */\n"); //$NON-NLS-1$
sb.append("public class ").append(className).append(" {\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
// DSL Rule constant
sb.append(" private static final String DSL_RULE = \"\"\"\n"); //$NON-NLS-1$
sb.append(" ").append(escapeForTextBlock(dslRule)).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"\"\";\n\n"); //$NON-NLS-1$
// Test 1: DSL parses successfully
sb.append(" @Test\n"); //$NON-NLS-1$
sb.append(" public void testDslParsesSuccessfully() throws Exception {\n"); //$NON-NLS-1$
sb.append(" HintFileParser parser = new HintFileParser();\n"); //$NON-NLS-1$
sb.append(" HintFile hintFile = parser.parse(DSL_RULE);\n"); //$NON-NLS-1$
sb.append(" assertFalse(hintFile.getRules().isEmpty(),\n"); //$NON-NLS-1$
sb.append(" \"DSL rule should parse into at least one rule\"); //$NON-NLS-1$\n"); //$NON-NLS-1$
sb.append(" }\n\n"); //$NON-NLS-1$
// Test 2: beforeExample matches (only if provided)
if (!beforeExample.isBlank()) {
sb.append(" @Test\n"); //$NON-NLS-1$
sb.append(" public void testBeforeExampleMatches() throws Exception {\n"); //$NON-NLS-1$
sb.append(" HintFileParser parser = new HintFileParser();\n"); //$NON-NLS-1$
sb.append(" HintFile hintFile = parser.parse(DSL_RULE);\n"); //$NON-NLS-1$
sb.append(" BatchTransformationProcessor processor = new BatchTransformationProcessor(hintFile);\n"); //$NON-NLS-1$
sb.append(" String beforeCode = \"\"\"\n"); //$NON-NLS-1$
sb.append(" ").append(escapeForTextBlock(beforeExample)).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"\"\";\n"); //$NON-NLS-1$
sb.append(" CompilationUnit cu = parseCode(beforeCode);\n"); //$NON-NLS-1$
sb.append(" List<TransformationResult> results = processor.process(cu);\n"); //$NON-NLS-1$
sb.append(" assertFalse(results.isEmpty(),\n"); //$NON-NLS-1$
sb.append(" \"Before example should match the DSL rule\"); //$NON-NLS-1$\n"); //$NON-NLS-1$
sb.append(" }\n\n"); //$NON-NLS-1$
}
// Test 3: matched replacement equals afterExample (only if both are provided)
if (!beforeExample.isBlank() && !afterExample.isBlank()) {
sb.append(" @Test\n"); //$NON-NLS-1$
sb.append(" public void testAfterExampleReplacementMatches() throws Exception {\n"); //$NON-NLS-1$
sb.append(" HintFileParser parser = new HintFileParser();\n"); //$NON-NLS-1$
sb.append(" HintFile hintFile = parser.parse(DSL_RULE);\n"); //$NON-NLS-1$
sb.append(" BatchTransformationProcessor processor = new BatchTransformationProcessor(hintFile);\n"); //$NON-NLS-1$
sb.append(" String beforeCode = \"\"\"\n"); //$NON-NLS-1$
sb.append(" ").append(escapeForTextBlock(beforeExample)).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"\"\";\n"); //$NON-NLS-1$
sb.append(" CompilationUnit cu = parseCode(beforeCode);\n"); //$NON-NLS-1$
sb.append(" List<TransformationResult> results = processor.process(cu);\n"); //$NON-NLS-1$
sb.append(" assertFalse(results.isEmpty(),\n"); //$NON-NLS-1$
sb.append(" \"Before example should produce a replacement\"); //$NON-NLS-1$\n"); //$NON-NLS-1$
sb.append(" assertTrue(results.stream()\n"); //$NON-NLS-1$
sb.append(" .filter(TransformationResult::hasReplacement)\n"); //$NON-NLS-1$
sb.append(" .map(TransformationResult::replacement)\n"); //$NON-NLS-1$
sb.append(" .anyMatch(replacement -> replacement.equals(\"\"\"\n"); //$NON-NLS-1$
sb.append(" ").append(escapeForTextBlock(afterExample)).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"\"\")),\n"); //$NON-NLS-1$
sb.append(" \"At least one replacement should exactly match afterExample\"); //$NON-NLS-1$\n"); //$NON-NLS-1$
sb.append(" }\n\n"); //$NON-NLS-1$
}
// Test 4: negativeExample does NOT match (only if provided)
if (!negativeExample.isBlank()) {
sb.append(" @Test\n"); //$NON-NLS-1$
sb.append(" public void testNegativeExampleDoesNotMatch() throws Exception {\n"); //$NON-NLS-1$
sb.append(" HintFileParser parser = new HintFileParser();\n"); //$NON-NLS-1$
sb.append(" HintFile hintFile = parser.parse(DSL_RULE);\n"); //$NON-NLS-1$
sb.append(" BatchTransformationProcessor processor = new BatchTransformationProcessor(hintFile);\n"); //$NON-NLS-1$
sb.append(" String negCode = \"\"\"\n"); //$NON-NLS-1$
sb.append(" ").append(escapeForTextBlock(negativeExample)).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"\"\";\n"); //$NON-NLS-1$
sb.append(" CompilationUnit cu = parseCode(negCode);\n"); //$NON-NLS-1$
sb.append(" List<TransformationResult> results = processor.process(cu);\n"); //$NON-NLS-1$
sb.append(" assertTrue(results.isEmpty(),\n"); //$NON-NLS-1$
sb.append(" \"Negative example should NOT match the DSL rule\"); //$NON-NLS-1$\n"); //$NON-NLS-1$
sb.append(" }\n\n"); //$NON-NLS-1$
}
// Helper method
sb.append(" private CompilationUnit parseCode(String code) {\n"); //$NON-NLS-1$
sb.append(" ASTParser astParser = ASTParser.newParser(AST.getJLSLatest());\n"); //$NON-NLS-1$
sb.append(" astParser.setSource(code.toCharArray());\n"); //$NON-NLS-1$
sb.append(" astParser.setKind(ASTParser.K_COMPILATION_UNIT);\n"); //$NON-NLS-1$
sb.append(" Map<String, String> options = JavaCore.getOptions();\n"); //$NON-NLS-1$
sb.append(" options.put(JavaCore.COMPILER_SOURCE, \"17\"); //$NON-NLS-1$\n"); //$NON-NLS-1$
sb.append(" astParser.setCompilerOptions(options);\n"); //$NON-NLS-1$
sb.append(" return (CompilationUnit) astParser.createAST(null);\n"); //$NON-NLS-1$
sb.append(" }\n"); //$NON-NLS-1$
sb.append("}\n"); //$NON-NLS-1$
return sb.toString();
}
/**
* Escapes a string for use in a Java text block.
* Specifically handles {@code """} sequences that would terminate the block.
*
* @param s the string to escape
* @return the escaped string
*/
static String escapeForTextBlock(String s) {
if (s == null) {
return ""; //$NON-NLS-1$
}
// Escape """ which would prematurely close the text block
return s.replace("\"\"\"", "\\\"\\\"\\\""); //$NON-NLS-1$ //$NON-NLS-2$
}
}