Skip to content

Commit 552444e

Browse files
matchers: containExactly displays potential suspects (#1552)
1 parent c9bf4ee commit 552444e

File tree

7 files changed

+234
-19
lines changed

7 files changed

+234
-19
lines changed

webtau-core-groovy/src/test/groovy/org/testingisdocumenting/webtau/data/datanode/ValueExtractorByPathTest.groovy

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.testingisdocumenting.webtau.data.datanode
1818

1919
import org.junit.Test
20+
import org.testingisdocumenting.webtau.data.ValuePath
2021

2122
class ValueExtractorByPathTest {
2223
@Test
@@ -45,4 +46,12 @@ class ValueExtractorByPathTest {
4546
ValueExtractorByPath.extractFromMapOrList([parent: [[child: 100], [another: 200]]], "parent[1].another.value")
4647
.should == null
4748
}
49+
50+
@Test
51+
void "starts with"() {
52+
new ValuePath("myActual[0].x").startsWith(new ValuePath("myActual[0]")).should == true
53+
new ValuePath("myActual[0][1]").startsWith(new ValuePath("myActual[0]")).should == true
54+
new ValuePath("myActual[0]x").startsWith(new ValuePath("myActual[0]")).should == false
55+
new ValuePath("myActual[0]").startsWith(new ValuePath("myActual[0]")).should == false
56+
}
4857
}

webtau-core/src/main/java/org/testingisdocumenting/webtau/data/ValuePath.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ public ValuePath(String path) {
3232
this.path = path;
3333
}
3434

35+
public boolean startsWith(ValuePath valuePath) {
36+
return path.startsWith(valuePath.path + ".") || path.startsWith(valuePath.path + "[");
37+
}
38+
3539
public ValuePath property(String propName) {
3640
return new ValuePath(isEmpty() ? propName : path + "." + propName);
3741
}

webtau-core/src/main/java/org/testingisdocumenting/webtau/expectation/contain/ContainExactlyMatcher.java

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,27 @@
2424
import org.testingisdocumenting.webtau.expectation.ExpectedValuesAware;
2525
import org.testingisdocumenting.webtau.expectation.ValueMatcher;
2626
import org.testingisdocumenting.webtau.expectation.equality.CompareToComparator;
27+
import org.testingisdocumenting.webtau.expectation.equality.CompareToResult;
28+
import org.testingisdocumenting.webtau.expectation.equality.ValuePathMessage;
2729
import org.testingisdocumenting.webtau.reporter.TokenizedMessage;
2830

2931
import java.util.*;
3032
import java.util.stream.Collectors;
3133
import java.util.stream.Stream;
3234

3335
import static org.testingisdocumenting.webtau.WebTauCore.*;
36+
import static org.testingisdocumenting.webtau.expectation.TokenizedReportUtils.*;
3437

3538
public class ContainExactlyMatcher implements ValueMatcher, ExpectedValuesAware, PrettyPrintable {
3639
private final Collection<Object> expectedList;
3740
private List<ValuePathWithValue<Object>> actualCopy;
3841
private List<ValuePathWithValue<Object>> expectedCopy;
3942

43+
private final Map<ValuePath, List<List<ValuePathMessage>>> notEqualMessagesByExpectedPath = new HashMap<>();
44+
private final List<ValuePathMessage> notEqualCandidateMessages = new ArrayList<>();
45+
private final List<ValuePathMessage> missingMessages = new ArrayList<>();
46+
private final List<ValuePathMessage> extraMessages = new ArrayList<>();
47+
4048
private CompareToComparator comparator;
4149

4250
public ContainExactlyMatcher(Collection<Object> expected) {
@@ -55,7 +63,13 @@ public Stream<Object> expectedValues() {
5563

5664
@Override
5765
public Set<ValuePath> mismatchedPaths() {
58-
return actualCopy.stream().map(ValuePathWithValue::getPath).collect(Collectors.toSet());
66+
Set<ValuePath> potentialPaths = Stream.concat(missingMessages.stream().map(ValuePathMessage::getActualPath),
67+
Stream.concat(extraMessages.stream().map(ValuePathMessage::getActualPath),
68+
notEqualCandidateMessages.stream().map(ValuePathMessage::getActualPath)))
69+
.collect(Collectors.toSet());
70+
return potentialPaths.isEmpty() ?
71+
actualCopy.stream().map(ValuePathWithValue::getPath).collect(Collectors.toSet()) :
72+
potentialPaths;
5973
}
6074

6175
@Override
@@ -77,20 +91,26 @@ public TokenizedMessage mismatchedTokenizedMessage(ValuePath actualPath, Object
7791
expectedCopy.stream().map(ValuePathWithValue::getValue).toList());
7892
}
7993

80-
if (!actualCopy.isEmpty()) {
94+
if (!actualCopy.isEmpty() && notEqualCandidateMessages.isEmpty()) {
8195
if (!messageTokens.isEmpty()) {
8296
messageTokens = messageTokens.newLine();
8397
}
8498
messageTokens = messageTokens.error("unexpected elements").colon().value(
8599
actualCopy.stream().map(ValuePathWithValue::getValue).toList());
86100
}
87101

102+
if (!notEqualCandidateMessages.isEmpty() || !missingMessages.isEmpty() || !extraMessages.isEmpty()) {
103+
messageTokens = messageTokens.newLine().add(generatePossibleMismatchesReport(actualPath));
104+
}
105+
88106
return messageTokens;
89107
}
90108

91109
@Override
92110
public boolean matches(ValuePath actualPath, Object actualIterable) {
93-
return matches(comparator, actualPath, actualIterable);
111+
boolean result = matches(comparator, actualPath, actualIterable, true);
112+
notEqualCandidateMessages.addAll(extractPotentialNotEqualMessages());
113+
return result;
94114
}
95115

96116
@Override
@@ -111,7 +131,7 @@ public TokenizedMessage negativeMismatchedTokenizedMessage(ValuePath actualPath,
111131

112132
@Override
113133
public boolean negativeMatches(ValuePath actualPath, Object actualIterable) {
114-
return !matches(comparator, actualPath, actualIterable);
134+
return !matches(comparator, actualPath, actualIterable, false);
115135
}
116136

117137
@Override
@@ -128,7 +148,7 @@ public String toString() {
128148
}
129149

130150
@SuppressWarnings("unchecked")
131-
private boolean matches(CompareToComparator comparator, ValuePath actualPath, Object actualIterable) {
151+
private boolean matches(CompareToComparator comparator, ValuePath actualPath, Object actualIterable, boolean collectSuspects) {
132152
if (!(actualIterable instanceof Iterable)) {
133153
return false;
134154
}
@@ -140,17 +160,84 @@ private boolean matches(CompareToComparator comparator, ValuePath actualPath, Ob
140160
while (expectedIt.hasNext()) {
141161
ValuePathWithValue<Object> expected = expectedIt.next();
142162
Iterator<ValuePathWithValue<Object>> actualIt = actualCopy.iterator();
163+
164+
// collect mismatches for each remaining actual value
165+
// find elements with the largest number of mismatches
166+
// remember those elements as suspects per expected value
167+
List<CompareToResult> compareToResults = new ArrayList<>();
168+
boolean found = false;
143169
while (actualIt.hasNext()) {
144170
ValuePathWithValue<Object> actual = actualIt.next();
145-
boolean isEqual = comparator.compareIsEqual(actual.getPath(), actual.getValue(), expected.getValue());
146-
if (isEqual) {
171+
CompareToResult compareToResult = comparator.compareUsingEqualOnly(actual.getPath(), actual.getValue(), expected.getValue());
172+
if (compareToResult.isEqual()) {
147173
actualIt.remove();
148174
expectedIt.remove();
175+
found = true;
149176
break;
150177
}
178+
179+
compareToResults.add(compareToResult);
180+
}
181+
182+
if (!found && collectSuspects) {
183+
notEqualMessagesByExpectedPath.put(expected.getPath(),
184+
compareToResults.stream().map(CompareToResult::getNotEqualMessages).toList());
185+
186+
compareToResults.forEach(r -> missingMessages.addAll(r.getMissingMessages()));
187+
compareToResults.forEach(r -> extraMessages.addAll(r.getExtraMessages()));
151188
}
152189
}
153190

154191
return actualCopy.isEmpty() && expectedCopy.isEmpty();
155192
}
193+
194+
private List<ValuePathMessage> extractPotentialNotEqualMessages() {
195+
List<ValuePath> actualPaths = actualCopy.stream().map(ValuePathWithValue::getPath).toList();
196+
List<ValuePathMessage> notEqualCandidateMessages = new ArrayList<>();
197+
for (ValuePathWithValue<Object> expectedWithPath : expectedCopy) {
198+
List<List<ValuePathMessage>> notEqualMessageBatches = notEqualMessagesByExpectedPath.get(expectedWithPath.getPath());
199+
if (notEqualMessageBatches == null) {
200+
continue;
201+
}
202+
203+
// remove all the messages that were matched against eventually matched actual values
204+
notEqualMessageBatches = notEqualMessageBatches.stream()
205+
.filter(batch -> {
206+
if (batch.isEmpty()) {
207+
return false;
208+
}
209+
210+
ValuePathMessage firstMessage = batch.get(0);
211+
return actualPaths.stream().anyMatch(path -> firstMessage.getActualPath().startsWith(path));
212+
})
213+
.toList();
214+
215+
216+
// need to find a subset that has the least amount of mismatches
217+
// it will be a potential mismatch detail to display,
218+
//
219+
int minNumberOMismatches = notEqualMessageBatches.stream()
220+
.map(List::size)
221+
.min(Integer::compareTo).orElse(0);
222+
223+
List<List<ValuePathMessage>> messagesWithMinFailures = notEqualMessageBatches.stream()
224+
.filter(v -> v.size() == minNumberOMismatches).toList();
225+
226+
if (notEqualMessageBatches.size() != messagesWithMinFailures.size()) {
227+
messagesWithMinFailures.forEach(notEqualCandidateMessages::addAll);
228+
}
229+
}
230+
231+
return notEqualCandidateMessages;
232+
}
233+
234+
private TokenizedMessage generatePossibleMismatchesReport(ValuePath topLevelActualPath) {
235+
return combineReportParts(
236+
generateReportPart(topLevelActualPath, tokenizedMessage().error("possible mismatches"),
237+
Collections.singletonList(notEqualCandidateMessages)),
238+
generateReportPart(topLevelActualPath, tokenizedMessage().error("missing values"),
239+
Collections.singletonList(missingMessages)),
240+
generateReportPart(topLevelActualPath, tokenizedMessage().error("extra values"),
241+
Collections.singletonList(extraMessages)));
242+
}
156243
}

webtau-core/src/main/java/org/testingisdocumenting/webtau/expectation/equality/CompareToResult.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/*
2+
* Copyright 2023 webtau maintainers
23
* Copyright 2019 TWO SIGMA OPEN SOURCE, LLC
34
*
45
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -27,6 +28,12 @@ public class CompareToResult {
2728
private List<ValuePathMessage> missingMessages = new ArrayList<>();
2829
private List<ValuePathMessage> extraMessages = new ArrayList<>();
2930

31+
public int numberOfMismatches() {
32+
return notEqualMessages.size() +
33+
greaterMessages.size() + lessMessages.size() +
34+
missingMessages.size() + extraMessages.size();
35+
}
36+
3037
public boolean isEqual() {
3138
return notEqualMessages.isEmpty() && hasNoExtraAndNoMissing();
3239
}

webtau-core/src/test/groovy/org/testingisdocumenting/webtau/expectation/contain/ContainExactlyMatcherGroovyTest.groovy

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,18 @@ import static org.testingisdocumenting.webtau.WebTauCore.*
2424
class ContainExactlyMatcherGroovyTest {
2525
@Test
2626
void matchRecordsAndMaps() {
27-
// records-and-maps-example
28-
def list = [new Person("id1", 3, 10),
29-
new Person("id2", 4, 20),
30-
new Person("id2", 4, 20)]
31-
32-
actual(list).should(containExactly(
33-
[id: "id2", level: 4, monthsAtCompany: 20],
34-
[id: "id1", level: 3, monthsAtCompany: 10],
35-
[id: "id2", level: 4, monthsAtCompany: 20]))
36-
// records-and-maps-example
27+
code {
28+
// possible-mismatches-example
29+
def list = [
30+
new Person("id1", 3, 12),
31+
new Person("id1", 4, 10),
32+
new Person("id2", 4, 20)
33+
]
34+
actual(list).should(containExactly(
35+
[id: "id2", level: 4, monthsAtCompany: 20],
36+
[id: "id1", level: 8, monthsAtCompany: 10],
37+
[id: "id1", level: 7, monthsAtCompany: 12]))
38+
// possible-mismatches-example
39+
} should throwException(AssertionError)
3740
}
3841
}

webtau-core/src/test/java/org/testingisdocumenting/webtau/expectation/contain/ContainExactlyMatcherJavaTest.java

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,104 @@ public void missingDuplicatedValueRecordsAndMaps() {
7979
});
8080
}
8181

82+
@Test
83+
public void suspectCandidateValueRecordsAndMaps() {
84+
runExpectExceptionCaptureAndValidateOutput(AssertionError.class, "possible-mismatches-output", """
85+
X failed expecting [value] to contain exactly [
86+
{"id": "id2", "level": 4, "monthsAtCompany": 20},
87+
{"id": "id1", "level": 8, "monthsAtCompany": 10},
88+
{"id": "id1", "level": 7, "monthsAtCompany": 12}
89+
]:
90+
no matches found for: [{"id": "id1", "level": 8, "monthsAtCompany": 10}, {"id": "id1", "level": 7, "monthsAtCompany": 12}]
91+
possible mismatches:
92+
\s
93+
[value][1].level: actual: 4 <java.lang.Integer>
94+
expected: 8 <java.lang.Integer>
95+
[value][0].level: actual: 3 <java.lang.Integer>
96+
expected: 7 <java.lang.Integer> (Xms)
97+
\s
98+
[
99+
{"id": "id1", "level": **3**, "monthsAtCompany": 12},
100+
{"id": "id1", "level": **4**, "monthsAtCompany": 10},
101+
{"id": "id2", "level": 4, "monthsAtCompany": 20}
102+
]""", () -> {
103+
104+
// possible-mismatches-example
105+
List<?> list = list(
106+
new Person("id1", 3, 12),
107+
new Person("id1", 4, 10),
108+
new Person("id2", 4, 20));
109+
110+
actual(list).should(containExactly(
111+
map("id", "id2", "level", 4, "monthsAtCompany", 20),
112+
map("id", "id1", "level", 8, "monthsAtCompany", 10),
113+
map("id", "id1", "level", 7, "monthsAtCompany", 12)));
114+
// possible-mismatches-example
115+
});
116+
}
117+
118+
@Test
119+
public void suspectCandidateWithMissing() {
120+
runExpectExceptionAndValidateOutput(AssertionError.class, """
121+
X failed expecting [value] to contain exactly [
122+
{"id": "id1", "level": 8, "monthsAtCompany": 10},
123+
{"id": "id1", "level": 7, "monthsAtCompany": 12},
124+
{"id": "id2", "level": 4, "monthsAtCompany": 20}
125+
]:
126+
no matches found for: [{"id": "id1", "level": 8, "monthsAtCompany": 10}]
127+
unexpected elements: [{"id": "id1", "level": 5}]
128+
missing values:
129+
\s
130+
[value][0].monthsAtCompany: 10 (Xms)
131+
\s
132+
[
133+
{"id": "id1", "level": 5, "monthsAtCompany": **<missing>**},
134+
{"id": "id1", "level": 7, "monthsAtCompany": 12},
135+
{"id": "id2", "level": 4, "monthsAtCompany": 20}
136+
]""", () -> {
137+
List<?> list = list(
138+
map("id", "id1", "level", 5),
139+
map("id", "id1", "level", 7, "monthsAtCompany", 12),
140+
map("id", "id2", "level", 4, "monthsAtCompany", 20));
141+
142+
actual(list).should(containExactly(
143+
map("id", "id1", "level", 8, "monthsAtCompany", 10),
144+
map("id", "id1", "level", 7, "monthsAtCompany", 12),
145+
map("id", "id2", "level", 4, "monthsAtCompany", 20)));
146+
});
147+
}
148+
149+
@Test
150+
public void suspectCandidateWithExtra() {
151+
runExpectExceptionAndValidateOutput(AssertionError.class, """
152+
X failed expecting [value] to contain exactly [
153+
{"id": "id1", "level": 8, "monthsAtCompany": 10},
154+
{"id": "id1", "level": 7, "monthsAtCompany": 12},
155+
{"id": "id2", "level": 4, "monthsAtCompany": 20}
156+
]:
157+
no matches found for: [{"id": "id1", "level": 8, "monthsAtCompany": 10}]
158+
unexpected elements: [{"id": "id1", "level": 5, "monthsAtCompany": 14, "salary": "yes"}]
159+
extra values:
160+
\s
161+
[value][0].salary: "yes" (Xms)
162+
\s
163+
[
164+
{"id": "id1", "level": 5, "monthsAtCompany": 14, "salary": **"yes"**},
165+
{"id": "id1", "level": 7, "monthsAtCompany": 12},
166+
{"id": "id2", "level": 4, "monthsAtCompany": 20}
167+
]""", () -> {
168+
List<?> list = list(
169+
map("id", "id1", "level", 5, "monthsAtCompany", 14, "salary", "yes"),
170+
map("id", "id1", "level", 7, "monthsAtCompany", 12),
171+
map("id", "id2", "level", 4, "monthsAtCompany", 20));
172+
173+
actual(list).should(containExactly(
174+
map("id", "id1", "level", 8, "monthsAtCompany", 10),
175+
map("id", "id1", "level", 7, "monthsAtCompany", 12),
176+
map("id", "id2", "level", 4, "monthsAtCompany", 20)));
177+
});
178+
}
179+
82180
@Test
83181
public void mismatchValue() {
84182
runExpectExceptionAndValidateOutput(AssertionError.class, """

webtau-docs/znai/matchers/contain-exactly.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@ Use `:identifier: containExactly` to match two collections of elements in any or
77
```tabs
88
Groovy:
99
:include-file: org/testingisdocumenting/webtau/expectation/contain/ContainExactlyMatcherGroovyTest.groovy {
10-
surroundedBy: "records-and-maps-example"
10+
surroundedBy: "possible-mismatches-example"
1111
}
1212
Java:
1313
:include-file: org/testingisdocumenting/webtau/expectation/contain/ContainExactlyMatcherJavaTest.java {
14-
surroundedBy: "records-and-maps-example"
14+
surroundedBy: "possible-mismatches-example"
1515
}
1616
```
1717

18+
Console output displays potential mismatches to help with investigation:
19+
20+
:include-cli-output: doc-artifacts/possible-mismatches-output.txt {
21+
title: "console output"
22+
}
23+
24+
1825
Note: If you have a clear key column(s) defined, consider using `TableData` as [expected values](matchers/java-beans-and-records#java-beans-equal-table-data)

0 commit comments

Comments
 (0)