diff --git a/fx/src/main/java/de/neitzel/fx/view/BindingList.java b/fx/src/main/java/de/neitzel/fx/view/BindingList.java
new file mode 100644
index 0000000..5d84696
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/BindingList.java
@@ -0,0 +1,49 @@
+package de.neitzel.fx.view;
+
+import javafx.beans.DefaultProperty;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The list of binding elements declared inside {@code fx:define}.
+ *
+ * The list is not a scene-graph child. FXML adds each {@link DirectBinding} child to
+ * {@link #getElements()}.
+ */
+@DefaultProperty("elements")
+public class BindingList {
+
+ private final List elements = new ArrayList<>();
+
+ /**
+ * Creates a list and registers it with the load in progress.
+ */
+ public BindingList() {
+ LoadSession.register(this);
+ }
+
+ /**
+ * Returns the Direct bindings in this list.
+ *
+ * FXML appends child elements to the returned list.
+ *
+ * @return the mutable list of Direct bindings
+ */
+ public List getElements() {
+ return elements;
+ }
+
+ /**
+ * Applies every Direct binding in this list.
+ *
+ * @param namespace objects addressed by {@code fx:id}
+ * @param viewModel the ViewModel for this View
+ */
+ void apply(Map namespace, ViewModel viewModel) {
+ for (DirectBinding binding : elements) {
+ DirectBindingApplier.apply(namespace, viewModel, binding);
+ }
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java b/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java
new file mode 100644
index 0000000..9938b32
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java
@@ -0,0 +1,88 @@
+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}.
+ */
+public class DirectBinding {
+
+ private String control;
+ private String property;
+ private String path;
+ private String direction;
+
+ /**
+ * Sets the {@code fx:id} of the control.
+ *
+ * @param control the control id
+ */
+ public void setControl(String control) {
+ this.control = control;
+ }
+
+ /**
+ * Sets the control property name, such as {@code text}.
+ *
+ * @param property the property name
+ */
+ public void setProperty(String property) {
+ this.property = property;
+ }
+
+ /**
+ * Sets the Path. One name is an Attribute of this View's ViewModel.
+ *
+ * @param path the path
+ */
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ /**
+ * Sets the direction. Accepted values are {@code view}, {@code model}, and {@code both}.
+ *
+ * @param direction the direction value
+ */
+ public void setDirection(String direction) {
+ this.direction = direction;
+ }
+
+ /**
+ * Returns the {@code fx:id} of the control.
+ *
+ * @return the control id
+ */
+ public String getControl() {
+ return control;
+ }
+
+ /**
+ * Returns the control property name.
+ *
+ * @return the property name
+ */
+ public String getProperty() {
+ return property;
+ }
+
+ /**
+ * Returns the Path.
+ *
+ * @return the path
+ */
+ public String getPath() {
+ return path;
+ }
+
+ /**
+ * Returns the direction value.
+ *
+ * @return {@code view}, {@code model}, or {@code both}
+ */
+ public String getDirection() {
+ return direction;
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java
new file mode 100644
index 0000000..73d170c
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java
@@ -0,0 +1,80 @@
+package de.neitzel.fx.view;
+
+import javafx.beans.property.Property;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Map;
+
+/**
+ * Connects one Direct binding to a control property and a ViewModel Attribute.
+ */
+final class DirectBindingApplier {
+
+ private DirectBindingApplier() {
+ }
+
+ /**
+ * Applies one Direct binding.
+ *
+ * @param namespace objects addressed by {@code fx:id}
+ * @param viewModel the ViewModel for this View
+ * @param binding the declaration
+ */
+ static void apply(Map namespace, ViewModel viewModel, DirectBinding binding) {
+ Object control = namespace.get(binding.getControl());
+ if (control == null) {
+ throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
+ }
+ 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() + "\"");
+ }
+ switch (binding.getDirection()) {
+ case "both" -> alignBoth(controlProperty, attribute);
+ case "view" -> alignTowardView(controlProperty, attribute);
+ case "model" -> alignTowardModel(controlProperty, attribute);
+ default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
+ }
+ }
+
+ private static Property> controlProperty(Object control, String propertyName) {
+ String methodName = propertyName + "Property";
+ try {
+ Method method = control.getClass().getMethod(methodName);
+ Object value = method.invoke(control);
+ if (!(value instanceof Property> property)) {
+ throw new IllegalArgumentException(methodName + " did not return a property");
+ }
+ return property;
+ } catch (NoSuchMethodException failure) {
+ throw new IllegalArgumentException(
+ "No property " + propertyName + " on " + control.getClass().getName(), failure);
+ } catch (IllegalAccessException | InvocationTargetException failure) {
+ throw new IllegalStateException("Could not read property " + propertyName, failure);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void alignBoth(Property> controlProperty, Property> attribute) {
+ ((Property) controlProperty).bindBidirectional((Property) attribute);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void alignTowardView(Property> controlProperty, Property> attribute) {
+ Property control = (Property) controlProperty;
+ Property source = (Property) attribute;
+ control.setValue(source.getValue());
+ source.addListener((observable, oldValue, newValue) -> control.setValue(newValue));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void alignTowardModel(Property> controlProperty, Property> attribute) {
+ Property control = (Property) controlProperty;
+ Property target = (Property) attribute;
+ control.setValue(target.getValue());
+ control.addListener((observable, oldValue, newValue) -> target.setValue(newValue));
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/LoadSession.java b/fx/src/main/java/de/neitzel/fx/view/LoadSession.java
new file mode 100644
index 0000000..33ea106
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/LoadSession.java
@@ -0,0 +1,60 @@
+package de.neitzel.fx.view;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Holds the binding lists instantiated while one View is loading.
+ *
+ * FXML objects declared in {@code fx:define} are not scene-graph children and have no
+ * {@code fx:id} in the accepted document shape, so each {@link BindingList} registers
+ * itself here from its constructor.
+ */
+final class LoadSession {
+
+ private static final ThreadLocal CURRENT = new ThreadLocal<>();
+
+ private final List bindingLists = new ArrayList<>();
+
+ private LoadSession() {
+ }
+
+ /**
+ * Starts a session on this thread.
+ *
+ * @return the open session
+ */
+ static LoadSession open() {
+ LoadSession session = new LoadSession();
+ CURRENT.set(session);
+ return session;
+ }
+
+ /**
+ * Drops the session for this thread.
+ */
+ static void close() {
+ CURRENT.remove();
+ }
+
+ /**
+ * Registers a binding list created while a session is open.
+ *
+ * @param bindingList the list FXML just constructed
+ */
+ static void register(BindingList bindingList) {
+ LoadSession session = CURRENT.get();
+ if (session != null) {
+ session.bindingLists.add(bindingList);
+ }
+ }
+
+ /**
+ * Returns the lists registered during this session, in document order.
+ *
+ * @return the binding lists
+ */
+ List bindingLists() {
+ return bindingLists;
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/LoadedView.java b/fx/src/main/java/de/neitzel/fx/view/LoadedView.java
new file mode 100644
index 0000000..25e394d
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/LoadedView.java
@@ -0,0 +1,54 @@
+package de.neitzel.fx.view;
+
+import javafx.beans.property.Property;
+import javafx.scene.Parent;
+
+/**
+ * The scene-graph root and ViewModel produced by loading a View for a Model instance.
+ */
+public final class LoadedView {
+
+ private final Parent root;
+ private final ViewModel viewModel;
+
+ /**
+ * Creates the result of one load.
+ *
+ * @param root the scene-graph root
+ * @param viewModel the ViewModel built for the Model instance
+ */
+ LoadedView(Parent root, ViewModel viewModel) {
+ this.root = root;
+ this.viewModel = viewModel;
+ }
+
+ /**
+ * Returns the scene-graph root.
+ *
+ * @return the root
+ */
+ public Parent getRoot() {
+ return root;
+ }
+
+ /**
+ * Returns the ViewModel the View is bound to.
+ *
+ * @return the ViewModel
+ */
+ public ViewModel getViewModel() {
+ return viewModel;
+ }
+
+ /**
+ * Returns the JavaFX property for an Attribute name.
+ *
+ * The property's class follows the Attribute's declared type.
+ *
+ * @param name the Attribute name
+ * @return the property, or {@code null} when that name is not a read-write Attribute
+ */
+ public Property> property(String name) {
+ return viewModel.property(name);
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
new file mode 100644
index 0000000..4189a94
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
@@ -0,0 +1,59 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.injection.ApplicationContext;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+
+import java.io.IOException;
+import java.net.URL;
+
+/**
+ * Loads a View for one Model instance.
+ */
+public final class ViewLoader {
+
+ private 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
+ * 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.
+ *
+ * @param fxml the FXML document
+ * @param model the Model instance
+ * @param context the application context
+ * @return the loaded View
+ * @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);
+ LoadSession session = LoadSession.open();
+ try {
+ FXMLLoader loader = new FXMLLoader(fxml);
+ loader.setControllerFactory(type -> controller(context, type));
+ Object loaded = loader.load();
+ if (!(loaded instanceof Parent root)) {
+ throw new IllegalArgumentException("View root must be a parent");
+ }
+ for (BindingList bindingList : session.bindingLists()) {
+ bindingList.apply(loader.getNamespace(), viewModel);
+ }
+ return new LoadedView(root, viewModel);
+ } catch (IOException failure) {
+ throw new IllegalStateException("Could not load view " + fxml, failure);
+ } finally {
+ LoadSession.close();
+ }
+ }
+
+ private static Object controller(ApplicationContext context, Class> type) {
+ return context.getComponent(type.asSubclass(Object.class));
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
new file mode 100644
index 0000000..1e108a1
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
@@ -0,0 +1,163 @@
+package de.neitzel.fx.view;
+
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.FloatProperty;
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.LongProperty;
+import javafx.beans.property.Property;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.beans.property.SimpleFloatProperty;
+import javafx.beans.property.SimpleIntegerProperty;
+import javafx.beans.property.SimpleLongProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.property.SimpleStringProperty;
+
+import java.beans.BeanInfo;
+import java.beans.IntrospectionException;
+import java.beans.Introspector;
+import java.beans.PropertyDescriptor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The object a View binds to, built at runtime for one Model instance.
+ *
+ * Each read-write Attribute that is not declared on {@link Object} is stored as a JavaFX
+ * 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.
+ * 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.
+ */
+public class ViewModel {
+
+ private final Object model;
+ private final Map> properties = new HashMap<>();
+
+ /**
+ * Builds a ViewModel for one Model instance.
+ *
+ * @param model the Model instance
+ */
+ ViewModel(Object model) {
+ this.model = model;
+ for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
+ Method getter = descriptor.getReadMethod();
+ Method setter = descriptor.getWriteMethod();
+ if (getter == null || setter == null || descriptor.getPropertyType() == null) {
+ continue;
+ }
+ String name = descriptor.getName();
+ Property> property = createProperty(descriptor.getPropertyType(), read(getter, name));
+ watch(property, setter, name);
+ properties.put(name, property);
+ }
+ }
+
+ /**
+ * Returns the JavaFX property for an Attribute name.
+ *
+ * @param name the Attribute name
+ * @return the property, or {@code null} when that name is not a read-write Attribute
+ */
+ public Property> property(String name) {
+ return properties.get(name);
+ }
+
+ private static List descriptors(Class> type) {
+ try {
+ BeanInfo info = Introspector.getBeanInfo(type, Object.class);
+ return List.of(info.getPropertyDescriptors());
+ } catch (IntrospectionException failure) {
+ throw new IllegalStateException("Could not introspect " + type.getName(), failure);
+ }
+ }
+
+ private static Property> createProperty(Class> type, Object value) {
+ if (type == String.class) {
+ return new SimpleStringProperty((String) value);
+ }
+ if (type == Integer.class || type == int.class) {
+ return integerProperty(value);
+ }
+ if (type == Long.class || type == long.class) {
+ return longProperty(value);
+ }
+ if (type == Float.class || type == float.class) {
+ return floatProperty(value);
+ }
+ if (type == Double.class || type == double.class) {
+ return doubleProperty(value);
+ }
+ if (type == Boolean.class || type == boolean.class) {
+ return booleanProperty(value);
+ }
+ return new SimpleObjectProperty<>(value);
+ }
+
+ private static IntegerProperty integerProperty(Object value) {
+ SimpleIntegerProperty property = new SimpleIntegerProperty();
+ if (value != null) {
+ property.setValue((Integer) value);
+ }
+ return property;
+ }
+
+ private static LongProperty longProperty(Object value) {
+ SimpleLongProperty property = new SimpleLongProperty();
+ if (value != null) {
+ property.setValue((Long) value);
+ }
+ return property;
+ }
+
+ private static FloatProperty floatProperty(Object value) {
+ SimpleFloatProperty property = new SimpleFloatProperty();
+ if (value != null) {
+ property.setValue((Float) value);
+ }
+ return property;
+ }
+
+ private static DoubleProperty doubleProperty(Object value) {
+ SimpleDoubleProperty property = new SimpleDoubleProperty();
+ if (value != null) {
+ property.setValue((Double) value);
+ }
+ return property;
+ }
+
+ private static BooleanProperty booleanProperty(Object value) {
+ SimpleBooleanProperty property = new SimpleBooleanProperty();
+ if (value != null) {
+ property.setValue((Boolean) value);
+ }
+ return property;
+ }
+
+ private Object read(Method getter, String name) {
+ try {
+ return getter.invoke(model);
+ } catch (IllegalAccessException | InvocationTargetException failure) {
+ throw new IllegalStateException("Could not read attribute " + name, failure);
+ }
+ }
+
+ private void watch(Property> property, Method setter, String name) {
+ property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
+ }
+
+ private void write(Method setter, String name, Object value) {
+ try {
+ setter.invoke(model, value);
+ } catch (IllegalAccessException | InvocationTargetException failure) {
+ throw new IllegalStateException("Could not write attribute " + name, failure);
+ }
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/ShowAndEditOneAttributeTest.java b/fx/src/test/java/de/neitzel/fx/view/ShowAndEditOneAttributeTest.java
new file mode 100644
index 0000000..d65b151
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/ShowAndEditOneAttributeTest.java
@@ -0,0 +1,160 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.fixture.Person;
+import de.neitzel.injection.ApplicationContext;
+import javafx.application.Platform;
+import javafx.beans.property.IntegerProperty;
+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.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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+class ShowAndEditOneAttributeTest {
+
+ @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 bothWaysTextFieldShowsTheNameAndTypingUpdatesTheModel() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
+ assertEquals("Ada", nameField.getText());
+ nameField.setText("Grace");
+ assertEquals("Grace", person.getName());
+ });
+ }
+
+ @Test
+ void towardViewLabelShowsTheNameFollowsTheFieldAndDoesNotWriteTheModel() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-and-view.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
+ Label nameLabel = (Label) loaded.getRoot().lookup("#nameLabel");
+ assertEquals("Ada", nameLabel.getText());
+ nameField.setText("Grace");
+ assertEquals("Grace", nameLabel.getText());
+ assertEquals("Grace", person.getName());
+ nameLabel.setText("Hopper");
+ assertEquals("Grace", person.getName());
+ });
+ }
+
+ @Test
+ void entitySetterAfterLoadLeavesTheTextControlUnchanged() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ person.setName("Grace");
+ TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
+ assertEquals("Ada", nameField.getText());
+ });
+ }
+
+ @Test
+ void bindingAReadOnlyGetterFailsTheLoad() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowAndEditOneAttributeTest.class.getResource("read-only-title.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
+ () -> ViewLoader.load(fxml, person, context));
+ assertTrue(failure.getMessage().contains("title"));
+ });
+ }
+
+ @Test
+ void bindingAMethodDeclaredOnObjectFailsTheLoad() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowAndEditOneAttributeTest.class.getResource("object-class.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
+ () -> ViewLoader.load(fxml, person, context));
+ assertTrue(failure.getMessage().contains("class"));
+ });
+ }
+
+ @Test
+ void nullIntegerAttributeUsesAnIntegerProperty() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ assertInstanceOf(IntegerProperty.class, loaded.property("age"));
+ });
+ }
+
+ 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/Person.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java
new file mode 100644
index 0000000..12cfd17
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java
@@ -0,0 +1,64 @@
+package de.neitzel.fx.view.fixture;
+
+/**
+ * Ordinary model used by view-loading tests.
+ */
+public class Person {
+
+ private String name;
+ private Integer age;
+
+ /**
+ * Creates a person with the given name.
+ *
+ * @param name the current name
+ */
+ public Person(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Returns the name.
+ *
+ * @return the name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the name.
+ *
+ * @param name the name
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Returns a title that cannot be written.
+ *
+ * @return the title
+ */
+ public String getTitle() {
+ return "Doctor";
+ }
+
+ /**
+ * Returns the age. The value may be null.
+ *
+ * @return the age
+ */
+ public Integer getAge() {
+ return age;
+ }
+
+ /**
+ * Sets the age.
+ *
+ * @param age the age
+ */
+ public void setAge(Integer age) {
+ this.age = age;
+ }
+}
diff --git a/fx/src/test/resources/de/neitzel/fx/view/name-both-and-view.fxml b/fx/src/test/resources/de/neitzel/fx/view/name-both-and-view.fxml
new file mode 100644
index 0000000..8b16ddd
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/name-both-and-view.fxml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/name-both-ways.fxml b/fx/src/test/resources/de/neitzel/fx/view/name-both-ways.fxml
new file mode 100644
index 0000000..18f18ff
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/name-both-ways.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/object-class.fxml b/fx/src/test/resources/de/neitzel/fx/view/object-class.fxml
new file mode 100644
index 0000000..df98c9c
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/object-class.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/read-only-title.fxml b/fx/src/test/resources/de/neitzel/fx/view/read-only-title.fxml
new file mode 100644
index 0000000..014044f
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/read-only-title.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+