From 5462f480a45f68014d44fa85a570509ee98b0ac9 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 13 Nov 2026 15:33:40 +0100 Subject: [PATCH] Discover Table compatibility and declared Row alignment --- README.md | 2 +- .../fxdesigner/protocol/BindingMetadata.java | 55 ++- .../fxdesigner/protocol/ProtocolSession.java | 6 +- .../protocol/BindingMetadataTest.java | 19 +- .../fxdesigner/workflow/DesignerWorkflow.java | 83 +++-- .../fxdesigner/workflow/PreviewWorker.java | 2 +- .../neitzel/fxdesigner/kit/DesignerPane.java | 13 +- .../neitzel/fxdesigner/kit/TableEditor.java | 90 ++++- .../fxdesigner/kit/ProjectionHostChecks.java | 113 ++++++ .../fxdesigner/target/BindingDiscovery.java | 10 +- .../fxdesigner/target/RepeatDiscovery.java | 26 +- .../fxdesigner/target/TableDiscovery.java | 351 ++++++++++++++++++ .../neitzel/fxdesigner/target/WorkerMain.java | 2 +- .../fxdesigner/target/TableDiscoveryTest.java | 222 +++++++++++ .../fxdesigner/target/DuplicateRow.fxml | 3 + .../neitzel/fxdesigner/target/TableRow.fxml | 3 + .../neitzel/fxdesigner/target/UnknownRow.fxml | 3 + docs/binding-discovery.md | 2 +- docs/converter-validation.md | 2 +- docs/repeat-compatibility.md | 4 +- docs/repository-bootstrap-plan.md | 4 + docs/table-column-inspector.md | 4 +- docs/table-compatibility.md | 37 ++ docs/viewmodel-computed-validation.md | 2 +- .../java/fixture/checks/PreviewChecks.java | 52 ++- .../java/fixture/checks/PreviewChecks.java | 52 ++- 26 files changed, 1086 insertions(+), 76 deletions(-) create mode 100644 designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TableDiscovery.java create mode 100644 designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/TableDiscoveryTest.java create mode 100644 designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/DuplicateRow.fxml create mode 100644 designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/TableRow.fxml create mode 100644 designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/UnknownRow.fxml create mode 100644 docs/table-compatibility.md diff --git a/README.md b/README.md index 4a4fd3b..27766d5 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. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation. Broader discovery, 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. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation, now with [Table-specific compatibility and declared Row alignment discovery](docs/table-compatibility.md). Broader discovery, 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 173f9c7..8b8513a 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 @@ -17,7 +17,45 @@ public record BindingMetadata( List computed, List collections, List components, - List panes) { + List panes, + List tableCollections, + List tableRows, + List tableColumns) { + public BindingMetadata( + String model, + String path, + String direction, + List attributes, + List targets, + List diagnostics, + List converters, + List problems, + String controller, + String viewModel, + List computed, + List collections, + List components, + List panes) { + this( + model, + path, + direction, + attributes, + targets, + diagnostics, + converters, + problems, + controller, + viewModel, + computed, + collections, + components, + panes, + List.of(), + List.of(), + List.of()); + } + public record RepeatOption(String name, String type, String status, String reason) { public String toString() { return name + " : " + type + " — " + status + " (" + reason + ")"; @@ -119,16 +157,22 @@ public record BindingMetadata( collections = List.copyOf(collections); components = List.copyOf(components); panes = List.copyOf(panes); + tableCollections = List.copyOf(tableCollections); + tableRows = List.copyOf(tableRows); + tableColumns = List.copyOf(tableColumns); if (!Set.of("view", "model", "both").contains(direction)) throw new IllegalArgumentException("Invalid direction"); } public Map encode() { var m = new TreeMap(); - m.put("format", "4"); + m.put("format", "5"); encodeOptions(m, "rc", collections); encodeOptions(m, "rt", components); encodeOptions(m, "rp", panes); + encodeOptions(m, "tc", tableCollections); + encodeOptions(m, "tr", tableRows); + encodeOptions(m, "ta", tableColumns); m.put("controller", controller); m.put("viewModel", viewModel); m.put("computed", "" + computed.size()); @@ -222,7 +266,7 @@ public record BindingMetadata( } public static BindingMetadata decode(Map m) { - if (!"4".equals(field(m, "format"))) + if (!"5".equals(field(m, "format"))) throw new IllegalArgumentException("Unsupported binding metadata"); var a = new ArrayList(); var t = new ArrayList(); @@ -286,6 +330,9 @@ public record BindingMetadata( methods, decodeOptions(m, "rc"), decodeOptions(m, "rt"), - decodeOptions(m, "rp")); + decodeOptions(m, "rp"), + decodeOptions(m, "tc"), + decodeOptions(m, "tr"), + decodeOptions(m, "ta")); } } 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 ac26426..91e9d4e 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-v4") - && hello.capabilities().contains("bindings-v4")) usable.add(Operation.BINDINGS); + && supported.contains("bindings-v5") + && hello.capabilities().contains("bindings-v5")) 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-v4") && !usable.contains(Operation.BINDINGS)) + if (requiredByEither.contains("bindings-v5") && !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 24ff5c1..7731856 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 @@ -29,7 +29,10 @@ class BindingMetadataTest { "#caption", "java.lang.String", "COMPATIBLE", "Declared type")), List.of(new BindingMetadata.RepeatOption("visits", "Address", "COMPATIBLE", "Element")), List.of(new BindingMetadata.RepeatOption("app.Card", "Address", "COMPATIBLE", "Model")), - List.of(new BindingMetadata.RepeatOption("rows", "VBox", "COMPATIBLE", "Pane"))); + List.of(new BindingMetadata.RepeatOption("rows", "VBox", "COMPATIBLE", "Pane")), + List.of(new BindingMetadata.RepeatOption("visits", "Address", "COMPATIBLE", "Bean")), + List.of(new BindingMetadata.RepeatOption("Row", "Address", "COMPATIBLE", "Row")), + List.of(new BindingMetadata.RepeatOption("street", "String", "COMPATIBLE", "Column"))); assertEquals( m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode())))); var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0); @@ -44,14 +47,14 @@ class BindingMetadataTest { new Identity("s", "r", "c", "b", Map.of(), 0, 0), new byte[0])); var invalid = new HashMap<>(m.encode()); - invalid.put("rc", "99999"); + invalid.put("ta", "99999"); assertThrows(IllegalArgumentException.class, () -> BindingMetadata.decode(invalid)); } @Test void negotiationRequiresMetadataVersion() throws Exception { String token = ProtocolSession.newToken(); - var session = new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4")); + var session = new ProtocolSession("s", 1, token, Set.of("bindings-v5"), Set.of("bindings-v5")); assertTrue( session .authenticate( @@ -63,11 +66,11 @@ class BindingMetadataTest { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("bindings-v4"), + Set.of("bindings-v5"), Set.of())) .contains(Operation.BINDINGS)); var oldWorker = - new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4")); + new ProtocolSession("s", 1, token, Set.of("bindings-v5"), Set.of("bindings-v5")); assertThrows( IllegalArgumentException.class, () -> @@ -80,10 +83,10 @@ class BindingMetadataTest { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("bindings-v3"), + Set.of("bindings-v4"), Set.of()))); var incompatible = - new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4")); + new ProtocolSession("s", 1, token, Set.of("bindings-v5"), Set.of("bindings-v5")); assertThrows( IllegalArgumentException.class, () -> @@ -96,7 +99,7 @@ class BindingMetadataTest { new Version(1, 0), new Version(2, 0), new Version(1, 0), - Set.of("bindings-v4"), + Set.of("bindings-v5"), 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 77aee35..0c26acc 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 @@ -210,38 +210,57 @@ public final class DesignerWorkflow implements AutoCloseable { String direction, String control, String property) { - return execute( - last, - List.of(document), - worker -> { - var result = - worker.request( - Operation.BINDINGS, - Map.of(document.document().toString(), document.revision()), - 0, - 0, - PreviewMessages.encode( - Map.of( - "fxml", - document.text(), - "path", - path, - "direction", - direction, - "control", - control, - "property", - property, - "resource", - worker.resource(document.document()))), - Duration.ofSeconds(15)); - try (var current = - host.documents().open(document.document()).toCompletableFuture().get()) { - if (current.snapshot().toCompletableFuture().get().revision() != document.revision()) - throw new CancellationException("Draft changed during discovery"); - } - return BindingMetadata.decode(PreviewMessages.decode(result.payload())); - }); + return host.documents() + .snapshots() + .thenCompose( + known -> { + var captured = new LinkedHashMap(); + known.stream() + .filter( + d -> + d.document().toString().endsWith(".fxml") + && Path.of(d.document()).startsWith(reactor) + && !Path.of(d.document()) + .startsWith(reactor.resolve(".neitzel-fx-designer"))) + .forEach(d -> captured.put(d.document(), d)); + captured.put(document.document(), document); + var revisions = new LinkedHashMap(); + captured.values().forEach(d -> revisions.put(d.document().toString(), d.revision())); + return execute( + last, + List.copyOf(captured.values()), + worker -> { + var result = + worker.request( + Operation.BINDINGS, + revisions, + 0, + 0, + PreviewMessages.encode( + Map.of( + "fxml", + document.text(), + "path", + path, + "direction", + direction, + "control", + control, + "property", + property, + "resource", + worker.resource(document.document()))), + Duration.ofSeconds(15)); + for (var draft : captured.values()) + try (var current = + host.documents().open(draft.document()).toCompletableFuture().get()) { + if (current.snapshot().toCompletableFuture().get().revision() + != draft.revision()) + throw new CancellationException("Draft changed during discovery"); + } + return BindingMetadata.decode(PreviewMessages.decode(result.payload())); + }); + }); } public CompletionStage> discover(boolean lastSuccessful) { 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 988c097..b46f795 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 @@ -108,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-v4"), + Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v5"), config.requiredCapabilities()); } drain(process.getInputStream(), log); 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 39694bf..e0f5c0a 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 @@ -213,7 +213,9 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { if (target != null) kit.editor().getSelection().select(target); }, values -> validateBindings(values, false)); - tableEditor = new TableEditor(this::editTable, this::navigateComponent); + tableEditor = + new TableEditor( + this::editTable, this::navigateComponent, values -> validateBindings(values, false)); bindingBrowser = new BindingBrowser( this::discoverBindings, @@ -691,6 +693,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { bindingWorkflow.cancel(); bindingBrowser.invalidate(message); repeatEditor.invalidateDiscovery(); + tableEditor.invalidateDiscovery(); } private void editTable(TableEditor.Intent intent) { @@ -817,6 +820,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { : "Current successful build") + " · draft revision " + captured.revision()); + tableEditor.metadata( + metadata, + values, + (last + ? "Explicit retained build (outdated)" + : "Current successful build") + + " · draft revision " + + captured.revision()); status.setText( "Binding validation complete: " + metadata.problems().size() diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableEditor.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableEditor.java index d209146..1fe5e42 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableEditor.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableEditor.java @@ -1,6 +1,7 @@ package de.neitzel.fxdesigner.kit; import de.neitzel.fxdesigner.contracts.EditBatch.Replacement; +import de.neitzel.fxdesigner.protocol.BindingMetadata; import java.util.List; import java.util.function.Consumer; import javafx.scene.control.*; @@ -21,12 +22,47 @@ final class TableEditor extends VBox { private final Consumer edit; private String source = ""; private TableDocument document; - private boolean loading; + private boolean loading, updatingChoices; + private final ComboBox collectionChoices = new ComboBox<>(), + rowChoices = new ComboBox<>(), + columnChoices = new ComboBox<>(); + private final Consumer discover; + + private BindingDocument.Values values() { + return BindingDocument.Values.repeat("@table:" + table, row.getText(), collection.getText()); + } + private Integer table, column; - TableEditor(Consumer edit, Consumer navigate) { + TableEditor( + Consumer edit, Consumer navigate, Consumer discover) { super(5); this.edit = edit; + this.discover = discover; + collectionChoices.setId("table-collections"); + rowChoices.setId("table-rows"); + columnChoices.setId("table-attributes"); + for (var c : List.of(collectionChoices, rowChoices, columnChoices)) c.setPrefWidth(360); + collectionChoices.setOnAction( + e -> { + var v = collectionChoices.getValue(); + if (v != null && !updatingChoices) { + collection.setText(v.name()); + discover.accept(values()); + } + }); + rowChoices.setOnAction( + e -> { + var v = rowChoices.getValue(); + if (v != null && !updatingChoices) row.setText(v.name()); + }); + columnChoices.setOnAction( + e -> { + var v = columnChoices.getValue(); + if (v != null && !updatingChoices) attribute.setText(v.name()); + }); + collection.textProperty().addListener((o, a, b) -> invalidateDiscovery()); + setId("table-editor"); tables.setId("table-bindings"); columns.setId("table-columns"); @@ -68,6 +104,11 @@ final class TableEditor extends VBox { .addAll( tables, tableFields, + new HBox( + 6, + button("Discover / Validate Table", () -> discover.accept(values())), + collectionChoices, + rowChoices), new HBox( 6, button( @@ -100,6 +141,7 @@ final class TableEditor extends VBox { })), columns, columnFields, + columnChoices, new HBox( 6, button( @@ -203,6 +245,7 @@ final class TableEditor extends VBox { setDisable(!writable); if (source.equals(text)) return; source = text; + invalidateDiscovery(); loading = true; try { document = new TableDocument(text); @@ -243,6 +286,49 @@ final class TableEditor extends VBox { } } + void invalidateDiscovery() { + updatingChoices = true; + try { + collectionChoices.getItems().clear(); + rowChoices.getItems().clear(); + columnChoices.getItems().clear(); + } finally { + updatingChoices = false; + } + status.setText( + "Document/build or collection changed; Discover / Validate Table again. Row alignment" + + " unverified."); + } + + void metadata(BindingMetadata metadata, BindingDocument.Values requested, String provenance) { + var messages = new java.util.ArrayList(); + if (document != null) messages.addAll(document.errors()); + for (var p : metadata.problems()) + if (java.util.Set.of("collection", "row", "columns", "alignment", "Table") + .contains(p.property())) + messages.add(p.severity() + " " + p.control() + ": " + p.message()); + status.setText( + provenance + + "\n" + + (messages.isEmpty() + ? "No Table compatibility errors found." + : String.join("\n", messages)) + + "\n" + + "Diagnostics describe the current document. Choices compare the requested collection." + + " Open Row Component to resolve alignment warnings; its layout is never changed" + + " automatically."); + if (requested != null && requested.equals(values())) { + updatingChoices = true; + try { + collectionChoices.getItems().setAll(metadata.tableCollections()); + rowChoices.getItems().setAll(metadata.tableRows()); + columnChoices.getItems().setAll(metadata.tableColumns()); + } finally { + updatingChoices = false; + } + } + } + void error(String message) { status.setText(message); } 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 1577376..d1a5609 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 @@ -691,6 +691,118 @@ public final class ProjectionHostChecks { }); } + private static void tableDiscovery(DesignerPane pane, DocumentSession document, String full) + throws Exception { + bindingTab(pane, "Table / Columns"); + tableSelect(pane, "bindings", 0); + click(pane, "Discover / Validate Table"); + await(() -> fx(() -> !((ComboBox) pane.lookup("#table-attributes")).getItems().isEmpty())); + check( + fx( + () -> + ((Label) pane.lookup("#table-status")) + .getText() + .contains("matches direct bindings")), + "Declared Row alignment missing"); + fx( + () -> { + var rows = + (ComboBox) + pane.lookup("#table-rows"); + check( + rows.getItems().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.VisitRow") + && c.status().equals("COMPATIBLE")), + "Compatible Row absent"); + check( + rows.getItems().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.WrongRepeatCard") + && c.status().equals("INCOMPATIBLE")), + "Wrong Model Row accepted"); + check( + ((TextField) pane.lookup("#table-row")) + .getText() + .equals("fixture.components.VisitRow"), + "Discovery replaced explicit Row"); + var attrs = + (ComboBox) + pane.lookup("#table-attributes"); + attrs + .getSelectionModel() + .select( + attrs.getItems().stream() + .filter(c -> c.name().equals("street")) + .findFirst() + .orElseThrow()); + return null; + }); + check(documentText(document).equals(full), "Table discovery changed source"); + // The Row draft must participate in discovery even though the parent source is unchanged. + var rowFile = + java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()) + .resolveSibling("VisitRow.fxml"); + String disk = java.nio.file.Files.readString(rowFile); + try (var row = + pane.kit().host().documents().open(rowFile.toUri()).toCompletableFuture().get()) { + String original = documentText(row); + set(row, original.replace("path=\"street\"", "path=\"different\"")); + await(() -> fx(() -> ((ComboBox) pane.lookup("#table-rows")).getItems().isEmpty())); + click(pane, "Discover / Validate Table"); + await( + () -> + fx( + () -> + ((Label) pane.lookup("#table-status")) + .getText() + .contains("mismatch at Column"))); + check( + java.nio.file.Files.readString(rowFile).equals(disk), + "Row discovery saved or rewrote Row source"); + set(row, original); + click(pane, "Discover / Validate Table"); + await( + () -> + fx( + () -> + ((Label) pane.lookup("#table-status")) + .getText() + .contains("matches direct bindings"))); + } + tableSelect(pane, "columns", 0); + tableField(pane, "attribute", "missing"); + click(pane, "Apply Column"); + await(() -> !documentText(document).equals(full)); + String invalid = documentText(document); + await(() -> fx(() -> source(pane).getText().equals(invalid))); + document + .save(document.snapshot().toCompletableFuture().get().revision()) + .toCompletableFuture() + .get(); + click(pane, "Render drafts"); + await( + () -> + fx( + () -> + pane.lookupAll(".label").stream() + .anyMatch( + n -> + n instanceof Label l + && l.getText() != null + && l.getText().startsWith("Preview blocked:") + && l.getText().contains("simple read/write Attribute")))); + repeatHistory(pane, document, full, invalid); + click(pane, "Undo"); + await(() -> documentText(document).equals(full)); + await(() -> fx(() -> source(pane).getText().equals(full))); + System.out.println( + "PASS Table discovery: choices, explicit selection, Row draft overlays/alignment, type" + + " errors, preview gating and undo/redo"); + } + private static void tableEdits(DesignerPane pane, DocumentSession document, String full) throws Exception { set(document, full); @@ -1413,6 +1525,7 @@ public final class ProjectionHostChecks { directBindingEdits(pane, document, full); computedBindingEdits(pane, document, full); repeatBindingEdits(pane, document, full); + tableDiscovery(pane, document, full); tableEdits(pane, document, full); document .save(document.snapshot().toCompletableFuture().get().revision()) 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 b1bb523..3141b2d 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 @@ -325,6 +325,9 @@ public final class BindingDiscovery { ? List.of() : converters.candidates(source, chosen == null ? null : chosen.type(), direction); repeats = new RepeatDiscovery(loader, attributes, owner, incompleteModel, controlTypes, ids); + var tables = new TableDiscovery(loader, repeats, modelType, owner); + var problems = new ArrayList<>(validate(doc, imports, ids, converters)); + problems.addAll(tables.validate(doc)); return new BindingMetadata( model, selected, @@ -333,13 +336,16 @@ public final class BindingDiscovery { targets, diagnostics, candidates, - validate(doc, imports, ids, converters), + problems, owner.controller == null ? "" : owner.controller.getName(), owner.viewModel == null ? "" : owner.viewModel.getName(), owner.methods(chosen == null ? null : chosen.type()), repeats.collections(), repeats.candidates(selected), - repeats.panes()); + repeats.panes(), + tables.collections(), + tables.rows(selected), + tables.columns(selected)); } private void selectOwner(Document doc, List imports, String resource, Class model) diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/RepeatDiscovery.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/RepeatDiscovery.java index 0c3f605..9b2493a 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/RepeatDiscovery.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/RepeatDiscovery.java @@ -133,10 +133,13 @@ final class RepeatDiscovery { } RepeatOption component(String name, String path) { + return component(name, collection(path)); + } + + RepeatOption component(String name, RepeatOption source) { ComponentInfo info = components.computeIfAbsent(name, this::readComponent); if (info.error() != null) return new RepeatOption(name, info.declared(), "INCOMPATIBLE", info.error()); - var source = collection(path); if (source.status().equals("INCOMPATIBLE")) return new RepeatOption( name, info.declared(), "UNVERIFIED", "Choose a collection to compare element types"); @@ -215,15 +218,7 @@ final class RepeatDiscovery { || !ControllerMetadata.accessible(accessor.getDeclaringClass(), true)) throw new IllegalArgumentException("Component needs an accessible writable modelProperty"); Class property = BindingDiscovery.propertyType(accessor.getGenericReturnType(), Map.of()); - var resource = type.getResource(type.getSimpleName() + ".fxml"); - if (resource == null) - throw new IllegalArgumentException("Component FXML unavailable: " + name); - Document doc; - try (var in = resource.openStream()) { - byte[] bytes = in.readNBytes(512 * 1024 + 1); - if (bytes.length > 512 * 1024) throw new IOException("Component FXML exceeds limit"); - doc = BindingDiscovery.parse(new String(bytes, StandardCharsets.UTF_8)); - } + Document doc = resource(type); Element root = doc.getDocumentElement(); if (!"http://javafx.com/fxml/1".equals(root.getNamespaceURI()) || !"root".equals(root.getLocalName())) @@ -255,6 +250,17 @@ final class RepeatDiscovery { } } + static Document resource(Class type) throws Exception { + var resource = type.getResource(type.getSimpleName() + ".fxml"); + if (resource == null) + throw new IllegalArgumentException("Component FXML unavailable: " + type.getName()); + try (var in = resource.openStream()) { + byte[] bytes = in.readNBytes(512 * 1024 + 1); + if (bytes.length > 512 * 1024) throw new IOException("Component FXML exceeds limit"); + return BindingDiscovery.parse(new String(bytes, StandardCharsets.UTF_8)); + } + } + private void indexResources() throws IOException { var entries = new LinkedHashSet(); for (String key : List.of("java.class.path", "jdk.module.path")) diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TableDiscovery.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TableDiscovery.java new file mode 100644 index 0000000..98a81aa --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TableDiscovery.java @@ -0,0 +1,351 @@ +package de.neitzel.fxdesigner.target; + +import de.neitzel.fx.view.ViewModel; +import de.neitzel.fxdesigner.protocol.BindingMetadata.*; +import java.beans.*; +import java.lang.reflect.*; +import java.time.temporal.*; +import java.util.*; +import org.w3c.dom.*; + +/** Table's bean-Attribute rules and declared Row positions; never executes application code. */ +final class TableDiscovery { + private static final String FX = "http://javafx.com/fxml/1", P = "de.neitzel.fx.view."; + private final ClassLoader loader; + private final RepeatDiscovery repeats; + private final Class model; + private final boolean unknownOwner; + + TableDiscovery( + ClassLoader loader, RepeatDiscovery repeats, Class model, ControllerMetadata owner) { + this.loader = loader; + this.repeats = repeats; + this.model = + owner.viewModel != null && !ViewModel.class.isAssignableFrom(owner.viewModel) + ? owner.viewModel + : model; + unknownOwner = owner.unknownController || owner.viewModel == null; + } + + private Class load(String name) throws ClassNotFoundException { + return Class.forName(name, false, loader); + } + + // Custom BeanInfo is executable. Absence of safe evidence must not be reported as + // incompatibility. + private List beans(Class type) throws Exception { + if (type == null || type == Object.class || type.isInterface()) + throw new IllegalArgumentException("Bean Attributes unavailable"); + for (Class c = type; c != null && c != Object.class; c = c.getSuperclass()) + try { + load(c.getName() + "BeanInfo"); + throw new IllegalArgumentException("Custom BeanInfo requires generated metadata"); + } catch (ClassNotFoundException ignored) { + } + return Arrays.stream( + Introspector.getBeanInfo(type, Object.class, Introspector.IGNORE_ALL_BEANINFO) + .getPropertyDescriptors()) + .sorted(Comparator.comparing(PropertyDescriptor::getName)) + .toList(); + } + + private static boolean writable(PropertyDescriptor p) { + return p.getReadMethod() != null && p.getWriteMethod() != null; + } + + private static Class argument(Type t) { + if (!(t instanceof ParameterizedType p) || p.getActualTypeArguments().length != 1) return null; + Type a = p.getActualTypeArguments()[0]; + if (a instanceof Class c) return c; + if (a instanceof WildcardType w + && w.getUpperBounds().length == 1 + && w.getUpperBounds()[0] instanceof Class c) return c; + return null; + } + + RepeatOption collection(String path) { + if (unknownOwner) + return option(path, "?", "UNVERIFIED", "Table Model behind ViewModel is unresolved"); + try { + var p = beans(model).stream().filter(b -> b.getName().equals(path)).findFirst().orElse(null); + if (p == null || !writable(p)) + return option( + path, + "?", + "INCOMPATIBLE", + "Table requires a direct read/write bean collection Attribute: " + path); + if (!Collection.class.isAssignableFrom(p.getReadMethod().getReturnType())) + return option(path, "?", "INCOMPATIBLE", "Table Attribute is not a collection: " + path); + Class element = argument(p.getReadMethod().getGenericReturnType()); + if (element == null) + return option( + path, "?", "INCOMPATIBLE", "Table cannot read the collection's declared element class"); + var backing = repeats.collection(path); + return option( + path, + element.getName(), + backing.status(), + "Table bean element type; " + backing.reason()); + } catch (Exception | LinkageError e) { + return option(path, "?", "UNVERIFIED", e.getMessage()); + } + } + + List collections() { + var names = new TreeSet(); + repeats.collections().forEach(c -> names.add(c.name())); + try { + for (var b : beans(model)) + if (b.getReadMethod() != null + && Collection.class.isAssignableFrom(b.getReadMethod().getReturnType())) + names.add(b.getName()); + } catch (Exception | LinkageError ignored) { + } + return names.stream().map(this::collection).toList(); + } + + List rows(String path) { + var source = collection(path); + return repeats.candidates(path).stream().map(c -> repeats.component(c.name(), source)).toList(); + } + + private static boolean simple(Class type) { + return type == String.class + || type == Boolean.class + || type.isEnum() + || type.isPrimitive() && type != char.class && type != void.class + || Number.class.isAssignableFrom(type) + || Date.class.isAssignableFrom(type) + || Calendar.class.isAssignableFrom(type) + || TemporalAccessor.class.isAssignableFrom(type) + || TemporalAmount.class.isAssignableFrom(type); + } + + List columns(String path) { + var c = collection(path); + if (c.type().equals("?")) return List.of(); + try { + var result = new ArrayList(); + for (var b : beans(load(c.type()))) { + Class type = b.getPropertyType(); + boolean ok = writable(b) && type != null && simple(type); + result.add( + option( + b.getName(), + type == null ? "?" : type.getName(), + ok ? "COMPATIBLE" : "INCOMPATIBLE", + ok + ? "Simple read/write Table Attribute" + : "Table Columns require simple read/write bean Attributes")); + if (result.size() > 256) throw new IllegalArgumentException("Too many Table Attributes"); + } + return List.copyOf(result); + } catch (Exception | LinkageError e) { + return List.of(); + } + } + + private RepeatOption column(String path, String attribute) { + var c = collection(path); + if (c.type().equals("?")) + return option( + attribute, "?", "UNVERIFIED", "Column type unavailable without collection element type"); + try { + var b = + beans(load(c.type())).stream() + .filter(p -> p.getName().equals(attribute)) + .findFirst() + .orElse(null); + if (b == null || !writable(b) || b.getPropertyType() == null || !simple(b.getPropertyType())) + return option( + attribute, + "?", + "INCOMPATIBLE", + "Column requires a simple read/write Attribute: " + attribute); + return option( + attribute, b.getPropertyType().getName(), "COMPATIBLE", "Simple Table Attribute"); + } catch (Exception | LinkageError e) { + return option(attribute, "?", "UNVERIFIED", "Column type unverified: " + e.getMessage()); + } + } + + List validate(Document document) { + var result = new ArrayList(); + var all = document.getElementsByTagName("*"); + for (int i = 0; i < all.getLength(); i++) { + var table = (Element) all.item(i); + if (!named(table, "Table")) continue; + String id = table.getAttributeNS(FX, "id"); + if (id.isEmpty()) id = "Table #" + i; + String path = table.getAttribute("collection"), row = table.getAttribute("row"); + if (children(table).stream() + .anyMatch(e -> Set.of("collection", "row").contains(e.getTagName()))) { + result.add( + new Problem(id, "Table", "WARNING", "Nested Table properties require Source review")); + continue; + } + var c = collection(path); + add(result, id, "collection", c); + var r = repeats.component(row, c); + add(result, id, "row", r); + var wrappers = + children(table).stream().filter(e -> e.getTagName().equals("columns")).toList(); + if (wrappers.size() > 1 + || !wrappers.isEmpty() && children(table).stream().anyMatch(e -> named(e, "Column"))) { + result.add(new Problem(id, "columns", "ERROR", "Mixed Column containers")); + continue; + } + var columns = + children(wrappers.isEmpty() ? table : wrappers.getFirst()).stream() + .filter(e -> named(e, "Column")) + .toList(); + for (var col : columns) { + if (!children(col).isEmpty()) + result.add( + new Problem( + id, "columns", "WARNING", "Nested Column properties require Source review")); + else add(result, id, "columns", column(path, col.getAttribute("attribute"))); + } + if (!r.status().equals("INCOMPATIBLE")) result.add(alignment(id, row, columns)); + } + return result; + } + + private Problem alignment(String id, String row, List columns) { + String prefix = "Declared Column/Row alignment: "; + try { + var type = load(row); + var doc = RepeatDiscovery.resource(type); + var root = doc.getDocumentElement(); + var owner = new ControllerMetadata(type, false, null); + if (owner.viewModel != ViewModel.class) + throw new IllegalArgumentException("custom Row ViewModel may change binding semantics"); + String base = root.getAttribute("type"); + if (!(base.equals("javafx.scene.layout.HBox") + || base.equals("HBox") + && imports(doc).stream() + .anyMatch( + v -> + v.equals("javafx.scene.layout.HBox") + || v.equals("javafx.scene.layout.*")))) + throw new IllegalArgumentException("Row is not declared as an HBox"); + var fields = new ArrayList(); + for (var child : children(root)) { + if (FX.equals(child.getNamespaceURI()) && "define".equals(child.getLocalName())) continue; + if (child.getTagName().equals("children")) fields.addAll(children(child)); + else if (!child.getTagName().contains(":") + && Character.isUpperCase( + child.getTagName().charAt(child.getTagName().lastIndexOf('.') + 1))) + fields.add(child); + else throw new IllegalArgumentException("Row contains unresolved property/include content"); + } + if (fields.size() < columns.size()) + return new Problem( + id, + "alignment", + "WARNING", + prefix + "mismatch — fewer declared Row fields than Columns. Open Row Component."); + var bindings = new ArrayList(); + for (var define : children(root)) + if (FX.equals(define.getNamespaceURI()) && "define".equals(define.getLocalName())) + for (var list : children(define)) + if (named(list, "BindingList")) { + var content = children(list); + if (content.size() == 1 && content.getFirst().getTagName().equals("elements")) + content = children(content.getFirst()); + bindings.addAll( + content.stream() + .filter(e -> named(e, "DirectBinding") || named(e, "ComputedBinding")) + .toList()); + } + for (int i = 0; i < columns.size(); i++) { + var col = columns.get(i); + var field = fields.get(i); + String fieldId = field.getAttributeNS(FX, "id"); + int identities = 0; + var rowNodes = doc.getElementsByTagName("*"); + for (int j = 0; j < rowNodes.getLength(); j++) + if (!fieldId.isEmpty() + && fieldId.equals(((Element) rowNodes.item(j)).getAttributeNS(FX, "id"))) + identities++; + if (identities != 1) throw new IllegalArgumentException("field has no unique fx:id"); + var matches = + bindings.stream() + .filter(b -> !fieldId.isEmpty() && fieldId.equals(b.getAttribute("control"))) + .toList(); + if (!children(col).isEmpty() + || matches.size() != 1 + || !named(matches.getFirst(), "DirectBinding") + || !children(matches.getFirst()).isEmpty()) + throw new IllegalArgumentException( + "field " + (i + 1) + " has no unique direct Attribute binding"); + var binding = matches.getFirst(); + String path = binding.getAttribute("path"); + if (path.isEmpty() + || binding.getAttribute("property").isEmpty() + || !Set.of("view", "model", "both").contains(binding.getAttribute("direction")) + || !binding.getAttribute("converter").isEmpty()) + throw new IllegalArgumentException("field binding requires runtime interpretation"); + if (!path.equals(col.getAttribute("attribute"))) + return new Problem( + id, + "alignment", + "WARNING", + prefix + + "mismatch at Column " + + (i + 1) + + " (" + + col.getAttribute("attribute") + + " versus Row " + + path + + "). Open Row Component."); + } + return new Problem( + id, + "alignment", + "INFO", + prefix + + "matches direct bindings; constructor/runtime layout changes remain unverified."); + } catch (Exception | LinkageError e) { + return new Problem( + id, + "alignment", + "WARNING", + prefix + "unverified — " + e.getMessage() + ". Open Row Component."); + } + } + + private static void add(List out, String id, String property, RepeatOption v) { + if (!v.status().equals("COMPATIBLE")) + out.add( + new Problem( + id, property, v.status().equals("INCOMPATIBLE") ? "ERROR" : "WARNING", v.reason())); + } + + private static RepeatOption option(String name, String type, String status, String reason) { + return new RepeatOption(name, type, status, reason == null ? "Unavailable metadata" : reason); + } + + private static List children(Element e) { + var out = new ArrayList(); + for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling()) + if (n instanceof Element c) out.add(c); + return out; + } + + private static List imports(Document d) { + var out = new ArrayList(); + for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling()) + if (n instanceof ProcessingInstruction p && p.getTarget().equals("import")) + out.add(p.getData().trim()); + return out; + } + + private static boolean named(Element e, String name) { + if (e.getTagName().equals(P + name)) return true; + var imports = imports(e.getOwnerDocument()); + return e.getTagName().equals(name) + && (imports.contains(P + name) || imports.contains(P + "*")) + && imports.stream().noneMatch(s -> s.endsWith("." + name) && !s.equals(P + name)); + } +} 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 9c08837..1f78ad6 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-v4"), + Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v5"), Set.of())); var welcome = FrameCodec.read(socket.getInputStream()); if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1) diff --git a/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/TableDiscoveryTest.java b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/TableDiscoveryTest.java new file mode 100644 index 0000000..894c917 --- /dev/null +++ b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/TableDiscoveryTest.java @@ -0,0 +1,222 @@ +package de.neitzel.fxdesigner.target; + +import static org.junit.jupiter.api.Assertions.*; + +import de.neitzel.fx.view.ViewModelClass; +import java.util.*; +import javafx.beans.property.*; +import javafx.scene.layout.HBox; +import org.junit.jupiter.api.Test; + +class TableDiscoveryTest { + public static class Item { + static { + System.setProperty("table.item.initialized", "yes"); + } + + public String getStreet() { + throw new AssertionError(); + } + + public void setStreet(String v) {} + + public int getNumber() { + throw new AssertionError(); + } + + public void setNumber(int v) {} + + public Item getNested() { + throw new AssertionError(); + } + + public void setNested(Item v) {} + + public char getLetter() { + throw new AssertionError(); + } + + public void setLetter(char v) {} + + public String getReadOnly() { + throw new AssertionError(); + } + } + + public static class Model { + static { + System.setProperty("table.model.initialized", "yes"); + } + + public List getItems() { + throw new AssertionError(); + } + + public void setItems(List v) {} + + public List getRaw() { + throw new AssertionError(); + } + + public void setRaw(List v) {} + + public String getName() { + throw new AssertionError(); + } + + public void setName(String v) {} + } + + public static class TableRow extends HBox { + static { + System.setProperty("table.row.initialized", "yes"); + } + + public ObjectProperty modelProperty() { + throw new AssertionError(); + } + } + + public static class UnknownRow extends HBox { + public ObjectProperty modelProperty() { + throw new AssertionError(); + } + } + + public static class DuplicateRow extends TableRow {} + + public static class BeanModel extends Model {} + + public static class BeanModelBeanInfo extends java.beans.SimpleBeanInfo { + static { + System.setProperty("table.beaninfo.initialized", "yes"); + } + + public BeanModelBeanInfo() { + throw new AssertionError("BeanInfo executed"); + } + } + + public static class Replacement { + public Replacement(Model m) { + throw new AssertionError(); + } + + public ListProperty itemsProperty() { + throw new AssertionError(); + } + } + + @ViewModelClass(Replacement.class) + public static class Controller {} + + private String xml(String path, String row, String cols, String controller) { + return "" + + "" + + cols + + "
"; + } + + private String col(String attr) { + return ""; + } + + private de.neitzel.fxdesigner.protocol.BindingMetadata inspect( + String path, Class row, String cols, String controller) throws Exception { + return BindingDiscovery.inspect( + xml(path, row.getName(), cols, controller), path, "view", getClass().getClassLoader()); + } + + @Test + void discoversTableTypesAndDeclaredAlignmentWithoutInitialization() throws Exception { + var m = inspect("items", TableRow.class, col("street") + col("number"), ""); + assertTrue( + m.problems().stream().noneMatch(p -> p.severity().equals("ERROR")), + m.problems().toString()); + assertTrue( + m.problems().stream() + .anyMatch(p -> p.property().equals("alignment") && p.severity().equals("INFO"))); + assertTrue( + m.tableColumns().stream() + .anyMatch(c -> c.name().equals("street") && c.status().equals("COMPATIBLE"))); + for (String name : List.of("nested", "letter", "readOnly")) + assertTrue( + m.tableColumns().stream() + .anyMatch(c -> c.name().equals(name) && c.status().equals("INCOMPATIBLE"))); + for (String key : + List.of("table.model.initialized", "table.item.initialized", "table.row.initialized")) + assertNull(System.getProperty(key)); + } + + @Test + void warnsOnReorderedAndUnknownFieldsWithoutChangingXml() throws Exception { + var mismatch = inspect("items", TableRow.class, col("number") + col("street"), ""); + assertTrue( + mismatch.problems().stream() + .anyMatch(p -> p.severity().equals("WARNING") && p.message().contains("mismatch"))); + var unknown = inspect("items", UnknownRow.class, col("street"), ""); + assertTrue( + unknown.problems().stream() + .anyMatch(p -> p.property().equals("alignment") && p.message().contains("unverified"))); + } + + @Test + void keepsCustomBeanInfoAndDuplicateFieldIdsUnverified() throws Exception { + String text = + xml("items", TableRow.class.getName(), col("street"), "") + .replace(Model.class.getName(), BeanModel.class.getName()); + var bean = BindingDiscovery.inspect(text, "items", "view", getClass().getClassLoader()); + assertTrue( + bean.problems().stream() + .anyMatch(p -> p.property().equals("collection") && p.severity().equals("WARNING"))); + assertNull(System.getProperty("table.beaninfo.initialized")); + var duplicate = inspect("items", DuplicateRow.class, col("street"), ""); + assertTrue( + duplicate.problems().stream() + .anyMatch( + p -> p.property().equals("alignment") && p.message().contains("unique fx:id"))); + } + + @Test + void rejectsMissingComplexReadOnlyAndUnsupportedScalarAttributes() throws Exception { + for (String name : List.of("absent", "nested", "letter", "readOnly", "nested.street")) { + var m = inspect("items", TableRow.class, col(name), ""); + assertTrue( + m.problems().stream() + .anyMatch(p -> p.property().equals("columns") && p.severity().equals("ERROR")), + name); + } + } + + @Test + void tableNeedsBeanMetadataEvenWhenReplacementHasListProperty() throws Exception { + var m = inspect("items", TableRow.class, col("street"), Controller.class.getName()); + assertTrue( + m.problems().stream() + .anyMatch(p -> p.property().equals("collection") && p.severity().equals("ERROR"))); + for (String path : List.of("raw", "name", "missing", "items.street")) + assertTrue( + inspect(path, TableRow.class, col("street"), "").problems().stream() + .anyMatch(p -> p.property().equals("collection") && p.severity().equals("ERROR")), + path); + } + + @Test + void rejectsWrongRowModelAndKeepsUnknownSourceSyntaxUnverified() throws Exception { + var wrong = inspect("items", RepeatDiscoveryTest.Wrong.class, col("street"), ""); + assertTrue( + wrong.problems().stream() + .anyMatch(p -> p.property().equals("row") && p.severity().equals("ERROR"))); + var nested = + inspect("items", TableRow.class, "street", ""); + assertTrue(nested.problems().stream().anyMatch(p -> p.message().contains("Nested Column"))); + } +} diff --git a/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/DuplicateRow.fxml b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/DuplicateRow.fxml new file mode 100644 index 0000000..ac3bd4f --- /dev/null +++ b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/DuplicateRow.fxml @@ -0,0 +1,3 @@ + + + diff --git a/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/TableRow.fxml b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/TableRow.fxml new file mode 100644 index 0000000..fcc023c --- /dev/null +++ b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/TableRow.fxml @@ -0,0 +1,3 @@ + + + diff --git a/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/UnknownRow.fxml b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/UnknownRow.fxml new file mode 100644 index 0000000..fc5fcf9 --- /dev/null +++ b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/UnknownRow.fxml @@ -0,0 +1,3 @@ + + + diff --git a/docs/binding-discovery.md b/docs/binding-discovery.md index b14858e..6f3f321 100644 --- a/docs/binding-discovery.md +++ b/docs/binding-discovery.md @@ -12,7 +12,7 @@ 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-v4` 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-v5` 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. 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. diff --git a/docs/converter-validation.md b/docs/converter-validation.md index 654d270..16ec8f4 100644 --- a/docs/converter-validation.md +++ b/docs/converter-validation.md @@ -27,7 +27,7 @@ The chooser distinguishes compatible, incompatible, unverified and invalid candi The worker indexes class headers in captured classpath/module-path directories and JARs, respecting multi-release JAR selection, then loads Converter descendants without initialization. It reads annotations and method signatures without constructing Converters or invoking application methods. The index is bounded to 100,000 classes and 256 candidates; the normal worker deadline and cancellation apply. Application classes never enter a host. -The subsequent [ViewModel/Computed slice](viewmodel-computed-validation.md) now uses `bindings-v4` and metadata format 4, 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-v5` and metadata format 5, 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. 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. diff --git a/docs/repeat-compatibility.md b/docs/repeat-compatibility.md index 33fa33b..c077392 100644 --- a/docs/repeat-compatibility.md +++ b/docs/repeat-compatibility.md @@ -18,11 +18,11 @@ Compatibility means declared type compatibility, not guaranteed runtime success. Named modules must expose Component resources to the discovery worker and reflective Model access to NeitzelLib; unavailable metadata is reported rather than assumed compatible. -Candidate enumeration follows conventional `package/ClassName.fxml` resource names; unusual/nested class naming may require typing the class explicitly and validating the declaration. Newly created classes still require Build and Refresh. Table/Column authoring, structural reference refactoring and complete IDE Problems integration remain later work. +Candidate enumeration follows conventional `package/ClassName.fxml` resource names; unusual/nested class naming may require typing the class explicitly and validating the declaration. Newly created classes still require Build and Refresh. [Table/Column authoring](table-column-inspector.md) and [Table-specific discovery](table-compatibility.md) are now implemented. Structural reference refactoring and complete IDE Problems integration remain later work. ## Protocol and automation -Binding metadata format 4 and the negotiated `bindings-v4` capability add bounded collection, Component and pane options. Old metadata capabilities are rejected. Existing build, draft and worker identity checks remain in effect; no host-specific application reflection was added. +The current metadata format 5 and negotiated `bindings-v5` capability retain bounded collection, Component and pane options and add [Table-specific options](table-compatibility.md). Old metadata capabilities are rejected. Existing build, draft and worker identity checks remain in effect; no host-specific application reflection was added. Unit tests cover matching and mismatching types, wrong pane targets, missing/non-collection paths, raw element types, writable Model access, replacement ViewModel collections, uncertain plain-List backing, non-initialization and metadata round trips. Real classpath and module-path fixtures qualify Component resource discovery and mismatch diagnostics. The shared live host scenario chooses a collection, checks compatible/incompatible options, preserves explicit choices and source until Apply, saves a mismatched Component, verifies preview blocking and exact Undo/Redo, then renders the corrected draft. Existing inspector and lifecycle scenarios remain enabled. No manual testing is required; previously skipped checks remain skipped. diff --git a/docs/repository-bootstrap-plan.md b/docs/repository-bootstrap-plan.md index fdb0fb7..05b51f0 100644 --- a/docs/repository-bootstrap-plan.md +++ b/docs/repository-bootstrap-plan.md @@ -129,3 +129,7 @@ The [shared Repeat discovery slice](repeat-compatibility.md) adds collection, pa ## Shared Table / Column inspector The [Table/Column authoring slice](table-column-inspector.md) adds Table and ordered Column CRUD, explicit reordering, optional widths and Row navigation in both hosts. Lexical edits and normal host Undo/Redo preserve the authoritative document. Structural errors block preview while drafts remain saveable. Row alignment and Table-specific type discovery remain unverified; generated metadata, reference refactoring and Problems integration remain open. + +## Table-specific compatibility and Row alignment + +The [Table discovery slice](table-compatibility.md) adds collection/Row/Attribute choices, Table-specific bean rules and conservative positional Row-binding diagnostics in both hosts. Binding discovery includes captured open Row drafts and rejects changed revisions. Known type errors block preview; mismatches and uncertain alignment offer explicit Row navigation without rearranging shared layouts. Generated metadata, structural reference repair and Problems integration remain open. diff --git a/docs/table-column-inspector.md b/docs/table-column-inspector.md index 2db847e..8a69fab 100644 --- a/docs/table-column-inspector.md +++ b/docs/table-column-inspector.md @@ -4,7 +4,7 @@ Open **Bindings → Table / Columns** in either host. Select an existing Table o Columns expose Attribute, header and optional starting width. **New Column**, **Apply Column**, **Remove Column**, **Column up** and **Column down** each produce one host-owned undo command. Blank width removes the width attribute and restores the runtime's equal-share default. Table removal removes its complete declaration and Columns. It does not repair other binding references; those remain visible to existing diagnostics. -**Open selected Row Component** uses the same shared navigation service as canvas Component navigation. Column order is positional in NeitzelLib. Reordering Columns never rearranges or edits the shared Row FXML. The inspector explicitly marks Column/Row alignment and type compatibility as unverified and offers Row navigation for review. Detecting correspondence between Row fields and Column Attributes is a later discovery slice. +**Open selected Row Component** uses the same shared navigation service as canvas Component navigation. Column order is positional in NeitzelLib. Reordering Columns never rearranges or edits the shared Row FXML. The subsequent [Table discovery slice](table-compatibility.md) now checks Table-specific types and declared Column/Row correspondence, retaining warnings where evidence is incomplete. ## Source and history @@ -12,7 +12,7 @@ The authoritative host document remains the only persisted representation. Table Nested property syntax is retained and remains Source-only for field edits. Mixed Column containers are rejected rather than rewritten. Source changes invalidate the form's captured source; stale or read-only edits are refused. Each Apply, removal or move is one ordinary standalone/IntelliJ Undo/Redo transaction. Discovery or form typing does not change source. -Structural diagnostics cover missing/invalid collection and Row names, empty Column lists, invalid or duplicate Attributes, missing headers, and nonpositive/nonfinite widths. Invalid drafts remain editable and saveable, while these errors block new previews. Runtime collection types, simple Column Attribute types, Row construction and positional field alignment are not certified by this authoring slice; target preview remains the execution check. No application reflection or classes enter either host. +Structural diagnostics cover missing/invalid collection and Row names, empty Column lists, invalid or duplicate Attributes, missing headers, and nonpositive/nonfinite widths. Invalid drafts remain editable and saveable, while these errors block new previews. The [Table discovery slice](table-compatibility.md) now checks declared collection/Attribute types and positional binding evidence. Row construction and runtime layout remain preview checks. No application reflection or classes enter either host. ## Automated qualification diff --git a/docs/table-compatibility.md b/docs/table-compatibility.md new file mode 100644 index 0000000..e6b42b0 --- /dev/null +++ b/docs/table-compatibility.md @@ -0,0 +1,37 @@ +# Table compatibility and declared Row alignment + +In **Bindings → Table / Columns**, select a Table and use **Discover / Validate Table** after Build and Refresh. The shared inspector lists Table-compatible collections, Row Components, and Column Attributes. Choosing a collection refreshes the comparisons; choosing a Row or Attribute only prepares the form. Explicit selections are preserved, and source changes still require Apply with ordinary host Undo/Redo. + +Document diagnostics describe applied declarations. Choices compare the collection requested by the form. Source/build changes clear choices, and obsolete responses cannot populate a changed form. The same target-side validation runs before preview: known errors block new previews, warnings allow preview, and invalid drafts remain saveable. + +## Table-specific runtime rules + +Table uses a direct read/write bean collection Attribute to obtain its element class, in addition to the ViewModel property providing an ObservableList. Generic ViewModels and subclasses use their Model's bean metadata; replacement ViewModels need bean metadata on the replacement itself. A property accessor alone can work for Repeat but does not satisfy Table's element-type lookup. Nested collection paths and raw/unresolvable generic element declarations are rejected when the known bean signatures prove the runtime cannot resolve them. Missing context, custom BeanInfo, unavailable classes and uncertain observable backing remain unverified. + +Columns must name direct simple read/write Attributes on that element class. The classifier follows NeitzelLib Table's rules: String, boolean, numeric primitives except char, Number, enum, Date/Calendar and TemporalAccessor/TemporalAmount. Complex objects, collections, arrays, char, missing setters/getters and nested paths are incompatible. No application BeanInfo, accessor, getter, constructor or static initializer is executed. Compatibility is declared type evidence, not a guarantee about null values, dynamic contents, injection or runtime code. + +Row candidates reuse Component resource/model-property checks and compare both declared Model and typed modelProperty with Table's element type. Manually typed Row classes are validated even if conventional resource scanning does not enumerate them. Named-module resource visibility and reflective access requirements remain those of the target runtime. + +## Alignment evidence and Row drafts + +The runtime assigns widths to the first N immediate Row children in Column order; trailing controls are allowed. For a Row declared as an HBox, discovery reads direct children or its explicit `children` wrapper and the root BindingList's Direct bindings. Each Column must correspond to a uniquely identified field with one unambiguous direct Attribute binding. Matching declarations receive an informational result. Different Attributes or too few declared fields produce a mismatch warning and **Open selected Row Component** remains available. + +Missing bindings, Computed bindings, Converters, nested property syntax, custom Row ViewModels, non-HBox layouts and unresolved content produce an unverified warning. Construction and runtime layout changes remain unverified even when declarations match. Warnings never silently rearrange either the Columns or the shared Row layout. + +Binding discovery now overlays captured open FXML documents from the active reactor, including unsaved Row drafts, and checks every captured document revision before returning. The request identity carries all captured revisions. A Row edit invalidates parent discovery through existing document notifications. This extends the same draft capture to the other binding inspectors; Java changes still require Build and Refresh. + +## Protocol and qualification + +Metadata format 5 and `bindings-v5` add bounded Table collection, Row and Attribute lists. Older binding capabilities are rejected; existing payload, snapshot, document identity and worker boundaries remain intact. No application classes enter either host. + +Automated tests cover the Table-specific bean rules, simple Attribute classifier, Row mismatch/unverified cases, non-initialization and metadata round trips. Both real target launch modes exercise discovery and error diagnostics. The shared host scenario checks choices, explicit selection/source preservation, an unsaved Row mismatch and restoration, invalid Column preview blocking, save and exact Undo/Redo. Run the bootstrap and registered IntelliJ checks in the [README](../README.md). No manual testing is required; previously skipped checks remain skipped. + +Generated metadata, automatic structural reference repair and IDE Problems integration remain subsequent work. Alignment inference deliberately remains conservative for arbitrary Row layouts and runtime behavior. + +### Qualification — 2026-11-13 + +The full bootstrap gate passed: 115 unit/adapter tests with no failures, 36 target processes with 512 assertions, both native standalone modes, 48 lifecycle cycles, connected adapters and plugin packaging. Registered IntelliJ classpath and module-path qualification passed the new discovery/Row-draft scenario, exclusive JavaFX 26 native-library checks and worker shutdown. No manual testing was used. + +An initial fixture assertion was updated to accept informational alignment results while still rejecting warnings/errors. The first registered IntelliJ attempt encountered the previously recorded intermittent exact-source save assertion in an older projection scenario, before Table tests. Its diagnostic's second file read equaled the expected text (both 1,191 characters). The unchanged registered rerun passed both modes. No save implementation or exact-source assertion was weakened; the intermittent persistence-check issue remains a separate reliability follow-up. + +Local evidence: `/tmp/table-discovery-bootstrap-final.log`, `/tmp/table-discovery-registered-ide.log`, `/tmp/table-discovery-registered-ide-retry.log`, `target/connected-workflow/` and `target/ide-workflow/`. Generated qualification artifacts are not repository inputs. diff --git a/docs/viewmodel-computed-validation.md b/docs/viewmodel-computed-validation.md index a173b3c..3fd6228 100644 --- a/docs/viewmodel-computed-validation.md +++ b/docs/viewmodel-computed-validation.md @@ -20,7 +20,7 @@ Computed validation still runs when a Model declaration is missing: that limits ## Boundaries -Application classes stay in the target worker and are loaded without initialization. The current `bindings-v4` capability and metadata format 4 retain Controller/ViewModel identity and Computed signatures, plus the captured resource on requests, and add [Repeat options](repeat-compatibility.md). 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. +Application classes stay in the target worker and are loaded without initialization. The current `bindings-v5` capability and metadata format 5 retain Controller/ViewModel identity and Computed signatures, plus the captured resource on requests, and add [Repeat options](repeat-compatibility.md). 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 remains later work. 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 99fbc98..8c06b5b 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 @@ -98,7 +98,7 @@ public final class PreviewChecks { String full = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); var details = BindingDiscovery.inspect(full, "rank", "both", "rankInput", "text", loader); check( - details.problems().isEmpty(), + details.problems().stream().allMatch(p -> p.severity().equals("INFO")), "Valid converter binding rejected: " + details.problems()); for (var expected : java.util.Map.of( @@ -203,7 +203,55 @@ public final class PreviewChecks { repeat.panes().stream() .anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")), "Repeat pane absent"); - check(repeat.problems().isEmpty(), "Valid Repeat rejected: " + repeat.problems()); + check( + repeat.problems().stream().allMatch(p -> p.severity().equals("INFO")), + "Valid Repeat/Table rejected: " + repeat.problems()); + check( + repeat.tableCollections().stream() + .anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")), + "Table collection absent"); + check( + repeat.tableRows().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.VisitRow") + && c.status().equals("COMPATIBLE")), + "Table Row absent"); + check( + repeat.tableColumns().stream() + .anyMatch(c -> c.name().equals("street") && c.status().equals("COMPATIBLE")), + "Table Attribute absent"); + check( + repeat.problems().stream() + .anyMatch(p -> p.property().equals("alignment") && p.severity().equals("INFO")), + "Row alignment not discovered"); + var wrongColumn = + BindingDiscovery.inspect( + full.replace("attribute=\"street\"", "attribute=\"missing\""), + "visits", + "view", + loader); + check( + wrongColumn.problems().stream() + .anyMatch(p -> p.property().equals("columns") && p.severity().equals("ERROR")), + "Missing Column Attribute accepted"); + check( + wrongColumn.problems().stream() + .anyMatch( + p -> p.property().equals("alignment") && p.message().contains("mismatch")), + "Column/Row mismatch not found"); + var wrongRow = + BindingDiscovery.inspect( + full.replace( + "row=\"fixture.components.VisitRow\"", + "row=\"fixture.components.WrongRepeatCard\""), + "visits", + "view", + loader); + check( + wrongRow.problems().stream() + .anyMatch(p -> p.property().equals("row") && p.severity().equals("ERROR")), + "Wrong Row Model accepted"); var bad = BindingDiscovery.inspect( full.replace( 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 99fbc98..8c06b5b 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 @@ -98,7 +98,7 @@ public final class PreviewChecks { String full = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); var details = BindingDiscovery.inspect(full, "rank", "both", "rankInput", "text", loader); check( - details.problems().isEmpty(), + details.problems().stream().allMatch(p -> p.severity().equals("INFO")), "Valid converter binding rejected: " + details.problems()); for (var expected : java.util.Map.of( @@ -203,7 +203,55 @@ public final class PreviewChecks { repeat.panes().stream() .anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")), "Repeat pane absent"); - check(repeat.problems().isEmpty(), "Valid Repeat rejected: " + repeat.problems()); + check( + repeat.problems().stream().allMatch(p -> p.severity().equals("INFO")), + "Valid Repeat/Table rejected: " + repeat.problems()); + check( + repeat.tableCollections().stream() + .anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")), + "Table collection absent"); + check( + repeat.tableRows().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.VisitRow") + && c.status().equals("COMPATIBLE")), + "Table Row absent"); + check( + repeat.tableColumns().stream() + .anyMatch(c -> c.name().equals("street") && c.status().equals("COMPATIBLE")), + "Table Attribute absent"); + check( + repeat.problems().stream() + .anyMatch(p -> p.property().equals("alignment") && p.severity().equals("INFO")), + "Row alignment not discovered"); + var wrongColumn = + BindingDiscovery.inspect( + full.replace("attribute=\"street\"", "attribute=\"missing\""), + "visits", + "view", + loader); + check( + wrongColumn.problems().stream() + .anyMatch(p -> p.property().equals("columns") && p.severity().equals("ERROR")), + "Missing Column Attribute accepted"); + check( + wrongColumn.problems().stream() + .anyMatch( + p -> p.property().equals("alignment") && p.message().contains("mismatch")), + "Column/Row mismatch not found"); + var wrongRow = + BindingDiscovery.inspect( + full.replace( + "row=\"fixture.components.VisitRow\"", + "row=\"fixture.components.WrongRepeatCard\""), + "visits", + "view", + loader); + check( + wrongRow.problems().stream() + .anyMatch(p -> p.property().equals("row") && p.severity().equals("ERROR")), + "Wrong Row Model accepted"); var bad = BindingDiscovery.inspect( full.replace(