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
+1 -1
View File
@@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and
The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits. The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits.
The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. Broader binding authoring, generation, structural editing and product packaging remain later work. The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. Broader binding authoring, generation, structural editing and product packaging remain later work.
Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules. Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules.
@@ -4,6 +4,24 @@ import java.util.*;
/** Non-constructing target metadata; compatibility is computed by the target type system. */ /** Non-constructing target metadata; compatibility is computed by the target type system. */
public record BindingMetadata( public record BindingMetadata(
String model,
String path,
String direction,
List<Attribute> attributes,
List<Target> targets,
List<String> diagnostics,
List<Converter> converters,
List<Problem> problems,
String controller,
String viewModel,
List<Computed> computed) {
public record Computed(String method, String returnType, String status, String reason) {
public String toString() {
return method + " : " + returnType + " — " + status + " (" + reason + ")";
}
}
public BindingMetadata(
String model, String model,
String path, String path,
String direction, String direction,
@@ -12,6 +30,20 @@ public record BindingMetadata(
List<String> diagnostics, List<String> diagnostics,
List<Converter> converters, List<Converter> converters,
List<Problem> problems) { List<Problem> problems) {
this(
model,
path,
direction,
attributes,
targets,
diagnostics,
converters,
problems,
"",
"",
List.of());
}
public record Converter(String name, String status, String reason) { public record Converter(String name, String status, String reason) {
public String toString() { public String toString() {
return name + " — " + status + ": " + reason; return name + " — " + status + ": " + reason;
@@ -45,13 +77,17 @@ public record BindingMetadata(
diagnostics = List.copyOf(diagnostics); diagnostics = List.copyOf(diagnostics);
converters = List.copyOf(converters); converters = List.copyOf(converters);
problems = List.copyOf(problems); problems = List.copyOf(problems);
computed = List.copyOf(computed);
if (!Set.of("view", "model", "both").contains(direction)) if (!Set.of("view", "model", "both").contains(direction))
throw new IllegalArgumentException("Invalid direction"); throw new IllegalArgumentException("Invalid direction");
} }
public Map<String, String> encode() { public Map<String, String> encode() {
var m = new TreeMap<String, String>(); var m = new TreeMap<String, String>();
m.put("format", "2"); m.put("format", "3");
m.put("controller", controller);
m.put("viewModel", viewModel);
m.put("computed", "" + computed.size());
m.put("converters", "" + converters.size()); m.put("converters", "" + converters.size());
m.put("problems", "" + problems.size()); m.put("problems", "" + problems.size());
m.put("model", model); m.put("model", model);
@@ -93,6 +129,14 @@ public record BindingMetadata(
m.put(p + "severity", v.severity()); m.put(p + "severity", v.severity());
m.put(p + "message", v.message()); m.put(p + "message", v.message());
} }
for (int i = 0; i < computed.size(); i++) {
var c = computed.get(i);
String p = "m." + i + ".";
m.put(p + "method", c.method());
m.put(p + "type", c.returnType());
m.put(p + "status", c.status());
m.put(p + "reason", c.reason());
}
return m; return m;
} }
@@ -107,7 +151,7 @@ public record BindingMetadata(
} }
public static BindingMetadata decode(Map<String, String> m) { public static BindingMetadata decode(Map<String, String> m) {
if (!"2".equals(field(m, "format"))) if (!"3".equals(field(m, "format")))
throw new IllegalArgumentException("Unsupported binding metadata"); throw new IllegalArgumentException("Unsupported binding metadata");
var a = new ArrayList<Attribute>(); var a = new ArrayList<Attribute>();
var t = new ArrayList<Target>(); var t = new ArrayList<Target>();
@@ -147,7 +191,27 @@ public record BindingMetadata(
field(m, p + "severity"), field(m, p + "severity"),
field(m, p + "message"))); field(m, p + "message")));
} }
var methods = new ArrayList<Computed>();
for (int i = 0; i < count(m, "computed"); i++) {
String p = "m." + i + ".";
methods.add(
new Computed(
field(m, p + "method"),
field(m, p + "type"),
field(m, p + "status"),
field(m, p + "reason")));
}
return new BindingMetadata( return new BindingMetadata(
field(m, "model"), field(m, "path"), field(m, "direction"), a, t, d, c, v); field(m, "model"),
field(m, "path"),
field(m, "direction"),
a,
t,
d,
c,
v,
field(m, "controller"),
field(m, "viewModel"),
methods);
} }
} }
@@ -82,8 +82,8 @@ public final class ProtocolSession implements AutoCloseable {
&& supported.contains("interactive-v1") && supported.contains("interactive-v1")
&& hello.capabilities().contains("interactive-v1")) usable.add(Operation.INTERACTIVE); && hello.capabilities().contains("interactive-v1")) usable.add(Operation.INTERACTIVE);
if (hello.metadata().major() == 1 if (hello.metadata().major() == 1
&& supported.contains("bindings-v2") && supported.contains("bindings-v3")
&& hello.capabilities().contains("bindings-v2")) usable.add(Operation.BINDINGS); && hello.capabilities().contains("bindings-v3")) usable.add(Operation.BINDINGS);
var requiredByEither = new HashSet<>(required); var requiredByEither = new HashSet<>(required);
requiredByEither.addAll(hello.required()); requiredByEither.addAll(hello.required());
if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER)) if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER))
@@ -92,7 +92,7 @@ public final class ProtocolSession implements AutoCloseable {
if ((requiredByEither.contains("layout-v1") && !usable.contains(Operation.MEASURE)) if ((requiredByEither.contains("layout-v1") && !usable.contains(Operation.MEASURE))
|| (requiredByEither.contains("interactive-v1") && !usable.contains(Operation.INTERACTIVE))) || (requiredByEither.contains("interactive-v1") && !usable.contains(Operation.INTERACTIVE)))
throw new IllegalArgumentException("Required capability version incompatible"); throw new IllegalArgumentException("Required capability version incompatible");
if (requiredByEither.contains("bindings-v2") && !usable.contains(Operation.BINDINGS)) if (requiredByEither.contains("bindings-v3") && !usable.contains(Operation.BINDINGS))
throw new IllegalArgumentException("Required capability version incompatible"); throw new IllegalArgumentException("Required capability version incompatible");
operations = Set.copyOf(usable); operations = Set.copyOf(usable);
state = State.READY; state = State.READY;
@@ -21,7 +21,12 @@ class BindingMetadataTest {
"input", "text", "java.lang.String", "COMPATIBLE", "Types match")), "input", "text", "java.lang.String", "COMPATIBLE", "Types match")),
List.of(), List.of(),
List.of(new BindingMetadata.Converter("app.C", "UNVERIFIED", "No metadata")), List.of(new BindingMetadata.Converter("app.C", "UNVERIFIED", "No metadata")),
List.of(new BindingMetadata.Problem("input", "text", "ERROR", "Missing path"))); List.of(new BindingMetadata.Problem("input", "text", "ERROR", "Missing path")),
"app.Controller",
"app.ViewModel",
List.of(
new BindingMetadata.Computed(
"#caption", "java.lang.String", "COMPATIBLE", "Declared type")));
assertEquals( assertEquals(
m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode())))); m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode()))));
var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0); var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0);
@@ -43,7 +48,7 @@ class BindingMetadataTest {
@Test @Test
void negotiationRequiresMetadataVersion() throws Exception { void negotiationRequiresMetadataVersion() throws Exception {
String token = ProtocolSession.newToken(); String token = ProtocolSession.newToken();
var session = new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2")); var session = new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3"));
assertTrue( assertTrue(
session session
.authenticate( .authenticate(
@@ -55,11 +60,11 @@ class BindingMetadataTest {
new Version(1, 0), new Version(1, 0),
new Version(1, 0), new Version(1, 0),
new Version(1, 0), new Version(1, 0),
Set.of("bindings-v2"), Set.of("bindings-v3"),
Set.of())) Set.of()))
.contains(Operation.BINDINGS)); .contains(Operation.BINDINGS));
var oldWorker = var oldWorker =
new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2")); new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3"));
assertThrows( assertThrows(
IllegalArgumentException.class, IllegalArgumentException.class,
() -> () ->
@@ -72,10 +77,10 @@ class BindingMetadataTest {
new Version(1, 0), new Version(1, 0),
new Version(1, 0), new Version(1, 0),
new Version(1, 0), new Version(1, 0),
Set.of("bindings-v1"), Set.of("bindings-v2"),
Set.of()))); Set.of())));
var incompatible = var incompatible =
new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2")); new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3"));
assertThrows( assertThrows(
IllegalArgumentException.class, IllegalArgumentException.class,
() -> () ->
@@ -88,7 +93,7 @@ class BindingMetadataTest {
new Version(1, 0), new Version(1, 0),
new Version(2, 0), new Version(2, 0),
new Version(1, 0), new Version(1, 0),
Set.of("bindings-v2"), Set.of("bindings-v3"),
Set.of()))); Set.of())));
} }
} }
@@ -231,7 +231,9 @@ public final class DesignerWorkflow implements AutoCloseable {
"control", "control",
control, control,
"property", "property",
property)), property,
"resource",
worker.resource(document.document()))),
Duration.ofSeconds(15)); Duration.ofSeconds(15));
try (var current = try (var current =
host.documents().open(document.document()).toCompletableFuture().get()) { host.documents().open(document.document()).toCompletableFuture().get()) {
@@ -12,6 +12,7 @@ public final class DraftOverlay implements AutoCloseable {
private final SnapshotStore.Lease lease; private final SnapshotStore.Lease lease;
private final Path directory; private final Path directory;
private final List<Path> runtime = new ArrayList<>(); private final List<Path> runtime = new ArrayList<>();
private final Map<java.net.URI, String> resourceNames = new HashMap<>();
private final Map<String, Long> revisions = new TreeMap<>(); private final Map<String, Long> revisions = new TreeMap<>();
public DraftOverlay(SnapshotStore.Lease lease, List<DocumentSnapshot> drafts) throws Exception { public DraftOverlay(SnapshotStore.Lease lease, List<DocumentSnapshot> drafts) throws Exception {
@@ -36,6 +37,7 @@ public final class DraftOverlay implements AutoCloseable {
if (matches.size() != 1) if (matches.size() != 1)
throw new IOException("FXML draft has no unambiguous captured source root: " + file); throw new IOException("FXML draft has no unambiguous captured source root: " + file);
String name = matches.iterator().next(); String name = matches.iterator().next();
resourceNames.put(draft.document(), name);
byte[] bytes = draft.text().getBytes(java.nio.charset.StandardCharsets.UTF_8); byte[] bytes = draft.text().getBytes(java.nio.charset.StandardCharsets.UTF_8);
if (bytes.length > 1024 * 1024) throw new IOException("FXML draft exceeds 1 MiB"); if (bytes.length > 1024 * 1024) throw new IOException("FXML draft exceeds 1 MiB");
if (resources.putIfAbsent(name, bytes) != null) if (resources.putIfAbsent(name, bytes) != null)
@@ -132,6 +134,10 @@ public final class DraftOverlay implements AutoCloseable {
return List.copyOf(result); return List.copyOf(result);
} }
public String resource(java.net.URI document) {
return Objects.requireNonNull(resourceNames.get(document), "Document is not captured");
}
public List<Path> runtime() { public List<Path> runtime() {
return List.copyOf(runtime); return List.copyOf(runtime);
} }
@@ -41,6 +41,10 @@ public final class PreviewWorker implements AutoCloseable {
return process.onExit(); return process.onExit();
} }
public String resource(java.net.URI document) {
return overlay.resource(document);
}
public DesignerConfiguration.Context context() throws Exception { public DesignerConfiguration.Context context() throws Exception {
return new com.fasterxml.jackson.databind.ObjectMapper() return new com.fasterxml.jackson.databind.ObjectMapper()
.readValue( .readValue(
@@ -104,7 +108,7 @@ public final class PreviewWorker implements AutoCloseable {
id, id,
process.pid(), process.pid(),
token, token,
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"),
config.requiredCapabilities()); config.requiredCapabilities());
} }
drain(process.getInputStream(), log); drain(process.getInputStream(), log);
@@ -54,6 +54,26 @@ public final class ConnectedWorkflowContract {
&& t.property().equals("text") && t.property().equals("text")
&& t.status().equals("COMPATIBLE")), && t.status().equals("COMPATIBLE")),
"Compatible binding target discovery"); "Compatible binding target discovery");
check(
bindings.viewModel().equals("fixture.components.PersonViewModel"),
"Custom ViewModel identity lost in transport");
check(
bindings.computed().stream().anyMatch(m -> m.method().equals("#greeting")),
"Computed methods lost in transport");
try (var badge =
await(host.documents().open(file.resolveSibling("BadgeCard.fxml").toUri()))) {
var metadata =
await(
workflow.bindingDetails(
false, await(badge.snapshot()), "badge", "view", "badge", "text"));
check(
metadata.controller().equals("fixture.components.BadgeCard"),
"Captured resource did not select Component Controller");
check(
metadata.viewModel().equals("fixture.components.BadgeViewModel"),
"Component ViewModel not selected");
check(metadata.problems().isEmpty(), "Component metadata rejected: " + metadata.problems());
}
var complex = await(workflow.bindings(false, initial, "home", "both")); var complex = await(workflow.bindings(false, initial, "home", "both"));
check( check(
complex.targets().stream() complex.targets().stream()
@@ -46,7 +46,7 @@ public final class ConnectedWorkflowTest extends BasePlatformTestCase {
UIUtil.dispatchAllInvocationEvents(); UIUtil.dispatchAllInvocationEvents();
Thread.sleep(10); Thread.sleep(10);
} }
assertEquals(56, (int) future.get(1, TimeUnit.SECONDS)); assertEquals(66, (int) future.get(1, TimeUnit.SECONDS));
} }
} }
} }
@@ -11,6 +11,7 @@ final class BindingBrowser extends VBox {
final ComboBox<BindingMetadata.Attribute> paths = new ComboBox<>(); final ComboBox<BindingMetadata.Attribute> paths = new ComboBox<>();
final ComboBox<String> directions = new ComboBox<>(); final ComboBox<String> directions = new ComboBox<>();
final TableView<BindingMetadata.Target> targets = new TableView<>(); final TableView<BindingMetadata.Target> targets = new TableView<>();
final ListView<BindingMetadata.Computed> methods = new ListView<>();
final Label status = new Label("Build and Refresh, then discover binding targets."); final Label status = new Label("Build and Refresh, then discover binding targets.");
private boolean updating; private boolean updating;
@@ -21,6 +22,8 @@ final class BindingBrowser extends VBox {
directions.setId("binding-direction"); directions.setId("binding-direction");
targets.setId("binding-targets"); targets.setId("binding-targets");
status.setId("binding-status"); status.setId("binding-status");
methods.setId("computed-methods");
methods.setPrefHeight(100);
paths.setPrefWidth(320); paths.setPrefWidth(320);
directions.getItems().setAll("view", "model", "both"); directions.getItems().setAll("view", "model", "both");
directions.setValue("both"); directions.setValue("both");
@@ -62,7 +65,9 @@ final class BindingBrowser extends VBox {
8, refresh, new Label("Model path"), paths, new Label("Direction"), directions), 8, refresh, new Label("Model path"), paths, new Label("Direction"), directions),
status, status,
targets, targets,
use); use,
new Label("Computed sources (public Controller methods)"),
methods);
} }
private String path() { private String path() {
@@ -77,8 +82,16 @@ final class BindingBrowser extends VBox {
} }
void invalidate(String message) { void invalidate(String message) {
updating = true;
try {
paths.getItems().clear();
paths.setValue(null);
targets.getItems().clear(); targets.getItems().clear();
methods.getItems().clear();
status.setText(message); status.setText(message);
} finally {
updating = false;
}
} }
void show(BindingMetadata metadata, String provenance) { void show(BindingMetadata metadata, String provenance) {
@@ -92,9 +105,14 @@ final class BindingBrowser extends VBox {
.orElse(null)); .orElse(null));
directions.setValue(metadata.direction()); directions.setValue(metadata.direction());
targets.getItems().setAll(metadata.targets()); targets.getItems().setAll(metadata.targets());
methods.getItems().setAll(metadata.computed());
status.setText( status.setText(
"Model: " "Model: "
+ metadata.model() + metadata.model()
+ " · ViewModel: "
+ metadata.viewModel()
+ " · Controller: "
+ metadata.controller()
+ " · " + " · "
+ provenance + provenance
+ "\n" + "\n"
@@ -748,17 +748,22 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
withDraft( withDraft(
() -> { () -> {
long ticket = ++bindingRequest; long ticket = ++bindingRequest;
validatingSource = null;
validationPreview = false;
bindingBrowser.invalidate("Discovering declared types in target runtime…"); bindingBrowser.invalidate("Discovering declared types in target runtime…");
var captured = draft; var captured = draft;
validatingSource = captured.text();
bindingWorkflow bindingWorkflow
.bindings(last, captured, path, direction) .bindings(last, captured, path, direction)
.whenComplete( .whenComplete(
(metadata, error) -> (metadata, error) ->
fx( fx(
() -> { () -> {
if (closed || ticket != bindingRequest) return; if (closed
|| ticket != bindingRequest
|| draft.revision() != captured.revision()) return;
validatingSource = null;
boolean resumePreview = validationPreview;
validationPreview = false;
if (error != null) { if (error != null) {
bindingBrowser.invalidate( bindingBrowser.invalidate(
"Discovery unavailable: " "Discovery unavailable: "
@@ -766,6 +771,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
+ ". Build and Refresh, then retry."); + ". Build and Refresh, then retry.");
return; return;
} }
validatedSource = captured.text();
bindingProblems = metadata.problems();
bindingBrowser.show( bindingBrowser.show(
metadata, metadata,
(last (last
@@ -773,6 +780,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
: "current successful build") : "current successful build")
+ " · draft revision " + " · draft revision "
+ captured.revision()); + captured.revision());
bindingEditor.metadata(
metadata,
null,
"Discovery · draft revision " + captured.revision());
if (resumePreview) render();
})); }));
}); });
} }
@@ -190,6 +190,10 @@ final class DirectBindingEditor extends VBox {
BindingMetadata metadata, DirectBindingDocument.Values requested, String provenance) { BindingMetadata metadata, DirectBindingDocument.Values requested, String provenance) {
status.setText( status.setText(
provenance provenance
+ " · ViewModel: "
+ metadata.viewModel()
+ " · Controller: "
+ metadata.controller()
+ "\n" + "\n"
+ (metadata.problems().isEmpty() + (metadata.problems().isEmpty()
? "No detected binding errors." ? "No detected binding errors."
@@ -482,6 +482,84 @@ public final class ProjectionHostChecks {
await(() -> fx(() -> source(pane).getText().equals(full))); await(() -> fx(() -> source(pane).getText().equals(full)));
} }
private static void viewModelBindings(
DesignerPane pane, DocumentSession document, String original) throws Exception {
bindingTab(pane, "Discover targets");
click(pane, "Discover bindings");
await(
() ->
fx(
() ->
((ComboBox<de.neitzel.fxdesigner.protocol.BindingMetadata.Attribute>)
pane.lookup("#binding-path"))
.getItems().stream().anyMatch(a -> a.path().equals("badge"))));
check(
fx(
() ->
((ListView<de.neitzel.fxdesigner.protocol.BindingMetadata.Computed>)
pane.lookup("#computed-methods"))
.getItems().stream().anyMatch(m -> m.method().equals("#greeting"))),
"Computed inventory missing");
check(documentText(document).equals(original), "ViewModel discovery altered FXML");
String invalid = original.replace("method=\"#greeting\"", "method=\"#invalidComputed\"");
check(!invalid.equals(original), "Computed fixture missing");
set(document, invalid);
await(() -> fx(() -> source(pane).getText().equals(invalid)));
bindingTab(pane, "Direct bindings");
click(pane, "Render drafts");
await(() -> bindingStatus(pane, "Computed return type"));
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& l.getText() != null
&& l.getText().startsWith("Preview blocked:"))));
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
.get();
check(
java.nio.file.Files.readString(
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
.equals(invalid),
"Invalid Computed draft could not be saved");
click(pane, "Undo");
await(() -> documentText(document).equals(original));
click(pane, "Redo");
await(() -> documentText(document).equals(invalid));
click(pane, "Undo");
await(() -> documentText(document).equals(original));
String replacement =
java.nio.file.Files.readString(
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document())
.resolveSibling("Replacement.fxml"));
set(document, replacement);
await(() -> fx(() -> source(pane).getText().equals(replacement)));
bindingTab(pane, "Discover targets");
click(pane, "Discover bindings");
await(
() ->
fx(
() ->
((Label) pane.lookup("#binding-status"))
.getText()
.contains("fixture.components.ReplacementViewModel")));
check(
fx(
() ->
((ComboBox<de.neitzel.fxdesigner.protocol.BindingMetadata.Attribute>)
pane.lookup("#binding-path"))
.getItems().stream().noneMatch(a -> a.path().equals("name"))),
"Replacement inherited Model attributes");
check(documentText(document).equals(replacement), "Replacement discovery altered FXML");
set(document, original);
await(() -> fx(() -> source(pane).getText().equals(original)));
}
public static void run(DesignerPane pane, DocumentSession document) throws Exception { public static void run(DesignerPane pane, DocumentSession document) throws Exception {
String original = document.snapshot().toCompletableFuture().get().text(); String original = document.snapshot().toCompletableFuture().get().text();
// The same real application, now with nested, fully-qualified Component declarations. // The same real application, now with nested, fully-qualified Component declarations.
@@ -810,6 +888,7 @@ public final class ProjectionHostChecks {
set(document, original); set(document, original);
await(() -> fx(() -> source(pane).getText().equals(original) && object(pane, "card") != null)); await(() -> fx(() -> source(pane).getText().equals(original) && object(pane, "card") != null));
viewModelBindings(pane, document, original);
document document
.save(document.snapshot().toCompletableFuture().get().revision()) .save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture() .toCompletableFuture()
@@ -16,6 +16,8 @@ public final class BindingDiscovery {
private final ClassLoader loader; private final ClassLoader loader;
private final List<String> diagnostics = new ArrayList<>(); private final List<String> diagnostics = new ArrayList<>();
private boolean incompleteModel; private boolean incompleteModel;
private ControllerMetadata owner;
private final List<Problem> selectionProblems = new ArrayList<>();
private final Map<String, Class<?>> paths = new TreeMap<>(); private final Map<String, Class<?>> paths = new TreeMap<>();
private final List<Attribute> attributes = new ArrayList<>(); private final List<Attribute> attributes = new ArrayList<>();
@@ -44,7 +46,21 @@ public final class BindingDiscovery {
throws Exception { throws Exception {
if (!Set.of("view", "model", "both").contains(direction)) if (!Set.of("view", "model", "both").contains(direction))
throw new IllegalArgumentException("Invalid 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 { private static Document parse(String xml) throws Exception {
@@ -131,6 +147,7 @@ public final class BindingDiscovery {
next.add(type); next.add(type);
for (var p : beans(type)) { for (var p : beans(type)) {
if (attributes.size() >= 256) throw new IOException("Too many Model paths"); 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) if (p.getReadMethod() == null || p.getWriteMethod() == null || p.getPropertyType() == null)
continue; continue;
Class<?> c = p.getPropertyType(); Class<?> c = p.getPropertyType();
@@ -155,6 +172,8 @@ public final class BindingDiscovery {
kind.equals("COLLECTION") ? element(p.getReadMethod().getGenericReturnType()) : "")); kind.equals("COLLECTION") ? element(p.getReadMethod().getGenericReturnType()) : ""));
if (kind.equals("COMPLEX")) attributes(c, path + ".", next, depth + 1); 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( private BindingMetadata inspect(
@@ -162,34 +181,47 @@ public final class BindingDiscovery {
String selected, String selected,
String direction, String direction,
String selectedControl, String selectedControl,
String selectedProperty) String selectedProperty,
String resource)
throws Exception { throws Exception {
Document doc = parse(xml); Document doc = parse(xml);
String model = model(doc); 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>(); var imports = new ArrayList<String>();
for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling()) for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import")) if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import"))
imports.add(pi.getData().trim()); 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 targets = new ArrayList<Target>();
var ids = new HashSet<String>(); var ids = new HashSet<String>();
var nodes = doc.getElementsByTagName("*"); var nodes = doc.getElementsByTagName("*");
@@ -200,11 +232,16 @@ public final class BindingDiscovery {
if (id.isEmpty() || defined(e)) continue; if (id.isEmpty() || defined(e)) continue;
if (!ids.add(id)) throw new IllegalArgumentException("Duplicate fx:id: " + id); if (!ids.add(id)) throw new IllegalArgumentException("Duplicate fx:id: " + id);
try { 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 = ""; String componentModel = "";
var resource = control.getResource(control.getSimpleName() + ".fxml"); var componentResource = control.getResource(control.getSimpleName() + ".fxml");
if (resource != null) if (componentResource != null)
try (var in = resource.openStream()) { try (var in = componentResource.openStream()) {
byte[] b = in.readNBytes(512 * 1024 + 1); byte[] b = in.readNBytes(512 * 1024 + 1);
if (b.length > 512 * 1024) throw new IOException("Component FXML exceeds limit"); if (b.length > 512 * 1024) throw new IOException("Component FXML exceeds limit");
componentModel = model(parse(new String(b, java.nio.charset.StandardCharsets.UTF_8))); componentModel = model(parse(new String(b, java.nio.charset.StandardCharsets.UTF_8)));
@@ -292,7 +329,73 @@ public final class BindingDiscovery {
targets, targets,
diagnostics, diagnostics,
candidates, 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) { private boolean bypassesConverter(String path) {
@@ -303,7 +406,8 @@ public final class BindingDiscovery {
private List<Problem> validate( private List<Problem> validate(
Document doc, List<String> imports, Set<String> ids, ConverterDiscovery converters) Document doc, List<String> imports, Set<String> ids, ConverterDiscovery converters)
throws Exception { throws Exception {
var problems = new ArrayList<Problem>(); var problems = new ArrayList<Problem>(selectionProblems);
problems.addAll(owner.problems);
var occupied = new HashSet<String>(); var occupied = new HashSet<String>();
var nodes = doc.getElementsByTagName("*"); var nodes = doc.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) { for (int i = 0; i < nodes.getLength(); i++) {
@@ -324,7 +428,7 @@ public final class BindingDiscovery {
key = control + "." + property; key = control + "." + property;
if (!occupied.add(key)) if (!occupied.add(key))
problems.add(new Problem(control, property, "ERROR", "Duplicate binding target")); problems.add(new Problem(control, property, "ERROR", "Duplicate binding target"));
if (library(e, "ComputedBinding", imports)) continue;
boolean nested = false; boolean nested = false;
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling()) for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element) nested = true; if (n instanceof Element) nested = true;
@@ -333,6 +437,26 @@ public final class BindingDiscovery {
new Problem(control, property, "WARNING", "Nested declaration requires Source review")); new Problem(control, property, "WARNING", "Nested declaration requires Source review"));
continue; 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"), String path = e.getAttribute("path"),
direction = e.getAttribute("direction"), direction = e.getAttribute("direction"),
converter = e.getAttribute("converter"); converter = e.getAttribute("converter");
@@ -364,6 +488,11 @@ public final class BindingDiscovery {
} else if (source == null) { } else if (source == null) {
severity = incompleteModel ? "WARNING" : "ERROR"; severity = incompleteModel ? "WARNING" : "ERROR";
message = "Unknown or unresolved Model path: " + path; 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()) { } else if (!converter.isEmpty()) {
var c = converters.check(converter, source, target.type(), direction); var c = converters.check(converter, source, target.type(), direction);
if (!c.status().equals("COMPATIBLE")) { 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), 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())); Set.of()));
var welcome = FrameCodec.read(socket.getInputStream()); var welcome = FrameCodec.read(socket.getInputStream());
if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1) 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(); var loader = WorkerMain.class.getClassLoader();
if (p.operation() == Operation.BINDINGS) { if (p.operation() == Operation.BINDINGS) {
var input = PreviewMessages.decode(p.payload()); 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"); throw new IOException("Invalid binding discovery input");
var metadata = var metadata =
BindingDiscovery.inspect( BindingDiscovery.inspect(
@@ -124,6 +126,7 @@ public final class WorkerMain {
input.get("direction"), input.get("direction"),
input.get("control"), input.get("control"),
input.get("property"), input.get("property"),
input.get("resource"),
loader); loader);
send( send(
socket, socket,
@@ -66,7 +66,8 @@ class BindingDiscoveryTest {
throws Exception { throws Exception {
var metadata = BindingDiscovery.inspect(fxml(), "name", "both", getClass().getClassLoader()); var metadata = BindingDiscovery.inspect(fxml(), "name", "both", getClass().getClassLoader());
assertEquals( assertEquals(
Set.of("home", "home.street", "name", "rank", "visits"), Set.of(
"home", "home.street", "name", "rank", "visits", "latestMessage", "home.latestMessage"),
metadata.attributes().stream() metadata.attributes().stream()
.map(a -> a.path()) .map(a -> a.path())
.collect(java.util.stream.Collectors.toSet())); .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");
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
# Shared Model and binding-target discovery # Shared Model and binding-target discovery
This is the first stage-4 implementation slice: a read-only binding browser shared by standalone and IntelliJ. The [DirectBinding inspector](direct-binding-inspector.md) now uses discovered targets to start editable declarations. [Converter discovery and DirectBinding validation](converter-validation.md) now extend this slice; generated metadata and the complete inspector remain subsequent work. This is the first stage-4 implementation slice: a read-only binding browser shared by standalone and IntelliJ. The [DirectBinding inspector](direct-binding-inspector.md) now uses discovered targets to start editable declarations. [Converter discovery and DirectBinding validation](converter-validation.md) now extend this slice; [custom ViewModel and Computed metadata](viewmodel-computed-validation.md) extend discovery further. Generated metadata and the complete inspector remain subsequent work.
## Use ## Use
@@ -12,9 +12,9 @@ For Person the browser exposes `name`, `rank`, `home`, `home.street`, and `visit
## Metadata boundary ## Metadata boundary
The fresh target worker loads classes without initialization and inspects signatures. It never constructs Models, Controllers, providers, controls or Converters, never invokes getters/property accessors, and never starts JavaFX. Application types stay outside both hosts. The independently negotiated `bindings-v2` capability adds the BINDINGS operation; it uses bounded UTF-8 metadata, build/context identity and document revisions, with zero image dimensions. Existing wire operation ordinals remain unchanged. The fresh target worker loads classes without initialization and inspects signatures. It never constructs Models, Controllers, providers, controls or Converters, never invokes getters/property accessors, and never starts JavaFX. Application types stay outside both hosts. The independently negotiated `bindings-v3` capability adds the BINDINGS operation; it uses bounded UTF-8 metadata, build/context identity and document revisions, with zero image dimensions. Existing wire operation ordinals remain unchanged.
Model fallback follows NeitzelLib's public read/write JavaBean Attribute rules and simple/complex/collection classification. Nested paths expand to depth four with cycle diagnostics; collection paths expose declared element types without pretending a collection is a single nested Model. Explicit custom BeanInfo is not executed: types requiring it report a generated-metadata diagnostic. This slice does not yet expose custom/replacement ViewModel members, generated metadata, Computed methods or library-provided message properties. Its compatibility results describe the Model-Attribute binding case, not the complete custom ViewModel API. Model fallback follows NeitzelLib's public read/write JavaBean Attribute rules and simple/complex/collection classification. Nested paths expand to depth four with cycle diagnostics; collection paths expose declared element types without pretending a collection is a single nested Model. Explicit custom BeanInfo is not executed: types requiring it report a generated-metadata diagnostic. Custom/replacement ViewModels, built-in message properties and Computed methods now follow the [signature-based selection rules](viewmodel-computed-validation.md). Generated metadata and arbitrary dynamic property APIs remain unresolved.
Targets come from public instance JavaFX observable-property accessors. The runtime requires writable `Property` accessors, so read-only targets remain visible as incompatible. Generic observable signatures and getter types identify the value type; incomplete or contradictory signatures remain unresolved. Component `model` targets use the Component resource's explicit Model declaration instead of treating Object as a specific type. Component resource metadata comes from the selected build; current-document metadata comes from the captured draft. Targets come from public instance JavaFX observable-property accessors. The runtime requires writable `Property` accessors, so read-only targets remain visible as incompatible. Generic observable signatures and getter types identify the value type; incomplete or contradictory signatures remain unresolved. Component `model` targets use the Component resource's explicit Model declaration instead of treating Object as a specific type. Component resource metadata comes from the selected build; current-document metadata comes from the captured draft.
@@ -24,7 +24,7 @@ Compatibility uses boxed declared types and target-runtime assignability: `view`
Discovery owns a separate workflow so it does not replace the canvas or interactive worker. It uses a leased successful build and a captured current-document overlay. Generation and document-revision checks reject obsolete replies. Document changes clear the target list and require rediscovery; Build and Refresh invalidates the panel. Explicit retained-build selection is identified in the displayed provenance. Closed editors cancel their metadata worker. Discovery owns a separate workflow so it does not replace the canvas or interactive worker. It uses a leased successful build and a captured current-document overlay. Generation and document-revision checks reject obsolete replies. Document changes clear the target list and require rediscovery; Build and Refresh invalidates the panel. Explicit retained-build selection is identified in the displayed provenance. Closed editors cancel their metadata worker.
FXML parsing disables external entities and DOCTYPE. Inputs, scene-node counts, path expansion, target counts and protocol payloads are bounded. Ambiguous imports, duplicate IDs, missing classes, invalid XML and absent declarations produce diagnostics. Custom ViewModel selection, attached-property binding discovery, open child-draft metadata overlays, generated metadata precedence and complete collection compatibility remain later extensions, not silently inferred support. FXML parsing disables external entities and DOCTYPE. Inputs, scene-node counts, path expansion, target counts and protocol payloads are bounded. Ambiguous imports, duplicate IDs, missing classes, invalid XML and absent declarations produce diagnostics. Attached-property binding discovery, open child-draft metadata overlays, generated metadata precedence and complete collection compatibility remain later extensions, not silently inferred support.
## Qualification ## Qualification
+2 -2
View File
@@ -27,9 +27,9 @@ The chooser distinguishes compatible, incompatible, unverified and invalid candi
The worker indexes class headers in captured classpath/module-path directories and JARs, respecting multi-release JAR selection, then loads Converter descendants without initialization. It reads annotations and method signatures without constructing Converters or invoking application methods. The index is bounded to 100,000 classes and 256 candidates; the normal worker deadline and cancellation apply. Application classes never enter a host. The worker indexes class headers in captured classpath/module-path directories and JARs, respecting multi-release JAR selection, then loads Converter descendants without initialization. It reads annotations and method signatures without constructing Converters or invoking application methods. The index is bounded to 100,000 classes and 256 candidates; the normal worker deadline and cancellation apply. Application classes never enter a host.
`bindings-v2` negotiates metadata format 2, adding candidate and document-problem records to the existing BINDINGS operation without changing its wire ordinal. Older binding capabilities are not silently interpreted as this format. The UTF-8 payload, metadata counts, request identity, build identity and document-revision checks remain bounded. The subsequent [ViewModel/Computed slice](viewmodel-computed-validation.md) now uses `bindings-v3` and metadata format 3, adding candidate and document-problem records to the existing BINDINGS operation without changing its wire ordinal. Older binding capabilities are not silently interpreted as this format. The UTF-8 payload, metadata counts, request identity, build identity and document-revision checks remain bounded.
The validator covers attribute-form DirectBindings in root BindingLists: discoverable Model paths, control IDs, JavaFX property existence/writability, plain type compatibility, explicit Converter availability/capabilities and duplicate Direct/Computed targets. Known violations are errors. Unresolved types, incomplete/custom Model metadata, missing Model declarations and nested binding syntax produce warnings. Computed methods, custom/generated ViewModels, collection semantics, nested Component draft metadata, dependency injection availability and runtime method behavior are not fully validated in this slice. A warning does not certify that preview will succeed. Complete Problems-view integration and broader binding authoring remain later work. The validator covers attribute-form DirectBindings in root BindingLists: discoverable Model paths, control IDs, JavaFX property existence/writability, plain type compatibility, explicit Converter availability/capabilities and duplicate Direct/Computed targets. Known violations are errors. Unresolved types, incomplete/custom Model metadata, missing Model declarations and nested binding syntax produce warnings. Custom/replacement ViewModel signatures and Computed methods are now checked by the [next slice](viewmodel-computed-validation.md). Generated metadata, collection semantics, nested Component draft metadata, dependency injection availability and runtime method behavior remain incomplete. A warning does not certify that preview will succeed. Complete Problems-view integration and broader binding authoring remain later work.
## Automated qualification ## Automated qualification
+1 -1
View File
@@ -16,7 +16,7 @@ A target occupied by another DirectBinding or ComputedBinding requires **Replace
Structural diagnostics cover duplicate targets, missing/ambiguous fx:id, empty property, malformed/missing Model path and invalid direction. [Converter discovery and binding validation](converter-validation.md) now add target-side path/property/type and capability checks, a shared chooser, and automatic validation before new previews. Saving and source repair remain available on errors. Structural diagnostics cover duplicate targets, missing/ambiguous fx:id, empty property, malformed/missing Model path and invalid direction. [Converter discovery and binding validation](converter-validation.md) now add target-side path/property/type and capability checks, a shared chooser, and automatic validation before new previews. Saving and source repair remain available on errors.
Generated/custom ViewModel metadata, Computed/Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work. [Custom ViewModel discovery and Computed validation](viewmodel-computed-validation.md) are implemented. Generated metadata, Computed/Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work.
## Automated qualification ## Automated qualification
+4
View File
@@ -109,3 +109,7 @@ Implemented the [read-only binding browser](binding-discovery.md) in the shared
## Converter discovery and binding validation ## Converter discovery and binding validation
The [next shared stage-4 slice](converter-validation.md) adds optional concrete-class capabilities, non-initializing classpath/module-path discovery, reviewed Converter proposals and DirectBinding diagnostics with preview gating in both hosts. Full metadata, structural reference repair and Problems integration remain open. The [next shared stage-4 slice](converter-validation.md) adds optional concrete-class capabilities, non-initializing classpath/module-path discovery, reviewed Converter proposals and DirectBinding diagnostics with preview gating in both hosts. Full metadata, structural reference repair and Problems integration remain open.
## Custom ViewModels and Computed bindings
The [shared signature-based slice](viewmodel-computed-validation.md) implements explicit Controller/Component ViewModel selection, subclass/replacement property discovery and Computed-method validation. Generated metadata, cross-View selection conflicts, full collection authoring and Problems integration remain open.
+35
View File
@@ -0,0 +1,35 @@
# Custom ViewModels and Computed bindings
The shared **Bindings → Discover targets** panel now identifies the declared Model, selected ViewModel and Controller, lists custom properties, and shows public zero-argument application methods as Computed sources. **Find Converters / Validate** and the automatic preview gate validate existing Direct and Computed declarations in both hosts. This slice validates Computed bindings edited in Source; it does not add a Computed-binding authoring form.
## Runtime selection and property precedence
Discovery reads NeitzelLib's `@ViewModelClass` on the FXML Controller. For Component `fx:root` documents it uses the captured resource path (`package/ClassName.fxml`) to identify the Component class, which is also the Controller. The worker receives this identity from the same unambiguous source-root mapping used for draft overlays. The root's actual Component class supplies its target properties, including additions beyond its FXML base type. An unavailable Component class/context is reported as unresolved; a conflicting `fx:controller` on Component FXML is an error.
With no explicit selection, discovery uses generic ViewModel capabilities, independent of incidental runtime reuse from another View. A subclass adds public no-argument accessors returning JavaFX `Property` to the inherited Model Attributes. Runtime precedence is preserved: reserved `latestMessage` wins, then Model Attributes, then additional property accessors. Generic nested Model paths also expose their message property. A replacement exposes only its own named JavaFX property API; it does not inherit Model Attributes, built-in message properties or dotted traversal through plain objects.
The selected class must be concrete and have a constructor accepting the declared Model. The most-specific compatible constructor is checked for access from NeitzelLib's module; incomparable overloads produce an ambiguity warning. No constructor or accessor runs during discovery. An overridden `ViewModel.property(String)` is dynamic: signature-derived results remain unverified and produce warnings. Raw/generic property signatures that cannot establish a value type stay unresolved. Read-only ObservableValue accessors are not advertised as bindable ViewModel properties, matching the runtime's requirement for `Property`.
## Computed validation
Discovery excludes Object methods and inherited JDK/JavaFX framework methods from the application-method inventory. Void and parameterized methods are unavailable as sources. Public inherited application methods are included. Method bodies never run during discovery.
Validation checks the declared Controller, public zero-argument method, reflective accessibility to NeitzelLib, target fx:id, writable JavaFX property, duplicate Direct/Computed targets and boxed return-type assignability. Private `@FXML` methods are not sufficient for NeitzelLib Computed bindings. Object or unresolved value types yield warnings; missing methods, void results and known incompatible types are errors. Existing unprefixed method names remain accepted because the runtime also accepts them; the inventory displays `#method`.
Computed validation still runs when a Model declaration is missing: that limits Model discovery but does not hide a known Controller-method error. Errors block new previews while saving invalid drafts and normal host Undo/Redo remain available. Results retain build/draft freshness checks. Pending target discovery can also satisfy an automatic preview-validation request, avoiding replacement of the user's discovery operation by a canvas refresh.
## Boundaries
Application classes stay in the target worker and are loaded without initialization. The `bindings-v3` capability and metadata format 3 add Controller/ViewModel identity and Computed signatures, plus the captured resource on requests. Old metadata capabilities are rejected rather than silently decoded. Existing protocol identities, payload limits, path/target limits and worker cancellation apply; the method inventory is bounded to 256 entries.
Generated metadata, custom BeanInfo execution, arbitrary dynamic property resolvers, full generic inference, cross-View first-use conflicts, nested ViewModel selection caused by other Views/Components, collection authoring and dependency-injection viability remain outside this slice. Method bodies, null results, constructor side effects and Computed refresh behavior are runtime concerns; compatible signatures do not prove that application code will succeed. In particular, discovery does not change which additional properties NeitzelLib observes for Computed refresh. Complete IDE Problems integration and a Computed authoring form remain later work.
## Automated qualification
Unit checks cover subclass/replacement selection, inherited properties and methods, name precedence, built-in messages, constructor mismatch, dynamic resolvers, Component resource identity/root properties, method accessibility and return types, missing Model declarations and non-initialization.
Real classpath and named-module fixtures check custom, replacement and Component metadata without initializing application classes. Separate fresh target processes load subclass/replacement Views through NeitzelLib, exercise custom-property write-back, confirm Model-name precedence and execute Computed methods. Shared host contracts verify resource identity through the worker transport. Live standalone and registered IntelliJ checks inspect custom properties and methods, save a type-invalid Computed binding, verify preview blocking and exact Undo/Redo, and discover replacement properties without mutating FXML. Existing rendering, Converter, input and lifecycle checks remain enabled. No manual test is required; previously skipped human checks remain skipped.
Qualification completed on 2026-11-13 on the pinned macOS baseline: `check-bootstrap.py` passed with **91 unit/adapter tests** (84 Maven, 7 IntelliJ), **36 fresh target processes / 484 fixture assertions**, both native standalone launch modes, both connected host adapters and **48 worker lifecycle cycles**. `check-ide-host.py` passed the registered IntelliJ editor scenarios for both classpath and module-path applications, including exclusive JavaFX 26 native-library verification and worker shutdown. No manual testing was used.
Local run evidence: `/tmp/viewmodel-bootstrap.log`, `/tmp/viewmodel-registered-ide.log`, `target/connected-workflow/` and `target/ide-workflow/{classpath,modulepath}.json`. Malformed-XML diagnostics are intentional recovery-test inputs. No uncaught application exception or native crash was found in these qualification runs. This is evidence for the pinned baseline, not a broader platform certification.
@@ -0,0 +1,20 @@
package fixture.components;
@jakarta.inject.Singleton
@de.neitzel.fx.view.ViewModelClass(BadgeViewModel.class)
public class BadgeCard extends javafx.scene.layout.VBox {
static {
System.setProperty("fixture.badgecard.initialized", "yes");
}
private final javafx.beans.property.ObjectProperty<Object> model =
new javafx.beans.property.SimpleObjectProperty<>();
public javafx.beans.property.ObjectProperty<Object> modelProperty() {
return model;
}
public String caption() {
throw new AssertionError("Metadata must not invoke Component method");
}
}
@@ -0,0 +1,11 @@
package fixture.components;
public class BadgeViewModel extends de.neitzel.fx.view.ViewModel {
public BadgeViewModel(fixture.model.Address model) {
super(model);
}
public javafx.beans.property.StringProperty badgeProperty() {
throw new AssertionError("Metadata must not invoke accessor");
}
}
@@ -3,6 +3,18 @@ package fixture.components;
@jakarta.inject.Singleton @jakarta.inject.Singleton
@de.neitzel.fx.view.ViewModelClass(PersonViewModel.class) @de.neitzel.fx.view.ViewModelClass(PersonViewModel.class)
public class PersonController implements de.neitzel.fx.view.HasViewModel { public class PersonController implements de.neitzel.fx.view.HasViewModel {
static {
System.setProperty("fixture.controller.initialized", "yes");
}
public Integer invalidComputed() {
throw new AssertionError("Validation must not invoke methods");
}
public Object uncertainComputed() {
return "Uncertain";
}
public final Greeting greeting; public final Greeting greeting;
public de.neitzel.fx.view.ViewModel received; public de.neitzel.fx.view.ViewModel received;
@@ -1,6 +1,21 @@
package fixture.components; package fixture.components;
public class PersonViewModel extends de.neitzel.fx.view.ViewModel { public class PersonViewModel extends de.neitzel.fx.view.ViewModel {
static {
System.setProperty("fixture.viewmodel.initialized", "yes");
}
private final javafx.beans.property.StringProperty badge =
new javafx.beans.property.SimpleStringProperty("Custom badge");
public javafx.beans.property.StringProperty badgeProperty() {
return badge;
}
public javafx.beans.property.IntegerProperty nameProperty() {
throw new AssertionError("Model name must win");
}
public PersonViewModel(Object model) { public PersonViewModel(Object model) {
super(model); super(model);
} }
@@ -0,0 +1,9 @@
package fixture.components;
@jakarta.inject.Singleton
@de.neitzel.fx.view.ViewModelClass(ReplacementViewModel.class)
public class ReplacementController {
public String caption() {
return "Replacement computed";
}
}
@@ -0,0 +1,16 @@
package fixture.components;
public class ReplacementViewModel {
static {
System.setProperty("fixture.replacement.initialized", "yes");
}
private final javafx.beans.property.StringProperty badge =
new javafx.beans.property.SimpleStringProperty("Replacement badge");
public ReplacementViewModel(fixture.model.Person model) {}
public javafx.beans.property.StringProperty badgeProperty() {
return badge;
}
}
@@ -0,0 +1,3 @@
<?neitzel-model fixture.model.Address?>
<?import javafx.scene.control.Label?>
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"><fx:define><de.neitzel.fx.view.BindingList><de.neitzel.fx.view.DirectBinding control="badge" property="text" path="badge" direction="view"/><de.neitzel.fx.view.ComputedBinding control="caption" property="text" method="#caption"/></de.neitzel.fx.view.BindingList></fx:define><Label fx:id="badge"/><Label fx:id="caption"/></fx:root>
@@ -0,0 +1,9 @@
<?neitzel-model fixture.model.Person?>
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.TextField?><?import javafx.scene.control.Label?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="fixture.components.PersonController">
<fx:define><de.neitzel.fx.view.BindingList>
<de.neitzel.fx.view.DirectBinding control="badge" property="text" path="badge" direction="both"/>
<de.neitzel.fx.view.ComputedBinding control="computed" property="text" method="#greeting"/>
</de.neitzel.fx.view.BindingList></fx:define>
<TextField fx:id="badge"/><Label fx:id="computed"/>
</VBox>
@@ -0,0 +1,9 @@
<?neitzel-model fixture.model.Person?>
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.TextField?><?import javafx.scene.control.Label?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="fixture.components.ReplacementController">
<fx:define><de.neitzel.fx.view.BindingList>
<de.neitzel.fx.view.DirectBinding control="badge" property="text" path="badge" direction="both"/>
<de.neitzel.fx.view.ComputedBinding control="computed" property="text" method="#caption"/>
</de.neitzel.fx.view.BindingList></fx:define>
<TextField fx:id="badge"/><Label fx:id="computed"/>
</VBox>
@@ -41,6 +41,10 @@ public final class PreviewChecks {
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
String mode = args[0]; String mode = args[0];
if (mode.equals("viewmodels")) {
ViewModelRuntimeChecks.run();
return;
}
if (mode.equals("unstable") if (mode.equals("unstable")
|| mode.equals("full") || mode.equals("full")
|| mode.equals("full-empty") || mode.equals("full-empty")
@@ -129,6 +133,51 @@ public final class PreviewChecks {
System.getProperty("fixture.converter.initialized") == null, System.getProperty("fixture.converter.initialized") == null,
"Discovery initialized Converter"); "Discovery initialized Converter");
} }
check(
metadata.viewModel().equals("fixture.components.PersonViewModel"),
"Explicit custom ViewModel");
check(
metadata.attributes().stream().anyMatch(a -> a.path().equals("badge")),
"Custom property absent");
check(
metadata.computed().stream().anyMatch(m -> m.method().equals("#greeting")),
"Computed method absent");
for (String resource : java.util.List.of("Replacement", "BadgeCard")) {
String path = "fixture/components/" + resource + ".fxml";
try (var in = loader.getResourceAsStream(path)) {
var result =
BindingDiscovery.inspect(
new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8),
"badge",
"view",
"badge",
"text",
path,
loader);
check(result.problems().isEmpty(), "Custom resource rejected: " + result.problems());
check(
result
.controller()
.equals(
"fixture.components."
+ (resource.equals("BadgeCard") ? "BadgeCard" : "ReplacementController")),
"Wrong resource Controller");
check(
result.attributes().stream().anyMatch(a -> a.path().equals("badge")),
"Missing custom resource property");
if (resource.equals("Replacement"))
check(
result.attributes().stream().noneMatch(a -> a.path().equals("name")),
"Replacement inherited Model");
}
}
for (String key :
java.util.List.of(
"fixture.viewmodel.initialized",
"fixture.controller.initialized",
"fixture.replacement.initialized",
"fixture.badgecard.initialized"))
check(System.getProperty(key) == null, "Discovery initialized " + key);
System.out.println("PASS bindings " + checks); System.out.println("PASS bindings " + checks);
return; return;
} }
@@ -0,0 +1,59 @@
package fixture.checks;
import de.neitzel.fx.view.*;
import de.neitzel.injection.ApplicationContext;
import fixture.model.Person;
import java.util.concurrent.*;
import javafx.application.Platform;
import javafx.scene.control.*;
public final class ViewModelRuntimeChecks {
public static void run() throws Exception {
var ready = new CountDownLatch(1);
Platform.startup(
() -> {
Platform.setImplicitExit(false);
ready.countDown();
});
if (!ready.await(10, TimeUnit.SECONDS)) throw new AssertionError("Toolkit startup");
try {
PreviewChecks.fx(
() -> {
var loader = ViewModelRuntimeChecks.class.getClassLoader();
for (String name : java.util.List.of("Custom", "Replacement")) {
var model = new Person();
var context = new ApplicationContext("fixture.components");
var view =
ViewLoader.load(
loader.getResource("fixture/components/" + name + ".fxml"), model, context);
var field = (TextField) view.getRoot().lookup("#badge");
PreviewChecks.check(
field
.getText()
.equals(name.equals("Custom") ? "Custom badge" : "Replacement badge"),
"Custom property initial value");
field.setText("Edited badge");
PreviewChecks.check(
view.property("badge").getValue().equals("Edited badge"),
"Custom property writeback");
PreviewChecks.check(
!((Label) view.getRoot().lookup("#computed")).getText().isEmpty(),
"Computed method did not run");
if (name.equals("Custom")) {
PreviewChecks.check(
view.property("name").getValue().equals(model.getName()),
"Subclass accessor shadowed Model attribute");
PreviewChecks.check(
view.property("latestMessage") != null, "Missing reserved property");
} else
PreviewChecks.check(
view.property("name") == null, "Replacement inherited Model attribute");
}
return null;
});
System.out.println("PASS viewmodels " + PreviewChecks.checks);
} finally {
Platform.exit();
}
}
}
@@ -0,0 +1,20 @@
package fixture.components;
@jakarta.inject.Singleton
@de.neitzel.fx.view.ViewModelClass(BadgeViewModel.class)
public class BadgeCard extends javafx.scene.layout.VBox {
static {
System.setProperty("fixture.badgecard.initialized", "yes");
}
private final javafx.beans.property.ObjectProperty<Object> model =
new javafx.beans.property.SimpleObjectProperty<>();
public javafx.beans.property.ObjectProperty<Object> modelProperty() {
return model;
}
public String caption() {
throw new AssertionError("Metadata must not invoke Component method");
}
}
@@ -0,0 +1,11 @@
package fixture.components;
public class BadgeViewModel extends de.neitzel.fx.view.ViewModel {
public BadgeViewModel(fixture.model.Address model) {
super(model);
}
public javafx.beans.property.StringProperty badgeProperty() {
throw new AssertionError("Metadata must not invoke accessor");
}
}
@@ -3,6 +3,18 @@ package fixture.components;
@jakarta.inject.Singleton @jakarta.inject.Singleton
@de.neitzel.fx.view.ViewModelClass(PersonViewModel.class) @de.neitzel.fx.view.ViewModelClass(PersonViewModel.class)
public class PersonController implements de.neitzel.fx.view.HasViewModel { public class PersonController implements de.neitzel.fx.view.HasViewModel {
static {
System.setProperty("fixture.controller.initialized", "yes");
}
public Integer invalidComputed() {
throw new AssertionError("Validation must not invoke methods");
}
public Object uncertainComputed() {
return "Uncertain";
}
public final Greeting greeting; public final Greeting greeting;
public de.neitzel.fx.view.ViewModel received; public de.neitzel.fx.view.ViewModel received;
@@ -1,6 +1,21 @@
package fixture.components; package fixture.components;
public class PersonViewModel extends de.neitzel.fx.view.ViewModel { public class PersonViewModel extends de.neitzel.fx.view.ViewModel {
static {
System.setProperty("fixture.viewmodel.initialized", "yes");
}
private final javafx.beans.property.StringProperty badge =
new javafx.beans.property.SimpleStringProperty("Custom badge");
public javafx.beans.property.StringProperty badgeProperty() {
return badge;
}
public javafx.beans.property.IntegerProperty nameProperty() {
throw new AssertionError("Model name must win");
}
public PersonViewModel(Object model) { public PersonViewModel(Object model) {
super(model); super(model);
} }
@@ -0,0 +1,9 @@
package fixture.components;
@jakarta.inject.Singleton
@de.neitzel.fx.view.ViewModelClass(ReplacementViewModel.class)
public class ReplacementController {
public String caption() {
return "Replacement computed";
}
}
@@ -0,0 +1,16 @@
package fixture.components;
public class ReplacementViewModel {
static {
System.setProperty("fixture.replacement.initialized", "yes");
}
private final javafx.beans.property.StringProperty badge =
new javafx.beans.property.SimpleStringProperty("Replacement badge");
public ReplacementViewModel(fixture.model.Person model) {}
public javafx.beans.property.StringProperty badgeProperty() {
return badge;
}
}
@@ -0,0 +1,3 @@
<?neitzel-model fixture.model.Address?>
<?import javafx.scene.control.Label?>
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"><fx:define><de.neitzel.fx.view.BindingList><de.neitzel.fx.view.DirectBinding control="badge" property="text" path="badge" direction="view"/><de.neitzel.fx.view.ComputedBinding control="caption" property="text" method="#caption"/></de.neitzel.fx.view.BindingList></fx:define><Label fx:id="badge"/><Label fx:id="caption"/></fx:root>
@@ -0,0 +1,9 @@
<?neitzel-model fixture.model.Person?>
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.TextField?><?import javafx.scene.control.Label?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="fixture.components.PersonController">
<fx:define><de.neitzel.fx.view.BindingList>
<de.neitzel.fx.view.DirectBinding control="badge" property="text" path="badge" direction="both"/>
<de.neitzel.fx.view.ComputedBinding control="computed" property="text" method="#greeting"/>
</de.neitzel.fx.view.BindingList></fx:define>
<TextField fx:id="badge"/><Label fx:id="computed"/>
</VBox>
@@ -0,0 +1,9 @@
<?neitzel-model fixture.model.Person?>
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.TextField?><?import javafx.scene.control.Label?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="fixture.components.ReplacementController">
<fx:define><de.neitzel.fx.view.BindingList>
<de.neitzel.fx.view.DirectBinding control="badge" property="text" path="badge" direction="both"/>
<de.neitzel.fx.view.ComputedBinding control="computed" property="text" method="#caption"/>
</de.neitzel.fx.view.BindingList></fx:define>
<TextField fx:id="badge"/><Label fx:id="computed"/>
</VBox>
@@ -41,6 +41,10 @@ public final class PreviewChecks {
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
String mode = args[0]; String mode = args[0];
if (mode.equals("viewmodels")) {
ViewModelRuntimeChecks.run();
return;
}
if (mode.equals("unstable") if (mode.equals("unstable")
|| mode.equals("full") || mode.equals("full")
|| mode.equals("full-empty") || mode.equals("full-empty")
@@ -129,6 +133,51 @@ public final class PreviewChecks {
System.getProperty("fixture.converter.initialized") == null, System.getProperty("fixture.converter.initialized") == null,
"Discovery initialized Converter"); "Discovery initialized Converter");
} }
check(
metadata.viewModel().equals("fixture.components.PersonViewModel"),
"Explicit custom ViewModel");
check(
metadata.attributes().stream().anyMatch(a -> a.path().equals("badge")),
"Custom property absent");
check(
metadata.computed().stream().anyMatch(m -> m.method().equals("#greeting")),
"Computed method absent");
for (String resource : java.util.List.of("Replacement", "BadgeCard")) {
String path = "fixture/components/" + resource + ".fxml";
try (var in = loader.getResourceAsStream(path)) {
var result =
BindingDiscovery.inspect(
new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8),
"badge",
"view",
"badge",
"text",
path,
loader);
check(result.problems().isEmpty(), "Custom resource rejected: " + result.problems());
check(
result
.controller()
.equals(
"fixture.components."
+ (resource.equals("BadgeCard") ? "BadgeCard" : "ReplacementController")),
"Wrong resource Controller");
check(
result.attributes().stream().anyMatch(a -> a.path().equals("badge")),
"Missing custom resource property");
if (resource.equals("Replacement"))
check(
result.attributes().stream().noneMatch(a -> a.path().equals("name")),
"Replacement inherited Model");
}
}
for (String key :
java.util.List.of(
"fixture.viewmodel.initialized",
"fixture.controller.initialized",
"fixture.replacement.initialized",
"fixture.badgecard.initialized"))
check(System.getProperty(key) == null, "Discovery initialized " + key);
System.out.println("PASS bindings " + checks); System.out.println("PASS bindings " + checks);
return; return;
} }
@@ -0,0 +1,59 @@
package fixture.checks;
import de.neitzel.fx.view.*;
import de.neitzel.injection.ApplicationContext;
import fixture.model.Person;
import java.util.concurrent.*;
import javafx.application.Platform;
import javafx.scene.control.*;
public final class ViewModelRuntimeChecks {
public static void run() throws Exception {
var ready = new CountDownLatch(1);
Platform.startup(
() -> {
Platform.setImplicitExit(false);
ready.countDown();
});
if (!ready.await(10, TimeUnit.SECONDS)) throw new AssertionError("Toolkit startup");
try {
PreviewChecks.fx(
() -> {
var loader = ViewModelRuntimeChecks.class.getClassLoader();
for (String name : java.util.List.of("Custom", "Replacement")) {
var model = new Person();
var context = new ApplicationContext("fixture.components");
var view =
ViewLoader.load(
loader.getResource("fixture/components/" + name + ".fxml"), model, context);
var field = (TextField) view.getRoot().lookup("#badge");
PreviewChecks.check(
field
.getText()
.equals(name.equals("Custom") ? "Custom badge" : "Replacement badge"),
"Custom property initial value");
field.setText("Edited badge");
PreviewChecks.check(
view.property("badge").getValue().equals("Edited badge"),
"Custom property writeback");
PreviewChecks.check(
!((Label) view.getRoot().lookup("#computed")).getText().isEmpty(),
"Computed method did not run");
if (name.equals("Custom")) {
PreviewChecks.check(
view.property("name").getValue().equals(model.getName()),
"Subclass accessor shadowed Model attribute");
PreviewChecks.check(
view.property("latestMessage") != null, "Missing reserved property");
} else
PreviewChecks.check(
view.property("name") == null, "Replacement inherited Model attribute");
}
return null;
});
System.out.println("PASS viewmodels " + PreviewChecks.checks);
} finally {
Platform.exit();
}
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ with tempfile.TemporaryDirectory(prefix="designer-preview-") as temp:
first = None first = None
for mode in ("discovery", "bindings", "standard", "async", "timeout", "cancel", for mode in ("discovery", "bindings", "standard", "async", "timeout", "cancel",
"readiness-failure", "wrong-model", "invalid-attribute", "readiness-failure", "wrong-model", "invalid-attribute",
"cleanup-failure", "factory-failure", "full", "full-empty", "card", "unstable"): "cleanup-failure", "factory-failure", "viewmodels", "full", "full-empty", "card", "unstable"):
log = launch(path, arrangement, mode, arrangement + "-" + mode) log = launch(path, arrangement, mode, arrangement + "-" + mode)
if mode == "standard": if mode == "standard":
first = re.search(r"SESSION (\S+) models=1", log)[1] first = re.search(r"SESSION (\S+) models=1", log)[1]