From 9474a7acfe30744aec5af087b8e4d3e41cbac5b5 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 30 Oct 2026 17:40:06 +0100 Subject: [PATCH] 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. --- .../de/neitzel/fx/view/DirectBinding.java | 6 +- .../neitzel/fx/view/DirectBindingApplier.java | 119 ++++++- .../java/de/neitzel/fx/view/ViewLoader.java | 14 +- .../java/de/neitzel/fx/view/ViewModel.java | 89 ++++- .../java/de/neitzel/fx/view/ViewModels.java | 41 +++ .../fx/view/ReachNestedAttributeTest.java | 330 ++++++++++++++++++ .../de/neitzel/fx/view/fixture/Address.java | 54 +++ .../neitzel/fx/view/fixture/ObjectHolder.java | 40 +++ .../de/neitzel/fx/view/fixture/Person.java | 195 +++++++++++ .../java/de/neitzel/fx/view/fixture/Role.java | 9 + .../de/neitzel/fx/view/address-object.fxml | 15 + .../fx/view/address-street-both-and-view.fxml | 18 + .../neitzel/fx/view/address-street-both.fxml | 15 + .../neitzel/fx/view/address-street-kept.fxml | 15 + .../de/neitzel/fx/view/home-work-street.fxml | 17 + 15 files changed, 962 insertions(+), 15 deletions(-) create mode 100644 fx/src/main/java/de/neitzel/fx/view/ViewModels.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/ReachNestedAttributeTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/Address.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/ObjectHolder.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/Role.java create mode 100644 fx/src/test/resources/de/neitzel/fx/view/address-object.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/address-street-both-and-view.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/address-street-both.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/address-street-kept.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/home-work-street.fxml 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 @@ + + + + + + + + + + + + + + + + +