/* * 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 com.google.common.base.Joiner; import com.google.common.base.Objects; 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)) { 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); 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)); } 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); } 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()) { failWithRawMessage( "Not true that %s contains exactly <%s>. " + "It is missing <%s> and has unexpected items <%s>", actualAsString(), expectedMultimap, countDuplicatesMultimap(missing), countDuplicatesMultimap(extra)); } else { failWithBadResults( "contains exactly", expectedMultimap, "is missing", countDuplicatesMultimap(missing)); } } else if (!extra.isEmpty()) { failWithBadResults( "contains exactly", expectedMultimap, "has unexpected items", countDuplicatesMultimap(extra)); } return new MultimapInOrder(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(); } /** * 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()); } @SuppressWarnings("unchecked") // throwing ClassCastException is the correct behaviour private Multimap<?, A> getCastActual() { return (Multimap<?, A>) actual(); } } }