diff --git a/.gitignore b/.gitignore index f813027..17faa4c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ build/ .project .settings/ .idea/ +gradle/ +gradlew +gradle.bat diff --git a/reactfx/build.gradle b/reactfx/build.gradle index 618fb4f..c03d81f 100644 --- a/reactfx/build.gradle +++ b/reactfx/build.gradle @@ -9,7 +9,7 @@ group = 'org.reactfx' dependencies { // Test dependencies - testImplementation group: 'junit', name: 'junit', version: '4.12' + testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.13.4' testImplementation group: 'org.hamcrest', name: 'hamcrest-library', version: '1.3' testImplementation group: 'org.junit.contrib', name: 'junit-theories', version: '4.12' testImplementation group: 'com.pholser', name: 'junit-quickcheck-core', version: '0.4' diff --git a/reactfx/src/main/java/org/reactfx/collection/LiveList.java b/reactfx/src/main/java/org/reactfx/collection/LiveList.java index 5db27b6..88568e3 100644 --- a/reactfx/src/main/java/org/reactfx/collection/LiveList.java +++ b/reactfx/src/main/java/org/reactfx/collection/LiveList.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.List; import java.util.NoSuchElementException; +import java.util.function.BiFunction; import java.util.function.BinaryOperator; import java.util.function.Consumer; import java.util.function.Function; @@ -21,6 +22,7 @@ import org.reactfx.collection.LiveList.QuasiModificationObserver; import org.reactfx.util.AccumulatorSize; import org.reactfx.util.Experimental; +import org.reactfx.util.Tuple2; import org.reactfx.util.WrapperBase; import org.reactfx.value.Val; @@ -179,6 +181,10 @@ default LiveList map(Function f) { return map(this, f); } + default LiveList map(BiFunction f) { + return map(this, f); + } + default LiveList mapDynamic( ObservableValue> f) { return mapDynamic(this, f); @@ -291,12 +297,25 @@ static Val sizeOf(ObservableList list) { return Val.create(() -> list.size(), list); } + /** + * If you wish to use the index of the element in your mapping function, check {@link #map(ObservableList, BiFunction)}. + * @param f a mapping function taking the element from source as input and producing a result + */ static LiveList map( ObservableList list, Function f) { return new MappedList<>(list, f); } + /** + * @param f a mapping function taking the index and the element from source as input and producing a result + */ + static LiveList map( + ObservableList list, + BiFunction f) { + return new MappedList<>(list, f); + } + static LiveList mapDynamic( ObservableList list, ObservableValue> f) { diff --git a/reactfx/src/main/java/org/reactfx/collection/MappedList.java b/reactfx/src/main/java/org/reactfx/collection/MappedList.java index 5605ecd..d8b9d69 100644 --- a/reactfx/src/main/java/org/reactfx/collection/MappedList.java +++ b/reactfx/src/main/java/org/reactfx/collection/MappedList.java @@ -1,6 +1,7 @@ package org.reactfx.collection; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Function; import javafx.beans.value.ObservableValue; @@ -13,18 +14,24 @@ class MappedList extends LiveListBase implements UnmodifiableByDefaultLiveList { private final ObservableList source; - private final Function mapper; + private final BiFunction mapper; public MappedList( ObservableList source, Function mapper) { + this(source, (index, elem) -> mapper.apply(elem)); + } + + public MappedList( + ObservableList source, + BiFunction mapper) { this.source = source; this.mapper = mapper; } @Override public F get(int index) { - return mapper.apply(source.get(index)); + return mapper.apply(index, source.get(index)); } @Override @@ -44,6 +51,12 @@ private void sourceChanged(QuasiListChange change) { static QuasiListChange mappedChangeView( QuasiListChange change, Function mapper) { + return mappedChangeView(change, (index, elem) -> mapper.apply(elem)); + } + + static QuasiListChange mappedChangeView( + QuasiListChange change, + BiFunction mapper) { return new QuasiListChange() { @Override @@ -63,7 +76,7 @@ public int getAddedSize() { @Override public List getRemoved() { - return Lists.mappedView(mod.getRemoved(), mapper); + return Lists.mappedView(mod.getRemoved(), (index, elem) -> mapper.apply(mod.getFrom(), elem)); } }); } diff --git a/reactfx/src/main/java/org/reactfx/util/Lists.java b/reactfx/src/main/java/org/reactfx/util/Lists.java index e91f34e..e4d24a8 100644 --- a/reactfx/src/main/java/org/reactfx/util/Lists.java +++ b/reactfx/src/main/java/org/reactfx/util/Lists.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.ListIterator; import java.util.Objects; +import java.util.function.BiFunction; import java.util.function.Function; public final class Lists { @@ -188,6 +189,23 @@ public int size() { }; } + public static List mappedView( + List source, + BiFunction f) { + return new AbstractList() { + + @Override + public F get(int index) { + return f.apply(index, source.get(index)); + } + + @Override + public int size() { + return source.size(); + } + }; + } + @SafeVarargs public static List concatView(List... lists) { return concatView(Arrays.asList(lists)); diff --git a/reactfx/src/test/java/org/reactfx/collection/ListMapTest.java b/reactfx/src/test/java/org/reactfx/collection/ListMapTest.java index 7fec01e..cf302f7 100644 --- a/reactfx/src/test/java/org/reactfx/collection/ListMapTest.java +++ b/reactfx/src/test/java/org/reactfx/collection/ListMapTest.java @@ -1,11 +1,14 @@ package org.reactfx.collection; import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.assertLinesMatch; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.function.Function; +import java.util.stream.Stream; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; @@ -13,60 +16,85 @@ import javafx.collections.ObservableList; import org.junit.Test; +import org.junit.jupiter.api.DisplayName; import org.reactfx.value.Var; public class ListMapTest { + private static class ChangeObserver { + public final List removed = new ArrayList<>(); + public final List added = new ArrayList<>(); - @Test - public void testGet() { - ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); - LiveList lengths = LiveList.map(strings, String::length); - - assertEquals(Arrays.asList(1, 2, 3), lengths); + public ChangeObserver(LiveList list) { + list.observeChanges(ch -> { + for (ListModification mod : ch.getModifications()) { + removed.addAll(mod.getRemoved()); + added.addAll(mod.getAddedSubList()); + } + }); + } } @Test public void testChanges() { ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); LiveList lengths = LiveList.map(strings, String::length); + assertEquals(Arrays.asList(1, 2, 3), lengths); - List removed = new ArrayList<>(); - List added = new ArrayList<>(); - lengths.observeChanges(ch -> { - for(ListModification mod: ch.getModifications()) { - removed.addAll(mod.getRemoved()); - added.addAll(mod.getAddedSubList()); - } - }); + ChangeObserver changes = new ChangeObserver<>(lengths); + // Set an item strings.set(1, "4444"); + assertEquals(Arrays.asList(1, 4, 3), lengths); + assertEquals(Collections.singletonList(2), changes.removed); + assertEquals(Collections.singletonList(4), changes.added); + + // Add an item + strings.add("7777777"); + assertEquals(Arrays.asList(1, 4, 3, 7), lengths); + assertEquals(Collections.singletonList(2), changes.removed); + assertEquals(Arrays.asList(4, 7), changes.added); - assertEquals(Arrays.asList(2), removed); - assertEquals(Arrays.asList(4), added); + // Remove an item + strings.remove(1); + assertEquals(Arrays.asList(1, 3, 7), lengths); + assertEquals(Arrays.asList(2, 4), changes.removed); + assertEquals(Arrays.asList(4, 7), changes.added); } @Test public void testLaziness() { ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); - IntegerProperty evaluations = new SimpleIntegerProperty(0); - LiveList lengths = LiveList.map(strings, s -> { - evaluations.set(evaluations.get() + 1); - return s.length(); + IntegerProperty evaluationsCounter = new SimpleIntegerProperty(0); + LiveList lengths = LiveList.map(strings, elem -> { + evaluationsCounter.set(evaluationsCounter.get() + 1); + return elem.length(); }); lengths.observeChanges(ch -> {}); strings.remove(1); - assertEquals(0, evaluations.get()); + assertEquals(0, evaluationsCounter.get()); + + // Get the first element and the counter has increased + assertEquals(1, lengths.get(0).intValue()); + assertEquals(1, evaluationsCounter.get()); + + // Get the second element, it will evaluate one item + assertEquals(3, lengths.get(1).intValue()); + assertEquals(2, evaluationsCounter.get()); + + // Get again the first, it will reevaluate it + assertEquals(1, lengths.get(0).intValue()); + assertEquals(3, evaluationsCounter.get()); } @Test public void testLazinessOnChangeAccumulation() { ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); - IntegerProperty evaluations = new SimpleIntegerProperty(0); - LiveList lengths = LiveList.map(strings, s -> { - evaluations.set(evaluations.get() + 1); - return s.length(); + IntegerProperty evaluationsCounter = new SimpleIntegerProperty(0); + LiveList lengths = LiveList.map(strings, elem -> { + evaluationsCounter.set(evaluationsCounter.get() + 1); + return elem.length(); }); SuspendableList suspendable = lengths.suspendable(); @@ -76,7 +104,7 @@ public void testLazinessOnChangeAccumulation() { strings.set(1, "abcd"); }); - assertEquals(0, evaluations.get()); + assertEquals(0, evaluationsCounter.get()); } @Test @@ -99,4 +127,205 @@ public void testDynamicMap() { fn.setValue(s -> s.length() * s.length()); }); } + + @Test + public void addingAndRemovingMultipleElements() { + ObservableList integers = FXCollections.observableArrayList(3, 4, 5); + // Live map receives index,item and returns %d-%d index item + LiveList mapped = LiveList.map(integers, (index, item) -> (index + 1) * item); + assertEquals(Arrays.asList(3, 8, 15), mapped); + + ChangeObserver changes = new ChangeObserver<>(mapped); + + // Add an item at position 2 + integers.add(2, 20); + assertEquals(Arrays.asList(3, 8, 60, 20), mapped); + assertEquals(Collections.singletonList(60), changes.added); + assertTrue(changes.removed.isEmpty()); + + // Remove an entry to the list and check changes (note that 3-7 becomes 2-7) + integers.remove(1); + assertEquals(Arrays.asList(3, 40, 15), mapped); + assertEquals(Collections.singletonList(60), changes.added); + assertEquals(Collections.singletonList(8), changes.removed); + + // Add two elements + integers.addAll(10, 11, 12); + assertEquals(Arrays.asList(3, 20, 5, 10, 11, 12), integers); + assertEquals(Arrays.asList(3, 40, 15, 40, 55, 72), mapped); + assertEquals(Arrays.asList(60, 40, 55, 72), changes.added); + assertEquals(Collections.singletonList(8), changes.removed); + + // Remove two elements at once + integers.removeAll(3, 5, 12); + assertEquals(Arrays.asList(20, 10, 11), integers); + assertEquals(Arrays.asList(20, 20, 33), mapped); + assertEquals(Arrays.asList(8, 3, 10, 48), changes.removed); + + // Conditional removal + integers.removeIf(i -> i % 2 == 0); + assertEquals(Collections.singletonList(11), integers); + assertEquals(Collections.singletonList(11), mapped); + assertEquals(Arrays.asList(8, 3, 10, 48, 20, 10), changes.removed); + + // Remove all + integers.clear(); + assertTrue(mapped.isEmpty()); + assertEquals(Arrays.asList(60, 40, 55, 72), changes.added); + assertEquals(Arrays.asList(8, 3, 10, 48, 20, 10, 11), changes.removed); + } + + @Test + public void removeMultipleElementsFromIndexList() { + ObservableList integers = FXCollections.observableArrayList(3, 4, 5, 6, 7, 8); + LiveList mapped = LiveList.map(integers, (index, item) -> (index + 1) * item); + assertEquals(Arrays.asList(3, 8, 15, 24, 35, 48), mapped); + ChangeObserver changes = new ChangeObserver<>(mapped); + + // Remove two elements at once + integers.removeAll(3, 5, 6, 7); + assertEquals(Arrays.asList(4, 8), integers); + assertEquals(Arrays.asList(4, 16), mapped); + assertTrue(changes.added.isEmpty()); + assertEquals(Arrays.asList(3, 10, 12, 14), changes.removed); + + // Remove elements one by one is giving the same result + integers.clear(); + assertTrue(integers.isEmpty()); + assertTrue(mapped.isEmpty()); + assertTrue(changes.added.isEmpty()); + assertEquals(Arrays.asList(3, 10, 12, 14, 4, 8), changes.removed); + + integers.addAll(3, 4, 5, 6, 7, 8); + assertEquals(Arrays.asList(3, 4, 5, 6, 7, 8), integers); + assertEquals(Arrays.asList(3, 8, 15, 24, 35, 48), changes.added); + assertEquals(Arrays.asList(3, 10, 12, 14, 4, 8), changes.removed); + + integers.remove(Integer.valueOf(3)); // Using 'Integer' to make sure it removes the element, not the index + integers.remove(Integer.valueOf(5)); + integers.remove(Integer.valueOf(6)); + integers.remove(Integer.valueOf(7)); + assertEquals(Arrays.asList(4, 8), integers); + assertEquals(Arrays.asList(4, 16), mapped); + assertEquals(Arrays.asList(3, 8, 15, 24, 35, 48), changes.added); + assertEquals(Arrays.asList(3, 10, 12, 14, 4, 8, 3, 10, 12, 14), changes.removed); + } + + @Test + public void removeMultipleElementsFromIndexListUsingPredicate() { + ObservableList integers = FXCollections.observableArrayList(3, 4, 5, 6, 7, 8); + LiveList mapped = LiveList.map(integers, (index, item) -> (index + 1) * item); + assertEquals(Arrays.asList(3, 8, 15, 24, 35, 48), mapped); + ChangeObserver changes = new ChangeObserver<>(mapped); + + // Remove two elements at once + integers.removeIf(i -> i % 2 == 0); + assertEquals(Arrays.asList(3, 5, 7), integers); + assertEquals(Arrays.asList(3, 10, 21), mapped); + assertTrue(changes.added.isEmpty()); + assertEquals(Arrays.asList(8, 18, 32), changes.removed); + } + + @Test + public void sortIndexedList() { + ObservableList integers = FXCollections.observableArrayList(8, 5, 1, 3, 4); + LiveList mapped = LiveList.map(integers, (index, item) -> (index + 1) * item); + ChangeObserver changes = new ChangeObserver<>(mapped); + assertEquals(Arrays.asList(8, 10, 3, 12, 20), mapped); + + // Sort the list will remove all and add all + integers.sort(Integer::compare); + assertEquals(Arrays.asList(1, 3, 4, 5, 8), integers); + assertEquals(Arrays.asList(1, 6, 12, 20, 40), mapped); + assertEquals(Arrays.asList(1, 6, 12, 20, 40), changes.added); + assertEquals(Arrays.asList(8, 5, 1, 3, 4), changes.removed); + } + + @Test + public void setAllContentOfIndexedList() { + ObservableList integers = FXCollections.observableArrayList(8, 5, 1, 3, 4); + LiveList mapped = LiveList.map(integers, (index, item) -> (index + 1) * item); + ChangeObserver changes = new ChangeObserver<>(mapped); + assertEquals(Arrays.asList(8, 10, 3, 12, 20), mapped); + + // Set all + integers.setAll(1, 2, 3); + assertEquals(Arrays.asList(1, 2, 3), integers); + assertEquals(Arrays.asList(1, 4, 9), mapped); + assertEquals(Arrays.asList(1, 4, 9), changes.added); + assertEquals(Arrays.asList(8, 5, 1, 3, 4), changes.removed); + } + + @Test + public void removingMultipleElements() { + ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); + // Live map receives index,item and returns %d-%d index item + LiveList lengths = LiveList.map(strings, (index, item) -> String.format("%d-%d", index, item.length())); + ChangeObserver changes = new ChangeObserver<>(lengths); + assertLinesMatch(Stream.of("0-1", "1-2", "2-3"), lengths.stream()); + + // Remove an entry to the list and check changes (note that 3-7 becomes 2-7) + strings.removeAll("22", "333"); + assertLinesMatch(Stream.of("1"), strings.stream()); + assertLinesMatch(Stream.of("0-1"), lengths.stream()); + assertTrue(changes.added.isEmpty()); + assertEquals(Arrays.asList("1-2", "1-3"), changes.removed); + } + + @Test + public void testIndexedList() { + ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); + // Live map receives index,item and returns %d-%d index item + LiveList lengths = LiveList.map(strings, (index, item) -> String.format("%d-%d", index, item.length())); + assertLinesMatch(Stream.of("0-1", "1-2", "2-3"), lengths.stream()); + + ChangeObserver changes = new ChangeObserver<>(lengths); + + // Set a value in the list and check changes + strings.set(1, "4444"); + assertLinesMatch(Stream.of("0-1", "1-4", "2-3"), lengths.stream()); + assertEquals(Collections.singletonList("1-4"), changes.added); + assertEquals(Collections.singletonList("1-2"), changes.removed); + + // Add an entry to the list and check changes + strings.add("7777777"); + assertLinesMatch(Stream.of("0-1", "1-4", "2-3", "3-7"), lengths.stream()); + assertEquals(Arrays.asList("1-4", "3-7"), changes.added); + assertEquals(Collections.singletonList("1-2"), changes.removed); + + // Remove an entry to the list and check changes (note that 3-7 becomes 2-7) + strings.remove(2); + assertLinesMatch(Stream.of("0-1", "1-4", "2-7"), lengths.stream()); + assertEquals(Arrays.asList("1-4", "3-7"), changes.added); + assertEquals(Arrays.asList("1-2", "2-3"), changes.removed); + } + + @Test + @DisplayName("testLazyIndexedList") + public void testLazyIndexedList() { + ObservableList strings = FXCollections.observableArrayList("1", "22", "333"); + IntegerProperty evaluationsCounter = new SimpleIntegerProperty(0); + LiveList lengths = LiveList.map(strings, (index, elem) -> { + evaluationsCounter.set(evaluationsCounter.get() + 1); + return String.format("%d-%d", index, elem.length()); + }); + + lengths.observeChanges(ch -> {}); + strings.remove(1); + + assertEquals(0, evaluationsCounter.get()); + + // Get the first element and the counter has increased + assertEquals("0-1", lengths.get(0)); + assertEquals(1, evaluationsCounter.get()); + + // Get the second element, it will evaluate one item + assertEquals("1-3", lengths.get(1)); + assertEquals(2, evaluationsCounter.get()); + + // Get again the first, it will reevaluate it + assertEquals("0-1", lengths.get(0)); + assertEquals(3, evaluationsCounter.get()); + } + }