Discover Converter capabilities and validate shared binding drafts
This commit is contained in:
@@ -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. 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. 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.
|
||||
|
||||
|
||||
+59
-4
@@ -9,7 +9,27 @@ public record BindingMetadata(
|
||||
String direction,
|
||||
List<Attribute> attributes,
|
||||
List<Target> targets,
|
||||
List<String> diagnostics) {
|
||||
List<String> diagnostics,
|
||||
List<Converter> converters,
|
||||
List<Problem> problems) {
|
||||
public record Converter(String name, String status, String reason) {
|
||||
public String toString() {
|
||||
return name + " — " + status + ": " + reason;
|
||||
}
|
||||
}
|
||||
|
||||
public record Problem(String control, String property, String severity, String message) {}
|
||||
|
||||
public BindingMetadata(
|
||||
String model,
|
||||
String path,
|
||||
String direction,
|
||||
List<Attribute> attributes,
|
||||
List<Target> targets,
|
||||
List<String> diagnostics) {
|
||||
this(model, path, direction, attributes, targets, diagnostics, List.of(), List.of());
|
||||
}
|
||||
|
||||
public record Attribute(String path, String type, String kind, String elementType) {
|
||||
public String toString() {
|
||||
return path + " : " + type + " (" + kind + ")";
|
||||
@@ -23,13 +43,17 @@ public record BindingMetadata(
|
||||
attributes = List.copyOf(attributes);
|
||||
targets = List.copyOf(targets);
|
||||
diagnostics = List.copyOf(diagnostics);
|
||||
converters = List.copyOf(converters);
|
||||
problems = List.copyOf(problems);
|
||||
if (!Set.of("view", "model", "both").contains(direction))
|
||||
throw new IllegalArgumentException("Invalid direction");
|
||||
}
|
||||
|
||||
public Map<String, String> encode() {
|
||||
var m = new TreeMap<String, String>();
|
||||
m.put("format", "1");
|
||||
m.put("format", "2");
|
||||
m.put("converters", "" + converters.size());
|
||||
m.put("problems", "" + problems.size());
|
||||
m.put("model", model);
|
||||
m.put("path", path);
|
||||
m.put("direction", direction);
|
||||
@@ -54,6 +78,21 @@ public record BindingMetadata(
|
||||
m.put(p + "reason", t.reason);
|
||||
}
|
||||
for (int i = 0; i < diagnostics.size(); i++) m.put("d." + i, diagnostics.get(i));
|
||||
for (int i = 0; i < converters.size(); i++) {
|
||||
var c = converters.get(i);
|
||||
String p = "c." + i + ".";
|
||||
m.put(p + "name", c.name());
|
||||
m.put(p + "status", c.status());
|
||||
m.put(p + "reason", c.reason());
|
||||
}
|
||||
for (int i = 0; i < problems.size(); i++) {
|
||||
var v = problems.get(i);
|
||||
String p = "v." + i + ".";
|
||||
m.put(p + "control", v.control());
|
||||
m.put(p + "property", v.property());
|
||||
m.put(p + "severity", v.severity());
|
||||
m.put(p + "message", v.message());
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -68,7 +107,7 @@ public record BindingMetadata(
|
||||
}
|
||||
|
||||
public static BindingMetadata decode(Map<String, String> m) {
|
||||
if (!"1".equals(field(m, "format")))
|
||||
if (!"2".equals(field(m, "format")))
|
||||
throw new IllegalArgumentException("Unsupported binding metadata");
|
||||
var a = new ArrayList<Attribute>();
|
||||
var t = new ArrayList<Target>();
|
||||
@@ -93,6 +132,22 @@ public record BindingMetadata(
|
||||
field(m, p + "reason")));
|
||||
}
|
||||
for (int i = 0; i < count(m, "diagnostics"); i++) d.add(field(m, "d." + i));
|
||||
return new BindingMetadata(field(m, "model"), field(m, "path"), field(m, "direction"), a, t, d);
|
||||
var c = new ArrayList<Converter>();
|
||||
var v = new ArrayList<Problem>();
|
||||
for (int i = 0; i < count(m, "converters"); i++) {
|
||||
String p = "c." + i + ".";
|
||||
c.add(new Converter(field(m, p + "name"), field(m, p + "status"), field(m, p + "reason")));
|
||||
}
|
||||
for (int i = 0; i < count(m, "problems"); i++) {
|
||||
String p = "v." + i + ".";
|
||||
v.add(
|
||||
new Problem(
|
||||
field(m, p + "control"),
|
||||
field(m, p + "property"),
|
||||
field(m, p + "severity"),
|
||||
field(m, p + "message")));
|
||||
}
|
||||
return new BindingMetadata(
|
||||
field(m, "model"), field(m, "path"), field(m, "direction"), a, t, d, c, v);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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-v1")
|
||||
&& hello.capabilities().contains("bindings-v1")) usable.add(Operation.BINDINGS);
|
||||
&& supported.contains("bindings-v2")
|
||||
&& hello.capabilities().contains("bindings-v2")) 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-v1") && !usable.contains(Operation.BINDINGS))
|
||||
if (requiredByEither.contains("bindings-v2") && !usable.contains(Operation.BINDINGS))
|
||||
throw new IllegalArgumentException("Required capability version incompatible");
|
||||
operations = Set.copyOf(usable);
|
||||
state = State.READY;
|
||||
|
||||
+24
-6
@@ -19,7 +19,9 @@ class BindingMetadataTest {
|
||||
List.of(
|
||||
new BindingMetadata.Target(
|
||||
"input", "text", "java.lang.String", "COMPATIBLE", "Types match")),
|
||||
List.of());
|
||||
List.of(),
|
||||
List.of(new BindingMetadata.Converter("app.C", "UNVERIFIED", "No metadata")),
|
||||
List.of(new BindingMetadata.Problem("input", "text", "ERROR", "Missing path")));
|
||||
assertEquals(
|
||||
m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode()))));
|
||||
var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0);
|
||||
@@ -34,14 +36,14 @@ class BindingMetadataTest {
|
||||
new Identity("s", "r", "c", "b", Map.of(), 0, 0),
|
||||
new byte[0]));
|
||||
var invalid = new HashMap<>(m.encode());
|
||||
invalid.put("targets", "99999");
|
||||
invalid.put("converters", "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-v1"), Set.of("bindings-v1"));
|
||||
var session = new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2"));
|
||||
assertTrue(
|
||||
session
|
||||
.authenticate(
|
||||
@@ -53,11 +55,27 @@ class BindingMetadataTest {
|
||||
new Version(1, 0),
|
||||
new Version(1, 0),
|
||||
new Version(1, 0),
|
||||
Set.of("bindings-v1"),
|
||||
Set.of("bindings-v2"),
|
||||
Set.of()))
|
||||
.contains(Operation.BINDINGS));
|
||||
var oldWorker =
|
||||
new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2"));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
oldWorker.authenticate(
|
||||
InetAddress.getLoopbackAddress(),
|
||||
new Hello(
|
||||
"s",
|
||||
1,
|
||||
token,
|
||||
new Version(1, 0),
|
||||
new Version(1, 0),
|
||||
new Version(1, 0),
|
||||
Set.of("bindings-v1"),
|
||||
Set.of())));
|
||||
var incompatible =
|
||||
new ProtocolSession("s", 1, token, Set.of("bindings-v1"), Set.of("bindings-v1"));
|
||||
new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2"));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
@@ -70,7 +88,7 @@ class BindingMetadataTest {
|
||||
new Version(1, 0),
|
||||
new Version(2, 0),
|
||||
new Version(1, 0),
|
||||
Set.of("bindings-v1"),
|
||||
Set.of("bindings-v2"),
|
||||
Set.of())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,16 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
|
||||
public CompletionStage<BindingMetadata> bindings(
|
||||
boolean last, DocumentSnapshot document, String path, String direction) {
|
||||
return bindingDetails(last, document, path, direction, "", "");
|
||||
}
|
||||
|
||||
public CompletionStage<BindingMetadata> bindingDetails(
|
||||
boolean last,
|
||||
DocumentSnapshot document,
|
||||
String path,
|
||||
String direction,
|
||||
String control,
|
||||
String property) {
|
||||
return execute(
|
||||
last,
|
||||
List.of(document),
|
||||
@@ -211,7 +221,17 @@ public final class DesignerWorkflow implements AutoCloseable {
|
||||
0,
|
||||
0,
|
||||
PreviewMessages.encode(
|
||||
Map.of("fxml", document.text(), "path", path, "direction", direction)),
|
||||
Map.of(
|
||||
"fxml",
|
||||
document.text(),
|
||||
"path",
|
||||
path,
|
||||
"direction",
|
||||
direction,
|
||||
"control",
|
||||
control,
|
||||
"property",
|
||||
property)),
|
||||
Duration.ofSeconds(15));
|
||||
try (var current =
|
||||
host.documents().open(document.document()).toCompletableFuture().get()) {
|
||||
|
||||
@@ -104,7 +104,7 @@ public final class PreviewWorker implements AutoCloseable {
|
||||
id,
|
||||
process.pid(),
|
||||
token,
|
||||
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v1"),
|
||||
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v2"),
|
||||
config.requiredCapabilities());
|
||||
}
|
||||
drain(process.getInputStream(), log);
|
||||
|
||||
@@ -115,6 +115,10 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
private DesignerWorkflow bindingWorkflow;
|
||||
private BindingBrowser bindingBrowser;
|
||||
private DirectBindingEditor bindingEditor;
|
||||
private String validatedSource, validatingSource;
|
||||
private boolean validationPreview;
|
||||
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
|
||||
java.util.List.of();
|
||||
private Tab directBindingTab;
|
||||
private long bindingRequest;
|
||||
private boolean interactiveRequested, interactiveRestart;
|
||||
@@ -182,7 +186,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
if (kit.editor().getFxomDocument() == null) return;
|
||||
var target = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(id);
|
||||
if (target != null) kit.editor().getSelection().select(target);
|
||||
});
|
||||
},
|
||||
values -> validateBindings(values, false));
|
||||
bindingBrowser =
|
||||
new BindingBrowser(
|
||||
this::discoverBindings,
|
||||
@@ -639,6 +644,9 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
|
||||
private void invalidateBindings(String message) {
|
||||
bindingRequest++;
|
||||
validatedSource = null;
|
||||
validatingSource = null;
|
||||
validationPreview = false;
|
||||
bindingWorkflow.cancel();
|
||||
bindingBrowser.invalidate(message);
|
||||
}
|
||||
@@ -682,10 +690,66 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void validateBindings(DirectBindingDocument.Values values, boolean preview) {
|
||||
withDraft(
|
||||
() -> {
|
||||
if (preview && draft.text().equals(validatingSource)) {
|
||||
validationPreview = true;
|
||||
return;
|
||||
}
|
||||
long ticket = ++bindingRequest;
|
||||
var captured = draft;
|
||||
validatingSource = captured.text();
|
||||
validationPreview |= preview;
|
||||
status.setText("Validating binding types in target runtime…");
|
||||
bindingWorkflow
|
||||
.bindingDetails(
|
||||
last,
|
||||
captured,
|
||||
values == null ? "" : values.path(),
|
||||
values == null ? "both" : values.direction(),
|
||||
values == null ? "" : values.control(),
|
||||
values == null ? "" : values.property())
|
||||
.whenComplete(
|
||||
(metadata, error) ->
|
||||
fx(
|
||||
() -> {
|
||||
if (closed
|
||||
|| ticket != bindingRequest
|
||||
|| draft.revision() != captured.revision()) return;
|
||||
validatingSource = null;
|
||||
boolean resumePreview = validationPreview;
|
||||
validationPreview = false;
|
||||
if (error != null) {
|
||||
status.setText(
|
||||
"Binding validation unavailable: " + error.getMessage());
|
||||
return;
|
||||
}
|
||||
validatedSource = captured.text();
|
||||
bindingProblems = metadata.problems();
|
||||
bindingEditor.metadata(
|
||||
metadata,
|
||||
values,
|
||||
(last
|
||||
? "Explicit retained build (outdated)"
|
||||
: "Current successful build")
|
||||
+ " · draft revision "
|
||||
+ captured.revision());
|
||||
status.setText(
|
||||
"Binding validation complete: "
|
||||
+ metadata.problems().size()
|
||||
+ " diagnostics");
|
||||
if (resumePreview) render();
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private void discoverBindings(String path, String direction) {
|
||||
withDraft(
|
||||
() -> {
|
||||
long ticket = ++bindingRequest;
|
||||
validatingSource = null;
|
||||
validationPreview = false;
|
||||
bindingBrowser.invalidate("Discovering declared types in target runtime…");
|
||||
var captured = draft;
|
||||
bindingWorkflow
|
||||
@@ -803,6 +867,19 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
withDraft(
|
||||
() -> {
|
||||
if (!bindingPreviewAllowed()) return;
|
||||
if (!draft.text().equals(validatedSource)) {
|
||||
validateBindings(null, true);
|
||||
return;
|
||||
}
|
||||
if (bindingProblems.stream().anyMatch(p -> p.severity().equals("ERROR"))) {
|
||||
status.setText(
|
||||
"Preview blocked: "
|
||||
+ bindingProblems.stream()
|
||||
.filter(p -> p.severity().equals("ERROR"))
|
||||
.map(p -> p.message())
|
||||
.collect(java.util.stream.Collectors.joining("; ")));
|
||||
return;
|
||||
}
|
||||
var selected = examples.getValue();
|
||||
if (selected == null || projection == null) return;
|
||||
long ticket = renderSequence.incrementAndGet();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.neitzel.fxdesigner.kit;
|
||||
|
||||
import de.neitzel.fxdesigner.protocol.BindingMetadata;
|
||||
import java.util.function.*;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
@@ -18,6 +19,8 @@ final class DirectBindingEditor extends VBox {
|
||||
property = field("property"),
|
||||
path = field("path"),
|
||||
converter = field("converter");
|
||||
private final ComboBox<BindingMetadata.Converter> converters = new ComboBox<>();
|
||||
private final Button inspect = new Button("Find Converters / Validate");
|
||||
private final ComboBox<String> direction = new ComboBox<>();
|
||||
private final CheckBox replace = new CheckBox("Replace conflicting bindings");
|
||||
private final Label status = new Label("Open a valid FXML document to edit bindings.");
|
||||
@@ -29,9 +32,23 @@ final class DirectBindingEditor extends VBox {
|
||||
private boolean loading, writable, active, followingCanvas;
|
||||
private final Consumer<String> navigate;
|
||||
|
||||
DirectBindingEditor(Consumer<Intent> edit, Consumer<String> navigate) {
|
||||
DirectBindingEditor(
|
||||
Consumer<Intent> edit,
|
||||
Consumer<String> navigate,
|
||||
Consumer<DirectBindingDocument.Values> discover) {
|
||||
this.navigate = navigate;
|
||||
setSpacing(5);
|
||||
converters.setId("direct-converters");
|
||||
converters.setPrefWidth(680);
|
||||
inspect.setOnAction(e -> discover.accept(values()));
|
||||
converters.setOnAction(
|
||||
e -> {
|
||||
var c = converters.getValue();
|
||||
if (c != null) converter.setText(c.name());
|
||||
});
|
||||
for (var field : java.util.List.of(control, property, path))
|
||||
field.textProperty().addListener((o, a, b) -> converters.getItems().clear());
|
||||
direction.valueProperty().addListener((o, a, b) -> converters.getItems().clear());
|
||||
bindings.setId("direct-bindings");
|
||||
bindings.setPrefHeight(100);
|
||||
direction.setId("direct-direction");
|
||||
@@ -73,7 +90,13 @@ final class DirectBindingEditor extends VBox {
|
||||
new Label("Direction"),
|
||||
direction);
|
||||
grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter);
|
||||
getChildren().addAll(bindings, grid, new HBox(8, fresh, apply, remove, replace), status);
|
||||
getChildren()
|
||||
.addAll(
|
||||
bindings,
|
||||
grid,
|
||||
new HBox(8, inspect, converters),
|
||||
new HBox(8, fresh, apply, remove, replace),
|
||||
status);
|
||||
buttons();
|
||||
}
|
||||
|
||||
@@ -120,6 +143,7 @@ final class DirectBindingEditor extends VBox {
|
||||
|
||||
void load(String text, boolean editable) {
|
||||
if (source.equals(text) && writable == editable) return;
|
||||
converters.getItems().clear();
|
||||
source = text;
|
||||
writable = editable;
|
||||
loading = true;
|
||||
@@ -131,8 +155,8 @@ final class DirectBindingEditor extends VBox {
|
||||
bindings.getSelectionModel().clearSelection();
|
||||
status.setText(
|
||||
doc.errors().isEmpty()
|
||||
? "Select a DirectBinding or choose New binding. Converter types are not yet"
|
||||
+ " verified."
|
||||
? "Select a DirectBinding or choose New binding. Use Find Converters / Validate to"
|
||||
+ " check this draft."
|
||||
: String.join("\n", doc.errors()));
|
||||
} catch (Exception error) {
|
||||
bindings.getItems().clear();
|
||||
@@ -162,6 +186,35 @@ final class DirectBindingEditor extends VBox {
|
||||
});
|
||||
}
|
||||
|
||||
void metadata(
|
||||
BindingMetadata metadata, DirectBindingDocument.Values requested, String provenance) {
|
||||
status.setText(
|
||||
provenance
|
||||
+ "\n"
|
||||
+ (metadata.problems().isEmpty()
|
||||
? "No detected binding errors."
|
||||
: metadata.problems().stream()
|
||||
.map(
|
||||
p ->
|
||||
p.severity()
|
||||
+ " "
|
||||
+ p.control()
|
||||
+ "."
|
||||
+ p.property()
|
||||
+ ": "
|
||||
+ p.message())
|
||||
.collect(java.util.stream.Collectors.joining("\n"))));
|
||||
if (requested == null || !values().equals(requested)) return;
|
||||
converters.getItems().setAll(metadata.converters());
|
||||
var matches =
|
||||
metadata.converters().stream().filter(c -> c.status().equals("COMPATIBLE")).toList();
|
||||
if (converter.getText().isBlank() && matches.size() == 1) {
|
||||
converters.setValue(matches.getFirst());
|
||||
status.setText(
|
||||
status.getText() + "\nUnique compatible Converter proposed; review and Apply binding.");
|
||||
}
|
||||
}
|
||||
|
||||
void error(String message) {
|
||||
status.setText(message);
|
||||
}
|
||||
|
||||
@@ -267,6 +267,88 @@ public final class ProjectionHostChecks {
|
||||
return doc.snapshot().toCompletableFuture().get().text();
|
||||
}
|
||||
|
||||
private static boolean bindingStatus(DesignerPane pane, String text) throws Exception {
|
||||
return fx(() -> ((Label) pane.lookup("#direct-status")).getText().contains(text));
|
||||
}
|
||||
|
||||
private static void converterEdits(DesignerPane pane, DocumentSession document, String full)
|
||||
throws Exception {
|
||||
bindingTab(pane, "Direct bindings");
|
||||
selectDirect(pane, "rankInput");
|
||||
click(pane, "Find Converters / Validate");
|
||||
await(() -> fx(() -> !((ComboBox<?>) pane.lookup("#direct-converters")).getItems().isEmpty()));
|
||||
check(
|
||||
fx(
|
||||
() ->
|
||||
((TextField) pane.lookup("#direct-converter"))
|
||||
.getText()
|
||||
.equals("fixture.components.RankConverter")),
|
||||
"Existing Converter changed");
|
||||
check(documentText(document).equals(full), "Validation altered source");
|
||||
directField(pane, "converter", "");
|
||||
click(pane, "Apply binding");
|
||||
await(() -> !documentText(document).equals(full));
|
||||
String invalid = documentText(document);
|
||||
await(() -> fx(() -> source(pane).getText().equals(invalid)));
|
||||
click(pane, "Render drafts");
|
||||
await(() -> bindingStatus(pane, "Incompatible value types"));
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
pane.lookupAll(".label").stream()
|
||||
.anyMatch(
|
||||
n ->
|
||||
n instanceof Label l
|
||||
&& l.getText() != null
|
||||
&& l.getText().startsWith("Preview blocked:"))));
|
||||
selectDirect(pane, "rankInput");
|
||||
click(pane, "Find Converters / Validate");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((TextField) pane.lookup("#direct-converter"))
|
||||
.getText()
|
||||
.equals("fixture.components.RankConverter")));
|
||||
check(documentText(document).equals(invalid), "Proposal applied without review");
|
||||
click(pane, "Apply binding");
|
||||
await(() -> !documentText(document).equals(invalid));
|
||||
String repaired = documentText(document);
|
||||
click(pane, "Undo");
|
||||
await(() -> documentText(document).equals(invalid));
|
||||
click(pane, "Redo");
|
||||
await(() -> documentText(document).equals(repaired));
|
||||
await(() -> fx(() -> source(pane).getText().equals(repaired)));
|
||||
selectDirect(pane, "rankInput");
|
||||
directField(pane, "converter", "fixture.components.LegacyRankConverter");
|
||||
click(pane, "Apply binding");
|
||||
await(() -> documentText(document).contains("LegacyRankConverter"));
|
||||
await(() -> fx(() -> source(pane).getText().contains("LegacyRankConverter")));
|
||||
selectDirect(pane, "rankInput");
|
||||
click(pane, "Find Converters / Validate");
|
||||
await(() -> bindingStatus(pane, "WARNING rankInput.text"));
|
||||
check(
|
||||
fx(
|
||||
() ->
|
||||
((TextField) pane.lookup("#direct-converter"))
|
||||
.getText()
|
||||
.equals("fixture.components.LegacyRankConverter")),
|
||||
"Unverified explicit Converter replaced");
|
||||
set(document, full);
|
||||
await(() -> fx(() -> source(pane).getText().equals(full)));
|
||||
click(pane, "New binding");
|
||||
directField(pane, "control", "action");
|
||||
directField(pane, "property", "text");
|
||||
directField(pane, "path", "name");
|
||||
click(pane, "Find Converters / Validate");
|
||||
await(() -> fx(() -> ((ComboBox<?>) pane.lookup("#direct-converters")).getItems().size() > 1));
|
||||
check(
|
||||
fx(() -> ((TextField) pane.lookup("#direct-converter")).getText().isEmpty()),
|
||||
"Ambiguous candidates silently selected");
|
||||
check(documentText(document).equals(full), "Converter browsing altered source");
|
||||
}
|
||||
|
||||
private static void directBindingEdits(DesignerPane pane, DocumentSession document, String full)
|
||||
throws Exception {
|
||||
bindingTab(pane, "Direct bindings");
|
||||
@@ -697,6 +779,7 @@ public final class ProjectionHostChecks {
|
||||
&& ((TextField) pane.lookup("#direct-path")).getText().equals("rank")),
|
||||
"Discovery did not populate binding form");
|
||||
check(documentText(document).equals(full), "Discovery handoff changed source before Apply");
|
||||
converterEdits(pane, document, full);
|
||||
directBindingEdits(pane, document, full);
|
||||
document
|
||||
.save(document.snapshot().toCompletableFuture().get().revision())
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package de.neitzel.fxdesigner.preview;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/** Optional design-time contract. Declare on the concrete Converter; never inherited. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ConverterCapabilities {
|
||||
Class<?> modelType();
|
||||
|
||||
Class<?> controlType();
|
||||
|
||||
/** Whether toAttributeValue is implemented. Forward conversion is always required. */
|
||||
boolean reverse() default false;
|
||||
}
|
||||
+160
-5
@@ -15,9 +15,14 @@ public final class BindingDiscovery {
|
||||
private static final String FX = "http://javafx.com/fxml/1";
|
||||
private final ClassLoader loader;
|
||||
private final List<String> diagnostics = new ArrayList<>();
|
||||
private boolean incompleteModel;
|
||||
private final Map<String, Class<?>> paths = new TreeMap<>();
|
||||
private final List<Attribute> attributes = new ArrayList<>();
|
||||
|
||||
private record TargetType(Class<?> type, boolean writable) {}
|
||||
|
||||
private final Map<String, TargetType> targetTypes = new HashMap<>();
|
||||
|
||||
private BindingDiscovery(ClassLoader loader) {
|
||||
this.loader = loader;
|
||||
}
|
||||
@@ -26,7 +31,20 @@ public final class BindingDiscovery {
|
||||
String fxml, String path, String direction, 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);
|
||||
return inspect(fxml, path, direction, "", "", loader);
|
||||
}
|
||||
|
||||
public static BindingMetadata inspect(
|
||||
String fxml,
|
||||
String path,
|
||||
String direction,
|
||||
String control,
|
||||
String property,
|
||||
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);
|
||||
}
|
||||
|
||||
private static Document parse(String xml) throws Exception {
|
||||
@@ -63,6 +81,7 @@ public final class BindingDiscovery {
|
||||
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
|
||||
try {
|
||||
load(c.getName() + "BeanInfo");
|
||||
incompleteModel = true;
|
||||
diagnostics.add("Custom BeanInfo requires generated metadata: " + c.getName());
|
||||
return List.of();
|
||||
} catch (ClassNotFoundException absent) {
|
||||
@@ -104,6 +123,7 @@ public final class BindingDiscovery {
|
||||
private void attributes(Class<?> type, String prefix, Set<Class<?>> ancestors, int depth)
|
||||
throws Exception {
|
||||
if (depth > 4 || ancestors.contains(type)) {
|
||||
incompleteModel = true;
|
||||
diagnostics.add("Nested path expansion stopped at " + prefix);
|
||||
return;
|
||||
}
|
||||
@@ -137,7 +157,13 @@ public final class BindingDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
private BindingMetadata inspect(String xml, String selected, String direction) throws Exception {
|
||||
private BindingMetadata inspect(
|
||||
String xml,
|
||||
String selected,
|
||||
String direction,
|
||||
String selectedControl,
|
||||
String selectedProperty)
|
||||
throws Exception {
|
||||
Document doc = parse(xml);
|
||||
String model = model(doc);
|
||||
if (model.isEmpty())
|
||||
@@ -149,7 +175,11 @@ public final class BindingDiscovery {
|
||||
List.of(),
|
||||
List.of(
|
||||
"Declare this View's Model with <?neitzel-model package.Model?> before discovering"
|
||||
+ " bindings."));
|
||||
+ " bindings."),
|
||||
List.of(),
|
||||
List.of(
|
||||
new Problem(
|
||||
"", "", "WARNING", "No Model declaration; semantic validation unavailable")));
|
||||
attributes(load(model), "", Set.of(), 0);
|
||||
if (selected.isEmpty())
|
||||
selected =
|
||||
@@ -196,6 +226,9 @@ public final class BindingDiscovery {
|
||||
value = load(componentModel);
|
||||
constrained = true;
|
||||
}
|
||||
targetTypes.put(
|
||||
id + "." + property,
|
||||
new TargetType(value, Property.class.isAssignableFrom(m.getReturnType())));
|
||||
String status, reason;
|
||||
if (!Property.class.isAssignableFrom(m.getReturnType())) {
|
||||
status = "INCOMPATIBLE";
|
||||
@@ -245,7 +278,129 @@ public final class BindingDiscovery {
|
||||
.indexOf(t.status()))
|
||||
.thenComparing(Target::control)
|
||||
.thenComparing(Target::property));
|
||||
return new BindingMetadata(model, selected, direction, attributes, targets, diagnostics);
|
||||
var converters = new ConverterDiscovery(loader);
|
||||
var chosen = targetTypes.get(selectedControl + "." + selectedProperty);
|
||||
var candidates =
|
||||
bypassesConverter(selected)
|
||||
? List.<BindingMetadata.Converter>of()
|
||||
: converters.candidates(source, chosen == null ? null : chosen.type(), direction);
|
||||
return new BindingMetadata(
|
||||
model,
|
||||
selected,
|
||||
direction,
|
||||
attributes,
|
||||
targets,
|
||||
diagnostics,
|
||||
candidates,
|
||||
validate(doc, imports, ids, converters));
|
||||
}
|
||||
|
||||
private boolean bypassesConverter(String path) {
|
||||
return attributes.stream()
|
||||
.anyMatch(a -> a.path().equals(path) && Set.of("COMPLEX", "COLLECTION").contains(a.kind()));
|
||||
}
|
||||
|
||||
private List<Problem> validate(
|
||||
Document doc, List<String> imports, Set<String> ids, ConverterDiscovery converters)
|
||||
throws Exception {
|
||||
var problems = new ArrayList<Problem>();
|
||||
var occupied = new HashSet<String>();
|
||||
var nodes = doc.getElementsByTagName("*");
|
||||
for (int i = 0; i < nodes.getLength(); i++) {
|
||||
var e = (Element) nodes.item(i);
|
||||
if (!library(e, "DirectBinding", imports) && !library(e, "ComputedBinding", imports))
|
||||
continue;
|
||||
Node parent = e.getParentNode();
|
||||
if (parent instanceof Element p && p.getTagName().equals("elements"))
|
||||
parent = p.getParentNode();
|
||||
if (!(parent instanceof Element list)
|
||||
|| !library(list, "BindingList", imports)
|
||||
|| !(list.getParentNode() instanceof Element define)
|
||||
|| !FX.equals(define.getNamespaceURI())
|
||||
|| !"define".equals(define.getLocalName())
|
||||
|| define.getParentNode() != doc.getDocumentElement()) continue;
|
||||
String control = e.getAttribute("control"),
|
||||
property = e.getAttribute("property"),
|
||||
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;
|
||||
if (nested) {
|
||||
problems.add(
|
||||
new Problem(control, property, "WARNING", "Nested declaration requires Source review"));
|
||||
continue;
|
||||
}
|
||||
String path = e.getAttribute("path"),
|
||||
direction = e.getAttribute("direction"),
|
||||
converter = e.getAttribute("converter");
|
||||
if (!converter.isEmpty() && bypassesConverter(path)) {
|
||||
problems.add(
|
||||
new Problem(
|
||||
control,
|
||||
property,
|
||||
"WARNING",
|
||||
"Runtime bypasses Converters for complex and collection Attributes"));
|
||||
converter = "";
|
||||
}
|
||||
String severity = "", message = "";
|
||||
Class<?> source = paths.get(path);
|
||||
TargetType target = targetTypes.get(key);
|
||||
if (!Set.of("view", "model", "both").contains(direction)) {
|
||||
severity = "ERROR";
|
||||
message = "Invalid direction";
|
||||
} else if (!ids.contains(control)) {
|
||||
severity = "ERROR";
|
||||
message = "Unknown control: " + control;
|
||||
} else if (target == null) {
|
||||
severity =
|
||||
diagnostics.stream().anyMatch(d -> d.startsWith(control + ":")) ? "WARNING" : "ERROR";
|
||||
message = "No discoverable JavaFX property: " + key;
|
||||
} else if (!target.writable()) {
|
||||
severity = "ERROR";
|
||||
message = "Runtime requires a writable JavaFX Property";
|
||||
} else if (source == null) {
|
||||
severity = incompleteModel ? "WARNING" : "ERROR";
|
||||
message = "Unknown or unresolved Model path: " + path;
|
||||
} else if (!converter.isEmpty()) {
|
||||
var c = converters.check(converter, source, target.type(), direction);
|
||||
if (!c.status().equals("COMPATIBLE")) {
|
||||
severity = c.status().equals("UNVERIFIED") ? "WARNING" : "ERROR";
|
||||
message = converter + ": " + c.reason();
|
||||
}
|
||||
} else if (target.type() == null
|
||||
|| source == Object.class
|
||||
|| target.type() == Object.class
|
||||
|| Collection.class.isAssignableFrom(source)
|
||||
|| Collection.class.isAssignableFrom(target.type())
|
||||
|| Map.class.isAssignableFrom(source)
|
||||
|| Map.class.isAssignableFrom(target.type())) {
|
||||
severity = "WARNING";
|
||||
message = "Value types require additional metadata";
|
||||
} else {
|
||||
Class<?> a = box(source), b = box(target.type());
|
||||
if ((!direction.equals("model") && !b.isAssignableFrom(a))
|
||||
|| (!direction.equals("view") && !a.isAssignableFrom(b))) {
|
||||
severity = "ERROR";
|
||||
message = "Incompatible value types; select a compatible Converter";
|
||||
}
|
||||
}
|
||||
if (!severity.isEmpty()) problems.add(new Problem(control, property, severity, message));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
private boolean library(Element e, String simple, List<String> imports) {
|
||||
String name = "de.neitzel.fx.view." + simple;
|
||||
if (e.getTagName().equals(name)) return true;
|
||||
if (!e.getTagName().equals(simple)) return false;
|
||||
try {
|
||||
return resolve(e, imports).getName().equals(name);
|
||||
} catch (Exception | LinkageError ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean defined(Element e) {
|
||||
@@ -310,7 +465,7 @@ public final class BindingDiscovery {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Class<?> box(Class<?> c) {
|
||||
static Class<?> box(Class<?> c) {
|
||||
if (!c.isPrimitive()) return c;
|
||||
return Map.<Class<?>, Class<?>>of(
|
||||
int.class,
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package de.neitzel.fxdesigner.target;
|
||||
|
||||
import de.neitzel.fx.view.Converter;
|
||||
import de.neitzel.fxdesigner.preview.ConverterCapabilities;
|
||||
import de.neitzel.fxdesigner.protocol.BindingMetadata;
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
/** Reads class headers before loading only Converter descendants, always without initialization. */
|
||||
final class ConverterDiscovery {
|
||||
private final ClassLoader loader;
|
||||
private int indexed;
|
||||
private final SortedSet<String> names = new TreeSet<>();
|
||||
private final Map<String, List<String>> hierarchy = new HashMap<>();
|
||||
|
||||
ConverterDiscovery(ClassLoader loader) throws IOException {
|
||||
this.loader = loader;
|
||||
var entries = new LinkedHashSet<String>();
|
||||
for (String key : List.of("java.class.path", "jdk.module.path"))
|
||||
entries.addAll(Arrays.asList(System.getProperty(key, "").split(File.pathSeparator)));
|
||||
for (String entry : entries) {
|
||||
if (entry.isEmpty()) continue;
|
||||
Path path = Path.of(entry);
|
||||
if (Files.isDirectory(path)) {
|
||||
try (var files = Files.walk(path)) {
|
||||
var it = files.filter(p -> p.toString().endsWith(".class")).iterator();
|
||||
while (it.hasNext())
|
||||
try (var in = Files.newInputStream(it.next())) {
|
||||
header(in);
|
||||
}
|
||||
}
|
||||
} else if (Files.isRegularFile(path) && entry.endsWith(".jar")) {
|
||||
try (var jar = new JarFile(path.toFile(), false, JarFile.OPEN_READ, Runtime.version())) {
|
||||
var it = jar.versionedStream().filter(e -> e.getName().endsWith(".class")).iterator();
|
||||
while (it.hasNext())
|
||||
try (var in = jar.getInputStream(it.next())) {
|
||||
header(in);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String name : hierarchy.keySet())
|
||||
if (descendant(name, new HashSet<>()) && !name.equals(Converter.class.getName()))
|
||||
names.add(name);
|
||||
if (names.size() > 256) throw new IOException("Too many Converter candidates");
|
||||
}
|
||||
|
||||
private boolean descendant(String name, Set<String> visiting) {
|
||||
if (name.equals(Converter.class.getName())) return true;
|
||||
if (!visiting.add(name)) return false;
|
||||
return hierarchy.getOrDefault(name, List.of()).stream().anyMatch(p -> descendant(p, visiting));
|
||||
}
|
||||
|
||||
private void header(InputStream stream) throws IOException {
|
||||
if (++indexed > 100000) throw new IOException("Converter class index exceeds limit");
|
||||
var in = new DataInputStream(new BufferedInputStream(stream));
|
||||
if (in.readInt() != 0xcafebabe) throw new IOException("Invalid class header");
|
||||
in.readUnsignedShort();
|
||||
in.readUnsignedShort();
|
||||
Object[] cp = new Object[in.readUnsignedShort()];
|
||||
for (int i = 1; i < cp.length; i++)
|
||||
switch (in.readUnsignedByte()) {
|
||||
case 1 -> cp[i] = in.readUTF();
|
||||
case 7 -> cp[i] = in.readUnsignedShort();
|
||||
case 3, 4, 9, 10, 11, 12, 17, 18 -> in.skipNBytes(4);
|
||||
case 5, 6 -> {
|
||||
in.skipNBytes(8);
|
||||
i++;
|
||||
}
|
||||
case 8, 16, 19, 20 -> in.skipNBytes(2);
|
||||
case 15 -> in.skipNBytes(3);
|
||||
default -> throw new IOException("Unsupported constant pool entry");
|
||||
}
|
||||
in.readUnsignedShort();
|
||||
String name = name(cp, in.readUnsignedShort());
|
||||
int parent = in.readUnsignedShort();
|
||||
var parents = new ArrayList<String>();
|
||||
if (parent != 0) parents.add(name(cp, parent));
|
||||
int n = in.readUnsignedShort();
|
||||
for (int i = 0; i < n; i++) parents.add(name(cp, in.readUnsignedShort()));
|
||||
hierarchy.putIfAbsent(name, parents);
|
||||
}
|
||||
|
||||
private static String name(Object[] cp, int index) {
|
||||
return ((String) cp[(Integer) cp[index]]).replace('/', '.');
|
||||
}
|
||||
|
||||
List<BindingMetadata.Converter> candidates(Class<?> model, Class<?> control, String direction) {
|
||||
return names.stream().map(n -> check(n, model, control, direction)).toList();
|
||||
}
|
||||
|
||||
BindingMetadata.Converter check(String name, Class<?> model, Class<?> control, String direction) {
|
||||
try {
|
||||
Class<?> type = Class.forName(name, false, loader);
|
||||
if (!Converter.class.isAssignableFrom(type)
|
||||
|| type.isInterface()
|
||||
|| Modifier.isAbstract(type.getModifiers())
|
||||
|| !Modifier.isPublic(type.getModifiers()))
|
||||
return result(name, "ERROR", "Converter must be a public concrete implementation");
|
||||
var meta = type.getDeclaredAnnotation(ConverterCapabilities.class);
|
||||
if (meta == null)
|
||||
return result(name, "UNVERIFIED", "No capabilities declared on this concrete class");
|
||||
if (meta.reverse()
|
||||
&& type.getMethod("toAttributeValue", Object.class).getDeclaringClass()
|
||||
== Converter.class)
|
||||
return result(
|
||||
name,
|
||||
"ERROR",
|
||||
"Reverse conversion declared but default throwing implementation inherited");
|
||||
if (meta.modelType() == void.class || meta.controlType() == void.class)
|
||||
return result(name, "ERROR", "Converter value types cannot be void");
|
||||
if (!direction.equals("view") && !meta.reverse())
|
||||
return result(name, "INCOMPATIBLE", "This direction requires reverse conversion");
|
||||
if (model == null || control == null || model == Object.class || control == Object.class)
|
||||
return result(name, "UNVERIFIED", "Binding value types are unresolved");
|
||||
Class<?> m = BindingDiscovery.box(model),
|
||||
c = BindingDiscovery.box(control),
|
||||
a = BindingDiscovery.box(meta.modelType()),
|
||||
b = BindingDiscovery.box(meta.controlType());
|
||||
// Forward conversion is also used for initial value in model-direction bindings.
|
||||
boolean ok = a.isAssignableFrom(m) && c.isAssignableFrom(b);
|
||||
if (!direction.equals("view")) ok &= b.isAssignableFrom(c) && m.isAssignableFrom(a);
|
||||
return result(
|
||||
name,
|
||||
ok ? "COMPATIBLE" : "INCOMPATIBLE",
|
||||
ok ? "Declared types and direction match" : "Declared types do not support this binding");
|
||||
} catch (ReflectiveOperationException | LinkageError | RuntimeException e) {
|
||||
return result(name, "ERROR", "Converter metadata unavailable: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static BindingMetadata.Converter result(String n, String s, String r) {
|
||||
return new BindingMetadata.Converter(n, s, r);
|
||||
}
|
||||
}
|
||||
@@ -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-v1"),
|
||||
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v2"),
|
||||
Set.of()));
|
||||
var welcome = FrameCodec.read(socket.getInputStream());
|
||||
if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1)
|
||||
@@ -115,11 +115,16 @@ 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")))
|
||||
if (!input.keySet().equals(Set.of("fxml", "path", "direction", "control", "property")))
|
||||
throw new IOException("Invalid binding discovery input");
|
||||
var metadata =
|
||||
BindingDiscovery.inspect(
|
||||
input.get("fxml"), input.get("path"), input.get("direction"), loader);
|
||||
input.get("fxml"),
|
||||
input.get("path"),
|
||||
input.get("direction"),
|
||||
input.get("control"),
|
||||
input.get("property"),
|
||||
loader);
|
||||
send(
|
||||
socket,
|
||||
new Packet(
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package de.neitzel.fxdesigner.target;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import de.neitzel.fx.view.Converter;
|
||||
import de.neitzel.fxdesigner.preview.ConverterCapabilities;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ConverterDiscoveryTest {
|
||||
@ConverterCapabilities(modelType = Integer.class, controlType = String.class, reverse = true)
|
||||
public static class Valid implements Converter {
|
||||
static {
|
||||
System.setProperty("converter.initialized", "yes");
|
||||
}
|
||||
|
||||
public Valid() {
|
||||
throw new AssertionError("constructed");
|
||||
}
|
||||
|
||||
public Object toControlValue(Object value) {
|
||||
throw new AssertionError("invoked");
|
||||
}
|
||||
|
||||
public Object toAttributeValue(Object value) {
|
||||
throw new AssertionError("invoked");
|
||||
}
|
||||
}
|
||||
|
||||
public static class Legacy extends Valid {}
|
||||
|
||||
@ConverterCapabilities(modelType = Integer.class, controlType = String.class, reverse = true)
|
||||
public static class Broken implements Converter {
|
||||
public Object toControlValue(Object v) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ConverterCapabilities(modelType = Integer.class, controlType = String.class)
|
||||
public static class Forward implements Converter {
|
||||
public Object toControlValue(Object v) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ConverterDiscovery discovery() throws Exception {
|
||||
return new ConverterDiscovery(getClass().getClassLoader());
|
||||
}
|
||||
|
||||
@Test
|
||||
void discoveryNeverInitializesOrConstructsAndDoesNotInheritCapabilities() throws Exception {
|
||||
var d = discovery();
|
||||
assertTrue(
|
||||
d.candidates(int.class, String.class, "both").stream()
|
||||
.anyMatch(
|
||||
c -> c.name().equals(Valid.class.getName()) && c.status().equals("COMPATIBLE")));
|
||||
assertEquals(
|
||||
"UNVERIFIED", d.check(Legacy.class.getName(), int.class, String.class, "both").status());
|
||||
assertNull(System.getProperty("converter.initialized"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reverseContractAndConcreteTypeAreChecked() throws Exception {
|
||||
var d = discovery();
|
||||
assertEquals(
|
||||
"ERROR", d.check(Broken.class.getName(), int.class, String.class, "view").status());
|
||||
assertEquals(
|
||||
"ERROR", d.check(String.class.getName(), int.class, String.class, "both").status());
|
||||
assertEquals("ERROR", d.check("missing.Converter", int.class, String.class, "both").status());
|
||||
assertEquals(
|
||||
"INCOMPATIBLE",
|
||||
d.check(Forward.class.getName(), int.class, String.class, "model").status());
|
||||
assertEquals(
|
||||
"COMPATIBLE", d.check(Forward.class.getName(), int.class, String.class, "view").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardInitializationIsRequiredEvenForModelDirection() throws Exception {
|
||||
var d = discovery();
|
||||
assertEquals(
|
||||
"INCOMPATIBLE",
|
||||
d.check(Valid.class.getName(), Number.class, String.class, "model").status());
|
||||
assertEquals(
|
||||
"COMPATIBLE",
|
||||
d.check(Valid.class.getName(), int.class, CharSequence.class, "view").status());
|
||||
assertEquals(
|
||||
"INCOMPATIBLE",
|
||||
d.check(Valid.class.getName(), int.class, CharSequence.class, "both").status());
|
||||
}
|
||||
|
||||
private String xml(String path, String property, String converter) {
|
||||
return "<?neitzel-model "
|
||||
+ BindingDiscoveryTest.Model.class.getName()
|
||||
+ "?><?import javafx.scene.control.TextField?><TextField"
|
||||
+ " xmlns:fx='http://javafx.com/fxml/1'"
|
||||
+ " fx:id='input'><fx:define><de.neitzel.fx.view.BindingList><de.neitzel.fx.view.DirectBinding"
|
||||
+ " control='input' property='"
|
||||
+ property
|
||||
+ "' path='"
|
||||
+ path
|
||||
+ "' direction='both' converter='"
|
||||
+ converter
|
||||
+ "'/>"
|
||||
+ "</de.neitzel.fx.view.BindingList></fx:define></TextField>";
|
||||
}
|
||||
|
||||
private de.neitzel.fxdesigner.protocol.BindingMetadata inspect(
|
||||
String path, String property, String converter) throws Exception {
|
||||
return BindingDiscovery.inspect(
|
||||
xml(path, property, converter),
|
||||
"rank",
|
||||
"both",
|
||||
"input",
|
||||
"text",
|
||||
getClass().getClassLoader());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesPathsPropertiesAndConverterContracts() throws Exception {
|
||||
assertTrue(inspect("rank", "text", Valid.class.getName()).problems().isEmpty());
|
||||
assertTrue(
|
||||
BindingDiscovery.inspect(
|
||||
xml("absent", "text", ""),
|
||||
"absent",
|
||||
"both",
|
||||
"input",
|
||||
"text",
|
||||
getClass().getClassLoader())
|
||||
.problems()
|
||||
.stream()
|
||||
.anyMatch(p -> p.severity().equals("ERROR")));
|
||||
for (var input :
|
||||
List.of(
|
||||
new String[] {"absent", "text", ""},
|
||||
new String[] {"rank", "absent", ""},
|
||||
new String[] {"rank", "width", ""},
|
||||
new String[] {"rank", "text", ""},
|
||||
new String[] {"rank", "text", Broken.class.getName()}))
|
||||
assertTrue(
|
||||
inspect(input[0], input[1], input[2]).problems().stream()
|
||||
.anyMatch(p -> p.severity().equals("ERROR")),
|
||||
Arrays.toString(input));
|
||||
assertEquals(
|
||||
"WARNING",
|
||||
inspect("rank", "text", Legacy.class.getName()).problems().getFirst().severity());
|
||||
assertNull(System.getProperty("converter.initialized"));
|
||||
}
|
||||
}
|
||||
@@ -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, 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; generated metadata and the complete inspector remain subsequent work.
|
||||
|
||||
## Use
|
||||
|
||||
@@ -12,13 +12,13 @@ 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-v1` 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-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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Compatibility uses boxed declared types and target-runtime assignability: `view` tests Model-to-control, `model` the reverse, and `both` requires both. No implicit numeric/string conversion is assumed. Type mismatches require a compatible Converter; Converter availability/signatures are not yet discovered. Unknown Object types and collection variance remain unresolved, rather than being advertised as verified bindings. Results are potential type matches, not a check that a target is currently unbound or that all runtime access/injection conditions hold.
|
||||
Compatibility uses boxed declared types and target-runtime assignability: `view` tests Model-to-control, `model` the reverse, and `both` requires both. No implicit numeric/string conversion is assumed. Type mismatches require a compatible Converter; Converter availability and optional capabilities are checked by the DirectBinding inspector. Unknown Object types and collection variance remain unresolved, rather than being advertised as verified bindings. Results are potential type matches, not a check that a target is currently unbound or that all runtime access/injection conditions hold.
|
||||
|
||||
## Freshness and limits
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Converter discovery and binding validation
|
||||
|
||||
Both hosts use the same **Bindings → Direct bindings → Find Converters / Validate** action. It checks the current document and lists Converter candidates for the form's Model path, target property and direction. Choose a candidate or keep an explicit class name, review the form, then **Apply binding**. Discovery and validation never edit FXML. Apply uses the existing source-preserving command and host Undo/Redo.
|
||||
|
||||
A single compatible candidate is proposed only when the Converter field is empty. Multiple matches require a choice. Explicit selections, including unannotated classes, are retained. Form changes reject obsolete suggestions; document changes and builds invalidate validation. Results identify the draft revision and explicitly retained builds. Validation uses a separate target worker and leaves existing interactive sessions alone. New preview requests validate the current parent document before launching; known errors block the request, while warnings remain visible and saving stays available.
|
||||
|
||||
## Optional capabilities
|
||||
|
||||
The application-facing `designer-preview-api` artifact supplies a runtime-retained annotation with no JavaFX, Kit or IntelliJ dependency:
|
||||
|
||||
```java
|
||||
@ConverterCapabilities(modelType = Integer.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
public class RankConverter implements de.neitzel.fx.view.Converter {
|
||||
// Implement toControlValue and toAttributeValue.
|
||||
}
|
||||
```
|
||||
|
||||
Import `de.neitzel.fxdesigner.preview.ConverterCapabilities`. Named modules using this annotation require `de.neitzel.fxdesigner.preview`. The annotation is tooling metadata; NeitzelLib's Converter interface and runtime behavior remain unchanged. No new NeitzelLib artifact is required.
|
||||
|
||||
Metadata must be declared on the concrete class. An unannotated subclass does not inherit verified capabilities. Forward conversion is always required, including initialization of a converted `model` binding. `model` and `both` additionally require reverse conversion. Declaring reverse support while inheriting the interface's default throwing method is an error. Declared input/output types are checked with boxing and target-runtime assignability in each required direction. Plain bindings retain NeitzelLib's separate direction rules. NeitzelLib bypasses Converters for complex and collection Attributes; those paths get no proposals, and an explicit Converter produces a warning rather than a false compatibility claim.
|
||||
|
||||
The chooser distinguishes compatible, incompatible, unverified and invalid candidates. Metadata is a declared contract, not a proof of implementation behavior. Unannotated Converters remain usable with a warning. Target previews still exercise conversion, and the real fixtures test conversion failure/value preservation and recovery through NeitzelLib's Message behavior.
|
||||
|
||||
## Worker and validation boundary
|
||||
|
||||
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 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.
|
||||
|
||||
## Automated qualification
|
||||
|
||||
Unit tests cover non-initialization, inherited/unannotated metadata, reverse-method inconsistencies, assignability, the special converted model-direction initialization rule, path/property errors and metadata round trips. Real classpath and named-module fixtures exercise discovery and validate the same application declarations without initializing Converters. Shared live-UI checks cover explicit preservation, a unique proposal requiring Apply, ambiguous matches requiring selection, unverified warnings, type-error preview blocking and source-preserving Undo/Redo in standalone and registered IntelliJ. Existing rendering, input and worker lifecycle checks remain enabled. No manual review is required for this slice; earlier skipped manual checks retain their recorded disposition.
|
||||
|
||||
Qualification on 2026-11-13 passed: 79 Maven tests and 7 IntelliJ adapter tests; 34 target processes with 438 fixture assertions; 48 worker lifecycle cycles; connected workflows and live shared UI in standalone and registered IntelliJ for classpath and module-path applications. Native isolation, worker shutdown, bytecode/package boundaries and documentation checks passed. Final host logs contain no uncaught exceptions and no new native crash report appeared. The live regression exposed and fixed an automatic canvas-refresh race that replaced an in-flight chooser request; pending validation now also satisfies that preview request. Local evidence: `/tmp/converter-bootstrap.log`, `/tmp/converter-registered-ide.log`, and `/tmp/converter-protocol-test.log` (including rejection of the old binding capability).
|
||||
@@ -14,9 +14,9 @@ A target occupied by another DirectBinding or ComputedBinding requires **Replace
|
||||
|
||||
## Validation boundary
|
||||
|
||||
Structural diagnostics cover duplicate targets, missing/ambiguous fx:id, empty property, malformed/missing Model path and invalid direction. These known errors block a new canvas/interactive preview request while allowing save and source repair. Type compatibility remains the separate discovery result; the editor does not yet prove path existence, Converter compatibility or runtime property availability. A syntactically valid but incompatible declaration can therefore still fail in the target runtime.
|
||||
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.
|
||||
|
||||
Converter names are preserved/editable text, not a discovered or verified Converter chooser. Generated/custom ViewModel metadata, Computed/Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work.
|
||||
Generated/custom ViewModel metadata, Computed/Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work.
|
||||
|
||||
## Automated qualification
|
||||
|
||||
|
||||
@@ -105,3 +105,7 @@ Manual review disposition (2026-11-13): the user accepted proceeding after the r
|
||||
## Shared discovery: first stage-4 slice
|
||||
|
||||
Implemented the [read-only binding browser](binding-discovery.md) in the shared Kit UI: target-side Person Attribute/path and collection-element discovery, Component Model declarations and direction-aware binding-target compatibility. Real classpath and module-path fixtures qualify both hosts. The [first editable DirectBinding inspector](direct-binding-inspector.md) adds creation, changing, removal and explicit conflict replacement through one source-preserving host undo command per operation. Remaining metadata categories and broader binding authoring retain their subsequent work packages.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
</parent>
|
||||
<artifactId>app-classpath</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>de.neitzel.fxdesigner</groupId>
|
||||
<artifactId>designer-preview-api</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>de.neitzel.lib</groupId>
|
||||
<artifactId>fx</artifactId>
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = Integer.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
public class BrokenReverseConverter implements de.neitzel.fx.view.Converter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "BrokenReverseConverter");
|
||||
}
|
||||
|
||||
public BrokenReverseConverter() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
|
||||
public Object toControlValue(Object value) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = Integer.class,
|
||||
controlType = String.class)
|
||||
public class ForwardRankConverter implements de.neitzel.fx.view.Converter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "ForwardRankConverter");
|
||||
}
|
||||
|
||||
public ForwardRankConverter() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
|
||||
public Object toControlValue(Object value) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package fixture.components;
|
||||
|
||||
public class LegacyRankConverter extends RankConverter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "LegacyRankConverter");
|
||||
}
|
||||
|
||||
public LegacyRankConverter() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = String.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
public class NameConverterA extends RankConverter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "NameConverterA");
|
||||
}
|
||||
|
||||
public NameConverterA() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = String.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
public class NameConverterB extends RankConverter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "NameConverterB");
|
||||
}
|
||||
|
||||
public NameConverterB() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
}
|
||||
+4
@@ -1,5 +1,9 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = Integer.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
@jakarta.inject.Singleton
|
||||
public class RankConverter implements de.neitzel.fx.view.Converter {
|
||||
public Object toControlValue(Object value) {
|
||||
|
||||
+39
@@ -90,6 +90,45 @@ public final class PreviewChecks {
|
||||
check(
|
||||
System.getProperty("fixture.provider.initialized") == null,
|
||||
"Discovery initialized provider");
|
||||
try (var in = loader.getResourceAsStream("fixture/components/Full.fxml")) {
|
||||
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(),
|
||||
"Valid converter binding rejected: " + details.problems());
|
||||
for (var expected :
|
||||
java.util.Map.of(
|
||||
"RankConverter",
|
||||
"COMPATIBLE",
|
||||
"LegacyRankConverter",
|
||||
"UNVERIFIED",
|
||||
"BrokenReverseConverter",
|
||||
"ERROR",
|
||||
"ForwardRankConverter",
|
||||
"INCOMPATIBLE")
|
||||
.entrySet())
|
||||
check(
|
||||
details.converters().stream()
|
||||
.anyMatch(
|
||||
c ->
|
||||
c.name().equals("fixture.components." + expected.getKey())
|
||||
&& c.status().equals(expected.getValue())),
|
||||
"Converter status: " + expected);
|
||||
var invalid =
|
||||
BindingDiscovery.inspect(
|
||||
full.replace(
|
||||
"converter=\"fixture.components.RankConverter\"",
|
||||
"converter=\"fixture.components.BrokenReverseConverter\""),
|
||||
"rank",
|
||||
"both",
|
||||
loader);
|
||||
check(
|
||||
invalid.problems().stream().anyMatch(v -> v.severity().equals("ERROR")),
|
||||
"Invalid reverse metadata accepted");
|
||||
check(
|
||||
System.getProperty("fixture.converter.initialized") == null,
|
||||
"Discovery initialized Converter");
|
||||
}
|
||||
System.out.println("PASS bindings " + checks);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
</parent>
|
||||
<artifactId>app-modulepath</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>de.neitzel.fxdesigner</groupId>
|
||||
<artifactId>designer-preview-api</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>de.neitzel.lib</groupId>
|
||||
<artifactId>fx</artifactId>
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = Integer.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
public class BrokenReverseConverter implements de.neitzel.fx.view.Converter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "BrokenReverseConverter");
|
||||
}
|
||||
|
||||
public BrokenReverseConverter() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
|
||||
public Object toControlValue(Object value) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = Integer.class,
|
||||
controlType = String.class)
|
||||
public class ForwardRankConverter implements de.neitzel.fx.view.Converter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "ForwardRankConverter");
|
||||
}
|
||||
|
||||
public ForwardRankConverter() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
|
||||
public Object toControlValue(Object value) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package fixture.components;
|
||||
|
||||
public class LegacyRankConverter extends RankConverter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "LegacyRankConverter");
|
||||
}
|
||||
|
||||
public LegacyRankConverter() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = String.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
public class NameConverterA extends RankConverter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "NameConverterA");
|
||||
}
|
||||
|
||||
public NameConverterA() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = String.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
public class NameConverterB extends RankConverter {
|
||||
static {
|
||||
System.setProperty("fixture.converter.initialized", "NameConverterB");
|
||||
}
|
||||
|
||||
public NameConverterB() {
|
||||
throw new AssertionError("Discovery must not construct Converters");
|
||||
}
|
||||
}
|
||||
+4
@@ -1,5 +1,9 @@
|
||||
package fixture.components;
|
||||
|
||||
@de.neitzel.fxdesigner.preview.ConverterCapabilities(
|
||||
modelType = Integer.class,
|
||||
controlType = String.class,
|
||||
reverse = true)
|
||||
@jakarta.inject.Singleton
|
||||
public class RankConverter implements de.neitzel.fx.view.Converter {
|
||||
public Object toControlValue(Object value) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
module fixture.app {
|
||||
requires fx;
|
||||
requires de.neitzel.fxdesigner.preview;
|
||||
requires injection;
|
||||
requires jakarta.inject;
|
||||
requires javafx.controls;
|
||||
|
||||
+39
@@ -90,6 +90,45 @@ public final class PreviewChecks {
|
||||
check(
|
||||
System.getProperty("fixture.provider.initialized") == null,
|
||||
"Discovery initialized provider");
|
||||
try (var in = loader.getResourceAsStream("fixture/components/Full.fxml")) {
|
||||
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(),
|
||||
"Valid converter binding rejected: " + details.problems());
|
||||
for (var expected :
|
||||
java.util.Map.of(
|
||||
"RankConverter",
|
||||
"COMPATIBLE",
|
||||
"LegacyRankConverter",
|
||||
"UNVERIFIED",
|
||||
"BrokenReverseConverter",
|
||||
"ERROR",
|
||||
"ForwardRankConverter",
|
||||
"INCOMPATIBLE")
|
||||
.entrySet())
|
||||
check(
|
||||
details.converters().stream()
|
||||
.anyMatch(
|
||||
c ->
|
||||
c.name().equals("fixture.components." + expected.getKey())
|
||||
&& c.status().equals(expected.getValue())),
|
||||
"Converter status: " + expected);
|
||||
var invalid =
|
||||
BindingDiscovery.inspect(
|
||||
full.replace(
|
||||
"converter=\"fixture.components.RankConverter\"",
|
||||
"converter=\"fixture.components.BrokenReverseConverter\""),
|
||||
"rank",
|
||||
"both",
|
||||
loader);
|
||||
check(
|
||||
invalid.problems().stream().anyMatch(v -> v.severity().equals("ERROR")),
|
||||
"Invalid reverse metadata accepted");
|
||||
check(
|
||||
System.getProperty("fixture.converter.initialized") == null,
|
||||
"Discovery initialized Converter");
|
||||
}
|
||||
System.out.println("PASS bindings " + checks);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user