001/*
002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de)
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.test.generator.junit;
017
018import de.cuioss.test.generator.internal.net.java.quickcheck.generator.distribution.RandomConfiguration;
019import org.junit.jupiter.api.extension.BeforeEachCallback;
020import org.junit.jupiter.api.extension.ExtensionContext;
021import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;
022import org.opentest4j.AssertionFailedError;
023import org.opentest4j.TestAbortedException;
024
025import java.lang.reflect.Method;
026
027/**
028 * JUnit 5 extension that manages test data generation by controlling generator seeds
029 * and providing detailed failure information for test reproduction.
030 *
031 * <h2>Features</h2>
032 * <ul>
033 *   <li>Initializes generator seeds before each test</li>
034 *   <li>Supports seed configuration via {@link GeneratorSeed} annotation</li>
035 *   <li>Provides detailed failure information for test reproduction</li>
036 *   <li>Handles both method and class-level seed configuration</li>
037 * </ul>
038 *
039 * <h2>Usage</h2>
040 * Can be enabled in two ways:
041 * <pre>
042 * // Option 1: Direct extension usage
043 * &#64;ExtendWith(GeneratorControllerExtension.class)
044 * class MyTest {
045 *     &#64;Test
046 *     void shouldGenerateData() { ... }
047 * }
048 *
049 * // Option 2: Via meta-annotation (preferred)
050 * &#64;EnableGeneratorController
051 * class MyTest {
052 *     &#64;Test
053 *     void shouldGenerateData() { ... }
054 * }
055 * </pre>
056 *
057 * <h2>Seed Configuration</h2>
058 * Seeds can be configured in order of precedence:
059 * <ol>
060 *   <li>Method-level {@code @GeneratorSeed}</li>
061 *   <li>Class-level {@code @GeneratorSeed}</li>
062 *   <li>System property {@code de.cuioss.test.generator.seed}</li>
063 *   <li>Random seed (if no configuration present)</li>
064 * </ol>
065 *
066 * <h2>Failure Handling</h2>
067 * On test failure, provides a detailed message with:
068 * <ul>
069 *   <li>The original test failure message</li>
070 *   <li>The seed used for test data generation</li>
071 *   <li>Instructions for test reproduction</li>
072 * </ul>
073 *
074 * @author Oliver Wolff
075 * @see EnableGeneratorController
076 * @see GeneratorSeed
077 * @see RandomConfiguration#SEED_SYSTEM_PROPERTY
078 */
079public class GeneratorControllerExtension implements BeforeEachCallback, TestExecutionExceptionHandler {
080
081    private static final String MSG_TEMPLATE = """
082            %s
083            GeneratorController seed was %sL.\s
084            Use a fixed seed by applying @GeneratorSeed(%sL) for the method/class,\s
085            or by using the system property '-D\
086            """ + RandomConfiguration.SEED_SYSTEM_PROPERTY + "=%s'\n";
087
088    @Override
089    public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
090
091        if (throwable instanceof TestAbortedException) {
092            throw throwable;
093        }
094        if (throwable instanceof AssertionFailedError) {
095            var failure = new AssertionFailedError(createErrorMessage(throwable, RandomConfiguration.getLastSeed()));
096            failure.setStackTrace(throwable.getStackTrace());
097            throw failure;
098        }
099        var failure = new AssertionFailedError(
100                throwable.getClass() + ": " + createErrorMessage(throwable, RandomConfiguration.getLastSeed()));
101        failure.setStackTrace(throwable.getStackTrace());
102        throw failure;
103
104    }
105
106    @Override
107    @SuppressWarnings("java:S3655") // owolff: false positive: isPresent is checked
108    public void beforeEach(ExtensionContext context) {
109        var seedSetByAnnotation = false;
110        long initialSeed = -1;
111        if (context.getElement().isPresent()) {
112            var annotatedElement = context.getElement().get();
113            var seedAnnotation = annotatedElement.getAnnotation(GeneratorSeed.class);
114            if (null == seedAnnotation && annotatedElement instanceof Method method) {
115                seedAnnotation = method.getDeclaringClass().getAnnotation(GeneratorSeed.class);
116            }
117            if (null != seedAnnotation) {
118                initialSeed = seedAnnotation.value();
119                seedSetByAnnotation = true;
120            }
121        }
122        if (seedSetByAnnotation) {
123            RandomConfiguration.setSeed(initialSeed);
124        } else {
125            RandomConfiguration.initSeed();
126        }
127    }
128
129    private String createErrorMessage(Throwable e, Long seed) {
130        var causeMsg = e.getMessage() == null ? "" : e.getMessage();
131        return MSG_TEMPLATE.formatted(causeMsg, seed, seed, seed);
132    }
133
134}