blob: f58d86a930e90c139d1125ccaf46edbf1857ac37 [file] [log] [blame]
/*
* Copyright (c) 2014 Google, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.common.truth;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.truth.SubjectUtils.HUMAN_UNDERSTANDABLE_EMPTY_STRING;
import static com.google.common.truth.SubjectUtils.countDuplicatesAndAddTypeInfo;
import static com.google.common.truth.SubjectUtils.hasMatchingToStringPair;
import static com.google.common.truth.SubjectUtils.objectToTypeName;
import static com.google.common.truth.SubjectUtils.retainMatchingToString;
import com.google.common.base.Joiner;
import com.google.common.base.Objects;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.LinkedHashMultiset;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map.Entry;
import java.util.Set;
import javax.annotation.Nullable;
/**
* Propositions for {@link Multimap} subjects.
*
* @author Daniel Ploch
* @author Kurt Alfred Kluever
*/
// Not final since SetMultimapSubject and ListMultimapSubject extends this
public class MultimapSubject extends Subject<MultimapSubject, Multimap<?, ?>> {
MultimapSubject(FailureStrategy failureStrategy, @Nullable Multimap<?, ?> multimap) {
super(failureStrategy, multimap);
}
/** Fails if the multimap is not empty. */
public void isEmpty() {
if (!actual().isEmpty()) {
fail("is empty");
}
}
/** Fails if the multimap is empty. */
public void isNotEmpty() {
if (actual().isEmpty()) {
fail("is not empty");
}
}
/** Fails if the multimap does not have the given size. */
public void hasSize(int expectedSize) {
checkArgument(expectedSize >= 0, "expectedSize(%s) must be >= 0", expectedSize);
int actualSize = actual().size();
if (actualSize != expectedSize) {
failWithBadResults("has a size of", expectedSize, "is", actualSize);
}
}
/** Fails if the multimap does not contain the given key. */
public void containsKey(@Nullable Object key) {
if (!actual().containsKey(key)) {
List<Object> keyList = Lists.newArrayList(key);
if (hasMatchingToStringPair(actual().keySet(), keyList)) {
failWithRawMessage(
"Not true that %s contains key <%s (%s)>. However, it does contain keys <%s>.",
actualAsString(),
key,
objectToTypeName(key),
countDuplicatesAndAddTypeInfo(
retainMatchingToString(actual().keySet(), keyList /* itemsToCheck */)));
} else {
fail("contains key", key);
}
}
}
/** Fails if the multimap contains the given key. */
public void doesNotContainKey(@Nullable Object key) {
if (actual().containsKey(key)) {
fail("does not contain key", key);
}
}
/** Fails if the multimap does not contain the given entry. */
public void containsEntry(@Nullable Object key, @Nullable Object value) {
// TODO(kak): Can we share any of this logic w/ MapSubject.containsEntry()?
if (!actual().containsEntry(key, value)) {
Entry<Object, Object> entry = Maps.immutableEntry(key, value);
List<Entry<Object, Object>> entryList = ImmutableList.of(entry);
if (hasMatchingToStringPair(actual().entries(), entryList)) {
failWithRawMessage(
"Not true that %s contains entry <%s (%s)>. However, it does contain entries <%s>",
actualAsString(),
entry,
objectToTypeName(entry),
countDuplicatesAndAddTypeInfo(
retainMatchingToString(actual().entries(), entryList /* itemsToCheck */)));
} else if (actual().containsKey(key)) {
failWithRawMessage(
"Not true that %s contains entry <%s>. However, it has a mapping from <%s> to <%s>",
actualAsString(), entry, key, actual().asMap().get(key));
} else if (actual().containsValue(value)) {
Set<Object> keys = new LinkedHashSet<Object>();
for (Entry<?, ?> actualEntry : actual().entries()) {
if (Objects.equal(actualEntry.getValue(), value)) {
keys.add(actualEntry.getKey());
}
}
failWithRawMessage(
"Not true that %s contains entry <%s>. "
+ "However, the following keys are mapped to <%s>: %s",
actualAsString(), entry, value, keys);
} else {
fail("contains entry", Maps.immutableEntry(key, value));
}
}
}
/** Fails if the multimap contains the given entry. */
public void doesNotContainEntry(@Nullable Object key, @Nullable Object value) {
if (actual().containsEntry(key, value)) {
fail("does not contain entry", Maps.immutableEntry(key, value));
}
}
/**
* Returns a context-aware {@link Subject} for making assertions about the values for the given
* key within the {@link Multimap}.
*
* <p>This method performs no checks on its own and cannot cause test failures. Subsequent
* assertions must be chained onto this method call to test properties of the {@link Multimap}.
*/
public IterableSubject valuesForKey(@Nullable Object key) {
return new IterableValuesForKey(failureStrategy, this, key);
}
@Override
public void isEqualTo(@Nullable Object other) {
if (!Objects.equal(actual(), other)) {
if ((actual() instanceof ListMultimap && other instanceof SetMultimap)
|| (actual() instanceof SetMultimap && other instanceof ListMultimap)) {
String mapType1 = (actual() instanceof ListMultimap) ? "ListMultimap" : "SetMultimap";
String mapType2 = (other instanceof ListMultimap) ? "ListMultimap" : "SetMultimap";
failWithRawMessage(
"Not true that %s %s is equal to %s <%s>. "
+ "A %s cannot equal a %s if either is non-empty.",
mapType1, actualAsString(), mapType2, other, mapType1, mapType2);
} else {
if (actual() instanceof ListMultimap) {
// If we're comparing ListMultimaps, check for order
containsExactlyEntriesIn((Multimap<?, ?>) other).inOrder();
} else if (actual() instanceof SetMultimap) {
// If we're comparing SetMultimaps, don't check for order
containsExactlyEntriesIn((Multimap<?, ?>) other);
}
// This statement should generally never be reached because one of the two
// containsExactlyEntriesIn calls above should throw an exception. It'll only be reached if
// we're looking at a non-ListMultimap and non-SetMultimap
// (e.g., a custom Multimap implementation).
fail("is equal to", other);
}
}
}
/**
* Fails if the {@link Multimap} does not contain precisely the same entries as the argument
* {@link Multimap}.
*
* <p>A subsequent call to {@link Ordered#inOrder} may be made if the caller wishes to verify that
* the two multimaps iterate fully in the same order. That is, their key sets iterate in the same
* order, and the value collections for each key iterate in the same order.
*/
@CanIgnoreReturnValue
public Ordered containsExactlyEntriesIn(Multimap<?, ?> expectedMultimap) {
checkNotNull(expectedMultimap, "expectedMultimap");
ListMultimap<?, ?> missing = difference(expectedMultimap, actual());
ListMultimap<?, ?> extra = difference(actual(), expectedMultimap);
// TODO(kak): Possible enhancement: Include "[1 copy]" if the element does appear in
// the subject but not enough times. Similarly for unexpected extra items.
if (!missing.isEmpty()) {
if (!extra.isEmpty()) {
boolean addTypeInfo = hasMatchingToStringPair(missing.entries(), extra.entries());
failWithRawMessage(
"Not true that %s contains exactly <%s>. "
+ "It is missing <%s> and has unexpected items <%s>",
actualAsString(),
annotateEmptyStringsMultimap(expectedMultimap),
// Note: The usage of countDuplicatesAndAddTypeInfo() below causes entries no longer to
// be grouped by key in the 'missing' and 'unexpected items' parts of the message (we
// still show the actual and expected multimaps in the standard format).
addTypeInfo
? countDuplicatesAndAddTypeInfo(annotateEmptyStringsMultimap(missing).entries())
: countDuplicatesMultimap(annotateEmptyStringsMultimap(missing)),
addTypeInfo
? countDuplicatesAndAddTypeInfo(annotateEmptyStringsMultimap(extra).entries())
: countDuplicatesMultimap(annotateEmptyStringsMultimap(extra)));
} else {
failWithBadResults(
"contains exactly",
annotateEmptyStringsMultimap(expectedMultimap),
"is missing",
countDuplicatesMultimap(annotateEmptyStringsMultimap(missing)));
}
} else if (!extra.isEmpty()) {
failWithBadResults(
"contains exactly",
annotateEmptyStringsMultimap(expectedMultimap),
"has unexpected items",
countDuplicatesMultimap(annotateEmptyStringsMultimap(extra)));
}
return new MultimapInOrder(expectedMultimap);
}
/**
* Fails if the multimap does not contain exactly the given set of key/value pairs.
*
* <p><b>Warning:</b> the use of varargs means that we cannot guarantee an equal number of
* key/value pairs at compile time. Please make sure you provide varargs in key/value pairs!
*/
@CanIgnoreReturnValue
public Ordered containsExactly(@Nullable Object k0, @Nullable Object v0, Object... rest) {
return containsExactlyEntriesIn(accumulateMultimap(k0, v0, rest));
}
private static Multimap<Object, Object> accumulateMultimap(
@Nullable Object k0, @Nullable Object v0, Object... rest) {
checkArgument(
rest.length % 2 == 0,
"There must be an equal number of key/value pairs "
+ "(i.e., the number of key/value parameters (%s) must be even).",
rest.length + 2);
LinkedListMultimap<Object, Object> expectedMultimap = LinkedListMultimap.create();
expectedMultimap.put(k0, v0);
for (int i = 0; i < rest.length; i += 2) {
expectedMultimap.put(rest[i], rest[i + 1]);
}
return expectedMultimap;
}
/** @deprecated Use {@link #containsExactlyEntriesIn} instead. */
@Deprecated
@CanIgnoreReturnValue
public Ordered containsExactly(Multimap<?, ?> expectedMultimap) {
return containsExactlyEntriesIn(expectedMultimap);
}
private static class IterableValuesForKey extends IterableSubject {
@Nullable private final Object key;
private final String stringRepresentation;
@SuppressWarnings({"unchecked"})
IterableValuesForKey(
FailureStrategy failureStrategy, MultimapSubject multimapSubject, @Nullable Object key) {
super(failureStrategy, ((Multimap<Object, Object>) multimapSubject.actual()).get(key));
this.key = key;
this.stringRepresentation = multimapSubject.actualAsString();
}
@Override
protected String actualCustomStringRepresentation() {
return "Values for key <" + key + "> (<" + actual() + ">) in " + stringRepresentation;
}
}
private static class IterableEntries extends IterableSubject {
private final String stringRepresentation;
IterableEntries(FailureStrategy failureStrategy, MultimapSubject multimapSubject) {
super(failureStrategy, multimapSubject.actual().entries());
// We want to use the multimap's toString() instead of the iterable of entries' toString():
this.stringRepresentation = multimapSubject.actual().toString();
// If the multimap subject is named() then this should be, too:
if (multimapSubject.internalCustomName() != null) {
named(multimapSubject.internalCustomName());
}
}
@Override
protected String actualCustomStringRepresentation() {
return stringRepresentation;
}
}
private class MultimapInOrder implements Ordered {
private final Multimap<?, ?> expectedMultimap;
MultimapInOrder(Multimap<?, ?> expectedMultimap) {
this.expectedMultimap = expectedMultimap;
}
@Override
public void inOrder() {
boolean keysInOrder =
Lists.newArrayList(actual().keySet())
.equals(Lists.newArrayList(expectedMultimap.keySet()));
LinkedHashSet<Object> keysWithValuesOutOfOrder = Sets.newLinkedHashSet();
LinkedHashSet<Object> allKeys = Sets.newLinkedHashSet();
allKeys.addAll(actual().keySet());
allKeys.addAll(expectedMultimap.keySet());
for (Object key : allKeys) {
List<?> actualVals = Lists.newArrayList(get(actual(), key));
List<?> expectedVals = Lists.newArrayList(get(expectedMultimap, key));
if (!actualVals.equals(expectedVals)) {
keysWithValuesOutOfOrder.add(key);
}
}
if (!keysInOrder) {
if (!keysWithValuesOutOfOrder.isEmpty()) {
failWithRawMessage(
"Not true that %s contains exactly <%s> in order. The keys are not in order, "
+ "and the values for keys <%s> are not in order either",
actualAsString(), expectedMultimap, keysWithValuesOutOfOrder);
} else {
failWithRawMessage(
"Not true that %s contains exactly <%s> in order. The keys are not in order",
actualAsString(), expectedMultimap);
}
} else if (!keysWithValuesOutOfOrder.isEmpty()) {
failWithRawMessage(
"Not true that %s contains exactly <%s> in order. "
+ "The values for keys <%s> are not in order",
actualAsString(), expectedMultimap, keysWithValuesOutOfOrder);
}
}
}
private static <K, V> Collection<V> get(Multimap<K, V> multimap, @Nullable Object key) {
if (multimap.containsKey(key)) {
return multimap.asMap().get(key);
} else {
return Collections.emptyList();
}
}
private static ListMultimap<?, ?> difference(Multimap<?, ?> minuend, Multimap<?, ?> subtrahend) {
ListMultimap<Object, Object> difference = LinkedListMultimap.create();
for (Object key : minuend.keySet()) {
List<?> valDifference =
difference(
Lists.newArrayList(get(minuend, key)), Lists.newArrayList(get(subtrahend, key)));
difference.putAll(key, valDifference);
}
return difference;
}
private static List<?> difference(List<?> minuend, List<?> subtrahend) {
LinkedHashMultiset<Object> remaining = LinkedHashMultiset.<Object>create(subtrahend);
List<Object> difference = Lists.newArrayList();
for (Object elem : minuend) {
if (!remaining.remove(elem)) {
difference.add(elem);
}
}
return difference;
}
private static <K, V> String countDuplicatesMultimap(Multimap<K, V> multimap) {
List<String> entries = new ArrayList<String>();
for (K key : multimap.keySet()) {
entries.add(key + "=" + SubjectUtils.countDuplicates(multimap.get(key)));
}
StringBuilder sb = new StringBuilder();
sb.append("{");
Joiner.on(", ").appendTo(sb, entries);
sb.append("}");
return sb.toString();
}
/**
* Returns a multimap with all empty strings (as keys or values) replaced by a non-empty human
* understandable indicator for an empty string.
*
* <p>Returns the given multimap if it contains no empty strings.
*/
private static Multimap<?, ?> annotateEmptyStringsMultimap(Multimap<?, ?> multimap) {
if (multimap.containsKey("") || multimap.containsValue("")) {
ListMultimap<Object, Object> annotatedMultimap = LinkedListMultimap.create();
for (Entry<?, ?> entry : multimap.entries()) {
Object key = "".equals(entry.getKey()) ? HUMAN_UNDERSTANDABLE_EMPTY_STRING : entry.getKey();
Object value =
"".equals(entry.getValue()) ? HUMAN_UNDERSTANDABLE_EMPTY_STRING : entry.getValue();
annotatedMultimap.put(key, value);
}
return annotatedMultimap;
} else {
return multimap;
}
}
/**
* Starts a method chain for a test proposition in which the actual values (i.e. the values of the
* {@link Multimap} under test) are compared to expected values using the given {@link
* Correspondence}. The actual values must be of type {@code A}, and the expected values must be
* of type {@code E}. The proposition is actually executed by continuing the method chain. For
* example:
*
* <pre>{@code
* assertThat(actualMultimap)
* .comparingValuesUsing(correspondence)
* .containsEntry(expectedKey, expectedValue);
* }</pre>
*
* where {@code actualMultimap} is a {@code Multimap<?, A>} (or, more generally, a {@code
* Multimap<?, ? extends A>}), {@code correspondence} is a {@code Correspondence<A, E>}, and
* {@code expectedValue} is an {@code E}.
*
* <p>Note that keys will always be compared with regular object equality ({@link Object#equals}).
*
* <p>Any of the methods on the returned object may throw {@link ClassCastException} if they
* encounter an actual value that is not of type {@code A}.
*/
public <A, E> UsingCorrespondence<A, E> comparingValuesUsing(
Correspondence<A, E> correspondence) {
return new UsingCorrespondence<A, E>(correspondence);
}
/**
* A partially specified proposition in which the actual values (i.e. the values of the {@link
* Multimap} under test) are compared to expected values using a {@link Correspondence}. The
* expected values are of type {@code E}. Call methods on this object to actually execute the
* proposition.
*
* <p>Note that keys will always be compared with regular object equality ({@link Object#equals}).
*/
public final class UsingCorrespondence<A, E> {
private final Correspondence<A, E> correspondence;
private UsingCorrespondence(Correspondence<A, E> correspondence) {
this.correspondence = checkNotNull(correspondence);
}
/**
* Fails if the multimap does not contain an entry with the given key and a value that
* corresponds to the given value.
*/
public void containsEntry(@Nullable Object expectedKey, @Nullable E expectedValue) {
if (actual().containsKey(expectedKey)) {
// Found matching key.
Collection<A> actualValues = getCastActual().asMap().get(expectedKey);
for (A actualValue : actualValues) {
if (correspondence.compare(actualValue, expectedValue)) {
// Found matching key and value. Test passes!
return;
}
}
// Found matching key with non-matching values.
failWithRawMessage(
"Not true that %s contains at least one entry with key <%s> and a value that %s <%s>. "
+ "However, it has a mapping from that key to <%s>",
actualAsString(), expectedKey, correspondence, expectedValue, actualValues);
} else {
// Did not find matching key.
Set<Object> keys = new LinkedHashSet<Object>();
for (Entry<?, A> actualEntry : getCastActual().entries()) {
if (correspondence.compare(actualEntry.getValue(), expectedValue)) {
keys.add(actualEntry.getKey());
}
}
if (!keys.isEmpty()) {
// Found matching values with non-matching keys.
failWithRawMessage(
"Not true that %s contains at least one entry with key <%s> and a value that %s <%s>."
+ " However, the following keys are mapped to such values: <%s>",
actualAsString(), expectedKey, correspondence, expectedValue, keys);
} else {
// Did not find matching key or value.
failWithRawMessage(
"Not true that %s contains at least one entry with key <%s> and a value that %s <%s>",
actualAsString(), expectedKey, correspondence, expectedValue);
}
}
}
/**
* Fails if the multimap contains an entry with the given key and a value that corresponds to
* the given value.
*/
public void doesNotContainEntry(@Nullable Object excludedKey, @Nullable E excludedValue) {
if (actual().containsKey(excludedKey)) {
Collection<A> actualValues = getCastActual().asMap().get(excludedKey);
List<A> matchingValues = new ArrayList<A>();
for (A actualValue : actualValues) {
if (correspondence.compare(actualValue, excludedValue)) {
matchingValues.add(actualValue);
}
}
if (!matchingValues.isEmpty()) {
failWithRawMessage(
"Not true that %s did not contain an entry with key <%s> and a value that %s <%s>. "
+ "It maps that key to the following such values: <%s>",
actualAsString(), excludedKey, correspondence, excludedValue, matchingValues);
}
}
}
/**
* Fails if the map does not contain exactly the keys in the given multimap, mapping to values
* that correspond to the values of the given multimap.
*
* <p>A subsequent call to {@link Ordered#inOrder} may be made if the caller wishes to verify
* that the two Multimaps iterate fully in the same order. That is, their key sets iterate in
* the same order, and the corresponding value collections for each key iterate in the same
* order.
*/
@CanIgnoreReturnValue
public <K, V extends E> Ordered containsExactlyEntriesIn(Multimap<K, V> expectedMultimap) {
// Note: The non-fuzzy MultimapSubject.containsExactlyEntriesIn has a custom implementation
// and produces somewhat better failure messages simply asserting about the iterables of
// entries would: it formats the expected values as k=[v1, v2] rather than k=v1, k=v2; and in
// the case where inOrder() fails it says the keys and/or the values for some keys are out of
// order. We don't bother with that here. It would be nice, but it would be a lot of added
// complexity for little gain.
return new IterableEntries(failureStrategy, MultimapSubject.this)
.comparingElementsUsing(new MapSubject.EntryCorrespondence<K, A, V>(correspondence))
.containsExactlyElementsIn(expectedMultimap.entries());
}
/**
* Fails if the multimap does not contain exactly the given set of key/value pairs.
*
* <p><b>Warning:</b> the use of varargs means that we cannot guarantee an equal number of
* key/value pairs at compile time. Please make sure you provide varargs in key/value pairs!
*/
@CanIgnoreReturnValue
public <K, V extends E> Ordered containsExactly(
@Nullable Object k0, @Nullable Object v0, Object... rest) {
@SuppressWarnings("unchecked")
Multimap<K, V> expectedMultimap = (Multimap<K, V>) accumulateMultimap(k0, v0, rest);
return containsExactlyEntriesIn(expectedMultimap);
}
@SuppressWarnings("unchecked") // throwing ClassCastException is the correct behaviour
private Multimap<?, A> getCastActual() {
return (Multimap<?, A>) actual();
}
}
}