Compare commits
11
Commits
main
...
f8c65e444e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c65e444e | ||
|
|
a4e5ae865f | ||
|
|
60ba5d5909 | ||
|
|
7984464ab9 | ||
|
|
9474a7acfe | ||
|
|
8f34f51e97 | ||
|
|
6e3036669a | ||
|
|
aab55cbde3 | ||
|
|
99886efa8b | ||
|
|
1b1be747bb | ||
|
|
9835926f4d |
@@ -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>
|
||||||
@@ -3,9 +3,10 @@ package de.neitzel.injection;
|
|||||||
import de.neitzel.injection.annotation.Config;
|
import de.neitzel.injection.annotation.Config;
|
||||||
|
|
||||||
import java.lang.reflect.Constructor;
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the context of the application and serves as the foundation of a dependency
|
* Represents the context of the application and serves as the foundation of a dependency
|
||||||
@@ -79,43 +80,39 @@ public class ApplicationContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Constructor<?>[] constructors = data.getType().getConstructors();
|
Constructor<?> injectionConstructor = data.getInjectionConstructor();
|
||||||
for (Constructor<?> constructor : constructors) {
|
if (injectionConstructor == null) {
|
||||||
if (canBeInstantiated(constructor, components)) {
|
throw new IllegalStateException("No injection constructor resolved for " + type.getName());
|
||||||
Class<?>[] paramTypes = constructor.getParameterTypes();
|
}
|
||||||
|
|
||||||
|
Class<?>[] paramTypes = injectionConstructor.getParameterTypes();
|
||||||
Object[] parameters = new Object[paramTypes.length];
|
Object[] parameters = new Object[paramTypes.length];
|
||||||
for (int i = 0; i < paramTypes.length; i++) {
|
for (int i = 0; i < paramTypes.length; i++) {
|
||||||
parameters[i] = getComponent(paramTypes[i]);
|
parameters[i] = getComponent(paramTypes[i]);
|
||||||
}
|
}
|
||||||
instance = constructor.newInstance(parameters);
|
|
||||||
|
instance = injectionConstructor.newInstance(parameters);
|
||||||
|
|
||||||
|
for (Field field : data.getInjectableFields()) {
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(instance, getComponent(field.getType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Method setter : data.getInjectableSetters()) {
|
||||||
|
setter.setAccessible(true);
|
||||||
|
Class<?> dependencyType = setter.getParameterTypes()[0];
|
||||||
|
setter.invoke(instance, getComponent(dependencyType));
|
||||||
|
}
|
||||||
|
|
||||||
if (scope == Scope.SINGLETON) {
|
if (scope == Scope.SINGLETON) {
|
||||||
data.setInstance(instance);
|
data.setInstance(instance);
|
||||||
}
|
}
|
||||||
return (T) instance;
|
return (T) instance;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new IllegalStateException("Kein passender Konstruktor gefunden für " + type.getName());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException("Fehler beim Erstellen der Instanz für " + type.getName(), e);
|
throw new RuntimeException("Fehler beim Erstellen der Instanz für " + type.getName(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if a given constructor can be instantiated using the provided parameter map.
|
|
||||||
* <p>
|
|
||||||
* The method evaluates whether every parameter type required by the constructor
|
|
||||||
* is available in the given parameter map. If all parameter types are present,
|
|
||||||
* the constructor is considered instantiable.
|
|
||||||
*
|
|
||||||
* @param constructor the constructor to check for instantiation feasibility
|
|
||||||
* @param parameterMap a map containing available parameter types and their associated component data
|
|
||||||
* @return true if all parameter types required by the constructor are contained in the parameter map, false otherwise
|
|
||||||
*/
|
|
||||||
private boolean canBeInstantiated(Constructor<?> constructor, Map<Class<?>, ComponentData> parameterMap) {
|
|
||||||
return Stream.of(constructor.getParameterTypes()).allMatch(parameterMap::containsKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a singleton instance of a specific type into the application context.
|
* Registers a singleton instance of a specific type into the application context.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ package de.neitzel.injection;
|
|||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a component in a dependency injection framework.
|
* Represents a component in a dependency injection framework.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -33,6 +38,23 @@ public class ComponentData {
|
|||||||
*/
|
*/
|
||||||
private final Scope scope;
|
private final Scope scope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the constructor the framework will use when instantiating this component.
|
||||||
|
* The constructor is determined during scanning based on @Inject annotations and availability rules.
|
||||||
|
*/
|
||||||
|
private final Constructor<?> injectionConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds all fields annotated with {@code @Inject}. They are populated immediately after construction.
|
||||||
|
*/
|
||||||
|
private final List<Field> injectableFields;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains all setter methods annotated with {@code @Inject}. These are invoked post-construction
|
||||||
|
* to perform setter-based dependency injection.
|
||||||
|
*/
|
||||||
|
private final List<Method> injectableSetters;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the instantiated object associated with the component.
|
* Stores the instantiated object associated with the component.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -44,17 +66,6 @@ public class ComponentData {
|
|||||||
*/
|
*/
|
||||||
private Object instance;
|
private Object instance;
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs a new Component instance with the specified type and scope.
|
|
||||||
*
|
|
||||||
* @param type the class type of the component
|
|
||||||
* @param scope the scope of the component, which determines its lifecycle
|
|
||||||
*/
|
|
||||||
public ComponentData(Class<?> type, Scope scope) {
|
|
||||||
this.type = type;
|
|
||||||
this.scope = scope;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a new ComponentData instance with the specified type and an initial instance.
|
* Constructs a new ComponentData instance with the specified type and an initial instance.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -64,11 +75,41 @@ public class ComponentData {
|
|||||||
* @param instance the initial instance of the component
|
* @param instance the initial instance of the component
|
||||||
*/
|
*/
|
||||||
public ComponentData(Class<?> type, Object instance) {
|
public ComponentData(Class<?> type, Object instance) {
|
||||||
this.type = type;
|
this(type, Scope.SINGLETON);
|
||||||
this.scope = Scope.SINGLETON;
|
|
||||||
this.instance = instance;
|
this.instance = instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new Component instance with the specified type and scope.
|
||||||
|
*
|
||||||
|
* @param type the class type of the component
|
||||||
|
* @param scope the scope of the component, which determines its lifecycle
|
||||||
|
*/
|
||||||
|
public ComponentData(Class<?> type, Scope scope) {
|
||||||
|
this(type, scope, null, List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new ComponentData instance with the specified parameters.
|
||||||
|
*
|
||||||
|
* @param type the class type of the component
|
||||||
|
* @param scope the scope of the component, which determines its lifecycle
|
||||||
|
* @param injectionConstructor the constructor to be used for injection, if any
|
||||||
|
* @param injectableFields the fields to be injected, if any
|
||||||
|
* @param injectableSetters the setter methods to be used for injection, if any
|
||||||
|
*/
|
||||||
|
public ComponentData(Class<?> type,
|
||||||
|
Scope scope,
|
||||||
|
Constructor<?> injectionConstructor,
|
||||||
|
List<Field> injectableFields,
|
||||||
|
List<Method> injectableSetters) {
|
||||||
|
this.type = type;
|
||||||
|
this.scope = scope;
|
||||||
|
this.injectionConstructor = injectionConstructor;
|
||||||
|
this.injectableFields = injectableFields == null ? List.of() : List.copyOf(injectableFields);
|
||||||
|
this.injectableSetters = injectableSetters == null ? List.of() : List.copyOf(injectableSetters);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the instance for this component if it is configured with a {@code SINGLETON} scope.
|
* Sets the instance for this component if it is configured with a {@code SINGLETON} scope.
|
||||||
* This method ensures that the instance is only set once for a {@code SINGLETON} component.
|
* This method ensures that the instance is only set once for a {@code SINGLETON} component.
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package de.neitzel.injection;
|
package de.neitzel.injection;
|
||||||
|
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Named;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
import org.reflections.Reflections;
|
import org.reflections.Reflections;
|
||||||
|
|
||||||
import java.lang.reflect.Constructor;
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -12,13 +15,22 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InjectableComponentScanner is responsible for scanning packages to detect classes annotated
|
* Scans a base package for Jakarta CDI-style components and derives dependency-injection metadata.
|
||||||
* with @FXMLComponent and analyzing their compatibility for instantiation and dependency injection.
|
* <p>
|
||||||
* The resulting analysis identifies unique and shared interfaces/superclasses as well as
|
* The scanner detects component classes annotated with {@link jakarta.inject.Singleton} or
|
||||||
* potentially instantiable components, collecting relevant errors if instantiation is not feasible.
|
* {@link jakarta.inject.Named}. It then:
|
||||||
|
* <ul>
|
||||||
|
* <li>Analyzes the discovered components' super types (interfaces and superclasses) to detect
|
||||||
|
* unique vs. ambiguous (non-unique) injectable types.</li>
|
||||||
|
* <li>Determines an {@code InjectionPlan} for each component (constructor + field + setter injection).</li>
|
||||||
|
* <li>Iteratively resolves which components are instantiable based on already-known resolvable types.</li>
|
||||||
|
* <li>Collects errors for components that cannot be resolved or instantiated.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* The result is a mapping from injectable types to {@link ComponentData} describing how to create
|
||||||
|
* and inject the corresponding component.
|
||||||
*/
|
*/
|
||||||
public class ComponentScanner {
|
public class ComponentScanner {
|
||||||
|
|
||||||
@@ -70,20 +82,6 @@ public class ComponentScanner {
|
|||||||
*/
|
*/
|
||||||
private final Map<Class<?>, Class<?>> uniqueTypeToComponent = new HashMap<>();
|
private final Map<Class<?>, Class<?>> uniqueTypeToComponent = new HashMap<>();
|
||||||
|
|
||||||
/**
|
|
||||||
* A mapping of components that can be instantiated with their corresponding types.
|
|
||||||
* This map links a component's class (key) to the specific implementation class (value)
|
|
||||||
* that can be instantiated. The entries are resolved through the scanning and analysis
|
|
||||||
* of available component types, ensuring that only components with resolvable
|
|
||||||
* dependencies are included.
|
|
||||||
* <p>
|
|
||||||
* This field is part of the dependency injection process, where components annotated
|
|
||||||
* with {@code @Component} or similar annotations are scanned, analyzed, and registered.
|
|
||||||
* The resolution process checks if a component can be instantiated based on its
|
|
||||||
* constructor dependencies being resolvable using known types or other registered components.
|
|
||||||
*/
|
|
||||||
private final Map<Class<?>, ComponentData> instantiableComponents = new HashMap<>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of error messages encountered during the scanning and analysis
|
* A list of error messages encountered during the scanning and analysis
|
||||||
* of components in the dependency injection process.
|
* of components in the dependency injection process.
|
||||||
@@ -97,6 +95,28 @@ public class ComponentScanner {
|
|||||||
*/
|
*/
|
||||||
private final List<String> errors = new ArrayList<>();
|
private final List<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache of computed {@link InjectionPlan}s per component class.
|
||||||
|
* <p>
|
||||||
|
* Plans are derived once via reflection and then reused to avoid repeated expensive analysis.
|
||||||
|
*/
|
||||||
|
private final Map<Class<?>, InjectionPlan> injectionPlans = new HashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks component classes that are known to be invalid (i.e., no usable injection plan).
|
||||||
|
* <p>
|
||||||
|
* This prevents repeated analysis and also avoids emitting duplicate error messages.
|
||||||
|
*/
|
||||||
|
private final Set<Class<?>> invalidComponents = new HashSet<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final mapping of resolvable injectable types to their corresponding {@link ComponentData}.
|
||||||
|
* <p>
|
||||||
|
* This map includes concrete component classes as keys and, where unique, their injectable
|
||||||
|
* super types (interfaces/superclasses) as additional keys.
|
||||||
|
*/
|
||||||
|
private final Map<Class<?>, ComponentData> instantiableComponents = new HashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes a new instance of the {@code InjectableComponentScanner} class, which scans for injectable
|
* Initializes a new instance of the {@code InjectableComponentScanner} class, which scans for injectable
|
||||||
* components within the specified base package and resolves the set of recognizable and instantiable components.
|
* components within the specified base package and resolves the set of recognizable and instantiable components.
|
||||||
@@ -110,7 +130,7 @@ public class ComponentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scans the specified base package for classes annotated with {@link Singleton}.
|
* Scans the specified base package for classes annotated with {@link Singleton} or {@link Named}.
|
||||||
* Identified component classes are added to a collection for further processing.
|
* Identified component classes are added to a collection for further processing.
|
||||||
*
|
*
|
||||||
* @param basePackage the package to scan for component annotations
|
* @param basePackage the package to scan for component annotations
|
||||||
@@ -118,6 +138,7 @@ public class ComponentScanner {
|
|||||||
private void scanForComponents(String basePackage) {
|
private void scanForComponents(String basePackage) {
|
||||||
Reflections reflections = new Reflections(basePackage);
|
Reflections reflections = new Reflections(basePackage);
|
||||||
components.addAll(reflections.getTypesAnnotatedWith(Singleton.class));
|
components.addAll(reflections.getTypesAnnotatedWith(Singleton.class));
|
||||||
|
components.addAll(reflections.getTypesAnnotatedWith(Named.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,23 +209,32 @@ public class ComponentScanner {
|
|||||||
Set<Class<?>> resolved = new HashSet<>();
|
Set<Class<?>> resolved = new HashSet<>();
|
||||||
Set<Class<?>> unresolved = new HashSet<>(components);
|
Set<Class<?>> unresolved = new HashSet<>(components);
|
||||||
Map<Class<?>, ComponentData> knownTypes = new HashMap<>();
|
Map<Class<?>, ComponentData> knownTypes = new HashMap<>();
|
||||||
Set<Class<?>> resolvableNow;
|
|
||||||
|
|
||||||
do {
|
while (true) {
|
||||||
resolvableNow = unresolved.stream()
|
Map<Class<?>, ComponentData> newlyResolvable = new HashMap<>();
|
||||||
.filter(c -> canInstantiate(c, knownTypes.keySet()))
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
for (Class<?> clazz : resolvableNow) {
|
for (Class<?> clazz : unresolved) {
|
||||||
Singleton annotation = clazz.getAnnotation(Singleton.class);
|
if (invalidComponents.contains(clazz)) {
|
||||||
ComponentData componentInfo = new ComponentData(clazz, Scope.SINGLETON);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
resolved.add(clazz);
|
ComponentData componentInfo = createComponentData(clazz, knownTypes.keySet());
|
||||||
|
if (componentInfo != null) {
|
||||||
|
newlyResolvable.put(clazz, componentInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newlyResolvable.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ComponentData componentInfo : newlyResolvable.values()) {
|
||||||
|
resolved.add(componentInfo.getType());
|
||||||
registerComponentWithSuperTypes(componentInfo, knownTypes);
|
registerComponentWithSuperTypes(componentInfo, knownTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
unresolved.removeAll(resolvableNow);
|
unresolved.removeAll(newlyResolvable.keySet());
|
||||||
} while (!resolvableNow.isEmpty());
|
}
|
||||||
|
|
||||||
instantiableComponents.putAll(knownTypes);
|
instantiableComponents.putAll(knownTypes);
|
||||||
|
|
||||||
@@ -233,47 +263,34 @@ public class ComponentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether the specified component class can be instantiated based on the provided set
|
* Attempts to create {@link ComponentData} for a component if an injection plan can be determined
|
||||||
* of known types. A component is considered instantiable if it has at least one constructor where
|
* and all required dependencies are already known.
|
||||||
* all parameter types are contained in the known types set or if it has a no-argument constructor.
|
|
||||||
* Additionally, all fields annotated with {@code @Inject} must have their types present in the
|
|
||||||
* known types set.
|
|
||||||
*
|
*
|
||||||
* @param component the class to check for instantiability
|
* @param component the concrete component class to analyze
|
||||||
* @param knownTypes the set of currently known types that can be used to satisfy dependencies
|
* @param knownTypes the set of injectable types that are already resolvable in the current iteration
|
||||||
* @return {@code true} if the component can be instantiated; {@code false} otherwise
|
* @return component metadata if the component is currently instantiable; otherwise {@code null}
|
||||||
*/
|
*/
|
||||||
private boolean canInstantiate(Class<?> component, Set<Class<?>> knownTypes) {
|
private ComponentData createComponentData(Class<?> component, Set<Class<?>> knownTypes) {
|
||||||
boolean hasValidConstructor = false;
|
InjectionPlan plan = getInjectionPlan(component);
|
||||||
|
if (plan == null) {
|
||||||
for (Constructor<?> constructor : component.getConstructors()) {
|
return null;
|
||||||
Class<?>[] paramTypes = constructor.getParameterTypes();
|
|
||||||
if (paramTypes.length == 0 || Arrays.stream(paramTypes).allMatch(knownTypes::contains)) {
|
|
||||||
hasValidConstructor = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasValidConstructor) {
|
if (!areDependenciesKnown(plan, knownTypes)) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var field : component.getDeclaredFields()) {
|
return new ComponentData(component, Scope.SINGLETON, plan.constructor(), plan.fields(), plan.setters());
|
||||||
if (field.isAnnotationPresent(Inject.class) && !knownTypes.contains(field.getType())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a component and its superclasses or interfaces in the provided map of known types.
|
* Registers an instantiable component under its concrete class and (if unique) under its
|
||||||
* This method ensures that the component and its inheritance hierarchy are associated with the
|
* injectable super types.
|
||||||
* component's data unless the supertype is marked as non-unique.
|
* <p>
|
||||||
|
* Non-unique super types are intentionally skipped to prevent ambiguous dependency resolution.
|
||||||
*
|
*
|
||||||
* @param component the {@code ComponentData} instance representing the component to be registered
|
* @param component the instantiable component metadata
|
||||||
* @param knownTypes the map where component types and their data are stored
|
* @param knownTypes the mutable registry of resolvable injectable types for the resolution loop
|
||||||
*/
|
*/
|
||||||
private void registerComponentWithSuperTypes(ComponentData component, Map<Class<?>, ComponentData> knownTypes) {
|
private void registerComponentWithSuperTypes(ComponentData component, Map<Class<?>, ComponentData> knownTypes) {
|
||||||
knownTypes.put(component.getType(), component);
|
knownTypes.put(component.getType(), component);
|
||||||
@@ -286,102 +303,269 @@ public class ComponentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collects the instantiation errors for a set of unresolved classes and appends
|
* Builds human-readable error messages for components that could not be resolved/instantiated.
|
||||||
* detailed error messages to the internal error list. This method analyzes unresolved
|
* <p>
|
||||||
* components based on their constructors, determining if all required types are
|
* Missing dependencies are computed from the component's {@link InjectionPlan}. If no missing
|
||||||
* instantiable or if there are conflicting types that could prevent instantiation.
|
* dependencies can be identified, the component is assumed to be blocked by ambiguous (non-unique)
|
||||||
|
* super types or other conflicts.
|
||||||
*
|
*
|
||||||
* @param unresolved the set of classes for which instantiation errors are collected
|
* @param unresolved set of component classes that remained unresolved after the resolution loop
|
||||||
*/
|
*/
|
||||||
private void collectInstantiationErrors(Set<Class<?>> unresolved) {
|
private void collectInstantiationErrors(Set<Class<?>> unresolved) {
|
||||||
for (Class<?> component : unresolved) {
|
for (Class<?> component : unresolved) {
|
||||||
StringBuilder errorMsg = new StringBuilder("Component cannot be instantiated: " + component.getName());
|
if (invalidComponents.contains(component)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
boolean possibleWithUniqueTypes = false;
|
InjectionPlan plan = getInjectionPlan(component);
|
||||||
|
if (plan == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (Constructor<?> constructor : component.getConstructors()) {
|
List<Class<?>> missingConstructor = new ArrayList<>();
|
||||||
Class<?>[] paramTypes = constructor.getParameterTypes();
|
for (Class<?> parameterType : plan.constructor().getParameterTypes()) {
|
||||||
List<Class<?>> problematicTypes = Arrays.stream(paramTypes)
|
if (isUnknownDependency(parameterType)) {
|
||||||
.filter(t -> !instantiableComponents.containsKey(t) && !notUniqueTypes.contains(t))
|
missingConstructor.add(parameterType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Class<?>> missingFields = new ArrayList<>();
|
||||||
|
for (Field field : plan.fields()) {
|
||||||
|
if (isUnknownDependency(field.getType())) {
|
||||||
|
missingFields.add(field.getType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Class<?>> missingSetters = new ArrayList<>();
|
||||||
|
for (Method setter : plan.setters()) {
|
||||||
|
Class<?> dependencyType = setter.getParameterTypes()[0];
|
||||||
|
if (isUnknownDependency(dependencyType)) {
|
||||||
|
missingSetters.add(dependencyType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder errorMessage = new StringBuilder("Component cannot be instantiated: ");
|
||||||
|
errorMessage.append(component.getName());
|
||||||
|
if (!missingConstructor.isEmpty()) {
|
||||||
|
errorMessage.append(" | Missing constructor dependencies: ").append(missingConstructor);
|
||||||
|
}
|
||||||
|
if (!missingFields.isEmpty()) {
|
||||||
|
errorMessage.append(" | Missing field dependencies: ").append(missingFields);
|
||||||
|
}
|
||||||
|
if (!missingSetters.isEmpty()) {
|
||||||
|
errorMessage.append(" | Missing setter dependencies: ").append(missingSetters);
|
||||||
|
}
|
||||||
|
if (missingConstructor.isEmpty() && missingFields.isEmpty() && missingSetters.isEmpty()) {
|
||||||
|
errorMessage.append(" | Conflicting super types detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.add(errorMessage.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cached {@link InjectionPlan} for the given component class.
|
||||||
|
* <p>
|
||||||
|
* If plan determination fails, the component is marked as invalid and {@code null} is returned.
|
||||||
|
*
|
||||||
|
* @param component the component class
|
||||||
|
* @return the computed injection plan, or {@code null} if none can be determined
|
||||||
|
*/
|
||||||
|
private InjectionPlan getInjectionPlan(Class<?> component) {
|
||||||
|
if (injectionPlans.containsKey(component)) {
|
||||||
|
return injectionPlans.get(component);
|
||||||
|
}
|
||||||
|
if (invalidComponents.contains(component)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
InjectionPlan plan = determineInjectionPlan(component);
|
||||||
|
if (plan == null) {
|
||||||
|
invalidComponents.add(component);
|
||||||
|
} else {
|
||||||
|
injectionPlans.put(component, plan);
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether all dependencies referenced by the given plan are present in the known type set.
|
||||||
|
*
|
||||||
|
* @param plan the injection plan to evaluate
|
||||||
|
* @param knownTypes currently resolvable injectable types
|
||||||
|
* @return {@code true} if all constructor/field/setter dependencies are known; otherwise {@code false}
|
||||||
|
*/
|
||||||
|
private boolean areDependenciesKnown(InjectionPlan plan, Set<Class<?>> knownTypes) {
|
||||||
|
if (!Arrays.stream(plan.constructor().getParameterTypes()).allMatch(knownTypes::contains)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Field field : plan.fields()) {
|
||||||
|
if (!knownTypes.contains(field.getType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Method setter : plan.setters()) {
|
||||||
|
Class<?> dependencyType = setter.getParameterTypes()[0];
|
||||||
|
if (!knownTypes.contains(dependencyType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines an {@link InjectionPlan} for a component by selecting a constructor and collecting
|
||||||
|
* injectable fields and setter methods.
|
||||||
|
* <p>
|
||||||
|
* A field is injectable if annotated with {@link jakarta.inject.Inject}.
|
||||||
|
* A method is treated as setter injection if annotated with {@link jakarta.inject.Inject} and
|
||||||
|
* matches the JavaBeans setter shape ({@code void setX(T value)}).
|
||||||
|
*
|
||||||
|
* @param component the component class
|
||||||
|
* @return the injection plan, or {@code null} if no usable constructor can be selected
|
||||||
|
*/
|
||||||
|
private InjectionPlan determineInjectionPlan(Class<?> component) {
|
||||||
|
Constructor<?> constructor = selectConstructor(component);
|
||||||
|
if (constructor == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Field> fields = Arrays.stream(component.getDeclaredFields())
|
||||||
|
.filter(field -> field.isAnnotationPresent(Inject.class))
|
||||||
|
.peek(field -> field.setAccessible(true))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (problematicTypes.isEmpty()) {
|
List<Method> setters = Arrays.stream(component.getDeclaredMethods())
|
||||||
possibleWithUniqueTypes = true;
|
.filter(method -> method.isAnnotationPresent(Inject.class))
|
||||||
} else {
|
.filter(method -> isValidSetter(component, method))
|
||||||
errorMsg.append("\n ➤ Requires unknown types: ").append(problematicTypes);
|
.peek(method -> method.setAccessible(true))
|
||||||
}
|
.toList();
|
||||||
}
|
|
||||||
|
|
||||||
if (possibleWithUniqueTypes) {
|
return new InjectionPlan(constructor, fields, setters);
|
||||||
errorMsg.append("\n ➤ Could be instantiated if multiple implementations of ")
|
|
||||||
.append("interfaces/superclasses were resolved uniquely: ")
|
|
||||||
.append(getConflictingTypes(component));
|
|
||||||
}
|
|
||||||
|
|
||||||
errors.add(errorMsg.toString());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identifies and retrieves a comma-separated string of conflicting types for a given component.
|
* Selects the constructor to use for instantiation.
|
||||||
* A conflicting type is a parameter type in the constructor of the given component
|
* <ul>
|
||||||
* that is already marked as not unique within the application context.
|
* <li>If exactly one constructor is annotated with {@link jakarta.inject.Inject}, it is selected.</li>
|
||||||
|
* <li>If multiple constructors are annotated with {@code @Inject}, the component is invalid.</li>
|
||||||
|
* <li>If there is exactly one declared constructor, it is selected.</li>
|
||||||
|
* <li>Otherwise, a no-arg constructor is required and selected if present.</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @param component the class for which conflicting types need to be identified.
|
* @param component the component class
|
||||||
* @return a comma-separated string of fully qualified names of conflicting parameter types,
|
* @return the selected constructor, or {@code null} if none can be selected
|
||||||
* or an empty string if no conflicts are found.
|
|
||||||
*/
|
*/
|
||||||
private String getConflictingTypes(Class<?> component) {
|
private Constructor<?> selectConstructor(Class<?> component) {
|
||||||
return Arrays.stream(component.getConstructors())
|
Constructor<?>[] constructors = component.getDeclaredConstructors();
|
||||||
.flatMap(constructor -> Arrays.stream(constructor.getParameterTypes()))
|
List<Constructor<?>> injectConstructors = Arrays.stream(constructors)
|
||||||
.filter(notUniqueTypes::contains)
|
.filter(c -> c.isAnnotationPresent(Inject.class))
|
||||||
.map(Class::getName)
|
.toList();
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
|
if (injectConstructors.size() > 1) {
|
||||||
|
errors.add("Multiple @Inject constructors found for " + component.getName());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Constructor<?> selected;
|
||||||
|
if (injectConstructors.size() == 1) {
|
||||||
|
selected = injectConstructors.get(0);
|
||||||
|
} else if (constructors.length == 1) {
|
||||||
|
selected = constructors[0];
|
||||||
|
} else {
|
||||||
|
selected = Arrays.stream(constructors)
|
||||||
|
.filter(c -> c.getParameterCount() == 0)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (selected == null) {
|
||||||
|
errors.add("No @Inject constructor and no no-arg constructor found for " + component.getName());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.setAccessible(true);
|
||||||
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a map of classes representing component types to their corresponding instantiable implementations.
|
* Validates whether a method annotated for injection is a compatible setter.
|
||||||
|
* <p>
|
||||||
|
* A valid setter must:
|
||||||
|
* <ul>
|
||||||
|
* <li>Have exactly one parameter</li>
|
||||||
|
* <li>Return {@code void}</li>
|
||||||
|
* <li>Start with the prefix {@code set}</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @return A map where the key is a class type and the value is the corresponding class implementation
|
* @param component the declaring component class (used for error messages)
|
||||||
* that can be instantiated.
|
* @param method the method to validate
|
||||||
|
* @return {@code true} if the method is a valid setter; otherwise {@code false}
|
||||||
|
*/
|
||||||
|
private boolean isValidSetter(Class<?> component, Method method) {
|
||||||
|
boolean isSetter = method.getParameterCount() == 1
|
||||||
|
&& method.getReturnType() == Void.TYPE
|
||||||
|
&& method.getName().startsWith("set");
|
||||||
|
|
||||||
|
if (!isSetter) {
|
||||||
|
errors.add("Invalid @Inject method detected in " + component.getName() + ": " + method.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSetter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a dependency type is unknown to the container.
|
||||||
|
* <p>
|
||||||
|
* A dependency is considered unknown if it is neither resolvable (present in
|
||||||
|
* {@link #instantiableComponents}) nor explicitly marked as ambiguous (present in
|
||||||
|
* {@link #notUniqueTypes}).
|
||||||
|
*
|
||||||
|
* @param type the dependency type
|
||||||
|
* @return {@code true} if the dependency cannot currently be resolved; otherwise {@code false}
|
||||||
|
*/
|
||||||
|
private boolean isUnknownDependency(Class<?> type) {
|
||||||
|
return !instantiableComponents.containsKey(type) && !notUniqueTypes.contains(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the mapping of resolvable injectable types to their {@link ComponentData}.
|
||||||
|
*
|
||||||
|
* @return a map of types to instantiation/injection metadata
|
||||||
*/
|
*/
|
||||||
public Map<Class<?>, ComponentData> getInstantiableComponents() {
|
public Map<Class<?>, ComponentData> getInstantiableComponents() {
|
||||||
return instantiableComponents;
|
return instantiableComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a set of types that have multiple implementations, making them
|
* Returns the set of super types (interfaces/superclasses) that are implemented by multiple
|
||||||
* non-unique. These types could not be uniquely resolved during the
|
* components and therefore cannot be uniquely resolved.
|
||||||
* component scanning and analysis process.
|
|
||||||
*
|
*
|
||||||
* @return a set of classes representing the types that have multiple
|
* @return set of non-unique injectable types
|
||||||
* implementations and cannot be resolved uniquely
|
|
||||||
*/
|
*/
|
||||||
public Set<Class<?>> getNotUniqueTypes() {
|
public Set<Class<?>> getNotUniqueTypes() {
|
||||||
return notUniqueTypes;
|
return notUniqueTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a mapping of types to their unique component implementations.
|
* Returns collected errors discovered during scanning and resolution.
|
||||||
* This map includes only those types that have a single corresponding component
|
|
||||||
* implementation, ensuring no ambiguity in resolution.
|
|
||||||
*
|
*
|
||||||
* @return a map where the keys are types (e.g., interfaces or superclasses)
|
* @return list of human-readable error messages
|
||||||
* and the values are their unique component implementations.
|
|
||||||
*/
|
|
||||||
public Map<Class<?>, Class<?>> getUniqueTypeToComponent() {
|
|
||||||
return uniqueTypeToComponent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a list of error messages recorded during the scanning and analysis process.
|
|
||||||
* The errors typically indicate issues such as components that cannot be instantiated due to
|
|
||||||
* missing dependencies, unresolvable component types, or multiple implementations of a type
|
|
||||||
* that are not uniquely resolved.
|
|
||||||
*
|
|
||||||
* @return a list of error messages explaining the issues encountered.
|
|
||||||
*/
|
*/
|
||||||
public List<String> getErrors() {
|
public List<String> getErrors() {
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable description of how to inject a component.
|
||||||
|
*
|
||||||
|
* @param constructor constructor selected for instantiation
|
||||||
|
* @param fields fields annotated with {@link jakarta.inject.Inject}
|
||||||
|
* @param setters setter methods annotated with {@link jakarta.inject.Inject}
|
||||||
|
*/
|
||||||
|
private record InjectionPlan(Constructor<?> constructor, List<Field> fields, List<Method> setters) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.neitzel.injection;
|
||||||
|
|
||||||
|
import de.neitzel.injection.testcomponents.injection.success.ConstructorInjectedService;
|
||||||
|
import de.neitzel.injection.testcomponents.injection.success.FieldInjectedService;
|
||||||
|
import de.neitzel.injection.testcomponents.injection.success.MixedInjectionService;
|
||||||
|
import de.neitzel.injection.testcomponents.injection.success.RootDependency;
|
||||||
|
import de.neitzel.injection.testcomponents.injection.success.SetterInjectedService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
|
||||||
|
class ApplicationContextInjectionTest {
|
||||||
|
|
||||||
|
private ApplicationContext context;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
context = new ApplicationContext("de.neitzel.injection.testcomponents.injection.success");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesConstructorInjection() {
|
||||||
|
ConstructorInjectedService service = context.getComponent(ConstructorInjectedService.class);
|
||||||
|
assertNotNull(service.getRootDependency());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesFieldInjection() {
|
||||||
|
FieldInjectedService service = context.getComponent(FieldInjectedService.class);
|
||||||
|
assertNotNull(service.getRootDependency());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesSetterInjection() {
|
||||||
|
SetterInjectedService service = context.getComponent(SetterInjectedService.class);
|
||||||
|
assertNotNull(service.getRootDependency());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesMixedInjectionGraphAndSingletonReuse() {
|
||||||
|
MixedInjectionService service = context.getComponent(MixedInjectionService.class);
|
||||||
|
RootDependency rootDependency = context.getComponent(RootDependency.class);
|
||||||
|
assertSame(rootDependency, service.getConstructorInjectedService().getRootDependency());
|
||||||
|
assertSame(rootDependency, service.getFieldInjectedService().getRootDependency());
|
||||||
|
assertSame(rootDependency, service.getSetterInjectedService().getRootDependency());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.neitzel.injection;
|
||||||
|
|
||||||
|
import de.neitzel.injection.testcomponents.injection.error.MultipleInjectConstructorsComponent;
|
||||||
|
import de.neitzel.injection.testcomponents.injection.error.NoInjectConstructorWithoutDefault;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ComponentScannerInjectionPlanTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsMultipleInjectConstructorsAsError() {
|
||||||
|
ComponentScanner scanner = new ComponentScanner("de.neitzel.injection.testcomponents.injection.error");
|
||||||
|
assertFalse(scanner.getErrors().isEmpty());
|
||||||
|
assertTrue(scanner.getErrors().stream().anyMatch(error -> error.contains(MultipleInjectConstructorsComponent.class.getName())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsMissingUsableConstructorAsError() {
|
||||||
|
ComponentScanner scanner = new ComponentScanner("de.neitzel.injection.testcomponents.injection.error");
|
||||||
|
assertTrue(scanner.getErrors().stream().anyMatch(error -> error.contains(NoInjectConstructorWithoutDefault.class.getName())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package de.neitzel.injection.testcomponents.injection.error;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class MultipleInjectConstructorsComponent {
|
||||||
|
@Inject
|
||||||
|
public MultipleInjectConstructorsComponent(String firstDependency) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public MultipleInjectConstructorsComponent(Integer secondDependency) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package de.neitzel.injection.testcomponents.injection.error;
|
||||||
|
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class NoInjectConstructorWithoutDefault {
|
||||||
|
public NoInjectConstructorWithoutDefault(String firstDependency) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public NoInjectConstructorWithoutDefault(Integer secondDependency) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package de.neitzel.injection.testcomponents.injection.success;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class ConstructorInjectedService {
|
||||||
|
private final RootDependency rootDependency;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ConstructorInjectedService(RootDependency rootDependency) {
|
||||||
|
this.rootDependency = rootDependency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RootDependency getRootDependency() {
|
||||||
|
return rootDependency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package de.neitzel.injection.testcomponents.injection.success;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class FieldInjectedService {
|
||||||
|
@Inject
|
||||||
|
private RootDependency rootDependency;
|
||||||
|
|
||||||
|
public RootDependency getRootDependency() {
|
||||||
|
return rootDependency;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package de.neitzel.injection.testcomponents.injection.success;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class MixedInjectionService {
|
||||||
|
private final ConstructorInjectedService constructorInjectedService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private FieldInjectedService fieldInjectedService;
|
||||||
|
|
||||||
|
private SetterInjectedService setterInjectedService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public MixedInjectionService(ConstructorInjectedService constructorInjectedService) {
|
||||||
|
this.constructorInjectedService = constructorInjectedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConstructorInjectedService getConstructorInjectedService() {
|
||||||
|
return constructorInjectedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FieldInjectedService getFieldInjectedService() {
|
||||||
|
return fieldInjectedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SetterInjectedService getSetterInjectedService() {
|
||||||
|
return setterInjectedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public void setSetterInjectedService(SetterInjectedService setterInjectedService) {
|
||||||
|
this.setterInjectedService = setterInjectedService;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package de.neitzel.injection.testcomponents.injection.success;
|
||||||
|
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class RootDependency {
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package de.neitzel.injection.testcomponents.injection.success;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class SetterInjectedService {
|
||||||
|
private RootDependency rootDependency;
|
||||||
|
|
||||||
|
public RootDependency getRootDependency() {
|
||||||
|
return rootDependency;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public void setRootDependency(RootDependency rootDependency) {
|
||||||
|
this.rootDependency = rootDependency;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user