Discover custom ViewModels and validate Computed bindings in shared hosts
This commit is contained in:
+158
-29
@@ -16,6 +16,8 @@ public final class BindingDiscovery {
|
||||
private final ClassLoader loader;
|
||||
private final List<String> diagnostics = new ArrayList<>();
|
||||
private boolean incompleteModel;
|
||||
private ControllerMetadata owner;
|
||||
private final List<Problem> selectionProblems = new ArrayList<>();
|
||||
private final Map<String, Class<?>> paths = new TreeMap<>();
|
||||
private final List<Attribute> attributes = new ArrayList<>();
|
||||
|
||||
@@ -44,7 +46,21 @@ public final class BindingDiscovery {
|
||||
throws Exception {
|
||||
if (!Set.of("view", "model", "both").contains(direction))
|
||||
throw new IllegalArgumentException("Invalid direction");
|
||||
return new BindingDiscovery(loader).inspect(fxml, path, direction, control, property);
|
||||
return inspect(fxml, path, direction, control, property, "", loader);
|
||||
}
|
||||
|
||||
public static BindingMetadata inspect(
|
||||
String fxml,
|
||||
String path,
|
||||
String direction,
|
||||
String control,
|
||||
String property,
|
||||
String resource,
|
||||
ClassLoader loader)
|
||||
throws Exception {
|
||||
if (!Set.of("view", "model", "both").contains(direction))
|
||||
throw new IllegalArgumentException("Invalid direction");
|
||||
return new BindingDiscovery(loader).inspect(fxml, path, direction, control, property, resource);
|
||||
}
|
||||
|
||||
private static Document parse(String xml) throws Exception {
|
||||
@@ -131,6 +147,7 @@ public final class BindingDiscovery {
|
||||
next.add(type);
|
||||
for (var p : beans(type)) {
|
||||
if (attributes.size() >= 256) throw new IOException("Too many Model paths");
|
||||
if (p.getName().equals("latestMessage")) continue;
|
||||
if (p.getReadMethod() == null || p.getWriteMethod() == null || p.getPropertyType() == null)
|
||||
continue;
|
||||
Class<?> c = p.getPropertyType();
|
||||
@@ -155,6 +172,8 @@ public final class BindingDiscovery {
|
||||
kind.equals("COLLECTION") ? element(p.getReadMethod().getGenericReturnType()) : ""));
|
||||
if (kind.equals("COMPLEX")) attributes(c, path + ".", next, depth + 1);
|
||||
}
|
||||
paths.put(prefix + "latestMessage", String.class);
|
||||
attributes.add(new Attribute(prefix + "latestMessage", String.class.getName(), "BUILTIN", ""));
|
||||
}
|
||||
|
||||
private BindingMetadata inspect(
|
||||
@@ -162,34 +181,47 @@ public final class BindingDiscovery {
|
||||
String selected,
|
||||
String direction,
|
||||
String selectedControl,
|
||||
String selectedProperty)
|
||||
String selectedProperty,
|
||||
String resource)
|
||||
throws Exception {
|
||||
Document doc = parse(xml);
|
||||
String model = model(doc);
|
||||
if (model.isEmpty())
|
||||
return new BindingMetadata(
|
||||
"",
|
||||
"",
|
||||
direction,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(
|
||||
"Declare this View's Model with <?neitzel-model package.Model?> before discovering"
|
||||
+ " bindings."),
|
||||
List.of(),
|
||||
List.of(
|
||||
new Problem(
|
||||
"", "", "WARNING", "No Model declaration; semantic validation unavailable")));
|
||||
attributes(load(model), "", Set.of(), 0);
|
||||
if (selected.isEmpty())
|
||||
selected =
|
||||
paths.containsKey("name") ? "name" : paths.keySet().stream().findFirst().orElse("");
|
||||
Class<?> source = paths.get(selected);
|
||||
if (source == null && !selected.isEmpty()) diagnostics.add("Unknown Model path: " + selected);
|
||||
var imports = new ArrayList<String>();
|
||||
for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling())
|
||||
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import"))
|
||||
imports.add(pi.getData().trim());
|
||||
Class<?> modelType = null;
|
||||
if (model.isEmpty()) {
|
||||
incompleteModel = true;
|
||||
diagnostics.add(
|
||||
"Declare this View's Model with <?neitzel-model package.Model?> before discovering Model"
|
||||
+ " Attributes.");
|
||||
selectionProblems.add(
|
||||
new Problem(
|
||||
"", "", "WARNING", "No Model declaration; Model Attribute validation unavailable"));
|
||||
} else modelType = load(model);
|
||||
selectOwner(doc, imports, resource, modelType);
|
||||
Class<?> vm = owner.viewModel;
|
||||
if (vm == null) incompleteModel = true;
|
||||
else if (de.neitzel.fx.view.ViewModel.class.isAssignableFrom(vm)) {
|
||||
if (modelType != null) attributes(modelType, "", Set.of(), 0);
|
||||
else {
|
||||
paths.put("latestMessage", String.class);
|
||||
attributes.add(new Attribute("latestMessage", String.class.getName(), "BUILTIN", ""));
|
||||
}
|
||||
additionalProperties(vm);
|
||||
} else additionalProperties(vm);
|
||||
if (owner.dynamicProperties) {
|
||||
incompleteModel = true;
|
||||
diagnostics.add("Custom ViewModel.property resolver is unverified");
|
||||
}
|
||||
if (owner.unknownController) incompleteModel = true;
|
||||
if (selected.isEmpty())
|
||||
selected =
|
||||
paths.containsKey("name") ? "name" : paths.keySet().stream().findFirst().orElse("");
|
||||
Class<?> source =
|
||||
owner.dynamicProperties || owner.unknownController ? Object.class : paths.get(selected);
|
||||
if (source == null && !selected.isEmpty()) diagnostics.add("Unknown Model path: " + selected);
|
||||
var targets = new ArrayList<Target>();
|
||||
var ids = new HashSet<String>();
|
||||
var nodes = doc.getElementsByTagName("*");
|
||||
@@ -200,11 +232,16 @@ public final class BindingDiscovery {
|
||||
if (id.isEmpty() || defined(e)) continue;
|
||||
if (!ids.add(id)) throw new IllegalArgumentException("Duplicate fx:id: " + id);
|
||||
try {
|
||||
Class<?> control = resolve(e, imports);
|
||||
Class<?> control =
|
||||
e == doc.getDocumentElement()
|
||||
&& FX.equals(e.getNamespaceURI())
|
||||
&& owner.controller != null
|
||||
? owner.controller
|
||||
: resolve(e, imports);
|
||||
String componentModel = "";
|
||||
var resource = control.getResource(control.getSimpleName() + ".fxml");
|
||||
if (resource != null)
|
||||
try (var in = resource.openStream()) {
|
||||
var componentResource = control.getResource(control.getSimpleName() + ".fxml");
|
||||
if (componentResource != null)
|
||||
try (var in = componentResource.openStream()) {
|
||||
byte[] b = in.readNBytes(512 * 1024 + 1);
|
||||
if (b.length > 512 * 1024) throw new IOException("Component FXML exceeds limit");
|
||||
componentModel = model(parse(new String(b, java.nio.charset.StandardCharsets.UTF_8)));
|
||||
@@ -292,7 +329,73 @@ public final class BindingDiscovery {
|
||||
targets,
|
||||
diagnostics,
|
||||
candidates,
|
||||
validate(doc, imports, ids, converters));
|
||||
validate(doc, imports, ids, converters),
|
||||
owner.controller == null ? "" : owner.controller.getName(),
|
||||
owner.viewModel == null ? "" : owner.viewModel.getName(),
|
||||
owner.methods(chosen == null ? null : chosen.type()));
|
||||
}
|
||||
|
||||
private void selectOwner(Document doc, List<String> imports, String resource, Class<?> model)
|
||||
throws Exception {
|
||||
Element root = doc.getDocumentElement();
|
||||
boolean component = FX.equals(root.getNamespaceURI()) && "root".equals(root.getLocalName());
|
||||
String controllerName = root.getAttributeNS(FX, "controller");
|
||||
Class<?> controller = null;
|
||||
boolean unresolved = false;
|
||||
try {
|
||||
if (component) {
|
||||
if (!controllerName.isEmpty())
|
||||
selectionProblems.add(
|
||||
new Problem(
|
||||
"",
|
||||
"",
|
||||
"ERROR",
|
||||
"Component FXML cannot declare fx:controller; the Component is its Controller"));
|
||||
if (resource.matches("[a-zA-Z0-9_$/]+[.]fxml") && !resource.contains("..")) {
|
||||
controller = load(resource.substring(0, resource.length() - 5).replace('/', '.'));
|
||||
if (!resolve(root, imports).isAssignableFrom(controller)) {
|
||||
selectionProblems.add(
|
||||
new Problem("", "", "ERROR", "Component class is incompatible with fx:root type"));
|
||||
}
|
||||
} else unresolved = true;
|
||||
} else if (!controllerName.isEmpty()) controller = load(controllerName);
|
||||
} catch (ClassNotFoundException | LinkageError ex) {
|
||||
unresolved = true;
|
||||
selectionProblems.add(
|
||||
new Problem(
|
||||
"",
|
||||
"",
|
||||
component ? "WARNING" : "ERROR",
|
||||
"Controller type unavailable: " + ex.getMessage()));
|
||||
}
|
||||
if (component && controller == null)
|
||||
diagnostics.add("Component Controller requires its captured FXML resource identity");
|
||||
owner = new ControllerMetadata(controller, unresolved, model);
|
||||
}
|
||||
|
||||
private void additionalProperties(Class<?> vm) throws Exception {
|
||||
for (Method m : vm.getMethods()) {
|
||||
if (m.getParameterCount() != 0
|
||||
|| !m.getName().endsWith("Property")
|
||||
|| !Property.class.isAssignableFrom(m.getReturnType())
|
||||
|| m.isBridge()
|
||||
|| m.isSynthetic()) continue;
|
||||
String name = m.getName().substring(0, m.getName().length() - 8);
|
||||
if (name.isEmpty() || paths.containsKey(name)) continue;
|
||||
Class<?> value = propertyType(m.getGenericReturnType(), Map.of());
|
||||
Class<?> getter = valueType(vm, name);
|
||||
if (value == Number.class && getter != null && Number.class.isAssignableFrom(box(getter)))
|
||||
value = getter;
|
||||
else if (value != null && getter != null && box(value) != box(getter)) value = null;
|
||||
paths.put(name, value == null ? Object.class : value);
|
||||
attributes.add(
|
||||
new Attribute(
|
||||
name,
|
||||
value == null ? "?" : value.getName(),
|
||||
value == null || value == Object.class ? "UNRESOLVED" : "VIEWMODEL",
|
||||
""));
|
||||
if (attributes.size() > 256) throw new IOException("Too many ViewModel properties");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean bypassesConverter(String path) {
|
||||
@@ -303,7 +406,8 @@ public final class BindingDiscovery {
|
||||
private List<Problem> validate(
|
||||
Document doc, List<String> imports, Set<String> ids, ConverterDiscovery converters)
|
||||
throws Exception {
|
||||
var problems = new ArrayList<Problem>();
|
||||
var problems = new ArrayList<Problem>(selectionProblems);
|
||||
problems.addAll(owner.problems);
|
||||
var occupied = new HashSet<String>();
|
||||
var nodes = doc.getElementsByTagName("*");
|
||||
for (int i = 0; i < nodes.getLength(); i++) {
|
||||
@@ -324,7 +428,7 @@ public final class BindingDiscovery {
|
||||
key = control + "." + property;
|
||||
if (!occupied.add(key))
|
||||
problems.add(new Problem(control, property, "ERROR", "Duplicate binding target"));
|
||||
if (library(e, "ComputedBinding", imports)) continue;
|
||||
|
||||
boolean nested = false;
|
||||
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
|
||||
if (n instanceof Element) nested = true;
|
||||
@@ -333,6 +437,26 @@ public final class BindingDiscovery {
|
||||
new Problem(control, property, "WARNING", "Nested declaration requires Source review"));
|
||||
continue;
|
||||
}
|
||||
if (library(e, "ComputedBinding", imports)) {
|
||||
var target = targetTypes.get(key);
|
||||
if (!ids.contains(control))
|
||||
problems.add(new Problem(control, property, "ERROR", "Unknown control: " + control));
|
||||
else if (target == null)
|
||||
problems.add(
|
||||
new Problem(control, property, "ERROR", "No discoverable JavaFX property: " + key));
|
||||
else if (!target.writable())
|
||||
problems.add(
|
||||
new Problem(
|
||||
control,
|
||||
property,
|
||||
"ERROR",
|
||||
"Computed target requires a writable JavaFX Property"));
|
||||
else {
|
||||
var problem = owner.validate(control, property, e.getAttribute("method"), target.type());
|
||||
if (problem != null) problems.add(problem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String path = e.getAttribute("path"),
|
||||
direction = e.getAttribute("direction"),
|
||||
converter = e.getAttribute("converter");
|
||||
@@ -364,6 +488,11 @@ public final class BindingDiscovery {
|
||||
} else if (source == null) {
|
||||
severity = incompleteModel ? "WARNING" : "ERROR";
|
||||
message = "Unknown or unresolved Model path: " + path;
|
||||
} else if (owner.dynamicProperties || owner.unknownController) {
|
||||
severity = "WARNING";
|
||||
message =
|
||||
"Unresolved ViewModel selection or dynamic property resolver requires runtime"
|
||||
+ " validation";
|
||||
} else if (!converter.isEmpty()) {
|
||||
var c = converters.check(converter, source, target.type(), direction);
|
||||
if (!c.status().equals("COMPATIBLE")) {
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package de.neitzel.fxdesigner.target;
|
||||
|
||||
import de.neitzel.fx.view.*;
|
||||
import de.neitzel.fxdesigner.protocol.BindingMetadata.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Controller/ViewModel signatures only; no constructor, accessor or application method is invoked.
|
||||
*/
|
||||
final class ControllerMetadata {
|
||||
final Class<?> controller;
|
||||
final Class<?> viewModel;
|
||||
final boolean unknownController;
|
||||
final List<Problem> problems = new ArrayList<>();
|
||||
boolean dynamicProperties;
|
||||
|
||||
ControllerMetadata(Class<?> controller, boolean unknownController, Class<?> model) {
|
||||
this.controller = controller;
|
||||
this.unknownController = unknownController;
|
||||
Class<?> selected = ViewModel.class;
|
||||
if (controller != null) {
|
||||
try {
|
||||
var annotation = controller.getAnnotation(ViewModelClass.class);
|
||||
if (annotation != null) selected = annotation.value();
|
||||
} catch (RuntimeException | LinkageError e) {
|
||||
problems.add(problem("ERROR", "ViewModel selection unavailable: " + e.getMessage()));
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
viewModel = selected;
|
||||
if (selected == null || selected == ViewModel.class) return;
|
||||
if (selected.isInterface() || Modifier.isAbstract(selected.getModifiers())) {
|
||||
problems.add(problem("ERROR", "Selected ViewModel must be concrete"));
|
||||
return;
|
||||
}
|
||||
if (model == null)
|
||||
problems.add(problem("WARNING", "Declare the Model to check the ViewModel constructor"));
|
||||
else {
|
||||
var candidates =
|
||||
Arrays.stream(selected.getDeclaredConstructors())
|
||||
.filter(
|
||||
c ->
|
||||
c.getParameterCount() == 1
|
||||
&& c.getParameterTypes()[0].isAssignableFrom(model))
|
||||
.toList();
|
||||
if (candidates.isEmpty())
|
||||
problems.add(
|
||||
problem("ERROR", "No constructor taking the declared Model on " + selected.getName()));
|
||||
else {
|
||||
var mostSpecific =
|
||||
candidates.stream()
|
||||
.filter(
|
||||
c ->
|
||||
candidates.stream()
|
||||
.noneMatch(
|
||||
other ->
|
||||
other != c
|
||||
&& c.getParameterTypes()[0].isAssignableFrom(
|
||||
other.getParameterTypes()[0])))
|
||||
.toList();
|
||||
if (mostSpecific.size() != 1)
|
||||
problems.add(
|
||||
problem("WARNING", "Ambiguous ViewModel constructor selection for declared Model"));
|
||||
else {
|
||||
var constructor = mostSpecific.getFirst();
|
||||
if (!accessible(
|
||||
constructor.getDeclaringClass(), Modifier.isPublic(constructor.getModifiers())))
|
||||
problems.add(
|
||||
problem(
|
||||
"ERROR",
|
||||
"ViewModel constructor is not accessible to NeitzelLib; open its package"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ViewModel.class.isAssignableFrom(selected))
|
||||
try {
|
||||
dynamicProperties =
|
||||
selected.getMethod("property", String.class).getDeclaringClass() != ViewModel.class;
|
||||
if (dynamicProperties)
|
||||
problems.add(
|
||||
problem(
|
||||
"WARNING",
|
||||
"Overridden ViewModel.property requires generated metadata or runtime"
|
||||
+ " validation"));
|
||||
} catch (NoSuchMethodException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static Problem problem(String severity, String message) {
|
||||
return new Problem("", "", severity, message);
|
||||
}
|
||||
|
||||
static boolean accessible(Class<?> type, boolean publicMember) {
|
||||
Module source = type.getModule(), runtime = ViewModel.class.getModule();
|
||||
return source.isOpen(type.getPackageName(), runtime)
|
||||
|| (publicMember
|
||||
&& Modifier.isPublic(type.getModifiers())
|
||||
&& source.isExported(type.getPackageName(), runtime));
|
||||
}
|
||||
|
||||
static boolean objectMethod(String name) {
|
||||
try {
|
||||
Object.class.getMethod(name);
|
||||
return true;
|
||||
} catch (NoSuchMethodException absent) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
List<Computed> methods(Class<?> target) {
|
||||
if (controller == null) return List.of();
|
||||
var result = new TreeMap<String, Computed>();
|
||||
for (Method m : controller.getMethods()) {
|
||||
String pkg = m.getDeclaringClass().getPackageName();
|
||||
if (m.getParameterCount() != 0
|
||||
|| m.getReturnType() == void.class
|
||||
|| m.isBridge()
|
||||
|| m.isSynthetic()
|
||||
|| objectMethod(m.getName())
|
||||
|| pkg.startsWith("java.")
|
||||
|| pkg.startsWith("javafx.")
|
||||
|| pkg.startsWith("de.neitzel.fx.view")) continue;
|
||||
String error = methodError(m);
|
||||
String status =
|
||||
error == null
|
||||
? (m.getReturnType() == Object.class || target == null || target == Object.class
|
||||
? "UNRESOLVED"
|
||||
: BindingDiscovery.box(target)
|
||||
.isAssignableFrom(BindingDiscovery.box(m.getReturnType()))
|
||||
? "COMPATIBLE"
|
||||
: "INCOMPATIBLE")
|
||||
: "UNAVAILABLE";
|
||||
result.put(
|
||||
m.getName(),
|
||||
new Computed(
|
||||
"#" + m.getName(),
|
||||
m.getReturnType().getTypeName(),
|
||||
status,
|
||||
error == null
|
||||
? (target == null
|
||||
? "Select a target to compare declared types"
|
||||
: "Declared return type comparison")
|
||||
: error));
|
||||
if (result.size() > 256) throw new IllegalArgumentException("Too many Computed methods");
|
||||
}
|
||||
return List.copyOf(result.values());
|
||||
}
|
||||
|
||||
private String methodError(Method m) {
|
||||
if (!Modifier.isPublic(m.getDeclaringClass().getModifiers())
|
||||
|| !accessible(m.getDeclaringClass(), true))
|
||||
return "Computed method is inaccessible to NeitzelLib";
|
||||
if (m.getReturnType() == void.class) return "Computed method must return a value";
|
||||
if (objectMethod(m.getName())) return "Object methods are not Computed sources";
|
||||
return null;
|
||||
}
|
||||
|
||||
Problem validate(String control, String property, String value, Class<?> target) {
|
||||
String severity = "ERROR", message;
|
||||
if (controller == null) {
|
||||
severity = unknownController ? "WARNING" : "ERROR";
|
||||
message =
|
||||
unknownController
|
||||
? "Component Controller unresolved; open its captured resource"
|
||||
: "No Controller for Computed binding";
|
||||
} else {
|
||||
String name = value.startsWith("#") ? value.substring(1) : value;
|
||||
try {
|
||||
Method m = controller.getMethod(name);
|
||||
message = methodError(m);
|
||||
if (message == null) {
|
||||
if (target == null || target == Object.class || m.getReturnType() == Object.class) {
|
||||
severity = "WARNING";
|
||||
message = "Computed return/target type unresolved";
|
||||
} else if (!BindingDiscovery.box(target)
|
||||
.isAssignableFrom(BindingDiscovery.box(m.getReturnType())))
|
||||
message =
|
||||
"Computed return type "
|
||||
+ m.getReturnType().getTypeName()
|
||||
+ " is incompatible with "
|
||||
+ target.getTypeName();
|
||||
else return null;
|
||||
}
|
||||
} catch (NoSuchMethodException e) {
|
||||
message = "No public zero-argument Computed method: " + value;
|
||||
} catch (LinkageError e) {
|
||||
message = "Computed signature unavailable: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
return new Problem(control, property, severity, message);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ public final class WorkerMain {
|
||||
new Version(1, 0),
|
||||
new Version(1, 0),
|
||||
new Version(1, 0),
|
||||
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v2"),
|
||||
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v3"),
|
||||
Set.of()));
|
||||
var welcome = FrameCodec.read(socket.getInputStream());
|
||||
if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1)
|
||||
@@ -115,7 +115,9 @@ public final class WorkerMain {
|
||||
var loader = WorkerMain.class.getClassLoader();
|
||||
if (p.operation() == Operation.BINDINGS) {
|
||||
var input = PreviewMessages.decode(p.payload());
|
||||
if (!input.keySet().equals(Set.of("fxml", "path", "direction", "control", "property")))
|
||||
if (!input
|
||||
.keySet()
|
||||
.equals(Set.of("fxml", "path", "direction", "control", "property", "resource")))
|
||||
throw new IOException("Invalid binding discovery input");
|
||||
var metadata =
|
||||
BindingDiscovery.inspect(
|
||||
@@ -124,6 +126,7 @@ public final class WorkerMain {
|
||||
input.get("direction"),
|
||||
input.get("control"),
|
||||
input.get("property"),
|
||||
input.get("resource"),
|
||||
loader);
|
||||
send(
|
||||
socket,
|
||||
|
||||
+2
-1
@@ -66,7 +66,8 @@ class BindingDiscoveryTest {
|
||||
throws Exception {
|
||||
var metadata = BindingDiscovery.inspect(fxml(), "name", "both", getClass().getClassLoader());
|
||||
assertEquals(
|
||||
Set.of("home", "home.street", "name", "rank", "visits"),
|
||||
Set.of(
|
||||
"home", "home.street", "name", "rank", "visits", "latestMessage", "home.latestMessage"),
|
||||
metadata.attributes().stream()
|
||||
.map(a -> a.path())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
package de.neitzel.fxdesigner.target;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import de.neitzel.fx.view.*;
|
||||
import de.neitzel.fxdesigner.protocol.BindingMetadata;
|
||||
import java.util.*;
|
||||
import javafx.beans.property.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ViewModelComputedTest {
|
||||
public interface A {}
|
||||
|
||||
public interface B {}
|
||||
|
||||
public static class Model implements A, B {
|
||||
public String getName() {
|
||||
throw new AssertionError("getter invoked");
|
||||
}
|
||||
|
||||
public void setName(String s) {}
|
||||
|
||||
public int getLatestMessage() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void setLatestMessage(int i) {}
|
||||
}
|
||||
|
||||
public static class Custom extends ViewModel {
|
||||
static {
|
||||
System.setProperty("vm.metadata.initialized", "yes");
|
||||
}
|
||||
|
||||
public Custom(Model m) {
|
||||
super(m);
|
||||
throw new AssertionError("ViewModel constructed");
|
||||
}
|
||||
|
||||
public StringProperty badgeProperty() {
|
||||
throw new AssertionError("property invoked");
|
||||
}
|
||||
|
||||
public IntegerProperty nameProperty() {
|
||||
throw new AssertionError("shadow invoked");
|
||||
}
|
||||
|
||||
public IntegerProperty latestMessageProperty() {
|
||||
throw new AssertionError("reserved invoked");
|
||||
}
|
||||
|
||||
public ReadOnlyStringProperty readOnlyProperty() {
|
||||
throw new AssertionError("readonly invoked");
|
||||
}
|
||||
}
|
||||
|
||||
public static class Replacement {
|
||||
static {
|
||||
System.setProperty("replacement.metadata.initialized", "yes");
|
||||
}
|
||||
|
||||
public Replacement(Model m) {
|
||||
throw new AssertionError("replacement constructed");
|
||||
}
|
||||
|
||||
public IntegerProperty nameProperty() {
|
||||
throw new AssertionError("property invoked");
|
||||
}
|
||||
|
||||
public ObjectProperty<Model> homeProperty() {
|
||||
throw new AssertionError("property invoked");
|
||||
}
|
||||
|
||||
public StringProperty badgeProperty() {
|
||||
throw new AssertionError("property invoked");
|
||||
}
|
||||
}
|
||||
|
||||
public static class Ambiguous {
|
||||
public Ambiguous(A a) {}
|
||||
|
||||
public Ambiguous(B b) {}
|
||||
}
|
||||
|
||||
@ViewModelClass(Ambiguous.class)
|
||||
public static class AmbiguousController {}
|
||||
|
||||
public static class WrongConstructor {
|
||||
public WrongConstructor(String s) {}
|
||||
|
||||
public StringProperty badgeProperty() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Dynamic extends Custom {
|
||||
public Dynamic(Model m) {
|
||||
super(m);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Property<?> property(String name) {
|
||||
throw new AssertionError("dynamic invoked");
|
||||
}
|
||||
}
|
||||
|
||||
public static class ParentController {
|
||||
public String inherited() {
|
||||
throw new AssertionError("method invoked");
|
||||
}
|
||||
}
|
||||
|
||||
@ViewModelClass(Custom.class)
|
||||
public static class Controller extends ParentController {
|
||||
static {
|
||||
System.setProperty("controller.metadata.initialized", "yes");
|
||||
}
|
||||
|
||||
public Controller() {
|
||||
throw new AssertionError("Controller constructed");
|
||||
}
|
||||
|
||||
public String good() {
|
||||
throw new AssertionError("method invoked");
|
||||
}
|
||||
|
||||
public Integer wrong() {
|
||||
throw new AssertionError("method invoked");
|
||||
}
|
||||
|
||||
public Object unknown() {
|
||||
throw new AssertionError("method invoked");
|
||||
}
|
||||
|
||||
public void action() {
|
||||
throw new AssertionError("method invoked");
|
||||
}
|
||||
|
||||
public String argument(String s) {
|
||||
throw new AssertionError("method invoked");
|
||||
}
|
||||
|
||||
@javafx.fxml.FXML
|
||||
private String hidden() {
|
||||
throw new AssertionError("method invoked");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
throw new AssertionError("method invoked");
|
||||
}
|
||||
}
|
||||
|
||||
@ViewModelClass(Replacement.class)
|
||||
public static class ReplacementController {}
|
||||
|
||||
@ViewModelClass(WrongConstructor.class)
|
||||
public static class WrongController {}
|
||||
|
||||
@ViewModelClass(Dynamic.class)
|
||||
public static class DynamicController {}
|
||||
|
||||
@ViewModelClass(Replacement.class)
|
||||
public static class Component extends javafx.scene.layout.VBox {
|
||||
static {
|
||||
System.setProperty("component.metadata.initialized", "yes");
|
||||
}
|
||||
|
||||
public StringProperty captionProperty() {
|
||||
throw new AssertionError("property invoked");
|
||||
}
|
||||
|
||||
public String caption() {
|
||||
throw new AssertionError("component method invoked");
|
||||
}
|
||||
}
|
||||
|
||||
private String xml(Class<?> controller, String binding) {
|
||||
return "<?neitzel-model "
|
||||
+ Model.class.getName()
|
||||
+ "?><?import javafx.scene.control.TextField?><TextField"
|
||||
+ " xmlns:fx='http://javafx.com/fxml/1' fx:id='input'"
|
||||
+ (controller == null ? "" : " fx:controller='" + controller.getName() + "'")
|
||||
+ "><fx:define><de.neitzel.fx.view.BindingList>"
|
||||
+ binding
|
||||
+ "</de.neitzel.fx.view.BindingList></fx:define></TextField>";
|
||||
}
|
||||
|
||||
private String computed(String method) {
|
||||
return "<de.neitzel.fx.view.ComputedBinding control='input' property='text' method='"
|
||||
+ method
|
||||
+ "'/>";
|
||||
}
|
||||
|
||||
private String direct(String path) {
|
||||
return "<de.neitzel.fx.view.DirectBinding control='input' property='text' path='"
|
||||
+ path
|
||||
+ "' direction='view'/>";
|
||||
}
|
||||
|
||||
private BindingMetadata inspect(String xml) throws Exception {
|
||||
return BindingDiscovery.inspect(
|
||||
xml, "badge", "view", "input", "text", getClass().getClassLoader());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subclassUsesRuntimePrecedenceAndNeverInitializesApplicationClasses() throws Exception {
|
||||
var result =
|
||||
inspect(
|
||||
xml(
|
||||
Controller.class,
|
||||
direct("badge")
|
||||
+ computed("#good").replace("property='text'", "property='promptText'")));
|
||||
var types = new HashMap<String, String>();
|
||||
result.attributes().forEach(a -> types.put(a.path(), a.type()));
|
||||
assertEquals(String.class.getName(), types.get("badge"));
|
||||
assertEquals(String.class.getName(), types.get("name"));
|
||||
assertEquals(String.class.getName(), types.get("latestMessage"));
|
||||
assertFalse(types.containsKey("readOnly"));
|
||||
assertEquals(Custom.class.getName(), result.viewModel());
|
||||
assertTrue(result.problems().isEmpty(), result.problems().toString());
|
||||
for (String key : List.of("vm.metadata.initialized", "controller.metadata.initialized"))
|
||||
assertNull(System.getProperty(key));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacementDoesNotInheritModelAttributesOrDottedTraversal() throws Exception {
|
||||
var result = inspect(xml(ReplacementController.class, direct("home.name")));
|
||||
assertEquals(
|
||||
Set.of("name", "home", "badge"),
|
||||
result.attributes().stream()
|
||||
.map(a -> a.path())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(
|
||||
Number.class.getName(),
|
||||
result.attributes().stream()
|
||||
.filter(a -> a.path().equals("name"))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.type());
|
||||
assertTrue(result.problems().stream().anyMatch(p -> p.severity().equals("ERROR")));
|
||||
assertNull(System.getProperty("replacement.metadata.initialized"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void computedSignaturesAndTargetsAreCheckedWithoutInvokingMethods() throws Exception {
|
||||
var valid = inspect(xml(Controller.class, computed("#inherited")));
|
||||
assertTrue(valid.problems().isEmpty());
|
||||
assertTrue(
|
||||
valid.computed().stream()
|
||||
.anyMatch(m -> m.method().equals("#good") && m.status().equals("COMPATIBLE")));
|
||||
assertFalse(
|
||||
valid.computed().stream()
|
||||
.anyMatch(
|
||||
m -> Set.of("#action", "#toString", "#argument", "#hidden").contains(m.method())));
|
||||
for (String method :
|
||||
List.of("#wrong", "#missing", "#hidden", "#argument", "#action", "#toString"))
|
||||
assertTrue(
|
||||
inspect(xml(Controller.class, computed(method))).problems().stream()
|
||||
.anyMatch(p -> p.severity().equals("ERROR")),
|
||||
method);
|
||||
assertEquals(
|
||||
"WARNING",
|
||||
inspect(xml(Controller.class, computed("#unknown"))).problems().getFirst().severity());
|
||||
assertTrue(
|
||||
inspect(xml(null, computed("#good"))).problems().stream()
|
||||
.anyMatch(p -> p.message().contains("No Controller")));
|
||||
assertTrue(
|
||||
inspect(
|
||||
xml(Controller.class, computed("#wrong"))
|
||||
.replaceFirst("<\\?neitzel-model[^>]*>", ""))
|
||||
.problems()
|
||||
.stream()
|
||||
.anyMatch(p -> p.message().contains("incompatible")));
|
||||
assertTrue(
|
||||
inspect(
|
||||
xml(Controller.class, computed("#good"))
|
||||
.replace("property='text'", "property='width'"))
|
||||
.problems()
|
||||
.stream()
|
||||
.anyMatch(p -> p.message().contains("writable")));
|
||||
assertNull(System.getProperty("controller.metadata.initialized"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructionAndDynamicResolutionAreReported() throws Exception {
|
||||
assertTrue(
|
||||
inspect(xml(AmbiguousController.class, "")).problems().stream()
|
||||
.anyMatch(p -> p.message().contains("Ambiguous ViewModel constructor")));
|
||||
assertTrue(
|
||||
inspect(xml(WrongController.class, direct("badge"))).problems().stream()
|
||||
.anyMatch(p -> p.message().contains("No constructor")));
|
||||
assertTrue(
|
||||
inspect(xml(DynamicController.class, direct("badge"))).problems().stream()
|
||||
.anyMatch(p -> p.severity().equals("WARNING")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void componentResourceSelectsItsOwnControllerAndViewModel() throws Exception {
|
||||
String xml =
|
||||
"<?neitzel-model "
|
||||
+ Model.class.getName()
|
||||
+ "?><fx:root xmlns:fx='http://javafx.com/fxml/1' fx:id='root'"
|
||||
+ " type='javafx.scene.layout.VBox'/>";
|
||||
var metadata =
|
||||
BindingDiscovery.inspect(
|
||||
xml,
|
||||
"badge",
|
||||
"view",
|
||||
"",
|
||||
"",
|
||||
Component.class.getName().replace('.', '/') + ".fxml",
|
||||
getClass().getClassLoader());
|
||||
assertTrue(
|
||||
metadata.targets().stream()
|
||||
.anyMatch(t -> t.control().equals("root") && t.property().equals("caption")),
|
||||
"Actual Component root properties missing");
|
||||
assertEquals(Component.class.getName(), metadata.controller());
|
||||
assertEquals(Replacement.class.getName(), metadata.viewModel());
|
||||
assertTrue(metadata.computed().stream().anyMatch(m -> m.method().equals("#caption")));
|
||||
assertNull(System.getProperty("component.metadata.initialized"));
|
||||
assertTrue(inspect(xml).diagnostics().stream().anyMatch(d -> d.contains("captured FXML")));
|
||||
var unresolved = BindingDiscovery.inspect(xml, "name", "view", getClass().getClassLoader());
|
||||
assertTrue(
|
||||
unresolved.targets().stream()
|
||||
.anyMatch(t -> t.property().equals("style") && t.status().equals("UNRESOLVED")),
|
||||
"Unknown Component ViewModel was certified as generic");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user