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 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
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 }