FileComparisonUtils.java

  1. /*
  2.  * *********************************************************************************************************************
  3.  *
  4.  * TheseFoolishThings: Miscellaneous utilities
  5.  * http://tidalwave.it/projects/thesefoolishthings
  6.  *
  7.  * Copyright (C) 2009 - 2023 by Tidalwave s.a.s. (http://tidalwave.it)
  8.  *
  9.  * *********************************************************************************************************************
  10.  *
  11.  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  12.  * the License. You may obtain a copy of the License at
  13.  *
  14.  *     http://www.apache.org/licenses/LICENSE-2.0
  15.  *
  16.  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  17.  * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the
  18.  * specific language governing permissions and limitations under the License.
  19.  *
  20.  * *********************************************************************************************************************
  21.  *
  22.  * git clone https://bitbucket.org/tidalwave/thesefoolishthings-src
  23.  * git clone https://github.com/tidalwave-it/thesefoolishthings-src
  24.  *
  25.  * *********************************************************************************************************************
  26.  */
  27. package it.tidalwave.util.test;

  28. import javax.annotation.Nonnegative;
  29. import javax.annotation.Nonnull;
  30. import javax.annotation.Nullable;
  31. import java.util.ArrayList;
  32. import java.util.List;
  33. import java.io.BufferedReader;
  34. import java.io.ByteArrayInputStream;
  35. import java.io.File;
  36. import java.io.IOException;
  37. import java.io.InputStream;
  38. import java.io.InputStreamReader;
  39. import java.nio.file.Files;
  40. import java.nio.file.Path;
  41. import com.github.difflib.DiffUtils;
  42. import com.github.difflib.patch.AbstractDelta;
  43. import com.github.difflib.text.DiffRowGenerator;
  44. import it.tidalwave.util.Pair;
  45. import lombok.experimental.UtilityClass;
  46. import lombok.extern.slf4j.Slf4j;
  47. import static java.util.stream.Collectors.*;
  48. import static java.nio.charset.StandardCharsets.UTF_8;
  49. import static it.tidalwave.util.Pair.indexedPairStream;
  50. import static org.junit.Assert.fail;

  51. /***********************************************************************************************************************
  52.  *
  53.  * A utility class to compare two text files and assert that they have the same contents.
  54.  *
  55.  * @author  Fabrizio Giudici
  56.  *
  57.  **********************************************************************************************************************/
  58. @UtilityClass @Slf4j
  59. public class FileComparisonUtils
  60.   {
  61.     private static final String P_BASE_NAME = FileComparisonUtils.class.getName();

  62.     public static final String P_TABULAR_OUTPUT = P_BASE_NAME + ".tabularOutput";
  63.     public static final String P_TABULAR_LIMIT = P_BASE_NAME + ".tabularLimit";

  64.     private static final boolean TABULAR_OUTPUT = Boolean.getBoolean(P_TABULAR_OUTPUT);
  65.     private static final int TABULAR_LIMIT = Integer.getInteger(P_TABULAR_LIMIT, 500);
  66.     private static final String TF = "TEST FAILED";

  67.     /*******************************************************************************************************************
  68.      *
  69.      * Asserts that two files have the same contents.
  70.      *
  71.      * @param   expectedFile    the file with the expected contents
  72.      * @param   actualFile      the file with the contents to probe
  73.      * @throws  IOException     in case of error
  74.      *
  75.      ******************************************************************************************************************/
  76.     public static void assertSameContents (@Nonnull final File expectedFile, @Nonnull final File actualFile)
  77.       throws IOException
  78.       {
  79.         assertSameContents(expectedFile.toPath(), actualFile.toPath());
  80.       }

  81.     /*******************************************************************************************************************
  82.      *
  83.      * Asserts that two files have the same contents.
  84.      *
  85.      * @param   expectedPath    the file with the expected contents
  86.      * @param   actualPath      the file with the contents to probe
  87.      * @throws  IOException     in case of error
  88.      *
  89.      ******************************************************************************************************************/
  90.     public static void assertSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
  91.       throws IOException
  92.       {
  93.         log.info("******** Comparing files:");
  94.         logPaths(expectedPath, actualPath, "");
  95.         assertSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath);
  96.       }

  97.     /*******************************************************************************************************************
  98.      *
  99.      * Asserts that two collections of strings have the same contents.
  100.      *
  101.      * @param   expected        the expected values
  102.      * @param   actual          the actual values
  103.      *
  104.      ******************************************************************************************************************/
  105.     public static void assertSameContents (@Nonnull final List<String> expected, @Nonnull final List<String> actual)
  106.       {
  107.         assertSameContents(expected, actual, null, null);
  108.       }

  109.     /*******************************************************************************************************************
  110.      *
  111.      * Checks whether two files have the same contents.
  112.      *
  113.      * @param   expectedPath    the file with the expected contents
  114.      * @param   actualPath      the file with the contents to probe
  115.      * @throws  IOException     in case of error
  116.      * @since   1.2-ALPHA-15
  117.      *
  118.      ******************************************************************************************************************/
  119.     public static boolean checkSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
  120.             throws IOException
  121.       {
  122.         return checkSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath)
  123.                 .isEmpty();
  124.       }

  125.     /*******************************************************************************************************************
  126.      *
  127.      * Converts a string which contains newlines into a list of strings.
  128.      *
  129.      * @param   string          the source
  130.      * @return                  the strings
  131.      * @throws  IOException     in case of error
  132.      *
  133.      ******************************************************************************************************************/
  134.     @Nonnull
  135.     public static List<String> stringToStrings (@Nonnull final String string)
  136.       throws IOException
  137.       {
  138.         //return List.of(string.split("\n"));
  139.         return resourceToStrings(new ByteArrayInputStream(string.getBytes(UTF_8)));
  140.       }

  141.     /*******************************************************************************************************************
  142.      *
  143.      * Reads a file into a list of strings.
  144.      *
  145.      * @param   file            the file
  146.      * @return                  the strings
  147.      * @throws  IOException     in case of error
  148.      *
  149.      ******************************************************************************************************************/
  150.     @Nonnull
  151.     public static List<String> fileToStrings (@Nonnull final Path file)
  152.       throws IOException
  153.       {
  154.         return Files.readAllLines(file);
  155.       }

  156.     /*******************************************************************************************************************
  157.      *
  158.      * Reads a classpath resource (not a regular file) into a list of strings.
  159.      *
  160.      * @param   path            the path of the classpath resource
  161.      * @return                  the strings
  162.      * @throws  IOException     in case of error
  163.      *
  164.      ******************************************************************************************************************/
  165.     @Nonnull
  166.     public static List<String> resourceToStrings (@Nonnull final String path)
  167.       throws IOException
  168.       {
  169.         final var is = FileComparisonUtils.class.getClassLoader().getResourceAsStream(path);

  170.         if (is == null)
  171.           {
  172.             throw new RuntimeException("Resource not found: " + path);
  173.           }

  174.         return resourceToStrings(is);
  175.       }

  176.     /*******************************************************************************************************************
  177.      *
  178.      * Reads an input stream into a list of strings. The stream is closed at the end.
  179.      *
  180.      * @param   is              the input stream
  181.      * @return                  the strings
  182.      * @throws  IOException     in case of error
  183.      *
  184.      ******************************************************************************************************************/
  185.     @Nonnull
  186.     public static List<String> resourceToStrings (@Nonnull final InputStream is)
  187.       throws IOException
  188.       {
  189.         try (final var br = new BufferedReader(new InputStreamReader(is, UTF_8)))
  190.           {
  191.             final var result = new ArrayList<String>();

  192.             for (;;)
  193.               {
  194.                 final var s = br.readLine();

  195.                 if (s == null)
  196.                   {
  197.                     break;
  198.                   }

  199.                 result.add(s);
  200.               }

  201.             return result;
  202.           }
  203.       }

  204.     /*******************************************************************************************************************
  205.      *
  206.      * Given a string that represents a path whose segments are separated by the standard separator of the platform,
  207.      * returns the common prefix - which means the common directory parents.
  208.      *
  209.      * @param   s1    the former string
  210.      * @param   s2    the latter string
  211.      * @return        the common prefix
  212.      *
  213.      ******************************************************************************************************************/
  214.     @Nonnull
  215.     public static String commonPrefix (@Nonnull final String s1, @Nonnull final String s2)
  216.       {
  217.         final var min = Math.min(s1.length(), s2.length());
  218.         var latestSeenSlash = 0;

  219.         for (var i = 0; i < min; i++)
  220.           {
  221.             if (s1.charAt(i) != s2.charAt(i))
  222.               {
  223.                 return (i == 0) ? "" : s1.substring(0, Math.min(latestSeenSlash + 1, min));
  224.               }
  225.             else
  226.               {
  227.                 if (s1.charAt(i) == File.separatorChar)
  228.                   {
  229.                     latestSeenSlash = i;
  230.                   }
  231.               }
  232.           }

  233.         return s1.substring(0, min);
  234.       }

  235.     /*******************************************************************************************************************
  236.      *
  237.      * Asserts that two collections of strings have the same contents.
  238.      *
  239.      * @param   expected        the expected values
  240.      * @param   actual          the actual values
  241.      * @param   expectedPath    an optional path for expected values
  242.      * @param   actualPath      an optional path for actual values
  243.      *
  244.      ******************************************************************************************************************/
  245.     private static void assertSameContents (@Nonnull final List<String> expected,
  246.                                             @Nonnull final List<String> actual,
  247.                                             @Nullable final Path expectedPath,
  248.                                             @Nullable final Path actualPath)
  249.       {
  250.         final var diff = checkSameContents(expected, actual, expectedPath, actualPath);

  251.         if (!diff.isEmpty())
  252.           {
  253.             fail(String.join(System.lineSeparator(), diff));
  254.           }
  255.       }

  256.     /*******************************************************************************************************************
  257.      *
  258.      * Checks whether two collections of strings have the same contents.
  259.      *
  260.      * @param   expected        the expected values
  261.      * @param   actual          the actual values
  262.      * @param   expectedPath    an optional path for expected values
  263.      * @param   actualPath      an optional path for actual values
  264.      * @return                  the differences
  265.      *
  266.      ******************************************************************************************************************/
  267.     private static List<String> checkSameContents (@Nonnull final List<String> expected,
  268.                                                    @Nonnull final List<String> actual,
  269.                                                    @Nullable final Path expectedPath,
  270.                                                    @Nullable final Path actualPath)
  271.       {
  272.         final var deltas = DiffUtils.diff(expected, actual).getDeltas();

  273.         if (deltas.isEmpty())
  274.           {
  275.             return List.of();
  276.           }

  277.         if ((expectedPath != null) && (actualPath != null))
  278.           {
  279.             logPaths(expectedPath, actualPath, "TEST FAILED ");
  280.           }

  281.         final var strings = toStrings(deltas);
  282.         strings.forEach(log::error);

  283.         if (!TABULAR_OUTPUT)
  284.           {
  285.             log.error("{} You can set -D{}=true for tabular output; -D{}=<num> to set max table size",
  286.                       TF, P_TABULAR_OUTPUT, P_TABULAR_LIMIT);
  287.           }
  288.         else
  289.           {
  290.             final var generator = DiffRowGenerator.create()
  291.                                                   .showInlineDiffs(false)
  292.                                                   .inlineDiffByWord(true)
  293.                                                   .lineNormalizer(l -> l)
  294.                                                   .build();
  295.             final var pairs = generator.generateDiffRows(expected, actual)
  296.                                        .stream()
  297.                                        .filter(row -> !row.getNewLine().equals(row.getOldLine()))
  298.                                        .map(row -> Pair.of(row.getOldLine().trim(), row.getNewLine().trim()))
  299.                                        .limit(TABULAR_LIMIT)
  300.                                        .collect(toList());

  301.             final var padA = pairs.stream().mapToInt(p -> p.a.length()).max().getAsInt();
  302.             final var padB = pairs.stream().mapToInt(p -> p.b.length()).max().getAsInt();
  303.             log.error("{} Tabular text is trimmed; row limit set to -D{}={}", TF, P_TABULAR_LIMIT, TABULAR_LIMIT);
  304.             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
  305.             log.error("{} | {} | {} |", TF, pad("expected", padA, ' '), pad("actual  ", padB, ' '));
  306.             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
  307.             pairs.forEach(p -> log.error("{} | {} | {} |", TF, pad(p.a, padA, ' '), pad(p.b, padB,' ')));
  308.             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
  309.           }

  310.         strings.add(0, "Unexpected contents: see log above (you can grep '" + TF + "')");
  311.         return strings;
  312.       }

  313.     /*******************************************************************************************************************
  314.      *
  315.      * Converts deltas to output as a list of strings.
  316.      *
  317.      * @param   deltas  the deltas
  318.      * @return          the strings
  319.      *
  320.      ******************************************************************************************************************/
  321.     @Nonnull
  322.     private static List<String> toStrings (@Nonnull final Iterable<? extends AbstractDelta<String>> deltas)
  323.       {
  324.         final List<String> strings = new ArrayList<>();

  325.         deltas.forEach(delta ->
  326.           {
  327.             final var sourceLines = delta.getSource().getLines();
  328.             final var targetLines = delta.getTarget().getLines();
  329.             final var sourcePosition = delta.getSource().getPosition() + 1;
  330.             final var targetPosition = delta.getTarget().getPosition() + 1;

  331.             switch (delta.getType())
  332.               {
  333.                 case CHANGE:
  334.                   indexedPairStream(sourceLines).forEach(p -> strings.add(
  335.                           String.format("%s  exp[%d] *%s*", TF, sourcePosition + p.a, p.b)));
  336.                   indexedPairStream(targetLines).forEach(p -> strings.add(
  337.                           String.format("%s  act[%d] *%s*", TF, targetPosition + p.a, p.b)));
  338.                   break;

  339.                 case DELETE:
  340.                   indexedPairStream(sourceLines).forEach(p -> strings.add(
  341.                           String.format("%s -act[%d] *%s*", TF, sourcePosition + p.a, p.b)));
  342.                   break;

  343.                 case INSERT:
  344.                   indexedPairStream(targetLines).forEach(p -> strings.add(
  345.                           String.format("%s +act[%d] *%s*", TF, targetPosition + p.a, p.b)));
  346.                   break;

  347.                 default:
  348.               }
  349.           });

  350.         return strings;
  351.       }

  352.     /*******************************************************************************************************************
  353.      *
  354.      * Logs info about file comparison paths.
  355.      *
  356.      * @param expectedPath      the expected path
  357.      * @param actualPath        the actual path
  358.      * @param prefix            a log prefix
  359.      *
  360.      ******************************************************************************************************************/
  361.     private static void logPaths (@Nonnull final Path expectedPath,
  362.                                   @Nonnull final Path actualPath,
  363.                                   @Nonnull final String prefix)
  364.       {
  365.         final var expectedPathAsString = expectedPath.toAbsolutePath().toString();
  366.         final var actualPathAsString = actualPath.toAbsolutePath().toString();
  367.         final var commonPath = commonPrefix(expectedPathAsString, actualPathAsString);
  368.         log.info("{}>>>> path is: {}", prefix, commonPath);
  369.         log.info("{}>>>> exp is:  {}", prefix, expectedPathAsString.substring(commonPath.length()));
  370.         log.info("{}>>>> act is:  {}", prefix, actualPathAsString.substring(commonPath.length()));
  371.       }

  372.     /*******************************************************************************************************************
  373.      *
  374.      * Pads a string to left to fit the given width.
  375.      *
  376.      * @param   string    the string
  377.      * @param   width     the width
  378.      * @return            the padded string
  379.      *
  380.      ******************************************************************************************************************/
  381.     @Nonnull
  382.     private static String pad (@Nonnull final String string, @Nonnegative final int width, final char padding)
  383.       {
  384.         return String.format("%-" + width + "s", string).replace(' ', padding);
  385.       }
  386.   }