JsonTestParameters.java

/*
 * Copyright ConsenSys AG.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
 * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 */
package org.hyperledger.besu.testutil;

import static com.google.common.base.Preconditions.checkState;

import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.annotation.Nullable;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonFactoryBuilder;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;

/**
 * Utility class for generating JUnit test parameters from json files. Each set of test parameters
 * will contain a String name followed by an object representing a deserialized json test case.
 *
 * @param <S> the type parameter
 * @param <T> the type parameter
 */
public class JsonTestParameters<S, T> {

  private static final String TEST_PATTERN_STR = System.getProperty("test.ethereum.include");

  /**
   * The Collector.
   *
   * @param <S> the type parameter
   */
  public static class Collector<S> {

    @Nullable private final Predicate<String> includes;
    private final Predicate<String> ignore;

    private Collector(@Nullable final Predicate<String> includes, final Predicate<String> ignore) {
      this.includes = includes;
      this.ignore = ignore;
    }

    // Reference tests are plentiful so we'll add quite a bit of element, so starting with a
    // relatively large capacity (without getting crazy) to avoid resizing. It's going to waste
    // memory when we run a single test, but it's not the case we're trying to optimize.
    private final List<Object[]> testParameters = new ArrayList<>(256);

    /**
     * Add.
     *
     * @param name the name
     * @param fullPath the full path of the test
     * @param value the value
     * @param runTest the run test
     */
    public void add(
        final String name, final String fullPath, final S value, final boolean runTest) {
      testParameters.add(
          new Object[] {name, value, runTest && includes(name) && includes(fullPath)});
    }

    private boolean includes(final String name) {
      // If there is no specific includes, everything is included unless it is ignored, otherwise,
      // only what is in includes is included whether or not it is ignored.
      if (includes == null) {
        return !ignore.test(name);
      } else {
        return includes.test(name);
      }
    }

    private Collection<Object[]> getParameters() {
      return testParameters;
    }
  }

  /**
   * The interface Generator.
   *
   * @param <S> the type parameter
   * @param <T> the type parameter
   */
  @FunctionalInterface
  public interface Generator<S, T> {
    /**
     * Generate.
     *
     * @param name the name
     * @param fullPath the full path of the test
     * @param mappedType the mapped type
     * @param collector the collector
     */
    void generate(String name, String fullPath, S mappedType, Collector<T> collector);
  }

  private static final ObjectMapper objectMapper =
      new ObjectMapper(
              new JsonFactoryBuilder()
                  .streamReadConstraints(
                      StreamReadConstraints.builder().maxStringLength(Integer.MAX_VALUE).build())
                  .build())
          .registerModule(new Jdk8Module());

  // The type to which the json file is directly mapped
  private final Class<S> jsonFileMappedType;

  // The final type of the test case spec, which may or may not not be same than jsonFileMappedType
  // Note that we don't really use this field as of now, but as this is the actual type of the final
  // spec used by tests, it feels "right" to have it passed explicitly at construction and having it
  // around could prove useful later.
  @SuppressWarnings({"FieldCanBeLocal", "unused"})
  private final Class<T> testCaseSpec;

  private final Set<String> fileExcludes = new HashSet<>();
  private Generator<S, T> generator;

  private final List<Predicate<String>> testIncludes = new ArrayList<>();
  private final List<Predicate<String>> testIgnores = new ArrayList<>();

  private JsonTestParameters(final Class<S> jsonFileMappedType, final Class<T> testCaseSpec) {
    this.jsonFileMappedType = jsonFileMappedType;
    this.testCaseSpec = testCaseSpec;

    if (TEST_PATTERN_STR != null) {
      includeTests(TEST_PATTERN_STR);
    }
  }

  /**
   * Create json test parameters.
   *
   * @param <T> the type parameter
   * @param testCaseSpec the test case spec
   * @return the json test parameters
   */
  public static <T> JsonTestParameters<T, T> create(final Class<T> testCaseSpec) {
    return new JsonTestParameters<>(testCaseSpec, testCaseSpec)
        .generator(
            (name, fullPath, testCase, collector) -> collector.add(name, fullPath, testCase, true));
  }

  /**
   * Create json test parameters.
   *
   * @param <S> the type parameter
   * @param <T> the type parameter
   * @param jsonFileMappedType the json file mapped type
   * @param testCaseSpec the test case spec
   * @return the json test parameters
   */
  public static <S, T> JsonTestParameters<S, T> create(
      final Class<S> jsonFileMappedType, final Class<T> testCaseSpec) {
    return new JsonTestParameters<>(jsonFileMappedType, testCaseSpec);
  }

  /**
   * Exclude files json test parameters.
   *
   * @param filenames the filenames
   * @return the json test parameters
   */
  @SuppressWarnings("unused")
  public JsonTestParameters<S, T> excludeFiles(final String... filenames) {
    fileExcludes.addAll(Arrays.asList(filenames));
    return this;
  }

  private void addPatterns(final String[] patterns, final List<Predicate<String>> listForAddition) {
    for (final String pattern : patterns) {
      final Pattern compiled = Pattern.compile(pattern);
      listForAddition.add(t -> compiled.matcher(t).find());
    }
  }

  @SuppressWarnings({"unused"})
  private void includeTests(final String... patterns) {
    addPatterns(patterns, testIncludes);
  }

  /**
   * Ignore json test parameters.
   *
   * @param patterns the patterns
   * @return the json test parameters
   */
  public JsonTestParameters<S, T> ignore(final String... patterns) {
    addPatterns(patterns, testIgnores);
    return this;
  }

  /** Ignore all. */
  public void ignoreAll() {
    testIgnores.add(t -> true);
  }

  /**
   * Generator json test parameters.
   *
   * @param generator the generator
   * @return the json test parameters
   */
  public JsonTestParameters<S, T> generator(final Generator<S, T> generator) {
    this.generator = generator;
    return this;
  }

  /**
   * Generate collection.
   *
   * @param paths the paths
   * @return the collection
   */
  public Collection<Object[]> generate(final String... paths) {
    return generate(getFilteredFiles(paths));
  }

  /**
   * Generate collection.
   *
   * @param filteredFiles the filtered files
   * @return the collection
   */
  public Collection<Object[]> generate(final Collection<File> filteredFiles) {
    checkState(generator != null, "Missing generator function");

    final Collector<T> collector =
        new Collector<>(
            testIncludes.isEmpty() ? null : t -> matchAny(t, testIncludes),
            t -> matchAny(t, testIgnores));

    for (final File file : filteredFiles) {
      final JsonTestCaseReader<S> testCase = parseFile(file);
      for (final Map.Entry<String, S> entry : testCase.testCaseSpecs.entrySet()) {
        final String testName = entry.getKey();
        final S mappedType = entry.getValue();
        generator.generate(testName, file.getPath(), mappedType, collector);
      }
    }
    return collector.getParameters();
  }

  private static <T> boolean matchAny(final T toTest, final List<Predicate<T>> tests) {
    for (final Predicate<T> predicate : tests) {
      if (predicate.test(toTest)) {
        return true;
      }
    }
    return false;
  }

  private Collection<File> getFilteredFiles(final String[] paths) {
    final ClassLoader classLoader = JsonTestParameters.class.getClassLoader();
    final List<File> files = new ArrayList<>();
    for (final String path : paths) {
      final URL url = classLoader.getResource(path);
      checkState(url != null, "Cannot find test directory " + path);
      final Path dir;
      try {
        dir = Paths.get(url.toURI());
      } catch (final URISyntaxException e) {
        throw new RuntimeException("Problem converting URL to URI " + url, e);
      }
      try (final Stream<Path> s = Files.walk(dir)) {
        s.map(Path::toFile)
            .filter(f -> f.getPath().endsWith(".json"))
            .filter(f -> !fileExcludes.contains(f.getName()))
            .forEach(files::add);
      } catch (final IOException e) {
        throw new RuntimeException("Problem reading directory " + dir, e);
      }
    }
    return files;
  }

  private JsonTestCaseReader<S> parseFile(final File file) {
    final JavaType javaType =
        objectMapper
            .getTypeFactory()
            .constructParametricType(JsonTestCaseReader.class, jsonFileMappedType);

    try {
      return objectMapper.readValue(file, javaType);
    } catch (final IOException e) {
      throw new RuntimeException(
          "Error parsing test case file " + file + " to class " + jsonFileMappedType, e);
    }
  }

  private static class JsonTestCaseReader<T> {

    /** The Test case specs. */
    final Map<String, T> testCaseSpecs;

    /**
     * Public constructor.
     *
     * @param testCaseSpecs The test cases to run.
     */
    @JsonCreator
    JsonTestCaseReader(@JsonProperty final Map<String, T> testCaseSpecs) {
      this.testCaseSpecs = testCaseSpecs;
    }
  }
}