View Javadoc
1   /*
2    * *************************************************************************************************************************************************************
3    *
4    * TheseFoolishThings: Miscellaneous utilities
5    * http://tidalwave.it/projects/thesefoolishthings
6    *
7    * Copyright (C) 2009 - 2025 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   *
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  
63      public static final String P_TABULAR_OUTPUT = P_BASE_NAME + ".tabularOutput";
64      public static final String P_TABULAR_LIMIT = P_BASE_NAME + ".tabularLimit";
65  
66      private static final boolean TABULAR_OUTPUT = Boolean.getBoolean(P_TABULAR_OUTPUT);
67      private static final int TABULAR_LIMIT = Integer.getInteger(P_TABULAR_LIMIT, 500);
68      private static final String TF = "TEST FAILED";
69  
70      /***********************************************************************************************************************************************************
71       * Asserts that two files have the same contents.
72       *
73       * @param   expectedFile    the file with the expected contents
74       * @param   actualFile      the file with the contents to probe
75       * @throws  IOException     in case of error
76       **********************************************************************************************************************************************************/
77      public static void assertSameContents (@Nonnull final File expectedFile, @Nonnull final File actualFile)
78        throws IOException
79        {
80          assertSameContents(expectedFile.toPath(), actualFile.toPath());
81        }
82  
83      /***********************************************************************************************************************************************************
84       * Asserts that two files have the same contents.
85       *
86       * @param   expectedPath    the file with the expected contents
87       * @param   actualPath      the file with the contents to probe
88       * @throws  IOException     in case of error
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     public static void assertSameContents (@Nonnull final List<String> expected, @Nonnull final List<String> actual)
105       {
106         assertSameContents(expected, actual, null, null);
107       }
108 
109     /***********************************************************************************************************************************************************
110      * Checks whether two files have the same contents.
111      *
112      * @param   expectedPath    the file with the expected contents
113      * @param   actualPath      the file with the contents to probe
114      * @return                  whether the two files have the same contents
115      * @throws  IOException     in case of error
116      * @since   1.2-ALPHA-15
117      **********************************************************************************************************************************************************/
118     public static boolean checkSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
119             throws IOException
120       {
121         return checkSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath)
122                 .isEmpty();
123       }
124 
125     /***********************************************************************************************************************************************************
126      * Converts a string which contains newlines into a list of strings.
127      *
128      * @param   string          the source
129      * @return                  the strings
130      * @throws  IOException     in case of error
131      **********************************************************************************************************************************************************/
132     @Nonnull
133     public static List<String> stringToStrings (@Nonnull final String string)
134       throws IOException
135       {
136         //return List.of(string.split("\n"));
137         return resourceToStrings(new ByteArrayInputStream(string.getBytes(UTF_8)));
138       }
139 
140     /***********************************************************************************************************************************************************
141      * Reads a file into a list of strings.
142      *
143      * @param   file            the file
144      * @return                  the strings
145      * @throws  IOException     in case of error
146      **********************************************************************************************************************************************************/
147     @Nonnull
148     public static List<String> fileToStrings (@Nonnull final Path file)
149       throws IOException
150       {
151         return Files.readAllLines(file);
152       }
153 
154     /***********************************************************************************************************************************************************
155      * Reads a classpath resource (not a regular file) into a list of strings.
156      *
157      * @param   path            the path of the classpath resource
158      * @return                  the strings
159      * @throws  IOException     in case of error
160      **********************************************************************************************************************************************************/
161     @Nonnull
162     public static List<String> resourceToStrings (@Nonnull final String path)
163       throws IOException
164       {
165         final var is = FileComparisonUtils.class.getClassLoader().getResourceAsStream(path);
166 
167         if (is == null)
168           {
169             throw new RuntimeException("Resource not found: " + path);
170           }
171 
172         return resourceToStrings(is);
173       }
174 
175     /***********************************************************************************************************************************************************
176      * Reads an input stream into a list of strings. The stream is closed at the end.
177      *
178      * @param   is              the input stream
179      * @return                  the strings
180      * @throws  IOException     in case of error
181      **********************************************************************************************************************************************************/
182     @Nonnull
183     public static List<String> resourceToStrings (@Nonnull final InputStream is)
184       throws IOException
185       {
186         try (final var br = new BufferedReader(new InputStreamReader(is, UTF_8)))
187           {
188             final var result = new ArrayList<String>();
189 
190             for (;;)
191               {
192                 final var s = br.readLine();
193 
194                 if (s == null)
195                   {
196                     break;
197                   }
198 
199                 result.add(s);
200               }
201 
202             return result;
203           }
204       }
205 
206     /***********************************************************************************************************************************************************
207      * Given a string that represents a path whose segments are separated by the standard separator of the platform,
208      * returns the common prefix - which means the common directory parents.
209      *
210      * @param   s1    the former string
211      * @param   s2    the latter string
212      * @return        the common prefix
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 
220         for (var i = 0; i < min; i++)
221           {
222             if (s1.charAt(i) != s2.charAt(i))
223               {
224                 return (i == 0) ? "" : s1.substring(0, Math.min(latestSeenSlash + 1, min));
225               }
226             else
227               {
228                 if (s1.charAt(i) == File.separatorChar)
229                   {
230                     latestSeenSlash = i;
231                   }
232               }
233           }
234 
235         return s1.substring(0, min);
236       }
237 
238     /***********************************************************************************************************************************************************
239      * Asserts that two collections of strings have the same contents.
240      *
241      * @param   expected        the expected values
242      * @param   actual          the actual values
243      * @param   expectedPath    an optional path for expected values
244      * @param   actualPath      an optional path for actual values
245      **********************************************************************************************************************************************************/
246     private static void assertSameContents (@Nonnull final List<String> expected,
247                                             @Nonnull final List<String> actual,
248                                             @Nullable final Path expectedPath,
249                                             @Nullable final Path actualPath)
250       {
251         final var diff = checkSameContents(expected, actual, expectedPath, actualPath);
252 
253         if (!diff.isEmpty())
254           {
255             throw new AssertionError(String.join(System.lineSeparator(), diff));
256           }
257       }
258 
259     /***********************************************************************************************************************************************************
260      * Checks whether two collections of strings have the same contents.
261      *
262      * @param   expected        the expected values
263      * @param   actual          the actual values
264      * @param   expectedPath    an optional path for expected values
265      * @param   actualPath      an optional path for actual values
266      * @return                  the differences
267      **********************************************************************************************************************************************************/
268     private static List<String> checkSameContents (@Nonnull final List<String> expected,
269                                                    @Nonnull final List<String> actual,
270                                                    @Nullable final Path expectedPath,
271                                                    @Nullable final Path actualPath)
272       {
273         final var deltas = DiffUtils.diff(expected, actual).getDeltas();
274 
275         if (deltas.isEmpty())
276           {
277             return List.of();
278           }
279 
280         if ((expectedPath != null) && (actualPath != null))
281           {
282             logPaths(expectedPath, actualPath, "TEST FAILED ");
283           }
284 
285         final var strings = toStrings(deltas);
286         strings.forEach(log::error);
287 
288         if (!TABULAR_OUTPUT)
289           {
290             log.error("{} You can set -D{}=true for tabular output; -D{}=<num> to set max table size",
291                       TF, P_TABULAR_OUTPUT, P_TABULAR_LIMIT);
292           }
293         else
294           {
295             final var generator = DiffRowGenerator.create()
296                                                   .showInlineDiffs(false)
297                                                   .inlineDiffByWord(true)
298                                                   .lineNormalizer(l -> l)
299                                                   .build();
300             final var pairs = generator.generateDiffRows(expected, actual)
301                                        .stream()
302                                        .filter(row -> !row.getNewLine().equals(row.getOldLine()))
303                                        .map(row -> Pair.of(row.getOldLine().trim(), row.getNewLine().trim()))
304                                        .limit(TABULAR_LIMIT)
305                                        .collect(toList());
306 
307             final var padA = pairs.stream().mapToInt(p -> p.a.length()).max().orElseThrow();
308             final var padB = pairs.stream().mapToInt(p -> p.b.length()).max().orElseThrow();
309             log.error("{} Tabular text is trimmed; row limit set to -D{}={}", TF, P_TABULAR_LIMIT, TABULAR_LIMIT);
310             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
311             log.error("{} | {} | {} |", TF, pad("expected", padA, ' '), pad("actual  ", padB, ' '));
312             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
313             pairs.forEach(p -> log.error("{} | {} | {} |", TF, pad(p.a, padA, ' '), pad(p.b, padB,' ')));
314             log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
315           }
316 
317         strings.add(0, "Unexpected contents: see log above (you can grep '" + TF + "')");
318         return strings;
319       }
320 
321     /***********************************************************************************************************************************************************
322      * Converts deltas to output as a list of strings.
323      *
324      * @param   deltas  the deltas
325      * @return          the strings
326      **********************************************************************************************************************************************************/
327     @Nonnull
328     private static List<String> toStrings (@Nonnull final Iterable<? extends AbstractDelta<String>> deltas)
329       {
330         final List<String> strings = new ArrayList<>();
331 
332         deltas.forEach(delta ->
333           {
334             final var sourceLines = delta.getSource().getLines();
335             final var targetLines = delta.getTarget().getLines();
336             final var sourcePosition = delta.getSource().getPosition() + 1;
337             final var targetPosition = delta.getTarget().getPosition() + 1;
338 
339             switch (delta.getType())
340               {
341                 case CHANGE:
342                   indexedPairStream(sourceLines).forEach(p -> strings.add(
343                           String.format("%s  exp[%d] *%s*", TF, sourcePosition + p.a, p.b)));
344                   indexedPairStream(targetLines).forEach(p -> strings.add(
345                           String.format("%s  act[%d] *%s*", TF, targetPosition + p.a, p.b)));
346                   break;
347 
348                 case DELETE:
349                   indexedPairStream(sourceLines).forEach(p -> strings.add(
350                           String.format("%s -act[%d] *%s*", TF, sourcePosition + p.a, p.b)));
351                   break;
352 
353                 case INSERT:
354                   indexedPairStream(targetLines).forEach(p -> strings.add(
355                           String.format("%s +act[%d] *%s*", TF, targetPosition + p.a, p.b)));
356                   break;
357 
358                 default:
359               }
360           });
361 
362         return strings;
363       }
364 
365     /***********************************************************************************************************************************************************
366      * Logs info about file comparison paths.
367      *
368      * @param expectedPath      the expected path
369      * @param actualPath        the actual path
370      * @param prefix            a log prefix
371      **********************************************************************************************************************************************************/
372     private static void logPaths (@Nonnull final Path expectedPath,
373                                   @Nonnull final Path actualPath,
374                                   @Nonnull final String prefix)
375       {
376         final var expectedPathAsString = expectedPath.toAbsolutePath().toString();
377         final var actualPathAsString = actualPath.toAbsolutePath().toString();
378         final var commonPath = commonPrefix(expectedPathAsString, actualPathAsString);
379         log.info("{}>>>> path is: {}", prefix, commonPath);
380         log.info("{}>>>> exp is:  {}", prefix, expectedPathAsString.substring(commonPath.length()));
381         log.info("{}>>>> act is:  {}", prefix, actualPathAsString.substring(commonPath.length()));
382       }
383 
384     /***********************************************************************************************************************************************************
385      * Pads a string to left to fit the given width.
386      *
387      * @param   string    the string
388      * @param   width     the width
389      * @return            the padded string
390      **********************************************************************************************************************************************************/
391     @Nonnull
392     private static String pad (@Nonnull final String string, @Nonnegative final int width, final char padding)
393       {
394         return String.format("%-" + width + "s", string).replace(' ', padding);
395       }
396   }