package org.zwobble.mammoth.tests; import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.Matchers; import org.hamcrest.TypeSafeDiagnosingMatcher; import org.zwobble.mammoth.internal.util.Sets; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import static java.util.Arrays.asList; import static org.zwobble.mammoth.internal.util.Iterables.any; import static org.zwobble.mammoth.internal.util.Iterables.lazyMap; import static org.zwobble.mammoth.internal.util.Lists.eagerFilter; import static org.zwobble.mammoth.internal.util.Lists.skip; public class DeepReflectionMatcher<T> extends TypeSafeDiagnosingMatcher<T> { public static <T> Matcher<T> deepEquals(T value) { return new DeepReflectionMatcher<>(value); } private final T expected; public DeepReflectionMatcher(T expected) { this.expected = expected; } @Override protected boolean matchesSafely(T item, Description mismatchDescription) { return matchesSafely("", expected, item, mismatchDescription); } private static boolean deepEquals(Object expected, Object actual) { Description description = new Description.NullDescription(); return matchesSafely("", expected, actual, description); } private static <T> boolean matchesSafely(String path, T expected, T actual, Description mismatchDescription) { if (expected instanceof List && actual instanceof List) { return matchesList(path, (List)expected, (List)actual, mismatchDescription); } if (expected instanceof Set && actual instanceof Set) { return matchesSet(path, (Set)expected, (Set)actual, mismatchDescription); } if (expected instanceof Map && actual instanceof Map) { return matchesMap(path, (Map)expected, (Map)actual, mismatchDescription); } if (!expected.getClass().equals(actual.getClass())) { appendPath(mismatchDescription, path); mismatchDescription.appendText("was " + actual.getClass().getName()); return false; } if (expected instanceof Optional && actual instanceof Optional) { return matchesOptional(path, (Optional)expected, (Optional)actual, mismatchDescription); } if (expected instanceof String || expected instanceof Boolean || expected instanceof Enum || expected instanceof Integer) { return matchesPrimitive(path, expected, actual, mismatchDescription); } for (Field field : fields(expected.getClass())) { if (!matchesSafely(path + "." + field.getName(), readField(expected, field), readField(actual, field), mismatchDescription)) { return false; } } return true; } private static <T> boolean matchesList(String path, List<?> expected, List<?> actual, Description mismatchDescription) { if (actual.size() > expected.size()) { appendPath(mismatchDescription, path); mismatchDescription.appendText("extra elements:" + indentedList(lazyMap(skip(actual, expected.size()), DeepReflectionMatcher::describeValue))); return false; } if (actual.size() < expected.size()) { appendPath(mismatchDescription, path); mismatchDescription.appendText("missing elements:" + indentedList(lazyMap(skip(expected, actual.size()), DeepReflectionMatcher::describeValue))); return false; } for (int index = 0; index < expected.size(); index++) { if (!matchesSafely(path + "[" + index + "]", expected.get(index), actual.get(index), mismatchDescription)) { return false; } } return true; } private static <T> boolean matchesSet(String path, Set<?> expected, Set<?> actual, Description mismatchDescription) { List<?> missing = eagerFilter( expected, expectedElement -> !any(actual, actualElement -> deepEquals(expectedElement, actualElement))); if (!missing.isEmpty()) { appendPath(mismatchDescription, path); mismatchDescription.appendText("missing elements:" + indentedList(lazyMap(missing, DeepReflectionMatcher::describeValue))); return false; } List<?> extra = eagerFilter( actual, actualElement -> !any(expected, expectedElement -> deepEquals(expectedElement, actualElement))); if (!extra.isEmpty()) { appendPath(mismatchDescription, path); mismatchDescription.appendText("extra elements:" + indentedList(lazyMap(extra, DeepReflectionMatcher::describeValue))); return false; } return true; } private static <T> boolean matchesMap(String path, Map<?, ?> expected, Map<?, ?> actual, Description mismatchDescription) { if (!handleExtraElements(path, expected, actual, mismatchDescription, "extra elements:")) { return false; } if (!handleExtraElements(path, actual, expected, mismatchDescription, "missing elements:")) { return false; } for (Object key : expected.keySet()) { if (!matchesSafely(path + "[" + key + "]", expected.get(key), actual.get(key), mismatchDescription)) { return false; } } return true; } private static boolean handleExtraElements(String path, Map<?, ?> expected, Map<?, ?> actual, Description mismatchDescription, String prefix) { Set<?> extraElements = Sets.difference(actual.keySet(), expected.keySet()); if (!extraElements.isEmpty()) { appendPath(mismatchDescription, path); mismatchDescription.appendText(prefix + indentedList(lazyMap( extraElements, key -> describeValue(key) + "=" + describeValue(actual.get(key))))); return false; } return true; } private static boolean matchesOptional(String path, Optional expected, Optional actual, Description mismatchDescription) { if (actual.isPresent() && !expected.isPresent()) { appendPath(mismatchDescription, path); mismatchDescription.appendText("had value " + describeValue(actual.get())); return false; } if (expected.isPresent() && !actual.isPresent()) { appendPath(mismatchDescription, path); mismatchDescription.appendText("was empty"); return false; } if (actual.isPresent() && expected.isPresent()) { return matchesSafely(path, expected.get(), actual.get(), mismatchDescription); } return true; } private static <T> boolean matchesPrimitive(String path, T expected, T actual, Description mismatchDescription) { Matcher<Object> matcher = Matchers.equalTo(expected); if (!matcher.matches(actual)) { appendPath(mismatchDescription, path); matcher.describeMismatch(actual, mismatchDescription); return false; } else { return true; } } private static void appendPath(Description mismatchDescription, String path) { mismatchDescription.appendText(path + ": "); } @Override public void describeTo(Description description) { description.appendText(describeValue(expected)); } private static String describeValue(Object value) { if (value instanceof String || value instanceof Boolean || value instanceof Integer) { return value.toString(); } else if (value instanceof Optional) { Optional<?> optional = (Optional)value; if (optional.isPresent()) { return describeValue(optional.get()); } else { return "(empty)"; } } else if (value instanceof List) { List<?> list = (List)value; return "[" + indentedList(lazyMap(list, DeepReflectionMatcher::describeValue)) + "]"; } else if (value instanceof Set) { Set<?> list = (Set)value; return "{" + indentedList(lazyMap(list, DeepReflectionMatcher::describeValue)) + "}"; } else if (value instanceof Map) { Map<?, ?> map = (Map)value; String entries = indentedList( lazyMap( map.entrySet(), entry -> describeValue(entry.getKey()) + "=" + describeValue(entry.getValue()))); return "{" + entries + "}"; } else { Class<?> clazz = value.getClass(); List<Field> fields = fields(clazz); Iterable<String> fieldStrings = lazyMap( fields, field -> field.getName() + "=" + describeValue(readField(value, field))); return String.format("%s(%s)", clazz.getSimpleName(), indentedList(fieldStrings)); } } private static String indentedList(Iterable<String> values) { return String.join(",", lazyMap(values, value -> "\n " + indent(value))); } private static String indent(String value) { return value.replaceAll("\n", "\n "); } private static List<Field> fields(Class<?> clazz) { return eagerFilter( asList(clazz.getDeclaredFields()), field -> !Modifier.isStatic(field.getModifiers())); } private static Object readField(Object obj, Field field) { try { field.setAccessible(true); return field.get(obj); } catch (IllegalAccessException exception) { throw new RuntimeException(exception); } } }