From 99d93a11fc2e9be70950dc63d83fb6dc668f43b5 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 13 Nov 2026 13:27:23 +0100 Subject: [PATCH] Discover custom ViewModels and validate Computed bindings in shared hosts --- README.md | 2 +- .../fxdesigner/protocol/BindingMetadata.java | 72 +++- .../fxdesigner/protocol/ProtocolSession.java | 6 +- .../protocol/BindingMetadataTest.java | 19 +- .../fxdesigner/workflow/DesignerWorkflow.java | 4 +- .../fxdesigner/workflow/DraftOverlay.java | 6 + .../fxdesigner/workflow/PreviewWorker.java | 6 +- .../workflow/ConnectedWorkflowContract.java | 20 ++ .../intellij/ConnectedWorkflowTest.java | 2 +- .../fxdesigner/kit/BindingBrowser.java | 24 +- .../neitzel/fxdesigner/kit/DesignerPane.java | 18 +- .../fxdesigner/kit/DirectBindingEditor.java | 4 + .../fxdesigner/kit/ProjectionHostChecks.java | 79 +++++ .../fxdesigner/target/BindingDiscovery.java | 187 ++++++++-- .../fxdesigner/target/ControllerMetadata.java | 194 +++++++++++ .../neitzel/fxdesigner/target/WorkerMain.java | 7 +- .../target/BindingDiscoveryTest.java | 3 +- .../target/ViewModelComputedTest.java | 329 ++++++++++++++++++ docs/binding-discovery.md | 8 +- docs/converter-validation.md | 4 +- docs/direct-binding-inspector.md | 2 +- docs/repository-bootstrap-plan.md | 4 + docs/viewmodel-computed-validation.md | 35 ++ .../java/fixture/components/BadgeCard.java | 20 ++ .../fixture/components/BadgeViewModel.java | 11 + .../fixture/components/PersonController.java | 12 + .../fixture/components/PersonViewModel.java | 15 + .../components/ReplacementController.java | 9 + .../components/ReplacementViewModel.java | 16 + .../fixture/components/BadgeCard.fxml | 3 + .../resources/fixture/components/Custom.fxml | 9 + .../fixture/components/Replacement.fxml | 9 + .../java/fixture/checks/PreviewChecks.java | 49 +++ .../checks/ViewModelRuntimeChecks.java | 59 ++++ .../java/fixture/components/BadgeCard.java | 20 ++ .../fixture/components/BadgeViewModel.java | 11 + .../fixture/components/PersonController.java | 12 + .../fixture/components/PersonViewModel.java | 15 + .../components/ReplacementController.java | 9 + .../components/ReplacementViewModel.java | 16 + .../fixture/components/BadgeCard.fxml | 3 + .../resources/fixture/components/Custom.fxml | 9 + .../fixture/components/Replacement.fxml | 9 + .../java/fixture/checks/PreviewChecks.java | 49 +++ .../checks/ViewModelRuntimeChecks.java | 59 ++++ scripts/check-preview-fixtures.py | 2 +- 46 files changed, 1397 insertions(+), 64 deletions(-) create mode 100644 designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ControllerMetadata.java create mode 100644 designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ViewModelComputedTest.java create mode 100644 docs/viewmodel-computed-validation.md create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BadgeCard.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BadgeViewModel.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ReplacementController.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ReplacementViewModel.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/BadgeCard.fxml create mode 100644 integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Custom.fxml create mode 100644 integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Replacement.fxml create mode 100644 integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/ViewModelRuntimeChecks.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BadgeCard.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BadgeViewModel.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ReplacementController.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ReplacementViewModel.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/BadgeCard.fxml create mode 100644 integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Custom.fxml create mode 100644 integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Replacement.fxml create mode 100644 integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/ViewModelRuntimeChecks.java diff --git a/README.md b/README.md index e855a40..161f2a7 100644 --- a/README.md +++ b/README.md @@ -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 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. diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java index 21a5a8a..4ffe81f 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java @@ -11,7 +11,39 @@ public record BindingMetadata( List targets, List diagnostics, List converters, - List problems) { + List problems, + String controller, + String viewModel, + List 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 path, + String direction, + List attributes, + List targets, + List diagnostics, + List converters, + List problems) { + this( + model, + path, + direction, + attributes, + targets, + diagnostics, + converters, + problems, + "", + "", + List.of()); + } + public record Converter(String name, String status, String reason) { public String toString() { return name + " — " + status + ": " + reason; @@ -45,13 +77,17 @@ public record BindingMetadata( diagnostics = List.copyOf(diagnostics); converters = List.copyOf(converters); problems = List.copyOf(problems); + computed = List.copyOf(computed); if (!Set.of("view", "model", "both").contains(direction)) throw new IllegalArgumentException("Invalid direction"); } public Map encode() { var m = new TreeMap(); - 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("problems", "" + problems.size()); m.put("model", model); @@ -93,6 +129,14 @@ public record BindingMetadata( m.put(p + "severity", v.severity()); 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; } @@ -107,7 +151,7 @@ public record BindingMetadata( } public static BindingMetadata decode(Map m) { - if (!"2".equals(field(m, "format"))) + if (!"3".equals(field(m, "format"))) throw new IllegalArgumentException("Unsupported binding metadata"); var a = new ArrayList(); var t = new ArrayList(); @@ -147,7 +191,27 @@ public record BindingMetadata( field(m, p + "severity"), field(m, p + "message"))); } + var methods = new ArrayList(); + 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( - 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); } } diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java index ff1cbc2..9f7ec71 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java @@ -82,8 +82,8 @@ public final class ProtocolSession implements AutoCloseable { && supported.contains("interactive-v1") && hello.capabilities().contains("interactive-v1")) usable.add(Operation.INTERACTIVE); if (hello.metadata().major() == 1 - && supported.contains("bindings-v2") - && hello.capabilities().contains("bindings-v2")) usable.add(Operation.BINDINGS); + && supported.contains("bindings-v3") + && hello.capabilities().contains("bindings-v3")) usable.add(Operation.BINDINGS); var requiredByEither = new HashSet<>(required); requiredByEither.addAll(hello.required()); 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)) || (requiredByEither.contains("interactive-v1") && !usable.contains(Operation.INTERACTIVE))) 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"); operations = Set.copyOf(usable); state = State.READY; diff --git a/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java b/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java index f1612d0..2a20422 100644 --- a/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java +++ b/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java @@ -21,7 +21,12 @@ class BindingMetadataTest { "input", "text", "java.lang.String", "COMPATIBLE", "Types match")), List.of(), 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( m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode())))); var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0); @@ -43,7 +48,7 @@ class BindingMetadataTest { @Test void negotiationRequiresMetadataVersion() throws Exception { 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( session .authenticate( @@ -55,11 +60,11 @@ class BindingMetadataTest { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("bindings-v2"), + Set.of("bindings-v3"), Set.of())) .contains(Operation.BINDINGS)); 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( IllegalArgumentException.class, () -> @@ -72,10 +77,10 @@ class BindingMetadataTest { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("bindings-v1"), + Set.of("bindings-v2"), Set.of()))); 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( IllegalArgumentException.class, () -> @@ -88,7 +93,7 @@ class BindingMetadataTest { new Version(1, 0), new Version(2, 0), new Version(1, 0), - Set.of("bindings-v2"), + Set.of("bindings-v3"), Set.of()))); } } diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java index dc010c7..77aee35 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java @@ -231,7 +231,9 @@ public final class DesignerWorkflow implements AutoCloseable { "control", control, "property", - property)), + property, + "resource", + worker.resource(document.document()))), Duration.ofSeconds(15)); try (var current = host.documents().open(document.document()).toCompletableFuture().get()) { diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DraftOverlay.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DraftOverlay.java index 6a2f58e..ec5d260 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DraftOverlay.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DraftOverlay.java @@ -12,6 +12,7 @@ public final class DraftOverlay implements AutoCloseable { private final SnapshotStore.Lease lease; private final Path directory; private final List runtime = new ArrayList<>(); + private final Map resourceNames = new HashMap<>(); private final Map revisions = new TreeMap<>(); public DraftOverlay(SnapshotStore.Lease lease, List drafts) throws Exception { @@ -36,6 +37,7 @@ public final class DraftOverlay implements AutoCloseable { if (matches.size() != 1) throw new IOException("FXML draft has no unambiguous captured source root: " + file); String name = matches.iterator().next(); + resourceNames.put(draft.document(), name); 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 (resources.putIfAbsent(name, bytes) != null) @@ -132,6 +134,10 @@ public final class DraftOverlay implements AutoCloseable { return List.copyOf(result); } + public String resource(java.net.URI document) { + return Objects.requireNonNull(resourceNames.get(document), "Document is not captured"); + } + public List runtime() { return List.copyOf(runtime); } diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java index 3675964..7905d47 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java @@ -41,6 +41,10 @@ public final class PreviewWorker implements AutoCloseable { return process.onExit(); } + public String resource(java.net.URI document) { + return overlay.resource(document); + } + public DesignerConfiguration.Context context() throws Exception { return new com.fasterxml.jackson.databind.ObjectMapper() .readValue( @@ -104,7 +108,7 @@ public final class PreviewWorker implements AutoCloseable { id, process.pid(), 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()); } drain(process.getInputStream(), log); diff --git a/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java index ef08f85..7787441 100644 --- a/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java +++ b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java @@ -54,6 +54,26 @@ public final class ConnectedWorkflowContract { && t.property().equals("text") && t.status().equals("COMPATIBLE")), "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")); check( complex.targets().stream() diff --git a/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java index 7b6178c..2d15636 100644 --- a/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java +++ b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java @@ -46,7 +46,7 @@ public final class ConnectedWorkflowTest extends BasePlatformTestCase { UIUtil.dispatchAllInvocationEvents(); Thread.sleep(10); } - assertEquals(56, (int) future.get(1, TimeUnit.SECONDS)); + assertEquals(66, (int) future.get(1, TimeUnit.SECONDS)); } } } diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingBrowser.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingBrowser.java index 5cac7be..460bb81 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingBrowser.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingBrowser.java @@ -11,6 +11,7 @@ final class BindingBrowser extends VBox { final ComboBox paths = new ComboBox<>(); final ComboBox directions = new ComboBox<>(); final TableView targets = new TableView<>(); + final ListView methods = new ListView<>(); final Label status = new Label("Build and Refresh, then discover binding targets."); private boolean updating; @@ -21,6 +22,8 @@ final class BindingBrowser extends VBox { directions.setId("binding-direction"); targets.setId("binding-targets"); status.setId("binding-status"); + methods.setId("computed-methods"); + methods.setPrefHeight(100); paths.setPrefWidth(320); directions.getItems().setAll("view", "model", "both"); directions.setValue("both"); @@ -62,7 +65,9 @@ final class BindingBrowser extends VBox { 8, refresh, new Label("Model path"), paths, new Label("Direction"), directions), status, targets, - use); + use, + new Label("Computed sources (public Controller methods)"), + methods); } private String path() { @@ -77,8 +82,16 @@ final class BindingBrowser extends VBox { } void invalidate(String message) { - targets.getItems().clear(); - status.setText(message); + updating = true; + try { + paths.getItems().clear(); + paths.setValue(null); + targets.getItems().clear(); + methods.getItems().clear(); + status.setText(message); + } finally { + updating = false; + } } void show(BindingMetadata metadata, String provenance) { @@ -92,9 +105,14 @@ final class BindingBrowser extends VBox { .orElse(null)); directions.setValue(metadata.direction()); targets.getItems().setAll(metadata.targets()); + methods.getItems().setAll(metadata.computed()); status.setText( "Model: " + metadata.model() + + " · ViewModel: " + + metadata.viewModel() + + " · Controller: " + + metadata.controller() + " · " + provenance + "\n" diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java index eca5e01..cc1bfe3 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java @@ -748,17 +748,22 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { withDraft( () -> { long ticket = ++bindingRequest; - validatingSource = null; - validationPreview = false; + bindingBrowser.invalidate("Discovering declared types in target runtime…"); var captured = draft; + validatingSource = captured.text(); bindingWorkflow .bindings(last, captured, path, direction) .whenComplete( (metadata, error) -> 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) { bindingBrowser.invalidate( "Discovery unavailable: " @@ -766,6 +771,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { + ". Build and Refresh, then retry."); return; } + validatedSource = captured.text(); + bindingProblems = metadata.problems(); bindingBrowser.show( metadata, (last @@ -773,6 +780,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { : "current successful build") + " · draft revision " + captured.revision()); + bindingEditor.metadata( + metadata, + null, + "Discovery · draft revision " + captured.revision()); + if (resumePreview) render(); })); }); } diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DirectBindingEditor.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DirectBindingEditor.java index 8431dc7..ce7c9b8 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DirectBindingEditor.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DirectBindingEditor.java @@ -190,6 +190,10 @@ final class DirectBindingEditor extends VBox { BindingMetadata metadata, DirectBindingDocument.Values requested, String provenance) { status.setText( provenance + + " · ViewModel: " + + metadata.viewModel() + + " · Controller: " + + metadata.controller() + "\n" + (metadata.problems().isEmpty() ? "No detected binding errors." diff --git a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java index a108218..e0a2da9 100644 --- a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java +++ b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java @@ -482,6 +482,84 @@ public final class ProjectionHostChecks { 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) + pane.lookup("#binding-path")) + .getItems().stream().anyMatch(a -> a.path().equals("badge")))); + check( + fx( + () -> + ((ListView) + 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) + 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 { String original = document.snapshot().toCompletableFuture().get().text(); // The same real application, now with nested, fully-qualified Component declarations. @@ -810,6 +888,7 @@ public final class ProjectionHostChecks { set(document, original); await(() -> fx(() -> source(pane).getText().equals(original) && object(pane, "card") != null)); + viewModelBindings(pane, document, original); document .save(document.snapshot().toCompletableFuture().get().revision()) .toCompletableFuture() diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java index 9647897..cd44e8d 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java @@ -16,6 +16,8 @@ public final class BindingDiscovery { private final ClassLoader loader; private final List diagnostics = new ArrayList<>(); private boolean incompleteModel; + private ControllerMetadata owner; + private final List selectionProblems = new ArrayList<>(); private final Map> paths = new TreeMap<>(); private final List attributes = new ArrayList<>(); @@ -44,7 +46,21 @@ public final class BindingDiscovery { throws Exception { if (!Set.of("view", "model", "both").contains(direction)) throw new IllegalArgumentException("Invalid direction"); - return new BindingDiscovery(loader).inspect(fxml, path, direction, control, property); + return inspect(fxml, path, direction, control, property, "", loader); + } + + public static BindingMetadata inspect( + String fxml, + String path, + String direction, + String control, + String property, + String resource, + ClassLoader loader) + throws Exception { + if (!Set.of("view", "model", "both").contains(direction)) + throw new IllegalArgumentException("Invalid direction"); + return new BindingDiscovery(loader).inspect(fxml, path, direction, control, property, resource); } private static Document parse(String xml) throws Exception { @@ -131,6 +147,7 @@ public final class BindingDiscovery { next.add(type); for (var p : beans(type)) { if (attributes.size() >= 256) throw new IOException("Too many Model paths"); + if (p.getName().equals("latestMessage")) continue; if (p.getReadMethod() == null || p.getWriteMethod() == null || p.getPropertyType() == null) continue; Class c = p.getPropertyType(); @@ -155,6 +172,8 @@ public final class BindingDiscovery { kind.equals("COLLECTION") ? element(p.getReadMethod().getGenericReturnType()) : "")); if (kind.equals("COMPLEX")) attributes(c, path + ".", next, depth + 1); } + paths.put(prefix + "latestMessage", String.class); + attributes.add(new Attribute(prefix + "latestMessage", String.class.getName(), "BUILTIN", "")); } private BindingMetadata inspect( @@ -162,34 +181,47 @@ public final class BindingDiscovery { String selected, String direction, String selectedControl, - String selectedProperty) + String selectedProperty, + String resource) throws Exception { Document doc = parse(xml); String model = model(doc); - if (model.isEmpty()) - return new BindingMetadata( - "", - "", - direction, - List.of(), - List.of(), - List.of( - "Declare this View's Model with 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(); for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling()) if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import")) imports.add(pi.getData().trim()); + Class modelType = null; + if (model.isEmpty()) { + incompleteModel = true; + diagnostics.add( + "Declare this View's Model with 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(); var ids = new HashSet(); var nodes = doc.getElementsByTagName("*"); @@ -200,11 +232,16 @@ public final class BindingDiscovery { if (id.isEmpty() || defined(e)) continue; if (!ids.add(id)) throw new IllegalArgumentException("Duplicate fx:id: " + id); try { - Class control = resolve(e, imports); + Class control = + e == doc.getDocumentElement() + && FX.equals(e.getNamespaceURI()) + && owner.controller != null + ? owner.controller + : resolve(e, imports); String componentModel = ""; - var resource = control.getResource(control.getSimpleName() + ".fxml"); - if (resource != null) - try (var in = resource.openStream()) { + var componentResource = control.getResource(control.getSimpleName() + ".fxml"); + if (componentResource != null) + try (var in = componentResource.openStream()) { byte[] b = in.readNBytes(512 * 1024 + 1); if (b.length > 512 * 1024) throw new IOException("Component FXML exceeds limit"); componentModel = model(parse(new String(b, java.nio.charset.StandardCharsets.UTF_8))); @@ -292,7 +329,73 @@ public final class BindingDiscovery { targets, diagnostics, candidates, - validate(doc, imports, ids, converters)); + validate(doc, imports, ids, converters), + owner.controller == null ? "" : owner.controller.getName(), + owner.viewModel == null ? "" : owner.viewModel.getName(), + owner.methods(chosen == null ? null : chosen.type())); + } + + private void selectOwner(Document doc, List imports, String resource, Class model) + throws Exception { + Element root = doc.getDocumentElement(); + boolean component = FX.equals(root.getNamespaceURI()) && "root".equals(root.getLocalName()); + String controllerName = root.getAttributeNS(FX, "controller"); + Class controller = null; + boolean unresolved = false; + try { + if (component) { + if (!controllerName.isEmpty()) + selectionProblems.add( + new Problem( + "", + "", + "ERROR", + "Component FXML cannot declare fx:controller; the Component is its Controller")); + if (resource.matches("[a-zA-Z0-9_$/]+[.]fxml") && !resource.contains("..")) { + controller = load(resource.substring(0, resource.length() - 5).replace('/', '.')); + if (!resolve(root, imports).isAssignableFrom(controller)) { + selectionProblems.add( + new Problem("", "", "ERROR", "Component class is incompatible with fx:root type")); + } + } else unresolved = true; + } else if (!controllerName.isEmpty()) controller = load(controllerName); + } catch (ClassNotFoundException | LinkageError ex) { + unresolved = true; + selectionProblems.add( + new Problem( + "", + "", + component ? "WARNING" : "ERROR", + "Controller type unavailable: " + ex.getMessage())); + } + if (component && controller == null) + diagnostics.add("Component Controller requires its captured FXML resource identity"); + owner = new ControllerMetadata(controller, unresolved, model); + } + + private void additionalProperties(Class vm) throws Exception { + for (Method m : vm.getMethods()) { + if (m.getParameterCount() != 0 + || !m.getName().endsWith("Property") + || !Property.class.isAssignableFrom(m.getReturnType()) + || m.isBridge() + || m.isSynthetic()) continue; + String name = m.getName().substring(0, m.getName().length() - 8); + if (name.isEmpty() || paths.containsKey(name)) continue; + Class value = propertyType(m.getGenericReturnType(), Map.of()); + Class getter = valueType(vm, name); + if (value == Number.class && getter != null && Number.class.isAssignableFrom(box(getter))) + value = getter; + else if (value != null && getter != null && box(value) != box(getter)) value = null; + paths.put(name, value == null ? Object.class : value); + attributes.add( + new Attribute( + name, + value == null ? "?" : value.getName(), + value == null || value == Object.class ? "UNRESOLVED" : "VIEWMODEL", + "")); + if (attributes.size() > 256) throw new IOException("Too many ViewModel properties"); + } } private boolean bypassesConverter(String path) { @@ -303,7 +406,8 @@ public final class BindingDiscovery { private List validate( Document doc, List imports, Set ids, ConverterDiscovery converters) throws Exception { - var problems = new ArrayList(); + var problems = new ArrayList(selectionProblems); + problems.addAll(owner.problems); var occupied = new HashSet(); var nodes = doc.getElementsByTagName("*"); for (int i = 0; i < nodes.getLength(); i++) { @@ -324,7 +428,7 @@ public final class BindingDiscovery { key = control + "." + property; if (!occupied.add(key)) problems.add(new Problem(control, property, "ERROR", "Duplicate binding target")); - if (library(e, "ComputedBinding", imports)) continue; + boolean nested = false; for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling()) if (n instanceof Element) nested = true; @@ -333,6 +437,26 @@ public final class BindingDiscovery { new Problem(control, property, "WARNING", "Nested declaration requires Source review")); continue; } + if (library(e, "ComputedBinding", imports)) { + var target = targetTypes.get(key); + if (!ids.contains(control)) + problems.add(new Problem(control, property, "ERROR", "Unknown control: " + control)); + else if (target == null) + problems.add( + new Problem(control, property, "ERROR", "No discoverable JavaFX property: " + key)); + else if (!target.writable()) + problems.add( + new Problem( + control, + property, + "ERROR", + "Computed target requires a writable JavaFX Property")); + else { + var problem = owner.validate(control, property, e.getAttribute("method"), target.type()); + if (problem != null) problems.add(problem); + } + continue; + } String path = e.getAttribute("path"), direction = e.getAttribute("direction"), converter = e.getAttribute("converter"); @@ -364,6 +488,11 @@ public final class BindingDiscovery { } else if (source == null) { severity = incompleteModel ? "WARNING" : "ERROR"; message = "Unknown or unresolved Model path: " + path; + } else if (owner.dynamicProperties || owner.unknownController) { + severity = "WARNING"; + message = + "Unresolved ViewModel selection or dynamic property resolver requires runtime" + + " validation"; } else if (!converter.isEmpty()) { var c = converters.check(converter, source, target.type(), direction); if (!c.status().equals("COMPATIBLE")) { diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ControllerMetadata.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ControllerMetadata.java new file mode 100644 index 0000000..d660d87 --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ControllerMetadata.java @@ -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 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 methods(Class target) { + if (controller == null) return List.of(); + var result = new TreeMap(); + 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); + } +} diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java index c2e92d8..d8d0df6 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java @@ -92,7 +92,7 @@ public final class WorkerMain { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v2"), + Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v3"), Set.of())); var welcome = FrameCodec.read(socket.getInputStream()); if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1) @@ -115,7 +115,9 @@ public final class WorkerMain { var loader = WorkerMain.class.getClassLoader(); if (p.operation() == Operation.BINDINGS) { var input = PreviewMessages.decode(p.payload()); - if (!input.keySet().equals(Set.of("fxml", "path", "direction", "control", "property"))) + if (!input + .keySet() + .equals(Set.of("fxml", "path", "direction", "control", "property", "resource"))) throw new IOException("Invalid binding discovery input"); var metadata = BindingDiscovery.inspect( @@ -124,6 +126,7 @@ public final class WorkerMain { input.get("direction"), input.get("control"), input.get("property"), + input.get("resource"), loader); send( socket, diff --git a/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/BindingDiscoveryTest.java b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/BindingDiscoveryTest.java index f406ab8..5bd2ba7 100644 --- a/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/BindingDiscoveryTest.java +++ b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/BindingDiscoveryTest.java @@ -66,7 +66,8 @@ class BindingDiscoveryTest { throws Exception { var metadata = BindingDiscovery.inspect(fxml(), "name", "both", getClass().getClassLoader()); assertEquals( - Set.of("home", "home.street", "name", "rank", "visits"), + Set.of( + "home", "home.street", "name", "rank", "visits", "latestMessage", "home.latestMessage"), metadata.attributes().stream() .map(a -> a.path()) .collect(java.util.stream.Collectors.toSet())); diff --git a/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ViewModelComputedTest.java b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ViewModelComputedTest.java new file mode 100644 index 0000000..723dd84 --- /dev/null +++ b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ViewModelComputedTest.java @@ -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 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 "" + + binding + + ""; + } + + private String computed(String method) { + return ""; + } + + private String direct(String path) { + return ""; + } + + 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(); + 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 = + ""; + 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"); + } +} diff --git a/docs/binding-discovery.md b/docs/binding-discovery.md index 94b2dab..8eac5b8 100644 --- a/docs/binding-discovery.md +++ b/docs/binding-discovery.md @@ -1,6 +1,6 @@ # 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 @@ -12,9 +12,9 @@ For Person the browser exposes `name`, `rank`, `home`, `home.street`, and `visit ## 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. @@ -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. -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 diff --git a/docs/converter-validation.md b/docs/converter-validation.md index de0c4a6..ad2ba9e 100644 --- a/docs/converter-validation.md +++ b/docs/converter-validation.md @@ -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. -`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 diff --git a/docs/direct-binding-inspector.md b/docs/direct-binding-inspector.md index 7c2562b..e5a0c3c 100644 --- a/docs/direct-binding-inspector.md +++ b/docs/direct-binding-inspector.md @@ -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. -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 diff --git a/docs/repository-bootstrap-plan.md b/docs/repository-bootstrap-plan.md index 87a5e24..87b885f 100644 --- a/docs/repository-bootstrap-plan.md +++ b/docs/repository-bootstrap-plan.md @@ -109,3 +109,7 @@ Implemented the [read-only binding browser](binding-discovery.md) in the shared ## 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. + +## 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. diff --git a/docs/viewmodel-computed-validation.md b/docs/viewmodel-computed-validation.md new file mode 100644 index 0000000..e9d31f4 --- /dev/null +++ b/docs/viewmodel-computed-validation.md @@ -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. diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BadgeCard.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BadgeCard.java new file mode 100644 index 0000000..c566e92 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BadgeCard.java @@ -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 model = + new javafx.beans.property.SimpleObjectProperty<>(); + + public javafx.beans.property.ObjectProperty modelProperty() { + return model; + } + + public String caption() { + throw new AssertionError("Metadata must not invoke Component method"); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BadgeViewModel.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BadgeViewModel.java new file mode 100644 index 0000000..c0e86b8 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BadgeViewModel.java @@ -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"); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java index bfaf0ce..bcf77e4 100644 --- a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java @@ -3,6 +3,18 @@ package fixture.components; @jakarta.inject.Singleton @de.neitzel.fx.view.ViewModelClass(PersonViewModel.class) 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 de.neitzel.fx.view.ViewModel received; diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonViewModel.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonViewModel.java index 7ecd6a3..214c976 100644 --- a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonViewModel.java +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonViewModel.java @@ -1,6 +1,21 @@ package fixture.components; 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) { super(model); } diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ReplacementController.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ReplacementController.java new file mode 100644 index 0000000..ae2eb98 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ReplacementController.java @@ -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"; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ReplacementViewModel.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ReplacementViewModel.java new file mode 100644 index 0000000..765f239 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ReplacementViewModel.java @@ -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; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/BadgeCard.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/BadgeCard.fxml new file mode 100644 index 0000000..c440952 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/BadgeCard.fxml @@ -0,0 +1,3 @@ + + + diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Custom.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Custom.fxml new file mode 100644 index 0000000..5ce12b8 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Custom.fxml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Replacement.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Replacement.fxml new file mode 100644 index 0000000..2ac3a57 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Replacement.fxml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java index 4bee914..22c2edd 100644 --- a/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java +++ b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java @@ -41,6 +41,10 @@ public final class PreviewChecks { public static void main(String[] args) throws Exception { String mode = args[0]; + if (mode.equals("viewmodels")) { + ViewModelRuntimeChecks.run(); + return; + } if (mode.equals("unstable") || mode.equals("full") || mode.equals("full-empty") @@ -129,6 +133,51 @@ public final class PreviewChecks { System.getProperty("fixture.converter.initialized") == null, "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); return; } diff --git a/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/ViewModelRuntimeChecks.java b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/ViewModelRuntimeChecks.java new file mode 100644 index 0000000..1078421 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/ViewModelRuntimeChecks.java @@ -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(); + } + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BadgeCard.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BadgeCard.java new file mode 100644 index 0000000..c566e92 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BadgeCard.java @@ -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 model = + new javafx.beans.property.SimpleObjectProperty<>(); + + public javafx.beans.property.ObjectProperty modelProperty() { + return model; + } + + public String caption() { + throw new AssertionError("Metadata must not invoke Component method"); + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BadgeViewModel.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BadgeViewModel.java new file mode 100644 index 0000000..c0e86b8 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BadgeViewModel.java @@ -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"); + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonController.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonController.java index bfaf0ce..bcf77e4 100644 --- a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonController.java +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonController.java @@ -3,6 +3,18 @@ package fixture.components; @jakarta.inject.Singleton @de.neitzel.fx.view.ViewModelClass(PersonViewModel.class) 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 de.neitzel.fx.view.ViewModel received; diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonViewModel.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonViewModel.java index 7ecd6a3..214c976 100644 --- a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonViewModel.java +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonViewModel.java @@ -1,6 +1,21 @@ package fixture.components; 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) { super(model); } diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ReplacementController.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ReplacementController.java new file mode 100644 index 0000000..ae2eb98 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ReplacementController.java @@ -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"; + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ReplacementViewModel.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ReplacementViewModel.java new file mode 100644 index 0000000..765f239 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ReplacementViewModel.java @@ -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; + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/BadgeCard.fxml b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/BadgeCard.fxml new file mode 100644 index 0000000..c440952 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/BadgeCard.fxml @@ -0,0 +1,3 @@ + + + diff --git a/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Custom.fxml b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Custom.fxml new file mode 100644 index 0000000..5ce12b8 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Custom.fxml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Replacement.fxml b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Replacement.fxml new file mode 100644 index 0000000..2ac3a57 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Replacement.fxml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java index 4bee914..22c2edd 100644 --- a/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java +++ b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java @@ -41,6 +41,10 @@ public final class PreviewChecks { public static void main(String[] args) throws Exception { String mode = args[0]; + if (mode.equals("viewmodels")) { + ViewModelRuntimeChecks.run(); + return; + } if (mode.equals("unstable") || mode.equals("full") || mode.equals("full-empty") @@ -129,6 +133,51 @@ public final class PreviewChecks { System.getProperty("fixture.converter.initialized") == null, "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); return; } diff --git a/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/ViewModelRuntimeChecks.java b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/ViewModelRuntimeChecks.java new file mode 100644 index 0000000..1078421 --- /dev/null +++ b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/ViewModelRuntimeChecks.java @@ -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(); + } + } +} diff --git a/scripts/check-preview-fixtures.py b/scripts/check-preview-fixtures.py index cbf960d..b6ef821 100644 --- a/scripts/check-preview-fixtures.py +++ b/scripts/check-preview-fixtures.py @@ -64,7 +64,7 @@ with tempfile.TemporaryDirectory(prefix="designer-preview-") as temp: first = None for mode in ("discovery", "bindings", "standard", "async", "timeout", "cancel", "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) if mode == "standard": first = re.search(r"SESSION (\S+) models=1", log)[1]