JUnit3SuiteModel.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer and others.
*
* 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
*******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.helper.lib;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeLiteral;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
/**
* Fail-closed model of a JUnit 3 {@code public static Test suite()} or
* {@code public static TestSuite suite()} aggregator.
*
* <p>Only aggregators whose selected classes and their order are provable from
* the source are modelled. Everything else is reported as a precise rejection so
* that the migration can leave the aggregator untouched instead of guessing.
*
* <p>Supported shapes:
* <pre>
* public static Test suite() { return new TestSuite(A.class, B.class); }
*
* public static Test suite() {
* TestSuite suite= new TestSuite("name");
* suite.addTestSuite(A.class);
* suite.addTest(new TestSuite(B.class));
* suite.addTest(C.suite());
* return suite;
* }
* </pre>
*/
public final class JUnit3SuiteModel {
/** Fully qualified name of the JUnit 3 suite type. */
public static final String JUNIT3_TEST_SUITE= "junit.framework.TestSuite"; //$NON-NLS-1$
/** Fully qualified name of the JUnit 3 test interface. */
public static final String JUNIT3_TEST= "junit.framework.Test"; //$NON-NLS-1$
/** Precise, stable reason why an aggregator cannot be modelled. */
public record Rejection(String reasonCode, String explanation) {
public Rejection {
reasonCode= Objects.requireNonNull(reasonCode);
explanation= Objects.requireNonNull(explanation);
}
}
/**
* Analysis outcome of one {@code suite()} declaration.
*
* @param selectedTypes source-visible names of the selected test classes, in declaration order
* @param rejection the rejection when the aggregator is not modelled, otherwise {@code null}
*/
public record Result(List<String> selectedTypes, Rejection rejection) {
public Result {
selectedTypes= List.copyOf(selectedTypes);
}
public boolean supported() {
return rejection == null;
}
static Result rejected(String reasonCode, String explanation) {
return new Result(List.of(), new Rejection(reasonCode, explanation));
}
static Result supported(List<String> selectedTypes) {
return new Result(selectedTypes, null);
}
}
private JUnit3SuiteModel() {
}
/**
* Returns whether the declaration has a supported JUnit 3 suite-builder signature.
*
* @param method method declaration to inspect
* @return {@code true} for {@code public static Test suite()} or
* {@code public static TestSuite suite()}
*/
public static boolean isSuiteBuilder(MethodDeclaration method) {
if (method == null || method.isConstructor() || !method.parameters().isEmpty()
|| !Modifier.isPublic(method.getModifiers()) || !Modifier.isStatic(method.getModifiers())
|| !"suite".equals(method.getName().getIdentifier())) { //$NON-NLS-1$
return false;
}
Type returnType= method.getReturnType2();
return returnType != null && isJUnit3Type(returnType, JUNIT3_TEST, JUNIT3_TEST_SUITE);
}
/**
* Models the selected classes of a JUnit 3 suite aggregator.
*
* @param method a {@code public static Test suite()} or
* {@code public static TestSuite suite()} declaration
* @return the ordered selected classes, or a precise rejection
*/
public static Result analyze(MethodDeclaration method) {
if (!isSuiteBuilder(method)) {
return Result.rejected("NOT_A_JUNIT3_SUITE_BUILDER", //$NON-NLS-1$
"The method does not declare a public static Test or TestSuite suite() contract."); //$NON-NLS-1$
}
Block body= method.getBody();
if (body == null) {
return Result.rejected("UNRESOLVED_JUNIT3_SUITE_BODY", //$NON-NLS-1$
"The suite() declaration has no source body to model."); //$NON-NLS-1$
}
@SuppressWarnings("unchecked")
List<Statement> statements= body.statements();
if (statements.size() == 1 && statements.get(0) instanceof ReturnStatement singleReturn) {
return analyzeDirectReturn(singleReturn);
}
return analyzeAccumulatingSuite(statements);
}
private static Result analyzeDirectReturn(ReturnStatement statement) {
if (!(statement.getExpression() instanceof ClassInstanceCreation creation)
|| !isTestSuiteCreation(creation)) {
return Result.rejected("DYNAMIC_JUNIT3_SUITE", //$NON-NLS-1$
"The suite() result is not a directly constructed junit.framework.TestSuite."); //$NON-NLS-1$
}
List<String> selected= new ArrayList<>();
Result rejection= collectCreationArguments(creation, selected);
if (rejection != null) {
return rejection;
}
return finish(selected);
}
private static Result analyzeAccumulatingSuite(List<Statement> statements) {
if (statements.size() < 2) {
return Result.rejected("DYNAMIC_JUNIT3_SUITE", //$NON-NLS-1$
"The suite() body does not declare and return one local junit.framework.TestSuite."); //$NON-NLS-1$
}
if (!(statements.get(0) instanceof VariableDeclarationStatement declaration)
|| declaration.fragments().size() != 1
|| !isJUnit3Type(declaration.getType(), JUNIT3_TEST_SUITE)) {
return Result.rejected("DYNAMIC_JUNIT3_SUITE", //$NON-NLS-1$
"The suite() body does not start with a single local junit.framework.TestSuite declaration."); //$NON-NLS-1$
}
VariableDeclarationFragment fragment= (VariableDeclarationFragment) declaration.fragments().get(0);
String suiteName= fragment.getName().getIdentifier();
List<String> selected= new ArrayList<>();
if (fragment.getInitializer() instanceof ClassInstanceCreation creation
&& isTestSuiteCreation(creation)) {
Result rejection= collectCreationArguments(creation, selected);
if (rejection != null) {
return rejection;
}
} else {
return Result.rejected("DYNAMIC_JUNIT3_SUITE", //$NON-NLS-1$
"The local suite variable is not initialized with a directly constructed TestSuite."); //$NON-NLS-1$
}
for (Statement statement : statements.subList(1, statements.size() - 1)) {
Result rejection= collectAddition(statement, suiteName, selected);
if (rejection != null) {
return rejection;
}
}
Statement last= statements.get(statements.size() - 1);
if (!(last instanceof ReturnStatement returnStatement)
|| !(returnStatement.getExpression() instanceof SimpleName returned)
|| !suiteName.equals(returned.getIdentifier())) {
return Result.rejected("DYNAMIC_JUNIT3_SUITE", //$NON-NLS-1$
"The suite() body does not return the declared local suite variable."); //$NON-NLS-1$
}
return finish(selected);
}
private static Result collectAddition(Statement statement, String suiteName, List<String> selected) {
if (!(statement instanceof ExpressionStatement expressionStatement)
|| !(expressionStatement.getExpression() instanceof MethodInvocation invocation)) {
return Result.rejected("ORDER_DEPENDENT_JUNIT3_SUITE", //$NON-NLS-1$
"The suite() body contains statements other than suite additions, so its composition is not provable."); //$NON-NLS-1$
}
if (!(invocation.getExpression() instanceof SimpleName receiver)
|| !suiteName.equals(receiver.getIdentifier())) {
return Result.rejected("ORDER_DEPENDENT_JUNIT3_SUITE", //$NON-NLS-1$
"A suite() statement does not add to the declared local suite variable."); //$NON-NLS-1$
}
String invoked= invocation.getName().getIdentifier();
@SuppressWarnings("unchecked")
List<Expression> arguments= invocation.arguments();
if ("addTestSuite".equals(invoked) && arguments.size() == 1) { //$NON-NLS-1$
return addTypeLiteral(arguments.get(0), selected);
}
if ("addTest".equals(invoked) && arguments.size() == 1) { //$NON-NLS-1$
return addTestArgument(arguments.get(0), selected);
}
return Result.rejected("CUSTOM_JUNIT3_SUITE_COMPOSITION", //$NON-NLS-1$
"The suite() body calls " + invoked + "(), which has no provable @SelectClasses equivalent."); //$NON-NLS-1$ //$NON-NLS-2$
}
private static Result addTestArgument(Expression argument, List<String> selected) {
if (argument instanceof ClassInstanceCreation creation) {
if (!isTestSuiteCreation(creation)) {
return Result.rejected("CUSTOM_JUNIT3_SUITE_DECORATOR", //$NON-NLS-1$
"The suite() body wraps tests in a decorator, whose setup must be migrated to an explicit extension first."); //$NON-NLS-1$
}
return collectCreationArguments(creation, selected);
}
if (argument instanceof MethodInvocation nested && "suite".equals(nested.getName().getIdentifier()) //$NON-NLS-1$
&& nested.arguments().isEmpty() && nested.getExpression() instanceof Name qualifier) {
// A nested aggregator stays selectable by class; the platform resolves it
// through its own @Suite or through the vintage engine.
selected.add(qualifier.getFullyQualifiedName());
return null;
}
return Result.rejected("DYNAMIC_JUNIT3_SUITE", //$NON-NLS-1$
"The suite() body adds a test whose identity is not provable from the source."); //$NON-NLS-1$
}
private static Result collectCreationArguments(ClassInstanceCreation creation, List<String> selected) {
@SuppressWarnings("unchecked")
List<Expression> arguments= creation.arguments();
if (creation.getAnonymousClassDeclaration() != null) {
return Result.rejected("CUSTOM_JUNIT3_SUITE_DECORATOR", //$NON-NLS-1$
"The suite() body creates an anonymous TestSuite subclass with custom behavior."); //$NON-NLS-1$
}
for (Expression argument : arguments) {
if (argument instanceof StringLiteral) {
// The suite display name has no @SelectClasses equivalent and is dropped.
continue;
}
Result rejection= addTypeLiteral(argument, selected);
if (rejection != null) {
return rejection;
}
}
return null;
}
private static Result addTypeLiteral(Expression argument, List<String> selected) {
if (!(argument instanceof TypeLiteral literal)) {
return Result.rejected("DYNAMIC_JUNIT3_SUITE", //$NON-NLS-1$
"The suite() body selects tests through a value that is not a class literal."); //$NON-NLS-1$
}
selected.add(literal.getType().toString());
return null;
}
private static Result finish(List<String> selected) {
if (selected.isEmpty()) {
return Result.rejected("EMPTY_JUNIT3_SUITE", //$NON-NLS-1$
"The suite() body does not select any test class."); //$NON-NLS-1$
}
Set<String> unique= new LinkedHashSet<>(selected);
if (unique.size() != selected.size()) {
return Result.rejected("DUPLICATED_JUNIT3_SUITE_ENTRY", //$NON-NLS-1$
"The suite() body selects the same test class more than once, which @SelectClasses cannot reproduce."); //$NON-NLS-1$
}
return Result.supported(selected);
}
private static boolean isTestSuiteCreation(ClassInstanceCreation creation) {
return isJUnit3Type(creation.getType(), JUNIT3_TEST_SUITE);
}
private static boolean isJUnit3Type(Type type, String... qualifiedNames) {
if (type == null) {
return false;
}
ITypeBinding binding= type.resolveBinding();
if (binding != null && !binding.isRecovered()) {
String name= binding.getErasure().getQualifiedName();
for (String candidate : qualifiedNames) {
if (candidate.equals(name)) {
return true;
}
}
return false;
}
String written= type.toString();
for (String candidate : qualifiedNames) {
if (candidate.equals(written) || simpleName(candidate).equals(written)) {
return true;
}
}
return false;
}
private static String simpleName(String qualifiedName) {
int separator= qualifiedName.lastIndexOf('.');
return separator < 0 ? qualifiedName : qualifiedName.substring(separator + 1);
}
}