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 }