Skip to content

Commit 42deea6

Browse files
committed
Add jqwik exhaustive schema/document integration test
1 parent 9368a2a commit 42deea6

File tree

1 file changed

+304
-0
lines changed

1 file changed

+304
-0
lines changed
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
package io.github.simbo1905.json.schema;
2+
3+
import jdk.sandbox.java.util.json.JsonArray;
4+
import jdk.sandbox.java.util.json.JsonBoolean;
5+
import jdk.sandbox.java.util.json.JsonNull;
6+
import jdk.sandbox.java.util.json.JsonNumber;
7+
import jdk.sandbox.java.util.json.JsonObject;
8+
import jdk.sandbox.java.util.json.JsonString;
9+
import jdk.sandbox.java.util.json.JsonValue;
10+
import net.jqwik.api.Arbitraries;
11+
import net.jqwik.api.Arbitrary;
12+
import net.jqwik.api.Combinators;
13+
import net.jqwik.api.ForAll;
14+
import net.jqwik.api.GenerationMode;
15+
import net.jqwik.api.Property;
16+
import net.jqwik.api.UseArbitraryProviders;
17+
import net.jqwik.api.providers.ArbitraryProvider;
18+
import net.jqwik.api.providers.SubtypeProvider;
19+
import net.jqwik.api.providers.TypeUsage;
20+
21+
import java.math.BigDecimal;
22+
import java.util.LinkedHashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Objects;
26+
import java.util.Set;
27+
import java.util.logging.Logger;
28+
import java.util.stream.Collectors;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
32+
@UseArbitraryProviders(ITJsonSchemaExhaustiveTest.SchemaArbitraryProvider.class)
33+
class ITJsonSchemaExhaustiveTest extends JsonSchemaLoggingConfig {
34+
35+
private static final Logger LOGGER = Logger.getLogger(io.github.simbo1905.json.schema.JsonSchema.class.getName());
36+
private static final int MAX_DEPTH = 3;
37+
private static final List<String> PROPERTY_NAMES = List.of("alpha", "beta", "gamma", "delta");
38+
private static final List<List<String>> PROPERTY_PAIRS = List.of(
39+
List.of("alpha", "beta"),
40+
List.of("alpha", "gamma"),
41+
List.of("alpha", "delta"),
42+
List.of("beta", "gamma"),
43+
List.of("beta", "delta"),
44+
List.of("gamma", "delta")
45+
);
46+
47+
@Property(generation = GenerationMode.EXHAUSTIVE)
48+
void exhaustiveRoundTrip(@ForAll ITJsonSchemaExhaustiveTest.JsonSchema schema) {
49+
LOGGER.info(() -> "Executing exhaustiveRoundTrip property test");
50+
51+
final var schemaDescription = describeSchema(schema);
52+
LOGGER.fine(() -> "Schema descriptor: " + schemaDescription);
53+
54+
final var schemaJson = schemaToJsonObject(schema);
55+
LOGGER.finer(() -> "Schema JSON: " + schemaJson);
56+
57+
final var compiled = io.github.simbo1905.json.schema.JsonSchema.compile(schemaJson);
58+
59+
final var compliantDocument = buildCompliantDocument(schema);
60+
LOGGER.finer(() -> "Compliant document: " + compliantDocument);
61+
62+
final var validation = compiled.validate(compliantDocument);
63+
assertThat(validation.valid())
64+
.as("Compliant document should validate for schema %s", schemaDescription)
65+
.isTrue();
66+
assertThat(validation.errors())
67+
.as("No validation errors expected for compliant document")
68+
.isEmpty();
69+
70+
final var failingDocuments = failingDocuments(schema, compliantDocument);
71+
assertThat(failingDocuments)
72+
.as("Negative cases should be generated for schema %s", schemaDescription)
73+
.isNotEmpty();
74+
75+
final var failingDocumentStrings = failingDocuments.stream()
76+
.map(Object::toString)
77+
.toList();
78+
LOGGER.finest(() -> "Failing documents: " + failingDocumentStrings);
79+
80+
failingDocuments.forEach(failing -> {
81+
final var failingResult = compiled.validate(failing);
82+
assertThat(failingResult.valid())
83+
.as("Expected validation failure for %s against schema %s", failing, schemaDescription)
84+
.isFalse();
85+
assertThat(failingResult.errors())
86+
.as("Expected validation errors for %s against schema %s", failing, schemaDescription)
87+
.isNotEmpty();
88+
});
89+
}
90+
91+
private static JsonValue buildCompliantDocument(JsonSchema schema) {
92+
return switch (schema) {
93+
case ObjectSchema(var properties) -> JsonObject.of(properties.stream()
94+
.collect(Collectors.toMap(
95+
JsonSchema.Property::name,
96+
property -> buildCompliantDocument(property.schema()),
97+
(left, right) -> left,
98+
LinkedHashMap::new
99+
)));
100+
case ArraySchema(var items) -> JsonArray.of(items.stream()
101+
.map(ITJsonSchemaExhaustiveTest::buildCompliantDocument)
102+
.toList());
103+
case StringSchema ignored -> JsonString.of("valid");
104+
case NumberSchema ignored -> JsonNumber.of(BigDecimal.ONE);
105+
case BooleanSchema ignored -> JsonBoolean.of(true);
106+
case NullSchema ignored -> JsonNull.of();
107+
};
108+
}
109+
110+
private static List<JsonValue> failingDocuments(JsonSchema schema, JsonValue compliant) {
111+
return switch (schema) {
112+
case ObjectSchema(var properties) -> properties.isEmpty()
113+
? List.<JsonValue>of(JsonNull.of())
114+
: properties.stream()
115+
.map(JsonSchema.Property::name)
116+
.map(name -> removeProperty((JsonObject) compliant, name))
117+
.map(json -> (JsonValue) json)
118+
.toList();
119+
case ArraySchema(var items) -> {
120+
final var values = ((JsonArray) compliant).values();
121+
if (values.isEmpty()) {
122+
yield List.<JsonValue>of(JsonNull.of());
123+
}
124+
final var truncated = JsonArray.of(values.stream().limit(values.size() - 1L).toList());
125+
yield List.<JsonValue>of(truncated);
126+
}
127+
case StringSchema ignored -> List.<JsonValue>of(JsonNumber.of(BigDecimal.TWO));
128+
case NumberSchema ignored -> List.<JsonValue>of(JsonString.of("not-a-number"));
129+
case BooleanSchema ignored -> List.<JsonValue>of(JsonNull.of());
130+
case NullSchema ignored -> List.<JsonValue>of(JsonBoolean.of(true));
131+
};
132+
}
133+
134+
private static JsonObject removeProperty(JsonObject original, String missingProperty) {
135+
final var filtered = original.members().entrySet().stream()
136+
.filter(entry -> !Objects.equals(entry.getKey(), missingProperty))
137+
.collect(Collectors.toMap(
138+
Map.Entry::getKey,
139+
Map.Entry::getValue,
140+
(left, right) -> left,
141+
LinkedHashMap::new
142+
));
143+
return JsonObject.of(filtered);
144+
}
145+
146+
private static JsonObject schemaToJsonObject(JsonSchema schema) {
147+
return switch (schema) {
148+
case ObjectSchema(var properties) -> {
149+
final var schemaMap = new LinkedHashMap<String, JsonValue>();
150+
schemaMap.put("type", JsonString.of("object"));
151+
final var propertyMap = properties.isEmpty()
152+
? JsonObject.of(Map.<String, JsonValue>of())
153+
: JsonObject.of(properties.stream()
154+
.collect(Collectors.toMap(
155+
JsonSchema.Property::name,
156+
property -> (JsonValue) schemaToJsonObject(property.schema()),
157+
(left, right) -> left,
158+
LinkedHashMap::new
159+
)));
160+
schemaMap.put("properties", propertyMap);
161+
final var requiredValues = properties.stream()
162+
.map(JsonSchema.Property::name)
163+
.map(JsonString::of)
164+
.map(value -> (JsonValue) value)
165+
.toList();
166+
schemaMap.put("required", JsonArray.of(requiredValues));
167+
schemaMap.put("additionalProperties", JsonBoolean.of(false));
168+
yield JsonObject.of(schemaMap);
169+
}
170+
case ArraySchema(var items) -> {
171+
final var schemaMap = new LinkedHashMap<String, JsonValue>();
172+
schemaMap.put("type", JsonString.of("array"));
173+
final var prefixItems = items.stream()
174+
.map(ITJsonSchemaExhaustiveTest::schemaToJsonObject)
175+
.map(value -> (JsonValue) value)
176+
.toList();
177+
schemaMap.put("prefixItems", JsonArray.of(prefixItems));
178+
schemaMap.put("items", JsonBoolean.of(false));
179+
schemaMap.put("minItems", JsonNumber.of((long) items.size()));
180+
schemaMap.put("maxItems", JsonNumber.of((long) items.size()));
181+
yield JsonObject.of(schemaMap);
182+
}
183+
case StringSchema ignored -> primitiveSchema("string");
184+
case NumberSchema ignored -> primitiveSchema("number");
185+
case BooleanSchema ignored -> primitiveSchema("boolean");
186+
case NullSchema ignored -> primitiveSchema("null");
187+
};
188+
}
189+
190+
private static JsonObject primitiveSchema(String type) {
191+
final var schemaMap = new LinkedHashMap<String, JsonValue>();
192+
schemaMap.put("type", JsonString.of(type));
193+
return JsonObject.of(schemaMap);
194+
}
195+
196+
private static String describeSchema(JsonSchema schema) {
197+
return switch (schema) {
198+
case ObjectSchema(var properties) -> properties.stream()
199+
.map(property -> property.name() + ":" + describeSchema(property.schema()))
200+
.collect(Collectors.joining(",", "object{", "}"));
201+
case ArraySchema(var items) -> items.stream()
202+
.map(ITJsonSchemaExhaustiveTest::describeSchema)
203+
.collect(Collectors.joining(",", "array[", "]"));
204+
case StringSchema ignored -> "string";
205+
case NumberSchema ignored -> "number";
206+
case BooleanSchema ignored -> "boolean";
207+
case NullSchema ignored -> "null";
208+
};
209+
}
210+
211+
static final class SchemaArbitraryProvider implements ArbitraryProvider {
212+
@Override
213+
public boolean canProvideFor(TypeUsage targetType) {
214+
return targetType.isOfType(ITJsonSchemaExhaustiveTest.JsonSchema.class);
215+
}
216+
217+
@Override
218+
public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
219+
return Set.of(schemaArbitrary(MAX_DEPTH));
220+
}
221+
}
222+
223+
private static Arbitrary<JsonSchema> schemaArbitrary(int depth) {
224+
final var primitives = Arbitraries.of(
225+
new StringSchema(),
226+
new NumberSchema(),
227+
new BooleanSchema(),
228+
new NullSchema()
229+
);
230+
if (depth == 0) {
231+
return primitives;
232+
}
233+
return Arbitraries.oneOf(
234+
primitives,
235+
objectSchemaArbitrary(depth),
236+
arraySchemaArbitrary(depth)
237+
);
238+
}
239+
240+
private static Arbitrary<JsonSchema> objectSchemaArbitrary(int depth) {
241+
if (depth == 1) {
242+
return Arbitraries.of(new ObjectSchema(List.of()));
243+
}
244+
final var childDepth = depth - 1;
245+
final var empty = Arbitraries.of(new ObjectSchema(List.of()));
246+
final var single = Combinators.combine(
247+
Arbitraries.of(PROPERTY_NAMES),
248+
schemaArbitrary(childDepth)
249+
).as((name, child) -> new ObjectSchema(List.of(new Property(name, child))));
250+
final var pair = Combinators.combine(
251+
Arbitraries.of(PROPERTY_PAIRS),
252+
schemaArbitrary(childDepth),
253+
schemaArbitrary(childDepth)
254+
).as((names, first, second) -> new ObjectSchema(List.of(
255+
new Property(names.getFirst(), first),
256+
new Property(names.getLast(), second)
257+
)));
258+
return Arbitraries.oneOf(empty, single, pair);
259+
}
260+
261+
private static Arbitrary<JsonSchema> arraySchemaArbitrary(int depth) {
262+
if (depth == 1) {
263+
return Arbitraries.of(new ArraySchema(List.of()));
264+
}
265+
final var childDepth = depth - 1;
266+
final var empty = Arbitraries.of(new ArraySchema(List.of()));
267+
final var single = schemaArbitrary(childDepth)
268+
.map(child -> new ArraySchema(List.of(child)));
269+
final var pair = Combinators.combine(
270+
schemaArbitrary(childDepth),
271+
schemaArbitrary(childDepth)
272+
).as((first, second) -> new ArraySchema(List.of(first, second)));
273+
return Arbitraries.oneOf(empty, single, pair);
274+
}
275+
276+
sealed interface JsonSchema permits ObjectSchema, ArraySchema, StringSchema, NumberSchema, BooleanSchema, NullSchema {
277+
record ObjectSchema(List<Property> properties) implements JsonSchema {
278+
ObjectSchema {
279+
properties = List.copyOf(properties);
280+
}
281+
}
282+
283+
record Property(String name, JsonSchema schema) {
284+
Property {
285+
name = Objects.requireNonNull(name);
286+
schema = Objects.requireNonNull(schema);
287+
}
288+
}
289+
290+
record ArraySchema(List<JsonSchema> items) implements JsonSchema {
291+
ArraySchema {
292+
items = List.copyOf(items);
293+
}
294+
}
295+
296+
record StringSchema() implements JsonSchema {}
297+
298+
record NumberSchema() implements JsonSchema {}
299+
300+
record BooleanSchema() implements JsonSchema {}
301+
302+
record NullSchema() implements JsonSchema {}
303+
}
304+
}

0 commit comments

Comments
 (0)