Author SHA1 Message Date
konradandCursor f8c65e444e Keep a simple-value list observable without treating it as a child ViewModel.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:45:43 +01:00
konradandCursor a4e5ae865f Merge simple-value lists into the shared View loader.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:43:42 +01:00
konradandCursor 60ba5d5909 Merge value conversion and failure Messages into the View loader.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:42:09 +01:00
konradandCursor 7984464ab9 Merge computed bindings into nested ViewModels.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:41:10 +01:00
konrad 9474a7acfe 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.
2026-10-30 17:40:06 +01:00
konradandCursor 8f34f51e97 Let a Direct binding convert a value and keep one failure Message.
A bad control value must leave the previous Attribute in place, and the ViewModel should keep a single error for that Path until the value is fixed. An info notice stays on the ViewModel and is not written to the log.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:37:53 +01:00
konradandCursor 6e3036669a Let a label show a Controller method and follow other values in the View.
The method does not write the Model, and a change to the label itself does not run the method again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:37:06 +01:00
konradandCursor aab55cbde3 Let an application show a list of simple values in a control and on the entity as one shared list.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:28:18 +01:00
konradandCursor 99886efa8b Let an application load a View for an ordinary Model and edit one Attribute in place.
The control shows the current value, typing writes it immediately, and a later entity setter no longer moves the control.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 17:17:44 +01:00
40 changed files with 3212 additions and 0 deletions
@@ -0,0 +1,62 @@
package de.neitzel.fx.view;
import de.neitzel.injection.ApplicationContext;
import javafx.beans.DefaultProperty;
import javafx.scene.Parent;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* The list of binding elements declared inside {@code fx:define}.
* <p>
* The list is not a scene-graph child. FXML adds each {@link DirectBinding} or
* {@link ComputedBinding} child to {@link #getElements()}.
*/
@DefaultProperty("elements")
public class BindingList {
private final List<Object> elements = new ArrayList<>();
/**
* Creates a list and registers it with the load in progress.
*/
public BindingList() {
LoadSession.register(this);
}
/**
* Returns the binding elements in this list.
* <p>
* FXML appends child elements to the returned list. A child is a {@link DirectBinding} or a
* {@link ComputedBinding}.
*
* @return the mutable list of binding elements
*/
public List<Object> getElements() {
return elements;
}
/**
* Applies every binding element in this list.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
* @param controller the Controller for this View, or {@code null} when the document names none
* @param root the scene-graph root of this View
* @param context the container that creates a named Converter
*/
void apply(Map<String, Object> namespace, ViewModel viewModel, Object controller, Parent root,
ApplicationContext context) {
for (Object element : elements) {
if (element instanceof DirectBinding directBinding) {
DirectBindingApplier.apply(namespace, viewModel, directBinding, context);
} else if (element instanceof ComputedBinding computedBinding) {
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
} else {
throw new IllegalArgumentException("Not a binding element: " + element);
}
}
}
}
@@ -0,0 +1,70 @@
package de.neitzel.fx.view;
/**
* A Computed binding declared in FXML.
* <p>
* The element names the control by {@code fx:id}, the control property, and a no-argument
* method on the Controller. The method value uses a {@code #method} prefix, such as
* {@code #workingAge}. The method may read the Controller's fields. Its result is written to
* the control property. The method does not write the Model.
*/
public class ComputedBinding {
private String control;
private String property;
private String method;
/**
* 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 Controller method. A {@code #method} value names a no-argument method.
*
* @param method the method value, such as {@code #workingAge}
*/
public void setMethod(String method) {
this.method = method;
}
/**
* 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 Controller method value.
*
* @return the method value, such as {@code #workingAge}
*/
public String getMethod() {
return method;
}
}
@@ -0,0 +1,147 @@
package de.neitzel.fx.view;
import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Control;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
/**
* Writes a Controller method result into one control property and keeps it current.
*/
final class ComputedBindingApplier {
private ComputedBindingApplier() {
}
/**
* Applies one Computed binding.
* <p>
* Calls the Controller method and writes the result to the control property. The method
* runs again when a JavaFX property changes on a control in this View or on this View's
* ViewModel, other than the target property. Writing the target does not call the method
* again, so the write does not loop. The Model is not written.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
* @param controller the Controller for this View
* @param root the scene-graph root of this View
* @param binding the declaration
*/
static void apply(
Map<String, Object> namespace,
ViewModel viewModel,
Object controller,
Parent root,
ComputedBinding binding) {
Object control = namespace.get(binding.getControl());
if (control == null) {
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
}
Property<Object> target = controlProperty(control, binding.getProperty());
Runnable refresh = refresh(controller, binding.getMethod(), target);
refresh.run();
watchControls(root, target, refresh);
viewModel.addAttributeListener((observable, oldValue, newValue) -> refresh.run());
}
private static Runnable refresh(Object controller, String methodValue, Property<Object> target) {
boolean[] running = {false};
return () -> {
if (running[0]) {
return;
}
running[0] = true;
try {
target.setValue(invoke(controller, methodValue));
} finally {
running[0] = false;
}
};
}
private static void watchControls(Parent root, Property<?> target, Runnable refresh) {
watchNode(root, target, refresh);
}
private static void watchNode(Node node, Property<?> target, Runnable refresh) {
if (node instanceof Control control) {
watchProperties(control, target, refresh);
}
if (node instanceof Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
watchNode(child, target, refresh);
}
}
}
@SuppressWarnings("unchecked")
private static void watchProperties(Object bean, Property<?> target, Runnable refresh) {
ChangeListener<Object> listener = (observable, oldValue, newValue) -> refresh.run();
for (Method method : bean.getClass().getMethods()) {
if (method.getParameterCount() != 0 || !method.getName().endsWith("Property")) {
continue;
}
if (!ObservableValue.class.isAssignableFrom(method.getReturnType())) {
continue;
}
Object value = readProperty(bean, method);
if (value instanceof ObservableValue<?> observable && observable != target) {
((ObservableValue<Object>) observable).addListener(listener);
}
}
}
private static Object readProperty(Object bean, Method method) {
try {
return method.invoke(bean);
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not read property " + method.getName(), failure);
}
}
@SuppressWarnings("unchecked")
private static Property<Object> 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<Object>) 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);
}
}
private static Object invoke(Object controller, String methodValue) {
if (controller == null) {
throw new IllegalArgumentException("No controller for " + methodValue);
}
String name = methodName(methodValue);
try {
Method method = controller.getClass().getMethod(name);
return method.invoke(controller);
} catch (NoSuchMethodException failure) {
throw new IllegalArgumentException("No computed method \"" + methodValue + "\"", failure);
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not call " + methodValue, failure);
}
}
private static String methodName(String methodValue) {
if (methodValue != null && methodValue.startsWith("#")) {
return methodValue.substring(1);
}
return methodValue;
}
}
@@ -0,0 +1,35 @@
package de.neitzel.fx.view;
/**
* The developer-written translation between a Direct binding's Attribute value and its control value.
* <p>
* The container constructs the class and injects its services. {@link #toControlValue(Object)} turns
* the Attribute value into the control value. {@link #toAttributeValue(Object)} turns the control
* value into the Attribute value and runs only when the Direct binding runs toward the Model.
* A toward-view binding does not call the reverse method, so a converter used only toward the View
* can leave it unimplemented. When a method cannot produce a value, it throws a runtime exception.
* The binding then keeps the previous value.
*/
public interface Converter {
/**
* Turns the Attribute value into the control value.
*
* @param attributeValue the current Attribute value, which may be null
* @return the value written to the control
*/
Object toControlValue(Object attributeValue);
/**
* Turns the control value into the Attribute value.
* <p>
* Called only when the Direct binding runs toward the Model ({@code model} or {@code both}).
* A toward-view binding does not require this method.
*
* @param controlValue the current control value
* @return the value written to the Attribute
*/
default Object toAttributeValue(Object controlValue) {
throw new UnsupportedOperationException("no reverse conversion for " + controlValue);
}
}
@@ -0,0 +1,110 @@
package de.neitzel.fx.view;
/**
* A Direct binding declared in FXML.
* <p>
* The element names the control by {@code fx:id}, the control property, the Path, and the
* direction, and an optional Converter class. 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}. The Converter value is the class name. The container creates that class.
*/
public class DirectBinding {
private String control;
private String property;
private String path;
private String direction;
private String converter;
/**
* 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;
}
/**
* Sets the Converter class name. The container creates that class.
*
* @param converter the converter class name, or {@code null} when values pass through
*/
public void setConverter(String converter) {
this.converter = converter;
}
/**
* 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;
}
/**
* Returns the Converter class name.
*
* @return the class name, or {@code null} when values pass through
*/
public String getConverter() {
return converter;
}
}
@@ -0,0 +1,315 @@
package de.neitzel.fx.view;
import de.neitzel.injection.ApplicationContext;
import javafx.beans.property.Property;
import javafx.collections.ObservableList;
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
* @param context the container that creates a named Converter
*/
static void apply(Map<String, Object> namespace, ViewModel viewModel, DirectBinding binding,
ApplicationContext context) {
Object control = namespace.get(binding.getControl());
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 = 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;
}
if (attribute.getValue() instanceof ObservableList<?> values) {
assignList(controlProperty, values);
return;
}
String converterName = binding.getConverter();
if (converterName == null || converterName.isEmpty()) {
alignPlain(controlProperty, attribute, binding.getDirection());
return;
}
Converter converter = converter(context, converterName);
switch (binding.getDirection()) {
case "both" -> alignConverted(
controlProperty, attribute, converter, viewModel, binding.getPath(), true, true);
case "view" -> alignConverted(
controlProperty, attribute, converter, viewModel, binding.getPath(), true, false);
case "model" -> alignConverted(
controlProperty, attribute, converter, viewModel, binding.getPath(), false, true);
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
}
}
private static void alignPlain(Property<?> controlProperty, Property<?> attribute, String direction) {
switch (direction) {
case "both" -> alignBoth(controlProperty, attribute);
case "view" -> alignTowardView(controlProperty, attribute);
case "model" -> alignTowardModel(controlProperty, attribute);
default -> throw new IllegalArgumentException("Not a direction: " + direction);
}
}
/**
* 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 Converter converter(ApplicationContext context, String className) {
try {
Class<?> type = Class.forName(className);
Object instance = context.getComponent(type.asSubclass(Object.class));
if (!(instance instanceof Converter created)) {
throw new IllegalArgumentException("Not a converter: " + className);
}
return created;
} catch (ClassNotFoundException failure) {
throw new IllegalArgumentException("No converter " + className, failure);
}
}
@SuppressWarnings("unchecked")
private static void alignConverted(Property<?> controlProperty, Property<?> attribute, Converter converter,
ViewModel viewModel, String path, boolean towardView, boolean towardModel) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) attribute;
Link link = new Link();
pushControl(control, source, converter, viewModel, path, link);
if (towardView) {
source.addListener((observable, oldValue, newValue) -> {
if (!link.applying) {
pushControl(control, source, converter, viewModel, path, link);
}
});
}
if (towardModel) {
control.addListener((observable, oldValue, newValue) -> {
if (!link.applying) {
pushAttribute(control, source, converter, viewModel, path, link);
}
});
}
}
private static void pushControl(Property<Object> control, Property<Object> attribute, Converter converter,
ViewModel viewModel, String path, Link link) {
try {
Object shown = converter.toControlValue(attribute.getValue());
link.applying = true;
try {
control.setValue(shown);
link.committedControl = shown;
} finally {
link.applying = false;
}
viewModel.clearBindingFailure(path);
} catch (RuntimeException failure) {
viewModel.reportBindingFailure(path, failureText(failure));
}
}
private static void pushAttribute(Property<Object> control, Property<Object> attribute, Converter converter,
ViewModel viewModel, String path, Link link) {
try {
Object converted = converter.toAttributeValue(control.getValue());
link.applying = true;
try {
attribute.setValue(converted);
link.committedControl = control.getValue();
} finally {
link.applying = false;
}
viewModel.clearBindingFailure(path);
} catch (RuntimeException failure) {
link.applying = true;
try {
control.setValue(link.committedControl);
} finally {
link.applying = false;
}
viewModel.reportBindingFailure(path, failureText(failure));
}
}
private static String failureText(RuntimeException failure) {
String text = failure.getMessage();
if (text == null || text.isEmpty()) {
return "Conversion failed";
}
return text;
}
/**
* Guards one converted binding against hearing its own writes, and remembers the last
* control value that was successfully shown.
*/
private static final class Link {
private boolean applying;
private Object committedControl;
}
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 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")
private static void assignList(Property<?> controlProperty, ObservableList<?> values) {
((Property<Object>) controlProperty).setValue(values);
}
@SuppressWarnings("unchecked")
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
}
@SuppressWarnings("unchecked")
private static void alignTowardView(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) attribute;
control.setValue(source.getValue());
source.addListener((observable, oldValue, newValue) -> control.setValue(newValue));
}
@SuppressWarnings("unchecked")
private static void alignTowardModel(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> target = (Property<Object>) attribute;
control.setValue(target.getValue());
control.addListener((observable, oldValue, newValue) -> target.setValue(newValue));
}
}
@@ -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.
* <p>
* 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<LoadSession> CURRENT = new ThreadLocal<>();
private final List<BindingList> 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<BindingList> bindingLists() {
return bindingLists;
}
}
@@ -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.
* <p>
* 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);
}
}
@@ -0,0 +1,74 @@
package de.neitzel.fx.view;
/**
* A notice on a ViewModel with a severity, a text, and a source.
*/
public final class Message {
/**
* How serious a notice is.
*/
public enum Severity {
/** Informational notice. Not written to the log. */
INFO,
/** Warning. Not written to the log. */
WARN,
/** Error. Written to the log. */
ERROR,
/** Fatal error. Written to the log. */
FATAL
}
private final Severity severity;
private String text;
private final String source;
/**
* Creates a notice.
*
* @param severity how serious the notice is
* @param text the notice text
* @param source where the notice came from, such as a binding Path
*/
public Message(Severity severity, String text, String source) {
this.severity = severity;
this.text = text;
this.source = source;
}
/**
* Returns how serious the notice is.
*
* @return the severity
*/
public Severity getSeverity() {
return severity;
}
/**
* Returns the notice text.
*
* @return the text
*/
public String getText() {
return text;
}
/**
* Returns where the notice came from.
*
* @return the source
*/
public String getSource() {
return source;
}
/**
* Replaces the text of a binding failure that is already in the list.
*
* @param text the new text
*/
void setText(String text) {
this.text = text;
}
}
@@ -0,0 +1,68 @@
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.
* <p>
* Reuses the ViewModel already built for {@code model}, or builds one, then loads {@code fxml}
* and applies binding elements declared in {@code fx:define}. A Computed binding writes a
* Controller method result to a control and does not write the Model. 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 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 collection of simple values is one shared
* list: the Direct binding sets the control property to that
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
* A path that is not a read-write Attribute fails the load. The context creates a Controller,
* and a Converter when a Direct binding names one. A converter that cannot produce a value
* keeps the previous value and records one error Message. It does not fail the load.
*
* @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 = ViewModels.of(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, loader.getController(), root, context);
}
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));
}
}
@@ -0,0 +1,419 @@
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 javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalAmount;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
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.
* <p>
* 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.
* 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 of Model instances is not a
* Complex attribute. A list, set, or collection of simple values is an object property whose
* value is an {@link ObservableList} of those values. When that attribute's current value is a
* list, the observable list is backed by it and stored on the entity when the setter accepts it,
* so both sides hold one list. 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 Complex attribute writes the Model instance behind the ViewModel.
* A call to the entity's setter does not update the property.
* <p>
* Beside those attributes, the ViewModel keeps a list of {@link Message}s and the latest one.
* The developer can add a message of any severity. Error and fatal messages are written to the log.
* Info and warn are not.
*/
public class ViewModel {
private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class);
private final Object model;
private final Map<String, Property<?>> properties = new HashMap<>();
private final Set<String> complexAttributes = new HashSet<>();
private final List<Message> messages = new ArrayList<>();
private final Map<String, Message> bindingFailures = new HashMap<>();
private Message latestMessage;
/**
* Builds a ViewModel for one Model instance.
*
* @param model the Model instance
*/
ViewModel(Object model) {
this.model = model;
ViewModels.register(model, this);
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();
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 {
if (isSimpleValueCollection(getter)) {
current = shareSimpleList(setter, name, current);
}
Property<?> property = createProperty(type, current);
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);
}
/**
* 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);
}
/**
* Calls {@code listener} when any Attribute property changes.
*
* @param listener the listener
*/
@SuppressWarnings("unchecked")
void addAttributeListener(ChangeListener<Object> listener) {
for (Property<?> property : properties.values()) {
((Property<Object>) property).addListener(listener);
}
}
private static List<PropertyDescriptor> 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 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);
}
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 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);
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not write attribute " + name, failure);
}
}
/**
* Returns the notices on this ViewModel, beside the Model attributes.
*
* @return the messages, oldest first
*/
public List<Message> getMessages() {
return Collections.unmodifiableList(messages);
}
/**
* Returns the notice most recently added.
* <p>
* An in-place update of a binding failure does not change which notice is latest.
*
* @return the latest notice, or {@code null} when there is none
*/
public Message getLatestMessage() {
return latestMessage;
}
/**
* Adds a notice of any severity.
*
* @param message the notice
*/
public void addMessage(Message message) {
messages.add(message);
latestMessage = message;
logSevere(message);
}
/**
* Records one error for a binding Path. A further failure of that Path replaces the text.
*
* @param path the binding Path
* @param text the error text
*/
void reportBindingFailure(String path, String text) {
Message existing = bindingFailures.get(path);
if (existing != null) {
existing.setText(text);
logSevere(existing);
return;
}
Message message = new Message(Message.Severity.ERROR, text, path);
bindingFailures.put(path, message);
addMessage(message);
}
/**
* Drops the error recorded for a binding Path after a later conversion succeeds.
*
* @param path the binding Path
*/
void clearBindingFailure(String path) {
Message existing = bindingFailures.remove(path);
if (existing == null) {
return;
}
messages.remove(existing);
if (latestMessage == existing) {
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
}
}
/**
* Writes an error or fatal notice to the log. Info and warn are left out.
*
* @param message the notice
*/
private static void logSevere(Message message) {
Message.Severity severity = message.getSeverity();
if (severity == Message.Severity.ERROR || severity == Message.Severity.FATAL) {
LOGGER.error(message.getText());
}
}
private static boolean isSimpleValueCollection(Method getter) {
if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
return false;
}
Class<?> element = elementType(getter.getGenericReturnType());
return element != null && isSimpleValue(element);
}
private static Class<?> elementType(Type type) {
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
return null;
}
return classOf(parameterized.getActualTypeArguments()[0]);
}
private static Class<?> classOf(Type type) {
if (type instanceof Class<?> raw) {
return raw;
}
if (type instanceof ParameterizedType parameterized && parameterized.getRawType() instanceof Class<?> raw) {
return raw;
}
if (type instanceof WildcardType wildcard && wildcard.getUpperBounds().length == 1) {
return classOf(wildcard.getUpperBounds()[0]);
}
return null;
}
private static boolean isSimpleValue(Class<?> type) {
if (type == String.class || type == Boolean.class || type == boolean.class || type.isEnum()) {
return true;
}
if (type.isPrimitive()) {
return type != char.class && type != void.class;
}
if (Number.class.isAssignableFrom(type)) {
return true;
}
if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
return true;
}
return TemporalAccessor.class.isAssignableFrom(type) || TemporalAmount.class.isAssignableFrom(type);
}
@SuppressWarnings("unchecked")
private Object shareSimpleList(Method setter, String name, Object current) {
if (!(current instanceof Collection<?> collection)) {
return current;
}
ObservableList<Object> observable = current instanceof List<?> list
? FXCollections.observableList((List<Object>) list)
: FXCollections.observableArrayList((Collection<Object>) collection);
if (setter.getParameterTypes()[0].isAssignableFrom(observable.getClass())) {
write(setter, name, observable);
}
return observable;
}
}
@@ -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,135 @@
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.Property;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;
class ComputeAValueTest {
@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 computedBindingShowsTheMethodResultAndLeavesTheModelUnchanged() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(36);
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
assertEquals("initial-age", workingAgeLabel.getText());
assertEquals("Ada", person.getName());
assertEquals(36, person.getAge());
});
}
@Test
void typingInAnotherTextFieldUpdatesTheComputedLabel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
nameField.setText("Grace");
assertEquals("typed-age", workingAgeLabel.getText());
assertEquals("Grace", person.getName());
});
}
@Test
void settingAViewModelPropertyUpdatesTheComputedLabel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
@SuppressWarnings("unchecked")
Property<String> name = (Property<String>) loaded.property("name");
name.setValue("Edsger");
assertEquals("model-age", workingAgeLabel.getText());
assertEquals("Edsger", person.getName());
});
}
@Test
void changingTheComputedLabelDoesNotRunTheMethodAgain() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
assertEquals("initial-age", workingAgeLabel.getText());
workingAgeLabel.setText("sentinel");
assertEquals("sentinel", workingAgeLabel.getText());
});
}
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,292 @@
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.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
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.assertFalse;
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.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
class ConvertAndRecordFailureTest {
@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 injectedConverterPrefixesTheAgeText() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
assertEquals("years:42", ageField.getText());
});
}
@Test
void typingAPrefixedAgeUpdatesTheModel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
ageField.setText("years:11");
assertEquals(11, person.getAge());
});
}
@Test
void towardViewLoadsWhenTheReverseMethodIsAbsent() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-view-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label ageLabel = (Label) loaded.getRoot().lookup("#ageLabel");
assertEquals("shown 42", ageLabel.getText());
ageLabel.setText("shown 99");
assertEquals(42, person.getAge());
});
}
@Test
void towardViewConverterFollowsALaterAge() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-and-view-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
Label ageLabel = (Label) loaded.getRoot().lookup("#ageLabel");
assertEquals("shown 42", ageLabel.getText());
ageField.setText("years:11");
assertEquals(11, person.getAge());
assertEquals("shown 11", ageLabel.getText());
});
}
@Test
void towardModelConvertsTypedTextWithoutFollowingTheAttribute() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-model-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
assertEquals("years:42", ageField.getText());
((IntegerProperty) loaded.property("age")).set(7);
assertEquals("years:42", ageField.getText());
assertEquals(Integer.valueOf(7), person.getAge());
ageField.setText("years:8");
assertEquals(Integer.valueOf(8), person.getAge());
assertEquals("years:8", ageField.getText());
});
}
@Test
void badAgeTextKeepsThePreviousIntAndOneErrorMessage() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
ageField.setText("nope");
assertEquals(Integer.valueOf(42), person.getAge());
assertEquals("years:42", ageField.getText());
ViewModel viewModel = loaded.getViewModel();
assertEquals(1, viewModel.getMessages().size());
Message message = viewModel.getMessages().get(0);
assertEquals(Message.Severity.ERROR, message.getSeverity());
assertEquals("age", message.getSource());
assertEquals("not a whole number: nope", message.getText());
assertSame(message, viewModel.getLatestMessage());
});
}
@Test
void anotherBadAgeUpdatesTheSameMessage() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
ageField.setText("nope");
ViewModel viewModel = loaded.getViewModel();
Message first = viewModel.getMessages().get(0);
ageField.setText("still-bad");
assertEquals(1, viewModel.getMessages().size());
assertSame(first, viewModel.getMessages().get(0));
assertEquals("not a whole number: still-bad", first.getText());
assertEquals(Integer.valueOf(42), person.getAge());
assertEquals("years:42", ageField.getText());
});
}
@Test
void aValidAgeRemovesTheFailureMessage() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
ageField.setText("nope");
ageField.setText("years:11");
assertEquals(Integer.valueOf(11), person.getAge());
assertEquals("years:11", ageField.getText());
assertEquals(0, loaded.getViewModel().getMessages().size());
assertNull(loaded.getViewModel().getLatestMessage());
});
}
@Test
void developerInfoStaysLatestAndIsNotLogged() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
ViewModel viewModel = loaded.getViewModel();
ageField.setText("nope");
String log = captureErr(() -> viewModel.addMessage(
new Message(Message.Severity.INFO, "developer-info-notice", "form")));
assertEquals(2, viewModel.getMessages().size());
assertEquals("developer-info-notice", viewModel.getLatestMessage().getText());
assertEquals(Message.Severity.INFO, viewModel.getLatestMessage().getSeverity());
assertFalse(log.contains("developer-info-notice"));
ageField.setText("still-bad");
assertEquals(2, viewModel.getMessages().size());
assertEquals("not a whole number: still-bad", viewModel.getMessages().get(0).getText());
assertEquals("developer-info-notice", viewModel.getLatestMessage().getText());
});
}
@Test
void errorAndFatalMessagesAreWrittenToTheLog() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
ViewModel viewModel = loaded.getViewModel();
String log = captureErr(() -> {
viewModel.addMessage(new Message(Message.Severity.WARN, "developer-warn-notice", "form"));
viewModel.addMessage(new Message(Message.Severity.ERROR, "developer-error-notice", "form"));
viewModel.addMessage(new Message(Message.Severity.FATAL, "developer-fatal-notice", "form"));
ageField.setText("nope");
ageField.setText("still-bad");
});
assertFalse(log.contains("developer-warn-notice"));
assertTrue(log.contains("developer-error-notice"));
assertTrue(log.contains("developer-fatal-notice"));
assertTrue(log.contains("not a whole number: nope"));
assertTrue(log.contains("not a whole number: still-bad"));
assertEquals("not a whole number: still-bad", viewModel.getLatestMessage().getText());
assertEquals(4, viewModel.getMessages().size());
});
}
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);
}
}
private static String captureErr(FxAction action) throws Exception {
PrintStream previous = System.err;
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
PrintStream captured = new PrintStream(buffer, true, StandardCharsets.UTF_8);
System.setErr(captured);
try {
action.run();
captured.flush();
return buffer.toString(StandardCharsets.UTF_8);
} finally {
System.setErr(previous);
}
}
@FunctionalInterface
private interface FxAction {
void run() throws Exception;
}
}
@@ -0,0 +1,337 @@
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.collections.ObservableList;
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);
assertInstanceOf(ObservableList.class, loaded.property("tags").getValue());
@SuppressWarnings("unchecked")
ObservableList<String> shown = (ObservableList<String>) loaded.property("tags").getValue();
assertEquals(List.of("friend"), shown);
assertSame(person.getTags(), shown);
shown.add("kin");
assertEquals(List.of("friend", "kin"), tags);
});
}
@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,99 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.fixture.Person;
import de.neitzel.injection.ApplicationContext;
import javafx.application.Platform;
import javafx.scene.control.ListView;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail;
class ShowAListOfSimpleValuesTest {
@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 listViewAndPersonShareTheTagList() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setTags(new ArrayList<>(List.of("red", "blue")));
URL fxml = ShowAListOfSimpleValuesTest.class.getResource("tags-both-ways.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
VBox root = (VBox) loaded.getRoot();
assertEquals(1, root.getChildren().size());
@SuppressWarnings("unchecked")
ListView<String> tags = (ListView<String>) root.lookup("#tags");
assertEquals(List.of("red", "blue"), List.copyOf(tags.getItems()));
assertEquals("red", tags.getItems().get(0));
assertEquals("blue", tags.getItems().get(1));
assertSame(person.getTags(), tags.getItems());
tags.getItems().add("green");
assertEquals(List.of("red", "blue", "green"), List.copyOf(person.getTags()));
person.getTags().add("gold");
assertEquals(List.of("red", "blue", "green", "gold"), List.copyOf(tags.getItems()));
tags.getItems().remove("blue");
assertEquals(List.of("red", "green", "gold"), List.copyOf(person.getTags()));
});
}
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,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<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,30 @@
package de.neitzel.fx.view.computed;
import jakarta.inject.Singleton;
import javafx.fxml.FXML;
import javafx.scene.control.TextField;
/**
* Controller that derives a label from the name field and does not write the Model.
*/
@Singleton
public class WorkingAgeController {
@FXML
private TextField nameField;
/**
* Returns a literal chosen from the current name field.
*
* @return the value written to the label
*/
public String workingAge() {
if ("Ada".equals(nameField.getText())) {
return "initial-age";
}
if ("Grace".equals(nameField.getText())) {
return "typed-age";
}
return "model-age";
}
}
@@ -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,19 @@
package de.neitzel.fx.view.fixture;
import jakarta.inject.Singleton;
/**
* Service whose text a converter must show, so a test can see that injection ran.
*/
@Singleton
public class AgeUnit {
/**
* Returns the prefix written in front of an age.
*
* @return the prefix
*/
public String prefix() {
return "years:";
}
}
@@ -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);
}
}
@@ -0,0 +1,259 @@
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.
*/
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<String> tags;
/**
* 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;
}
/**
* 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. The value may be null.
*
* @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,55 @@
package de.neitzel.fx.view.fixture;
import de.neitzel.fx.view.Converter;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Converts an age using the prefix from {@link AgeUnit}.
*/
@Singleton
public class PrefixedAgeConverter implements Converter {
private final AgeUnit ageUnit;
/**
* Creates a converter that reads its prefix from the injected service.
*
* @param ageUnit the service that supplies the prefix
*/
@Inject
public PrefixedAgeConverter(AgeUnit ageUnit) {
this.ageUnit = ageUnit;
}
/**
* Writes the injected prefix in front of the age.
*
* @param attributeValue the age
* @return the control text
*/
@Override
public Object toControlValue(Object attributeValue) {
return ageUnit.prefix() + attributeValue;
}
/**
* Reads an age that starts with the injected prefix.
*
* @param controlValue the control text
* @return the age
*/
@Override
public Object toAttributeValue(Object controlValue) {
String text = controlValue == null ? "" : controlValue.toString();
String prefix = ageUnit.prefix();
if (!text.startsWith(prefix)) {
throw new IllegalArgumentException("not a whole number: " + text);
}
try {
return Integer.valueOf(text.substring(prefix.length()));
} catch (NumberFormatException failure) {
throw new IllegalArgumentException("not a whole number: " + text);
}
}
}
@@ -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,22 @@
package de.neitzel.fx.view.fixture;
import de.neitzel.fx.view.Converter;
import jakarta.inject.Singleton;
/**
* Shows an age and has no reverse conversion.
*/
@Singleton
public class ShownAgeConverter implements Converter {
/**
* Turns the age into label text.
*
* @param attributeValue the age
* @return the label text
*/
@Override
public Object toControlValue(Object attributeValue) {
return "shown " + attributeValue;
}
}
@@ -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,20 @@
<?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="ageField" property="text" path="age" direction="both"
converter="de.neitzel.fx.view.fixture.PrefixedAgeConverter"/>
<DirectBinding control="ageLabel" property="text" path="age" direction="view"
converter="de.neitzel.fx.view.fixture.ShownAgeConverter"/>
</BindingList>
</fx:define>
<TextField fx:id="ageField" id="ageField"/>
<Label fx:id="ageLabel" id="ageLabel"/>
</VBox>
@@ -0,0 +1,16 @@
<?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="ageField" property="text" path="age" direction="both"
converter="de.neitzel.fx.view.fixture.PrefixedAgeConverter"/>
</BindingList>
</fx:define>
<TextField fx:id="ageField" id="ageField"/>
</VBox>
@@ -0,0 +1,16 @@
<?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="ageField" property="text" path="age" direction="model"
converter="de.neitzel.fx.view.fixture.PrefixedAgeConverter"/>
</BindingList>
</fx:define>
<TextField fx:id="ageField" id="ageField"/>
</VBox>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?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="ageLabel" property="text" path="age" direction="view"
converter="de.neitzel.fx.view.fixture.ShownAgeConverter"/>
</BindingList>
</fx:define>
<Label fx:id="ageLabel" id="ageLabel"/>
</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>
@@ -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="nameField" property="text" path="name" direction="both"/>
<DirectBinding control="nameLabel" property="text" path="name" direction="view"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
<Label fx:id="nameLabel" id="nameLabel"/>
</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="nameField" property="text" path="name" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
</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="classField" property="text" path="class" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="classField" id="classField"/>
</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="titleField" property="text" path="title" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="titleField" id="titleField"/>
</VBox>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.ListView?>
<?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="tags" property="items" path="tags" direction="both"/>
</BindingList>
</fx:define>
<ListView fx:id="tags" id="tags"/>
</VBox>
@@ -0,0 +1,20 @@
<?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.ComputedBinding?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.view.computed.WorkingAgeController">
<fx:define>
<BindingList>
<DirectBinding control="nameField" property="text" path="name" direction="both"/>
<ComputedBinding control="workingAgeLabel" property="text" method="#workingAge"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
<Label fx:id="workingAgeLabel" id="workingAgeLabel"/>
</VBox>