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.InputStream;
  34. import java.io.BufferedReader;
  35. import java.io.ByteArrayInputStream;
  36. import java.io.File;
  37. import java.io.FileInputStream;
  38. import java.io.IOException;
  39. import java.io.InputStreamReader;
  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 lombok.Data;
  45. import lombok.RequiredArgsConstructor;
  46. import lombok.extern.slf4j.Slf4j;
  47. import lombok.experimental.UtilityClass;
  48. import static java.util.stream.Collectors.toList;
  49. import static java.nio.charset.StandardCharsets.*;
  50. import static it.tidalwave.util.Pair.indexedPairStream;
  51. import static org.junit.Assert.*;

  52. /***********************************************************************************************************************
  53.  *
  54.  * A utility class to compare two text files and assert that they have the same contents.
  55.  *
  56.  * @author  Fabrizio Giudici
  57.  *
  58.  **********************************************************************************************************************/
  59. @UtilityClass @Slf4j
  60. public class FileComparisonUtils
  61.   {
  62.     @Data @RequiredArgsConstructor(staticName = "of")
  63.     static class Tuple { public final String a, b; }

  64.     private static final String P_BASE_NAME = FileComparisonUtils.class.getName();

  65.     public static final String P_TABULAR_OUTPUT = P_BASE_NAME + ".tabularOutput";
  66.     public static final String P_TABULAR_LIMIT = P_BASE_NAME + ".tabularLimit";

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

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

  84.     /*******************************************************************************************************************
  85.      *
  86.      * Asserts that two files have the same contents.
  87.      *
  88.      * @param   expectedFile    the file with the expected contents
  89.      * @param   actualFile      the file with the contents to probe
  90.      * @throws  IOException     in case of error
  91.      *
  92.      ******************************************************************************************************************/
  93.     public static void assertSameContents (@Nonnull final File expectedFile, @Nonnull final File actualFile)
  94.       throws IOException
  95.       {
  96.         final String expectedPath = expectedFile.getAbsolutePath();
  97.         final String actualPath = actualFile.getAbsolutePath();
  98.         log.info("******** Comparing files:");
  99.         logPaths(expectedPath, actualPath, "");
  100.         assertSameContents(fileToStrings(expectedFile), fileToStrings(actualFile), expectedPath, actualPath);
  101.       }

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

  114.     /*******************************************************************************************************************
  115.      *
  116.      * Converts a string which contains newlines into a list of strings.
  117.      *
  118.      * @param   string          the source
  119.      * @return                  the strings
  120.      * @throws  IOException     in case of error
  121.      *
  122.      ******************************************************************************************************************/
  123.     @Nonnull
  124.     public static List<String> stringToStrings (@Nonnull final String string)
  125.       throws IOException
  126.       {
  127.         //return Arrays.asList(string.split("\n"));
  128.         return fileToStrings(new ByteArrayInputStream(string.getBytes(UTF_8)));
  129.       }

  130.     /*******************************************************************************************************************
  131.      *
  132.      * Reads a file into a list of strings.
  133.      *
  134.      * @param   file            the file
  135.      * @return                  the strings
  136.      * @throws  IOException     in case of error
  137.      *
  138.      ******************************************************************************************************************/
  139.     @Nonnull
  140.     public static List<String> fileToStrings (@Nonnull final File file)
  141.       throws IOException
  142.       {
  143.         return fileToStrings(new FileInputStream(file));
  144.       }

  145.     /*******************************************************************************************************************
  146.      *
  147.      * Reads a classpath resource into a list of strings.
  148.      *
  149.      * @param   path            the path of the classpath resource
  150.      * @return                  the strings
  151.      * @throws  IOException     in case of error
  152.      *
  153.      ******************************************************************************************************************/
  154.     @Nonnull
  155.     public static List<String> fileToStrings (@Nonnull final String path)
  156.       throws IOException
  157.       {
  158.         final InputStream is = FileComparisonUtils.class.getClassLoader().getResourceAsStream(path);

  159.         if (is == null)
  160.           {
  161.             throw new RuntimeException("Resource not found: " + path);
  162.           }

  163.         return fileToStrings(is);
  164.       }

  165.     /*******************************************************************************************************************
  166.      *
  167.      * Reads an input stream into a list of strings.
  168.      *
  169.      * @param   is              the input stream
  170.      * @return                  the strings
  171.      * @throws  IOException     in case of error
  172.      *
  173.      ******************************************************************************************************************/
  174.     @Nonnull
  175.     public static List<String> fileToStrings (@Nonnull final InputStream is)
  176.       throws IOException
  177.       {
  178.         try (final BufferedReader br = new BufferedReader(new InputStreamReader(is, UTF_8)))
  179.           {
  180.             final List<String> result = new ArrayList<>();

  181.             for (;;)
  182.               {
  183.                 final String s = br.readLine();

  184.                 if (s == null)
  185.                   {
  186.                     break;
  187.                   }

  188.                 result.add(s);
  189.               }

  190.             return result;
  191.           }
  192.       }

  193.     /*******************************************************************************************************************
  194.      *
  195.      * Given a string that represents a path whose segments are separated by the standard separator of the platform,
  196.      * returns the common prefix - which means the common directory parents.
  197.      *
  198.      * @param   s1    the former string
  199.      * @param   s2    the latter string
  200.      * @return        the common prefix
  201.      *
  202.      ******************************************************************************************************************/
  203.     @Nonnull
  204.     public static String commonPrefix (@Nonnull final String s1, @Nonnull final String s2)
  205.       {
  206.         final int min = Math.min(s1.length(), s2.length());
  207.         int latestSeenSlash = 0;

  208.         for (int i = 0; i < min; i++)
  209.           {
  210.             if (s1.charAt(i) != s2.charAt(i))
  211.               {
  212.                 return (i == 0) ? "" : s1.substring(0, Math.min(latestSeenSlash + 1, min));
  213.               }
  214.             else
  215.               {
  216.                 if (s1.charAt(i) == File.separatorChar)
  217.                   {
  218.                     latestSeenSlash = i;
  219.                   }
  220.               }
  221.           }

  222.         return s1.substring(0, min);
  223.       }

  224.     /*******************************************************************************************************************
  225.      *
  226.      * Asserts that two collections of strings have the same contents.
  227.      *
  228.      * @param   expected        the expected values
  229.      * @param   actual          the actual values
  230.      * @param   expectedPath    an optional path for expected values
  231.      * @param   actualPath      an optional path for actual values
  232.      *
  233.      ******************************************************************************************************************/
  234.     private static void assertSameContents (@Nonnull final List<String> expected,
  235.                                             @Nonnull final List<String> actual,
  236.                                             @Nullable final String expectedPath,
  237.                                             @Nullable final String actualPath)
  238.       {
  239.         final List<AbstractDelta<String>> deltas = DiffUtils.diff(expected, actual).getDeltas();

  240.         if (!deltas.isEmpty())
  241.           {
  242.             if ((expectedPath != null) && (actualPath != null))
  243.               {
  244.                 logPaths(expectedPath, actualPath, "TEST FAILED ");
  245.               }

  246.             final List<String> strings = toStrings(deltas);
  247.             strings.forEach(log::error);

  248.             if (!TABULAR_OUTPUT)
  249.               {
  250.                 log.error("{} You can set -D{}=true for tabular output; -D{}=<num> to set max table size",
  251.                           TF, P_TABULAR_OUTPUT, P_TABULAR_LIMIT);
  252.               }
  253.             else
  254.               {
  255.                 final DiffRowGenerator generator = DiffRowGenerator.create()
  256.                         .showInlineDiffs(false)
  257.                         .inlineDiffByWord(true)
  258.                         .lineNormalizer(l -> l)
  259.                         .build();
  260.                 final List<Tuple> tuples = generator.generateDiffRows(expected, actual)
  261.                         .stream()
  262.                         .filter(row -> !row.getNewLine().equals(row.getOldLine()))
  263.                         .map(row -> Tuple.of(row.getOldLine().trim(), row.getNewLine().trim()))
  264.                         .limit(TABULAR_LIMIT)
  265.                         .collect(toList());

  266.                 final int padA = tuples.stream().mapToInt(p -> p.a.length()).max().getAsInt();
  267.                 final int padB = tuples.stream().mapToInt(p -> p.b.length()).max().getAsInt();
  268.                 log.error("{} Tabular text is trimmed; row limit set to -D{}={}",
  269.                           TF, P_TABULAR_LIMIT, TABULAR_LIMIT);
  270.                 log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
  271.                 log.error("{} | {} | {} |", TF, pad("expected", padA, ' '), pad("actual  ", padB, ' '));
  272.                 log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
  273.                 tuples.forEach(p -> log.error("{} | {} | {} |", TF, pad(p.a, padA, ' '), pad(p.b, padB,' ')));
  274.                 log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
  275.               }

  276.             strings.add(0, "Unexpected contents: see log above (you can grep '" + TF + "')");
  277.             fail(String.join(System.lineSeparator(), strings));
  278.           }
  279.       }

  280.     /*******************************************************************************************************************
  281.      *
  282.      * Converts deltas to output as a list of strings.
  283.      *
  284.      * @param   deltas  the deltas
  285.      * @return          the strings
  286.      *
  287.      ******************************************************************************************************************/
  288.     @Nonnull
  289.     private static List<String> toStrings (@Nonnull final Iterable<AbstractDelta<String>> deltas)
  290.       {
  291.         final List<String> strings = new ArrayList<>();

  292.         deltas.forEach(delta ->
  293.           {
  294.             final List<String> sourceLines = delta.getSource().getLines();
  295.             final List<String> targetLines = delta.getTarget().getLines();
  296.             final int sourcePosition = delta.getSource().getPosition() + 1;
  297.             final int targetPosition = delta.getTarget().getPosition() + 1;

  298.             switch (delta.getType())
  299.               {
  300.                 case CHANGE:
  301.                   indexedPairStream(sourceLines).forEach(p -> strings.add(
  302.                           String.format("%s  exp[%d] *%s*", TF, sourcePosition + p.a, p.b)));
  303.                   indexedPairStream(targetLines).forEach(p -> strings.add(
  304.                           String.format("%s  act[%d] *%s*", TF, targetPosition + p.a, p.b)));
  305.                   break;

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

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

  314.                 default:
  315.               }
  316.           });

  317.         return strings;
  318.       }

  319.     /*******************************************************************************************************************
  320.      *
  321.      * Logs info about file comparison paths.
  322.      *
  323.      * @param expectedPath      the expected path
  324.      * @param actualPath        the actual path
  325.      * @param prefix            a log prefix
  326.      *
  327.      ******************************************************************************************************************/
  328.     private static void logPaths (@Nonnull final String expectedPath,
  329.                                   @Nonnull final String actualPath,
  330.                                   @Nonnull final String prefix)
  331.       {
  332.         final String commonPath = commonPrefix(expectedPath, actualPath);
  333.         log.info("{}>>>> path is: {}", prefix, commonPath);
  334.         log.info("{}>>>> exp is:  {}", prefix, expectedPath.substring(commonPath.length()));
  335.         log.info("{}>>>> act is:  {}", prefix, actualPath.substring(commonPath.length()));
  336.       }

  337.     /*******************************************************************************************************************
  338.      *
  339.      * Pads a string to left to fit the given width.
  340.      *
  341.      * @param   string    the string
  342.      * @param   width     the width
  343.      * @return            the padded string
  344.      *
  345.      ******************************************************************************************************************/
  346.     @Nonnull
  347.     private static String pad (@Nonnull final String string, @Nonnegative final int width, final char padding)
  348.       {
  349.         return String.format("%-" + width + "s", string).replace(' ', padding);
  350.       }
  351.   }