diff --git a/README.md b/README.md index 029074c..93302d6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,12 @@ This somewhat vague sentiment does not translate to quality! The code is clean, These features are present in the latest release: +* [ControlPropertyListener](https://github.com/CodeFX-org/LibFX/wiki/ControlPropertyListener): creating listeners for the property map of JavaFX controls +* [ListenerHandle](https://github.com/CodeFX-org/LibFX/wiki/ListenerHandle): encapsulating an observable and a listener for easier add/remove of the listener * [Nestings](https://github.com/CodeFX-org/LibFX/wiki/Nestings): using all the power of JavaFX' properties for nested object aggregations +* [SerializableOptional](https://github.com/CodeFX-org/LibFX/wiki/SerializableOptional): serializable wrapper for `Optional` +* [WebViewHyperlinkListener](https://github.com/CodeFX-org/LibFX/wiki/WebViewHyperlinkListener): add hyperlink listeners to JavaFX' `WebView` + ## Documentation @@ -22,7 +27,25 @@ License details can be found in the *LICENSE* file in the project's root folder. ## Releases -Releases are published [here](https://github.com/CodeFX-org/LibFX/releases). The release notes also contain the Maven coordinates for each version available in Maven Central. +Releases are published [on GitHub](https://github.com/CodeFX-org/LibFX/releases). The release notes also contain a link to the artifact in Maven Central and its coordinates. + +The current version is [0.2.0](http://search.maven.org/#artifactdetails|org.codefx.libfx|LibFX|0.2.0|jar): + +**Maven**: + +``` XML + + org.codefx.libfx + LibFX + 0.2.0 + +``` + +**Gradle**: + +``` + compile 'org.codefx.libfx:LibFX:0.2.0' +``` ## Development @@ -42,7 +65,7 @@ The library has its home on [GitHub](https://github.com/CodeFX-org/LibFX) where I have a blog at [codefx.org](http://blog.codefx.org) where I might occasionally blog about **LibFX**. Those posts are filed under [their own tag](http://blog.codefx.org/tag/libfx/). -I use Eclipse and my project settings (like compiler warnings, formatter and save actions) can be found in the repository folder **.settings**. I do this to make it easier for contributors to cope with my obsession for warning free and consistently formatted code. +I use Eclipse and my project settings (like compiler warnings, formatter and save actions) can be found in the repository folder **.settings**. I know this is a little unusual but it makes it easier for contributors to cope with my obsession for warning free and consistently formatted code. ## Contact @@ -51,4 +74,5 @@ CodeFX Web: http://codefx.org
Mail: nipa@codefx.org
-Key: http://keys.gnupg.net/pks/lookup?op=vindex&search=0xA47A795BA5BF8326
+Twitter: https://twitter.com/nipafx
+PGP-Key: http://keys.gnupg.net/pks/lookup?op=vindex&search=0xA47A795BA5BF8326
diff --git a/pom.xml b/pom.xml index b9c1561..b3e6594 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.codefx.libfx LibFX - 0.1.1 + 0.2.0 jar @@ -67,17 +67,25 @@ + junit junit 4.11 test + org.mockito mockito-all 1.9.5 test + + + net.sourceforge.nekohtml + nekohtml + 1.9.21 + @@ -95,7 +103,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.1 + 3.2 1.8 1.8 @@ -105,7 +113,7 @@ org.apache.maven.plugins maven-source-plugin - 2.3 + 2.4 attach-sources @@ -119,7 +127,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 2.9.1 + 2.10.1 attach-javadocs @@ -128,6 +136,25 @@ + + + + + api_1.8 + https://docs.oracle.com/javase/8/docs/api/ + + + + + CC-BY 4.0, + attributed to Nicolai Parlog from + CodeFX. + ]]> + + diff --git a/src/demo/java/org/codefx/libfx/control/ControlPropertyListenerDemo.java b/src/demo/java/org/codefx/libfx/control/ControlPropertyListenerDemo.java new file mode 100644 index 0000000..589a75f --- /dev/null +++ b/src/demo/java/org/codefx/libfx/control/ControlPropertyListenerDemo.java @@ -0,0 +1,206 @@ +package org.codefx.libfx.control; + +import java.util.function.Consumer; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableMap; + +import org.codefx.libfx.control.properties.ControlProperties; +import org.codefx.libfx.control.properties.ControlPropertyListenerHandle; + +/** + * Demonstrates how to use the {@link ControlPropertyListenerHandle} and its builder. + */ +@SuppressWarnings("static-method") +public class ControlPropertyListenerDemo { + + // #region CONSTRUCTION & MAIN + + /** + * Creates a new demo. + */ + private ControlPropertyListenerDemo() { + // nothing to do + } + + /** + * Runs this demo. + * + * @param args + * command line arguments (will not be used) + */ + public static void main(String[] args) { + ControlPropertyListenerDemo demo = new ControlPropertyListenerDemo(); + + demo.simpleCase(); + demo.attachAndDetach(); + + demo.timeNoTypeCheck(); + demo.timeWithTypeCheck(); + + demo.castVsTypeChecking(); + } + + // #end CONSTRUCTION & MAIN + + // #region DEMOS + + /** + * Demonstrates the simple case, in which a value processor is added for some key. + */ + private void simpleCase() { + ObservableMap properties = FXCollections.observableHashMap(); + + // build and attach the listener + ControlProperties. on(properties) + .forKey("Key") + .processValue(value -> System.out.println(" -> " + value)) + .buildAttached(); + + // set values of the correct type for the correct key + System.out.print("Set \"Value\" for the correct key for the first time: "); + properties.put("Key", "Value"); + System.out.print("Set \"Value\" for the correct key for the second time: "); + properties.put("Key", "Value"); + + // set values of the wrong type: + System.out.println("Set an Integer for the correct key: ... (nothing will happen)"); + properties.put("Key", 5); + + // set values for the wrong key + System.out.println("Set \"Value\" for another key: ... (nothing will happen)"); + properties.put("OtherKey", "Value"); + + System.out.println(); + } + + /** + * Demonstrates how a listener can be attached and detached. + */ + private void attachAndDetach() { + ObservableMap properties = FXCollections.observableHashMap(); + + // build the listener (but don't attach it yet) and assign it to a variable + ControlPropertyListenerHandle listener = ControlProperties. on(properties) + .forKey("Key") + .processValue(value -> System.out.println(" -> " + value)) + .buildDetached(); + + // set a value when the listener is not yet attached + System.out.println( + "Set \"ExistingValue\" before attaching the listener: ... (nothing will happen)"); + properties.put("Key", "ExistingValue"); + + // now attach the listener + System.out.print("When the listener is set, \"ExistingValue\" is processed and removed: "); + listener.attach(); + + System.out.print("Set \"Value\": "); + properties.put("Key", "Value"); + + // detach the listener + listener.detach(); + System.out.println("Set \"UnnoticedValue\" when the listener is detached: ... (nothing will happen)"); + + System.out.println(); + } + + /** + * Measures the time it takes to get a lot of {@link ClassCastException}. + */ + private void timeNoTypeCheck() { + ObservableMap properties = FXCollections.observableHashMap(); + + Consumer unreached = value -> { + throw new RuntimeException("Should not be executed!"); + }; + + // build and a attach a listener which does no type check before cast + ControlProperties. on(properties) + .forKey("Key") + .processValue(unreached) + .buildAttached(); + + // add a couple of values of the wrong type to average the time that takes + Integer valueOfWrongType = 5; + int runs = (int) 1e5; + long startTimeInNS = System.nanoTime(); + + for (int i = 0; i < runs; i++) + properties.put("Key", valueOfWrongType); + + long endTimeInNS = System.nanoTime(); + long timePerRunInNS = (endTimeInNS - startTimeInNS) / runs; + System.out.println("For unchecked casts, adding a value of the wrong type takes ~" + timePerRunInNS + " ns."); + + System.out.println(); + } + + /** + * Demonstrates how type checking increases performance if values of an incorrect type are added frequently. + */ + private void timeWithTypeCheck() { + ObservableMap properties = FXCollections.observableHashMap(); + + Consumer unreached = value -> { + throw new RuntimeException("Should not be executed!"); + }; + + // build and a attach a listener which does a type check before cast + ControlProperties. on(properties) + .forKey("Key") + .forValueType(String.class) + .processValue(unreached) + .buildAttached(); + + // add a couple of values of the wrong type to average the time that takes + Integer valueOfWrongType = 5; + int runs = (int) 1e5; + long startTimeInNS = System.nanoTime(); + + for (int i = 0; i < runs; i++) + properties.put("Key", valueOfWrongType); + + long endTimeInNS = System.nanoTime(); + long timePerRunInNS = (endTimeInNS - startTimeInNS) / runs; + System.out.println("For checked casts, adding a value of the wrong type takes ~" + timePerRunInNS + " ns."); + + System.out.println(); + } + + // #end DEMOS + + /** + * TODO (nipa): I don't get it. The simple test below clearly shows that raising an exception takes about 6.000 ns. + * So why the hell does {@link #timeNoTypeCheck()} run way faster than that?! + *

+ * Some days later: I ran this again and discovered that the time difference is now very measurable and looks + * correct. Perhaps some JVM optimization because I ran it so often? + */ + private void castVsTypeChecking() { + int runs = (int) 1e5; + Object integer = 3; + + // CAST + long start = System.nanoTime(); + for (int i = 0; i < runs; i++) + try { + String string = (String) integer; + System.out.println(string); + } catch (ClassCastException e) { + // do nothing + } + long end = System.nanoTime(); + System.out.println("Each unchecked cast took ~" + (end - start) / runs + " ns."); + + // TYPE CHECK + start = System.nanoTime(); + for (int i = 0; i < runs; i++) + if (String.class.isInstance(integer)) { + String bar = (String) integer; + System.out.println(bar); + } + end = System.nanoTime(); + System.out.println("Each type check took ~" + (end - start) / runs + " ns."); + } +} diff --git a/src/demo/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerDemo.java b/src/demo/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerDemo.java new file mode 100644 index 0000000..35f6a4c --- /dev/null +++ b/src/demo/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerDemo.java @@ -0,0 +1,154 @@ +package org.codefx.libfx.control.webview; + +import javafx.application.Application; +import javafx.beans.property.BooleanProperty; +import javafx.scene.Scene; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.web.WebView; +import javafx.stage.Stage; + +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkEvent.EventType; + +import org.codefx.libfx.listener.handle.ListenerHandle; + +/** + * Demonstrates how to use the {@link WebViewHyperlinkListener}. + */ +public class WebViewHyperlinkListenerDemo extends Application { + + // #region INITIALIZATION + + /** + * Runs this demo. + * + * @param args + * command line arguments (will not be used) + */ + public static void main(String[] args) { + Application.launch(args); + } + + @Override + public void start(Stage primaryStage) throws Exception { + // controls + WebView webView = createWebView(); + Label urlLabel = createUrlLabel(); + CheckBox listenerAttachedBox = createListenerAttachedBox(); + CheckBox cancelEventBox = createCancelEventBox(); + + // listener + WebViewHyperlinkListener listener = event -> { + showEventOnLabel(event, urlLabel); + return cancelEventBox.isSelected(); + }; + manageListener(webView, listener, listenerAttachedBox.selectedProperty()); + + // put together + VBox box = new VBox(webView, listenerAttachedBox, cancelEventBox, urlLabel); + Scene scene = new Scene(box); + primaryStage.setScene(scene); + primaryStage.show(); + } + + /** + * Creates the web view to which the listener will be attached. + * + * @return a {@link WebView} + */ + private static WebView createWebView() { + WebView webView = new WebView(); + webView.getEngine().getLoadWorker().stateProperty().addListener( + (obs, o, n) -> System.out.println("WEB VIEW WORKER STATUS: " + n)); + webView.getEngine().load("https://en.wikipedia.org/wiki/Main_Page"); + return webView; + } + + /** + * Creates the Label which will display the URL. + * + * @return a {@link Label} + */ + private static Label createUrlLabel() { + return new Label(); + } + + /** + * Creates the check box with which the listener can be attached and detached. + * + * @return a {@link CheckBox} + */ + private static CheckBox createListenerAttachedBox() { + return new CheckBox("hyperlink listener attached"); + } + + /** + * Creates the check box with which the further processing of events can be cancelled. + * + * @return a {@link CheckBox} + */ + private static CheckBox createCancelEventBox() { + return new CheckBox("cancel event processing"); + } + + // #end INITIALIZATION + + // #region LISTENER + + /** + * Attaches/detaches the specified listener to/from the specified web view according to the specified property's + * value. + * + * @param webView + * the {@link WebView} to which the listener will be added + * @param listener + * the added listener + * @param attachedProperty + * defines whether the listener is attached or not + */ + private static void manageListener(WebView webView, WebViewHyperlinkListener listener, + BooleanProperty attachedProperty) { + attachedProperty.set(true); + ListenerHandle listenerHandle = WebViews.addHyperlinkListener(webView, listener); + + attachedProperty.addListener((obs, wasAttached, isAttached) -> { + if (isAttached) { + listenerHandle.attach(); + System.out.println("LISTENER: attached."); + } else { + listenerHandle.detach(); + System.out.println("LISTENER: detached."); + } + }); + } + + /** + * Visualizes the specified event's type and URL on the specified label. + * + * @param event + * the {@link HyperlinkEvent} to visualize + * @param urlLabel + * the {@link Label} which will visualize the event + */ + private static void showEventOnLabel(HyperlinkEvent event, Label urlLabel) { + if (event.getEventType() == EventType.ENTERED) { + urlLabel.setTextFill(Color.BLACK); + urlLabel.setText("ENTERED: " + event.getURL().toExternalForm()); + System.out.println("EVENT: " + WebViews.hyperlinkEventToString(event)); + } else if (event.getEventType() == EventType.EXITED) { + urlLabel.setTextFill(Color.BLACK); + urlLabel.setText("EXITED: " + event.getURL().toExternalForm()); + System.out.println("EVENT: " + WebViews.hyperlinkEventToString(event)); + } else if (event.getEventType() == EventType.ACTIVATED) { + urlLabel.setText("ACTIVATED: " + event.getURL().toExternalForm()); + urlLabel.setTextFill(Color.RED); + System.out.println("EVENT: " + WebViews.hyperlinkEventToString(event)); + } + } + + // #end LISTENER + +} diff --git a/src/demo/java/org/codefx/libfx/listener/handle/ListenerHandleDemo.java b/src/demo/java/org/codefx/libfx/listener/handle/ListenerHandleDemo.java new file mode 100644 index 0000000..cd3ac64 --- /dev/null +++ b/src/demo/java/org/codefx/libfx/listener/handle/ListenerHandleDemo.java @@ -0,0 +1,155 @@ +package org.codefx.libfx.listener.handle; + +import javafx.beans.Observable; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ChangeListener; + +/** + * Demonstrates how to create and use {@link ListenerHandle}s. + */ +@SuppressWarnings("static-method") +public class ListenerHandleDemo { + + // #region CONSTRUCTION & MAIN + + /** + * Creates a new demo. + */ + private ListenerHandleDemo() { + // nothing to do + } + + /** + * Runs this demo. + * + * @param args + * command line arguments (will not be used) + */ + public static void main(String[] args) { + ListenerHandleDemo demo = new ListenerHandleDemo(); + + demo.createCommonListenerHandle(); + demo.createCustomListenerHandle(); + demo.attachAndDetach(); + } + + // #end CONSTRUCTION & MAIN + + // #region DEMOS + + // construction + + /** + * Demonstrates how to simply create a handle for a given observable and listener. + */ + private void createCommonListenerHandle() { + Property property = new SimpleStringProperty(); + ChangeListener listener = (obs, oldValue, newValue) -> { /* do nothing for this demo */}; + + // create the handle; this one is initially attached, i.e. the listener is added to the property + ListenerHandle handle = ListenerHandles.createAttached(property, listener); + // the handle can be used to easily detach and reattach the listener + handle.detach(); + handle.attach(); + + // create a detached handle where the listener was not yet added to the property + handle = ListenerHandles.createDetached(property, listener); + // this one needs to be attached before the listener is executed on changes + handle.attach(); + } + + /** + * Demonstrates how a listener handle can be created for custom observable implementations with + * {@link ListenerHandleBuilder}. + */ + private void createCustomListenerHandle() { + MyCustomObservable customObservable = new MyCustomObservable(); + MyCustomListener customListener = new MyCustomListener(); + + // use 'ListenerHandles' to get a 'ListenerHandleBuilder' which can be used to create a handle for this + // observable and listener + ListenerHandles + .createFor(customObservable, customListener) + .onAttach((observable, listener) -> observable.addListener(listener)) + .onDetach((observable, listener) -> observable.removeListener(listener)) + .buildAttached(); + } + + // attach & detach + + /** + * Demonstrates how to add and remove a listener with a {@link ListenerHandle} and compares this to the normal + * approach. + */ + private void attachAndDetach() { + Property observedProperty = new SimpleStringProperty("initial value"); + + // usually a listener is directly added to the property; + // but if the listener has to be removed later, the reference needs to be stored explicitly + ChangeListener changePrintingListener = (obs, oldValue, newValue) -> + System.out.println("[LISTENER] Value changed from \"" + oldValue + "\" to \"" + newValue + "\"."); + observedProperty.addListener(changePrintingListener); + + // this is the alternative with a 'ListenerHandle' + ListenerHandle newValuePrinter = ListenerHandles.createAttached(observedProperty, + (obs, oldValue, newValue) -> System.out.println("[HANDLE] New value: \"" + newValue + "\"")); + + // now lets change the value to see how it works + observedProperty.setValue("new value"); + observedProperty.setValue("even newer value"); + + // removing a listener needs references to both the observable and the listener; + // depending on the situation this might not be feasible + observedProperty.removeListener(changePrintingListener); + // with a handle, the listener can be removed without giving the caller the possibility tp interact with + // the observable or the listener; it is also a little more readable + newValuePrinter.detach(); + + // some unobserved changes... + observedProperty.setValue("you won't see this on the console"); + observedProperty.setValue("nor this"); + + // the same as above goes for adding the listener + observedProperty.addListener(changePrintingListener); + newValuePrinter.attach(); + + // now some more changes + observedProperty.setValue("but you will see this"); + observedProperty.setValue("and this"); + } + + // #end DEMOS + + // #region NESTED CLASSES + + /** + * Represents a custom observable instance. Note that it is not necessary to implement {@link Observable} (or any + * other interface) in order to use this class with the {@link ListenerHandleBuilder}. + */ + private static class MyCustomObservable { + + @SuppressWarnings({ "javadoc", "unused" }) + public void addListener(MyCustomListener listener) { + // do nothing - just for demo + } + + @SuppressWarnings({ "javadoc", "unused" }) + public void removeListener(MyCustomListener listener) { + // do nothing - just for demo + } + + } + + /** + * Represents a listener for a custom observable instance. Note that it is not necessary to implement + * {@link ChangeListener} (or any other interface) in order to use this class with the {@link ListenerHandleBuilder} + * . + */ + private static class MyCustomListener { + // has no members - just for demo + } + + // #end NESTED CLASSES + +} diff --git a/src/demo/java/org/codefx/libfx/nesting/NestedDemo.java b/src/demo/java/org/codefx/libfx/nesting/NestedDemo.java index e6f82e3..d98ca13 100644 --- a/src/demo/java/org/codefx/libfx/nesting/NestedDemo.java +++ b/src/demo/java/org/codefx/libfx/nesting/NestedDemo.java @@ -17,14 +17,14 @@ */ public class NestedDemo { - // #region ATTRIBUTES + // #region FIELDS /** * The currently selected employee. */ private final Property currentEmployee; - //#end ATTRIBUTES + //#end FIELDS // #region CONSTRUCTION & MAIN diff --git a/src/demo/java/org/codefx/libfx/serialization/SerializableOptionalDemo.java b/src/demo/java/org/codefx/libfx/serialization/SerializableOptionalDemo.java new file mode 100644 index 0000000..22d4198 --- /dev/null +++ b/src/demo/java/org/codefx/libfx/serialization/SerializableOptionalDemo.java @@ -0,0 +1,176 @@ +package org.codefx.libfx.serialization; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.NotSerializableException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Optional; +import java.util.Random; + +/** + * Demonstrates how to use the {@link SerializableOptional}. + */ +@SuppressWarnings("static-method") +public class SerializableOptionalDemo { + + /** + * Runs this demo. + * + * @param args + * command line arguments (will not be used) + * @throws Exception + * if (de)serialization fails + */ + public static void main(String[] args) throws Exception { + SerializableOptionalDemo demo = new SerializableOptionalDemo(); + + demo.serializeString(); + demo.failSerializingOptional(); + demo.serializeEmptySerializableOptional(); + demo.serializeNonEmptySerializableOptional(); + + print(""); + + demo.callMethodsWithSerializableOptional(); + } + + // DEMO + + // serialize "simple" objects, i.e. ones which contain no further instances, to demo serialization in general + + /** + * To get started, serialize a string, deserialize it and print its value. + * + * @throws Exception + * if (de)serialization fails + */ + private void serializeString() throws Exception { + String someString = "a string"; + String deserializedString = serializeAndDeserialize(someString); + print("The deserialized 'String' is \"" + deserializedString + "\"."); + } + + /** + * Try the same with an {@code Optional}, which will fail as {@link Optional} is not {@link Serializable}. + * + * @throws Exception + * if (de)serialization fails + */ + private void failSerializingOptional() throws Exception { + try { + Optional someOptional = Optional.of("another string"); + Optional deserializedOptional = serializeAndDeserialize(someOptional); + print("The deserialized 'Optional' should have the value \"" + deserializedOptional.get() + "\"."); + } catch (NotSerializableException e) { + print("Serialization of 'Optional' failed as expected."); + } + } + + /** + * Create a {@link SerializableOptional} from an empty {@link Optional} and (de)serialize it successfully. + * + * @throws Exception + * if (de)serialization fails + */ + private void serializeEmptySerializableOptional() throws Exception { + Optional someOptional = Optional.empty(); + SerializableOptional serializableOptional = SerializableOptional.fromOptional(someOptional); + Optional deserializedOptional = serializeAndDeserialize(serializableOptional).asOptional(); + print("The deserialized empty 'SerializableOptional' has no value: " + !deserializedOptional.isPresent() + "."); + } + + /** + * Create a {@link SerializableOptional} from a nonempty {@link Optional} and (de)serialize it successfully. + * + * @throws Exception + * if (de)serialization fails + */ + private void serializeNonEmptySerializableOptional() throws Exception { + Optional someOptional = Optional.of("another string"); + SerializableOptional serializableOptional = SerializableOptional.fromOptional(someOptional); + Optional deserializedOptional = serializeAndDeserialize(serializableOptional).asOptional(); + print("The deserialized non-empty 'SerializableOptional' has the value \"" + deserializedOptional.get() + "\"."); + } + + // use 'SerializableOptional' in method signatures + + /** + * Shows how to quickly wrap and unwrap an {@link Optional} for RPC method calls which rely on serialization. + *

+ * Note that {@link SearchAndLog}'s methods have {@link SerializableOptional} as argument and return type. + */ + private void callMethodsWithSerializableOptional() { + SearchAndLog searchAndLog = new SearchAndLog(); + for (int id = 0; id < 7; id++) { + // unwrap the returned optional using 'asOptional' + Optional searchResult = searchAndLog.search(id).asOptional(); + // wrap the optional using 'fromOptional'; if used often, this could be a static import + searchAndLog.log(id, SerializableOptional.fromOptional(searchResult)); + } + } + + // USABILITY + + /** + * Serializes the specified instance to disk. Then deserializes the file and returns the deserialized value. + * + * @param + * the type of the serialized instance + * @param serialized + * the instance to be serialized + * @return the deserialized instance + * @throws Exception + * if (de)serialization fails + */ + private static T serializeAndDeserialize(T serialized) throws Exception { + File serializeFile = new File("_serialized"); + // serialize + try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(serializeFile))) { + out.writeObject(serialized); + } + // deserialize + try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(serializeFile))) { + @SuppressWarnings("unchecked") + T deserialized = (T) in.readObject(); + return deserialized; + } + } + + /** + * Prints the specified text to the console. + * + * @param text + * the text to print + */ + private static void print(String text) { + System.out.println(text); + } + + // INNER CLASS FOR METHOD CALLS + + /** + * A class with methods which have an optional return value or argument. + */ + @SuppressWarnings("javadoc") + private static class SearchAndLog { + + Random random = new Random(); + + public SerializableOptional search(@SuppressWarnings("unused") int id) { + boolean searchSuccessfull = random.nextBoolean(); + if (searchSuccessfull) + return SerializableOptional.of("found something!"); + else + return SerializableOptional.empty(); + } + + public void log(int id, SerializableOptional item) { + print("Search result for id " + id + ": " + item.asOptional().orElse("empty search result")); + } + + } + +} diff --git a/src/main/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhen.java b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhen.java new file mode 100644 index 0000000..f24d93d --- /dev/null +++ b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhen.java @@ -0,0 +1,197 @@ +package org.codefx.libfx.concurrent.when; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +/** + * Executes an action when an {@link ObservableValue}'s value fulfills a certain condition. + *

+ * The action will not be executed before {@link #executeWhen()} is called. The action is executed every time the value + * passes the condition. If this can happen in parallel in several threads, the action must be thread-safe as no further + * synchronization is provided by this class. Further execution can be prevented by calling {@link #cancel()}. + *

+ * This class guarantees that regardless of the way different threads interact with the {@code ObservableValue} the + * action will be executed... + *

    + *
  • ... once during {@code executeWhen()} if either the observable's initial value or one it was changed to passes + * the condition + *
  • ... every time a new value passes the condition after {@code executeWhen()} returns + *
+ * If the observable is manipulated by several threads during {@code executeWhen()}, this class does not guarantee that + * the first value to pass the condition is the one handed to the action. Depending on the interaction of those threads + * it might be the initial value or one of several which were set by those threads. + *

+ * Use {@link ExecuteWhen} to build an instance of this class. + * + * @param + * the type the observed {@link ObservableValue}'s wraps + */ +public class ExecuteAlwaysWhen { + + /* + * If no other threads were involved the class would be simple. It would suffice to check the observable's current + * value. If it passes the condition, execute the action. Also, a listener which processes each new value in the + * same way has to be added. + */ + /* + * But since other threads are allowed to interfere, this could fail. If a correct value is set between the check + * and attaching the listener, this value would not be processed and the action would not be executed. To prevent + * this the listener is added first and only then is the current value checked and the action possibly executed. + */ + /* + * Now, if another thread sets the correct value after the listener was added but before the current value is + * processed, the action would be executed twice. Actually, if the value is switched fast enough by different + * threads, each could be in the middle of executing the listener when the check of the initial value finally + * proceeds. This would lead to multiple desired executions plus the undesired one of the initial check. (Note that + * this problem can only occur until the initial value is processed. After that only the listener can execute the + * action and there is only one so no funny stuff can happen.) That is the reason why the contract states that a + * correct value will only be processed once during executeWhen(). + */ + /* + * To achieve this, two atomic booleans are used. The first, 'executeAlways', is false after construction and will + * be set to true at the end of 'executeWhen'. When it is true, all values will be processed. Until then only one + * execution is allowed. This is monitored using 'alreadyExecuted', which is initially false and will be set to true + * on the first execution of the action. + */ + + // #region FIELDS + + /** + * The {@link ObservableValue} upon whose value the action's execution depends. + */ + private final ObservableValue observable; + + /** + * The condition the {@link #observable}'s value must fulfill for {@link #action} to be executed. + */ + private final Predicate condition; + + /** + * The action which will be executed. + */ + private final Consumer action; + + /** + * The listener which executes {@link #action} and sets {@link #alreadyExecuted} accordingly. + */ + private final ChangeListener listenerWhichExecutesAction; + + /** + * Indicates whether {@link #executeWhen()} was already called. If so, it can not be called again. + */ + private final AtomicBoolean executeWhenWasAlreadyCalled; + + /** + * Indicates whether {@link #action} is executed each time the value passes the {@link #condition}. + */ + private final AtomicBoolean executeAlways; + + /** + * Indicates whether {@link #action} was already executed once. + */ + private final AtomicBoolean alreadyExecuted; + + // #end FIELDS + + /** + * Creates a new instance from the specified arguments. * + *

+ * Note that for the action to be executed, {@link #executeWhen()} needs to be called. + * + * @param observable + * the {@link ObservableValue} upon whose value the action's execution depends + * @param condition + * the condition the {@link #observable}'s value must fulfill for {@link #action} to be executed + * @param action + * the action which will be executed + */ + ExecuteAlwaysWhen(ObservableValue observable, Predicate condition, Consumer action) { + this.observable = observable; + this.condition = condition; + this.action = action; + + listenerWhichExecutesAction = (obs, oldValue, newValue) -> tryExecuteAction(newValue); + executeAlways = new AtomicBoolean(false); + executeWhenWasAlreadyCalled = new AtomicBoolean(false); + alreadyExecuted = new AtomicBoolean(false); + } + + // #region METHODS + + /** + * Executes the action (every time) when the observable's value passes the condition. + *

+ * This is a one way function that must only be called once. Calling it again throws an + * {@link IllegalStateException}. + *

+ * Call {@link #cancel()} to prevent further execution. + * + * @throws IllegalStateException + * if this method is called more than once + */ + public void executeWhen() throws IllegalStateException { + boolean wasAlreadyCalled = executeWhenWasAlreadyCalled.getAndSet(true); + if (wasAlreadyCalled) + throw new IllegalStateException("The method 'executeWhen' must only be called once."); + + observable.addListener(listenerWhichExecutesAction); + tryExecuteAction(observable.getValue()); + executeAlways.set(true); + } + + /** + * Executes {@link #action} if the specified value fulfills the {@link #condition}. Sets {@link #alreadyExecuted} to + * indicate that the action was executed at least once. + *

+ * Called by {@link #listenerWhichExecutesAction} every time {@link #observable} changes its value. + * + * @param currentValue + * the {@link #observable}'s current value + */ + private void tryExecuteAction(T currentValue) { + boolean valueFailsGateway = !condition.test(currentValue); + if (valueFailsGateway) + return; + + boolean canNotExecuteNow = !canExecuteNow(); + if (canNotExecuteNow) + return; + + action.accept(currentValue); + } + + /** + * Indicates whether the {@link #action} can be executed now by checking {@link #executeAlways} and + * {@link #alreadyExecuted}. + *

+ * Potentially changes the state of {@code alreadyExecuted} so it must only be called if {@link #action} is really + * executed afterwards (i.e. the value should already have passed the {@link #condition}). + * + * @return true if the {@link #action} can be executed now + */ + private boolean canExecuteNow() { + if (executeAlways.get()) { + alreadyExecuted.set(true); + return true; + } + + // in this case the action must only be executed once, so make sure that didn't happen yet + boolean alreadyExecutedOnce = alreadyExecuted.getAndSet(true); + boolean canExecuteNow = !alreadyExecutedOnce; + return canExecuteNow; + } + + /** + * Cancels the future execution of the action. If {@link #executeWhen()} was not yet called or the action was + * already executed, this is a no-op. + */ + public void cancel() { + observable.removeListener(listenerWhichExecutesAction); + } + + // #end METHODS +} diff --git a/src/main/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhen.java b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhen.java new file mode 100644 index 0000000..9e9f7e4 --- /dev/null +++ b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhen.java @@ -0,0 +1,161 @@ +package org.codefx.libfx.concurrent.when; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +/** + * Executes an action when an {@link ObservableValue}'s value fulfills a certain condition. + *

+ * The action will not be executed before {@link #executeWhen()} is called. The action is only executed once. If it was + * not yet executed, this can be prevented by calling {@link #cancel()}. + *

+ * This class guarantees that regardless of the way different threads interact with the {@code ObservableValue} the + * action will be executed... + *

    + *
  • ... if the value held when {@code executeWhen()} returns passes the condition + *
  • ... if a new value passes the condition (either during {@code executeWhen()} or after it returns) + *
  • ... at most once + *
+ * If the observable is manipulated by several threads, this class does not guarantee that the first value to pass the + * condition is the one handed to the action. Depending on the interaction of those threads it might be the initial + * value (the one tested during {@code executeWhen()}) or one of several which were set by those threads. + *

+ * Use {@link ExecuteWhen} to build an instance of this class. + * + * @param + * the type the observed {@link ObservableValue}'s wraps + */ +public class ExecuteOnceWhen { + + /* + * If no other threads were involved the class would be simple. It would suffice to check the observable's current + * value. If it passes the condition, execute the action; otherwise attach a listener which processes each new value + * in the same way. + */ + /* + * But since other threads are allowed to interfere, this could fail. If a correct value is set between the check + * and attaching the listener, this value would not be processed and the action would not be executed. To prevent + * this the listener is added first and only then is the current value checked and the action possibly executed. + */ + /* + * Now, if another thread sets the correct value after the listener was added but before the current value is + * processed, the action would be executed twice. To prevent this from happening an atomic boolean is used. It will + * contain true when the action can still be executed. When a value (either the initial or a new one) fulfills the + * condition, it is checked (and set to false). If it contained true, the action will be executed. + */ + + // #region FIELDS + + /** + * The {@link ObservableValue} upon whose value the action's execution depends. + */ + private final ObservableValue observable; + + /** + * The condition the {@link #observable}'s value must fulfill for {@link #action} to be executed. + */ + private final Predicate condition; + + /** + * The action which will be executed. + */ + private final Consumer action; + + /** + * Indicates whether {@link #action} might still be executed at some point in the future. Is used to prevent the + * listener and the initial check (see {@link #executeWhen()}) to both execute the action. + */ + private final AtomicBoolean willExecute; + + /** + * The listener which executes {@link #action} and sets {@link #willExecute} accordingly. + */ + private final ChangeListener listenerWhichExecutesAction; + + /** + * Indicates whether {@link #executeWhen()} was already called. If so, it can not be called again. + */ + private final AtomicBoolean executeWhenWasAlreadyCalled; + + // #end FIELDS + + /** + * Creates a new instance from the specified arguments. + *

+ * Note that for the action to be executed, {@link #executeWhen()} needs to be called. + * + * @param observable + * the {@link ObservableValue} upon whose value the action's execution depends + * @param condition + * the condition the {@link #observable}'s value must fulfill for {@link #action} to be executed + * @param action + * the action which will be executed + */ + ExecuteOnceWhen(ObservableValue observable, Predicate condition, Consumer action) { + this.observable = observable; + this.condition = condition; + this.action = action; + + listenerWhichExecutesAction = (obs, oldValue, newValue) -> tryExecuteAction(newValue); + executeWhenWasAlreadyCalled = new AtomicBoolean(false); + willExecute = new AtomicBoolean(true); + } + + // #region METHODS + + /** + * Executes the action (once) when the observable's value passes the condition. + *

+ * This is a one way function that must only be called once. Calling it again throws an + * {@link IllegalStateException}. + *

+ * Call {@link #cancel()} to prevent future execution. + * + * @throws IllegalStateException + * if this method is called more than once + */ + public void executeWhen() throws IllegalStateException { + boolean wasAlreadyCalled = executeWhenWasAlreadyCalled.getAndSet(true); + if (wasAlreadyCalled) + throw new IllegalStateException("The method 'executeWhen' can only be called once."); + + observable.addListener(listenerWhichExecutesAction); + tryExecuteAction(observable.getValue()); + } + + /** + * Executes {@link #action} if the specified value fulfills the {@link #condition} and the action was not yet + * executed. The latter is indicated by the {@link #willExecute}, which will also be updated. + * + * @param currentValue + * the {@link #observable}'s current value + */ + private void tryExecuteAction(T currentValue) { + boolean valueFailsGateway = !condition.test(currentValue); + if (valueFailsGateway) + return; + + boolean actionCanBeExecuted = willExecute.getAndSet(false); + if (actionCanBeExecuted) { + action.accept(currentValue); + // the action was just executed and will not be executed again so the listener is not needed anymore + observable.removeListener(listenerWhichExecutesAction); + } + } + + /** + * Cancels the future execution of the action. If {@link #executeWhen()} was not yet called or the action was + * already executed, this is a no-op. + */ + public void cancel() { + willExecute.set(false); + observable.removeListener(listenerWhichExecutesAction); + } + + // #end METHODS + +} diff --git a/src/main/java/org/codefx/libfx/concurrent/when/ExecuteWhen.java b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteWhen.java new file mode 100644 index 0000000..cd80fe6 --- /dev/null +++ b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteWhen.java @@ -0,0 +1,147 @@ +package org.codefx.libfx.concurrent.when; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import javafx.beans.value.ObservableValue; + +/** + *

+ * Builder for {@link ExecuteAlwaysWhen} and {@link ExecuteOnceWhen}. + *

+ *

Example

A typical use would look like this: + * + *
+ * 	ObservableValue<State> workerState;
+ *	ExecuteWhen.on(workerState)
+ *		.when(state -> state == State.SUCCEEDED)
+ *		.thenOnce(state -> logSuccess())
+ *		.executeWhen();
+ * 
+ * + * @param + * the type the {@link ObservableValue} which will be observed by the constructed instance wraps + */ +public class ExecuteWhen { + + // #region FIELDS + + /** + * The {@link ObservableValue} upon whose value the action's execution depends. + */ + private final ObservableValue observable; + + /** + * The condition the {@link #observable}'s value must fulfill for the action to be executed. + */ + private Optional> condition; + + // #end FIELDS + + // #region CONSTRUCTION + + /** + * Creates a new instance for the specified observable + * + * @param observable + * the {@link ObservableValue} which will be observed by the created {@code Execute...When} instances + */ + private ExecuteWhen(ObservableValue observable) { + this.observable = observable; + condition = Optional.empty(); + } + + /** + * Creates a new builder. The built instance of {@code Execute...When} will observe the specified observable. + * + * @param + * the type the {@link ObservableValue} which will be observed by the constructed instance wraps + * @param observable + * the {@link ObservableValue} which will be observed by the created {@code Execute...When} instances + * @return a new builder instance + */ + public static ExecuteWhen on(ObservableValue observable) { + return new ExecuteWhen<>(observable); + } + + // #end CONSTRUCTION + + // #region SETTING VALUES + + /** + * Specifies the condition the observable's value must fulfill in order for the action to be executed. + * + * @param condition + * the condition as a {@link Predicate} + * @return this builder + */ + public ExecuteWhen when(Predicate condition) { + Objects.requireNonNull(condition, "The argument 'condition' must not be null."); + this.condition = Optional.of(condition); + return this; + } + + // #end SETTING VALUES + + // #region BUILD + + /** + * Creates an instance which: + *
    + *
  • observes the {@link ObservableValue} (specified for this builder's construction) for new values + *
  • checks each new value against the condition set with {@link #when(Predicate)} (calling which is required) + *
  • executes the specified {@code action} once if a value fulfills the condition + *
+ * Note that the observation does not start until {@link ExecuteOnceWhen#executeWhen()} is called. See + * {@link ExecuteOnceWhen} for details. + * + * @param action + * the {@link Consumer} of the value which passed the condition + * @return an instance of {@link ExecuteOnceWhen} + * @throws IllegalStateException + * if {@link #when(Predicate)} was not called + */ + public ExecuteOnceWhen thenOnce(Consumer action) throws IllegalStateException { + ensureConditionWasSet(); + return new ExecuteOnceWhen(observable, condition.get(), action); + } + + /** + * Creates an instance which: + *
    + *
  • observes the {@link ObservableValue} (specified for this builder's construction) for new values + *
  • checks each new value against the condition set with {@link #when(Predicate)} (calling which is required) + *
  • executes the specified {@code action} every time a value fulfills the condition + *
+ * Note that the observation does not start until {@link ExecuteAlwaysWhen#executeWhen()} is called. See + * {@link ExecuteAlwaysWhen} for details. + * + * @param action + * the {@link Consumer} of the value which passed the condition + * @return an instance of {@link ExecuteOnceWhen} + * @throws IllegalStateException + * if {@link #when(Predicate)} was not called + */ + public ExecuteAlwaysWhen thenAlways(Consumer action) throws IllegalStateException { + ensureConditionWasSet(); + return new ExecuteAlwaysWhen(observable, condition.get(), action); + } + + /** + * Makes sure that {@link #condition} was set, i.e. the {@link Optional} is not empty. + * + * @throws IllegalStateException + * if {@link #condition} was not set + */ + private void ensureConditionWasSet() throws IllegalStateException { + boolean noCondition = !condition.isPresent(); + if (noCondition) + throw new IllegalStateException( + "Set a condition with 'when(Predicate)' before calling any 'then' method."); + } + + // #end BUILD + +} diff --git a/src/main/java/org/codefx/libfx/concurrent/when/package-info.java b/src/main/java/org/codefx/libfx/concurrent/when/package-info.java new file mode 100644 index 0000000..d339b82 --- /dev/null +++ b/src/main/java/org/codefx/libfx/concurrent/when/package-info.java @@ -0,0 +1,11 @@ +/** + * With {@link org.codefx.libfx.concurrent.when.ExecuteOnceWhen ExecuteOnceWhen} and + * {@link org.codefx.libfx.concurrent.when.ExecuteAlwaysWhen ExecuteAlwaysWhen} this package provides two classes which + * help to make sure some action which is triggered by a change on an {@link javafx.beans.value.ObservableValue + * ObservableValue} gets executed under threading. Refer to the two classes for a detailed description. + *

+ * Instances of those classes can be built with {@link org.codefx.libfx.concurrent.when.ExecuteWhen ExecuteWhen}. + * + * @see org.codefx.libfx.concurrent.when.ExecuteWhen ExecuteWhen + */ +package org.codefx.libfx.concurrent.when; \ No newline at end of file diff --git a/src/main/java/org/codefx/libfx/control/package-info.java b/src/main/java/org/codefx/libfx/control/package-info.java new file mode 100644 index 0000000..f0a6a20 --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/package-info.java @@ -0,0 +1,6 @@ +/** + * This package provides functionality around UI Controls. Subpackages might provide additional functionality for + * existing Swing or JavaFX controls, implement new ones or provide other features related to controls. + */ +package org.codefx.libfx.control; + diff --git a/src/main/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandle.java b/src/main/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandle.java new file mode 100644 index 0000000..2434a52 --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandle.java @@ -0,0 +1,122 @@ +package org.codefx.libfx.control.properties; + +import java.util.Objects; + +import javafx.collections.MapChangeListener; +import javafx.collections.ObservableMap; + +/** + * Abstract superclass to implementations of {@link ControlPropertyListenerHandle}. Handles all aspects of listening + * except the actual processing of the value which is delegated to the implementations. + */ +abstract class AbstractControlPropertyListenerHandle implements ControlPropertyListenerHandle { + + // #region FIELDS + + /** + * The properties to which the {@link #listener} will be added. + */ + private final ObservableMap properties; + + /** + * The key to which the {@link #listener} listens. + */ + private final Object key; + + /** + * The listener which will be added to the {@link #properties}. + */ + private final MapChangeListener listener; + + /** + * Indicates whether the {@link #listener} is currently attached to the {@link #properties} map. + */ + private boolean attached; + + // #end FIELDS + + // #region CONSTRUCTION + + /** + * Creates a new listener handle. Initially detached. + * + * @param properties + * the {@link ObservableMap} holding the properties + * @param key + * the key to which the listener will listen + */ + protected AbstractControlPropertyListenerHandle( + ObservableMap properties, Object key) { + + Objects.requireNonNull(properties, "The argument 'properties' must not be null."); + Objects.requireNonNull(key, "The argument 'key' must not be null."); + + this.properties = properties; + this.key = key; + this.listener = createListener(key); + } + + /** + * Creates a map listener which checks whether a value was set for the correct key, delegates to + * {@link #processValueIfPossible(Object)} if that is so and then removes the key-value-pair from the map. + * + * @param key + * the key to which the listener will listen + * @return a {@link MapChangeListener} + */ + private MapChangeListener createListener(Object key) { + return change -> { + boolean setForCorrectKey = change.wasAdded() && Objects.equals(key, change.getKey()); + if (setForCorrectKey) + processAndRemoveValue(change.getValueAdded()); + }; + } + + // #end CONSTRUCTION + + // #region PROCESS VALUE + + /** + * Processes the specified value for the {@link #key} before removing the pair from the {@link #properties} + * + * @param value + * the value added to the map + */ + private void processAndRemoveValue(Object value) { + processValueIfPossible(value); + properties.remove(key); + } + + /** + * Called when a value was set for the correct key. + * + * @param value + * the value associated with the key + * @return whether the value could be processed + */ + protected abstract boolean processValueIfPossible(Object value); + + // #end PROCESS VALUE + + // #region IMPLEMENTATION OF 'ControlPropertyListenerHandle' + + @Override + public void attach() { + if (attached) + return; + + attached = true; + properties.addListener(listener); + if (properties.containsKey(key)) + processAndRemoveValue(properties.get(key)); + } + + @Override + public void detach() { + attached = false; + properties.removeListener(listener); + } + + // #end IMPLEMENTATION OF 'ControlPropertyListenerHandle' + +} diff --git a/src/main/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandle.java b/src/main/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandle.java new file mode 100644 index 0000000..78a6a26 --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandle.java @@ -0,0 +1,57 @@ +package org.codefx.libfx.control.properties; + +import java.util.Objects; +import java.util.function.Consumer; + +import javafx.collections.ObservableMap; + +/** + * Implementation of {@link ControlPropertyListenerHandle} which optimistically casts all values to the expected type. + * If that does not work, the {@link ClassCastException} is caught and ignored. + * + * @param + * the type of values which the listener processes + */ +final class CastingControlPropertyListenerHandle extends AbstractControlPropertyListenerHandle { + + /** + * The user specified processor for values. + */ + private final Consumer valueProcessor; + + /** + * Creates a new listener handle. Initially detached. + * + * @param properties + * the {@link ObservableMap} holding the properties + * @param key + * the key to which the listener will listen + * @param valueProcessor + * the {@link Consumer} for the key's values + */ + CastingControlPropertyListenerHandle( + ObservableMap properties, Object key, Consumer valueProcessor) { + + super(properties, key); + Objects.requireNonNull(valueProcessor, "The argument 'valueProcessor' must not be null."); + + this.valueProcessor = valueProcessor; + } + + @Override + protected boolean processValueIfPossible(Object value) { + // give the value to the consumer if it has the correct type + try { + // note that this cast does nothing except to calm the compiler + @SuppressWarnings("unchecked") + T convertedValue = (T) value; + // this is where the exception might actually be created + valueProcessor.accept(convertedValue); + return true; + } catch (ClassCastException e) { + // the value was of the wrong type so it can't be processed by the consumer + return false; + } + } + +} diff --git a/src/main/java/org/codefx/libfx/control/properties/ControlProperties.java b/src/main/java/org/codefx/libfx/control/properties/ControlProperties.java new file mode 100644 index 0000000..1fa2d4b --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/properties/ControlProperties.java @@ -0,0 +1,29 @@ +package org.codefx.libfx.control.properties; + +import javafx.collections.ObservableMap; + +/** + * Gives access to a {@link ControlPropertyListenerBuilder}. + */ +public class ControlProperties { + + /** + * Creates a builder for a {@link ControlPropertyListenerHandle} which observes the specified property map. + *

+ * Note that it is often necessary to explicitly specify the type parameter {@code T} like so: + * + *

+	 * ControlProperties.<String> on(...)
+	 * 
+ * + * @param + * the type of values which the listener processes + * @param properties + * the {@link ObservableMap} holding the properties + * @return a {@link ControlPropertyListenerBuilder} + */ + public static ControlPropertyListenerBuilder on(ObservableMap properties) { + return ControlPropertyListenerBuilder. on(properties); + } + +} diff --git a/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerBuilder.java b/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerBuilder.java new file mode 100644 index 0000000..4b4a1d9 --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerBuilder.java @@ -0,0 +1,185 @@ +package org.codefx.libfx.control.properties; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +import javafx.collections.ObservableMap; + +/** + * A builder for a {@code ControlPropertyListener}. This is no type on its own as explained in + * {@link ControlPropertyListenerHandle}. Such a handle is returned by this builder. + *

+ * It is best created by calling {@link ControlProperties#on(ObservableMap)} with the control's property map as an + * argument. It is necessary to set a key (with {@link #forKey(Object)}) and a processor function for the value (with + * {@link #processValue(Consumer)}) before calling {@link #buildDetached()}. + *

+ * Specifying the value's type with {@link #forValueType(Class)} is optional. If it is done, the built listener will use + * it to check the type of the value before casting it to the type accepted by the value processor. If those types do + * not match, this prevents {@link ClassCastException} (which would otherwise be caught and silently ignored). If that + * case occurs frequently, specifying the type to allow the check will improve performance considerably. + *

+ *

Example

+ *

+ * A typical use looks like this: + * + *

+ * ControlProperties.<Boolean> on(control.getProperties())
+ * 	.forKey("visible")
+ * 	.processValue(this::setVisibility)
+ * 	.buildDetached();
+ * 
+ * + * @param + * the type of values which the listener processes + */ +public class ControlPropertyListenerBuilder { + + // #region FIELDS + + /** + * The properties which will be observed. + */ + private final ObservableMap properties; + + /** + * The key to which the listener will listen; must no be null by the time {@link #buildDetached()} is called. + */ + private Object key; + + /** + * The processor of the key's values; must no be null by the time {@link #buildDetached()} is called. + */ + private Consumer valueProcessor; + + /** + * The type of value which the listener processes + */ + private Optional> valueType; + + // #end FIELDS + + // #region CONSTRUCTION & SETTING VALUES + + /** + * Creates a new builder. + * + * @param properties + * the properties which will be observed by the built listener + */ + private ControlPropertyListenerBuilder(ObservableMap properties) { + Objects.requireNonNull(properties, "The argument 'properties' must not be null."); + this.properties = properties; + this.valueType = Optional.empty(); + } + + /** + * Creates a builder for a {@link ControlPropertyListenerHandle} which observes the specified property map. + *

+ * Note that it is often necessary to explicitly specify the type parameter {@code T} like so: + * + *

+	 * ControlProperties.<String> on(...)
+	 * 
+ * + * @param + * the type of values which the listener processes + * @param properties + * the {@link ObservableMap} holding the properties + * @return a {@link ControlPropertyListenerBuilder} + */ + public static ControlPropertyListenerBuilder on(ObservableMap properties) { + return new ControlPropertyListenerBuilder(properties); + } + + /** + * Sets the key. This must be called before {@link #buildDetached()}. + * + * @param key + * the key the built listener will observe + * @return this builder instance for fluent API + */ + public ControlPropertyListenerBuilder forKey(Object key) { + Objects.requireNonNull(key, "The argument 'key' must not be null."); + this.key = key; + return this; + } + + /** + * Sets the type of the values which the built listener will process. Used to type check before calling the + * {@link #processValue(Consumer) valueProcessor}. + *

+ * This type is optional. See the class comment on {@link ControlPropertyListenerBuilder this builder} for details. + * + * @param valueType + * the type of values the built listener will process + * @return this builder instance for fluent API + */ + public ControlPropertyListenerBuilder forValueType(Class valueType) { + Objects.requireNonNull(valueType, "The argument 'valueType' must not be null."); + this.valueType = Optional.of(valueType); + return this; + } + + /** + * Sets the processor for the key's values. This must be called before {@link #buildAttached()}. + * + * @param valueProcessor + * the {@link Consumer} for the key's values + * @return this builder instance for fluent API + */ + public ControlPropertyListenerBuilder processValue(Consumer valueProcessor) { + Objects.requireNonNull(valueProcessor, "The argument 'valueProcessor' must not be null."); + this.valueProcessor = valueProcessor; + return this; + } + + // #end CONSTRUCTION & SETTING VALUES + + // #region BUILD + + /** + * Creates a new property listener according to the arguments specified before and + * {@link ControlPropertyListenerHandle#attach() attaches} it. + * + * @return a {@link ControlPropertyListenerHandle}; initially attached + * @see #buildDetached() + */ + public ControlPropertyListenerHandle buildAttached() { + ControlPropertyListenerHandle listener = buildDetached(); + listener.attach(); + return listener; + } + + /** + * Creates a new property listener according to the arguments specified before. + *

+ * Note that this builder is not yet attached to the map! This can be done by calling + * {@link ControlPropertyListenerHandle#attach() attach()} on the returned instance. + * + * @return a {@link ControlPropertyListenerHandle}; initially detached + * @see #buildAttached() + */ + public ControlPropertyListenerHandle buildDetached() { + checkFields(); + + if (valueType.isPresent()) + return new TypeCheckingControlPropertyListenerHandle(properties, key, valueType.get(), valueProcessor); + else + return new CastingControlPropertyListenerHandle(properties, key, valueProcessor); + } + + /** + * Checks whether the fields are valid so they can be used to {@link #buildDetached() build} a listener. + */ + private void checkFields() { + if (key == null) + throw new IllegalStateException("Set a key with 'forKey' before calling 'build'."); + if (valueProcessor == null) + throw new IllegalStateException("Set a value processor with 'processValue' before calling 'build'."); + // value type is optional, so no checks + } + + // #end BUILD + +} diff --git a/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerHandle.java b/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerHandle.java new file mode 100644 index 0000000..6c6f066 --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerHandle.java @@ -0,0 +1,44 @@ +package org.codefx.libfx.control.properties; + +import org.codefx.libfx.listener.handle.ListenerHandle; + +/** + *

+ * This is a {@link ListenerHandle handle} on a {@code ControlPropertyListener}, which can be used to {@link #attach()} + * and {@link #detach()} it. The {@code ControlPropertyListener} is no type on its own so it is described here. + *

+ *

ControlPropertyListener

+ *

+ * A control property listener listens to the changes in a Control's + * {@link javafx.scene.control.Control#getProperties() propertyMap}. It is created to listen for a specific key and + * hands all new values for that key to a value processor (a {@link java.util.function.Consumer Consumer}).. + *

+ * Even though the property map's value type is {@code Object}, the processor might limit the value's type to any other + * class. If the actual value can not be cast to that type, it is silently ignored. + *

+ * Regardless of whether a value could be cast and processed or not, it will be removed from the map. So if the same + * value is set repeatedly, the specified value processor is called every time. + *

+ *

ControlPropertyListenerHandle

+ *

+ * Listener handles are not thread-safe. See {@link ListenerHandle} for details. Additionally, a new value might be + * processed twice if inserted into a map by another thread while {@link #attach()} is executed. This behavior should + * not be relied upon and might change (i.e. be fixed) in the future. + *

+ * A listener handle is best created with the {@link ControlPropertyListenerBuilder}. + */ +public interface ControlPropertyListenerHandle extends ListenerHandle { + + /** + * Attaches/adds the listener to the properties map. This immediately processes the key if it is present. + */ + @Override + void attach(); + + /** + * Detaches/removes the listener from the properties map. + */ + @Override + void detach(); + +} diff --git a/src/main/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandle.java b/src/main/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandle.java new file mode 100644 index 0000000..a8241db --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandle.java @@ -0,0 +1,63 @@ +package org.codefx.libfx.control.properties; + +import java.util.Objects; +import java.util.function.Consumer; + +import javafx.collections.ObservableMap; + +/** + * A {@link ControlPropertyListenerHandle} which uses a {@link Class} instance specified during construction to check + * whether a value is of the correct type. + * + * @param + * the type of values which the listener processes + */ +final class TypeCheckingControlPropertyListenerHandle extends AbstractControlPropertyListenerHandle { + + /** + * The type of values which the listener processes. + */ + private final Class valueType; + + /** + * The user specified processor for values. + */ + private final Consumer valueProcessor; + + /** + * Creates a listener handle. Initially detached. + * + * @param properties + * the {@link ObservableMap} holding the properties + * @param key + * the key to which the listener will listen + * @param valueType + * the type of values which the listener processes + * @param valueProcessor + * the {@link Consumer} for the key's values + */ + TypeCheckingControlPropertyListenerHandle( + ObservableMap properties, Object key, Class valueType, Consumer valueProcessor) { + + super(properties, key); + Objects.requireNonNull(valueProcessor, "The argument 'valueProcessor' must not be null."); + Objects.requireNonNull(valueType, "The argument 'valueType' must not be null."); + + this.valueType = valueType; + this.valueProcessor = valueProcessor; + } + + @Override + protected boolean processValueIfPossible(Object value) { + boolean valueHasCorrectType = valueType.isInstance(value); + if (valueHasCorrectType) { + // due to the check above the cast always succeeds + @SuppressWarnings("unchecked") + T convertedValue = (T) value; + valueProcessor.accept(convertedValue); + return true; + } else + return false; + } + +} diff --git a/src/main/java/org/codefx/libfx/control/properties/package-info.java b/src/main/java/org/codefx/libfx/control/properties/package-info.java new file mode 100644 index 0000000..2bd979e --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/properties/package-info.java @@ -0,0 +1,36 @@ +/** + *

+ * This package provides functionality to make using a {@link javafx.scene.control.Control Control}'s + * {@link javafx.scene.control.Control#getProperties() propertyMap} easier. As such its main use will be to creators of + * controls. + *

+ *

Listening to the Property Map

+ *

+ * In order to use the property map, a control has to create a listener which does these things: + *

    + *
  • identify the correct key + *
  • check whether the value is of the correct type and cast it + *
  • process the value + *
  • remove the value so when the same value is added again, the listener notices that + *
+ * While implementing such a listener is not difficult, some details have to be considered. This makes the code a little + * lengthy and hinders readability while at the same time repeating the same pattern over and over. + *

ControlPropertyListener

+ *

+ * This package provides usability functions to create such a listener in a concise and readable way (this code would be + * inside a control): + * + *

+ * ControlProperties.on(getProperties())
+ * 	.forKey("SomeKey")
+ * 	.processValue(valueString -> System.out.println(valueString))
+ * 	.buildAndAttach();
+ * 
+ * It returns an instance of {@link org.codefx.libfx.control.properties.ControlPropertyListenerHandle + * ControlPropertyListenerHandle} which can be used to easily detach and reattach the listener. + * + * @see org.codefx.libfx.control.properties.ControlPropertyListenerHandle ControlPropertyListener + * @see org.codefx.libfx.control.properties.ControlPropertyListenerBuilder ControlPropertyListenerBuilder + */ +package org.codefx.libfx.control.properties; + diff --git a/src/main/java/org/codefx/libfx/control/webview/DefaultWebViewHyperlinkListenerHandle.java b/src/main/java/org/codefx/libfx/control/webview/DefaultWebViewHyperlinkListenerHandle.java new file mode 100644 index 0000000..b56374c --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/webview/DefaultWebViewHyperlinkListenerHandle.java @@ -0,0 +1,282 @@ +package org.codefx.libfx.control.webview; + +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import javafx.application.Platform; +import javafx.beans.value.ObservableValue; +import javafx.concurrent.Worker.State; +import javafx.scene.web.WebView; + +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkEvent.EventType; + +import org.codefx.libfx.concurrent.when.ExecuteAlwaysWhen; +import org.codefx.libfx.concurrent.when.ExecuteWhen; +import org.codefx.libfx.dom.DomEventConverter; +import org.codefx.libfx.dom.DomEventType; +import org.w3c.dom.NodeList; +import org.w3c.dom.events.Event; +import org.w3c.dom.events.EventListener; +import org.w3c.dom.events.EventTarget; + +/** + * A default implementation of {@link WebViewHyperlinkListenerHandle} which acts on the {@link WebView} and + * {@link WebViewHyperlinkListener} specified during construction. + */ +class DefaultWebViewHyperlinkListenerHandle implements WebViewHyperlinkListenerHandle { + + // Inspired by : + // - http://stackoverflow.com/q/17555937 - + // - http://blogs.kiyut.com/tonny/2013/07/30/javafx-webview-addhyperlinklistener/ + + /* + * Many type names do not allow to easily recognize whether they come from the DOM- or the JavaFX-packages. To make + * it easier, all instances of org.w3c.dom classes carry a 'dom'-prefix. + */ + + // #region FIELDS + + /** + * The {@link WebView} to which the {@link #domEventListener} will be attached. + */ + private final WebView webView; + + /** + * The {@link WebViewHyperlinkListener} which will be called by {@link #domEventListener} when an event occurs. + */ + private final WebViewHyperlinkListener eventListener; + + /** + * The filter for events by their {@link EventType}. If the filter is empty, all events will be processed. Otherwise + * only events of the present type will be processed. + */ + private final Optional eventTypeFilter; + + /** + * The DOM-{@link EventListener} which will be attached to the {@link #webView}. + */ + private final EventListener domEventListener; + + /** + * Converts the observed DOM {@link Event}s to {@link HyperlinkEvent}s. + */ + private final DomEventConverter eventConverter; + + /** + * Executes {@link #attachListenerInApplicationThread()} each time the web view's load worker changes its state to + * {@link State#SUCCEEDED SUCCEEDED}. + *

+ * The executer is only present while the listener is {@link #attached}. + */ + private Optional> attachWhenLoadSucceeds; + + /** + * Indicates whether the listener is currently attached. + */ + private boolean attached; + + // #end FIELDS + + /** + * Creates a new listener handle for the specified arguments. The listener is not attached to the web view. + * + * @param webView + * the {@link WebView} to which the {@code eventListener} will be attached + * @param eventListener + * the {@link WebViewHyperlinkListener} which will be attached to the {@code webView} + * @param eventTypeFilter + * the filter for events by their {@link EventType} + * @param eventConverter + * the converter for DOM {@link Event}s + */ + public DefaultWebViewHyperlinkListenerHandle( + WebView webView, WebViewHyperlinkListener eventListener, Optional eventTypeFilter, + DomEventConverter eventConverter) { + + this.webView = webView; + this.eventListener = eventListener; + this.eventTypeFilter = eventTypeFilter; + this.eventConverter = eventConverter; + + domEventListener = this::callHyperlinkListenerWithEvent; + } + + // #region ATTACH + + @Override + public void attach() { + if (attached) + return; + + attached = true; + if (Platform.isFxApplicationThread()) + attachInApplicationThreadEachTimeLoadSucceeds(); + else + Platform.runLater(() -> attachInApplicationThreadEachTimeLoadSucceeds()); + } + + /** + * Attaches the {@link #domEventListener} to the {@link #webView} every time the {@code webView} successfully loaded + * a page. + *

+ * Must be called in JavaFX application thread. + */ + private void attachInApplicationThreadEachTimeLoadSucceeds() { + ObservableValue webWorkerState = webView.getEngine().getLoadWorker().stateProperty(); + + ExecuteAlwaysWhen attachWhenLoadSucceeds = ExecuteWhen + .on(webWorkerState) + .when(state -> state == State.SUCCEEDED) + .thenAlways(state -> attachListenerInApplicationThread()); + this.attachWhenLoadSucceeds = Optional.of(attachWhenLoadSucceeds); + + attachWhenLoadSucceeds.executeWhen(); + } + + /** + * Attaches the {@link #domEventListener} to the {@link #webView}. + *

+ * Must be called in JavaFX application thread. + */ + private void attachListenerInApplicationThread() { + BiConsumer addListener = + (eventTarget, eventType) -> eventTarget.addEventListener(eventType, domEventListener, false); + onEachLinkForEachManagedEventType(addListener); + } + + // #end ATTACH + + // #region DETACH + + @Override + public void detach() { + if (!attached) + return; + + attached = false; + if (Platform.isFxApplicationThread()) + detachInApplicationThread(); + else + Platform.runLater(() -> detachInApplicationThread()); + } + + /** + * Detaches the {@link #domEventListener} from the {@link #webView} and cancels and resets + * {@link #attachWhenLoadSucceeds}. + *

+ * Must be called in JavaFX application thread. + */ + private void detachInApplicationThread() { + attachWhenLoadSucceeds.ifPresent(attachWhen -> attachWhen.cancel()); + attachWhenLoadSucceeds = Optional.empty(); + + // it suffices to remove the listener if the worker state is on SUCCEEDED; + // because when the view is currently loading, the canceled 'attachWhen' will not re-add the listener + State webWorkerState = webView.getEngine().getLoadWorker().getState(); + if (webWorkerState == State.SUCCEEDED) { + BiConsumer removeListener = + (eventTarget, eventType) -> eventTarget.removeEventListener(eventType, domEventListener, false); + onEachLinkForEachManagedEventType(removeListener); + } + } + + // #end DETACH + + // #region COMMON MANAGEMENT METHODS + + /** + * Executes the specified function on each link in the {@link #webView}'s current document for each + * {@link DomEventType} for which {@link #manageListenerForEventType(DomEventType)} returns true. + *

+ * Must be called in JavaFX application thread. + * + * @param manageListener + * a {@link BiConsumer} which acts on a link and a DOM event type + */ + private void onEachLinkForEachManagedEventType(BiConsumer manageListener) { + NodeList domNodeList = webView.getEngine().getDocument().getElementsByTagName("a"); + for (int i = 0; i < domNodeList.getLength(); i++) { + EventTarget domTarget = (EventTarget) domNodeList.item(i); + onLinkForEachManagedEventType(domTarget, manageListener); + } + } + + /** + * Executes the specified function on the specified link for each {@link DomEventType} for which + * {@link #manageListenerForEventType(DomEventType)} returns true. + *

+ * Must be called in JavaFX application thread. + * + * @param link + * The {@link EventTarget} with which {@code manageListener} will be called + * @param manageListener + * a {@link BiConsumer} which acts on a link and a DOM event type + */ + private void onLinkForEachManagedEventType(EventTarget link, BiConsumer manageListener) { + Consumer manageListenerForType = + domEventType -> manageListener.accept(link, domEventType.getDomName()); + Stream.of(DomEventType.values()) + .filter(this::manageListenerForEventType) + .forEach(manageListenerForType); + } + + /** + * Indicates whether a listener must be added for the specified DOM event type and the {@link #eventTypeFilter}. + * + * @param domEventType + * the {@link DomEventType} for which a listener might be added + * @return true if the DOM event type has a representation as an hyperlink event type and is not filtered out; false + * otherwise + */ + private boolean manageListenerForEventType(DomEventType domEventType) { + boolean domEventTypeHasRepresentation = domEventType.toHyperlinkEventType().isPresent(); + if (!domEventTypeHasRepresentation) + return false; + + boolean filterOn = eventTypeFilter.isPresent(); + if (!filterOn) + return true; + + return domEventType.toHyperlinkEventType().get() == eventTypeFilter.get(); + } + + // #end COMMON MANAGEMENT METHODS + + // #region PROCESS EVENT + + /** + * Converts the specified {@code domEvent} into a {@link HyperlinkEvent} and calls the {@link #eventListener} with + * it. + * + * @param domEvent + * the DOM-{@link Event} + */ + private void callHyperlinkListenerWithEvent(Event domEvent) { + boolean canNotConvertEvent = !eventConverter.canConvertToHyperlinkEvent(domEvent); + if (canNotConvertEvent) + return; + + HyperlinkEvent event = eventConverter.convertToHyperlinkEvent(domEvent, webView); + boolean cancel = eventListener.hyperlinkUpdate(event); + cancel(domEvent, cancel); + } + + /** + * Cancels the specified event if it is cancelable and cancellation is indicated by the specified flag. + * + * @param domEvent + * the DOM-{@link Event} to be canceled + * @param cancel + * indicates whether the event should be canceled + */ + private static void cancel(Event domEvent, boolean cancel) { + if (domEvent.getCancelable() && cancel) + domEvent.preventDefault(); + } + + // #end PROCESS EVENT + +} diff --git a/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListener.java b/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListener.java new file mode 100644 index 0000000..01e8876 --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListener.java @@ -0,0 +1,63 @@ +package org.codefx.libfx.control.webview; + +import java.util.function.Function; + +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebView; + +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkListener; + +/** + * A listener to {@link HyperlinkEvent}s which are dispatched by a {@link WebView}. + *

+ * Very similar do the {@link HyperlinkListener} but it can cancel the further processing of events by the + * {@link WebEngine}. This does not extent to other listeners of this type to the same {@code WebView} - these are + * always called. + */ +public interface WebViewHyperlinkListener { + + /** + * Adapts the specified (Swing) hyperlink listener to a web view hyperlink listener. + * + * @param listener + * the {@link HyperlinkListener} to adapt + * @param cancel + * a {@link Function} which checks for the specified event whether it should be canceled + * @return a {@link WebViewHyperlinkListener} + */ + static WebViewHyperlinkListener fromHyperlinkListener(HyperlinkListener listener, + Function cancel) { + return event -> { + listener.hyperlinkUpdate(event); + return cancel.apply(event); + }; + } + + /** + * Adapts the specified (Swing) hyperlink listener to a web view hyperlink listener. + * + * @param listener + * the {@link HyperlinkListener} to adapt + * @param cancel + * whether the created listener should cancel every event + * @return a {@link WebViewHyperlinkListener} + */ + static WebViewHyperlinkListener fromHyperlinkListener(HyperlinkListener listener, boolean cancel) { + return event -> { + listener.hyperlinkUpdate(event); + return cancel; + }; + } + + /** + * Called when a hypertext link is updated. + * + * @param event + * the event responsible for the update + * @return whether the event should be canceled; if more than one listener is called on a single event, their return + * values are "ored". + */ + boolean hyperlinkUpdate(HyperlinkEvent event); + +} diff --git a/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerHandle.java b/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerHandle.java new file mode 100644 index 0000000..273c758 --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerHandle.java @@ -0,0 +1,40 @@ +package org.codefx.libfx.control.webview; + +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebView; + +import org.codefx.libfx.listener.handle.ListenerHandle; + +/** + * A {@link ListenerHandle} for a {@link WebViewHyperlinkListener}. + * + * @see ListenerHandle + * @see WebViewHyperlinkListener + */ +public interface WebViewHyperlinkListenerHandle extends ListenerHandle { + + /** + * Attaches/adds the {@link WebViewHyperlinkListener} to the {@link WebView}. + *

+ * This method can be called from any thread and regardless of the state of the {@code WebView}'s + * {@link WebEngine#getLoadWorker() loadWorker}. If it is not called on the FX Application Thread, the listener will + * be added at some unspecified time in the future. If the {@code loadWorker} is currently loading, the listener is + * attached as soon as it is done. + * + * @see ListenerHandle#attach() + */ + @Override + void attach(); + + /** + * Detaches/removes the {@link WebViewHyperlinkListener} from the {@link WebView}. + *

+ * This method can be called from any thread and regardless of the state of the {@code WebView}'s + * {@link WebEngine#getLoadWorker() loadWorker}. + * + * @see ListenerHandle#detach() + */ + @Override + void detach(); + +} diff --git a/src/main/java/org/codefx/libfx/control/webview/WebViews.java b/src/main/java/org/codefx/libfx/control/webview/WebViews.java new file mode 100644 index 0000000..de507a2 --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/webview/WebViews.java @@ -0,0 +1,241 @@ +package org.codefx.libfx.control.webview; + +import java.util.Objects; +import java.util.Optional; + +import javafx.scene.web.WebView; + +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkEvent.EventType; + +import org.codefx.libfx.dom.DomEventConverter; +import org.codefx.libfx.dom.DomEventType; +import org.codefx.libfx.dom.StaticDomEventConverter; +import org.w3c.dom.events.Event; + +/** + * Usability methods for the {@link WebView}. + */ +public final class WebViews { + + /** + * Private constructor for utility class. + */ + private WebViews() { + // nothing to do + } + + // #region HYPERLINK LISTENERS + + // create listener handles + + /** + * Creates a handle with which the specified listener can be {@link WebViewHyperlinkListenerHandle#attach() + * attached} to the specified web view. + *

+ * Once attached, the listener will be called on any event on an hyperlink (i.e. any element with tag name "a") + * which can be represented as a {@link HyperlinkEvent}. This is the case on {@link DomEventType#MOUSE_ENTER + * MOUSE_ENTER}, {@link DomEventType#MOUSE_LEAVE MOUSE_LEAVE} and {@link DomEventType#CLICK CLICK}. + * + * @param webView + * the {@link WebView} to which the listener will be added + * @param listener + * the {@link WebViewHyperlinkListener} to add to the web view + * @return a handle on the created listener which allows to attach and detach it; initially detached + * @see #addHyperlinkListener(WebView, WebViewHyperlinkListener) + */ + public static WebViewHyperlinkListenerHandle createHyperlinkListenerHandle( + WebView webView, WebViewHyperlinkListener listener) { + + return addHyperlinkListenerDetached(webView, listener, Optional.empty()); + } + + /** + * Creates a handle with which the specified listener can be {@link WebViewHyperlinkListenerHandle#attach() + * attached} to the specified web view. + *

+ * Once attached, the listener will be called on any event on an hyperlink (i.e. any element with tag name "a") + * which can be represented as a {@link HyperlinkEvent} with the specified event type. See + * {@link DomEventType#toHyperlinkEventType()} for the conversion of event types. + * + * @param webView + * the {@link WebView} to which the listener will be added + * @param listener + * the {@link WebViewHyperlinkListener} to add to the web view + * @param eventType + * the {@link EventType} of all events passed to the listener + * @return a handle on the created listener which allows to attach and detach it; initially detached + * @see #addHyperlinkListener(WebView, WebViewHyperlinkListener, HyperlinkEvent.EventType) + */ + public static WebViewHyperlinkListenerHandle createHyperlinkListenerHandle( + WebView webView, WebViewHyperlinkListener listener, EventType eventType) { + + Objects.requireNonNull(eventType, "The argument 'eventType' must not be null."); + return addHyperlinkListenerDetached(webView, listener, Optional.of(eventType)); + } + + // create and attach listener handles + + /** + * {@link #createHyperlinkListenerHandle(WebView, WebViewHyperlinkListener) Creates} a listener handle and + * immediately {@link WebViewHyperlinkListenerHandle#attach() attaches} it. + * + * @param webView + * the {@link WebView} to which the listener will be added + * @param listener + * the {@link WebViewHyperlinkListener} to add to the web view + * @return a handle on the created listener which allows to attach and detach it; initially attached + * @see #createHyperlinkListenerHandle(WebView, WebViewHyperlinkListener) + */ + public static WebViewHyperlinkListenerHandle addHyperlinkListener( + WebView webView, WebViewHyperlinkListener listener) { + + WebViewHyperlinkListenerHandle listenerHandle = addHyperlinkListenerDetached(webView, listener, + Optional.empty()); + listenerHandle.attach(); + return listenerHandle; + } + + /** + * {@link #createHyperlinkListenerHandle(WebView, WebViewHyperlinkListener, HyperlinkEvent.EventType) Creates} a + * listener handle and immediately {@link WebViewHyperlinkListenerHandle#attach() attaches} it. + * + * @param webView + * the {@link WebView} to which the listener will be added + * @param listener + * the {@link WebViewHyperlinkListener} to add to the web view + * @param eventType + * the {@link EventType} of all events passed to the listener + * @return a handle on the created listener which allows to attach and detach it; initially attached + * @see #createHyperlinkListenerHandle(WebView, WebViewHyperlinkListener, HyperlinkEvent.EventType) + */ + public static WebViewHyperlinkListenerHandle addHyperlinkListener( + WebView webView, WebViewHyperlinkListener listener, EventType eventType) { + + Objects.requireNonNull(eventType, "The argument 'eventType' must not be null."); + WebViewHyperlinkListenerHandle listenerHandle = addHyperlinkListenerDetached(webView, listener, + Optional.of(eventType)); + listenerHandle.attach(); + return listenerHandle; + } + + /** + * Adds the specified listener to the specified WebView. + *

+ * If necessary this method switches to the FX application thread. + * + * @param webView + * the {@link WebView} to which the listener will be added + * @param listener + * the {@link WebViewHyperlinkListener} to add to the web view + * @param eventTypeFilter + * the {@link EventType} of all events passed to the listener; {@link Optional#empty()} means all events + * are passed + * @return a handle on the created listener which allows to attach and detach it + */ + private static WebViewHyperlinkListenerHandle addHyperlinkListenerDetached( + WebView webView, WebViewHyperlinkListener listener, Optional eventTypeFilter) { + + Objects.requireNonNull(webView, "The argument 'webView' must not be null."); + Objects.requireNonNull(listener, "The argument 'listener' must not be null."); + Objects.requireNonNull(eventTypeFilter, "The argument 'eventTypeFilter' must not be null."); + + return new DefaultWebViewHyperlinkListenerHandle( + webView, listener, eventTypeFilter, new DomEventConverter()); + } + + // #end HYPERLINK LISTENERS + + // #region EVENTS + + /** + * Indicates whether the specified DOM event can be converted to a {@link HyperlinkEvent}. + * + * @param domEvent + * the DOM-{@link Event} + * @return true if the event's {@link Event#getType() type} has an equivalent {@link EventType EventType} + */ + public static boolean canConvertToHyperlinkEvent(Event domEvent) { + return StaticDomEventConverter.canConvertToHyperlinkEvent(domEvent); + } + + /** + * Converts the specified DOM event to a hyperlink event. + * + * @param domEvent + * the DOM-{@link Event} from which the {@link HyperlinkEvent} will be created + * @param source + * the source of the {@code domEvent} + * @return a {@link HyperlinkEvent} + * @throws IllegalArgumentException + * if the specified event can not be converted to a hyperlink event; this is the case if + * {@link #canConvertToHyperlinkEvent(Event)} returns false + */ + public static HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source) + throws IllegalArgumentException { + + return StaticDomEventConverter.convertToHyperlinkEvent(domEvent, source); + } + + /** + * Returns a string representation of the specified event. + * + * @param event + * the {@link HyperlinkEvent} which will be converted to a string + * @return a string representation of the event + */ + public static String hyperlinkEventToString(HyperlinkEvent event) { + Objects.requireNonNull(event, "The parameter 'event' must not be null."); + + return "HyperlinkEvent [" + + "type: " + + event.getEventType() + + "; " + + "URL (description): " + + event.getURL() + + " (" + + event.getDescription() + + "); " + + "source: " + + event.getSource() + + "; " + + "source element: " + + event.getSourceElement() + + "]"; + } + + // #end HYPERLINK EVENTS + + // #region DOM EVENTS + + /** + * Returns a string representation of the specified event. + * + * @param event + * the DOM-{@link Event} which will be converted to a string + * @return a string representation of the event + */ + public static String domEventToString(Event event) { + Objects.requireNonNull(event, "The parameter 'event' must not be null."); + + return "DOM-Event [" + + "target: " + + event.getTarget() + + "; " + + "type: " + + event.getType() + + "; " + + "time stamp: " + + event.getTimeStamp() + + "; " + + "bubbles: " + + event.getBubbles() + + "; " + + "cancelable: " + + event.getCancelable() + + "]"; + } + + // #end DOM EVENTS + +} diff --git a/src/main/java/org/codefx/libfx/control/webview/package-info.java b/src/main/java/org/codefx/libfx/control/webview/package-info.java new file mode 100644 index 0000000..c1d402c --- /dev/null +++ b/src/main/java/org/codefx/libfx/control/webview/package-info.java @@ -0,0 +1,24 @@ +/** + *

+ * This package provides functionality around JavaFX' {@link javafx.scene.web.WebView WebView}. All of it can be + * accessed via the utility class {@link org.codefx.libfx.control.webview.WebViews WebViews}. + *

+ *

Hyperlink Listener

+ *

+ * The {@code WebView} provides no pleasant way to add an equivalent of Swing's + * {@link javax.swing.event.HyperlinkListener HyperlinkListener}. + *

+ * This can now be done by implementing a {@link org.codefx.libfx.control.webview.WebViewHyperlinkListener + * WebViewHyperlinkListener}, which is very similar to Swing's {@code HyperlinkListener} and also processes + * {@link javax.swing.event.HyperlinkEvent HyperlinkEvents}. Together with a {@code WebView} and optionally an event + * filter it can be handed to {@code WebViews}' + * {@link org.codefx.libfx.control.webview.WebViews#addHyperlinkListener(javafx.scene.web.WebView, WebViewHyperlinkListener) + * addHyperlinkListener} method. + *

+ * Adding listeners returns a {@link org.codefx.libfx.control.webview.WebViewHyperlinkListenerHandle + * WebViewHyperlinkListenerHandle} which can be used to easily attach and detach the listener. + * + * @see org.codefx.libfx.control.webview.WebViews WebViews + */ +package org.codefx.libfx.control.webview; + diff --git a/src/main/java/org/codefx/libfx/dom/DomEventConverter.java b/src/main/java/org/codefx/libfx/dom/DomEventConverter.java new file mode 100644 index 0000000..57fe3f1 --- /dev/null +++ b/src/main/java/org/codefx/libfx/dom/DomEventConverter.java @@ -0,0 +1,63 @@ +package org.codefx.libfx.dom; + +import java.util.Objects; + +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkEvent.EventType; + +import org.w3c.dom.events.Event; + +/** + * Converts {@link Event DOM Events} to Swing's {@link HyperlinkEvent HyperlinkEvents}. + *

+ * This class is thread-safe, i.e. the provided methods can be called from different threads and concurrent executions + * do not interfere with each other. + */ +public final class DomEventConverter { + + /** + * Indicates whether the specified DOM event can be converted to a {@link HyperlinkEvent}. + * + * @param domEvent + * the DOM-{@link Event} + * @return true if the event's {@link Event#getType() type} has an equivalent {@link EventType EventType} + */ + @SuppressWarnings("static-method") + public boolean canConvertToHyperlinkEvent(Event domEvent) { + Objects.requireNonNull(domEvent, "The argument 'domEvent' must not be null."); + + Object source = "the source does not matter for this call"; + SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source); + return converter.canConvert(); + } + + /** + * Converts the specified DOM event to a hyperlink event. + * + * @param domEvent + * the DOM-{@link Event} from which the {@link HyperlinkEvent} will be created + * @param source + * the source of the {@code domEvent} + * @return a {@link HyperlinkEvent} with the following properties: + *

    + *
  • {@link HyperlinkEvent#getEventType() getEventType()} returns the {@link EventType} corresponding to + * the domEvent's type as defined by {@link DomEventType} + *
  • {@link HyperlinkEvent#getSource() getSource()} returns the specified {@code source} + *
  • {@link HyperlinkEvent#getURL() getUrl()} returns the href-attribute's value of the event's source + * element + *
  • {@link HyperlinkEvent#getDescription() getDescription()} returns the text content of the event's + * source element + *
  • {@link HyperlinkEvent#getInputEvent() getInputEvent()} returns null + *
  • {@link HyperlinkEvent#getSourceElement() getSourceElement()} returns null + *
+ * @throws IllegalArgumentException + * if the specified event can not be converted to a hyperlink event; this is the case if + * {@link #canConvertToHyperlinkEvent(Event)} returns false + */ + @SuppressWarnings("static-method") + public HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source) throws IllegalArgumentException { + SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source); + return converter.convert(); + } + +} diff --git a/src/main/java/org/codefx/libfx/dom/DomEventType.java b/src/main/java/org/codefx/libfx/dom/DomEventType.java new file mode 100644 index 0000000..66151e1 --- /dev/null +++ b/src/main/java/org/codefx/libfx/dom/DomEventType.java @@ -0,0 +1,128 @@ +package org.codefx.libfx.dom; + +import java.util.Objects; +import java.util.Optional; + +import javax.swing.event.HyperlinkEvent.EventType; + +/** + * The names of those types of DOM events for which an equivalent hyperlink {@link EventType EventType}s exists. + *

+ * + * @see DOM Level 3 Events Specification - Event + * Type List + */ +public enum DomEventType { + + // #region INSTANCES + + /** + * A mouse click. + *

+ * This event can be canceled. + * + * @see DOM Level 3 Events Specification - CLICK + * Event + */ + CLICK("click"), + + /** + * The mouse entered an element's boundaries. Is not dispatched when the mouse moves inside the element + * between its descendant elements. + *

+ * This event can not be canceled, i.e. canceling it has no effect. + * + * @see DOM Level 3 Events Specification - + * MOUSEENTER Event + */ + MOUSE_ENTER("mouseenter"), + + /** + * The mouse left an element's boundaries. Is not dispatched when the mouse moves inside the element between + * its descendant elements. + *

+ * This event can not be canceled, i.e. canceling it has no effect. + * + * @see DOM Level 3 Events Specification - + * MOUSELEAVE Event + */ + MOUSE_LEAVE("mouseleave"); + + // #end INSTANCES + + // #region DEFINITION + + /** + * The event's name. + */ + private final String domName; + + /** + * Creates a new DOM event type with the specified name. + * + * @param domName + * the name of the event as per DOM + * Level 3 Events Specification + */ + private DomEventType(String domName) { + this.domName = domName; + } + + /** + * @return the name of the event as per DOM + * Level 3 Events Specification + */ + public String getDomName() { + return domName; + } + + // #end DEFINITION + + // #region HELPER + + /** + * Returns the DOM event type for the specified event name. + * + * @param domEventName + * the name of the DOM event as per W3C specification + * @return a {@link DomEventType} if it could be determined; otherwise {@link Optional#empty()} + * @see DOM Level 3 Events Specification - Event + * Type List + */ + public static Optional byName(String domEventName) { + Objects.requireNonNull(domEventName, "The argument 'domEventName' must not be null."); + + for (DomEventType type : DomEventType.values()) + if (type.getDomName().equals(domEventName)) + return Optional.of(type); + + return Optional.empty(); + } + + /** + * Returns the representation of this DOM event as an {@link EventType HyperlinkEventType} if that is possible. + * Otherwise returns an empty Optional. + * + * @return

    + *
  • {@link #CLICK} → {@link EventType#ACTIVATED ACTIVATED} + *
  • {@link #MOUSE_ENTER} → {@link EventType#ENTERED ENTERED} + *
  • {@link #MOUSE_LEAVE} → {@link EventType#EXITED EXITED} + *
  • Otherwise → {@link Optional#empty() empty} + *
+ */ + public Optional toHyperlinkEventType() { + switch (this) { + case CLICK: + return Optional.of(EventType.ACTIVATED); + case MOUSE_ENTER: + return Optional.of(EventType.ENTERED); + case MOUSE_LEAVE: + return Optional.of(EventType.EXITED); + default: + return Optional.empty(); + } + } + + // #end HELPER + +} diff --git a/src/main/java/org/codefx/libfx/dom/SingleDomEventConverter.java b/src/main/java/org/codefx/libfx/dom/SingleDomEventConverter.java new file mode 100644 index 0000000..0d3d35c --- /dev/null +++ b/src/main/java/org/codefx/libfx/dom/SingleDomEventConverter.java @@ -0,0 +1,213 @@ +package org.codefx.libfx.dom; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Objects; +import java.util.Optional; + +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkEvent.EventType; + +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.events.Event; + +/** + * Creates a {@link HyperlinkEvent} from the DOM-{@link Event} specified during construction. + *

+ * In does the actual work for the {@link DomEventConverter} but is a "one-shot" version in the sense that it can only + * convert the event specified during construction. + */ +class SingleDomEventConverter { + + /** + * The DOM-{@link Event} from which the {@link HyperlinkEvent} will be created. + */ + private final Event domEvent; + + /** + * The source of the {@link #domEvent}. + */ + private final Object source; + + /** + * Creates a new converter for the specified arguments. + * + * @param domEvent + * the DOM-{@link Event} from which the {@link HyperlinkEvent} will be created + * @param source + * the source of the {@code domEvent} + */ + public SingleDomEventConverter(Event domEvent, Object source) { + Objects.requireNonNull(domEvent, "The argument 'domEvent' must not be null."); + Objects.requireNonNull(source, "The argument 'source' must not be null."); + + this.domEvent = domEvent; + this.source = source; + } + + // #region CONVERT + + /** + * Indicates whether the DOM event specified during construction can be converted to a {@link HyperlinkEvent}. + * + * @return true if the event's {@link Event#getType() type} has an equivalent {@link EventType EventType} + */ + public boolean canConvert() { + Optional eventType = getEventTypeFrom(domEvent); + return eventType.isPresent(); + } + + /** + * Returns the hyperlink event type equivalent of the specified DOM event if it exists. + * + * @param domEvent + * the DOM-{@link Event} whose {@link Event#getType() type} will be determined + * @return the equivalent Hyperlink.{@link EventType} if it exists + */ + private static Optional getEventTypeFrom(Event domEvent) { + String domEventName = domEvent.getType(); + Optional eventType = DomEventType + .byName(domEventName) + .flatMap(domEventType -> domEventType.toHyperlinkEventType()); + return eventType; + } + + /** + * Converts the event specified during construction to a hyperlink event. + * + * @return a {@link HyperlinkEvent} + * @throws IllegalArgumentException + * if the specified event can not be converted to a hyperlink event; this is the case if + * {@link #canConvert()} returns false + */ + public HyperlinkEvent convert() throws IllegalArgumentException { + EventType type = getEventTypeForDomEvent(); + Optional url = getURL(); + String linkDescription = getTextContent(); + + return new HyperlinkEvent(source, type, url.orElse(null), linkDescription); + } + + /** + * Returns the hyperlink event type equivalent of the specified DOM event. + * + * @return the equivalent Hyperlink.{@link EventType} + * @throws IllegalArgumentException + * if the {@link #domEvent}'s type has no equivalent hyperlink event type + */ + private EventType getEventTypeForDomEvent() throws IllegalArgumentException { + Optional eventType = getEventTypeFrom(domEvent); + if (eventType.isPresent()) + return eventType.get(); + else + throw new IllegalArgumentException( + "The DOM event '" + domEvent + "' of type '" + domEvent.getType() + + "' can not be converted to a hyperlink event."); + } + + /** + * Returns the {@link #domEvent}'s target's text content. + * + * @return the description + */ + private String getTextContent() { + Element targetElement = (Element) domEvent.getTarget(); + return targetElement.getTextContent(); + } + + /** + * Returns the URL the interacted hyperlink points to. + * + * @return the {@link URL} if it could be created + */ + private Optional getURL() { + Element targetElement = (Element) domEvent.getTarget(); + Element anchor = getAnchor(targetElement); + + Optional baseURI = Optional.ofNullable(anchor.getBaseURI()); + String href = anchor.getAttribute("href"); + return createURL(baseURI, href); + } + + /** + * Returns the same element if it is an anchor (in the sense of HTML, i.e. has the a-tag). If it is not the closest + * parent which is an anchor is returned. If no such parent exists, an {@link IllegalArgumentException} is thrown. + * + * @param domElement + * the {@link Element} on which the search for an anchor element starts + * @return an {@link Element} which is an anchor + * @throws IllegalArgumentException + * if neither the specified element nor one of its parents is an anchor + */ + private static Element getAnchor(Element domElement) throws IllegalArgumentException { + Optional anchor = getAnchorAncestor(Optional.of(domElement)); + return anchor.orElseThrow(() -> new IllegalArgumentException( + "Neither the event's target element nor one of its parent nodes is an anchor.")); + } + + /** + * Searches for an a-tag starting on the specified node and recursing to its ancestors. + * + * @param domNode + * the node which is checked for the a-tag + * @return an {@link Optional} containing an anchor if one was found; otherwise an empty {@code Optional} + */ + private static Optional getAnchorAncestor(Optional domNode) { + // if there is no node, there can be no anchor, so return empty + if (!domNode.isPresent()) + return Optional.empty(); + + Node node = domNode.get(); + + // only elements can be anchors, so if the node is no element, recurse to its parent + boolean nodeIsNoElement = !(node instanceof Element); + if (nodeIsNoElement) + return getAnchorAncestor(Optional.ofNullable(node.getParentNode())); + + // if the node is an element, it might be an anchor + Element element = (Element) node; + boolean isAnchor = element.getTagName().equalsIgnoreCase("a"); + if (isAnchor) + return Optional.of(element); + + // if the element is no anchor, recurse to its parent + return getAnchorAncestor(Optional.ofNullable(element.getParentNode())); + } + + /** + * Creates a URL from the specified base URI and href of the link which caused the event. + * + * @param baseURI + * the base URI of the anchor {@link Element} which caused the event + * @param href + * the href attribute value of the {@link Element} which caused the event + * @return a URL if one could be created + */ + private static Optional createURL(Optional baseURI, String href) { + // create URL context from the document's base URI + URL context = null; + try { + if (baseURI.isPresent()) + context = new URL(baseURI.get()); + } catch (MalformedURLException e) { + // if LibFX supports logging, this could be logged: + // "Could not create a URL context from the base URI \"" + baseURI + "\".", e + } + + // create URL from context and href + try { + URL url = new URL(context, href); + return Optional.of(url); + } catch (MalformedURLException e) { + // if LibFX supports logging, this could be logged: + // "Could not create a URL from href \"" + href + "\" and context \"" + context + "\"." + // until then return empty + } + + return Optional.empty(); + } + + // #end CONVERT + +} diff --git a/src/main/java/org/codefx/libfx/dom/StaticDomEventConverter.java b/src/main/java/org/codefx/libfx/dom/StaticDomEventConverter.java new file mode 100644 index 0000000..629cc29 --- /dev/null +++ b/src/main/java/org/codefx/libfx/dom/StaticDomEventConverter.java @@ -0,0 +1,65 @@ +package org.codefx.libfx.dom; + +import java.util.Objects; + +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkEvent.EventType; + +import org.w3c.dom.events.Event; + +/** + * Class which provides {@link DomEventConverter} methods statically. + *

+ * This class is thread-safe, i.e. the provided methods can be called from different threads and concurrent executions + * do not interfere with each other. + */ +public final class StaticDomEventConverter { + + /** + * Indicates whether the specified DOM event can be converted to a {@link HyperlinkEvent}. + * + * @param domEvent + * the DOM-{@link Event} + * @return true if the event's {@link Event#getType() type} has an equivalent {@link EventType EventType} + * @see DomEventConverter#canConvertToHyperlinkEvent(Event) + */ + public static boolean canConvertToHyperlinkEvent(Event domEvent) { + Objects.requireNonNull(domEvent, "The argument 'domEvent' must not be null."); + + Object source = "the source does not matter for this call"; + SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source); + return converter.canConvert(); + } + + /** + * Converts the specified DOM event to a hyperlink event. + * + * @param domEvent + * the DOM-{@link Event} from which the {@link HyperlinkEvent} will be created + * @param source + * the source of the {@code domEvent} + * @return a {@link HyperlinkEvent} with the following properties: + *

    + *
  • {@link HyperlinkEvent#getEventType() getEventType()} returns the {@link EventType} corresponding to + * the domEvent's type as defined by {@link DomEventType} + *
  • {@link HyperlinkEvent#getSource() getSource()} returns the specified {@code source} + *
  • {@link HyperlinkEvent#getURL() getUrl()} returns the href-attribute's value of the event's source + * element + *
  • {@link HyperlinkEvent#getDescription() getDescription()} returns the text content of the event's + * source element + *
  • {@link HyperlinkEvent#getInputEvent() getInputEvent()} returns null + *
  • {@link HyperlinkEvent#getSourceElement() getSourceElement()} returns null + *
+ * @throws IllegalArgumentException + * if the specified event can not be converted to a hyperlink event; this is the case if + * {@link #canConvertToHyperlinkEvent(Event)} returns false + * @see DomEventConverter#convertToHyperlinkEvent(Event, Object) + */ + public static HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source) + throws IllegalArgumentException { + + SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source); + return converter.convert(); + } + +} diff --git a/src/main/java/org/codefx/libfx/dom/package-info.java b/src/main/java/org/codefx/libfx/dom/package-info.java new file mode 100644 index 0000000..8ca8a50 --- /dev/null +++ b/src/main/java/org/codefx/libfx/dom/package-info.java @@ -0,0 +1,11 @@ +/** + *

+ * This package provides functionality around DOM, i.e. classes from {@code org.w3c.dom}. + *

+ *

Event Conversion

The class {@link org.codefx.libfx.dom.DomEventConverter DomEventConverter} defines methods + * which allow the conversion of {@link org.w3c.dom.events.Event DOM Events} to {@link javax.swing.event.HyperlinkEvent + * HyperlinkEvents}. {@link org.codefx.libfx.dom.StaticDomEventConverter StaticDomEventConverter} gives access to the + * same methods without the need of instantiation. + */ +package org.codefx.libfx.dom; + diff --git a/src/main/java/org/codefx/libfx/listener/handle/GenericListenerHandle.java b/src/main/java/org/codefx/libfx/listener/handle/GenericListenerHandle.java new file mode 100644 index 0000000..eac4d01 --- /dev/null +++ b/src/main/java/org/codefx/libfx/listener/handle/GenericListenerHandle.java @@ -0,0 +1,99 @@ +package org.codefx.libfx.listener.handle; + +import java.util.Objects; +import java.util.function.BiConsumer; + +/** + * A generic implementation of {@link ListenerHandle} which uses functions specified during construction to + * {@link #attach()} and {@link #detach()} the listener to the observable instance. + * + * @param + * the type of the observable instance (e.g {@link javafx.beans.value.ObservableValue ObservableValue} or + * {@link javafx.collections.ObservableMap ObservableMap}) to which the listener will be added + * @param + * the type of the listener which will be added to the observable + */ +final class GenericListenerHandle implements ListenerHandle { + + // #region FIELDS + + /** + * The observable instance to which the {@link #listener} will be added. + */ + private final O observable; + + /** + * The listener which will be added to the {@link #observable}. + */ + private final L listener; + + /** + * Called on {@link #attach()}. + */ + private final BiConsumer add; + + /** + * Called on {@link #detach()}. + */ + private final BiConsumer remove; + + /** + * Indicates whether the {@link #listener} is currently added to the {@link #observable}. + */ + private boolean attached; + + // #end FIELDS + + // #region CONSTRUCITON + + /** + * Creates a new listener handle for the specified arguments. The listener is initially detached. + * + * @param observable + * the observable instance to which the {@code listener} will be added + * @param listener + * the listener which will be added to the {@code observable} + * @param add + * called when the {@code listener} must be added to the {@code observable} + * @param remove + * called when the {@code listener} must be removed from the {@code observable} + */ + public GenericListenerHandle( + O observable, L listener, BiConsumer add, BiConsumer remove) { + + Objects.requireNonNull(observable, "The argument 'observable' must not be null."); + Objects.requireNonNull(listener, "The argument 'listener' must not be null."); + Objects.requireNonNull(add, "The argument 'add' must not be null."); + Objects.requireNonNull(remove, "The argument 'remove' must not be null."); + + this.observable = observable; + this.listener = listener; + this.add = add; + this.remove = remove; + } + + // #end CONSTRUCITON + + // #region IMPLEMENTATION OF 'ListenerHandle' + + @Override + public void attach() { + if (attached) + return; + + attached = true; + add.accept(observable, listener); + } + + @Override + public void detach() { + if (!attached) + return; + + attached = false; + remove.accept(observable, listener); + } + + // #end IMPLEMENTATION OF 'ListenerHandle' + +} diff --git a/src/main/java/org/codefx/libfx/listener/handle/ListenerHandle.java b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandle.java new file mode 100644 index 0000000..735ab58 --- /dev/null +++ b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandle.java @@ -0,0 +1,28 @@ +package org.codefx.libfx.listener.handle; + +/** + * A listener handle can be used to {@link #attach() attach} and {@link #detach() detach} a listener to/from some + * observable instance. Using the handler the calling code must not manage references to both the observed instance and + * the listener, which can improve readability. + *

+ * A handle is created and returned by methods which connect a listener with an observable instance. This usually means + * that the listener is actually added to the observable but it is also possible to simply return a handler and wait for + * the call to {@code attach()} before adding the listener. It is up to such methods to specify this behavior. + *

+ * Unless otherwise noted it is not safe to share a handle between different threads. The behavior is undefined if + * parallel calls are made to {@code attach()} and/or {@code detach()}. + */ +public interface ListenerHandle { + + /** + * Adds the listener to the observable. Calling this method when the listener is already added is a no-op and will + * not result in the listener being called more than once. + */ + void attach(); + + /** + * Removes the listener from the observable. Calling this method when the listener is not added is a no-op. + */ + void detach(); + +} diff --git a/src/main/java/org/codefx/libfx/listener/handle/ListenerHandleBuilder.java b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandleBuilder.java new file mode 100644 index 0000000..d935457 --- /dev/null +++ b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandleBuilder.java @@ -0,0 +1,220 @@ +package org.codefx.libfx.listener.handle; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.BiConsumer; + +/** + * A builder for a {@link ListenerHandle}. Note that it is abstract enough to be used for all kinds of + * observable/listener relation and not just for those occurring in JavaFX. + *

+ * The created handle manages whether the listener is currently attached. The functions specified to + * {@link #onAttach(BiConsumer)} and {@link #onDetach(BiConsumer)} are only called when necessary. This is the case + *

    + *
  • if {@link ListenerHandle#attach() attach} is called when the listener is not currently added to the observable + *
  • if {@link ListenerHandle#detach() detach} is called when the listener is currently added to the observable + *
+ * This implies that they can be stateless functions which simply add and remove the listener. The functions are called + * with the observable and listener specified during construction. + *

+ * The {@link ListenerHandle} returned by this builder is not yet attached, i.e. it does not initially call the + * functions given to {@code onAttach} or {@code onDetach}. + *

+ *

Example

+ *

+ * A typical use looks like this: + * + *

+ * Property<String> textProperty;
+ * ChangeListener<String> textListener;
+ *
+ * ListenerHandle textListenerHandle = ListenerHandleBuilder
+ * 	.from(textProperty, textListener)
+ * 	.onAttach((property, listener) -> property.addListener(listener))
+ * 	.onDetach((property, listener) -> property.removeListener(listener))
+ * 	.build();
+ * 
+ * + * @param + * the type of the observable instance (e.g {@link javafx.beans.value.ObservableValue ObservableValue} or + * {@link javafx.collections.ObservableMap ObservableMap}) to which the listener will be added + * @param + * the type of the listener which will be added to the observable + */ +public final class ListenerHandleBuilder { + + // #region FIELDS + + /** + * The observable instance to which the {@link #listener} will be added. + */ + private final O observable; + + /** + * The listener which will be added to the {@link #observable}. + */ + private final L listener; + + /** + * Called on {@link ListenerHandle#attach()}. + */ + private Optional> add; + + /** + * Called on {@link ListenerHandle#detach()}. + */ + private Optional> remove; + + // #end FIELDS + + // #region CONSTRUCTION + + /** + * Creates a builder for a generic {@link ListenerHandle}. + * + * @param observable + * the observable instance to which the {@code listener} will be added + * @param listener + * the listener which will be added to the {@code observable} + */ + private ListenerHandleBuilder(O observable, L listener) { + Objects.requireNonNull(observable, "The argument 'observable' must not be null."); + Objects.requireNonNull(listener, "The argument 'listener' must not be null."); + + this.observable = observable; + this.listener = listener; + + add = Optional.empty(); + remove = Optional.empty(); + } + + /** + * Creates a builder for a generic {@link ListenerHandle}. + * + * @param + * the type of the observable instance (e.g {@link javafx.beans.value.ObservableValue ObservableValue} or + * {@link javafx.collections.ObservableMap ObservableMap}) to which the listener will be added + * @param + * the type of the listener which will be added to the observable + * @param observable + * the observable instance to which the {@code listener} will be added + * @param listener + * the listener which will be added to the {@code observable} + * @return a {@link ListenerHandleBuilder} for a {@link ListenerHandle}. + */ + public static ListenerHandleBuilder from(O observable, L listener) { + return new ListenerHandleBuilder<>(observable, listener); + } + + // #end CONSTRUCTION + + // #region SET AND BUILD + + /** + * Sets the function which is executed when the built {@link ListenerHandle} must add the listener because + * {@link ListenerHandle#attach() attach} was called. + *

+ * Because the built handle manages whether the listener is currently attached, the function is only called when + * necessary, i.e. when {@code attach} is called when the listener is currently not added to the observable. + * + * @param add + * the {@link BiConsumer} called on {@code attach}; the arguments for the function are the observable and + * listener specified during this builder's construction + * @return this builder for fluent calls + */ + public ListenerHandleBuilder onAttach(BiConsumer add) { + Objects.requireNonNull(add, "The argument 'add' must not be null."); + + this.add = Optional.of(add); + return this; + } + + /** + * Sets the function which is executed when the built {@link ListenerHandle} must remove the listener because + * {@link ListenerHandle#attach() detach} was called. + *

+ * Because the built handle manages whether the listener is currently attached, the function is only called when + * necessary, i.e. when {@code detach} is called when the listener is currently added to the observable. + * + * @param remove + * the {@link BiConsumer} called on {@code detach}; the arguments for the function are the observable and + * listener specified during this builder's construction + * @return this builder for fluent calls + */ + public ListenerHandleBuilder onDetach(BiConsumer remove) { + Objects.requireNonNull(remove, "The argument 'remove' must not be null."); + + this.remove = Optional.of(remove); + return this; + } + + /** + * Creates a new listener handle and attaches the listener. This will only succeed if {@link #onAttach(BiConsumer)} + * and {@link #onDetach(BiConsumer)} have been called. + * + * @return a new {@link ListenerHandle}; initially attached + * @throws IllegalStateException + * if {@link #onAttach(BiConsumer)} or {@link #onDetach(BiConsumer)} have not been called + */ + public ListenerHandle buildAttached() throws IllegalStateException { + ListenerHandle handle = buildDetached(); + handle.attach(); + return handle; + } + + /** + * Creates a new, initially detached listener handle. This will only succeed if {@link #onAttach(BiConsumer)} and + * {@link #onDetach(BiConsumer)} have been called. + * + * @return a new {@link ListenerHandle}; initially detached + * @throws IllegalStateException + * if {@link #onAttach(BiConsumer)} or {@link #onDetach(BiConsumer)} have not been called + */ + public ListenerHandle buildDetached() throws IllegalStateException { + verifyAddAndRemovePresent(); + return new GenericListenerHandle(observable, listener, add.get(), remove.get()); + } + + /** + * Verifies that {@link #add} and {@link #remove} are present. + * + * @throws IllegalStateException + * if {@link #add} or {@link #remove} is empty. + */ + private void verifyAddAndRemovePresent() throws IllegalStateException { + boolean onAttachNotCalled = !add.isPresent(); + boolean onDetachNotCalled = !remove.isPresent(); + boolean canBuild = !onAttachNotCalled && !onDetachNotCalled; + + if (canBuild) + return; + else + throwExceptionForMissingCall(onAttachNotCalled, onDetachNotCalled); + } + + /** + * Throws an {@link IllegalStateException} for a missing call. + * + * @param onAttachNotCalled + * indicates whether {@link #onAttach(BiConsumer)} has been called + * @param onDetachNotCalled + * indicates whether {@link #onDetach(BiConsumer)} has been called + * @throws IllegalStateException + * if at least one of the specified booleans is true + */ + private static void throwExceptionForMissingCall(boolean onAttachNotCalled, boolean onDetachNotCalled) + throws IllegalStateException { + + if (onAttachNotCalled && onDetachNotCalled) + throw new IllegalStateException( + "A listener handle can not be build until 'onAttach' and 'onDetach' have been called."); + + if (onAttachNotCalled) + throw new IllegalStateException("A listener handle can not be build until 'onAttach' has been called."); + + if (onDetachNotCalled) + throw new IllegalStateException("A listener handle can not be build until 'onDetach' has been called."); + } + + // #end SET AND BUILD +} diff --git a/src/main/java/org/codefx/libfx/listener/handle/ListenerHandles.java b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandles.java new file mode 100644 index 0000000..87c9bea --- /dev/null +++ b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandles.java @@ -0,0 +1,295 @@ +package org.codefx.libfx.listener.handle; + +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.collections.ArrayChangeListener; +import javafx.collections.ListChangeListener; +import javafx.collections.MapChangeListener; +import javafx.collections.ObservableArray; +import javafx.collections.ObservableList; +import javafx.collections.ObservableMap; +import javafx.collections.ObservableSet; +import javafx.collections.SetChangeListener; + +/** + * Factory class for functionality surrounding {@link ListenerHandle}s. + */ +public class ListenerHandles { + + /** + * Private constructor so utility class is not instantiated. + */ + private ListenerHandles() { + // nothing to do + } + + /** + * Creates a {@link ListenerHandleBuilder builder} for a generic {@link ListenerHandle}. + * + * @param + * the type of the observable instance (e.g {@link javafx.beans.value.ObservableValue ObservableValue} or + * {@link javafx.collections.ObservableMap ObservableMap}) to which the listener will be added + * @param + * the type of the listener which will be added to the observable + * @param observable + * the observable instance to which the {@code listener} will be added + * @param listener + * the listener which will be added to the {@code observable} + * @return a {@link ListenerHandleBuilder} for a {@code ListenerHandle}. + * @see ListenerHandleBuilder + */ + public static ListenerHandleBuilder createFor(O observable, L listener) { + return ListenerHandleBuilder.from(observable, listener); + } + + // Observable + InvalidationListener + + /** + * Adds the specified listener to the specified observable and returns a handle for the combination. + * + * @param observable + * the {@link Observable} to which the {@code invalidationListener} will be added + * @param invalidationListener + * the {@link InvalidationListener} which will be added to the {@code observable} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached + */ + public static ListenerHandle createAttached(Observable observable, InvalidationListener invalidationListener) { + ListenerHandle handle = createDetached(observable, invalidationListener); + handle.attach(); + return handle; + } + + /** + * Creates a listener handle for the specified observable and listener. The listener is not yet attached! + * + * @param observable + * the {@link Observable} to which the {@code invalidationListener} will be added + * @param invalidationListener + * the {@link InvalidationListener} which will be added to the {@code observableValue} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached + */ + public static ListenerHandle createDetached(Observable observable, InvalidationListener invalidationListener) { + return ListenerHandleBuilder + .from(observable, invalidationListener) + .onAttach((obs, listener) -> obs.addListener(listener)) + .onDetach((obs, listener) -> obs.removeListener(listener)) + .buildDetached(); + } + + // ObservableValue + ChangeListener + + /** + * Adds the specified listener to the specified observable value and returns a handle for the combination. + * + * @param + * the type of the value wrapped by the observable + * @param observableValue + * the {@link ObservableValue} to which the {@code changeListener} will be added + * @param changeListener + * the {@link ChangeListener} which will be added to the {@code observableValue} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached + */ + public static ListenerHandle createAttached( + ObservableValue observableValue, ChangeListener changeListener) { + + ListenerHandle handle = createDetached(observableValue, changeListener); + handle.attach(); + return handle; + } + + /** + * Creates a listener handle for the specified observable value and listener. The listener is not yet attached! + * + * @param + * the type of the value wrapped by the observable + * @param observableValue + * the {@link ObservableValue} to which the {@code changeListener} will be added + * @param changeListener + * the {@link ChangeListener} which will be added to the {@code observableValue} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached + */ + public static ListenerHandle createDetached( + ObservableValue observableValue, ChangeListener changeListener) { + + return ListenerHandleBuilder + .from(observableValue, changeListener) + .onAttach((observable, listener) -> observable.addListener(listener)) + .onDetach((observable, listener) -> observable.removeListener(listener)) + .buildDetached(); + } + + // ObservableArray + ArrayChangeListener + + /** + * Adds the specified listener to the specified observable array and returns a handle for the combination. + * + * @param + * the type of the array wrapped by the observable + * @param observableArray + * the {@link ObservableArray} to which the {@code changeListener} will be added + * @param changeListener + * the {@link ArrayChangeListener} which will be added to the {@code observableArray} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached + */ + public static > ListenerHandle createAttached( + ObservableArray observableArray, ArrayChangeListener changeListener) { + + ListenerHandle handle = createDetached(observableArray, changeListener); + handle.attach(); + return handle; + } + + /** + * Creates a listener handle for the specified observable array and listener. The listener is not yet attached! + * + * @param + * the type of the array wrapped by the observable + * @param observableArray + * the {@link ObservableArray} to which the {@code changeListener} will be added + * @param changeListener + * the {@link ArrayChangeListener} which will be added to the {@code observableArray} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached + */ + public static > ListenerHandle createDetached( + ObservableArray observableArray, ArrayChangeListener changeListener) { + + return ListenerHandleBuilder + .from(observableArray, changeListener) + .onAttach((observable, listener) -> observable.addListener(listener)) + .onDetach((observable, listener) -> observable.removeListener(listener)) + .buildDetached(); + } + + // ObservableList + ListChangeListener + + /** + * Adds the specified listener to the specified observable list and returns a handle for the combination. + * + * @param + * the list element type + * @param observableList + * the {@link ObservableList} to which the {@code changeListener} will be added + * @param changeListener + * the {@link ListChangeListener} which will be added to the {@code observableList} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached + */ + public static ListenerHandle createAttached( + ObservableList observableList, ListChangeListener changeListener) { + + ListenerHandle handle = createDetached(observableList, changeListener); + handle.attach(); + return handle; + } + + /** + * Creates a listener handle for the specified observable list and listener. The listener is not yet attached! + * + * @param + * the list element type + * @param observableList + * the {@link ObservableList} to which the {@code changeListener} will be added + * @param changeListener + * the {@link ListChangeListener} which will be added to the {@code observableList} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached + */ + public static ListenerHandle createDetached( + ObservableList observableList, ListChangeListener changeListener) { + + return ListenerHandleBuilder + .from(observableList, changeListener) + .onAttach((observable, listener) -> observable.addListener(listener)) + .onDetach((observable, listener) -> observable.removeListener(listener)) + .buildDetached(); + } + + // ObservableSet + SetChangeListener + + /** + * Adds the specified listener to the specified observable set and returns a handle for the combination. + * + * @param + * the set element type + * @param observableSet + * the {@link ObservableSet} to which the {@code changeListener} will be added + * @param changeListener + * the {@link SetChangeListener} which will be added to the {@code observableSet} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached + */ + public static ListenerHandle createAttached( + ObservableSet observableSet, SetChangeListener changeListener) { + + ListenerHandle handle = createDetached(observableSet, changeListener); + handle.attach(); + return handle; + } + + /** + * Creates a listener handle for the specified observable set and listener. The listener is not yet attached! + * + * @param + * the set element type + * @param observableSet + * the {@link ObservableSet} to which the {@code changeListener} will be added + * @param changeListener + * the {@link SetChangeListener} which will be added to the {@code observableSet} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached + */ + public static ListenerHandle createDetached( + ObservableSet observableSet, SetChangeListener changeListener) { + + return ListenerHandleBuilder + .from(observableSet, changeListener) + .onAttach((observable, listener) -> observable.addListener(listener)) + .onDetach((observable, listener) -> observable.removeListener(listener)) + .buildDetached(); + } + + // ObservableMap + MapChangeListener + + /** + * Adds the specified listener to the specified observable map and returns a handle for the combination. + * + * @param + * the map key element type + * @param + * the map value element type + * @param observableMap + * the {@link ObservableMap} to which the {@code changeListener} will be added + * @param changeListener + * the {@link MapChangeListener} which will be added to the {@code observableMap} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached + */ + public static ListenerHandle createAttached( + ObservableMap observableMap, MapChangeListener changeListener) { + + ListenerHandle handle = createDetached(observableMap, changeListener); + handle.attach(); + return handle; + } + + /** + * Creates a listener handle for the specified observable map and listener. The listener is not yet attached! + * + * @param + * the map key element type + * @param + * the map value element type + * @param observableMap + * the {@link ObservableMap} to which the {@code changeListener} will be added + * @param changeListener + * the {@link MapChangeListener} which will be added to the {@code observableMap} + * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached + */ + public static ListenerHandle createDetached( + ObservableMap observableMap, MapChangeListener changeListener) { + + return ListenerHandleBuilder + .from(observableMap, changeListener) + .onAttach((observable, listener) -> observable.addListener(listener)) + .onDetach((observable, listener) -> observable.removeListener(listener)) + .buildDetached(); + } + +} diff --git a/src/main/java/org/codefx/libfx/listener/handle/package-info.java b/src/main/java/org/codefx/libfx/listener/handle/package-info.java new file mode 100644 index 0000000..fac5b67 --- /dev/null +++ b/src/main/java/org/codefx/libfx/listener/handle/package-info.java @@ -0,0 +1,18 @@ +/** + * This package provides classes which make it easier to add and remove listeners from observable instances. + *

+ * Using the default JavaFX 8 features, it is necessary to store both the observed instance and the listener if the + * latter has to be added or removed repeatedly. A {@link org.codefx.libfx.listener.handle.ListenerHandle + * ListenerHandle} encapsulates those references and the state whether a listener is currently added or not. It provides + * an {@link org.codefx.libfx.listener.handle.ListenerHandle#attach() attach()} and a + * {@link org.codefx.libfx.listener.handle.ListenerHandle#detach() detach} method which add or remove the listener. + * Redundant calls (i.e. attaching when the listener is already added) are no-ops. + *

+ * All features of LibFX which deal with listeners are aware of {@code ListenerHandle}s and respective methods + * will return them. For observable classes included in the JDK, the factory + * {@link org.codefx.libfx.listener.handle.ListenerHandles ListenerHandles} provides methods to easily create a handle. + * + * @see org.codefx.libfx.listener.handle.ListenerHandle ListenerHandle + * @see org.codefx.libfx.listener.handle.ListenerHandles ListenerHandles + */ +package org.codefx.libfx.listener.handle; \ No newline at end of file diff --git a/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservable.java b/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservable.java index a96d512..da3a497 100644 --- a/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservable.java +++ b/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservable.java @@ -9,8 +9,8 @@ import javafx.beans.Observable; import javafx.beans.value.ObservableValue; -import org.codefx.libfx.nesting.listener.NestedInvalidationListener; import org.codefx.libfx.nesting.listener.NestedInvalidationListenerBuilder; +import org.codefx.libfx.nesting.listener.NestedInvalidationListenerHandle; /** * A superclass for builders for all kinds of nested functionality. Holds the nesting hierarchy (outer observable and @@ -170,14 +170,14 @@ private void fillNestingConstructionKit(NestingConstructionKit kit) { * * @param listener * the added {@link InvalidationListener} - * @return the {@link NestedInvalidationListener} which can be used to check the nesting's state + * @return the {@link NestedInvalidationListenerHandle} which can be used to check the nesting's state */ - public NestedInvalidationListener addListener(InvalidationListener listener) { + public NestedInvalidationListenerHandle addListener(InvalidationListener listener) { Nesting nesting = buildNesting(); return NestedInvalidationListenerBuilder .forNesting(nesting) .withListener(listener) - .build(); + .buildAttached(); } //#end LISTENERS @@ -217,7 +217,7 @@ public NestingConstructionKit() { //#end CONSTRUCTOR - // #region PROPERTY ACCESS + // #region ACCESSORS /** * @return the outer {@link ObservableValue} @@ -243,7 +243,7 @@ public List getNestingSteps() { return nestingSteps; } - //#end PROPERTY ACCESS + //#end ACCESSORS } diff --git a/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservableValue.java b/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservableValue.java index 5b6f056..b6e12f7 100644 --- a/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservableValue.java +++ b/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservableValue.java @@ -4,7 +4,7 @@ import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; -import org.codefx.libfx.nesting.listener.NestedChangeListener; +import org.codefx.libfx.nesting.listener.NestedChangeListenerHandle; import org.codefx.libfx.nesting.listener.NestedChangeListenerBuilder; /** @@ -55,14 +55,14 @@ protected

AbstractNestingBuilderOnObservableValue( * * @param listener * the added {@link ChangeListener} - * @return the {@link NestedChangeListener} which can be used to check the nesting's state + * @return the {@link NestedChangeListenerHandle} which can be used to check the nesting's state */ - public NestedChangeListener addListener(ChangeListener listener) { + public NestedChangeListenerHandle addListener(ChangeListener listener) { Nesting nesting = buildNesting(); return NestedChangeListenerBuilder .forNesting(nesting) .withListener(listener) - .build(); + .buildAttached(); } //#end LISTENERS diff --git a/src/main/java/org/codefx/libfx/nesting/DeepNesting.java b/src/main/java/org/codefx/libfx/nesting/DeepNesting.java index 2eea79a..453e93b 100644 --- a/src/main/java/org/codefx/libfx/nesting/DeepNesting.java +++ b/src/main/java/org/codefx/libfx/nesting/DeepNesting.java @@ -182,7 +182,7 @@ private void updateNestingFromLevel(int startLevel) { new NestingUpdater(startLevel).update(); } - // #region PROPERTY ACCESS + // #region ACCESSORS /** * {@inheritDoc} @@ -192,7 +192,7 @@ public ReadOnlyProperty> innerObservableProperty() { return inner; } - //#end PROPERTY ACCESS + //#end ACCESSORS // #region PRIVATE CLASSES diff --git a/src/main/java/org/codefx/libfx/nesting/NestingObserver.java b/src/main/java/org/codefx/libfx/nesting/NestingObserver.java index c599e24..d06d53c 100644 --- a/src/main/java/org/codefx/libfx/nesting/NestingObserver.java +++ b/src/main/java/org/codefx/libfx/nesting/NestingObserver.java @@ -213,9 +213,9 @@ public NestingObserverBuilder whenInnerObservableChanges(BiConsumer(this); + @SuppressWarnings("unused") + NestingObserver observer = new NestingObserver(this); } } diff --git a/src/main/java/org/codefx/libfx/nesting/Nestings.java b/src/main/java/org/codefx/libfx/nesting/Nestings.java index 7637ae7..59e4009 100644 --- a/src/main/java/org/codefx/libfx/nesting/Nestings.java +++ b/src/main/java/org/codefx/libfx/nesting/Nestings.java @@ -6,8 +6,8 @@ import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; -import org.codefx.libfx.nesting.listener.NestedChangeListener; -import org.codefx.libfx.nesting.listener.NestedInvalidationListener; +import org.codefx.libfx.nesting.listener.NestedChangeListenerHandle; +import org.codefx.libfx.nesting.listener.NestedInvalidationListenerHandle; import org.codefx.libfx.nesting.property.NestedDoubleProperty; import org.codefx.libfx.nesting.property.NestedProperty; @@ -51,8 +51,8 @@ * * @see Nesting * @see NestedProperty - * @see NestedChangeListener - * @see NestedInvalidationListener + * @see NestedChangeListenerHandle + * @see NestedInvalidationListenerHandle */ public class Nestings { diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListener.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListener.java deleted file mode 100644 index 3f0a9e1..0000000 --- a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListener.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.codefx.libfx.nesting.listener; - -import java.util.Objects; - -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.ReadOnlyBooleanProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; - -import org.codefx.libfx.nesting.Nested; -import org.codefx.libfx.nesting.Nesting; -import org.codefx.libfx.nesting.NestingObserver; -import org.codefx.libfx.nesting.property.NestedProperty; - -/** - *

- * Contains a {@link ChangeListener} which is connected to a {@link Nesting}. Simply put, the listener is always added - * to the nesting's inner observable (more precisely, it is added to the {@link ObservableValue} instance contained in - * the optional value held by the nesting's {@link Nesting#innerObservableProperty() innerObservable} property). - *

Inner Observable's Value Changes

The listener is added to the nesting's inner observable. So when that - * observable's value changes, the listener is called as usual. - *

Inner Observable Is Replaced

When the nesting's inner observable is replaced by another, the listener is - * removed from the old and added to the new observable. If one of them is missing, the affected removal or add is not - * performed, which means the listener might not be added to any observable. - *

- * Note that if the observable is replaced, the listener is not called! If this is the desired behavior, a - * listener has to be added to a {@link NestedProperty}. - * - * @param - * the type of the value wrapped by the {@link ObservableValue} - */ -public class NestedChangeListener implements Nested { - - // #region PROPERTIES - - /** - * The property indicating whether the nesting's inner observable is currently present, i.e. not null. - */ - private final BooleanProperty innerObservablePresent; - - //#end PROPERTIES - - // #region CONSTUCTION - - /** - * Creates a new {@link NestedChangeListener} which adds the specified listener to the specified nesting's inner - * observable. - * - * @param nesting - * the {@link Nesting} to which the listener is added - * @param listener - * the {@link ChangeListener} which is added to the nesting's {@link Nesting#innerObservableProperty() - * innerObservable} - */ - NestedChangeListener(Nesting> nesting, ChangeListener listener) { - Objects.requireNonNull(nesting, "The argument 'nesting' must not be null."); - Objects.requireNonNull(listener, "The argument 'listener' must not be null."); - - this.innerObservablePresent = new SimpleBooleanProperty(this, "innerObservablePresent"); - - NestingObserver - .forNesting(nesting) - .withOldInnerObservable(oldInnerObservable -> oldInnerObservable.removeListener(listener)) - .withNewInnerObservable(newInnerObservable -> newInnerObservable.addListener(listener)) - .whenInnerObservableChanges( - (Boolean any, Boolean newInnerObservablePresent) - -> innerObservablePresent.set(newInnerObservablePresent)) - .observe(); - } - - //#end CONSTUCTION - - // #region IMPLEMENTATION OF 'Nested' - - @Override - public ReadOnlyBooleanProperty innerObservablePresentProperty() { - return innerObservablePresent; - } - - @Override - public boolean isInnerObservablePresent() { - return innerObservablePresent.get(); - } - - //#end IMPLEMENTATION OF 'NestedProperty' - -} diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilder.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilder.java index b090c8f..edabc99 100644 --- a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilder.java +++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilder.java @@ -8,7 +8,7 @@ import org.codefx.libfx.nesting.Nesting; /** - * A builder for a {@link NestedChangeListener}. + * A builder for a {@link NestedChangeListenerHandle}. * * @param * the type of the value wrapped by the {@link ObservableValue} @@ -79,7 +79,7 @@ public static > NestedChangeListenerBuilder listener) { Objects.requireNonNull(listener, "The argument 'listener' must not be null."); @@ -93,7 +93,8 @@ public Buildable withListener(ChangeListener listener) { // #region PRIVATE CLASSES /** - * A subtype of {@link NestedChangeListenerBuilder} which can actually build a listener with {@link #build()}. + * A subtype of {@link NestedChangeListenerBuilder} which can actually build a listener with + * {@link #buildAttached()}. */ public class Buildable extends NestedChangeListenerBuilder { @@ -113,17 +114,38 @@ private Buildable(NestedChangeListenerBuilder builder) { } /** - * Builds a nested change listener. This method can only be called once as the same {@link ChangeListener} - * should not be added more than once to the same {@link Nesting}. + * Builds and {@link NestedChangeListenerHandle#attach() attaches} a nested change listener and returns the + * handle for it. + *

+ * This method can only be called once as the same {@link ChangeListener} should not be added more than once to + * the same {@link Nesting}. * - * @return a new instance of {@link NestedChangeListener} + * @return a new instance of {@link NestedChangeListenerHandle}; initially attached + * @see #buildDetached() */ - public NestedChangeListener build() { + public NestedChangeListenerHandle buildAttached() { + NestedChangeListenerHandle listenerHandle = buildDetached(); + listenerHandle.attach(); + return listenerHandle; + } + + /** + * Builds a nested change listener and returns the handle for it. + *

+ * Note that the listener is not yet {@link NestedChangeListenerHandle#attach() attached}! + *

+ * This method can only be called once as the same {@link ChangeListener} should not be added more than once to + * the same {@link Nesting}. + * + * @return a new instance of {@link NestedChangeListenerHandle}; initially detached + * @see #buildAttached() + */ + public NestedChangeListenerHandle buildDetached() { if (built) - throw new IllegalStateException("This builder can only build one 'NestedChangeListener'."); + throw new IllegalStateException("This builder can only build one 'NestedChangeListenerHandle'."); built = true; - return new NestedChangeListener(nesting, listener); + return new NestedChangeListenerHandle(nesting, listener); } } diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandle.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandle.java new file mode 100644 index 0000000..e801e6b --- /dev/null +++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandle.java @@ -0,0 +1,145 @@ +package org.codefx.libfx.nesting.listener; + +import java.util.Objects; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +import org.codefx.libfx.nesting.Nesting; +import org.codefx.libfx.nesting.NestingObserver; +import org.codefx.libfx.nesting.property.NestedProperty; + +/** + * Contains a {@link ChangeListener} which is connected to a {@link Nesting}. Simply put, the listener is always added + * to the nesting's inner observable (more precisely, it is added to the {@link ObservableValue} instance contained in + * the optional value held by the nesting's {@link Nesting#innerObservableProperty() innerObservable} property).

+ * Inner Observable's Value Changes

The listener is added to the nesting's inner observable. So when that + * observable's value changes, the listener is called as usual.

Inner Observable Is Replaced

When the nesting's + * inner observable is replaced by another, the listener is removed from the old and added to the new observable. If one + * of them is missing, the affected removal or add is not performed, which means the listener might not be added to any + * observable. + *

+ * Note that if the observable is replaced, the listener is not called! If this is the desired behavior, a + * listener has to be added to a {@link NestedProperty}. + * + * @param + * the type of the value wrapped by the {@link ObservableValue} + */ +public class NestedChangeListenerHandle implements NestedListenerHandle { + + // #region PROPERTIES + + /** + * The {@link Nesting} to whose inner observable the {@link #listener} is attached. + */ + private final Nesting> nesting; + + /** + * The property indicating whether the nesting's inner observable is currently present, i.e. not null. + */ + private final BooleanProperty innerObservablePresent; + + /** + * The {@link ChangeListener} which is added to the {@link #nesting}'s inner observable. + */ + private final ChangeListener listener; + + /** + * Indicates whether the {@link #listener} is currently attached to the {@link #nesting}'s inner observable. + */ + private boolean attached; + + //#end PROPERTIES + + // #region CONSTUCTION + + /** + * Creates a new {@link NestedChangeListenerHandle} which can {@link #attach() attach} the specified listener to the + * specified nesting's inner observable. + *

+ * The listener is initially detached. + * + * @param nesting + * the {@link Nesting} to which the listener is added + * @param listener + * the {@link ChangeListener} which is added to the nesting's {@link Nesting#innerObservableProperty() + * innerObservable} + */ + NestedChangeListenerHandle(Nesting> nesting, ChangeListener listener) { + Objects.requireNonNull(nesting, "The argument 'nesting' must not be null."); + Objects.requireNonNull(listener, "The argument 'listener' must not be null."); + + this.nesting = nesting; + this.innerObservablePresent = new SimpleBooleanProperty(this, "innerObservablePresent"); + this.listener = listener; + + NestingObserver + .forNesting(nesting) + .withOldInnerObservable(this::remove) + .withNewInnerObservable(this::addIfAttached) + .whenInnerObservableChanges( + (any, newInnerObservablePresent) -> innerObservablePresent.set(newInnerObservablePresent)) + .observe(); + } + + //#end CONSTUCTION + + // #region ADD & REMOVE + + /** + * Adds the {@link #listener} to the specified observable, when indicated by {@link #attached}. + * + * @param observable + * the {@link ObservableValue} to which the listener will be added + */ + private void addIfAttached(ObservableValue observable) { + if (attached) + observable.addListener(listener); + } + + /** + * Removes the {@link #listener} from the specified observable. + * + * @param observable + * the {@link ObservableValue} from which the listener will be removed. + */ + private void remove(ObservableValue observable) { + observable.removeListener(listener); + } + + // #end ADD & REMOVE + + // #region IMPLEMENTATION OF 'NestedListenerHandle' + + @Override + public void attach() { + if (!attached) { + attached = true; + nesting.innerObservableProperty().getValue().ifPresent(this::addIfAttached); + } + } + + @Override + public void detach() { + if (attached) { + attached = false; + nesting.innerObservableProperty().getValue().ifPresent(this::remove); + } + } + + @Override + public ReadOnlyBooleanProperty innerObservablePresentProperty() { + return innerObservablePresent; + } + + @Override + public boolean isInnerObservablePresent() { + return innerObservablePresent.get(); + } + + //#end IMPLEMENTATION OF 'NestedListenerHandle' + +} diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListener.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListener.java deleted file mode 100644 index e10d396..0000000 --- a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListener.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.codefx.libfx.nesting.listener; - -import java.util.Objects; - -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.ReadOnlyBooleanProperty; -import javafx.beans.property.SimpleBooleanProperty; - -import org.codefx.libfx.nesting.Nested; -import org.codefx.libfx.nesting.Nesting; -import org.codefx.libfx.nesting.NestingObserver; -import org.codefx.libfx.nesting.property.NestedProperty; - -/** - *

- * Contains an {@link InvalidationListener} which is connected to a {@link Nesting}. Simply put, the listener is always - * added to the nesting's inner observable (more precisely, it is added to the {@link Observable} instance contained in - * the optional value held by the nesting's {@link Nesting#innerObservableProperty() innerObservable} property). - *

Inner Observable's Value is Invalidated

The listener is added to the nesting's inner observable. So when - * that observable's value is invalidated, the listener is called as usual. - *

Inner Observable Is Replaced

When the nesting's inner observable is replaced by another, the listener is - * removed from the old and added to the new observable. If one of them is missing, the affected removal or add is not - * performed, which means the listener might not be added to any observable. - *

- * Note that if the observable is replaced, the listener is not called! If this is the desired behavior, a - * listener has to be added to a {@link NestedProperty}. - */ -public class NestedInvalidationListener implements Nested { - - // #region PROPERTIES - - /** - * The property indicating whether the nesting's inner observable is currently present, i.e. not null. - */ - private final BooleanProperty innerObservablePresent; - - //#end PROPERTIES - - // #region CONSTUCTION - - /** - * Creates a new {@link NestedInvalidationListener} which adds the specified listener to the specified nesting's - * inner observable. - * - * @param nesting - * the {@link Nesting} to which the listener is added - * @param listener - * the {@link InvalidationListener} which is added to the nesting's - * {@link Nesting#innerObservableProperty() innerObservable} - */ - NestedInvalidationListener(Nesting nesting, InvalidationListener listener) { - Objects.requireNonNull(nesting, "The argument 'nesting' must not be null."); - Objects.requireNonNull(listener, "The argument 'listener' must not be null."); - - this.innerObservablePresent = new SimpleBooleanProperty(this, "innerObservablePresent"); - - NestingObserver - .forNesting(nesting) - .withOldInnerObservable(oldInnerObservable -> oldInnerObservable.removeListener(listener)) - .withNewInnerObservable(newInnerObservable -> newInnerObservable.addListener(listener)) - .whenInnerObservableChanges( - (Boolean any, Boolean newInnerObservablePresent) - -> innerObservablePresent.set(newInnerObservablePresent)) - .observe(); - } - - //#end CONSTUCTION - - // #region IMPLEMENTATION OF 'Nested' - - @Override - public ReadOnlyBooleanProperty innerObservablePresentProperty() { - return innerObservablePresent; - } - - @Override - public boolean isInnerObservablePresent() { - return innerObservablePresent.get(); - } - - //#end IMPLEMENTATION OF 'NestedProperty' - -} diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilder.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilder.java index 8dc426d..9ece5a3 100644 --- a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilder.java +++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilder.java @@ -7,7 +7,7 @@ import org.codefx.libfx.nesting.Nesting; /** - * A builder for a {@link NestedInvalidationListener}. + * A builder for a {@link NestedInvalidationListenerHandle}. */ public class NestedInvalidationListenerBuilder { @@ -71,7 +71,7 @@ public static NestedInvalidationListenerBuilder forNesting(Nesting nesting) { * * @param listener * the {@link InvalidationListener} which will be added to the nesting's inner observable - * @return a {@link NestedInvalidationListenerBuilder} which provides a {@link Buildable#build() build}-method + * @return a {@link NestedInvalidationListenerBuilder} which provides a {@link Buildable#buildAttached() build}-method */ public Buildable withListener(InvalidationListener listener) { Objects.requireNonNull(listener, "The argument 'listener' must not be null."); @@ -85,7 +85,7 @@ public Buildable withListener(InvalidationListener listener) { // #region PRIVATE CLASSES /** - * A subtype of {@link NestedInvalidationListenerBuilder} which can actually build a listener with {@link #build()}. + * A subtype of {@link NestedInvalidationListenerBuilder} which can actually build a listener with {@link #buildAttached()}. */ public class Buildable extends NestedInvalidationListenerBuilder { @@ -105,17 +105,38 @@ private Buildable(NestedInvalidationListenerBuilder builder) { } /** - * Builds a nested invalidation listener. This method can only be called once as the same - * {@link InvalidationListener} should not be added more than once to the same {@link Nesting}. + * Builds and {@link NestedInvalidationListenerHandle#attach() attaches} a nested invalidation listener and + * returns the handle for it. + *

+ * This method can only be called once as the same {@link InvalidationListener} should not be added more than + * once to the same {@link Nesting}. * - * @return a new instance of {@link NestedChangeListener} + * @return a new instance of {@link NestedInvalidationListenerHandle}; initially attached + * @see #buildDetached() */ - public NestedInvalidationListener build() { + public NestedInvalidationListenerHandle buildAttached() { + NestedInvalidationListenerHandle listenerHandle = buildDetached(); + listenerHandle.attach(); + return listenerHandle; + } + + /** + * Builds a nested invalidation listener and returns the handle for it. + *

+ * Note that the listener is not yet {@link NestedInvalidationListenerHandle#attach() attached}! + *

+ * This method can only be called once as the same {@link InvalidationListener} should not be added more than + * once to the same {@link Nesting}. + * + * @return a new instance of {@link NestedInvalidationListenerHandle}; initially detached + * @see #buildAttached() + */ + public NestedInvalidationListenerHandle buildDetached() { if (built) - throw new IllegalStateException("This builder can only build one 'NestedInvalidationListener'."); + throw new IllegalStateException("This builder can only build one 'NestedInvalidationListenerHandle'."); built = true; - return new NestedInvalidationListener(nesting, listener); + return new NestedInvalidationListenerHandle(nesting, listener); } } diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandle.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandle.java new file mode 100644 index 0000000..3a803da --- /dev/null +++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandle.java @@ -0,0 +1,140 @@ +package org.codefx.libfx.nesting.listener; + +import java.util.Objects; + +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; + +import org.codefx.libfx.nesting.Nesting; +import org.codefx.libfx.nesting.NestingObserver; +import org.codefx.libfx.nesting.property.NestedProperty; + +/** + * Contains an {@link InvalidationListener} which is connected to a {@link Nesting}. Simply put, the listener is always + * added to the nesting's inner observable (more precisely, it is added to the {@link Observable} instance contained in + * the optional value held by the nesting's {@link Nesting#innerObservableProperty() innerObservable} property).

+ * Inner Observable's Value is Invalidated

The listener is added to the nesting's inner observable. So when that + * observable's value is invalidated, the listener is called as usual.

Inner Observable Is Replaced

When the + * nesting's inner observable is replaced by another, the listener is removed from the old and added to the new + * observable. If one of them is missing, the affected removal or add is not performed, which means the listener might + * not be added to any observable. + *

+ * Note that if the observable is replaced, the listener is not called! If this is the desired behavior, a + * listener has to be added to a {@link NestedProperty}. + */ +public class NestedInvalidationListenerHandle implements NestedListenerHandle { + + // #region PROPERTIES + + /** + * The {@link Nesting} to whose inner observable the {@link #listener} is attached. + */ + private final Nesting nesting; + + /** + * The property indicating whether the nesting's inner observable is currently present, i.e. not null. + */ + private final BooleanProperty innerObservablePresent; + + /** + * The {@link InvalidationListener} which is added to the {@link #nesting}'s inner observable. + */ + private final InvalidationListener listener; + + /** + * Indicates whether the {@link #listener} is currently attached to the {@link #nesting}'s inner observable. + */ + private boolean attached; + + //#end PROPERTIES + + // #region CONSTUCTION + + /** + * Creates a new {@link NestedInvalidationListenerHandle} which adds the specified listener to the specified + * nesting's inner observable. + * + * @param nesting + * the {@link Nesting} to which the listener is added + * @param listener + * the {@link InvalidationListener} which is added to the nesting's + * {@link Nesting#innerObservableProperty() innerObservable} + */ + NestedInvalidationListenerHandle(Nesting nesting, InvalidationListener listener) { + Objects.requireNonNull(nesting, "The argument 'nesting' must not be null."); + Objects.requireNonNull(listener, "The argument 'listener' must not be null."); + + this.nesting = nesting; + this.innerObservablePresent = new SimpleBooleanProperty(this, "innerObservablePresent"); + this.listener = listener; + + NestingObserver + .forNesting(nesting) + .withOldInnerObservable(this::remove) + .withNewInnerObservable(this::addIfAttached) + .whenInnerObservableChanges( + (any, newInnerObservablePresent) -> innerObservablePresent.set(newInnerObservablePresent)) + .observe(); + } + + //#end CONSTUCTION + + // #region ADD & REMOVE + + /** + * Adds the {@link #listener} to the specified observable, when indicated by {@link #attached}. + * + * @param observable + * the {@link Observable} to which the listener will be added + */ + private void addIfAttached(Observable observable) { + if (attached) + observable.addListener(listener); + } + + /** + * Removes the {@link #listener} from the specified observable. + * + * @param observable + * the {@link Observable} from which the listener will be removed. + */ + private void remove(Observable observable) { + observable.removeListener(listener); + } + + // #end ADD & REMOVE + + // #region IMPLEMENTATION OF 'NestedListenerHandle' + + @Override + public void attach() { + if (!attached) { + attached = true; + nesting.innerObservableProperty().getValue().ifPresent(this::addIfAttached); + } + } + + @Override + public void detach() { + if (attached) { + attached = false; + nesting.innerObservableProperty().getValue().ifPresent(this::remove); + } + } + + @Override + public ReadOnlyBooleanProperty innerObservablePresentProperty() { + return innerObservablePresent; + } + + @Override + public boolean isInnerObservablePresent() { + return innerObservablePresent.get(); + } + + //#end IMPLEMENTATION OF 'NestedListenerHandle' + +} diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedListenerHandle.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedListenerHandle.java new file mode 100644 index 0000000..d92ecb5 --- /dev/null +++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedListenerHandle.java @@ -0,0 +1,15 @@ +package org.codefx.libfx.nesting.listener; + +import org.codefx.libfx.listener.handle.ListenerHandle; +import org.codefx.libfx.nesting.Nested; + +/** + * A {@link ListenerHandle} for a listener added to the inner observable of a {@link org.codefx.libfx.nesting.Nesting + * Nesting}. + * + * @see Nested + * @see ListenerHandle + */ +public interface NestedListenerHandle extends Nested, ListenerHandle { + // no additional methods defined +} diff --git a/src/main/java/org/codefx/libfx/nesting/package-info.java b/src/main/java/org/codefx/libfx/nesting/package-info.java index 2deb9d3..7edbf5f 100644 --- a/src/main/java/org/codefx/libfx/nesting/package-info.java +++ b/src/main/java/org/codefx/libfx/nesting/package-info.java @@ -15,15 +15,15 @@ *

Nested Listeners

A {@code Nesting} can also be used to add listeners to its inner observable. These * listeners are moved from one observable to the next as they are replaced. *

- * See the comments on {@link org.codefx.libfx.nesting.listener.NestedChangeListener NestedChangeListener} and - * {@link org.codefx.libfx.nesting.listener.NestedInvalidationListener NestedInvalidationListener} for details. + * See the comments on {@link org.codefx.libfx.nesting.listener.NestedChangeListenerHandle NestedChangeListener} and + * {@link org.codefx.libfx.nesting.listener.NestedInvalidationListenerHandle NestedInvalidationListener} for details. *

Builders

Instances of the classes described above can be build by starting with the methods in * {@link org.codefx.libfx.nesting.Nestings Nestings}. * * @see org.codefx.libfx.nesting.Nesting Nesting * @see org.codefx.libfx.nesting.property.NestedProperty NestedProperty - * @see org.codefx.libfx.nesting.listener.NestedChangeListener NestedChangeListener - * @see org.codefx.libfx.nesting.listener.NestedInvalidationListener NestedInvalidationListener + * @see org.codefx.libfx.nesting.listener.NestedChangeListenerHandle NestedChangeListener + * @see org.codefx.libfx.nesting.listener.NestedInvalidationListenerHandle NestedInvalidationListener */ package org.codefx.libfx.nesting; diff --git a/src/main/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyBuilder.java b/src/main/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyBuilder.java index 6dfbdf6..b68407a 100644 --- a/src/main/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyBuilder.java +++ b/src/main/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyBuilder.java @@ -63,7 +63,7 @@ protected AbstractNestedPropertyBuilder(Nesting nesting) { //#end ABSTRACT METHODS - // #region PROPERTY ACCESS + // #region ACCESSORS /** * @return the nesting which will be used for all nested properties @@ -133,6 +133,6 @@ public AbstractNestedPropertyBuilder setName(String name) { return this; } - //#end PROPERTY ACCESS + //#end ACCESSORS } diff --git a/src/main/java/org/codefx/libfx/serialization/SerializableOptional.java b/src/main/java/org/codefx/libfx/serialization/SerializableOptional.java new file mode 100644 index 0000000..c93aba5 --- /dev/null +++ b/src/main/java/org/codefx/libfx/serialization/SerializableOptional.java @@ -0,0 +1,235 @@ +package org.codefx.libfx.serialization; + +import java.io.IOException; +import java.io.InvalidObjectException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; + +/** + * Convenience class to wrap an {@link Optional} for serialization. Instances of this class are immutable. + *

+ * Note that it does not provide any of the methods {@code Optional} has as its only goal is to enable serialization. + * But it holds a reference to the {@code Optional} which was used to create it (can be accessed with + * {@link #asOptional()}). This {@code Optional} instance is of course reconstructed on deserialization, so it will not + * be the same as the one specified for its creation. + *

+ * The class can be used as an argument or return type for serialization-based RPC technologies like RMI. + *

+ * There are three ways to use this class to serialize instances which have an optional field. + *

+ *

Transform On Serialization

+ *

+ * The field can be declared as {@code transient Optional optionalField}, which will exclude it from serialization. + *

+ * The class then needs to implement custom (de)serialization methods {@code writeObject} and {@code readObject}. They + * must transform the {@code optionalField} to a {@code SerializableOptional} when writing the object and after reading + * such an instance transform it back to an {@code Optional}. + *

+ *

Example

+ * + *
+ * private void writeObject(ObjectOutputStream out) throws IOException {
+ * 	out.defaultWriteObject();
+ * 	out.writeObject(
+ * 		SerializableOptional.fromOptional(optionalField));
+ * }
+ *
+ * private void readObject(ObjectInputStream in)
+ * 	throws IOException, ClassNotFoundException {
+ *
+ * 	in.defaultReadObject();
+ * 	optionalField =
+ * 		((SerializableOptional<T>) in.readObject()).toOptional();
+ * }
+ * 
+ * + *

Transform On Replace

+ *

+ * If the class is serialized using the Serialization Proxy Pattern (see Effective Java, 2nd Edition by Joshua + * Bloch, Item 78), the proxy can have an instance of {@link SerializableOptional} to clearly denote the field as being + * optional. + *

+ * In this case, the proxy needs to transform the {@code Optional} to {@code SerializableOptional} in its constructor + * (using {@link SerializableOptional#fromOptional(Optional)}) and the other way in {@code readResolve()} (with + * {@link SerializableOptional#asOptional()}). + *

+ *

Transform On Access

+ *

+ * The field can be declared as {@code SerializableOptional optionalField}. This will include it in the + * (de)serialization process so it does not need to be customized. + *

+ * But methods interacting with the field need to get an {@code Optional} instead. This can easily be done by writing + * the accessor methods such that they transform the field on each access. + *

+ * Note that {@link #asOptional()} simply returns the {@code Optional} which with this instance was created so no + * constructor needs to be invoked. + *

+ *

Example

+ *

+ * Note that it is rarely useful to expose an optional field via accessor methods. Hence the following are private and + * for use inside the class. + * + *

+ * private Optional<T> getOptionalField() {
+ * 	return optionalField.asOptional();
+ * }
+ *
+ * private void setOptionalField(Optional<T> optionalField) {
+ * 	this.optionalField = SerializableOptional.fromOptional(optionalField);
+ * }
+ * 
+ * + * @param + * the type of the wrapped value + */ +public final class SerializableOptional implements Serializable { + + // FIELDS + + @SuppressWarnings("javadoc") + private static final long serialVersionUID = -652697447004597911L; + + /** + * The wrapped {@link Optional}. Note that this field is transient so it will not be (de)serializd automatically. + */ + private final Optional optional; + + // CONSTRUCTION AND TRANSFORMATION + + /** + * Creates a new instance. Private to enforce use of {@link #fromOptional(Optional)}. + * + * @param optional + * the wrapped {@link Optional} + */ + private SerializableOptional(Optional optional) { + Objects.requireNonNull(optional, "The argument 'optional' must not be null."); + this.optional = optional; + } + + /** + * Creates a serializable optional from the specified optional. + * + * @param + * the type of the wrapped value + * @param optional + * the {@link Optional} from which the serializable wrapper will be created + * @return a {@link SerializableOptional} which wraps the specified optional + */ + public static SerializableOptional fromOptional(Optional optional) { + return new SerializableOptional<>(optional); + } + + /** + * Creates a serializable optional which wraps an empty optional. + * + * @param + * the type of the non-existent value + * @return a {@link SerializableOptional} which wraps an {@link Optional#empty() empty} {@link Optional} + * @see Optional#of(Object) + */ + public static SerializableOptional empty() { + return new SerializableOptional<>(Optional.empty()); + } + + /** + * Creates a serializable optional for the specified value by wrapping it in an {@link Optional}. + * + * @param + * the type of the wrapped value + * @param value + * the value which will be contained in the wrapped {@link Optional}; must be non-null + * @return a {@link SerializableOptional} which wraps the an optional for the specified value + * @throws NullPointerException + * if {@code value} is null + * @see Optional#of(Object) + */ + public static SerializableOptional of(T value) throws NullPointerException { + return new SerializableOptional<>(Optional.of(value)); + } + + /** + * Creates a serializable optional for the specified value by wrapping it in an {@link Optional}. + * + * @param + * the type of the wrapped value + * @param value + * the value which will be contained in the wrapped {@link Optional}; may be null + * @return a {@link SerializableOptional} which wraps the an optional for the specified value + * @see Optional#ofNullable(Object) + */ + public static SerializableOptional ofNullable(T value) { + return new SerializableOptional<>(Optional.ofNullable(value)); + } + + /** + * Returns the {@code Optional} instance with which this instance was created. + * + * @return this instance as an {@link Optional} + */ + public Optional asOptional() { + return optional; + } + + // SERIALIZATION + + /** + * Implements the "write part" of the Serialization Proxy Pattern by creating a proxy which will be serialized + * instead of this instance. + * + * @return the {@link SerializationProxy} + */ + private Object writeReplace() { + return new SerializationProxy<>(this); + } + + /** + * Since this class should never be deserialized directly, this method should not be called. If it is, someone + * purposely created a serialization of this class to bypass that mechanism, so throw an exception. + */ + @SuppressWarnings({ "static-method", "javadoc", "unused" }) + private void readObject(ObjectInputStream in) throws IOException { + throw new InvalidObjectException("Serialization proxy expected."); + } + + /** + * The proxy which is serialized instead of an instance of {@link SerializableOptional}. + * + * @param + * the type of the wrapped value + */ + private static class SerializationProxy implements Serializable { + + @SuppressWarnings("javadoc") + private static final long serialVersionUID = -1326520485869949065L; + + /** + * This value is (de)serialized. It comes from the {@link Optional} wrapped by the {@code SerializableOptional}. + */ + private final T value; + + /** + * Creates a new serialization proxy for the specified serializable optional. + * + * @param serializableOptional + * the {@link SerializableOptional} for which this proxy is created + */ + public SerializationProxy(SerializableOptional serializableOptional) { + value = serializableOptional.asOptional().orElse(null); + } + + /** + * Implements the "read part" of the Serialization Proxy Pattern by creating a serializable optional for the + * deserialized value. + * + * @return a {@link SerializableOptional} + */ + private Object readResolve() { + return SerializableOptional.ofNullable(value); + } + + } + +} diff --git a/src/test/java/org/codefx/libfx/AllTests.java b/src/test/java/org/codefx/libfx/AllTests.java deleted file mode 100644 index 3e64809..0000000 --- a/src/test/java/org/codefx/libfx/AllTests.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.codefx.libfx; - -import org.codefx.libfx.nesting._AllNestingTests; -import org.junit.runner.RunWith; -import org.junit.runners.Suite; -import org.junit.runners.Suite.SuiteClasses; - -/** - * Runs all tests. - */ -@RunWith(Suite.class) -@SuiteClasses({ - _AllNestingTests.class, -}) -public class AllTests { - // no body needed -} diff --git a/src/test/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhenTest.java b/src/test/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhenTest.java new file mode 100644 index 0000000..b27b00d --- /dev/null +++ b/src/test/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhenTest.java @@ -0,0 +1,219 @@ +package org.codefx.libfx.concurrent.when; + +import static org.junit.Assert.assertEquals; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; + +import org.junit.Before; +import org.junit.Test; + +/** + * Tests the class {@link ExecuteAlwaysWhen}. + */ +public class ExecuteAlwaysWhenTest { + + // #region FIELDS & INITIALIZATION + + /** + * The string which passes the {@link #ACTION_GATEWAY}. + */ + private static final String ACTION_STRING = "action!"; + + /** + * A string which does not pass the {@link #ACTION_GATEWAY}. + */ + private static final String NO_ACTION_STRING = "no action..."; + + /** + * The gateway which has to be passed for the action to be executed. + */ + private static final Predicate ACTION_GATEWAY = string -> Objects.equals(string, ACTION_STRING); + + /** + * The observable on which is acted. + */ + private Property observable; + + /** + * The action which is undertaken. Increases {@link #executedActionCount}. + */ + private Consumer action; + + /** + * Counts how many actions were executed. + */ + private AtomicInteger executedActionCount; + + /** + * Initializes the instances used to test. + */ + @Before + public void setUp() { + observable = new SimpleStringProperty(NO_ACTION_STRING); + executedActionCount = new AtomicInteger(0); + action = string -> executedActionCount.incrementAndGet(); + } + + // #end FIELDS & INITIALIZATION + + // #region SINGLE-THREADED TESTS + + /** + * Tests whether an {@link IllegalStateException} is thrown when {@link ExecuteAlwaysWhen#executeWhen() + * executeWhen()} is called for the second time. + */ + @Test(expected = IllegalStateException.class) + public void testThrowExceptionIfCallActTwice() { + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + execute.executeWhen(); + execute.executeWhen(); + } + + /** + * Tests whether no action is executed if the initial value does not pass the {@link #ACTION_GATEWAY}. + */ + @Test + public void testDoNotActIfInitialValueWrong() { + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + execute.executeWhen(); + + assertEquals(0, executedActionCount.get()); + } + + /** + * Tests whether the action is executed when the initial value passes the {@link #ACTION_GATEWAY}. + */ + @Test + public void testExecuteWhenWhenInitialValueCorrect() { + observable.setValue(ACTION_STRING); + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + execute.executeWhen(); + + assertEquals(1, executedActionCount.get()); + } + + /** + * Tests whether the action is executed repeatedly. + */ + @Test + public void testExecuteWhenRepeatedlyWhenInitialValueWasCorrect() { + observable.setValue(ACTION_STRING); + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + // this executes the action for the first time + execute.executeWhen(); + + // change the value and set the action string again; this must execute the action again + observable.setValue(NO_ACTION_STRING); + observable.setValue(ACTION_STRING); + + assertEquals(2, executedActionCount.get()); + } + + /** + * Tests whether the action is executed when the value is changed to one which passes the {@link #ACTION_GATEWAY} + * after waiting began. + */ + @Test + public void testExecuteWhenWhenCorrectValueIsObserved() { + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + execute.executeWhen(); + + observable.setValue(ACTION_STRING); + + assertEquals(1, executedActionCount.get()); + } + + /** + * Tests whether the action is executed repeatedly. + */ + @Test + public void testExecuteWhenRepeatedlyWhenCorrectValueWasObserved() { + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + execute.executeWhen(); + + // this executes the action for the first time + observable.setValue(ACTION_STRING); + + // change the value and set the action string again; this must execute the action again + observable.setValue(NO_ACTION_STRING); + observable.setValue(ACTION_STRING); + + assertEquals(2, executedActionCount.get()); + } + + /** + * Tests whether {@link ExecuteAlwaysWhen#cancel()} correctly prevents the execution of the action if it was not yet + * executed. + */ + @Test + public void testCancelAfterNoAction() { + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + execute.executeWhen(); + + // cancel and then set the value, which would lead to action execution + execute.cancel(); + observable.setValue(ACTION_STRING); + + assertEquals(0, executedActionCount.get()); + } + + /** + * Tests whether {@link ExecuteAlwaysWhen#cancel()} correctly prevents the execution of the action if the initial + * value was correct. + */ + @Test + public void testCancelAfterInitialValueWasCorrect() { + observable.setValue(ACTION_STRING); + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + // this executes the action for the first time + execute.executeWhen(); + + // cancel and then reset the value, which would lead to action execution + execute.cancel(); + observable.setValue(NO_ACTION_STRING); + observable.setValue(ACTION_STRING); + + assertEquals(1, executedActionCount.get()); + } + + /** + * Tests whether {@link ExecuteAlwaysWhen#cancel()} correctly prevents the execution of the action if the correct + * value was already observed. + */ + @Test + public void testCancelAfterCorrectValueWasObserved() { + ExecuteAlwaysWhen execute = new ExecuteAlwaysWhen<>(observable, ACTION_GATEWAY, action); + execute.executeWhen(); + // this executes the action for the first time + observable.setValue(ACTION_STRING); + + // cancel and then reset the value, which would lead to action execution + execute.cancel(); + observable.setValue(NO_ACTION_STRING); + observable.setValue(ACTION_STRING); + + assertEquals(1, executedActionCount.get()); + } + + // #end SINGLE-THREADED TESTS + + // #region MULTI-THREADED TESTS + + /* + * Unfortunately I could not come up with multi-threaded tests... :( The problem is that the only interesting part + * where threads interact is during the call to 'executeWhen'. So to check whether everything works as intended, it + * would be necessary to precisely count the number of actions executed during that call. Due to threading this + * seems impossible to do precisely; and because the time window in which the measurement would have to take place + * is so tiny (the method doesn't do much after all), I expect the margin of error to be so big to make any result + * meaningless. + */ + + // #end MULTI-THREADED TESTS + +} diff --git a/src/test/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhenTest.java b/src/test/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhenTest.java new file mode 100644 index 0000000..c1ae9a4 --- /dev/null +++ b/src/test/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhenTest.java @@ -0,0 +1,300 @@ +package org.codefx.libfx.concurrent.when; + +import static org.junit.Assert.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; + +import org.junit.Before; +import org.junit.Test; + +/** + * Tests the class {@link ExecuteOnceWhen}. + */ +public class ExecuteOnceWhenTest { + + // #region FIELDS & INITIALIZATION + + /** + * The string which passes the {@link #ACTION_CONDITION}. + */ + private static final String ACTION_STRING = "action!"; + + /** + * A string which does not pass the {@link #ACTION_CONDITION}. + */ + private static final String NO_ACTION_STRING = "no action..."; + + /** + * The condition which has to be passed for the action to be executed. + */ + private static final Predicate ACTION_CONDITION = string -> Objects.equals(string, ACTION_STRING); + + /** + * The observable on which the execution depends. + */ + private Property observable; + + /** + * The action which is undertaken. Increases {@link #executedActionCount}. + */ + private Consumer action; + + /** + * Counts how many actions were executed. + */ + private AtomicInteger executedActionCount; + + /** + * Initializes the instances used to test. + */ + @Before + public void setUp() { + observable = new SimpleStringProperty(NO_ACTION_STRING); + executedActionCount = new AtomicInteger(0); + action = string -> executedActionCount.incrementAndGet(); + } + + // #end FIELDS & INITIALIZATION + + // #region SINGLE-THREADED TESTS + + /** + * Tests whether an {@link IllegalStateException} is thrown when {@link ExecuteOnceWhen#executeWhen() executeWhen()} + * is called for the second time. + */ + @Test(expected = IllegalStateException.class) + public void testThrowExceptionIfCallActTwice() { + ExecuteOnceWhen execute = new ExecuteOnceWhen<>(observable, ACTION_CONDITION, action); + execute.executeWhen(); + execute.executeWhen(); + } + + /** + * Tests whether no action is executed if the initial value does not pass the {@link #ACTION_CONDITION}. + */ + @Test + public void testDoNotActIfInitialValueWrong() { + ExecuteOnceWhen execute = new ExecuteOnceWhen<>(observable, ACTION_CONDITION, action); + execute.executeWhen(); + + assertEquals(0, executedActionCount.get()); + } + + /** + * Tests whether the action is executed when the initial value passes the {@link #ACTION_CONDITION}. + */ + @Test + public void testExecuteWhenWhenInitialValueCorrect() { + observable.setValue(ACTION_STRING); + ExecuteOnceWhen execute = new ExecuteOnceWhen<>(observable, ACTION_CONDITION, action); + execute.executeWhen(); + + assertEquals(1, executedActionCount.get()); + } + + /** + * Tests whether the action is executed only once after the initial value already passed the + * {@link #ACTION_CONDITION} . + */ + @Test + public void testExecuteWhenOnlyOnceWhenInitialValueWasCorrect() { + observable.setValue(ACTION_STRING); + ExecuteOnceWhen execute = new ExecuteOnceWhen<>(observable, ACTION_CONDITION, action); + // this executes the action for the first time + execute.executeWhen(); + + // change the value and set the action string again; if this executes the action again, there is a bug + observable.setValue(NO_ACTION_STRING); + observable.setValue(ACTION_STRING); + + assertEquals(1, executedActionCount.get()); + } + + /** + * Tests whether the action is executed when the value is changed to one which passes the {@link #ACTION_CONDITION} + * after waiting began. + */ + @Test + public void testExecuteWhenWhenCorrectValueIsObserved() { + ExecuteOnceWhen execute = new ExecuteOnceWhen<>(observable, ACTION_CONDITION, action); + execute.executeWhen(); + + observable.setValue(ACTION_STRING); + + assertEquals(1, executedActionCount.get()); + } + + /** + * Tests whether the action is executed only once after some value already passed the {@link #ACTION_CONDITION}. + */ + @Test + public void testExecuteWhenOnlyOnceWhenCorrectValueWasObserved() { + ExecuteOnceWhen execute = new ExecuteOnceWhen<>(observable, ACTION_CONDITION, action); + execute.executeWhen(); + + // this executes the action for the first time + observable.setValue(ACTION_STRING); + + // change the value and set the action string again; if this executes the action again, there is a bug + observable.setValue(NO_ACTION_STRING); + observable.setValue(ACTION_STRING); + + assertEquals(1, executedActionCount.get()); + } + + /** + * Tests whether {@link ExecuteOnceWhen#cancel()} correctly prevents the execution of the action. + */ + @Test + public void testCancel() { + ExecuteOnceWhen execute = new ExecuteOnceWhen<>(observable, ACTION_CONDITION, action); + execute.executeWhen(); + + // cancel and then set the value, which would lead to action execution + execute.cancel(); + observable.setValue(ACTION_STRING); + + assertEquals(0, executedActionCount.get()); + } + + // #end SINGLE-THREADED TESTS + + // #region MULTI-THREADED TESTS + + /** + * Creates a number of threads which repeatedly change the {@link #observable}'s value and a number of threads which + * execute {@link #action} once when the correct value is set. The value setting threads behave randomly but will + * definitely set the correct value at least once. This means that the action must be executed exactly as often as + * acting threads exist. + *

+ * This is tested. + * + * @throws InterruptedException + * if waiting for the {@link CountDownLatch} fails + */ + @Test + public void testWithMultipleThreads() throws InterruptedException { + int nrOfActThreads = 4; + int nrOfValueThreads = 16; + int nrOfLoopsPerThread = (int) 1e5; + CountDownLatch latch = new CountDownLatch(nrOfValueThreads); + + createThreadsWhichActAndSetValues( + latch, nrOfActThreads, nrOfValueThreads, nrOfLoopsPerThread) + .forEach(thread -> thread.start()); + + latch.await(); + + assertEquals(nrOfActThreads, executedActionCount.get()); + } + + /** + * Creates threads where some repeatedly set a value on {@link #observable} (setting {@link #ACTION_STRING} approx. + * half of the time) and some execute {@link #action} when the correct value is set. + * + * @param latch + * the latch used to signal that the value threads are done + * @param nrOfActThreads + * number of threads which execute {@link #action}. + * @param nrOfValueThreads + * the number of created threads + * @param nrOfLoopsPerValueThread + * the number of times each thread sets a new value on {@link #observable} + * @return a {@link List} of {@link Thread}s which did not yet start + */ + private List createThreadsWhichActAndSetValues( + CountDownLatch latch, int nrOfActThreads, int nrOfValueThreads, int nrOfLoopsPerValueThread) { + + Random random = new Random(); + List threads = createThreadsWhichSetCorrectValueOften(latch, nrOfValueThreads, nrOfLoopsPerValueThread); + for (int i = 0; i < nrOfActThreads; i++) { + int randomIndex = random.nextInt(threads.size()); + Thread threadWhichActs = createThreadWhichActs(); + threadWhichActs.setName("ACT #" + i); + threads.add(randomIndex, threadWhichActs); + } + + return threads; + } + + /** + * Creates a thread which execute {@link #action} when the correct value is set to {@link #observable}. + * + * @return a {@link Thread} which did not yet start + */ + private Thread createThreadWhichActs() { + Runnable runnable = () -> { + ExecuteOnceWhen execute = new ExecuteOnceWhen<>(observable, ACTION_CONDITION, action); + execute.executeWhen(); + }; + + return new Thread(runnable); + } + + /** + * Creates threads which set the correct value in approximately half of the specified number of loops. + * + * @param latch + * the latch used to signal that the thread is done + * @param nrOfThreads + * the number of created threads + * @param nrOfLoopsPerThread + * the number of times each thread sets a new value on {@link #observable} + * @return a {@link List} of {@link Thread}s which did not yet start + */ + private List createThreadsWhichSetCorrectValueOften( + CountDownLatch latch, int nrOfThreads, int nrOfLoopsPerThread) { + + List threads = new ArrayList<>(nrOfThreads * 2); + for (int i = 0; i < nrOfThreads; i++) { + Thread thread = createThreadWhichSetsCorrectValueOften(latch, nrOfLoopsPerThread); + thread.setName("CORRECT VALUE #" + i); + threads.add(thread); + } + + return threads; + } + + /** + * Creates a thread which sets the correct value in approximately half of the specified number of loops. + * + * @param latch + * the latch used to signal that the thread is done + * @param nrOfLoops + * the number of times the thread sets a new value on {@link #observable} + * @return a {@link Thread} which did not yet start + */ + private Thread createThreadWhichSetsCorrectValueOften(CountDownLatch latch, int nrOfLoops) { + Random random = new Random(); + Runnable runnable = () -> { + // make the first n-1 loops random + for (int i = 0; i < nrOfLoops - 1; i++) { + boolean setCorrectValue = random.nextBoolean(); + if (setCorrectValue) + observable.setValue(ACTION_STRING); + else { + String randomValue = "" + random.nextDouble(); + observable.setValue(randomValue); + } + } + // set the correct value at the end to give executing threads which start afterwards a chance to execute + observable.setValue(ACTION_STRING); + + latch.countDown(); + }; + + return new Thread(runnable, "CorrectValue"); + } + + // #end MULTI-THREADED TESTS +} diff --git a/src/test/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandleTest.java b/src/test/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandleTest.java new file mode 100644 index 0000000..3efc09b --- /dev/null +++ b/src/test/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandleTest.java @@ -0,0 +1,327 @@ +package org.codefx.libfx.control.properties; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.util.Random; +import java.util.function.Consumer; + +import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableMap; + +import org.codefx.libfx.listener.handle.CreateListenerHandle; +import org.junit.Before; +import org.junit.Test; + +/** + * Abstract superclass to all tests of {@link ControlPropertyListenerHandle}. + */ +public abstract class AbstractControlPropertyListenerHandleTest { + + // #region FIELDS + + /** + * A key to which the created listeners listen. + */ + private static final Object LISTENED_KEY = "listened"; + + /** + * A key which the created listeners ignore. + */ + private static final Object IGNORED_KEY = "ignored"; + + /** + * The property map to which the listeners listen. + */ + private ObservableMap properties; + + /** + * The default value processor. Will be mocked to verify interactions. + */ + private Consumer valueProcessor; + + /** + * A value processor which fails the test if it is called. + */ + private Consumer valueProcessorWhichFailsTestWhenCalled; + + // #end FIELDS + + // #region SETUP + + /** + * Initializes fields for tests. + */ + @Before + @SuppressWarnings("unchecked") + public void setUp() { + properties = FXCollections.observableHashMap(); + valueProcessor = mock(Consumer.class); + valueProcessorWhichFailsTestWhenCalled = any -> fail(); + } + + /** + * Creates the tested {@link ControlPropertyListenerHandle} from the specified arguments. + * + * @param + * the type of values which the listener processes + * @param properties + * the {@link ObservableMap} holding the properties + * @param key + * the key to which the listener will listen + * @param valueType + * the type of values which the listener processes + * @param valueProcessor + * the {@link Consumer} for the key's values + * @param attachedOrDetached + * indicates whether the created handle will be initially attached or detached + * @return the created {@link ControlPropertyListenerHandle} + */ + protected abstract ControlPropertyListenerHandle createListener( + ObservableMap properties, Object key, + Class valueType, Consumer valueProcessor, CreateListenerHandle attachedOrDetached); + + /** + * Creates the tested {@link ControlPropertyListenerHandle}. It will operate on {@link #properties}, listen to + * {@link #LISTENED_KEY} and values of type {@link String}. The created listener is initially + * {@link CreateListenerHandle#DETACHED detached}. + * + * @param valueProcessor + * the {@link Consumer} for the key's string values + * @return the created {@link ControlPropertyListenerHandle} + */ + private ControlPropertyListenerHandle createDetachedDefaultListener(Consumer valueProcessor) { + return createListener(properties, LISTENED_KEY, String.class, valueProcessor, CreateListenerHandle.DETACHED); + } + + /** + * Creates the tested {@link ControlPropertyListenerHandle}. It will operate on {@link #properties}, listen to + * {@link #LISTENED_KEY} and values of type {@link String}. The created listener is initially + * {@link CreateListenerHandle#ATTACHED attached}. + * + * @param valueProcessor + * the {@link Consumer} for the key's string values + * @return the created {@link ControlPropertyListenerHandle} + */ + private ControlPropertyListenerHandle createAttachedDefaultListener(Consumer valueProcessor) { + return createListener(properties, LISTENED_KEY, String.class, valueProcessor, CreateListenerHandle.ATTACHED); + } + + // #end SETUP + + // #region TESTS + + /** + * Tests whether the listener correctly processes a value for the correct key if the listener is initially attached. + */ + @Test + public void testSettingListenedKeyOnceWhenInitiallyAttached() { + // setup + createAttachedDefaultListener(valueProcessor); + + // put a value + String addedValue = "This value is put into the map."; + properties.put(LISTENED_KEY, addedValue); + + // check + verify(valueProcessor, times(1)).accept(addedValue); + verifyNoMoreInteractions(valueProcessor); + } + + /** + * Tests whether the listener correctly processes a value for the correct key. + */ + @Test + public void testSettingListenedKeyOnceWhenAttachedAfterConstruction() { + // setup + ControlPropertyListenerHandle listenerHandle = createDetachedDefaultListener(valueProcessor); + listenerHandle.attach(); + + // put a value + String addedValue = "This value is put into the map."; + properties.put(LISTENED_KEY, addedValue); + + // check + verify(valueProcessor, times(1)).accept(addedValue); + verifyNoMoreInteractions(valueProcessor); + } + + /** + * Tests whether the listener correctly processes setting the same value multiple times for the correct key. + */ + @Test + public void testSettingListenedKeyRepeatedly() { + // setup + createAttachedDefaultListener(valueProcessor); + + // put the same value over and over + String addedValue = "This value is put into the map."; + for (int i = 0; i < 10; i++) + properties.put(LISTENED_KEY, addedValue); + + // check + verify(valueProcessor, times(10)).accept(addedValue); + verifyNoMoreInteractions(valueProcessor); + } + + /** + * Tests whether the listener correctly processes setting multiple random values. + */ + @Test + public void testSettingListenedKeyRandomly() { + // setup + Property listenedValue = new SimpleStringProperty(); + createAttachedDefaultListener(listenedValue::setValue); + + // put and check some random values + Random random = new Random(); + for (int i = 0; i < 10; i++) { + // create a random string + byte[] bytes = new byte[256]; + random.nextBytes(bytes); + String addedValue = new String(bytes); + // put and check + properties.put(LISTENED_KEY, addedValue); + assertSame(addedValue, listenedValue.getValue()); + } + } + + /** + * Tests whether the listener ignores values of the wrong type. + */ + @Test + public void testSettingListenedKeyOfWrongType() { + Consumer valueProcessorWhichFailsTestWhenCalled = any -> fail(); + // setup + createListener(properties, LISTENED_KEY, Integer.class, + valueProcessorWhichFailsTestWhenCalled, CreateListenerHandle.ATTACHED); + + // put a value of the wrong type + properties.put(LISTENED_KEY, "some non integer"); + } + + /** + * Tests whether the listener ignores values for other keys. + */ + @Test + public void testSettingIgnoredKey() { + // setup + ControlPropertyListenerHandle listenerHandle = + createDetachedDefaultListener(valueProcessorWhichFailsTestWhenCalled); + listenerHandle.attach(); + + // put a value for a key to which the listener does not listen + properties.put(IGNORED_KEY, "some value"); + } + + /** + * Tests whether the listener correctly processes a value which already existed in the map before it was attached. + */ + @Test + public void testProcessingPresentValueOnAttach() { + // setup + ControlPropertyListenerHandle listenerHandle = createDetachedDefaultListener(valueProcessor); + String existingValue = "some existing value"; + properties.put(LISTENED_KEY, existingValue); + + // this should trigger processing the value + listenerHandle.attach(); + + verify(valueProcessor, times(1)).accept(existingValue); + verifyNoMoreInteractions(valueProcessor); + } + + /** + * Tests whether the listener ignores values after it was detached. + */ + @Test + public void testDetachWhenInitiallyAttached() { + // setup + ControlPropertyListenerHandle listenerHandle = + createAttachedDefaultListener(valueProcessorWhichFailsTestWhenCalled); + listenerHandle.detach(); + + // put a value of the correct type for the listened key + properties.put(LISTENED_KEY, "some value"); + } + + /** + * Tests whether the listener ignores values after it was detached. + */ + @Test + public void testDetachAfterAttach() { + // setup + ControlPropertyListenerHandle listenerHandle = + createDetachedDefaultListener(valueProcessorWhichFailsTestWhenCalled); + listenerHandle.attach(); + listenerHandle.detach(); + + // put a value of the correct type for the listened key + properties.put(LISTENED_KEY, "some value"); + } + + /** + * Tests whether the listener ignores values after it was detached repeatedly. + */ + @Test + public void testMultipleDetach() { + // setup + ControlPropertyListenerHandle listenerHandle = + createDetachedDefaultListener(valueProcessorWhichFailsTestWhenCalled); + listenerHandle.attach(); + listenerHandle.detach(); + listenerHandle.detach(); + listenerHandle.detach(); + + // put a value of the correct type for the listened key + properties.put(LISTENED_KEY, "some value"); + } + + /** + * Tests whether the listener processes values after it was detached and then reattached. + */ + @Test + public void testReattach() { + // setup + ControlPropertyListenerHandle listenerHandle = createDetachedDefaultListener(valueProcessor); + listenerHandle.attach(); + listenerHandle.detach(); + listenerHandle.attach(); + + // put a value of the correct type for the listened key + String addedValue = "This value is put into the map."; + properties.put(LISTENED_KEY, addedValue); + + // check + verify(valueProcessor, times(1)).accept(addedValue); + verifyNoMoreInteractions(valueProcessor); + } + + /** + * Tests whether the listener is only called once even when attached is called repeatedly. + */ + @Test + public void testMultipleAttach() { + ControlPropertyListenerHandle listenerHandle = createDetachedDefaultListener(valueProcessor); + listenerHandle.attach(); + listenerHandle.attach(); + listenerHandle.attach(); + + // put a value of the correct type for the listened key + String addedValue = "Some value..."; + properties.put(LISTENED_KEY, addedValue); + + // check + verify(valueProcessor, times(1)).accept(addedValue); + verifyNoMoreInteractions(valueProcessor); + } + + // #end TESTS + +} diff --git a/src/test/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandleTest.java b/src/test/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandleTest.java new file mode 100644 index 0000000..0d8380b --- /dev/null +++ b/src/test/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandleTest.java @@ -0,0 +1,28 @@ +package org.codefx.libfx.control.properties; + +import java.util.function.Consumer; + +import javafx.collections.ObservableMap; + +import org.codefx.libfx.listener.handle.CreateListenerHandle; + +/** + * Tests {@link CastingControlPropertyListenerHandle}. + */ +public class CastingControlPropertyListenerHandleTest extends AbstractControlPropertyListenerHandleTest { + + @Override + protected ControlPropertyListenerHandle createListener( + ObservableMap properties, Object key, + Class valueType, Consumer valueProcessor, + CreateListenerHandle attachedOrDetached) { + + ControlPropertyListenerHandle handle = + new CastingControlPropertyListenerHandle(properties, key, valueProcessor); + if (attachedOrDetached == CreateListenerHandle.ATTACHED) + handle.attach(); + + return handle; + } + +} diff --git a/src/test/java/org/codefx/libfx/control/properties/ControlPropertiesTest.java b/src/test/java/org/codefx/libfx/control/properties/ControlPropertiesTest.java new file mode 100644 index 0000000..7086f42 --- /dev/null +++ b/src/test/java/org/codefx/libfx/control/properties/ControlPropertiesTest.java @@ -0,0 +1,219 @@ +package org.codefx.libfx.control.properties; + +import static org.junit.Assert.fail; + +import java.util.function.Consumer; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableMap; + +import org.codefx.libfx.listener.handle.CreateListenerHandle; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; +import org.junit.runners.Suite.SuiteClasses; + +/** + * Tests the {@link ControlPropertyListenerBuilder}. + */ +@RunWith(Suite.class) +@SuiteClasses({ + ControlPropertiesTest.BuilderContract.class, + ControlPropertiesTest.CreatedCastingControlPropertyListener.class, + ControlPropertiesTest.CreatedTypeCheckingControlPropertyListener.class }) +public class ControlPropertiesTest { + + /** + * Tests the builder's contract. + */ + public static class BuilderContract { + + // #region FIELDS + + /** + * The key used to create the listeners. + */ + private static final Object KEY = "key"; + + /** + * The processor which can be used when nothing needs to happen. + */ + private static final Consumer VOID_PROCESSOR = value -> {/* do nothing */}; + + /** + * The property map used to create the listeners. + */ + private ObservableMap properties; + + // #end FIELDS + + /** + * Initializes the fields. + */ + @Before + public void setUp() { + properties = FXCollections.observableHashMap(); + } + + // #region TESTS + + // EXCEPTIONS DURING CONSTRUCTION + + /** + * Tests whether starting on a null map throws the correct exception. + */ + @Test(expected = NullPointerException.class) + public void testNullPointerExceptionOnNullMap() { + ControlProperties.on(null); + } + + /** + * Tests whether using a null key throws the correct exception. + */ + @Test(expected = NullPointerException.class) + public void testNullPointerExceptionOnNullKey() { + ControlProperties.on(properties) + .forKey(null); + } + + /** + * Tests whether using a null value type throws the correct exception. + */ + @Test(expected = NullPointerException.class) + public void testNullPointerExceptionOnNullValueType() { + ControlProperties.on(properties) + .forValueType(null); + } + + /** + * Tests whether using a null value processor throws the correct exception. + */ + @Test(expected = NullPointerException.class) + public void testNullPointerExceptionOnNullValueProcessor() { + ControlProperties.on(properties) + .processValue(null); + } + + /** + * Tests whether building with a missing key throws the correct exception. + */ + @Test(expected = IllegalStateException.class) + public void testIllegalStateExceptionOnBuildWithoutKey() { + ControlProperties.on(properties) + .processValue(VOID_PROCESSOR) + .buildDetached(); + } + + /** + * Tests whether building with a missing key value processor the correct exception. + */ + @Test(expected = IllegalStateException.class) + public void testIllegalStateExceptionOnBuildWithoutValueProcessor() { + ControlProperties.on(properties) + .forKey(KEY) + .buildDetached(); + } + + // SUCCESSFUL CONSTRUCTION + + /** + * Tests whether building is successful when the minimum of values is set. + */ + public void testSuccessfulBuild() { + ControlProperties. on(properties) + .forKey(KEY) + .processValue(VOID_PROCESSOR) + .buildDetached(); + } + + /** + * Tests whether building with a value type works as well. + */ + public void testSuccessfulBuildWithValueType() { + ControlProperties. on(properties) + .forKey(KEY) + .forValueType(String.class) + .processValue(VOID_PROCESSOR) + .buildDetached(); + } + + // #end TESTS + + } + + // #region TESTS CREATED LISTENERS + + /** + * Tests the created {@link CastingControlPropertyListenerHandle}. + */ + public static class CreatedCastingControlPropertyListener extends AbstractControlPropertyListenerHandleTest { + + @Override + protected ControlPropertyListenerHandle createListener( + ObservableMap properties, Object key, + Class valueType, Consumer valueProcessor, + CreateListenerHandle attachedOrDetached) { + + // parameterize the builder + ControlPropertyListenerBuilder builder = ControlProperties. on(properties) + .forKey(key) + .processValue(valueProcessor); + // in order to create a casting listener, do not set the builder type; + + // create the listener according to 'attachedOrDetached' + ControlPropertyListenerHandle listener; + if (attachedOrDetached == CreateListenerHandle.ATTACHED) + listener = builder.buildAttached(); + else if (attachedOrDetached == CreateListenerHandle.DETACHED) + listener = builder.buildDetached(); + else + throw new IllegalArgumentException(); + + // check whether the correct type was created + if (!(listener instanceof CastingControlPropertyListenerHandle)) + fail(); + + return listener; + } + + } + + /** + * Tests the created {@link TypeCheckingControlPropertyListenerHandle}. + */ + public static class CreatedTypeCheckingControlPropertyListener extends AbstractControlPropertyListenerHandleTest { + + @Override + protected ControlPropertyListenerHandle createListener( + ObservableMap properties, Object key, + Class valueType, Consumer valueProcessor, + CreateListenerHandle attachedOrDetached) { + + // parameterize the builder + ControlPropertyListenerBuilder builder = ControlProperties. on(properties) + .forKey(key) + .forValueType(valueType) + .processValue(valueProcessor); + + // create the listener according to 'attachedOrDetached' + ControlPropertyListenerHandle listener; + if (attachedOrDetached == CreateListenerHandle.ATTACHED) + listener = builder.buildAttached(); + else if (attachedOrDetached == CreateListenerHandle.DETACHED) + listener = builder.buildDetached(); + else + throw new IllegalArgumentException(); + + // check whether the correct type was created + if (!(listener instanceof TypeCheckingControlPropertyListenerHandle)) + fail(); + + return listener; + } + + } + + // #end TESTS CREATED LISTENERS + +} diff --git a/src/test/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandleTest.java b/src/test/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandleTest.java new file mode 100644 index 0000000..91c40c3 --- /dev/null +++ b/src/test/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandleTest.java @@ -0,0 +1,28 @@ +package org.codefx.libfx.control.properties; + +import java.util.function.Consumer; + +import javafx.collections.ObservableMap; + +import org.codefx.libfx.listener.handle.CreateListenerHandle; + +/** + * Tests {@link TypeCheckingControlPropertyListenerHandle}. + */ +public class TypeCheckingControlPropertyListenerHandleTest extends AbstractControlPropertyListenerHandleTest { + + @Override + protected ControlPropertyListenerHandle createListener( + ObservableMap properties, Object key, + Class valueType, Consumer valueProcessor, + CreateListenerHandle attachedOrDetached) { + + ControlPropertyListenerHandle handle = + new TypeCheckingControlPropertyListenerHandle(properties, key, valueType, valueProcessor); + if (attachedOrDetached == CreateListenerHandle.ATTACHED) + handle.attach(); + + return handle; + } + +} diff --git a/src/test/java/org/codefx/libfx/dom/AbstractDomEventConverterTest.java b/src/test/java/org/codefx/libfx/dom/AbstractDomEventConverterTest.java new file mode 100644 index 0000000..4d74f22 --- /dev/null +++ b/src/test/java/org/codefx/libfx/dom/AbstractDomEventConverterTest.java @@ -0,0 +1,289 @@ +package org.codefx.libfx.dom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.StringReader; + +import javafx.beans.property.Property; +import javafx.beans.property.SimpleObjectProperty; + +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkEvent.EventType; + +import org.apache.xerces.dom.events.EventImpl; +import org.cyberneko.html.parsers.DOMParser; +import org.junit.Before; +import org.junit.Test; +import org.w3c.dom.Document; +import org.w3c.dom.events.Event; +import org.w3c.dom.events.EventTarget; +import org.xml.sax.InputSource; + +/** + * Abstract superclass to all test classes which convert DOM events. + */ +public abstract class AbstractDomEventConverterTest { + + // #region FIELDS & INITIALIZATION + + /** + * The URL used for all links. + */ + private static final String LINK_URL = "http://www.w3.org/TR/DOM-Level-3-Events/#event-types-list"; + + /** + * The ID of the {@link #simpleLink}. Used to retrieve the corresponding element from the parsed HTML. + */ + private static final String SIMPLE_LINK_ID = "simple_link"; + + /** + * The text displayed for the {@link #simpleLink}. + */ + private static final String SIMPLE_LINK_TEXT = "Link!"; + + /** + * A simple HTML string. + */ + private static final String SIMPLE_HTML_STRING = "" + + "" + + SIMPLE_LINK_TEXT + + ""; + + /** + * A simple link which is used to generate DOM events. + */ + private EventTarget simpleLink; + + /** + * Parses the HTML strings and extracts the id'd DOM event targets. + * + * @throws Exception + * if parsing fails + */ + @Before + public void setUp() throws Exception { + DOMParser parser = new DOMParser(); + parser.parse(new InputSource(new StringReader(SIMPLE_HTML_STRING))); + Document htmlDocument = parser.getDocument(); + simpleLink = (EventTarget) htmlDocument.getElementById(SIMPLE_LINK_ID); + } + + // #end FIELDS & INITIALIZATION + + // #region TESTS + + // #end FIELDS & INITIALIZATION + + // #region TESTS + + /** + * Tests whether all DOM events[1] which have a corresponding {@link EventType HyperlinkEventType} are correctly + * reported to be convertible. + *

+ * [1] http://www.w3.org/TR/DOM-Level-3-Events/#event-types-list + */ + @Test + public void testCanConvertToHyperlinkEvent() { + // all convertible DOM events + String[] convertibleEventNames = new String[] { + DomEventType.CLICK.getDomName(), + DomEventType.MOUSE_ENTER.getDomName(), + DomEventType.MOUSE_LEAVE.getDomName() }; + + for (String domEventName : convertibleEventNames) { + Event domEvent = createDispatchAndCatchEvent(simpleLink, domEventName); + boolean canConvert = canConvertToHyperlinkEvent(domEvent); + + assertTrue("Should be able to convert '" + domEventName + "'.", canConvert); + } + } + + /** + * Tests whether all DOM events[1] which have no corresponding {@link EventType HyperlinkEventType} are correctly + * reported to be not convertible. + *

+ * [1] http://www.w3.org/TR/DOM-Level-3-Events/#event-types-list + */ + @Test + public void testCanNotConvert() { + // all existing DOM events[1] minus the convertible ones + String[] notConvertibleEventNames = new String[] { "abort", "beforeinput", "blur", "compositionstart", + "compositionupdate", "compositionend", "dblclick", "error", "focus", "focusin", "focusout", "input", + "keydown", "keyup", "load", "mousedown", "mousemove", "mouseout", "mouseover", "mouseup", "resize", + "scroll", "select", "unload", "wheel" }; + + for (String domEventName : notConvertibleEventNames) { + Event domEvent = createDispatchAndCatchEvent(simpleLink, domEventName); + boolean canConvert = canConvertToHyperlinkEvent(domEvent); + + assertFalse("Should not be able to convert '" + domEventName + "'.", canConvert); + } + } + + /** + * Tests whether converted events have the correct event type. + */ + @Test + public void testEventTypes() { + for (DomEventType domEventType : DomEventType.values()) { + if (!domEventType.toHyperlinkEventType().isPresent()) + continue; + + Event domEvent = createDispatchAndCatchEvent(simpleLink, domEventType.getDomName()); + HyperlinkEvent convertedEvent = convertToHyperlinkEvent(domEvent, new Object()); + + assertEquals(domEventType.toHyperlinkEventType().get(), convertedEvent.getEventType()); + } + } + + /** + * Tests whether the converted event's {@link HyperlinkEvent#getSource() source} is correctly set. + */ + @Test + public void testSource() { + Event domEvent = createDispatchAndCatchEvent(simpleLink, DomEventType.CLICK.getDomName()); + Object source = "the source"; + HyperlinkEvent convertedEvent = convertToHyperlinkEvent(domEvent, source); + + assertSame(source, convertedEvent.getSource()); + } + + /** + * Tests whether the converted event's {@link HyperlinkEvent#getURL() URL} is correctly set. + */ + @Test + public void testUrl() { + Event domEvent = createDispatchAndCatchEvent(simpleLink, DomEventType.CLICK.getDomName()); + HyperlinkEvent convertedEvent = convertToHyperlinkEvent(domEvent, new Object()); + + assertEquals(LINK_URL, convertedEvent.getURL().toExternalForm()); + } + + /** + * Tests whether the converted event's {@link HyperlinkEvent#getDescription description} is correctly set. + */ + @Test + public void testDescription() { + Event domEvent = createDispatchAndCatchEvent(simpleLink, DomEventType.CLICK.getDomName()); + HyperlinkEvent convertedEvent = convertToHyperlinkEvent(domEvent, new Object()); + + assertEquals(SIMPLE_LINK_TEXT, convertedEvent.getDescription()); + } + + /** + * Tests whether the converted event's {@link HyperlinkEvent#getInputEvent() inputEvent} is null as per contract. + */ + @Test + public void testInputEvent() { + Event domEvent = createDispatchAndCatchEvent(simpleLink, DomEventType.CLICK.getDomName()); + HyperlinkEvent convertedEvent = convertToHyperlinkEvent(domEvent, new Object()); + + assertNull(convertedEvent.getInputEvent()); + } + + /** + * Tests whether the converted event's {@link HyperlinkEvent#getSourceElement() sourceElement} is null as per + * contract. + */ + @Test + public void testSourceElement() { + Event domEvent = createDispatchAndCatchEvent(simpleLink, DomEventType.CLICK.getDomName()); + HyperlinkEvent convertedEvent = convertToHyperlinkEvent(domEvent, new Object()); + + assertNull(convertedEvent.getSourceElement()); + } + + // #end TESTS + + // #region ABSTRACT METHODS + + /** + * Implemented by subclasses to check whether the specified event can be converted. + * + * @param domEvent + * the {@link Event} to check + * @return true if {@link #convertToHyperlinkEvent(Event, Object)} will succeed + */ + protected abstract boolean canConvertToHyperlinkEvent(Event domEvent); + + /** + * Implemented by subclasses to convert the specified DOM event to a hyperlink event. + * + * @param domEvent + * the {@link Event} to be converted + * @param object + * the new hyperlink event's source + * @return a {@link HyperlinkEvent} + */ + protected abstract HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object object); + + // #end ABSTRACT METHODS + + // #region HELPER METHODS + + /** + * Creates a DOM event and dispatches it with the specified target. A listener on the same target catches any event + * and returns it. + * + * @param target + * the {@link EventTarget} which will {@link EventTarget#dispatchEvent(Event) dispatch} the created event + * @param eventType + * the type of the event as specified here: http://www.w3.org/TR/DOM-Level-3-Events/#event-types-list + * @return the DOM-{@link Event} caught from the target + */ + private static Event createDispatchAndCatchEvent(EventTarget target, String eventType) { + return createDispatchAndCatchEvent(target, eventType, true, true); + } + + /** + * Creates a DOM event and dispatches it with the specified target. A listener on the same target catches any event + * and returns it. + * + * @param target + * the {@link EventTarget} which will {@link EventTarget#dispatchEvent(Event) dispatch} the created event + * @param eventType + * the type of the event as specified here: http://www.w3.org/TR/DOM-Level-3-Events/#event-types-list + * @param canBubbleArg + * indicates whether the event can bubble + * @param cancelableArg + * indicates whether the event can be canceled + * @return the DOM-{@link Event} caught from the target + */ + private static Event createDispatchAndCatchEvent( + EventTarget target, String eventType, boolean canBubbleArg, boolean cancelableArg) { + + Property caughtEvent = new SimpleObjectProperty<>(); + target.addEventListener(eventType, caughtEvent::setValue, false); + + Event createdEvent = createEvent(eventType, canBubbleArg, cancelableArg); + target.dispatchEvent(createdEvent); + + return caughtEvent.getValue(); + } + + /** + * Creates and initializes a DOM event from the specified arguments. + * + * @param eventType + * the type of the event as specified here: http://www.w3.org/TR/DOM-Level-3-Events/#event-types-list + * @param canBubbleArg + * indicates whether the event can bubble + * @param cancelableArg + * indicates whether the event can be canceled + * @return the created and initialized DOM-{@link Event} + */ + private static Event createEvent(String eventType, boolean canBubbleArg, boolean cancelableArg) { + Event event = new EventImpl(); + event.initEvent(eventType, canBubbleArg, cancelableArg); + return event; + } + + // #end HELPER METHODS + +} diff --git a/src/test/java/org/codefx/libfx/dom/DomEventConverterTest.java b/src/test/java/org/codefx/libfx/dom/DomEventConverterTest.java new file mode 100644 index 0000000..722a7b4 --- /dev/null +++ b/src/test/java/org/codefx/libfx/dom/DomEventConverterTest.java @@ -0,0 +1,37 @@ +package org.codefx.libfx.dom; + +import javax.swing.event.HyperlinkEvent; + +import org.w3c.dom.events.Event; + +/** + * Test the class {@link DomEventConverter}. + */ +public class DomEventConverterTest extends AbstractDomEventConverterTest { + + // #region FIELDS & INITIALIZATION + + /** + * The tested {@link DomEventConverter}. + */ + private DomEventConverter converter; + + @Override + public void setUp() throws Exception { + super.setUp(); + converter = new DomEventConverter(); + } + + // #end FIELDS & INITIALIZATION + + @Override + protected boolean canConvertToHyperlinkEvent(Event domEvent) { + return converter.canConvertToHyperlinkEvent(domEvent); + } + + @Override + protected HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source) { + return converter.convertToHyperlinkEvent(domEvent, source); + } + +} diff --git a/src/test/java/org/codefx/libfx/dom/SingleDomEventConverterTest.java b/src/test/java/org/codefx/libfx/dom/SingleDomEventConverterTest.java new file mode 100644 index 0000000..c0f1705 --- /dev/null +++ b/src/test/java/org/codefx/libfx/dom/SingleDomEventConverterTest.java @@ -0,0 +1,25 @@ +package org.codefx.libfx.dom; + +import javax.swing.event.HyperlinkEvent; + +import org.w3c.dom.events.Event; + +/** + * Test the class {@link SingleDomEventConverter}. + */ +public class SingleDomEventConverterTest extends AbstractDomEventConverterTest { + + @Override + protected boolean canConvertToHyperlinkEvent(Event domEvent) { + Object source = "no source needed"; + SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source); + return converter.canConvert(); + } + + @Override + protected HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source) { + SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source); + return converter.convert(); + } + +} diff --git a/src/test/java/org/codefx/libfx/dom/StaticDomEventConverterTest.java b/src/test/java/org/codefx/libfx/dom/StaticDomEventConverterTest.java new file mode 100644 index 0000000..34455fc --- /dev/null +++ b/src/test/java/org/codefx/libfx/dom/StaticDomEventConverterTest.java @@ -0,0 +1,22 @@ +package org.codefx.libfx.dom; + +import javax.swing.event.HyperlinkEvent; + +import org.w3c.dom.events.Event; + +/** + * Test the class {@link StaticDomEventConverter}. + */ +public class StaticDomEventConverterTest extends AbstractDomEventConverterTest { + + @Override + protected boolean canConvertToHyperlinkEvent(Event domEvent) { + return StaticDomEventConverter.canConvertToHyperlinkEvent(domEvent); + } + + @Override + protected HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source) { + return StaticDomEventConverter.convertToHyperlinkEvent(domEvent, source); + } + +} diff --git a/src/test/java/org/codefx/libfx/listener/handle/CreateListenerHandle.java b/src/test/java/org/codefx/libfx/listener/handle/CreateListenerHandle.java new file mode 100644 index 0000000..34f77a0 --- /dev/null +++ b/src/test/java/org/codefx/libfx/listener/handle/CreateListenerHandle.java @@ -0,0 +1,20 @@ +package org.codefx.libfx.listener.handle; + +import org.codefx.libfx.listener.handle.ListenerHandle; + +/** + * Indicates how to create the {@link ListenerHandle}. If the handle is created by a builder, the corresponding method + * should be called (in order to test it) instead of attaching/detaching the listener after its creation. + */ +public enum CreateListenerHandle { + + /** + * The listener must be initially attached. + */ + ATTACHED, + + /** + * The listener must be initially detached. + */ + DETACHED; +} diff --git a/src/test/java/org/codefx/libfx/listener/handle/GenericListenerHandleTest.java b/src/test/java/org/codefx/libfx/listener/handle/GenericListenerHandleTest.java new file mode 100644 index 0000000..3060db5 --- /dev/null +++ b/src/test/java/org/codefx/libfx/listener/handle/GenericListenerHandleTest.java @@ -0,0 +1,165 @@ +package org.codefx.libfx.listener.handle; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; + +import java.util.function.BiConsumer; + +import org.codefx.libfx.listener.handle.GenericListenerHandle; +import org.codefx.libfx.listener.handle.ListenerHandle; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests the class {@link GenericListenerHandle}. + */ +public class GenericListenerHandleTest { + + // #region INSTANCES + + /** + * The tested handle. + */ + private GenericListenerHandle handle; + + /** + * The observable on which the {@link #handle} operates. + */ + private Object observable; + + /** + * The listener on which the {@link #handle} operates. + */ + private Object listener; + + /** + * The function which adds the listener to the observable. This is a mock so calls can be verified. + */ + private BiConsumer add; + + /** + * The function which adds the listener to the observable. This is a mock so calls can be verified. + */ + private BiConsumer remove; + + // #end INSTANCES + + // #region SETUP + + /** + * Creates the tested instances. + */ + @Before + @SuppressWarnings("unchecked") + public void setUp() { + add = mock(BiConsumer.class); + remove = mock(BiConsumer.class); + observable = "observable"; + listener = "listner"; + + handle = new GenericListenerHandle(observable, listener, add, remove); + } + + // #end SETUP + + // #region TESTS + + /** + * Tests whether the construction of the handle does not cause any calls to {@link #add} and {@link #remove}. + */ + @Test + public void testNoCallsToAddAndRemoveOnConstruction() { + verifyZeroInteractions(add, remove); + } + + /** + * Tests whether the a call to {@link ListenerHandle#detach() detach()} after construction does not cause any calls + * to {@link #add} and {@link #remove}. + */ + @Test + public void testDetachAfterConstruction() { + handle.detach(); + + verifyZeroInteractions(add, remove); + } + + /** + * Tests whether the first call to {@link ListenerHandle#attach() attach()} correctly calls {@link #add}. + */ + @Test + public void testAttachAfterConstruction() { + handle.attach(); + + verify(add, times(1)).accept(observable, listener); + verifyNoMoreInteractions(add); + verifyZeroInteractions(remove); + } + + /** + * Tests whether calling {@link ListenerHandle#attach() attach()} multiple times in a row calls {@link #add} only + * once. + */ + @Test + public void testMultipleAttach() { + handle.attach(); + handle.attach(); + handle.attach(); + + verify(add, times(1)).accept(observable, listener); + verifyNoMoreInteractions(add); + verifyZeroInteractions(remove); + } + + /** + * Tests whether calling {@link ListenerHandle#detach() detach()} correctly calls {@link #remove}. + */ + @Test + public void testDetach() { + handle.attach(); + handle.detach(); + + // the order of those calls is not verified here; + // but if it would not match the intuition (first 'add', then 'remove'), a more specific test above would fail + verify(add, times(1)).accept(observable, listener); + verify(remove, times(1)).accept(observable, listener); + verifyNoMoreInteractions(add, remove); + } + + /** + * Tests whether calling {@link ListenerHandle#detach() detach()} multiple times in a row calls {@link #remove} only + * once. + */ + @Test + public void testMultipleDetach() { + handle.attach(); + handle.detach(); + handle.detach(); + handle.detach(); + + verify(add, times(1)).accept(observable, listener); + verify(remove, times(1)).accept(observable, listener); + verifyZeroInteractions(remove); + } + + /** + * Tests whether reattaching calls {@link #add} twice. + */ + @Test + public void testReattach() { + handle.attach(); + handle.detach(); + handle.attach(); + + // the order of those calls is not verified here; + // but if it would not match the intuition ('add', 'remove', 'add'), a more specific test above would fail + verify(add, times(2)).accept(observable, listener); + verify(remove, times(1)).accept(observable, listener); + verifyNoMoreInteractions(add, remove); + } + + // #end TESTS + +} diff --git a/src/test/java/org/codefx/libfx/listener/handle/ListenerHandleBuilderTest.java b/src/test/java/org/codefx/libfx/listener/handle/ListenerHandleBuilderTest.java new file mode 100644 index 0000000..2234c1e --- /dev/null +++ b/src/test/java/org/codefx/libfx/listener/handle/ListenerHandleBuilderTest.java @@ -0,0 +1,178 @@ +package org.codefx.libfx.listener.handle; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.util.function.BiConsumer; + +import org.junit.Test; + +/** + * Tests the class {@link ListenerHandleBuilder}. + */ +public class ListenerHandleBuilderTest { + + /** + * A not null {@link Object} which can be used to represent an observable or a listener. + */ + private static final Object NOT_NULL = new Object(); + + /** + * A not null {@link BiConsumer} which can be used to represent an add or a remove function. + */ + private static final BiConsumer NOT_NULL_CONSUMER = (o, l) -> { /* do nothing */}; + + // #region TESTS + + // construction + + /** + * Tests whether the factory method can not be called with a null observable. + */ + @Test(expected = NullPointerException.class) + public void testConstructorWithNullObservable() { + ListenerHandleBuilder.from(null, NOT_NULL); + } + + /** + * Tests whether the factory method can not be called with a null listener. + */ + @Test(expected = NullPointerException.class) + public void testConstructorWithNullListener() { + ListenerHandleBuilder.from(NOT_NULL, null); + } + + /** + * Tests whether the factory method returns a non-null builder. + */ + @Test + public void testSuccessfulConstruction() { + ListenerHandleBuilder builder = ListenerHandleBuilder.from(NOT_NULL, NOT_NULL); + assertNotNull(builder); + } + + // setting values + + /** + * Tests whether the builder does not accepts a null add function. + */ + @Test(expected = NullPointerException.class) + public void testSetNullAdd() { + ListenerHandleBuilder builder = ListenerHandleBuilder.from(NOT_NULL, NOT_NULL); + builder.onAttach(null); + } + + /** + * Tests whether the builder does not accepts a null remove function. + */ + @Test(expected = NullPointerException.class) + public void testSetNullRemove() { + ListenerHandleBuilder builder = ListenerHandleBuilder.from(NOT_NULL, NOT_NULL); + builder.onDetach(null); + } + + // build + + /** + * Tests whether {@link ListenerHandleBuilder#buildAttached() build} can not be called when neither + * {@link ListenerHandleBuilder#onAttach(BiConsumer) onAttach} nor + * {@link ListenerHandleBuilder#onDetach(BiConsumer) onDetach} were called. + */ + @Test(expected = IllegalStateException.class) + public void testNotCallingOnAttachAndOnDetachBeforeBuild() { + ListenerHandleBuilder + .from(NOT_NULL, NOT_NULL) + .buildAttached(); + } + + /** + * Tests whether {@link ListenerHandleBuilder#buildAttached() build} can not be called when + * {@link ListenerHandleBuilder#onAttach(BiConsumer) onAttach} was not called. + */ + @Test(expected = IllegalStateException.class) + public void testNotCallingOnAttachBeforeBuild() { + ListenerHandleBuilder + .from(NOT_NULL, NOT_NULL) + .onDetach(NOT_NULL_CONSUMER) + .buildAttached(); + } + + /** + * Tests whether {@link ListenerHandleBuilder#buildAttached() build} can not be called when + * {@link ListenerHandleBuilder#onDetach(BiConsumer) onDetach} was not called. + */ + @Test(expected = IllegalStateException.class) + public void testNotCallingOnDetachBeforeBuild() { + ListenerHandleBuilder + .from(NOT_NULL, NOT_NULL) + .onAttach(NOT_NULL_CONSUMER) + .buildAttached(); + } + + /** + * Tests whether the built {@link ListenerHandle} is not null. + */ + @Test + public void testSuccessfulBuild() { + ListenerHandle handle = ListenerHandleBuilder + .from(NOT_NULL, NOT_NULL) + .onAttach(NOT_NULL_CONSUMER) + .onDetach(NOT_NULL_CONSUMER) + .buildAttached(); + + assertNotNull(handle); + } + + // correct arguments for 'add' and 'remove' functions + + /** + * Tests whether the add function is called with the correct arguments. + */ + @Test + public void testAddCalledWithCorrectArguments() { + // setup + @SuppressWarnings("unchecked") + BiConsumer add = mock(BiConsumer.class); + ListenerHandle handle = ListenerHandleBuilder + .from(NOT_NULL, NOT_NULL) + .onAttach(add) + .onDetach(NOT_NULL_CONSUMER) + .buildDetached(); + + // trigger a call to 'add' + handle.attach(); + + // verify + verify(add, times(1)).accept(NOT_NULL, NOT_NULL); + verifyNoMoreInteractions(add); + } + + /** + * Tests whether the remove function is called with the correct arguments. + */ + @Test + public void testRemoveCalledWithCorrectArguments() { + // setup + @SuppressWarnings("unchecked") + BiConsumer remove = mock(BiConsumer.class); + ListenerHandle handle = ListenerHandleBuilder + .from(NOT_NULL, NOT_NULL) + .onAttach(NOT_NULL_CONSUMER) + .onDetach(remove) + .buildDetached(); + + // trigger a call to 'remove' + handle.attach(); + handle.detach(); + + // verify + verify(remove, times(1)).accept(NOT_NULL, NOT_NULL); + verifyNoMoreInteractions(remove); + } + + // #end TESTS + +} diff --git a/src/test/java/org/codefx/libfx/nesting/_AllNestingTests.java b/src/test/java/org/codefx/libfx/nesting/_AllNestingTests.java deleted file mode 100644 index 52958e1..0000000 --- a/src/test/java/org/codefx/libfx/nesting/_AllNestingTests.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.codefx.libfx.nesting; - -import org.codefx.libfx.nesting.listener._AllNestedListenerTests; -import org.codefx.libfx.nesting.property._AllNestedPropertyTests; -import org.junit.runner.RunWith; -import org.junit.runners.Suite; -import org.junit.runners.Suite.SuiteClasses; - -/** - * Runs all tests in this package and its subpackages. - */ -@RunWith(Suite.class) -@SuiteClasses({ - _AllNestedListenerTests.class, - _AllNestedPropertyTests.class, - DeepNestingTest.class, - NestingObserverTest.class, - ShallowNestingTest.class, -}) -public class _AllNestingTests { - // no body needed -} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerBuilderTest.java b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerBuilderTest.java index 5d0774a..6d550c0 100644 --- a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerBuilderTest.java +++ b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerBuilderTest.java @@ -1,7 +1,7 @@ package org.codefx.libfx.nesting.listener; import static org.junit.Assert.assertNotNull; -import javafx.beans.property.StringProperty; +import javafx.beans.property.Property; import org.codefx.libfx.nesting.Nesting; import org.junit.Before; @@ -17,18 +17,32 @@ public abstract class AbstractNestedChangeListenerBuilderTest { /** * The tested builder. */ - private NestedChangeListenerBuilder builder; + private NestedChangeListenerBuilder> builder; //#end TESTED INSTANCES + // #region SETUP + /** * Creates a new builder before each test. */ @Before public void setUp() { - builder = createBuilder(); + builder = this. createBuilder(); } + /** + * Creates the tested builder. Each call must return a new instance + * + * @param + * the value wrapped by the nesting's inner observable, which is also the type observed by the change + * listener + * @return a {@link NestedChangeListenerBuilder} + */ + protected abstract NestedChangeListenerBuilder> createBuilder(); + + // #end SETUP + // #region TESTS /** @@ -52,9 +66,9 @@ public void testUsingNullListener() { */ @Test public void testBuildCreatesInstance() { - NestedChangeListener listener = builder + NestedChangeListenerHandle listener = builder .withListener((observable, oldValue, newValue) -> {/* don't do anything */}) - .build(); + .buildAttached(); assertNotNull(listener); } @@ -64,27 +78,16 @@ public void testBuildCreatesInstance() { */ @Test(expected = IllegalStateException.class) public void testBuildSeveralInstances() { - NestedChangeListenerBuilder.Buildable buildable = + NestedChangeListenerBuilder>.Buildable buildable = builder.withListener((observable, oldValue, newValue) -> {/* don't do anything */}); // first build must work (see other tests) - buildable.build(); + buildable.buildAttached(); // second build must fail - buildable.build(); + buildable.buildAttached(); } //#end TESTS - // #region ABSTRACT METHODS - - /** - * Creates the tested builder. Each call must return a new instance - * - * @return a {@link NestedChangeListenerBuilder} - */ - protected abstract NestedChangeListenerBuilder createBuilder(); - - //#end ABSTRACT METHODS - } diff --git a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerHandleTest.java b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerHandleTest.java new file mode 100644 index 0000000..296420b --- /dev/null +++ b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerHandleTest.java @@ -0,0 +1,314 @@ +package org.codefx.libfx.nesting.listener; + +import static org.codefx.libfx.nesting.testhelper.NestingAccess.getNestingObservable; +import static org.codefx.libfx.nesting.testhelper.NestingAccess.setNestingObservable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +import org.codefx.libfx.listener.handle.CreateListenerHandle; +import org.codefx.libfx.nesting.Nesting; +import org.codefx.libfx.nesting.testhelper.NestingAccess; +import org.junit.Before; +import org.junit.Test; + +/** + * Abstract superclass to tests of {@link NestedChangeListenerHandle}. + */ +public abstract class AbstractNestedChangeListenerHandleTest { + + // #region INSTANCES USED FOR TESTING + + /** + * The nesting's inner observable. + */ + private StringProperty innerObservable; + + /** + * The nesting to which the listener is added. + */ + private NestingAccess.EditableNesting nesting; + + /** + * The default listener. This {@link ChangeListener} will be mocked to verify invocations. + */ + private ChangeListener listener; + + /** + * A listener which fails the test when being called. + */ + private ChangeListener listenerWhichFailsWhenCalled; + + /** + * The tested nested listener, which adds the {@link #listener} to the {@link #nesting}. + */ + private NestedChangeListenerHandle nestedListenerHandle; + + //#end INSTANCES USED FOR TESTING + + // #region SETUP + + /** + * Creates a new instance of {@link #nesting} and {@link #listener}. + *

+ * Note: A {@link #nestedListenerHandle} has to be created in the test according to the desired initial state + * (attached or detached). + */ + @Before + @SuppressWarnings("unchecked") + public void setUp() { + innerObservable = new SimpleStringProperty("initial value"); + nesting = NestingAccess.EditableNesting.createWithInnerObservable(innerObservable); + listener = mock(ChangeListener.class); + listenerWhichFailsWhenCalled = (obs, oldValue, newValue) -> fail(); + } + + /** + * Creates a new, initially attached nested listener from the specified nesting and listener. + * + * @param + * the value wrapped by the nesting's inner observable, which is also the type observed by the change + * listener + * @param nesting + * the {@link Nesting} to which the listener will be added + * @param listener + * the {@link ChangeListener} which will be added to the nesting + * @return a new {@link NestedChangeListenerHandle} + */ + private NestedChangeListenerHandle createAttachedNestedListenerHandle( + Nesting> nesting, ChangeListener listener) { + + return createNestedListenerHandle(nesting, listener, CreateListenerHandle.ATTACHED); + } + + /** + * Creates a new, initially detached nested listener from the specified nesting and listener. + * + * @param + * the value wrapped by the nesting's inner observable, which is also the type observed by the change + * listener + * @param nesting + * the {@link Nesting} to which the listener will be added + * @param listener + * the {@link ChangeListener} which will be added to the nesting + * @return a new {@link NestedChangeListenerHandle} + */ + private NestedChangeListenerHandle createDetachedNestedListenerHandle( + Nesting> nesting, ChangeListener listener) { + + return createNestedListenerHandle(nesting, listener, CreateListenerHandle.DETACHED); + } + + /** + * Creates a new nested listener from the specified nesting and listener. + * + * @param + * the value wrapped by the nesting's inner observable, which is also the type observed by the change + * listener + * @param nesting + * the {@link Nesting} to which the listener will be added + * @param listener + * the {@link ChangeListener} which will be added to the nesting + * @param attachedOrDetached + * indicates whether the listener will be initially attached or detached + * @return a new {@link NestedChangeListenerHandle} + */ + protected abstract NestedChangeListenerHandle createNestedListenerHandle( + Nesting> nesting, + ChangeListener listener, + CreateListenerHandle attachedOrDetached); + + //end SETUP + + // #region TESTS + + // construction + + /** + * Tests whether the properties the tested nested listener owns have the correct bean. + */ + @Test + public void testPropertyBean() { + nestedListenerHandle = createDetachedNestedListenerHandle(nesting, listener); + assertSame(nestedListenerHandle, nestedListenerHandle.innerObservablePresentProperty().getBean()); + } + + /** + * Tests whether the {@link #nestedListenerHandle} correctly reports whether the inner observable is present. + */ + @Test + public void testObservablePresentAfterConstruction() { + nestedListenerHandle = createDetachedNestedListenerHandle(nesting, listener); + assertTrue(nestedListenerHandle.isInnerObservablePresent()); + } + + /** + * Tests whether the construction does not call the {@link #listener}. + */ + @Test + public void testNoInteractionWithListenerDuringConstruction() { + nestedListenerHandle = createDetachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + } + + // changing value + + /** + * Tests whether no listener invocation occurs when the nesting's observable changes its value and the listener is + * initially detached. + */ + @Test + public void testChangingValueWhenInitiallyDetached() { + nestedListenerHandle = createDetachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + innerObservable.set("new value"); + } + + /** + * Tests whether the listener is correctly invoked when the nesting's observable changes its value. + */ + @Test + public void testChangingValue() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listener); + innerObservable.set("new value"); + + // assert that 'changed' was called once and with the right arguments + verify(listener, times(1)).changed(innerObservable, "initial value", "new value"); + verifyNoMoreInteractions(listener); + } + + // changing observable + + /** + * Tests whether no listener invocation occurs when the nesting's inner observable is changed. + */ + @Test + public void testChangingObservable() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + StringProperty newObservable = new SimpleStringProperty("new observable's initial value"); + setNestingObservable(nesting, newObservable); + + assertTrue(nestedListenerHandle.isInnerObservablePresent()); + } + + /** + * Tests whether no listener invocation occurs when the nesting's inner observable is changed to null. + */ + @Test + public void testChangingObservableToNull() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + setNestingObservable(nesting, null); + + assertFalse(nestedListenerHandle.isInnerObservablePresent()); + } + + // changing observable and value + + /** + * Tests whether the listener is correctly invoked when the nesting's new observable gets a new value. + */ + @Test + public void testChangingNewObservablesValue() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listener); + // set a new observable ... + StringProperty newObservable = new SimpleStringProperty("new observable's initial value"); + setNestingObservable(nesting, newObservable); + // (assert that setting the observable worked) + assertEquals(newObservable, getNestingObservable(nesting)); + + // ... and change its value + newObservable.setValue("new observable's new value"); + + // assert that the listener was invoked once and with the new observable's old and new value + verify(listener, times(1)).changed(newObservable, + "new observable's initial value", "new observable's new value"); + verifyNoMoreInteractions(listener); + } + + /** + * Tests whether the listener is not invoked when the nesting's old observable gets a new value. + */ + @Test + public void testChangingOldObservablesValue() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + // set a new observable ... + StringProperty newObservable = new SimpleStringProperty("new observable's initial value"); + setNestingObservable(nesting, newObservable); + // (assert that setting the observable worked) + assertEquals(newObservable, getNestingObservable(nesting)); + + // ... and change the old observable's value + innerObservable.setValue("intial observable's new value"); + } + + // attach & detach + + /** + * Tests whether no listener invocation occurs when the nesting's inner observable's value is changed after the + * listener was detached. + */ + @Test + public void testDetach() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + nestedListenerHandle.detach(); + + innerObservable.set("new value while detached"); + } + + /** + * Tests whether the listener ignores values after it was detached repeatedly. + */ + @Test + public void testMultipleDetach() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + nestedListenerHandle.detach(); + nestedListenerHandle.detach(); + nestedListenerHandle.detach(); + + innerObservable.set("new value while detached"); + } + + /** + * Tests whether the listener is correctly invoked when the nesting's observable changes its value after the + * listener was detached and reattached. + */ + @Test + public void testReattach() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listener); + nestedListenerHandle.detach(); + nestedListenerHandle.attach(); + innerObservable.set("new value"); + + // assert that 'changed' was called once and with the right arguments + verify(listener, times(1)).changed(innerObservable, "initial value", "new value"); + verifyNoMoreInteractions(listener); + } + + /** + * Tests whether the listener is only called once even when attached is called repeatedly. + */ + @Test + public void testMultipleAttach() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listener); + nestedListenerHandle.attach(); + nestedListenerHandle.attach(); + nestedListenerHandle.attach(); + innerObservable.set("new value"); + + // assert that 'changed' was called only once + verify(listener, times(1)).changed(innerObservable, "initial value", "new value"); + verifyNoMoreInteractions(listener); + } + + //#end TESTS + +} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerTest.java b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerTest.java deleted file mode 100644 index 9e21331..0000000 --- a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedChangeListenerTest.java +++ /dev/null @@ -1,177 +0,0 @@ -package org.codefx.libfx.nesting.listener; - -import static org.codefx.libfx.nesting.testhelper.NestingAccess.getNestingObservable; -import static org.codefx.libfx.nesting.testhelper.NestingAccess.setNestingObservable; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.verifyZeroInteractions; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; - -import org.codefx.libfx.nesting.Nesting; -import org.codefx.libfx.nesting.testhelper.NestingAccess; -import org.codefx.libfx.nesting.testhelper.NestingAccess.EditableNesting; -import org.junit.Before; -import org.junit.Test; - -/** - * Abstract superclass to tests of {@link NestedChangeListener}. - */ -public abstract class AbstractNestedChangeListenerTest { - - // #region INSTANCES USED FOR TESTING - - /** - * The nesting's inner observable. - */ - private StringProperty innerObservable; - - /** - * The nesting to which the listener is added. - */ - private NestingAccess.EditableNesting nesting; - - /** - * The added listener. This {@link ChangeListener} will be mocked to verify possible invocations. - */ - private ChangeListener listener; - - /** - * The tested nested listener, which adds the {@link #listener} to the {@link #nesting}. - */ - private NestedChangeListener nestedListener; - - //#end INSTANCES USED FOR TESTING - - /** - * Creates a new instance of {@link #nesting}, {@link #listener} and {@link #nestedListener}. - */ - @Before - @SuppressWarnings("unchecked") - public void setUp() { - innerObservable = new SimpleStringProperty("initial value"); - nesting = NestingAccess.EditableNesting.createWithInnerObservable(innerObservable); - listener = mock(ChangeListener.class); - nestedListener = createNestedListener(nesting, listener); - } - - // #region TESTS - - /** - * Tests whether the properties the tested nested listener owns have the correct bean. - */ - @Test - public void testPropertyBean() { - assertSame(nestedListener, nestedListener.innerObservablePresentProperty().getBean()); - } - - /** - * Tests whether the {@link #nestedListener} and {@link #listener} are in the correct state after construction. - */ - @Test - public void testStateAfterConstruction() { - assertTrue(nestedListener.isInnerObservablePresent()); - // the listener must not have been called - verifyZeroInteractions(listener); - } - - /** - * Tests whether the listener is correctly invoked when the nesting's observable changes its value. - */ - @Test - public void testChangingValue() { - innerObservable.set("new value"); - - // assert that 'changed' was called once and with the right arguments - verify(listener, times(1)).changed(innerObservable, "initial value", "new value"); - verifyNoMoreInteractions(listener); - } - - /** - * Tests whether no listener invocation occurs when the nesting's inner observable is changed. - */ - @Test - public void testChangingObservable() { - StringProperty newObservable = new SimpleStringProperty("new observable's initial value"); - setNestingObservable(nesting, newObservable); - - assertTrue(nestedListener.isInnerObservablePresent()); - verifyZeroInteractions(listener); - } - - /** - * Tests whether no listener invocation occurs when the nesting's inner observable is changed to null. - */ - @Test - public void testChangingObservableToNull() { - setNestingObservable(nesting, null); - - assertFalse(nestedListener.isInnerObservablePresent()); - verifyZeroInteractions(listener); - } - - /** - * Tests whether the listener is correctly invoked when the nesting's new observable gets a new value. - */ - @Test - public void testChangingNewObservablesValue() { - // set a new observable ... - StringProperty newObservable = new SimpleStringProperty("new observable's initial value"); - setNestingObservable(nesting, newObservable); - // (assert that setting the observable worked) - assertEquals(newObservable, getNestingObservable(nesting)); - - // ... and change its value - newObservable.setValue("new observable's new value"); - - // assert that the listener was invoked once and with the new observable's old and new value - verify(listener, times(1)).changed(newObservable, - "new observable's initial value", "new observable's new value"); - verifyNoMoreInteractions(listener); - } - - /** - * Tests whether the listener is not invoked when the nesting's old observable gets a new value. - */ - @Test - public void testChangingOldObservablesValue() { - // set a new observable ... - StringProperty newObservable = new SimpleStringProperty("new observable's initial value"); - setNestingObservable(nesting, newObservable); - // (assert that setting the observable worked) - assertEquals(newObservable, getNestingObservable(nesting)); - - // ... and change the old observable's value - innerObservable.setValue("intial observable's new value"); - - // assert the listener was not invoked - verifyZeroInteractions(listener); - } - - //#end TESTS - - // #region ABSTRACT METHODS - - /** - * Creates a new nested listener from the specified nesting and listener. - * - * @param nesting - * the {@link Nesting} to which the listener will be added - * @param listener - * the {@link ChangeListener} which will be added to the nesting - * @return a new {@link NestedChangeListener} - */ - protected abstract NestedChangeListener createNestedListener( - EditableNesting> nesting, ChangeListener listener); - - //end ABSTRACT METHODS - -} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerBuilderTest.java b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerBuilderTest.java index 4fa25dc..63a3bbd 100644 --- a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerBuilderTest.java +++ b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerBuilderTest.java @@ -20,6 +20,8 @@ public abstract class AbstractNestedInvalidationListenerBuilderTest { //#end TESTED INSTANCES + // #region SETUP + /** * Creates a new builder before each test. */ @@ -28,6 +30,15 @@ public void setUp() { builder = createBuilder(); } + /** + * Creates the tested builder. Each call must return a new instance + * + * @return a {@link NestedInvalidationListenerBuilder} + */ + protected abstract NestedInvalidationListenerBuilder createBuilder(); + + // #end SETUP + // #region TESTS /** @@ -51,9 +62,9 @@ public void testUsingNullListener() { */ @Test public void testBuildCreatesInstance() { - NestedInvalidationListener listener = builder + NestedInvalidationListenerHandle listener = builder .withListener(observable -> {/* don't do anything */}) - .build(); + .buildAttached(); assertNotNull(listener); } @@ -67,23 +78,12 @@ public void testBuildSeveralInstances() { builder.withListener(observable -> {/* don't do anything */}); // first build must work (see other tests) - buildable.build(); + buildable.buildAttached(); // second build must fail - buildable.build(); + buildable.buildAttached(); } //#end TESTS - // #region ABSTRACT METHODS - - /** - * Creates the tested builder. Each call must return a new instance - * - * @return a {@link NestedInvalidationListenerBuilder} - */ - protected abstract NestedInvalidationListenerBuilder createBuilder(); - - //#end ABSTRACT METHODS - } diff --git a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerHandleTest.java b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerHandleTest.java new file mode 100644 index 0000000..89060eb --- /dev/null +++ b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerHandleTest.java @@ -0,0 +1,304 @@ +package org.codefx.libfx.nesting.listener; + +import static org.codefx.libfx.nesting.testhelper.NestingAccess.getNestingObservable; +import static org.codefx.libfx.nesting.testhelper.NestingAccess.setNestingObservable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ChangeListener; + +import org.codefx.libfx.listener.handle.CreateListenerHandle; +import org.codefx.libfx.nesting.Nesting; +import org.codefx.libfx.nesting.testhelper.NestingAccess; +import org.junit.Before; +import org.junit.Test; + +/** + * Abstract superclass to tests of {@link NestedInvalidationListenerHandle}. + */ +public abstract class AbstractNestedInvalidationListenerHandleTest { + + // #region INSTANCES USED FOR TESTING + + /** + * The nesting's inner observable. + */ + private StringProperty innerObservable; + + /** + * The nesting to which the listener is added. + */ + private NestingAccess.EditableNesting nesting; + + /** + * The default listener. This {@link InvalidationListener} will be mocked to verify possible invocations. + */ + private InvalidationListener listener; + + /** + * A listener which fails the test when being called. + */ + private InvalidationListener listenerWhichFailsWhenCalled; + + /** + * The tested nested listener, which adds the {@link #listener} to the {@link #nesting}. + */ + private NestedInvalidationListenerHandle nestedListenerHandle; + + //#end INSTANCES USED FOR TESTING + + // #region SETUP + + /** + * Creates a new instance of {@link #nesting} and {@link #listener}. + *

+ * Note: A {@link #nestedListenerHandle} has to be created in the test according to the desired initial state + * (attached or detached). + */ + @Before + public void setUp() { + innerObservable = new SimpleStringProperty(); + nesting = NestingAccess.EditableNesting.createWithInnerObservable(innerObservable); + listener = mock(InvalidationListener.class); + listenerWhichFailsWhenCalled = any -> fail(); + } + + /** + * Creates a new, initially attached nested listener from the specified nesting and listener. + * + * @param nesting + * the {@link Nesting} to which the listener will be added + * @param listener + * the {@link ChangeListener} which will be added to the nesting + * @return a new {@link NestedChangeListenerHandle} + */ + private NestedInvalidationListenerHandle createAttachedNestedListenerHandle( + Nesting nesting, InvalidationListener listener) { + + return createNestedListenerHandle(nesting, listener, CreateListenerHandle.ATTACHED); + } + + /** + * Creates a new, initially detached nested listener from the specified nesting and listener. + * + * @param nesting + * the {@link Nesting} to which the listener will be added + * @param listener + * the {@link ChangeListener} which will be added to the nesting + * @return a new {@link NestedChangeListenerHandle} + */ + private NestedInvalidationListenerHandle createDetachedNestedListenerHandle( + Nesting nesting, InvalidationListener listener) { + + return createNestedListenerHandle(nesting, listener, CreateListenerHandle.DETACHED); + } + + /** + * Creates a new nested listener from the specified nesting and listener. + * + * @param nesting + * the {@link Nesting} to which the listener will be added + * @param listener + * the {@link ChangeListener} which will be added to the nesting + * @param attachedOrDetached + * indicates whether the listener will be initially attached or detached + * @return a new {@link NestedChangeListenerHandle} + */ + protected abstract NestedInvalidationListenerHandle createNestedListenerHandle( + Nesting nesting, + InvalidationListener listener, + CreateListenerHandle attachedOrDetached); + + //end SETUP + + // #region TESTS + + // construction + + /** + * Tests whether the properties the tested nested listener owns have the correct bean. + */ + @Test + public void testPropertyBean() { + nestedListenerHandle = createDetachedNestedListenerHandle(nesting, listener); + assertSame(nestedListenerHandle, nestedListenerHandle.innerObservablePresentProperty().getBean()); + } + + /** + * Tests whether the {@link #nestedListenerHandle} correctly reports whether the inner observable is present. + */ + @Test + public void testObservablePresentAfterConstruction() { + nestedListenerHandle = createDetachedNestedListenerHandle(nesting, listener); + assertTrue(nestedListenerHandle.isInnerObservablePresent()); + } + + /** + * Tests whether the construction does not call the {@link #listener}. + */ + @Test + public void testNoInteractionWithListenerDuringConstruction() { + nestedListenerHandle = createDetachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + } + + // changing value + + /** + * Tests whether no listener invocation occurs when the nesting's observable changes its value and the listener is + * initially detached. + */ + @Test + public void testChangingValueWhenInitiallyDetached() { + nestedListenerHandle = createDetachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + innerObservable.set("new value"); + } + + /** + * Tests whether the listener is correctly invoked when the nesting's observable changes its value. + */ + @Test + public void testChangingValue() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listener); + innerObservable.set("new value"); + + // assert that 'invalidated' was called once and with the right observable + verify(listener, times(1)).invalidated(innerObservable); + verifyNoMoreInteractions(listener); + } + + // changing observable + + /** + * Tests whether no listener invocation occurs when the nesting's inner observable is changed. + */ + @Test + public void testChangingObservable() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + StringProperty newObservable = new SimpleStringProperty(); + setNestingObservable(nesting, newObservable); + + assertTrue(nestedListenerHandle.isInnerObservablePresent()); + } + + /** + * Tests whether no listener invocation occurs when the nesting's inner observable is changed to null. + */ + @Test + public void testChangingObservableToNull() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + setNestingObservable(nesting, null); + + assertFalse(nestedListenerHandle.isInnerObservablePresent()); + } + + // changing observable and value + + /** + * Tests whether the listener is correctly invoked when the nesting's new observable gets a new value. + */ + @Test + public void testChangingNewObservablesValue() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listener); + // set a new observable ... + StringProperty newObservable = new SimpleStringProperty(); + setNestingObservable(nesting, newObservable); + // (assert that setting the observable worked) + assertEquals(newObservable, getNestingObservable(nesting)); + + // ... and change its value + newObservable.setValue("new observable's new value"); + + // assert that the listener was invoked once and with the right observable + verify(listener, times(1)).invalidated(newObservable); + verifyNoMoreInteractions(listener); + } + + /** + * Tests whether the listener is not invoked when the nesting's old observable gets a new value. + */ + @Test + public void testChangingOldObservablesValue() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + // set a new observable ... + StringProperty newObservable = new SimpleStringProperty(); + setNestingObservable(nesting, newObservable); + // (assert that setting the observable worked) + assertEquals(newObservable, getNestingObservable(nesting)); + + // ... and change the old observable's value + innerObservable.setValue("intial observable's new value"); + } + + // attach & detach + + /** + * Tests whether no listener invocation occurs when the nesting's inner observable's value is changed after the + * listener was detached. + */ + @Test + public void testDetach() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + nestedListenerHandle.detach(); + + innerObservable.set("new value while detached"); + } + + /** + * Tests whether the listener ignores values after it was detached repeatedly. + */ + @Test + public void testMultipleDetach() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listenerWhichFailsWhenCalled); + nestedListenerHandle.detach(); + nestedListenerHandle.detach(); + nestedListenerHandle.detach(); + + innerObservable.set("new value while detached"); + } + + /** + * Tests whether the listener is correctly invoked when the nesting's observable changes its value after the + * listener was detached and reattached. + */ + @Test + public void testReattach() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listener); + nestedListenerHandle.detach(); + nestedListenerHandle.attach(); + innerObservable.set("new value"); + + // assert that 'invalidated' was called once and with the right arguments + verify(listener, times(1)).invalidated(innerObservable); + verifyNoMoreInteractions(listener); + } + + /** + * Tests whether the listener is only called once even when attached is called repeatedly. + */ + @Test + public void testMultipleAttach() { + nestedListenerHandle = createAttachedNestedListenerHandle(nesting, listener); + nestedListenerHandle.attach(); + nestedListenerHandle.attach(); + nestedListenerHandle.attach(); + innerObservable.set("new value"); + + // assert that 'invalidated' was called only once + verify(listener, times(1)).invalidated(innerObservable); + verifyNoMoreInteractions(listener); + } + + //#end TESTS + +} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerTest.java b/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerTest.java deleted file mode 100644 index 6445eed..0000000 --- a/src/test/java/org/codefx/libfx/nesting/listener/AbstractNestedInvalidationListenerTest.java +++ /dev/null @@ -1,175 +0,0 @@ -package org.codefx.libfx.nesting.listener; - -import static org.codefx.libfx.nesting.testhelper.NestingAccess.getNestingObservable; -import static org.codefx.libfx.nesting.testhelper.NestingAccess.setNestingObservable; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.verifyZeroInteractions; -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; - -import org.codefx.libfx.nesting.Nesting; -import org.codefx.libfx.nesting.testhelper.NestingAccess; -import org.codefx.libfx.nesting.testhelper.NestingAccess.EditableNesting; -import org.junit.Before; -import org.junit.Test; - -/** - * Abstract superclass to tests of {@link NestedInvalidationListener}. - */ -public abstract class AbstractNestedInvalidationListenerTest { - - // #region INSTANCES USED FOR TESTING - - /** - * The nesting's inner observable. - */ - private StringProperty innerObservable; - - /** - * The nesting to which the listener is added. - */ - private NestingAccess.EditableNesting nesting; - - /** - * The added listener. This {@link InvalidationListener} will be mocked to verify possible invocations. - */ - private InvalidationListener listener; - - /** - * The tested nested listener, which adds the {@link #listener} to the {@link #nesting}. - */ - private NestedInvalidationListener nestedListener; - - //#end INSTANCES USED FOR TESTING - - /** - * Creates a new instance of {@link #nesting}, {@link #listener} and {@link #nestedListener}. - */ - @Before - public void setUp() { - innerObservable = new SimpleStringProperty(); - nesting = NestingAccess.EditableNesting.createWithInnerObservable(innerObservable); - listener = mock(InvalidationListener.class); - nestedListener = createNestedListener(nesting, listener); - } - - // #region TESTS - - /** - * Tests whether the properties the tested nested listener owns have the correct bean. - */ - @Test - public void testPropertyBean() { - assertSame(nestedListener, nestedListener.innerObservablePresentProperty().getBean()); - } - - /** - * Tests whether the {@link #nestedListener} and {@link #listener} are in the correct state after construction. - */ - @Test - public void testStateAfterConstruction() { - assertTrue(nestedListener.isInnerObservablePresent()); - // the listener must not have been called - verifyZeroInteractions(listener); - } - - /** - * Tests whether the listener is correctly invoked when the nesting's observable changes its value. - */ - @Test - public void testChangingValue() { - innerObservable.set("new value"); - - // assert that 'invalidated' was called once and with the right observable - verify(listener, times(1)).invalidated(innerObservable); - verifyNoMoreInteractions(listener); - } - - /** - * Tests whether no listener invocation occurs when the nesting's inner observable is changed. - */ - @Test - public void testChangingObservable() { - StringProperty newObservable = new SimpleStringProperty(); - setNestingObservable(nesting, newObservable); - - assertTrue(nestedListener.isInnerObservablePresent()); - verifyZeroInteractions(listener); - } - - /** - * Tests whether no listener invocation occurs when the nesting's inner observable is changed to null. - */ - @Test - public void testChangingObservableToNull() { - setNestingObservable(nesting, null); - - assertFalse(nestedListener.isInnerObservablePresent()); - verifyZeroInteractions(listener); - } - - /** - * Tests whether the listener is correctly invoked when the nesting's new observable gets a new value. - */ - @Test - public void testChangingNewObservablesValue() { - // set a new observable ... - StringProperty newObservable = new SimpleStringProperty(); - setNestingObservable(nesting, newObservable); - // (assert that setting the observable worked) - assertEquals(newObservable, getNestingObservable(nesting)); - - // ... and change its value - newObservable.setValue("new observable's new value"); - - // assert that the listener was invoked once and with the right observable - verify(listener, times(1)).invalidated(newObservable); - verifyNoMoreInteractions(listener); - } - - /** - * Tests whether the listener is not invoked when the nesting's old observable gets a new value. - */ - @Test - public void testChangingOldObservablesValue() { - // set a new observable ... - StringProperty newObservable = new SimpleStringProperty(); - setNestingObservable(nesting, newObservable); - // (assert that setting the observable worked) - assertEquals(newObservable, getNestingObservable(nesting)); - - // ... and change the old observable's value - innerObservable.setValue("intial observable's new value"); - - // assert the listener was not invoked - verifyZeroInteractions(listener); - } - - //#end TESTS - - // #region ABSTRACT METHODS - - /** - * Creates a new nested listener from the specified nesting and listener. - * - * @param nesting - * the {@link Nesting} to which the listener will be added - * @param listener - * the {@link InvalidationListener} which will be added to the nesting - * @return a new {@link NestedInvalidationListener} - */ - protected abstract NestedInvalidationListener createNestedListener( - EditableNesting nesting, InvalidationListener listener); - - //end ABSTRACT METHODS - -} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilderTest.java b/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilderTest.java index d99db9b..ced9098 100644 --- a/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilderTest.java +++ b/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilderTest.java @@ -1,10 +1,12 @@ package org.codefx.libfx.nesting.listener; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; +import org.codefx.libfx.listener.handle.CreateListenerHandle; +import org.codefx.libfx.nesting.Nesting; import org.codefx.libfx.nesting.testhelper.NestingAccess.EditableNesting; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -16,7 +18,7 @@ @RunWith(Suite.class) @SuiteClasses({ NestedChangeListenerBuilderTest.Builder.class, - NestedChangeListenerBuilderTest.CreatedListeners.class, + NestedChangeListenerBuilderTest.CreatedListenerHandles.class, }) public class NestedChangeListenerBuilderTest { @@ -26,27 +28,36 @@ public class NestedChangeListenerBuilderTest { public static class Builder extends AbstractNestedChangeListenerBuilderTest { @Override - protected NestedChangeListenerBuilder createBuilder() { - StringProperty innerObservable = new SimpleStringProperty(); - EditableNesting nesting = EditableNesting.createWithInnerObservable(innerObservable); + protected NestedChangeListenerBuilder> createBuilder() { + Property innerObservable = new SimpleObjectProperty<>(); + EditableNesting> nesting = EditableNesting.createWithInnerObservable(innerObservable); return NestedChangeListenerBuilder.forNesting(nesting); } } /** - * Tests whether the created listeners behave well. + * Tests whether the created listener handles behave well. */ - public static class CreatedListeners extends AbstractNestedChangeListenerTest { + public static class CreatedListenerHandles extends AbstractNestedChangeListenerHandleTest { @Override - protected NestedChangeListener createNestedListener( - EditableNesting> nesting, ChangeListener listener) { + protected NestedChangeListenerHandle createNestedListenerHandle( + Nesting> nesting, + ChangeListener listener, + CreateListenerHandle attachedOrDetached) { - return NestedChangeListenerBuilder - .forNesting(nesting) - .withListener(listener) - .build(); + NestedChangeListenerBuilder>.Buildable builder = + NestedChangeListenerBuilder + .forNesting(nesting) + .withListener(listener); + + if (attachedOrDetached == CreateListenerHandle.ATTACHED) + return builder.buildAttached(); + else if (attachedOrDetached == CreateListenerHandle.DETACHED) + return builder.buildDetached(); + else + throw new IllegalArgumentException(); } } diff --git a/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandleTest.java b/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandleTest.java new file mode 100644 index 0000000..1147319 --- /dev/null +++ b/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandleTest.java @@ -0,0 +1,26 @@ +package org.codefx.libfx.nesting.listener; + +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; + +import org.codefx.libfx.listener.handle.CreateListenerHandle; +import org.codefx.libfx.nesting.Nesting; + +/** + * Tests the class {@link NestedChangeListenerHandle}. + */ +public class NestedChangeListenerHandleTest extends AbstractNestedChangeListenerHandleTest { + + @Override + protected NestedChangeListenerHandle createNestedListenerHandle( + Nesting> nesting, + ChangeListener listener, + CreateListenerHandle attachedOrDetached) { + + NestedChangeListenerHandle listenerHandle = new NestedChangeListenerHandle(nesting, listener); + if (attachedOrDetached == CreateListenerHandle.ATTACHED) + listenerHandle.attach(); + return listenerHandle; + } + +} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerTest.java b/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerTest.java deleted file mode 100644 index c4872ca..0000000 --- a/src/test/java/org/codefx/libfx/nesting/listener/NestedChangeListenerTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.codefx.libfx.nesting.listener; - -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; - -import org.codefx.libfx.nesting.testhelper.NestingAccess.EditableNesting; - -/** - * Tests the class {@link NestedChangeListener}. - */ -public class NestedChangeListenerTest extends AbstractNestedChangeListenerTest { - - @Override - protected NestedChangeListener createNestedListener( - EditableNesting> nesting, ChangeListener listener) { - - return new NestedChangeListener(nesting, listener); - } - -} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilderTest.java b/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilderTest.java index 67fe0ff..653ff9a 100644 --- a/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilderTest.java +++ b/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilderTest.java @@ -5,6 +5,8 @@ import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; +import org.codefx.libfx.listener.handle.CreateListenerHandle; +import org.codefx.libfx.nesting.Nesting; import org.codefx.libfx.nesting.testhelper.NestingAccess.EditableNesting; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -37,16 +39,25 @@ protected NestedInvalidationListenerBuilder createBuilder() { /** * Tests whether the created listeners behave well. */ - public static class CreatedListeners extends AbstractNestedInvalidationListenerTest { + public static class CreatedListeners extends AbstractNestedInvalidationListenerHandleTest { @Override - protected NestedInvalidationListener createNestedListener( - EditableNesting nesting, InvalidationListener listener) { + protected NestedInvalidationListenerHandle createNestedListenerHandle( + Nesting nesting, + InvalidationListener listener, + CreateListenerHandle attachedOrDetached) { - return NestedInvalidationListenerBuilder - .forNesting(nesting) - .withListener(listener) - .build(); + NestedInvalidationListenerBuilder.Buildable builder = + NestedInvalidationListenerBuilder + .forNesting(nesting) + .withListener(listener); + + if (attachedOrDetached == CreateListenerHandle.ATTACHED) + return builder.buildAttached(); + else if (attachedOrDetached == CreateListenerHandle.DETACHED) + return builder.buildDetached(); + else + throw new IllegalArgumentException(); } } diff --git a/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandleTest.java b/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandleTest.java new file mode 100644 index 0000000..19b7fd8 --- /dev/null +++ b/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandleTest.java @@ -0,0 +1,26 @@ +package org.codefx.libfx.nesting.listener; + +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; + +import org.codefx.libfx.listener.handle.CreateListenerHandle; +import org.codefx.libfx.nesting.Nesting; + +/** + * Tests the class {@link NestedInvalidationListenerHandle}. + */ +public class NestedInvalidationListenerHandleTest extends AbstractNestedInvalidationListenerHandleTest { + + @Override + protected NestedInvalidationListenerHandle createNestedListenerHandle( + Nesting nesting, + InvalidationListener listener, + CreateListenerHandle attachedOrDetached) { + + NestedInvalidationListenerHandle listenerHandle = new NestedInvalidationListenerHandle(nesting, listener); + if (attachedOrDetached == CreateListenerHandle.ATTACHED) + listenerHandle.attach(); + return listenerHandle; + } + +} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerTest.java b/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerTest.java deleted file mode 100644 index ee75a1e..0000000 --- a/src/test/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.codefx.libfx.nesting.listener; - -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; - -import org.codefx.libfx.nesting.testhelper.NestingAccess.EditableNesting; - -/** - * Tests the class {@link NestedInvalidationListener}. - */ -public class NestedInvalidationListenerTest extends AbstractNestedInvalidationListenerTest { - - @Override - protected NestedInvalidationListener createNestedListener( - EditableNesting nesting, InvalidationListener listener) { - - return new NestedInvalidationListener(nesting, listener); - } - -} diff --git a/src/test/java/org/codefx/libfx/nesting/listener/_AllNestedListenerTests.java b/src/test/java/org/codefx/libfx/nesting/listener/_AllNestedListenerTests.java deleted file mode 100644 index cfd3610..0000000 --- a/src/test/java/org/codefx/libfx/nesting/listener/_AllNestedListenerTests.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.codefx.libfx.nesting.listener; - -import org.junit.runner.RunWith; -import org.junit.runners.Suite; -import org.junit.runners.Suite.SuiteClasses; - -/** - * Runs all tests in this package and its subpackages. - */ -@RunWith(Suite.class) -@SuiteClasses({ - NestedChangeListenerBuilderTest.class, - NestedChangeListenerTest.class, - NestedInvalidationListenerBuilderTest.class, - NestedInvalidationListenerTest.class, -}) -public class _AllNestedListenerTests { - // no body needed -} diff --git a/src/test/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyTest.java b/src/test/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyTest.java index 04e6ab9..bb454a9 100644 --- a/src/test/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyTest.java +++ b/src/test/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyTest.java @@ -266,7 +266,7 @@ public void testChangingOldObservablesValue() { //#end ABSTRACT METHODS - // #region ATTRIBUTE ACCESS + // #region ACCESSORS /** * @return the nesting on which the tested property is based @@ -289,6 +289,6 @@ public T getPropertyValue() { return property.getValue(); } - //#end ATTRIBUTE ACCESS + //#end ACCESSORS } diff --git a/src/test/java/org/codefx/libfx/nesting/property/_AllNestedPropertyTests.java b/src/test/java/org/codefx/libfx/nesting/property/_AllNestedPropertyTests.java deleted file mode 100644 index 6de1900..0000000 --- a/src/test/java/org/codefx/libfx/nesting/property/_AllNestedPropertyTests.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.codefx.libfx.nesting.property; - -import org.junit.runner.RunWith; -import org.junit.runners.Suite; -import org.junit.runners.Suite.SuiteClasses; - -/** - * Runs all tests in this package and its subpackages. - */ -@RunWith(Suite.class) -@SuiteClasses({ - NestedBooleanPropertyBuilderTest.class, - NestedBooleanPropertyTest.class, - NestedDoublePropertyBuilderTest.class, - NestedDoublePropertyTest.class, - NestedFloatPropertyBuilderTest.class, - NestedFloatPropertyTest.class, - NestedIntegerPropertyBuilderTest.class, - NestedIntegerPropertyTest.class, - NestedLongPropertyBuilderTest.class, - NestedLongPropertyTest.class, - NestedObjectPropertyBuilderTest.class, - NestedObjectPropertyTest.class, - NestedStringPropertyBuilderTest.class, - NestedStringPropertyTest.class, -}) -public class _AllNestedPropertyTests { - // no body needed -} diff --git a/src/test/java/org/codefx/libfx/nesting/testhelper/InnerValue.java b/src/test/java/org/codefx/libfx/nesting/testhelper/InnerValue.java index 4be5833..4c498c5 100644 --- a/src/test/java/org/codefx/libfx/nesting/testhelper/InnerValue.java +++ b/src/test/java/org/codefx/libfx/nesting/testhelper/InnerValue.java @@ -68,7 +68,7 @@ public static InnerValue createWithObservables() { // #end CONSTRUCTOR -// #region PROPERTY ACCESS +// #region ACCESSORS /** * An observable. @@ -97,6 +97,6 @@ public IntegerProperty integerProperty() { return integerProperty; } -// #end PROPERTY ACCESS +// #end ACCESSORS } diff --git a/src/test/java/org/codefx/libfx/serialization/SerializableOptionalTest.java b/src/test/java/org/codefx/libfx/serialization/SerializableOptionalTest.java new file mode 100644 index 0000000..2af4e58 --- /dev/null +++ b/src/test/java/org/codefx/libfx/serialization/SerializableOptionalTest.java @@ -0,0 +1,192 @@ +package org.codefx.libfx.serialization; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Optional; + +import org.junit.Test; + +/** + * Tests the class {@link SerializableOptional}. + */ +public class SerializableOptionalTest { + + // #region CONSTRUCTION + + /** + * Tests whether a {@code SerializableOptional} can be created from an empty {@code Optional} with + * {@link SerializableOptional#fromOptional(Optional) fromOptional(Optional)}. + */ + @Test + public void testFromEmptyOptional() { + Optional empty = Optional.empty(); + SerializableOptional emptySerializable = SerializableOptional.fromOptional(empty); + + // note that 'Optional' is a value-based class and reference identity must not be relied upon; + // hence not 'assertSame' + assertEquals(empty, emptySerializable.asOptional()); + } + + /** + * Tests whether a {@code SerializableOptional} can be created from a non-empty {@code Optional} with + * {@link SerializableOptional#fromOptional(Optional) fromOptional(Optional)}. + */ + @Test + public void testFromNonEmptyOptional() { + Optional nonEmpty = Optional.of("Not Empty!"); + SerializableOptional nonEmptySerializable = SerializableOptional.fromOptional(nonEmpty); + + // note that 'Optional' is a value-based class and reference identity must not be relied upon; + // hence not 'assertSame' + assertEquals(nonEmpty, nonEmptySerializable.asOptional()); + } + + /** + * Tests whether an empty {@code SerializableOptional} can be created. + */ + @Test + public void testEmpty() { + SerializableOptional emptySerializable = SerializableOptional.empty(); + + assertFalse(emptySerializable.asOptional().isPresent()); + } + + /** + * Tests whether calling {@link SerializableOptional#of(Serializable) of(null)} throws a + * {@link NullPointerException}. + */ + @Test(expected = NullPointerException.class) + public void testOfWithNull() { + SerializableOptional.of(null); + } + + /** + * Tests whether a {@code SerializableOptional} can be created from a non-null reference with + * {@link SerializableOptional#of(Serializable) of(Serializable)}. + */ + @Test + public void testOfWithNonNull() { + String notNull = "Not Null!"; + SerializableOptional presentSerializable = SerializableOptional.of(notNull); + + assertEquals(notNull, presentSerializable.asOptional().get()); + } + + /** + * Tests whether a {@code SerializableOptional} can be created from a null reference with + * {@link SerializableOptional#ofNullable(Serializable) ofNullable(Serializable)}. + */ + @Test + public void testOfNullableWithNull() { + SerializableOptional emptySerializable = SerializableOptional.ofNullable(null); + + assertFalse(emptySerializable.asOptional().isPresent()); + } + + /** + * Tests whether a {@code SerializableOptional} can be created from a non-null reference with + * {@link SerializableOptional#ofNullable(Serializable) ofNullable(Serializable)}. + */ + @Test + public void testOfNullableWithNonNull() { + String notNull = "Not Null!"; + SerializableOptional presentSerializable = SerializableOptional.ofNullable(notNull); + + assertEquals(notNull, presentSerializable.asOptional().get()); + } + + // #end CONSTRUCTION + + // #region SERIALIZATION + + /** + * Tests whether {@link SerializableOptional} with an empty {@link Optional} can be serialized. + * + * @throws Exception + * if serialization fails + */ + @Test + public void testSerializeEmpty() throws Exception { + SerializableOptional empty = SerializableOptional.ofNullable(null); + // serialize + try (ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream())) { + out.writeObject(empty); + } + } + + /** + * Tests whether {@link SerializableOptional} with a non-empty {@link Optional} can be serialized. + * + * @throws Exception + * if serialization fails + */ + @Test + public void testSerializeNonEmpty() throws Exception { + SerializableOptional nonEmpty = SerializableOptional.ofNullable("Not Null!"); + // serialize + try (ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream())) { + out.writeObject(nonEmpty); + } + } + + /** + * Tests whether {@link SerializableOptional} with an empty {@link Optional} can be deserialized. + * + * @throws Exception + * if serialization fails + */ + @Test + public void testDeserializeEmpty() throws Exception { + SerializableOptional emptyToSerialize = SerializableOptional.ofNullable(null); + SerializableOptional deserializedEmpty = null; + + ByteArrayOutputStream serialized = new ByteArrayOutputStream(); + // serialize + try (ObjectOutputStream out = new ObjectOutputStream(serialized)) { + out.writeObject(emptyToSerialize); + } + // deserialize + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(serialized.toByteArray()))) { + @SuppressWarnings("unchecked") + SerializableOptional deserialized = (SerializableOptional) in.readObject(); + deserializedEmpty = deserialized; + } + + assertEquals(emptyToSerialize.asOptional(), deserializedEmpty.asOptional()); + } + + /** + * Tests whether {@link SerializableOptional} with a non-empty {@link Optional} can be deserialized. + * + * @throws Exception + * if serialization fails + */ + @Test + public void testDeserializeNonEmpty() throws Exception { + SerializableOptional nonEmptyToSerialize = SerializableOptional.ofNullable("Not Null!"); + SerializableOptional deserializedNonEmpty = null; + + ByteArrayOutputStream serialized = new ByteArrayOutputStream(); + // serialize + try (ObjectOutputStream out = new ObjectOutputStream(serialized)) { + out.writeObject(nonEmptyToSerialize); + } + // deserialize + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(serialized.toByteArray()))) { + @SuppressWarnings("unchecked") + SerializableOptional deserialized = (SerializableOptional) in.readObject(); + deserializedNonEmpty = deserialized; + } + + assertEquals(nonEmptyToSerialize.asOptional(), deserializedNonEmpty.asOptional()); + } + + // #end SERIALIZATION + +}