Skip to content

Commit c846fd3

Browse files
author
Steven Yuan
committed
Add Smithy Diff test runner
This is strongly based on `SmithyTestSuite` and `SmithyTestCase`. Also adds example diff tests.
1 parent 2883dc7 commit c846fd3

29 files changed

+1096
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.diff.testrunner;
7+
8+
import static java.lang.String.format;
9+
10+
import java.nio.file.Path;
11+
import java.util.Arrays;
12+
import java.util.Collection;
13+
import java.util.Collections;
14+
import java.util.List;
15+
import java.util.Objects;
16+
import java.util.TreeSet;
17+
import java.util.regex.Matcher;
18+
import java.util.regex.Pattern;
19+
import java.util.stream.Collectors;
20+
import software.amazon.smithy.model.SourceLocation;
21+
import software.amazon.smithy.model.shapes.ShapeId;
22+
import software.amazon.smithy.model.validation.Severity;
23+
import software.amazon.smithy.model.validation.ValidationEvent;
24+
import software.amazon.smithy.utils.IoUtils;
25+
26+
/**
27+
* Runs a single test case by loading corresponding models `a` and `b` and
28+
* ensuring the resulting events match the diff events stored in a `-----`
29+
* separated file.
30+
*/
31+
public final class SmithyDiffTestCase {
32+
private static final Pattern EVENT_PATTERN = Pattern.compile(
33+
"^\\[(?<severity>SUPPRESSED|NOTE|WARNING|DANGER|ERROR)] "
34+
+ "(?<shape>[^ ]+): "
35+
+ "?(?<message>.*) "
36+
+ "\\| "
37+
+ "(?<id>[^)]+)",
38+
Pattern.DOTALL);
39+
40+
private final Path path;
41+
private final String name;
42+
private final List<ValidationEvent> expectedEvents;
43+
44+
/**
45+
* @param path Parent path of where the model and event files are stored.
46+
* @param name Name of the test case
47+
* @param expectedEvents The expected diff events to encounter.
48+
*/
49+
public SmithyDiffTestCase(
50+
Path path,
51+
String name,
52+
List<ValidationEvent> expectedEvents
53+
) {
54+
this.path = Objects.requireNonNull(path);
55+
this.name = Objects.requireNonNull(name);
56+
this.expectedEvents = Collections.unmodifiableList(expectedEvents);
57+
}
58+
59+
/**
60+
* Creates a test case from a test case path and name.
61+
*
62+
* <p>The models and events file are expected to be stored in the same
63+
* directory as the model and events file are assumed to be named the same
64+
* barring the file extensions: `.a.(json|smithy)`, `.b.(json|smithy)`,
65+
* `.events`.
66+
*
67+
* <p>The accompanying events file is a `-----` separated list of event
68+
* strings, where each event is defined in the following format:
69+
* {@code [SEVERITY] shapeId message | EventId filename:line:column}.
70+
* A shapeId of "-" means that a specific shape is not targeted.
71+
*
72+
* @param path Parent path of where the model and event files are stored.
73+
* @param name Name of the test case
74+
* @return Returns the created test case.
75+
*/
76+
public static SmithyDiffTestCase from(Path path, String name) {
77+
List<ValidationEvent> expectedEvents = loadExpectedEvents(path, name);
78+
return new SmithyDiffTestCase(path, name, expectedEvents);
79+
}
80+
81+
/**
82+
* Gets the parent path of the test case.
83+
*
84+
* @return parent path of the test case.
85+
*/
86+
public Path getPath() {
87+
return path;
88+
}
89+
90+
/**
91+
* Gets the name of the test case.
92+
*
93+
* @return name of the test case.
94+
*/
95+
public String getName() {
96+
return name;
97+
}
98+
99+
/**
100+
* Gets the expected validation events.
101+
*
102+
* @return Expected validation events.
103+
*/
104+
public List<ValidationEvent> getExpectedEvents() {
105+
return expectedEvents;
106+
}
107+
108+
/**
109+
* Creates a test case result from a list of model diff events.
110+
*
111+
* <p>The diff events encountered are compared against the expected
112+
* validation events. An actual event (A) is considered a match with an
113+
* expected event (E) if A and E target the same shape, have the same
114+
* severity, the eventId of A contains the eventId of E, and the message
115+
* of E starts with the suppression reason or message of A.
116+
*
117+
* @param actualEvents List of actual diff events.
118+
* @return Returns the created test case result.
119+
*/
120+
public Result createResult(List<ValidationEvent> actualEvents) {
121+
List<ValidationEvent> unmatchedEvents = expectedEvents.stream()
122+
.filter(expectedEvent -> actualEvents.stream()
123+
.noneMatch(actualEvent -> compareEvents(expectedEvent, actualEvent)))
124+
.collect(Collectors.toList());
125+
126+
List<ValidationEvent> extraEvents = actualEvents.stream()
127+
.filter(actualEvent -> expectedEvents.stream()
128+
.noneMatch(expectedEvent -> compareEvents(expectedEvent, actualEvent)))
129+
// Exclude suppressed events from needing to be defined as acceptable events.
130+
// However, these can still be defined as required events.
131+
.filter(event -> event.getSeverity() != Severity.SUPPRESSED)
132+
.collect(Collectors.toList());
133+
134+
return new SmithyDiffTestCase.Result(name, unmatchedEvents, extraEvents);
135+
}
136+
137+
private static boolean compareEvents(ValidationEvent expected, ValidationEvent actual) {
138+
String normalizedActualMessage = normalizeMessage(actual.getMessage());
139+
if (actual.getSuppressionReason().isPresent()) {
140+
normalizedActualMessage += " (" + actual.getSuppressionReason().get() + ")";
141+
}
142+
normalizedActualMessage = normalizeMessage(normalizedActualMessage);
143+
144+
String comparedMessage = normalizeMessage(expected.getMessage());
145+
return expected.getSeverity() == actual.getSeverity()
146+
&& actual.containsId(expected.getId())
147+
&& expected.getShapeId().equals(actual.getShapeId())
148+
// Normalize new lines.
149+
&& normalizedActualMessage.startsWith(comparedMessage);
150+
}
151+
152+
// Newlines in persisted validation events are escaped.
153+
private static String normalizeMessage(String message) {
154+
return message.replace("\n", "\\n").replace("\r", "\\n");
155+
}
156+
157+
private static List<ValidationEvent> loadExpectedEvents(Path path, String name) {
158+
String fileName = path.resolve(name + SmithyDiffTestSuite.EVENTS).toString();
159+
String contents = IoUtils.readUtf8File(fileName);
160+
return Arrays.stream(contents.split("-----"))
161+
.map(chunk -> chunk.trim())
162+
.filter(chunk -> !chunk.isEmpty())
163+
.map(chunk -> parseValidationEvent(chunk, fileName))
164+
.collect(Collectors.toList());
165+
}
166+
167+
static ValidationEvent parseValidationEvent(String event, String fileName) {
168+
Matcher matcher = EVENT_PATTERN.matcher(event);
169+
if (!matcher.find()) {
170+
throw new IllegalArgumentException(format("Invalid validation event in file `%s`, the following event did "
171+
+ "not match the expected regular expression `%s`: %s",
172+
fileName, EVENT_PATTERN.pattern(), event));
173+
}
174+
175+
// Construct a dummy source location since we don't validate it.
176+
SourceLocation location = new SourceLocation("/", 0, 0);
177+
178+
ValidationEvent.Builder builder = ValidationEvent.builder()
179+
.severity(Severity.fromString(matcher.group("severity")).get())
180+
.sourceLocation(location)
181+
.id(matcher.group("id"))
182+
.message(matcher.group("message"));
183+
184+
// A shape ID of "-" means no shape.
185+
if (!matcher.group("shape").equals("-")) {
186+
builder.shapeId(ShapeId.from(matcher.group("shape")));
187+
}
188+
189+
return builder.build();
190+
}
191+
192+
/**
193+
* Output of diffing a model against a test case.
194+
*/
195+
public static final class Result {
196+
private final String name;
197+
private final Collection<ValidationEvent> unmatchedEvents;
198+
private final Collection<ValidationEvent> extraEvents;
199+
200+
Result(
201+
String name,
202+
Collection<ValidationEvent> unmatchedEvents,
203+
Collection<ValidationEvent> extraEvents
204+
) {
205+
this.name = name;
206+
this.unmatchedEvents = Collections.unmodifiableCollection(new TreeSet<>(unmatchedEvents));
207+
this.extraEvents = Collections.unmodifiableCollection(new TreeSet<>(extraEvents));
208+
}
209+
210+
@Override
211+
public String toString() {
212+
StringBuilder builder = new StringBuilder();
213+
214+
builder
215+
.append("============================\n"
216+
+ "Model Diff Validation Result\n"
217+
+ "============================\n")
218+
.append(name)
219+
.append('\n');
220+
221+
if (!unmatchedEvents.isEmpty()) {
222+
builder.append("\nDid not match the following events\n"
223+
+ "----------------------------------\n");
224+
for (ValidationEvent event : unmatchedEvents) {
225+
builder.append(event.toString()).append("\n\n");
226+
}
227+
}
228+
229+
if (!extraEvents.isEmpty()) {
230+
builder.append("\nEncountered unexpected events\n"
231+
+ "-----------------------------\n");
232+
for (ValidationEvent event : extraEvents) {
233+
builder.append(event.toString()).append("\n\n");
234+
}
235+
}
236+
237+
return builder.toString();
238+
}
239+
240+
/**
241+
* @return Returns the name of the test case.
242+
*/
243+
public String getName() {
244+
return name;
245+
}
246+
247+
/**
248+
* @return Returns the events that were expected but not encountered.
249+
*/
250+
public Collection<ValidationEvent> getUnmatchedEvents() {
251+
return unmatchedEvents;
252+
}
253+
254+
/**
255+
* @return Returns the events that were encountered but not expected.
256+
*/
257+
public Collection<ValidationEvent> getExtraEvents() {
258+
return extraEvents;
259+
}
260+
261+
/**
262+
* Checks if the result does not match expected results.
263+
*
264+
* @return True if there are extra or unmatched events.
265+
*/
266+
public boolean isInvalid() {
267+
return !unmatchedEvents.isEmpty() || !extraEvents.isEmpty();
268+
}
269+
270+
/**
271+
* Throws an exception if the result is invalid, otherwise returns the result.
272+
*
273+
* @return Returns the result if it is ok.
274+
* @throws Error if the result contains invalid events.
275+
*/
276+
public Result unwrap() {
277+
if (isInvalid()) {
278+
throw new Error(this);
279+
}
280+
281+
return this;
282+
}
283+
}
284+
285+
/**
286+
* Thrown when errors are encountered while unwrapping a test case.
287+
*/
288+
public static final class Error extends RuntimeException {
289+
public final Result result;
290+
291+
Error(Result result) {
292+
super(result.toString());
293+
this.result = result;
294+
}
295+
}
296+
}

0 commit comments

Comments
 (0)