TestExpectedAndTimeoutJUnitPlugin.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
 *******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.helper;

import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ANNOTATION_TEST;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ANNOTATION_TIMEOUT;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.METHOD_ASSERT_THROWS;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_ASSERTIONS;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_TIMEOUT;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_TEST;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_TEST;

import java.util.List;

import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MarkerAnnotation;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.NumberLiteral;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.TypeLiteral;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jdt.core.dom.rewrite.ListRewrite;
import org.eclipse.jdt.internal.corext.dom.ASTNodes;

import org.eclipse.text.edits.TextEditGroup;

import org.sandbox.jdt.internal.corext.fix.helper.lib.JunitHolder;
import org.sandbox.jdt.internal.corext.fix.helper.lib.TriggerPatternCleanupPlugin;
import org.sandbox.jdt.internal.corext.util.AnnotationUtils;
import org.sandbox.jdt.triggerpattern.api.CleanupPattern;
import org.sandbox.jdt.triggerpattern.api.Match;
import org.sandbox.jdt.triggerpattern.api.Pattern;
import org.sandbox.jdt.triggerpattern.api.PatternKind;

/**
 * Coordinates migration of a JUnit 4 {@code @Test} annotation that declares
 * both {@code expected} and {@code timeout}.
 *
 * <p>A single operation must own the annotation because the ordinary timeout
 * and expected plugins deliberately share a processed-node set. Processing the
 * attributes independently would either skip one migration or schedule
 * conflicting replacements for the same annotation.</p>
 */
@CleanupPattern(value = "@Test(expected=$ex, timeout=$t)", kind = PatternKind.ANNOTATION,
		qualifiedType = ORG_JUNIT_TEST, cleanupId = "cleanup.junit.test.expected-timeout",
		description = "Migrate combined @Test(expected, timeout)",
		displayName = "JUnit 4 @Test(expected, timeout) → assertThrows() and @Timeout")
public class TestExpectedAndTimeoutJUnitPlugin extends TriggerPatternCleanupPlugin {

	private record Parameters(MemberValuePair expected, MemberValuePair timeout) {
	}

	@Override
	protected List<Pattern> getPatterns() {
		return List.of(new Pattern("@Test(expected=$ex, timeout=$t)", PatternKind.ANNOTATION, null, null, //$NON-NLS-1$
				ORG_JUNIT_TEST, null, null));
	}

	@Override
	protected JunitHolder createHolder(Match match) {
		if (!(match.getMatchedNode() instanceof NormalAnnotation annotation)) {
			return null;
		}
		MemberValuePair expected= pair(annotation, "expected"); //$NON-NLS-1$
		MemberValuePair timeout= pair(annotation, "timeout"); //$NON-NLS-1$
		if (expected == null || timeout == null || !(expected.getValue() instanceof TypeLiteral)
				|| !(timeout.getValue() instanceof NumberLiteral number) || !isLong(number.getToken())) {
			return null;
		}
		return new JunitHolder().setMinv(annotation).setAdditionalInfo(new Parameters(expected, timeout));
	}

	@Override
	@SuppressWarnings("unchecked")
	protected void process2Rewrite(TextEditGroup group, ASTRewrite rewriter, AST ast,
			ImportRewrite importRewriter, JunitHolder junitHolder) {
		if (!(junitHolder.getAnnotation() instanceof NormalAnnotation testAnnotation)
				|| !(junitHolder.getAdditionalInfo() instanceof Parameters parameters)
				|| !(parameters.expected().getValue() instanceof TypeLiteral expectedType)
				|| !(parameters.timeout().getValue() instanceof NumberLiteral timeoutLiteral)) {
			return;
		}
		MethodDeclaration method= ASTNodes.getParent(testAnnotation, MethodDeclaration.class);
		Block methodBody= method == null ? null : method.getBody();
		if (methodBody == null) {
			return;
		}
		long timeoutMillis;
		try {
			timeoutMillis= Long.parseLong(timeoutLiteral.getToken());
		} catch (NumberFormatException exception) {
			return;
		}

		MethodInvocation assertThrows= ast.newMethodInvocation();
		assertThrows.setName(ast.newSimpleName(METHOD_ASSERT_THROWS));
		assertThrows.arguments().add(ASTNode.copySubtree(ast, expectedType));
		LambdaExpression lambda= ast.newLambdaExpression();
		lambda.setParentheses(true);
		Block lambdaBody= ast.newBlock();
		List<Statement> statements= methodBody.statements();
		for (Statement statement : statements) {
			lambdaBody.statements().add(ASTNode.copySubtree(ast, statement));
		}
		lambda.setBody(lambdaBody);
		assertThrows.arguments().add(lambda);
		ExpressionStatement assertion= ast.newExpressionStatement(assertThrows);
		ListRewrite bodyRewrite= rewriter.getListRewrite(methodBody, Block.STATEMENTS_PROPERTY);
		for (Statement statement : statements) {
			bodyRewrite.remove(statement, group);
		}
		bodyRewrite.insertLast(assertion, group);

		NormalAnnotation timeoutAnnotation= ast.newNormalAnnotation();
		timeoutAnnotation.setTypeName(ast.newSimpleName(ANNOTATION_TIMEOUT));
		MemberValuePair valuePair= ast.newMemberValuePair();
		valuePair.setName(ast.newSimpleName("value")); //$NON-NLS-1$
		boolean seconds= timeoutMillis >= 1000 && timeoutMillis % 1000 == 0;
		valuePair.setValue(ast.newNumberLiteral(Long.toString(seconds ? timeoutMillis / 1000 : timeoutMillis)));
		timeoutAnnotation.values().add(valuePair);
		MemberValuePair unitPair= ast.newMemberValuePair();
		unitPair.setName(ast.newSimpleName("unit")); //$NON-NLS-1$
		QualifiedName unit= ast.newQualifiedName(ast.newSimpleName("TimeUnit"), //$NON-NLS-1$
				ast.newSimpleName(seconds ? "SECONDS" : "MILLISECONDS")); //$NON-NLS-1$ //$NON-NLS-2$
		unitPair.setValue(unit);
		timeoutAnnotation.values().add(unitPair);
		ListRewrite modifiers= rewriter.getListRewrite(method, MethodDeclaration.MODIFIERS2_PROPERTY);
		modifiers.insertAfter(timeoutAnnotation, testAnnotation, group);

		MarkerAnnotation markerTest= AnnotationUtils.createMarkerAnnotation(ast, ANNOTATION_TEST);
		ASTNodes.replaceButKeepComment(rewriter, testAnnotation, markerTest, group);

		importRewriter.removeImport(ORG_JUNIT_TEST);
		importRewriter.addImport(ORG_JUNIT_JUPITER_TEST);
		importRewriter.addImport(ORG_JUNIT_JUPITER_API_TIMEOUT);
		importRewriter.addImport("java.util.concurrent.TimeUnit"); //$NON-NLS-1$
		importRewriter.addStaticImport(ORG_JUNIT_JUPITER_API_ASSERTIONS, METHOD_ASSERT_THROWS, false);
	}

	private static MemberValuePair pair(NormalAnnotation annotation, String name) {
		for (Object value : annotation.values()) {
			MemberValuePair pair= (MemberValuePair) value;
			if (name.equals(pair.getName().getIdentifier())) {
				return pair;
			}
		}
		return null;
	}

	private static boolean isLong(String token) {
		try {
			Long.parseLong(token);
			return true;
		} catch (NumberFormatException exception) {
			return false;
		}
	}

	@Override
	public String getPreview(boolean afterRefactoring) {
		return afterRefactoring
				? "@Test\n@Timeout(value = 1, unit = TimeUnit.SECONDS)\nvoid test() { assertThrows(Exception.class, () -> work()); }" //$NON-NLS-1$
				: "@Test(expected = Exception.class, timeout = 1000)\nvoid test() { work(); }"; //$NON-NLS-1$
	}

	@Override
	public String toString() {
		return "TestExpectedAndTimeout"; //$NON-NLS-1$
	}
}