DiffTestCase.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to you 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.
 */
package org.apache.calcite.test;

import org.apache.calcite.util.ReflectUtil;
import org.apache.calcite.util.TestUtil;
import org.apache.calcite.util.Util;

import org.checkerframework.checker.nullness.qual.Nullable;
import org.incava.diff.Diff;
import org.incava.diff.Difference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.LineNumberReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.StringWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.fail;

/**
 * DiffTestCase is an abstract base for JUnit tests which produce multi-line
 * output to be verified by diffing against a pre-existing reference file.
 */
public abstract class DiffTestCase {
  //~ Instance fields --------------------------------------------------------

  private final String testCaseName;

  /**
   * Name of current .log file.
   */
  protected @Nullable File logFile;

  /**
   * Name of current .ref file.
   */
  protected @Nullable File refFile;

  /**
   * OutputStream for current test log.
   */
  protected @Nullable OutputStream logOutputStream;

  /** Diff masks defined so far. */
  private String diffMasks;
  @Nullable Pattern compiledDiffPattern;
  @Nullable Matcher compiledDiffMatcher;
  private String ignorePatterns;
  @Nullable Pattern compiledIgnorePattern;
  @Nullable Matcher compiledIgnoreMatcher;

  /**
   * Whether to give verbose message if diff fails.
   */
  private boolean verbose;

  /**
   * Initializes a new DiffTestCase.
   *
   * @param testCaseName Test case name
   */
  protected DiffTestCase(String testCaseName) {
    this.testCaseName = testCaseName;
    // diffMasks = new ArrayList();
    diffMasks = "";
    ignorePatterns = "";
    compiledIgnoreMatcher = null;
    compiledDiffMatcher = null;
    String verboseVal =
        System.getProperty(DiffTestCase.class.getName() + ".verbose");
    if (verboseVal != null) {
      verbose = true;
    }
  }

  //~ Methods ----------------------------------------------------------------

  @BeforeEach
  protected void setUp() {
    // diffMasks.clear();
    diffMasks = "";
    ignorePatterns = "";
    compiledIgnoreMatcher = null;
    compiledDiffMatcher = null;
  }

  @AfterEach
  protected void tearDown() throws IOException {
    if (logOutputStream != null) {
      logOutputStream.close();
      logOutputStream = null;
    }
  }

  /**
   * Initializes a diff-based test. Any existing .log and .dif files
   * corresponding to this test case are deleted, and a new, empty .log file
   * is created. The default log file location is a subdirectory under the
   * result getTestlogRoot(), where the subdirectory name is based on the
   * unqualified name of the test class. The generated log file name will be
   * testMethodName.log, and the expected reference file will be
   * testMethodName.ref.
   *
   * @return Writer for log file, which caller should use as a destination for
   * test output to be diffed
   */
  protected Writer openTestLog() throws Exception {
    File testClassDir =
        new File(getTestlogRoot(),
            ReflectUtil.getUnqualifiedClassName(getClass()));
    //noinspection ResultOfMethodCallIgnored
    testClassDir.mkdirs();
    File testLogFile =
        new File(
            testClassDir,
            testCaseName);
    return new OutputStreamWriter(
        openTestLogOutputStream(testLogFile), StandardCharsets.UTF_8);
  }

  /** Returns the root directory under which test logs should be written. */
  protected abstract File getTestlogRoot();

  /**
   * Initializes a diff-based test, overriding the default log file naming
   * scheme altogether.
   *
   * @param testFileSansExt full path to log filename, without .log/.ref
   *                        extension
   */
  protected OutputStream openTestLogOutputStream(File testFileSansExt)
      throws IOException {
    assert logOutputStream == null;

    logFile = new File(testFileSansExt + ".log");
    //noinspection ResultOfMethodCallIgnored
    logFile.delete();

    refFile = new File(testFileSansExt + ".ref");

    logOutputStream = Files.newOutputStream(logFile.toPath());
    return logOutputStream;
  }

  /**
   * Finishes a diff-based test. Output that was written to the Writer
   * returned by openTestLog is diffed against a .ref file, and if any
   * differences are detected, the test case fails. Note that the diff used is
   * just a boolean test, and does not create any .dif ouput.
   *
   * <p>NOTE: if you wrap the Writer returned by openTestLog() (e.g. with a
   * PrintWriter), be sure to flush the wrapping Writer before calling this
   * method.
   *
   * @see #diffFile(File, File)
   */
  protected void diffTestLog() throws IOException {
    if (logOutputStream == null) {
      throw new IllegalStateException();
    }
    logOutputStream.close();
    logOutputStream = null;

    if (refFile == null) {
      throw new IllegalStateException();
    }
    if (!refFile.exists()) {
      fail("Reference file " + refFile + " does not exist");
    }
    if (logFile == null) {
      throw new IllegalStateException();
    }
    diffFile(logFile, refFile);
  }

  /**
   * Compares a log file with its reference log.
   *
   * <p>Usually, the log file and the reference log are in the same directory,
   * one ending with '.log' and the other with '.ref'.
   *
   * <p>If the files are identical, removes logFile.
   *
   * @param logFile Log file
   * @param refFile Reference log
   */
  protected void diffFile(File logFile, File refFile) throws IOException {
    BufferedReader logReader = null;
    BufferedReader refReader = null;
    try {
      // NOTE: Use of diff.mask is deprecated, use diff_mask.
      String diffMask = System.getProperty("diff.mask", null);
      if (diffMask != null) {
        addDiffMask(diffMask);
      }

      diffMask = System.getProperty("diff_mask", null);
      if (diffMask != null) {
        addDiffMask(diffMask);
      }

      logReader = Util.reader(logFile);
      refReader = Util.reader(refFile);
      LineNumberReader logLineReader = new LineNumberReader(logReader);
      LineNumberReader refLineReader = new LineNumberReader(refReader);
      for (;;) {
        String logLine = logLineReader.readLine();
        String refLine = refLineReader.readLine();
        while ((logLine != null) && matchIgnorePatterns(logLine)) {
          // System.out.println("logMatch Line:" + logLine);
          logLine = logLineReader.readLine();
        }
        while ((refLine != null) && matchIgnorePatterns(refLine)) {
          // System.out.println("refMatch Line:" + logLine);
          refLine = refLineReader.readLine();
        }
        if ((logLine == null) || (refLine == null)) {
          if (logLine != null) {
            diffFail(
                logFile,
                logLineReader.getLineNumber());
          }
          if (refLine != null) {
            diffFail(
                logFile,
                refLineReader.getLineNumber());
          }
          break;
        }
        logLine = applyDiffMask(logLine);
        refLine = applyDiffMask(refLine);
        if (!logLine.equals(refLine)) {
          diffFail(
              logFile,
              logLineReader.getLineNumber());
        }
      }
    } finally {
      if (logReader != null) {
        logReader.close();
      }
      if (refReader != null) {
        refReader.close();
      }
    }

    // no diffs detected, so delete redundant .log file
    //noinspection ResultOfMethodCallIgnored
    logFile.delete();
  }

  /**
   * Adds a diff mask. Strings matching the given regular expression will be
   * masked before diffing. This can be used to suppress spurious diffs on a
   * case-by-case basis.
   *
   * @param mask a regular expression, as per String.replaceAll
   */
  protected void addDiffMask(String mask) {
    // diffMasks.add(mask);
    if (diffMasks.isEmpty()) {
      diffMasks = mask;
    } else {
      diffMasks = diffMasks + "|" + mask;
    }
    compiledDiffPattern = Pattern.compile(diffMasks);
    compiledDiffMatcher = compiledDiffPattern.matcher("");
  }

  protected void addIgnorePattern(String javaPattern) {
    if (ignorePatterns.isEmpty()) {
      ignorePatterns = javaPattern;
    } else {
      ignorePatterns = ignorePatterns + "|" + javaPattern;
    }
    compiledIgnorePattern = Pattern.compile(ignorePatterns);
    compiledIgnoreMatcher = compiledIgnorePattern.matcher("");
  }

  private String applyDiffMask(String s) {
    if (compiledDiffMatcher != null) {
      if (compiledDiffPattern == null) {
        throw new AssertionError();
      }
      compiledDiffMatcher.reset(s);

      // we assume most lines do not match
      // so compiled matches will be faster than replaceAll.
      if (compiledDiffMatcher.find()) {
        return compiledDiffPattern.matcher(s).replaceAll("XYZZY");
      }
    }
    return s;
  }

  private boolean matchIgnorePatterns(String s) {
    if (compiledIgnoreMatcher != null) {
      compiledIgnoreMatcher.reset(s);
      return compiledIgnoreMatcher.matches();
    }
    return false;
  }

  private void diffFail(
      File logFile,
      int lineNumber) {
    final String message =
        "diff detected at line " + lineNumber + " in " + logFile;
    if (verbose) {
      if (refFile == null) {
        throw new IllegalStateException();
      }
      if (inIde()) {
        // If we're in IntelliJ, it's worth printing the 'expected
        // <...> actual <...>' string, because IntelliJ can format
        // this intelligently. Otherwise, use the more concise
        // diff format.
        assertThat(message, fileContents(logFile),
            is(fileContents(refFile)));
      } else {
        String s = diff(refFile, logFile);
        fail(message + '\n' + s + '\n');
      }
    }
    fail(message);
  }

  /**
   * Returns whether this test is running inside the IntelliJ IDE.
   *
   * @return whether we're running in IntelliJ.
   */
  private static boolean inIde() {
    Throwable runtimeException = new Throwable();
    runtimeException.fillInStackTrace();
    final StackTraceElement[] stackTrace =
        runtimeException.getStackTrace();
    StackTraceElement lastStackTraceElement =
        stackTrace[stackTrace.length - 1];

    // Junit test launched from IntelliJ 6.0
    if (lastStackTraceElement.getClassName().equals(
        "com.intellij.rt.execution.junit.JUnitStarter")
        && lastStackTraceElement.getMethodName().equals("main")) {
      return true;
    }

    // Application launched from IntelliJ 6.0
    if (lastStackTraceElement.getClassName().equals(
        "com.intellij.rt.execution.application.AppMain")
        && lastStackTraceElement.getMethodName().equals("main")) {
      return true;
    }
    return false;
  }

  /**
   * Returns a string containing the difference between the contents of two
   * files. The string has a similar format to the UNIX 'diff' utility.
   */
  public static String diff(File file1, File file2) {
    List<String> lines1 = fileLines(file1);
    List<String> lines2 = fileLines(file2);
    return diffLines(lines1, lines2);
  }

  /**
   * Returns a string containing the difference between the two sets of lines.
   */
  public static String diffLines(List<String> lines1, List<String> lines2) {
    final Diff<String> differencer = new Diff<>(lines1, lines2);
    final List<Difference> differences = differencer.execute();
    StringWriter sw = new StringWriter();
    int offset = 0;
    for (Difference d : differences) {
      final int as = d.getAddedStart() + 1;
      final int ae = d.getAddedEnd() + 1;
      final int ds = d.getDeletedStart() + 1;
      final int de = d.getDeletedEnd() + 1;
      if (ae == 0) {
        if (de == 0) {
          // no change
        } else {
          // a deletion: "<ds>,<de>d<as>"
          sw.append(String.valueOf(ds));
          if (de > ds) {
            sw.append(",").append(String.valueOf(de));
          }
          sw.append("d").append(String.valueOf(as - 1)).append('\n');
          for (int i = ds - 1; i < de; ++i) {
            sw.append("< ").append(lines1.get(i)).append('\n');
          }
        }
      } else {
        if (de == 0) {
          // an addition: "<ds>a<as,ae>"
          sw.append(String.valueOf(ds - 1)).append("a").append(
              String.valueOf(as));
          if (ae > as) {
            sw.append(",").append(String.valueOf(ae));
          }
          sw.append('\n');
          for (int i = as - 1; i < ae; ++i) {
            sw.append("> ").append(lines2.get(i)).append('\n');
          }
        } else {
          // a change: "<ds>,<de>c<as>,<ae>
          sw.append(String.valueOf(ds));
          if (de > ds) {
            sw.append(",").append(String.valueOf(de));
          }
          sw.append("c").append(String.valueOf(as));
          if (ae > as) {
            sw.append(",").append(String.valueOf(ae));
          }
          sw.append('\n');
          for (int i = ds - 1; i < de; ++i) {
            sw.append("< ").append(lines1.get(i)).append('\n');
          }
          sw.append("---\n");
          for (int i = as - 1; i < ae; ++i) {
            sw.append("> ").append(lines2.get(i)).append('\n');
          }
          offset = offset + (ae - as) - (de - ds);
        }
      }
    }
    return sw.toString();
  }

  /**
   * Returns a list of the lines in a given file.
   *
   * @param file File
   * @return List of lines
   */
  private static List<String> fileLines(File file) {
    List<String> lines = new ArrayList<>();
    try (LineNumberReader r = new LineNumberReader(Util.reader(file))) {
      String line;
      while ((line = r.readLine()) != null) {
        lines.add(line);
      }
      return lines;
    } catch (IOException e) {
      e.printStackTrace();
      throw TestUtil.rethrow(e);
    }
  }

  /**
   * Returns the contents of a file as a string.
   *
   * @param file File
   * @return Contents of the file
   */
  protected static String fileContents(File file) {
    byte[] buf = new byte[2048];
    try (FileInputStream reader = new FileInputStream(file)) {
      int readCount;
      final ByteArrayOutputStream writer = new ByteArrayOutputStream();
      while ((readCount = reader.read(buf)) >= 0) {
        writer.write(buf, 0, readCount);
      }
      return writer.toString(StandardCharsets.UTF_8.name());
    } catch (IOException e) {
      throw TestUtil.rethrow(e);
    }
  }

  /**
   * Sets whether to give verbose message if diff fails.
   */
  protected void setVerbose(boolean verbose) {
    this.verbose = verbose;
  }

  /**
   * Sets the diff masks that are common to .REF files
   */
  protected void setRefFileDiffMasks() {
    // mask out source control Id
    addDiffMask("\\$Id.*\\$");

    // NOTE hersker 2006-06-02:
    // The following two patterns can be used to mask out the
    // sqlline JDBC URI and continuation prompts. This is useful
    // during transition periods when URIs are changed, or when
    // new drivers are deployed which have their own URIs but
    // should first pass the existing test suite before their
    // own .ref files get checked in.
    //
    // It is not recommended to use these patterns on an everyday
    // basis. Real differences in the output are difficult to spot
    // when diff-ing .ref and .log files which have different
    // sqlline prompts at the start of each line.

    // mask out sqlline JDBC URI prompt
    addDiffMask("0: \\bjdbc(:[^:>]+)+:>");

    // mask out different-length sqlline continuation prompts
    addDiffMask("^(\\.\\s?)+>");
  }
}