diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java b/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java
index 9938b32..af5996b 100644
--- a/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java
+++ b/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java
@@ -4,8 +4,10 @@ package de.neitzel.fx.view;
* A Direct binding declared in FXML.
*
* The element names the control by {@code fx:id}, the control property, the Path, and the
- * direction. A Path of one name is that Attribute of this View's ViewModel. Direction values
- * are {@code view}, {@code model}, and {@code both}.
+ * direction. A Path of one name is that Attribute of this View's ViewModel. Further names step
+ * into the ViewModel of a Complex attribute, and the direction applies to the last name.
+ * When that last name is complex, the control receives the Model instance behind the ViewModel.
+ * Direction values are {@code view}, {@code model}, and {@code both}.
*/
public class DirectBinding {
diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java
index 73d170c..83af595 100644
--- a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java
+++ b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java
@@ -26,11 +26,20 @@ final class DirectBindingApplier {
if (control == null) {
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
}
+ Resolved resolved = resolve(viewModel, binding.getPath());
+ if (resolved == null) {
+ return;
+ }
Property> controlProperty = controlProperty(control, binding.getProperty());
- Property> attribute = viewModel.property(binding.getPath());
- if (attribute == null) {
- throw new IllegalArgumentException(
- "No read-write attribute for path \"" + binding.getPath() + "\"");
+ Property> attribute = resolved.attribute;
+ if (resolved.complex) {
+ switch (binding.getDirection()) {
+ case "both" -> alignBothComplex(controlProperty, attribute);
+ case "view" -> alignTowardViewComplex(controlProperty, attribute);
+ case "model" -> alignTowardModelComplex(controlProperty, attribute);
+ default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
+ }
+ return;
}
switch (binding.getDirection()) {
case "both" -> alignBoth(controlProperty, attribute);
@@ -40,6 +49,61 @@ final class DirectBindingApplier {
}
}
+ /**
+ * The Attribute a Path names, and whether that Attribute is complex.
+ */
+ private static final class Resolved {
+ private final Property> attribute;
+ private final boolean complex;
+
+ private Resolved(Property> attribute, boolean complex) {
+ this.attribute = attribute;
+ this.complex = complex;
+ }
+ }
+
+ /**
+ * Follows a Path to the Attribute it names.
+ *
+ * One name is an Attribute of {@code viewModel}. Each earlier name steps into the ViewModel
+ * held by that Complex attribute. A null Complex attribute along the Path leaves the binding
+ * unapplied.
+ *
+ * @param viewModel the ViewModel for this View
+ * @param path the Path
+ * @return the Attribute for the last name, or {@code null} when a Complex attribute
+ * along the Path is null
+ */
+ private static Resolved resolve(ViewModel viewModel, String path) {
+ String[] names = path.split("\\.", -1);
+ ViewModel current = viewModel;
+ for (int index = 0; index < names.length - 1; index++) {
+ String name = names[index];
+ Property> step = current.property(name);
+ if (step == null || !current.isComplex(name)) {
+ throw missing(path);
+ }
+ Object nested = step.getValue();
+ if (nested == null) {
+ return null;
+ }
+ if (!(nested instanceof ViewModel child)) {
+ throw missing(path);
+ }
+ current = child;
+ }
+ String last = names[names.length - 1];
+ Property> attribute = current.property(last);
+ if (attribute == null) {
+ throw missing(path);
+ }
+ return new Resolved(attribute, current.isComplex(last));
+ }
+
+ private static IllegalArgumentException missing(String path) {
+ return new IllegalArgumentException("No read-write attribute for path \"" + path + "\"");
+ }
+
private static Property> controlProperty(Object control, String propertyName) {
String methodName = propertyName + "Property";
try {
@@ -57,6 +121,53 @@ final class DirectBindingApplier {
}
}
+ @SuppressWarnings("unchecked")
+ private static void alignBothComplex(Property> controlProperty, Property> attribute) {
+ Property control = (Property) controlProperty;
+ Property source = (Property) attribute;
+ control.setValue(modelBehind(source.getValue()));
+ source.addListener((observable, oldValue, newValue) -> {
+ Object model = modelBehind(newValue);
+ if (control.getValue() != model) {
+ control.setValue(model);
+ }
+ });
+ control.addListener((observable, oldValue, newValue) -> {
+ if (modelBehind(source.getValue()) == newValue) {
+ return;
+ }
+ source.setValue(newValue == null ? null : ViewModels.of(newValue));
+ });
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void alignTowardViewComplex(Property> controlProperty, Property> attribute) {
+ Property control = (Property) controlProperty;
+ Property source = (Property) attribute;
+ control.setValue(modelBehind(source.getValue()));
+ source.addListener((observable, oldValue, newValue) -> control.setValue(modelBehind(newValue)));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void alignTowardModelComplex(Property> controlProperty, Property> attribute) {
+ Property control = (Property) controlProperty;
+ Property source = (Property) attribute;
+ control.setValue(modelBehind(source.getValue()));
+ control.addListener((observable, oldValue, newValue) -> {
+ if (modelBehind(source.getValue()) == newValue) {
+ return;
+ }
+ source.setValue(newValue == null ? null : ViewModels.of(newValue));
+ });
+ }
+
+ private static Object modelBehind(Object value) {
+ if (value instanceof ViewModel viewModel) {
+ return viewModel.model();
+ }
+ return null;
+ }
+
@SuppressWarnings("unchecked")
private static void alignBoth(Property> controlProperty, Property> attribute) {
((Property) controlProperty).bindBidirectional((Property) attribute);
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
index 4189a94..1fef237 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
@@ -18,13 +18,17 @@ public final class ViewLoader {
/**
* Loads a View for one Model instance.
*
- * Builds a ViewModel for {@code model}, loads {@code fxml}, and applies Direct bindings
- * declared in {@code fx:define}. Direction {@code both} keeps the control and the Attribute
+ * Reuses the ViewModel already built for {@code model}, or builds one, then loads {@code fxml}
+ * and applies Direct bindings declared in {@code fx:define}. Direction {@code both} keeps the
+ * control and the Attribute
* aligned, so editing the control writes the Attribute immediately. Direction {@code view}
* shows the Attribute and follows later property changes without writing the Attribute back.
* Direction {@code model} shows the current value, writes the Attribute when the control
- * changes, and does not push later property changes to the control. A path that is not a
- * read-write Attribute fails the load. The context is used when the document names a Controller.
+ * changes, and does not push later property changes to the control. A Path of one name is an
+ * Attribute of this View's ViewModel. Further names step into a nested ViewModel, and the
+ * direction applies to the last name. When a Complex attribute along the Path is null, the
+ * control and the Model keep their current values. A path that is not a read-write Attribute
+ * fails the load. The context is used when the document names a Controller.
*
* @param fxml the FXML document
* @param model the Model instance
@@ -33,7 +37,7 @@ public final class ViewLoader {
* @throws IllegalArgumentException when a Direct binding names a path that is not a read-write Attribute
*/
public static LoadedView load(URL fxml, Object model, ApplicationContext context) {
- ViewModel viewModel = new ViewModel(model);
+ ViewModel viewModel = ViewModels.of(model);
LoadSession session = LoadSession.open();
try {
FXMLLoader loader = new FXMLLoader(fxml);
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
index 1e108a1..b2b1d93 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
@@ -20,9 +20,14 @@ import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.Date;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* The object a View binds to, built at runtime for one Model instance.
@@ -31,14 +36,21 @@ import java.util.Map;
* property under the Attribute name. The property class follows the Attribute's declared type,
* including when the current value is null. String, integer, long, float, double, and boolean
* attributes use the matching JavaFX property. Any other declared type is an object property.
+ * A Complex attribute, a read-write Attribute whose declared type itself has a read-write
+ * Attribute, stores the ViewModel for the nested Model instance, or null when the Attribute
+ * is null. Strings, numbers, booleans, enums, {@link Date}, {@link Calendar}, and {@code java.time}
+ * values stay simple even when that type has setters. A collection is not a Complex attribute.
+ * A cycle, or another Attribute that reaches the same instance, reuses that ViewModel.
* The property is set from the Attribute when the ViewModel is created. A null number or
* boolean leaves the primitive property at its default. After that, a change of the property
- * writes the Attribute. A call to the entity's setter does not update the property.
+ * writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
+ * A call to the entity's setter does not update the property.
*/
public class ViewModel {
private final Object model;
private final Map> properties = new HashMap<>();
+ private final Set complexAttributes = new HashSet<>();
/**
* Builds a ViewModel for one Model instance.
@@ -47,6 +59,7 @@ public class ViewModel {
*/
ViewModel(Object model) {
this.model = model;
+ ViewModels.register(model, this);
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
Method getter = descriptor.getReadMethod();
Method setter = descriptor.getWriteMethod();
@@ -54,9 +67,19 @@ public class ViewModel {
continue;
}
String name = descriptor.getName();
- Property> property = createProperty(descriptor.getPropertyType(), read(getter, name));
- watch(property, setter, name);
- properties.put(name, property);
+ Class> type = descriptor.getPropertyType();
+ Object current = read(getter, name);
+ if (isComplex(type)) {
+ Property> property = new SimpleObjectProperty<>(
+ current == null ? null : ViewModels.of(current));
+ watchComplex(property, setter, name);
+ properties.put(name, property);
+ complexAttributes.add(name);
+ } else {
+ Property> property = createProperty(type, current);
+ watch(property, setter, name);
+ properties.put(name, property);
+ }
}
}
@@ -70,6 +93,25 @@ public class ViewModel {
return properties.get(name);
}
+ /**
+ * Returns the Model instance this ViewModel was built for.
+ *
+ * @return the Model instance
+ */
+ Object model() {
+ return model;
+ }
+
+ /**
+ * Reports whether an Attribute is complex.
+ *
+ * @param name the Attribute name
+ * @return {@code true} when that Attribute holds a nested ViewModel
+ */
+ boolean isComplex(String name) {
+ return complexAttributes.contains(name);
+ }
+
private static List descriptors(Class> type) {
try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
@@ -79,6 +121,38 @@ public class ViewModel {
}
}
+ private static boolean isComplex(Class> type) {
+ if (simpleValue(type)) {
+ return false;
+ }
+ for (PropertyDescriptor descriptor : descriptors(type)) {
+ if (descriptor.getReadMethod() != null
+ && descriptor.getWriteMethod() != null
+ && descriptor.getPropertyType() != null) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean simpleValue(Class> type) {
+ if (type.isPrimitive() || type.isEnum() || type.isArray()
+ || type == String.class || type == Boolean.class) {
+ return true;
+ }
+ if (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type)) {
+ return true;
+ }
+ if (Number.class.isAssignableFrom(type)) {
+ return true;
+ }
+ if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
+ return true;
+ }
+ Package packaged = type.getPackage();
+ return packaged != null && packaged.getName().startsWith("java.time");
+ }
+
private static Property> createProperty(Class> type, Object value) {
if (type == String.class) {
return new SimpleStringProperty((String) value);
@@ -153,6 +227,13 @@ public class ViewModel {
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
}
+ private void watchComplex(Property> property, Method setter, String name) {
+ property.addListener((observable, oldValue, newValue) -> {
+ Object written = newValue instanceof ViewModel viewModel ? viewModel.model() : newValue;
+ write(setter, name, written);
+ });
+ }
+
private void write(Method setter, String name, Object value) {
try {
setter.invoke(model, value);
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModels.java b/fx/src/main/java/de/neitzel/fx/view/ViewModels.java
new file mode 100644
index 0000000..ff8bfbd
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModels.java
@@ -0,0 +1,41 @@
+package de.neitzel.fx.view;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+/**
+ * The ViewModels already built, one for each Model instance.
+ *
+ * Instances are compared by identity, so two Models that are equal still get two ViewModels.
+ */
+final class ViewModels {
+
+ private static final Map BY_INSTANCE = new IdentityHashMap<>();
+
+ private ViewModels() {
+ }
+
+ /**
+ * Returns the ViewModel for a Model instance, building it when none exists yet.
+ *
+ * @param model the Model instance
+ * @return the ViewModel for that instance
+ */
+ static ViewModel of(Object model) {
+ ViewModel existing = BY_INSTANCE.get(model);
+ if (existing != null) {
+ return existing;
+ }
+ return new ViewModel(model);
+ }
+
+ /**
+ * Records a ViewModel before its Attributes are walked, so a cycle reuses it.
+ *
+ * @param model the Model instance
+ * @param viewModel the ViewModel under construction
+ */
+ static void register(Object model, ViewModel viewModel) {
+ BY_INSTANCE.put(model, viewModel);
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/ReachNestedAttributeTest.java b/fx/src/test/java/de/neitzel/fx/view/ReachNestedAttributeTest.java
new file mode 100644
index 0000000..1bf52c3
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/ReachNestedAttributeTest.java
@@ -0,0 +1,330 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.fixture.Address;
+import de.neitzel.fx.view.fixture.ObjectHolder;
+import de.neitzel.fx.view.fixture.Person;
+import de.neitzel.fx.view.fixture.Role;
+import de.neitzel.injection.ApplicationContext;
+import javafx.application.Platform;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextField;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.fail;
+
+class ReachNestedAttributeTest {
+
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ CountDownLatch started = new CountDownLatch(1);
+ try {
+ Platform.startup(started::countDown);
+ } catch (IllegalStateException alreadyStarted) {
+ started.countDown();
+ }
+ if (!started.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX toolkit did not start");
+ }
+ }
+
+ @Test
+ void complexAttributeHoldsTheViewModelForTheNestedAddress() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ Object addressValue = loaded.property("address").getValue();
+ assertInstanceOf(ViewModel.class, addressValue);
+ assertEquals("Main", ((ViewModel) addressValue).property("street").getValue());
+ });
+ }
+
+ @Test
+ void bothWaysTextFieldReadsAndWritesTheNestedStreet() throws Exception {
+ onFx(() -> {
+ Address address = new Address("Main");
+ Person person = new Person("Ada");
+ person.setAddress(address);
+ URL fxml = ReachNestedAttributeTest.class.getResource("address-street-both.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ assertEquals("Main", streetField.getText());
+ streetField.setText("Oak");
+ assertEquals("Oak", address.getStreet());
+ });
+ }
+
+ @Test
+ void towardViewLabelFollowsTheNestedStreetAndDoesNotWrite() throws Exception {
+ onFx(() -> {
+ Address address = new Address("Main");
+ Person person = new Person("Ada");
+ person.setAddress(address);
+ URL fxml = ReachNestedAttributeTest.class.getResource("address-street-both-and-view.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ Label streetLabel = (Label) loaded.getRoot().lookup("#streetLabel");
+ assertEquals("Main", streetLabel.getText());
+ streetField.setText("Oak");
+ assertEquals("Oak", streetLabel.getText());
+ assertEquals("Oak", address.getStreet());
+ streetLabel.setText("Side");
+ assertEquals("Oak", address.getStreet());
+ });
+ }
+
+ @Test
+ void bothWaysBindingOnAComplexPathYieldsTheModelInstance() throws Exception {
+ onFx(() -> {
+ Address address = new Address("Main");
+ Person person = new Person("Ada");
+ person.setAddress(address);
+ URL fxml = ReachNestedAttributeTest.class.getResource("address-object.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ ObjectHolder addressHolder = (ObjectHolder) loaded.getRoot().lookup("#addressHolder");
+ assertSame(address, addressHolder.getValue());
+ Address moved = new Address("Oak");
+ addressHolder.setValue(moved);
+ assertSame(moved, person.getAddress());
+ });
+ }
+
+ @Test
+ void fillingAMissingAddressStoresTheViewModelAndWritesTheModel() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ReachNestedAttributeTest.class.getResource("address-object.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ ObjectHolder addressHolder = (ObjectHolder) loaded.getRoot().lookup("#addressHolder");
+ assertNull(addressHolder.getValue());
+ assertNull(person.getAddress());
+ Address address = new Address("Main");
+ addressHolder.setValue(address);
+ assertSame(address, person.getAddress());
+ Object addressValue = loaded.property("address").getValue();
+ assertInstanceOf(ViewModel.class, addressValue);
+ assertEquals("Main", ((ViewModel) addressValue).property("street").getValue());
+ });
+ }
+
+ @Test
+ void missingAddressLeavesTheControlAndTheModelUnchanged() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ReachNestedAttributeTest.class.getResource("address-street-kept.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ assertEquals("kept", streetField.getText());
+ assertNull(person.getAddress());
+ });
+ }
+
+ @Test
+ void nullComplexAttributeHoldsNull() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ assertNull(loaded.property("address").getValue());
+ });
+ }
+
+ @Test
+ void attributesThatReachTheSameAddressShareItsViewModel() throws Exception {
+ onFx(() -> {
+ Address shared = new Address("Main");
+ Person person = new Person("Ada");
+ person.setHome(shared);
+ person.setWork(shared);
+ URL fxml = ReachNestedAttributeTest.class.getResource("home-work-street.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ assertSame(loaded.property("home").getValue(), loaded.property("work").getValue());
+ TextField homeField = (TextField) loaded.getRoot().lookup("#homeField");
+ TextField workField = (TextField) loaded.getRoot().lookup("#workField");
+ homeField.setText("Oak");
+ assertEquals("Oak", workField.getText());
+ assertEquals("Oak", shared.getStreet());
+ });
+ }
+
+ @Test
+ void equalAddressesKeepSeparateViewModels() throws Exception {
+ onFx(() -> {
+ Address home = new Address("Main");
+ Address work = new Address("Main");
+ assertEquals(home, work);
+ Person person = new Person("Ada");
+ person.setHome(home);
+ person.setWork(work);
+ URL fxml = ReachNestedAttributeTest.class.getResource("home-work-street.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ assertNotSame(loaded.property("home").getValue(), loaded.property("work").getValue());
+ TextField homeField = (TextField) loaded.getRoot().lookup("#homeField");
+ TextField workField = (TextField) loaded.getRoot().lookup("#workField");
+ homeField.setText("Oak");
+ assertEquals("Oak", home.getStreet());
+ assertEquals("Main", work.getStreet());
+ assertEquals("Main", workField.getText());
+ });
+ }
+
+ @Test
+ void aPersonWhoIsTheirOwnFriendReusesTheSameViewModel() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setFriend(person);
+ URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ assertSame(loaded.getViewModel(), loaded.property("friend").getValue());
+ });
+ }
+
+ @Test
+ void twoPeopleWhoPointAtEachOtherReuseTheViewModelsAlreadyBuilt() throws Exception {
+ onFx(() -> {
+ Person ada = new Person("Ada");
+ Person grace = new Person("Grace");
+ ada.setFriend(grace);
+ grace.setFriend(ada);
+ URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, ada, context);
+ ViewModel graceModel = (ViewModel) loaded.property("friend").getValue();
+ assertEquals("Grace", graceModel.property("name").getValue());
+ assertSame(loaded.getViewModel(), graceModel.property("friend").getValue());
+ });
+ }
+
+ @Test
+ void enumsBooleansAndDateValuesStaySimpleValues() throws Exception {
+ onFx(() -> {
+ Date updated = new Date(0L);
+ Calendar hired = new GregorianCalendar(2020, Calendar.JANUARY, 2);
+ LocalDate born = LocalDate.of(1815, 12, 10);
+ Person person = new Person("Ada");
+ person.setActive(true);
+ person.setRole(Role.ADMIN);
+ person.setBorn(born);
+ person.setUpdated(updated);
+ person.setHired(hired);
+ URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ assertEquals(true, loaded.property("active").getValue());
+ assertSame(Role.ADMIN, loaded.property("role").getValue());
+ assertEquals(born, loaded.property("born").getValue());
+ assertSame(updated, loaded.property("updated").getValue());
+ assertSame(hired, loaded.property("hired").getValue());
+ });
+ }
+
+ @Test
+ void aCollectionStaysTheCollectionRatherThanAViewModel() throws Exception {
+ onFx(() -> {
+ List tags = new ArrayList<>();
+ tags.add("friend");
+ Person person = new Person("Ada");
+ person.setTags(tags);
+ URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ assertSame(tags, loaded.property("tags").getValue());
+ });
+ }
+
+ @Test
+ void twoLoadsOfTheSamePersonShareTheViewModel() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView first = ViewLoader.load(fxml, person, context);
+ LoadedView second = ViewLoader.load(fxml, person, context);
+ assertSame(first.getViewModel(), second.getViewModel());
+ TextField firstName = (TextField) first.getRoot().lookup("#nameField");
+ TextField secondName = (TextField) second.getRoot().lookup("#nameField");
+ firstName.setText("Grace");
+ assertEquals("Grace", secondName.getText());
+ assertEquals("Grace", person.getName());
+ });
+ }
+
+ private static void onFx(FxAction action) throws Exception {
+ if (Platform.isFxApplicationThread()) {
+ action.run();
+ return;
+ }
+ AtomicReference thrown = new AtomicReference<>();
+ CountDownLatch done = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ try {
+ action.run();
+ } catch (Throwable failure) {
+ thrown.set(failure);
+ } finally {
+ done.countDown();
+ }
+ });
+ if (!done.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX thread did not finish");
+ }
+ Throwable failure = thrown.get();
+ if (failure instanceof Exception exception) {
+ throw exception;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ if (failure != null) {
+ throw new AssertionError(failure);
+ }
+ }
+
+ @FunctionalInterface
+ private interface FxAction {
+ void run() throws Exception;
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/Address.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Address.java
new file mode 100644
index 0000000..7ade13b
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Address.java
@@ -0,0 +1,54 @@
+package de.neitzel.fx.view.fixture;
+
+import java.util.Objects;
+
+/**
+ * Ordinary nested model used by view-loading tests.
+ *
+ * Equality follows the street, so two addresses with the same street are equal while remaining
+ * different instances.
+ */
+public class Address {
+
+ private String street;
+
+ /**
+ * Creates an address with the given street.
+ *
+ * @param street the current street
+ */
+ public Address(String street) {
+ this.street = street;
+ }
+
+ /**
+ * Returns the street.
+ *
+ * @return the street
+ */
+ public String getStreet() {
+ return street;
+ }
+
+ /**
+ * Sets the street.
+ *
+ * @param street the street
+ */
+ public void setStreet(String street) {
+ this.street = street;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other instanceof Address address) {
+ return Objects.equals(street, address.street);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(street);
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/ObjectHolder.java b/fx/src/test/java/de/neitzel/fx/view/fixture/ObjectHolder.java
new file mode 100644
index 0000000..7a45362
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/ObjectHolder.java
@@ -0,0 +1,40 @@
+package de.neitzel.fx.view.fixture;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.scene.layout.Pane;
+
+/**
+ * A small control whose value is an object, used to bind a Complex attribute.
+ */
+public class ObjectHolder extends Pane {
+
+ private final ObjectProperty value = new SimpleObjectProperty<>(this, "value");
+
+ /**
+ * Returns the value property.
+ *
+ * @return the value property
+ */
+ public ObjectProperty valueProperty() {
+ return value;
+ }
+
+ /**
+ * Returns the current value.
+ *
+ * @return the value
+ */
+ public Object getValue() {
+ return value.get();
+ }
+
+ /**
+ * Sets the value.
+ *
+ * @param value the value
+ */
+ public void setValue(Object value) {
+ this.value.set(value);
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java
index 12cfd17..1b7cc7d 100644
--- a/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java
@@ -1,5 +1,10 @@
package de.neitzel.fx.view.fixture;
+import java.time.LocalDate;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
/**
* Ordinary model used by view-loading tests.
*/
@@ -7,6 +12,16 @@ public class Person {
private String name;
private Integer age;
+ private boolean active;
+ private Address address;
+ private Address home;
+ private Address work;
+ private Person friend;
+ private Role role;
+ private LocalDate born;
+ private Date updated;
+ private Calendar hired;
+ private List tags;
/**
* Creates a person with the given name.
@@ -61,4 +76,184 @@ public class Person {
public void setAge(Integer age) {
this.age = age;
}
+
+ /**
+ * Returns the address.
+ *
+ * @return the address, or {@code null} when the person has none
+ */
+ public Address getAddress() {
+ return address;
+ }
+
+ /**
+ * Sets the address.
+ *
+ * @param address the address
+ */
+ public void setAddress(Address address) {
+ this.address = address;
+ }
+
+ /**
+ * Returns the home address.
+ *
+ * @return the home address
+ */
+ public Address getHome() {
+ return home;
+ }
+
+ /**
+ * Sets the home address.
+ *
+ * @param home the home address
+ */
+ public void setHome(Address home) {
+ this.home = home;
+ }
+
+ /**
+ * Returns the work address.
+ *
+ * @return the work address
+ */
+ public Address getWork() {
+ return work;
+ }
+
+ /**
+ * Sets the work address.
+ *
+ * @param work the work address
+ */
+ public void setWork(Address work) {
+ this.work = work;
+ }
+
+ /**
+ * Returns the friend.
+ *
+ * @return the friend
+ */
+ public Person getFriend() {
+ return friend;
+ }
+
+ /**
+ * Sets the friend.
+ *
+ * @param friend the friend
+ */
+ public void setFriend(Person friend) {
+ this.friend = friend;
+ }
+
+ /**
+ * Returns whether the person is active.
+ *
+ * @return {@code true} when the person is active
+ */
+ public boolean isActive() {
+ return active;
+ }
+
+ /**
+ * Sets whether the person is active.
+ *
+ * @param active {@code true} when the person is active
+ */
+ public void setActive(boolean active) {
+ this.active = active;
+ }
+
+ /**
+ * Returns the role.
+ *
+ * @return the role
+ */
+ public Role getRole() {
+ return role;
+ }
+
+ /**
+ * Sets the role.
+ *
+ * @param role the role
+ */
+ public void setRole(Role role) {
+ this.role = role;
+ }
+
+ /**
+ * Returns the birth date.
+ *
+ * @return the birth date
+ */
+ public LocalDate getBorn() {
+ return born;
+ }
+
+ /**
+ * Sets the birth date.
+ *
+ * @param born the birth date
+ */
+ public void setBorn(LocalDate born) {
+ this.born = born;
+ }
+
+ /**
+ * Returns the last update.
+ *
+ * @return the last update
+ */
+ public Date getUpdated() {
+ return updated;
+ }
+
+ /**
+ * Sets the last update.
+ *
+ * @param updated the last update
+ */
+ public void setUpdated(Date updated) {
+ this.updated = updated;
+ }
+
+ /**
+ * Returns the hire date.
+ *
+ * @return the hire date
+ */
+ public Calendar getHired() {
+ return hired;
+ }
+
+ /**
+ * Sets the hire date.
+ *
+ * @param hired the hire date
+ */
+ public void setHired(Calendar hired) {
+ this.hired = hired;
+ }
+
+ /**
+ * Returns the tags.
+ *
+ * @return the tags
+ */
+ public List getTags() {
+ return tags;
+ }
+
+ /**
+ * Sets the tags.
+ *
+ * @param tags the tags
+ */
+ public void setTags(List tags) {
+ this.tags = tags;
+ }
}
diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/Role.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Role.java
new file mode 100644
index 0000000..82bddd5
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Role.java
@@ -0,0 +1,9 @@
+package de.neitzel.fx.view.fixture;
+
+/**
+ * A simple enum Attribute used by view-loading tests.
+ */
+public enum Role {
+ MEMBER,
+ ADMIN
+}
diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-object.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-object.fxml
new file mode 100644
index 0000000..943d8d4
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/address-object.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-street-both-and-view.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-street-both-and-view.fxml
new file mode 100644
index 0000000..ce7c11d
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/address-street-both-and-view.fxml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-street-both.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-street-both.fxml
new file mode 100644
index 0000000..1f4e3f2
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/address-street-both.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-street-kept.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-street-kept.fxml
new file mode 100644
index 0000000..1e88e47
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/address-street-kept.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/home-work-street.fxml b/fx/src/test/resources/de/neitzel/fx/view/home-work-street.fxml
new file mode 100644
index 0000000..2f6963d
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/home-work-street.fxml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+