Let a Direct binding follow a dotted Path into a nested Model.

One ViewModel per Model instance keeps two Views and a cycle aligned, and a missing nested Model leaves both sides unchanged.
This commit is contained in:
2026-10-30 17:40:06 +01:00
parent 99886efa8b
commit 9474a7acfe
15 changed files with 962 additions and 15 deletions
@@ -4,8 +4,10 @@ package de.neitzel.fx.view;
* A Direct binding declared in FXML. * A Direct binding declared in FXML.
* <p> * <p>
* The element names the control by {@code fx:id}, the control property, the Path, and the * 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 * direction. A Path of one name is that Attribute of this View's ViewModel. Further names step
* are {@code view}, {@code model}, and {@code both}. * 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 { public class DirectBinding {
@@ -26,11 +26,20 @@ final class DirectBindingApplier {
if (control == null) { if (control == null) {
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\""); throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
} }
Resolved resolved = resolve(viewModel, binding.getPath());
if (resolved == null) {
return;
}
Property<?> controlProperty = controlProperty(control, binding.getProperty()); Property<?> controlProperty = controlProperty(control, binding.getProperty());
Property<?> attribute = viewModel.property(binding.getPath()); Property<?> attribute = resolved.attribute;
if (attribute == null) { if (resolved.complex) {
throw new IllegalArgumentException( switch (binding.getDirection()) {
"No read-write attribute for path \"" + binding.getPath() + "\""); 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()) { switch (binding.getDirection()) {
case "both" -> alignBoth(controlProperty, attribute); 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.
* <p>
* 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) { private static Property<?> controlProperty(Object control, String propertyName) {
String methodName = propertyName + "Property"; String methodName = propertyName + "Property";
try { try {
@@ -57,6 +121,53 @@ final class DirectBindingApplier {
} }
} }
@SuppressWarnings("unchecked")
private static void alignBothComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) 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<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) 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<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) 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") @SuppressWarnings("unchecked")
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) { private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute); ((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
@@ -18,13 +18,17 @@ public final class ViewLoader {
/** /**
* Loads a View for one Model instance. * Loads a View for one Model instance.
* <p> * <p>
* Builds a ViewModel for {@code model}, loads {@code fxml}, and applies Direct bindings * Reuses the ViewModel already built for {@code model}, or builds one, then loads {@code fxml}
* declared in {@code fx:define}. Direction {@code both} keeps the control and the Attribute * 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} * 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. * 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 * 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 * changes, and does not push later property changes to the control. A Path of one name is an
* read-write Attribute fails the load. The context is used when the document names a Controller. * 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 fxml the FXML document
* @param model the Model instance * @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 * @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) { public static LoadedView load(URL fxml, Object model, ApplicationContext context) {
ViewModel viewModel = new ViewModel(model); ViewModel viewModel = ViewModels.of(model);
LoadSession session = LoadSession.open(); LoadSession session = LoadSession.open();
try { try {
FXMLLoader loader = new FXMLLoader(fxml); FXMLLoader loader = new FXMLLoader(fxml);
@@ -20,9 +20,14 @@ import java.beans.Introspector;
import java.beans.PropertyDescriptor; import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* The object a View binds to, built at runtime for one Model instance. * 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, * 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 * 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. * 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 * 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 * 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 { public class ViewModel {
private final Object model; private final Object model;
private final Map<String, Property<?>> properties = new HashMap<>(); private final Map<String, Property<?>> properties = new HashMap<>();
private final Set<String> complexAttributes = new HashSet<>();
/** /**
* Builds a ViewModel for one Model instance. * Builds a ViewModel for one Model instance.
@@ -47,6 +59,7 @@ public class ViewModel {
*/ */
ViewModel(Object model) { ViewModel(Object model) {
this.model = model; this.model = model;
ViewModels.register(model, this);
for (PropertyDescriptor descriptor : descriptors(model.getClass())) { for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
Method getter = descriptor.getReadMethod(); Method getter = descriptor.getReadMethod();
Method setter = descriptor.getWriteMethod(); Method setter = descriptor.getWriteMethod();
@@ -54,11 +67,21 @@ public class ViewModel {
continue; continue;
} }
String name = descriptor.getName(); String name = descriptor.getName();
Property<?> property = createProperty(descriptor.getPropertyType(), read(getter, name)); 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); watch(property, setter, name);
properties.put(name, property); properties.put(name, property);
} }
} }
}
/** /**
* Returns the JavaFX property for an Attribute name. * Returns the JavaFX property for an Attribute name.
@@ -70,6 +93,25 @@ public class ViewModel {
return properties.get(name); 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<PropertyDescriptor> descriptors(Class<?> type) { private static List<PropertyDescriptor> descriptors(Class<?> type) {
try { try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class); 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) { private static Property<?> createProperty(Class<?> type, Object value) {
if (type == String.class) { if (type == String.class) {
return new SimpleStringProperty((String) value); return new SimpleStringProperty((String) value);
@@ -153,6 +227,13 @@ public class ViewModel {
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue)); 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) { private void write(Method setter, String name, Object value) {
try { try {
setter.invoke(model, value); setter.invoke(model, value);
@@ -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.
* <p>
* Instances are compared by identity, so two Models that are equal still get two ViewModels.
*/
final class ViewModels {
private static final Map<Object, ViewModel> 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);
}
}
@@ -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<String> 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<Throwable> 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;
}
}
@@ -0,0 +1,54 @@
package de.neitzel.fx.view.fixture;
import java.util.Objects;
/**
* Ordinary nested model used by view-loading tests.
* <p>
* 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);
}
}
@@ -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<Object> value = new SimpleObjectProperty<>(this, "value");
/**
* Returns the value property.
*
* @return the value property
*/
public ObjectProperty<Object> 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);
}
}
@@ -1,5 +1,10 @@
package de.neitzel.fx.view.fixture; 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. * Ordinary model used by view-loading tests.
*/ */
@@ -7,6 +12,16 @@ public class Person {
private String name; private String name;
private Integer age; 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<String> tags;
/** /**
* Creates a person with the given name. * Creates a person with the given name.
@@ -61,4 +76,184 @@ public class Person {
public void setAge(Integer age) { public void setAge(Integer age) {
this.age = 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<String> getTags() {
return tags;
}
/**
* Sets the tags.
*
* @param tags the tags
*/
public void setTags(List<String> tags) {
this.tags = tags;
}
} }
@@ -0,0 +1,9 @@
package de.neitzel.fx.view.fixture;
/**
* A simple enum Attribute used by view-loading tests.
*/
public enum Role {
MEMBER,
ADMIN
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<?import de.neitzel.fx.view.fixture.ObjectHolder?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="addressHolder" property="value" path="address" direction="both"/>
</BindingList>
</fx:define>
<ObjectHolder fx:id="addressHolder" id="addressHolder"/>
</VBox>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="address.street" direction="both"/>
<DirectBinding control="streetLabel" property="text" path="address.street" direction="view"/>
</BindingList>
</fx:define>
<TextField fx:id="streetField" id="streetField"/>
<Label fx:id="streetLabel" id="streetLabel"/>
</VBox>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="address.street" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="streetField" id="streetField"/>
</VBox>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="address.street" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="streetField" id="streetField" text="kept"/>
</VBox>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="homeField" property="text" path="home.street" direction="both"/>
<DirectBinding control="workField" property="text" path="work.street" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="homeField" id="homeField"/>
<TextField fx:id="workField" id="workField"/>
</VBox>