Discover custom ViewModels and validate Computed bindings in shared hosts

This commit is contained in:
2026-11-13 13:27:23 +01:00
parent 5a208d2336
commit 99d93a11fc
46 changed files with 1397 additions and 64 deletions
@@ -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")) {
@@ -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,
@@ -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()));
@@ -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");
}
}