1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
54
55
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
72
73
74
75
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
85
86
87
88
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
100
101
102
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
111
112
113
114
115
116
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
127
128
129
130
131
132 @Nonnull
133 public static List<String> stringToStrings (@Nonnull final String string)
134 throws IOException
135 {
136
137 return resourceToStrings(new ByteArrayInputStream(string.getBytes(UTF_8)));
138 }
139
140
141
142
143
144
145
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
156
157
158
159
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
177
178
179
180
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
208
209
210
211
212
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
240
241
242
243
244
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
261
262
263
264
265
266
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
323
324
325
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
367
368
369
370
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
386
387
388
389
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 }