View Javadoc
1   /*
2    * *************************************************************************************************************************************************************
3    *
4    * TheseFoolishThings: Miscellaneous utilities
5    * http://tidalwave.it/projects/thesefoolishthings
6    *
7    * Copyright (C) 2009 - 2026 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 the License.
12   * 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 an "AS IS" BASIS, WITHOUT WARRANTIES OR
17   * CONDITIONS OF ANY KIND, either express or implied.  See the License for the specific language governing permissions and limitations under the License.
18   *
19   * *************************************************************************************************************************************************************
20   *
21   * git clone https://bitbucket.org/tidalwave/thesefoolishthings-src
22   * git clone https://github.com/tidalwave-it/thesefoolishthings-src
23   *
24   * *************************************************************************************************************************************************************
25   */
26  package it.tidalwave.util.test;
27  
28  import javax.annotation.Nonnegative;
29  import jakarta.annotation.Nonnull;
30  import jakarta.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  
51  /***************************************************************************************************************************************************************
52   * A utility class to compare two text files, or two lists of strings, and assert that they have the same contents. The comparison is performed line by line, by
53   * means of <a href="https://github.com/java-diff-utils/java-diff-utils">java-diff-utils</a>.
54   *
55   * <p>When the contents differ, the {@code assertSameContents()} methods throw an {@link AssertionError}, while {@link #checkSameContents(Path, Path)} returns
56   * {@code false}. In both cases the differences are logged at the {@code ERROR} level, with each line prefixed by {@code TEST FAILED}, so they can be easily
57   * found in the test log, for instance with {@code grep}. Line numbers start from 1, and lines are enclosed between asterisks, so leading and trailing spaces
58   * are visible:
59   * <ul>
60   * <li>{@code exp[n] *...*} followed by {@code act[m] *...*}: line {@code n} of the expected contents has been replaced by line {@code m} of the actual
61   * contents;</li>
62   * <li>{@code -act[n] *...*}: line {@code n} of the expected contents is missing from the actual contents;</li>
63   * <li>{@code +act[n] *...*}: line {@code n} of the actual contents is not present in the expected contents.</li>
64   * </ul>
65   * <p>When files are compared, their paths are logged at the {@code INFO} level, relative to their common directory.
66   *
67   * <p>The output can be customised by means of the following system properties:
68   * <ul>
69   * <li>{@code it.tidalwave.util.test.FileComparisonUtils.tabularOutput} (see {@link #P_TABULAR_OUTPUT}): if {@code true}, the differences are also logged as a
70   * table, with the expected lines on the left and the actual lines on the right (lines are trimmed). The default is {@code false};</li>
71   * <li>{@code it.tidalwave.util.test.FileComparisonUtils.tabularLimit} (see {@link #P_TABULAR_LIMIT}): the maximum number of rows of the table. The default is
72   * 500.</li>
73   * </ul>
74   * <p>The properties are read when this class is loaded, so they must be set before: for instance, on the command line ({@code -D...}), or in the configuration
75   * of the test runner (such as the {@code systemPropertyVariables} of the Maven Surefire plugin). When the tabular output is disabled, a reminder about these
76   * properties is logged after the differences.
77   * @author  Fabrizio Giudici
78   **************************************************************************************************************************************************************/
79  @UtilityClass @Slf4j
80  public class FileComparisonUtils
81    {
82      private static final String P_BASE_NAME = FileComparisonUtils.class.getName();
83  
84      public static final String P_TABULAR_OUTPUT = P_BASE_NAME + ".tabularOutput";
85      public static final String P_TABULAR_LIMIT = P_BASE_NAME + ".tabularLimit";
86  
87      private static final boolean TABULAR_OUTPUT = Boolean.getBoolean(P_TABULAR_OUTPUT);
88      private static final int TABULAR_LIMIT = Integer.getInteger(P_TABULAR_LIMIT, 500);
89      private static final String TF = "TEST FAILED";
90  
91      /***********************************************************************************************************************************************************
92       * Asserts that two files have the same contents.
93       * @param   expectedFile    the file with the expected contents
94       * @param   actualFile      the file with the contents to probe
95       * @throws  IOException     in case of error
96       **********************************************************************************************************************************************************/
97      public static void assertSameContents (@Nonnull final File expectedFile, @Nonnull final File actualFile)
98        throws IOException
99        {
100         assertSameContents(expectedFile.toPath(), actualFile.toPath());
101       }
102 
103     /***********************************************************************************************************************************************************
104      * Asserts that two files have the same contents.
105      * @param   expectedPath    the file with the expected contents
106      * @param   actualPath      the file with the contents to probe
107      * @throws  IOException     in case of error
108      **********************************************************************************************************************************************************/
109     public static void assertSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
110       throws IOException
111       {
112         log.info("******** Comparing files:");
113         logPaths(expectedPath, actualPath, "");
114         assertSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath);
115       }
116 
117     /***********************************************************************************************************************************************************
118      * Asserts that two collections of strings have the same contents.
119      * @param   expected        the expected values
120      * @param   actual          the actual values
121      **********************************************************************************************************************************************************/
122     public static void assertSameContents (@Nonnull final List<String> expected, @Nonnull final List<String> actual)
123       {
124         assertSameContents(expected, actual, null, null);
125       }
126 
127     /***********************************************************************************************************************************************************
128      * Checks whether two files have the same contents.
129      * @param   expectedPath    the file with the expected contents
130      * @param   actualPath      the file with the contents to probe
131      * @return                  whether the two files have the same contents
132      * @throws  IOException     in case of error
133      * @since   1.2-ALPHA-15
134      **********************************************************************************************************************************************************/
135     public static boolean checkSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
136             throws IOException
137       {
138         return checkSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath)
139                 .isEmpty();
140       }
141 
142     /***********************************************************************************************************************************************************
143      * Converts a string which contains newlines into a list of strings.
144      * @param   string          the source
145      * @return                  the strings
146      * @throws  IOException     in case of error
147      **********************************************************************************************************************************************************/
148     @Nonnull
149     public static List<String> stringToStrings (@Nonnull final String string)
150       throws IOException
151       {
152         //return List.of(string.split("\n"));
153         return resourceToStrings(new ByteArrayInputStream(string.getBytes(UTF_8)));
154       }
155 
156     /***********************************************************************************************************************************************************
157      * Reads a file into a list of strings.
158      * @param   file            the file
159      * @return                  the strings
160      * @throws  IOException     in case of error
161      **********************************************************************************************************************************************************/
162     @Nonnull
163     public static List<String> fileToStrings (@Nonnull final Path file)
164       throws IOException
165       {
166         return Files.readAllLines(file);
167       }
168 
169     /***********************************************************************************************************************************************************
170      * Reads a classpath resource (not a regular file) into a list of strings.
171      * @param   path            the path of the classpath resource
172      * @return                  the strings
173      * @throws  IOException     in case of error
174      **********************************************************************************************************************************************************/
175     @Nonnull
176     public static List<String> resourceToStrings (@Nonnull final String path)
177       throws IOException
178       {
179         final var is = FileComparisonUtils.class.getClassLoader().getResourceAsStream(path);
180 
181         if (is == null)
182           {
183             throw new RuntimeException("Resource not found: " + path);
184           }
185 
186         return resourceToStrings(is);
187       }
188 
189     /***********************************************************************************************************************************************************
190      * Reads an input stream into a list of strings. The stream is closed at the end.
191      * @param   is              the input stream
192      * @return                  the strings
193      * @throws  IOException     in case of error
194      **********************************************************************************************************************************************************/
195     @Nonnull
196     public static List<String> resourceToStrings (@Nonnull final InputStream is)
197       throws IOException
198       {
199         try (final var br = new BufferedReader(new InputStreamReader(is, UTF_8)))
200           {
201             final var result = new ArrayList<String>();
202 
203             for (;;)
204               {
205                 final var s = br.readLine();
206 
207                 if (s == null)
208                   {
209                     break;
210                   }
211 
212                 result.add(s);
213               }
214 
215             return result;
216           }
217       }
218 
219     /***********************************************************************************************************************************************************
220      * Given a string that represents a path whose segments are separated by the standard separator of the platform, returns the common prefix - which means the
221      * common directory parents.
222      * @param   s1    the former string
223      * @param   s2    the latter string
224      * @return        the common prefix
225      **********************************************************************************************************************************************************/
226     @Nonnull
227     public static String commonPrefix (@Nonnull final String s1, @Nonnull final String s2)
228       {
229         final var min = Math.min(s1.length(), s2.length());
230         var latestSeenSlash = 0;
231 
232         for (var i = 0; i < min; i++)
233           {
234             if (s1.charAt(i) != s2.charAt(i))
235               {
236                 return (i == 0) ? "" : s1.substring(0, Math.min(latestSeenSlash + 1, min));
237               }
238             else
239               {
240                 if (s1.charAt(i) == File.separatorChar)
241                   {
242                     latestSeenSlash = i;
243                   }
244               }
245           }
246 
247         return s1.substring(0, min);
248       }
249 
250     /***********************************************************************************************************************************************************
251      * Asserts that two collections of strings have the same contents.
252      * @param   expected        the expected values
253      * @param   actual          the actual values
254      * @param   expectedPath    an optional path for expected values
255      * @param   actualPath      an optional path for actual values
256      **********************************************************************************************************************************************************/
257     private static void assertSameContents (@Nonnull final List<String> expected,
258                                             @Nonnull final List<String> actual,
259                                             @Nullable final Path expectedPath,
260                                             @Nullable final Path actualPath)
261       {
262         final var diff = checkSameContents(expected, actual, expectedPath, actualPath);
263 
264         if (!diff.isEmpty())
265           {
266             throw new AssertionError(String.join(System.lineSeparator(), diff));
267           }
268       }
269 
270     /***********************************************************************************************************************************************************
271      * Checks whether two collections of strings have the same contents.
272      * @param   expected        the expected values
273      * @param   actual          the actual values
274      * @param   expectedPath    an optional path for expected values
275      * @param   actualPath      an optional path for actual values
276      * @return                  the differences
277      **********************************************************************************************************************************************************/
278     private static List<String> checkSameContents (@Nonnull final List<String> expected,
279                                                    @Nonnull final List<String> actual,
280                                                    @Nullable final Path expectedPath,
281                                                    @Nullable final Path actualPath)
282       {
283         final var deltas = DiffUtils.diff(expected, actual).getDeltas();
284 
285         if (deltas.isEmpty())
286           {
287             return List.of();
288           }
289 
290         if ((expectedPath != null) && (actualPath != null))
291           {
292             logPaths(expectedPath, actualPath, "TEST FAILED ");
293           }
294 
295         final var strings = toStrings(deltas);
296         strings.forEach(log::error);
297 
298         if (!TABULAR_OUTPUT)
299           {
300             log.error("{} You can set -D{}=true for tabular output; -D{}=<num> to set max table size",
301                       TF, P_TABULAR_OUTPUT, P_TABULAR_LIMIT);
302           }
303         else
304           {
305             final var generator = DiffRowGenerator.create()
306                                                   .showInlineDiffs(false)
307                                                   .inlineDiffByWord(true)
308                                                   .lineNormalizer(l -> l)
309                                                   .build();
310             final var pairs = generator.generateDiffRows(expected, actual)
311                                        .stream()
312                                        .filter(row -> !row.getNewLine().equals(row.getOldLine()))
313                                        .map(row -> Pair.of(row.getOldLine().trim(), row.getNewLine().trim()))
314                                        .limit(TABULAR_LIMIT)
315                                        .collect(toList());
316 
317             final var padA = pairs.stream().mapToInt(p -> p.a.length()).max().orElseThrow();
318             final var padB = pairs.stream().mapToInt(p -> p.b.length()).max().orElseThrow();
319             log.error("{} Tabular text is trimmed; row limit set to -D{}={}", TF, P_TABULAR_LIMIT, TABULAR_LIMIT);
320             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
321             log.error("{} | {} | {} |", TF, pad("expected", padA, ' '), pad("actual  ", padB, ' '));
322             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
323             pairs.forEach(p -> log.error("{} | {} | {} |", TF, pad(p.a, padA, ' '), pad(p.b, padB,' ')));
324             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
325           }
326 
327         strings.add(0, "Unexpected contents: see log above (you can grep '" + TF + "')");
328         return strings;
329       }
330 
331     /***********************************************************************************************************************************************************
332      * Converts deltas to output as a list of strings.
333      * @param   deltas  the deltas
334      * @return          the strings
335      **********************************************************************************************************************************************************/
336     @Nonnull
337     private static List<String> toStrings (@Nonnull final Iterable<? extends AbstractDelta<String>> deltas)
338       {
339         final List<String> strings = new ArrayList<>();
340 
341         deltas.forEach(delta ->
342           {
343             final var sourceLines = delta.getSource().getLines();
344             final var targetLines = delta.getTarget().getLines();
345             final var sourcePosition = delta.getSource().getPosition() + 1;
346             final var targetPosition = delta.getTarget().getPosition() + 1;
347 
348             switch (delta.getType())
349               {
350                 case CHANGE:
351                   indexedPairStream(sourceLines).forEach(p -> strings.add(
352                           String.format("%s  exp[%d] *%s*", TF, sourcePosition + p.a, p.b)));
353                   indexedPairStream(targetLines).forEach(p -> strings.add(
354                           String.format("%s  act[%d] *%s*", TF, targetPosition + p.a, p.b)));
355                   break;
356 
357                 case DELETE:
358                   indexedPairStream(sourceLines).forEach(p -> strings.add(
359                           String.format("%s -act[%d] *%s*", TF, sourcePosition + p.a, p.b)));
360                   break;
361 
362                 case INSERT:
363                   indexedPairStream(targetLines).forEach(p -> strings.add(
364                           String.format("%s +act[%d] *%s*", TF, targetPosition + p.a, p.b)));
365                   break;
366 
367                 default:
368               }
369           });
370 
371         return strings;
372       }
373 
374     /***********************************************************************************************************************************************************
375      * Logs info about file comparison paths.
376      * @param expectedPath      the expected path
377      * @param actualPath        the actual path
378      * @param prefix            a log prefix
379      **********************************************************************************************************************************************************/
380     private static void logPaths (@Nonnull final Path expectedPath,
381                                   @Nonnull final Path actualPath,
382                                   @Nonnull final String prefix)
383       {
384         final var expectedPathAsString = expectedPath.toAbsolutePath().toString();
385         final var actualPathAsString = actualPath.toAbsolutePath().toString();
386         final var commonPath = commonPrefix(expectedPathAsString, actualPathAsString);
387         log.info("{}>>>> path is: {}", prefix, commonPath);
388         log.info("{}>>>> exp is:  {}", prefix, expectedPathAsString.substring(commonPath.length()));
389         log.info("{}>>>> act is:  {}", prefix, actualPathAsString.substring(commonPath.length()));
390       }
391 
392     /***********************************************************************************************************************************************************
393      * Pads a string to left to fit the given width.
394      * @param   string    the string
395      * @param   width     the width
396      * @param   padding   the character used for padding
397      * @return            the padded string
398      **********************************************************************************************************************************************************/
399     @Nonnull
400     private static String pad (@Nonnull final String string, @Nonnegative final int width, final char padding)
401       {
402         return String.format("%-" + width + "s", string).replace(' ', padding);
403       }
404   }