diff --git a/OcavaCore/src/main/java/com/ocadotechnology/config/ConfigParsers.java b/OcavaCore/src/main/java/com/ocadotechnology/config/ConfigParsers.java index a51325c1..f395229d 100644 --- a/OcavaCore/src/main/java/com/ocadotechnology/config/ConfigParsers.java +++ b/OcavaCore/src/main/java/com/ocadotechnology/config/ConfigParsers.java @@ -244,7 +244,7 @@ public static double parseSpeed(String value, LengthUnit returnLengthUnit, TimeU /** * Parse the given String into an acceleration value in the given {@link LengthUnit} over {@link TimeUnit} squared. - * The* String can specify input length and time units. If none are specified the SI Units are used. The parser can + * The String can specify input length and time units. If none are specified the SI Units are used. The parser can * cope with case insensitivity, as well as adding an "S" to pluralise units. *
*
diff --git a/OcavaCore/src/main/java/com/ocadotechnology/utils/SortByValueMap.java b/OcavaCore/src/main/java/com/ocadotechnology/utils/SortByValueMap.java new file mode 100644 index 00000000..5ef70a83 --- /dev/null +++ b/OcavaCore/src/main/java/com/ocadotechnology/utils/SortByValueMap.java @@ -0,0 +1,294 @@ +/* + * Copyright © 2017-2024 Ocado (Ocava) + * + * 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.ocadotechnology.utils; + +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeSet; + +import javax.annotation.Nonnull; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedMap; + +/** + * HashMap with iteration order defined by order of values. Sorts by values then keys. + * Non-equal keys must not compare as equal, as this will cause inconsistent ordering + * errors. This is only enforced lazily as active enforcement would incur a performance + * penalty. + *
+ * Note: Every view returned by this class is an immutable copy which will not reflect + * subsequent changes to the parent map. This has been done in order to make the + * development tractable. + */ +public class SortByValueMap implements SortedMap { + /** + * It is necessary to store the sequence and the mapping separately, since the sequence + * is dependent on the mapping. We could order entries instead of keys, but we still need + * to be able to query the value from the key in order to satisfy the map API. + */ + private final Map map = new HashMap<>(); + private final Comparator comparator; + private final SortedSet orderedKeys; + + /** + * Creates a new instance of SortByValueMap with natural order comparators for both keys and + * values. Requires both classes to be Comparable. + */ + public static , V extends Comparable> SortByValueMap createWithNaturalOrderComparators() { + return new SortByValueMap<>(Comparator.naturalOrder(), Comparator.naturalOrder()); + } + + /** + * Creates a new instance of SortByValueMap with a custom comparator for keys and natural order + * comparator for values. Requires values to be Comparable. + */ + public static > SortByValueMap createWithNaturalOrderValueComparator(Comparator keyComparator) { + return new SortByValueMap<>(keyComparator, Comparator.naturalOrder()); + } + + /** + * Creates a new instance of SortByValueMap with a natural order comparator for keys and a custom + * comparator for values. Requires keys to be Comparable. + */ + public static , V> SortByValueMap createWithNaturalOrderKeyComparator(Comparator valueComparator) { + return new SortByValueMap<>(Comparator.naturalOrder(), valueComparator); + } + + /** + * Creates a new instance of SortByValueMap with custom comparators for both keys and values. + */ + public static SortByValueMap createWithCustomComparators(Comparator keyComparator, Comparator valueComparator) { + return new SortByValueMap<>(keyComparator, valueComparator); + } + + private SortByValueMap(Comparator keyComparator, Comparator valueComparator) { + this.comparator = buildComparator(keyComparator, valueComparator); + this.orderedKeys = new TreeSet<>(comparator); + } + + private Comparator buildComparator(Comparator keyComparator, Comparator valueComparator) { + return (k1, k2) -> { + int result = valueComparator.compare(map.get(k1), map.get(k2)); + if (result == 0) { + result = keyComparator.compare(k1, k2); + } + if (result == 0) { + Preconditions.checkState(k1.equals(k2), "Keys %s and %s are non-equal but return zero in their comparator", k1, k2); + } + return result; + }; + } + + @Override + public int size() { + return orderedKeys.size(); + } + + @Override + public boolean isEmpty() { + return orderedKeys.isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return map.containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return map.containsValue(value); + } + + @Override + public V get(Object key) { + return map.get(key); + } + + @Override + public V put(K key, V value) { + clearSortOrderIfKnown(key); + V previous = map.put(key, value); + orderedKeys.add(key); + return previous; + } + + @Override + public void putAll(Map m) { + for (K key : m.keySet()) { + clearSortOrderIfKnown(key); + } + map.putAll(m); + orderedKeys.addAll(m.keySet()); + } + + @Override + public V remove(Object key) { + clearSortOrderIfKnown(key); + return map.remove(key); + } + + private void clearSortOrderIfKnown(Object key) { + if (map.containsKey(key)) { + orderedKeys.remove(key); + } + } + + @Override + public void clear() { + orderedKeys.clear(); + map.clear(); + } + + /** + * Returns an immutable copy of the key set, with iteration order defined by the + * iteration order of the map. The set is a snapshot of the keys at the time of + * the call, and will not reflect subsequent changes to the map. + */ + @Override + @Nonnull + public ImmutableSet keySet() { + return ImmutableSet.copyOf(orderedKeys); + } + + /** + * Returns an immutable copy of the value collection, with iteration order defined by the + * iteration order of the map. The collection is a snapshot of the values at the time of + * the call, and will not reflect subsequent changes to the map. + */ + @Override + @Nonnull + public ImmutableList values() { + return orderedKeys.stream().map(map::get).collect(ImmutableList.toImmutableList()); + } + + /** + * Returns an immutable copy of the entry set, with iteration order defined by the + * iteration order of the map. The set is a snapshot of the entries at the time of + * the call, and will not reflect subsequent changes to the map. + */ + @Override + @Nonnull + public ImmutableSet> entrySet() { + return orderedKeys.stream().map(k -> new SimpleImmutableEntry<>(k, map.get(k))).collect(ImmutableSet.toImmutableSet()); + } + + @Override + public K firstKey() { + return orderedKeys.first(); + } + + @Override + public K lastKey() { + return orderedKeys.last(); + } + + /** + * Returns an immutable copy of the portion of this map whose keys range from fromKey to toKey. + * Iteration order of the returned copy will match the parent map. The map is a snapshot of the + * entries at the time of the call, and will not reflect subsequent changes to the map. + */ + public ImmutableSortedMap subMap(K fromKey, boolean fromInclusive, K toKey, boolean toInclusive) { + ImmutableSortedMap.Builder builder = ImmutableSortedMap.orderedBy(comparator); + // orderedKeys.subSet is fromInclusive/toExclusive by default, so I need to potentially remove the first key and + // add the last key depending on the values provided by the caller. + for (K k : orderedKeys.subSet(fromKey, toKey)) { + if (!fromInclusive && k.equals(fromKey)) { + continue; + } + builder.put(k, map.get(k)); + } + if (toInclusive && map.containsKey(toKey)) { + builder.put(toKey, map.get(toKey)); + } + return builder.build(); + } + /** + * Returns an immutable copy of the portion of this map whose keys range from fromKey inclusive + * to toKey exclusive. Iteration order of the returned copy will match the parent map. The map + * is a snapshot of the entries at the time of the call, and will not reflect subsequent changes + * to the map. + */ + @Override + @Nonnull + public ImmutableSortedMap subMap(K fromKey, K toKey) { + return subMap(fromKey, true, toKey, false); + } + + /** + * Returns an immutable copy of the portion of this map whose keys are less than (or equal to if + * inclusive is set to true) toKey. Iteration order of the returned copy will match the parent map. + * The map is a snapshot of the entries at the time of the call, and will not reflect subsequent + * changes to the map. + */ + public ImmutableSortedMap headMap(K toKey, boolean inclusive) { + ImmutableSortedMap.Builder builder = ImmutableSortedMap.orderedBy(comparator); + orderedKeys.headSet(toKey).forEach(k -> builder.put(k, map.get(k))); + if (inclusive && map.containsKey(toKey)) { + builder.put(toKey, map.get(toKey)); + } + return builder.build(); + } + + /** + * Returns an immutable copy of the portion of this map whose keys are strictly less than toKey. + * Iteration order of the returned copy will match the parent map. The map is a snapshot of the + * entries at the time of the call, and will not reflect subsequent changes to the map. + */ + @Override + @Nonnull + public ImmutableSortedMap headMap(K toKey) { + return headMap(toKey, false); + } + + /** + * Returns an immutable copy of the portion of this map whose keys are greater than (or equal to if + * inclusive is set to true) fromKey. Iteration order of the returned copy will match the parent map. + * The map is a snapshot of the entries at the time of the call, and will not reflect subsequent + * changes to the map. + */ + public ImmutableSortedMap tailMap(K fromKey, boolean inclusive) { + ImmutableSortedMap.Builder builder = ImmutableSortedMap.orderedBy(comparator); + for (K k : orderedKeys.tailSet(fromKey)) { + if (inclusive || !k.equals(fromKey)) { + builder.put(k, map.get(k)); + } + } + return builder.build(); + } + + /** + * Returns an immutable copy of the portion of this map whose keys are greater than or equal to fromKey. + * Iteration order of the returned copy will match the parent map. The map is a snapshot of the entries + * at the time of the call, and will not reflect subsequent changes to the map. + */ + @Override + @Nonnull + public ImmutableSortedMap tailMap(K fromKey) { + return tailMap(fromKey, true); + } + + @Override + public Comparator comparator() { + return orderedKeys.comparator(); + } +} diff --git a/OcavaCore/src/test/java/com/ocadotechnology/utils/SortByValueMapTest.java b/OcavaCore/src/test/java/com/ocadotechnology/utils/SortByValueMapTest.java new file mode 100644 index 00000000..930b1abc --- /dev/null +++ b/OcavaCore/src/test/java/com/ocadotechnology/utils/SortByValueMapTest.java @@ -0,0 +1,408 @@ +/* + * Copyright © 2017-2024 Ocado (Ocava) + * + * 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.ocadotechnology.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableMap; +import com.ocadotechnology.wrappers.Pair; + +class SortByValueMapTest { + private static final String KEY_1 = "Alpha"; + private static final String KEY_2 = "Bravo"; + private static final String KEY_3 = "Charlie"; + private final SortByValueMap testMap = SortByValueMap.createWithNaturalOrderComparators(); + + @Test + void put_whenInconsistentEquals_thenThrowsException() { + SortByValueMap clashingTestMap = SortByValueMap.createWithNaturalOrderValueComparator((s1, s2) -> 0); + clashingTestMap.put(KEY_1, 1); + assertThatThrownBy(() -> clashingTestMap.put(KEY_2, 1)).isInstanceOf(IllegalStateException.class); + } + + /* + * We could consider how to proactively detect non-equal keys with an indeterminate sort order, but for now, these + * are only detected when the values are the same. This is because any proactive detection would incur a performance + * penalty which is not obviously justified. + */ + @Test + void put_whenInconsistentEqualsButDifferentValues_thenDoesNotThrow() { + SortByValueMap clashingTestMap = SortByValueMap.createWithNaturalOrderValueComparator((s1, s2) -> 0); + clashingTestMap.put(KEY_1, 1); + clashingTestMap.put(KEY_2, 2); + + assertThat(clashingTestMap.get(KEY_1)).isEqualTo(1); + assertThat(clashingTestMap.get(KEY_2)).isEqualTo(2); + } + + @Test + void get_whenMappingIsPresent_returnsValue() { + testMap.put(KEY_1, 1); + + assertThat(testMap.get(KEY_1)).isEqualTo(1); + } + + @Test + void get_whenMappingIsAbsent_returnsNull() { + testMap.put(KEY_1, 1); + + assertThat(testMap.get(KEY_2)).isNull(); + } + + @Test + void get_whenMultipleMappingsPresent_returnsValues() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + + assertThat(testMap.get(KEY_1)).isEqualTo(1); + assertThat(testMap.get(KEY_2)).isEqualTo(2); + } + + @Test + void get_whenDuplicateValuesPresent_returnsValues() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 1); + + assertThat(testMap.get(KEY_1)).isEqualTo(1); + assertThat(testMap.get(KEY_2)).isEqualTo(1); + } + + @Test + void remove_whenKeyExists_returnsValue() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + + assertThat(testMap.remove(KEY_1)).isEqualTo(1); + assertThat(testMap.get(KEY_1)).isNull(); + } + + @Test + void remove_whenKeyDoesNotExist_returnsNull() { + testMap.put(KEY_1, 1); + + assertThat(testMap.remove(KEY_2)).isNull(); + } + + @Test + void putAll_whenKeysNotPresent_valuesAdded() { + Map newMap = ImmutableMap.of(KEY_1, 1, KEY_2, 2); + testMap.putAll(newMap); + + assertThat(testMap.get(KEY_1)).isEqualTo(1); + assertThat(testMap.get(KEY_2)).isEqualTo(2); + } + + @Test + void putAll_whenKeysPresent_valuesReplaced() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + + Map newMap = ImmutableMap.of(KEY_1, 2, KEY_2, 1); + testMap.putAll(newMap); + + assertThat(testMap.get(KEY_1)).isEqualTo(2); + assertThat(testMap.get(KEY_2)).isEqualTo(1); + } + + @Test + void forEach_whenValuesDistinct_respectsSortOrder() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + + List> invocations = new ArrayList<>(); + testMap.forEach((k, v) -> invocations.add(Pair.of(k, v))); + assertThat(invocations).containsExactly(Pair.of(KEY_1, 1), Pair.of(KEY_2, 2)); + + testMap.put(KEY_1, 2); + testMap.put(KEY_2, 1); + + invocations.clear(); + testMap.forEach((k, v) -> invocations.add(Pair.of(k, v))); + assertThat(invocations).containsExactly(Pair.of(KEY_2, 1), Pair.of(KEY_1, 2)); + } + + @Test + void forEach_whenValuesEqual_respectsKeySortOrder() { + testMap.put(KEY_2, 1); + testMap.put(KEY_1, 1); + + List> invocations = new ArrayList<>(); + testMap.forEach((k, v) -> invocations.add(Pair.of(k, v))); + assertThat(invocations).containsExactly(Pair.of(KEY_1, 1), Pair.of(KEY_2, 1)); + } + + @Test + void firstKey_whenEmpty_throwsException() { + assertThatThrownBy(testMap::firstKey).isInstanceOf(NoSuchElementException.class); + } + + @Test + void firstKey_whenNotEmpty_returnsFirstKeyByValueOrdering() { + testMap.put(KEY_1, 2); + testMap.put(KEY_2, 1); + + assertThat(testMap.firstKey()).isEqualTo(KEY_2); + } + + @Test + void firstKey_whenNotEmpty_returnsFirstKeyByKeyOrdering() { + testMap.put(KEY_2, 1); + testMap.put(KEY_1, 1); + + assertThat(testMap.firstKey()).isEqualTo(KEY_1); + } + + @Test + void lastKey_whenEmpty_throwsException() { + assertThatThrownBy(testMap::lastKey).isInstanceOf(NoSuchElementException.class); + } + + @Test + void lastKey_whenNotEmpty_returnsLastKeyByValueOrdering() { + testMap.put(KEY_1, 2); + testMap.put(KEY_2, 1); + + assertThat(testMap.lastKey()).isEqualTo(KEY_1); + } + + @Test + void lastKey_whenNotEmpty_returnsLastKeyByKeyOrdering() { + testMap.put(KEY_2, 1); + testMap.put(KEY_1, 1); + + assertThat(testMap.lastKey()).isEqualTo(KEY_2); + } + + @Test + void subMap_whenKeyNotInMap_throwsException() { + assertThatThrownBy(() -> testMap.subMap(KEY_1, KEY_2)).isInstanceOf(NullPointerException.class); + } + + @Test + void subMap_whenFromKeyIsGreaterThanToKey_throwsException() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + + assertThatThrownBy(() -> testMap.subMap(KEY_2, KEY_1)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void subMap_whenFromKeyIsEqualToToKey_returnsEmptyMap() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + + assertThat(testMap.subMap(KEY_1, KEY_1)).isEmpty(); + } + + @Test + void subMap_whenFromKeyIsLessThanToKey_returnsMapWithKeysInRange() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + testMap.put(KEY_3, 3); + + SimpleImmutableEntry firstEntry = new SimpleImmutableEntry<>(KEY_1, 1); + SimpleImmutableEntry secondEntry = new SimpleImmutableEntry<>(KEY_2, 2); + + assertThat(testMap.subMap(KEY_1, KEY_2).entrySet()).containsExactly(firstEntry); + assertThat(testMap.subMap(KEY_1, KEY_3).entrySet()).containsExactly(firstEntry, secondEntry); + assertThat(testMap.subMap(KEY_2, KEY_3).entrySet()).containsExactly(secondEntry); + } + + @Test + void subMap_whenFromInclusiveFalse_excludesFromKey() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + testMap.put(KEY_3, 3); + + SimpleImmutableEntry secondEntry = new SimpleImmutableEntry<>(KEY_2, 2); + + assertThat(testMap.subMap(KEY_1, false, KEY_2, false).entrySet()).isEmpty(); + assertThat(testMap.subMap(KEY_1, false, KEY_3, false).entrySet()).containsExactly(secondEntry); + assertThat(testMap.subMap(KEY_2, false, KEY_3, false).entrySet()).isEmpty(); + } + + @Test + void subMap_whenToInclusiveTrue_includesToKey() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + testMap.put(KEY_3, 3); + + SimpleImmutableEntry firstEntry = new SimpleImmutableEntry<>(KEY_1, 1); + SimpleImmutableEntry secondEntry = new SimpleImmutableEntry<>(KEY_2, 2); + SimpleImmutableEntry thirdEntry = new SimpleImmutableEntry<>(KEY_3, 3); + + assertThat(testMap.subMap(KEY_1, true, KEY_2, true).entrySet()).containsExactly(firstEntry, secondEntry); + assertThat(testMap.subMap(KEY_1, true, KEY_3, true).entrySet()).containsExactly(firstEntry, secondEntry, thirdEntry); + assertThat(testMap.subMap(KEY_2, true, KEY_3, true).entrySet()).containsExactly(secondEntry, thirdEntry); + } + + @Test + void compute_whenKeyNotInMap_returnsNewValue() { + testMap.put(KEY_1, 1); + + assertThat(testMap.compute(KEY_2, (k, v) -> { + assertThat(v).isNull(); + return 2; + })).isEqualTo(2); + assertThat(testMap.get(KEY_2)).isEqualTo(2); + } + + @Test + void compute_whenKeyInMap_returnsNewValue() { + testMap.put(KEY_1, 1); + + assertThat(testMap.compute(KEY_1, (k, v) -> { + assertThat(v).isEqualTo(1); + return 2; + })).isEqualTo(2); + assertThat(testMap.get(KEY_1)).isEqualTo(2); + } + + @Test + void computeIfAbsent_whenKeyNotInMap_returnsNewValue() { + testMap.put(KEY_1, 1); + + assertThat(testMap.computeIfAbsent(KEY_2, k -> 2)).isEqualTo(2); + assertThat(testMap.get(KEY_2)).isEqualTo(2); + } + + @Test + void computeIfAbsent_whenKeyInMap_returnsExistingValue() { + testMap.put(KEY_1, 1); + + assertThat(testMap.computeIfAbsent(KEY_1, k -> { + Assertions.fail(); + return -1; + })).isEqualTo(1); + assertThat(testMap.get(KEY_1)).isEqualTo(1); + } + + @Test + void computeIfPresent_whenKeyNotInMap_returnsNull() { + testMap.put(KEY_1, 1); + + assertThat(testMap.computeIfPresent(KEY_2, (k, v) -> { + Assertions.fail(); + return -1; + })).isNull(); + } + + @Test + void computeIfPresent_whenKeyInMap_returnsNewValue() { + testMap.put(KEY_1, 1); + + assertThat(testMap.computeIfPresent(KEY_1, (k, v) -> { + assertThat(v).isEqualTo(1); + return 2; + })).isEqualTo(2); + assertThat(testMap.get(KEY_1)).isEqualTo(2); + } + + @Test + void headMap_whenKeyNotInMap_throwsException() { + assertThatThrownBy(() -> testMap.headMap(KEY_1)).isInstanceOf(NullPointerException.class); + } + + @Test + void headMap_whenKeyIsFirstKey_returnsEmptyMap() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + + assertThat(testMap.headMap(KEY_1).entrySet()).isEmpty(); + } + + @Test + void headMap_whenKeyIsNotFirstKey_returnsMapWithKeysLessThanKey() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + testMap.put(KEY_3, 3); + + SimpleImmutableEntry firstEntry = new SimpleImmutableEntry<>(KEY_1, 1); + SimpleImmutableEntry secondEntry = new SimpleImmutableEntry<>(KEY_2, 2); + + assertThat(testMap.headMap(KEY_2).entrySet()).containsExactly(firstEntry); + assertThat(testMap.headMap(KEY_3).entrySet()).containsExactly(firstEntry, secondEntry); + } + + @Test + void headMap_whenInclusiveTrue_includesKey() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + testMap.put(KEY_3, 3); + + SimpleImmutableEntry firstEntry = new SimpleImmutableEntry<>(KEY_1, 1); + SimpleImmutableEntry secondEntry = new SimpleImmutableEntry<>(KEY_2, 2); + SimpleImmutableEntry thirdEntry = new SimpleImmutableEntry<>(KEY_3, 3); + + assertThat(testMap.headMap(KEY_1, true).entrySet()).containsExactly(firstEntry); + assertThat(testMap.headMap(KEY_2, true).entrySet()).containsExactly(firstEntry, secondEntry); + assertThat(testMap.headMap(KEY_3, true).entrySet()).containsExactly(firstEntry, secondEntry, thirdEntry); + } + + @Test + void tailMap_whenKeyNotInMap_throwsException() { + assertThatThrownBy(() -> testMap.tailMap(KEY_1)).isInstanceOf(NullPointerException.class); + } + + @Test + void tailMap_whenKeyIsLastKey_returnsSingleEntryMap() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + + SimpleImmutableEntry secondEntry = new SimpleImmutableEntry<>(KEY_2, 2); + + assertThat(testMap.tailMap(KEY_2).entrySet()).containsExactly(secondEntry); + } + + @Test + void tailMap_whenKeyIsNotLastKey_returnsMapWithKeysGreaterThanOrEqualKey() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + testMap.put(KEY_3, 3); + + SimpleImmutableEntry firstEntry = new SimpleImmutableEntry<>(KEY_1, 1); + SimpleImmutableEntry secondEntry = new SimpleImmutableEntry<>(KEY_2, 2); + SimpleImmutableEntry thirdEntry = new SimpleImmutableEntry<>(KEY_3, 3); + + assertThat(testMap.tailMap(KEY_1).entrySet()).containsExactly(firstEntry, secondEntry, thirdEntry); + assertThat(testMap.tailMap(KEY_2).entrySet()).containsExactly(secondEntry, thirdEntry); + assertThat(testMap.tailMap(KEY_3).entrySet()).containsExactly(thirdEntry); + } + + @Test + void tailMap_whenInclusiveFalse_excludesKey() { + testMap.put(KEY_1, 1); + testMap.put(KEY_2, 2); + testMap.put(KEY_3, 3); + + SimpleImmutableEntry secondEntry = new SimpleImmutableEntry<>(KEY_2, 2); + SimpleImmutableEntry thirdEntry = new SimpleImmutableEntry<>(KEY_3, 3); + + assertThat(testMap.tailMap(KEY_1, false).entrySet()).containsExactly(secondEntry, thirdEntry); + assertThat(testMap.tailMap(KEY_2, false).entrySet()).containsExactly(thirdEntry); + assertThat(testMap.tailMap(KEY_3, false).entrySet()).isEmpty(); + } +} \ No newline at end of file