Add shared source-preserving Computed binding inspector

This commit is contained in:
2026-11-13 13:42:19 +01:00
parent 99d93a11fc
commit 510ce35b60
12 changed files with 639 additions and 224 deletions
@@ -15,8 +15,7 @@ final class BindingBrowser extends VBox {
final Label status = new Label("Build and Refresh, then discover binding targets.");
private boolean updating;
BindingBrowser(
BiConsumer<String, String> discover, Consumer<DirectBindingDocument.Values> create) {
BindingBrowser(BiConsumer<String, String> discover, Consumer<BindingDocument.Values> create) {
setSpacing(5);
paths.setId("binding-path");
directions.setId("binding-direction");
@@ -40,7 +39,7 @@ final class BindingBrowser extends VBox {
var target = targets.getSelectionModel().getSelectedItem();
if (target != null)
create.accept(
new DirectBindingDocument.Values(
new BindingDocument.Values(
target.control(), target.property(), path(), directions.getValue(), ""));
});
paths.setOnAction(
@@ -4,15 +4,42 @@ import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.*;
import org.w3c.dom.*;
/** Direct binding edit intents translated into lexical ranges in one immutable source revision. */
final class DirectBindingDocument {
/** Binding edit intents translated into lexical ranges in one immutable source revision. */
final class BindingDocument {
static final String FX = "http://javafx.com/fxml/1", PACKAGE = "de.neitzel.fx.view.";
record Values(String control, String property, String path, String direction, String converter) {}
enum Kind {
DIRECT,
COMPUTED;
String element() {
return this == DIRECT ? "DirectBinding" : "ComputedBinding";
}
}
record Values(
String control,
String property,
String path,
String direction,
String converter,
String method) {
Values(String control, String property, String path, String direction, String converter) {
this(control, property, path, direction, converter, "");
}
static Values computed(String control, String property, String method) {
return new Values(control, property, "", "view", "", method);
}
}
record Entry(int index, Values values, boolean editable) {
public String toString() {
return values.control() + "." + values.property() + " ← " + values.path();
return values.control()
+ "."
+ values.property()
+ " ← "
+ (values.method().isEmpty() ? values.path() : values.method());
}
}
@@ -25,6 +52,7 @@ final class DirectBindingDocument {
}
}
final Kind kind;
final String text;
final Document document;
final XmlSource source;
@@ -33,7 +61,12 @@ final class DirectBindingDocument {
private final List<String> imports = new ArrayList<>();
private final Map<Element, Integer> indexes = new IdentityHashMap<>();
DirectBindingDocument(String text) throws Exception {
BindingDocument(String text) throws Exception {
this(text, Kind.DIRECT);
}
BindingDocument(String text, Kind kind) throws Exception {
this.kind = kind;
this.text = text;
document = FxmlProjection.parse(text);
source = new XmlSource(text);
@@ -49,7 +82,7 @@ final class DirectBindingDocument {
if (elements.size() != source.tags.size())
throw new IllegalArgumentException("XML source ranges do not match parsed elements");
for (var e : elements)
if (binding(e, "DirectBinding"))
if (binding(e, kind.element()))
entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e)));
}
@@ -81,7 +114,10 @@ final class DirectBindingDocument {
return named(e, name) && bindingList(e) != null;
}
private static Values values(Element e) {
private Values values(Element e) {
if (kind == Kind.COMPUTED)
return Values.computed(
e.getAttribute("control"), e.getAttribute("property"), e.getAttribute("method"));
return new Values(
e.getAttribute("control"),
e.getAttribute("property"),
@@ -120,6 +156,10 @@ final class DirectBindingDocument {
if (ids.getOrDefault(e.getAttribute("control"), 0) != 1)
errors.add("Missing or ambiguous control: " + e.getAttribute("control"));
if (e.getAttribute("property").isBlank()) errors.add("Missing property: " + target);
if (binding(e, "ComputedBinding")
&& !e.getAttribute("method")
.matches("#?[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*"))
errors.add("Invalid or missing Controller method: " + target);
if (binding(e, "DirectBinding")) {
if (!e.getAttribute("path")
.matches(
@@ -165,17 +205,25 @@ final class DirectBindingDocument {
if (original != null) {
var changed = new LinkedHashMap<String, String>();
Map<String, String> fields =
Map.of(
"control",
value.control(),
"property",
value.property(),
"path",
value.path(),
"direction",
value.direction(),
"converter",
value.converter());
kind == Kind.COMPUTED
? Map.of(
"control",
value.control(),
"property",
value.property(),
"method",
value.method())
: Map.of(
"control",
value.control(),
"property",
value.property(),
"path",
value.path(),
"direction",
value.direction(),
"converter",
value.converter());
for (var f : fields.entrySet())
if (!original.getAttribute(f.getKey()).equals(f.getValue()))
changed.put(
@@ -186,19 +234,25 @@ final class DirectBindingDocument {
String node =
"<"
+ PACKAGE
+ "DirectBinding control=\""
+ kind.element()
+ " control=\""
+ XmlSource.escape(value.control(), '"')
+ "\" property=\""
+ XmlSource.escape(value.property(), '"')
+ "\" path=\""
+ XmlSource.escape(value.path(), '"')
+ "\" direction=\""
+ XmlSource.escape(value.direction(), '"')
+ "\""
+ (value.converter().isEmpty()
? ""
: " converter=\"" + XmlSource.escape(value.converter(), '"') + "\"")
+ "/>";
+ "\"";
if (kind == Kind.COMPUTED)
node += " method=\"" + XmlSource.escape(value.method(), '"') + "\"";
else
node +=
" path=\""
+ XmlSource.escape(value.path(), '"')
+ "\" direction=\""
+ XmlSource.escape(value.direction(), '"')
+ "\""
+ (value.converter().isEmpty()
? ""
: " converter=\"" + XmlSource.escape(value.converter(), '"') + "\"");
node += "/>";
var lists = elements.stream().filter(this::list).toList();
if (!lists.isEmpty()) {
Element container = lists.getFirst();
@@ -6,19 +6,23 @@ import javafx.scene.control.*;
import javafx.scene.layout.*;
/** View-wide declarations and an explicit, revision-bound edit form shared by both hosts. */
final class DirectBindingEditor extends VBox {
final class BindingEditor extends VBox {
record Intent(
BindingDocument.Kind kind,
String source,
Integer index,
DirectBindingDocument.Values values,
BindingDocument.Values values,
boolean remove,
boolean replace) {}
private final ListView<DirectBindingDocument.Entry> bindings = new ListView<>();
private final ListView<BindingDocument.Entry> bindings = new ListView<>();
private final TextField control = field("control"),
property = field("property"),
path = field("path"),
converter = field("converter");
converter = field("converter"),
method = field("method");
private final ComboBox<BindingMetadata.Computed> methods = new ComboBox<>();
private final BindingDocument.Kind kind;
private final ComboBox<BindingMetadata.Converter> converters = new ComboBox<>();
private final Button inspect = new Button("Find Converters / Validate");
private final ComboBox<String> direction = new ComboBox<>();
@@ -32,11 +36,30 @@ final class DirectBindingEditor extends VBox {
private boolean loading, writable, active, followingCanvas;
private final Consumer<String> navigate;
DirectBindingEditor(
BindingEditor(
BindingDocument.Kind kind,
Consumer<Intent> edit,
Consumer<String> navigate,
Consumer<DirectBindingDocument.Values> discover) {
Consumer<BindingDocument.Values> discover) {
this.kind = kind;
boolean computed = kind == BindingDocument.Kind.COMPUTED;
String prefix = computed ? "computed-" : "direct-";
this.navigate = navigate;
for (var field : java.util.List.of(control, property, path, converter, method))
field.setId(prefix + field.getId());
methods.setId("computed-sources");
methods.setPrefWidth(680);
methods.setOnAction(
e -> {
var m = methods.getValue();
if (m != null) method.setText(m.method());
});
if (computed) {
inspect.setText("Find methods / Validate");
fresh.setText("New Computed binding");
apply.setText("Apply Computed binding");
remove.setText("Remove Computed binding");
}
setSpacing(5);
converters.setId("direct-converters");
converters.setPrefWidth(680);
@@ -49,18 +72,24 @@ final class DirectBindingEditor extends VBox {
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.setId(prefix + "bindings");
bindings.setPrefHeight(100);
direction.setId("direct-direction");
direction.getItems().setAll("view", "model", "both");
direction.setValue("both");
replace.setId("direct-replace");
status.setId("direct-status");
replace.setId(prefix + "replace");
status.setId(prefix + "status");
status.setWrapText(true);
fresh.setOnAction(e -> newBinding(new DirectBindingDocument.Values("", "", "", "both", "")));
fresh.setOnAction(
e ->
newBinding(
computed
? BindingDocument.Values.computed("", "", "")
: new BindingDocument.Values("", "", "", "both", "")));
apply.setOnAction(
e -> edit.accept(new Intent(source, selected, values(), false, replace.isSelected())));
remove.setOnAction(e -> edit.accept(new Intent(source, selected, values(), true, false)));
e ->
edit.accept(new Intent(kind, source, selected, values(), false, replace.isSelected())));
remove.setOnAction(e -> edit.accept(new Intent(kind, source, selected, values(), true, false)));
bindings
.getSelectionModel()
.selectedItemProperty()
@@ -81,20 +110,28 @@ final class DirectBindingEditor extends VBox {
var grid = new GridPane();
grid.setHgap(8);
grid.setVgap(4);
grid.addRow(
0,
new Label("Control (fx:id)"),
control,
new Label("Property"),
property,
new Label("Direction"),
direction);
grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter);
if (!computed)
grid.addRow(
0,
new Label("Control (fx:id)"),
control,
new Label("Property"),
property,
new Label("Direction"),
direction);
if (!computed)
grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter);
else {
grid.addRow(0, new Label("Control (fx:id)"), control, new Label("Property"), property);
grid.addRow(1, new Label("Controller method"), method);
for (var field : java.util.List.of(control, property))
field.textProperty().addListener((o, a, b) -> methods.getItems().clear());
}
getChildren()
.addAll(
bindings,
grid,
new HBox(8, inspect, converters),
new HBox(8, inspect, computed ? methods : converters),
new HBox(8, fresh, apply, remove, replace),
status);
buttons();
@@ -102,13 +139,16 @@ final class DirectBindingEditor extends VBox {
private static TextField field(String name) {
var f = new TextField();
f.setId("direct-" + name);
f.setId(name);
f.setPrefColumnCount(14);
return f;
}
private DirectBindingDocument.Values values() {
return new DirectBindingDocument.Values(
private BindingDocument.Values values() {
if (kind == BindingDocument.Kind.COMPUTED)
return BindingDocument.Values.computed(
control.getText().trim(), property.getText().trim(), method.getText().trim());
return new BindingDocument.Values(
control.getText().trim(),
property.getText().trim(),
path.getText().trim(),
@@ -116,15 +156,16 @@ final class DirectBindingEditor extends VBox {
converter.getText().trim());
}
private void fill(DirectBindingDocument.Values v) {
private void fill(BindingDocument.Values v) {
control.setText(v.control());
property.setText(v.property());
path.setText(v.path());
direction.setValue(v.direction());
converter.setText(v.converter());
method.setText(v.method());
}
void newBinding(DirectBindingDocument.Values v) {
void newBinding(BindingDocument.Values v) {
loading = true;
bindings.getSelectionModel().clearSelection();
loading = false;
@@ -144,19 +185,21 @@ final class DirectBindingEditor extends VBox {
void load(String text, boolean editable) {
if (source.equals(text) && writable == editable) return;
converters.getItems().clear();
methods.getItems().clear();
source = text;
writable = editable;
loading = true;
selected = null;
active = false;
try {
var doc = new DirectBindingDocument(text);
var doc = new BindingDocument(text, kind);
bindings.getItems().setAll(doc.entries);
bindings.getSelectionModel().clearSelection();
status.setText(
doc.errors().isEmpty()
? "Select a DirectBinding or choose New binding. Use Find Converters / Validate to"
+ " check this draft."
? "Select a "
+ kind.element()
+ " or create a new binding. Validate to check this draft."
: String.join("\n", doc.errors()));
} catch (Exception error) {
bindings.getItems().clear();
@@ -186,8 +229,7 @@ final class DirectBindingEditor extends VBox {
});
}
void metadata(
BindingMetadata metadata, DirectBindingDocument.Values requested, String provenance) {
void metadata(BindingMetadata metadata, BindingDocument.Values requested, String provenance) {
status.setText(
provenance
+ " · ViewModel: "
@@ -209,6 +251,10 @@ final class DirectBindingEditor extends VBox {
+ p.message())
.collect(java.util.stream.Collectors.joining("\n"))));
if (requested == null || !values().equals(requested)) return;
if (kind == BindingDocument.Kind.COMPUTED) {
methods.getItems().setAll(metadata.computed());
return;
}
converters.getItems().setAll(metadata.converters());
var matches =
metadata.converters().stream().filter(c -> c.status().equals("COMPATIBLE")).toList();
@@ -43,6 +43,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (hit == null) return;
String id = hit.getFxId();
if (bindingEditor != null) bindingEditor.selectControl(id);
if (computedEditor != null) computedEditor.selectControl(id);
if (id == null || !projection.components().containsKey(id)) return;
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
componentInspector
@@ -114,7 +115,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private DesignerWorkflow interactiveWorkflow;
private DesignerWorkflow bindingWorkflow;
private BindingBrowser bindingBrowser;
private DirectBindingEditor bindingEditor;
private BindingEditor bindingEditor, computedEditor;
private String validatedSource, validatingSource;
private boolean validationPreview;
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
@@ -180,7 +181,18 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
launch.tools(),
this::log);
bindingEditor =
new DirectBindingEditor(
new BindingEditor(
BindingDocument.Kind.DIRECT,
this::editBinding,
id -> {
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));
computedEditor =
new BindingEditor(
BindingDocument.Kind.COMPUTED,
this::editBinding,
id -> {
if (kit.editor().getFxomDocument() == null) return;
@@ -334,7 +346,10 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
discoveryTab.setClosable(false);
directBindingTab = new Tab("Direct bindings", bindingEditor);
directBindingTab.setClosable(false);
var bindings = new TitledPane("Bindings", new TabPane(directBindingTab, discoveryTab));
var computedTab = new Tab("Computed bindings", computedEditor);
computedTab.setClosable(false);
var bindings =
new TitledPane("Bindings", new TabPane(directBindingTab, computedTab, discoveryTab));
bindings.setExpanded(false);
center.setBottom(new VBox(properties, bindings));
output.setEditable(false);
@@ -475,6 +490,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
}
draft = value;
bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
submittedSource = null;
commitSource();
return;
@@ -497,6 +513,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
String selectedId = hit == null ? null : hit.getFxId();
draft = value;
bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
if (!source.getText().equals(value.text())) {
int anchor = source.getAnchor(), caret = source.getCaretPosition();
var edits =
@@ -651,14 +668,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
bindingBrowser.invalidate(message);
}
private void editBinding(DirectBindingEditor.Intent intent) {
private void editBinding(BindingEditor.Intent intent) {
withDraft(
() -> {
try {
if (!draft.writable()) throw new IllegalStateException("Document is read-only");
if (!draft.text().equals(intent.source()))
throw new IllegalStateException("Document changed; select the binding again");
var bindings = new DirectBindingDocument(draft.text());
var bindings = new BindingDocument(draft.text(), intent.kind());
var edits =
intent.remove()
? bindings.remove(java.util.Objects.requireNonNull(intent.index()))
@@ -666,17 +683,18 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
submit(
edits,
intent.remove()
? "Remove DirectBinding"
: intent.index() == null ? "Create DirectBinding" : "Edit DirectBinding");
? "Remove " + intent.kind().element()
: (intent.index() == null ? "Create " : "Edit ") + intent.kind().element());
} catch (Exception error) {
bindingEditor.error(error.getMessage());
(intent.kind() == BindingDocument.Kind.COMPUTED ? computedEditor : bindingEditor)
.error(error.getMessage());
}
});
}
private boolean bindingPreviewAllowed() {
try {
var errors = new DirectBindingDocument(draft.text()).errors();
var errors = new BindingDocument(draft.text()).errors();
if (errors.isEmpty()) return true;
status.setText("Preview blocked: " + String.join("; ", errors));
if (projection != null)
@@ -690,7 +708,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
return false;
}
private void validateBindings(DirectBindingDocument.Values values, boolean preview) {
private void validateBindings(BindingDocument.Values values, boolean preview) {
withDraft(
() -> {
if (preview && draft.text().equals(validatingSource)) {
@@ -735,6 +753,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
: "Current successful build")
+ " · draft revision "
+ captured.revision());
computedEditor.metadata(
metadata,
values,
(last
? "Explicit retained build (outdated)"
: "Current successful build")
+ " · draft revision "
+ captured.revision());
status.setText(
"Binding validation complete: "
+ metadata.problems().size()
@@ -0,0 +1,240 @@
package de.neitzel.fxdesigner.kit;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.contracts.*;
import java.net.URI;
import java.util.*;
import org.junit.jupiter.api.Test;
class BindingDocumentTest {
private static final String P = "de.neitzel.fx.view.";
private static final BindingDocument.Values VALUE =
new BindingDocument.Values("label", "text", "name", "view", "");
private String apply(String text, List<EditBatch.Replacement> edits) throws Exception {
String result =
new EditBatch(7, "binding", edits)
.applyTo(new DocumentSnapshot(URI.create("file:///test.fxml"), 7, text, true, false));
new BindingDocument(result);
return result;
}
@Test
void createsRequiredContainersWithoutSerializingTheDocument() throws Exception {
for (String root :
List.of(
"<VBox/>",
"<VBox xmlns:fx='urn:other'/>",
"<VBox><!-- untouched -->\r\n</VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define/></VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define><"
+ P
+ "BindingList/></f:define></VBox>")) {
String text = "<?neitzel-model app.Person?>\n" + root;
var doc = new BindingDocument(text);
String result = apply(text, doc.write(null, VALUE, false));
var created = new BindingDocument(result);
assertEquals(1, created.entries.size());
assertEquals(VALUE, created.entries.getFirst().values());
assertTrue(result.startsWith("<?neitzel-model app.Person?>\n"));
assertFalse(result.contains("<?import"));
if (text.contains("untouched")) assertTrue(result.contains("<!-- untouched -->\r\n"));
if (text.contains("urn:other"))
assertTrue(result.contains("xmlns:fx1=\"http://javafx.com/fxml/1\""));
}
}
private String existing() {
return "<?neitzel-model app.Person?>\n"
+ "<?import de.neitzel.fx.view.*?>\n"
+ "<VBox xmlns:fx='http://javafx.com/fxml/1'><!-- before"
+ " --><fx:define><BindingList><!-- preserve --><DirectBinding control = 'label'"
+ " property='text' path = 'name' direction='view' converter='app.Old'"
+ " custom='untouched'/><!-- after --><app.Unknown"
+ " value='keep'/></BindingList></fx:define><Label fx:id='label'/></VBox>";
}
@Test
void changesOnlyRequestedAttributeRangesAndRetainsUnknownContent() throws Exception {
String text = existing();
var doc = new BindingDocument(text);
int index = doc.entries.getFirst().index();
var value = new BindingDocument.Values("label", "text", "home.street", "both", "app.New");
String updated = apply(text, doc.write(index, value, false));
assertEquals(
text.replace("path = 'name'", "path = 'home.street'")
.replace("direction='view'", "direction='both'")
.replace("converter='app.Old'", "converter='app.New'"),
updated);
assertTrue(new BindingDocument(updated).write(index, value, false).isEmpty());
String escaped =
apply(
text,
doc.write(
index, new BindingDocument.Values("label", "text", "a'&<", "view", ""), false));
assertEquals("a'&<", new BindingDocument(escaped).entries.getFirst().values().path());
assertFalse(escaped.contains("converter='app.Old'"));
assertFalse(new BindingDocument(escaped).errors().isEmpty());
}
@Test
void removesOnlyTheSelectedElementIncludingPairedTags() throws Exception {
String text =
existing()
.replace("custom='untouched'/>", "custom='untouched'><!-- inner --></DirectBinding>");
var doc = new BindingDocument(text);
var tag = doc.source.tags.get(doc.entries.getFirst().index());
String result = apply(text, doc.remove(doc.entries.getFirst().index()));
assertEquals(text.substring(0, tag.start()) + text.substring(tag.end()), result);
assertTrue(result.contains("<!-- preserve --><!-- after -->"));
assertTrue(result.contains("app.Unknown"));
}
@Test
void replacesAllConflictingDirectAndComputedBindingsAtomically() throws Exception {
String text =
existing()
.replace(
"<!-- after -->",
"<ComputedBinding control='label' property='text' method='#caption'/><!-- after"
+ " -->");
var doc = new BindingDocument(text);
assertTrue(doc.errors().stream().anyMatch(s -> s.contains("Duplicate")));
assertThrows(BindingDocument.Conflict.class, () -> doc.write(null, VALUE, false));
String replaced = apply(text, doc.write(null, VALUE, true));
var parsed = new BindingDocument(replaced);
assertEquals(1, parsed.entries.size());
assertFalse(replaced.contains("<ComputedBinding"));
assertTrue(parsed.errors().isEmpty());
assertTrue(replaced.contains("app.Unknown"));
}
@Test
void supportsExplicitElementsWrapperAndRefusesNestedPropertyEdits() throws Exception {
String text =
"<?import de.neitzel.fx.view.*?><VBox"
+ " xmlns:fx='http://javafx.com/fxml/1'><fx:define><BindingList><elements><DirectBinding"
+ " control='label'><property>text</property></DirectBinding></elements></BindingList></fx:define></VBox>";
var doc = new BindingDocument(text);
assertFalse(doc.entries.getFirst().editable());
assertThrows(
IllegalArgumentException.class,
() -> doc.write(doc.entries.getFirst().index(), VALUE, false));
String result =
apply(
text,
doc.write(
null, new BindingDocument.Values("other", "text", "name", "view", ""), false));
assertEquals(2, new BindingDocument(result).entries.size());
}
@Test
void usesOnlyTheLibraryDeclarationsAndRejectsMalformedXml() throws Exception {
String text =
existing().replace("<?import de.neitzel.fx.view.*?>", "<?import app.DirectBinding?>");
assertTrue(new BindingDocument(text).entries.isEmpty());
assertThrows(Exception.class, () -> new BindingDocument("<VBox>"));
assertThrows(
Exception.class, () -> new BindingDocument("<!DOCTYPE x SYSTEM 'file:///etc/passwd'><x/>"));
}
private BindingDocument computed(String text) throws Exception {
return new BindingDocument(text, BindingDocument.Kind.COMPUTED);
}
@Test
void computedCreatesContainersAndRoundTripsEscapedMethods() throws Exception {
for (String root :
List.of(
"<VBox/>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define/></VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define><"
+ P
+ "BindingList><elements/></"
+ P
+ "BindingList></f:define></VBox>")) {
String text = "<?neitzel-model app.Person?>\r\n" + root;
var value = BindingDocument.Values.computed("label", "text", "#a'&<");
String result = apply(text, computed(text).write(null, value, false));
assertEquals(value, computed(result).entries.getFirst().values());
assertTrue(result.startsWith("<?neitzel-model app.Person?>\r\n"));
assertFalse(result.contains(" direction="));
assertFalse(result.contains(" path="));
assertTrue(computed(result).errors().stream().anyMatch(e -> e.contains("Controller method")));
}
}
@Test
void computedChangesOnlyItsFieldsAndRemovesPairedDeclaration() throws Exception {
String text =
existing()
.replace(
"<app.Unknown",
"<ComputedBinding control = 'other' property='text' method = '#old'"
+ " custom='keep'><!-- inner --></ComputedBinding><Label"
+ " fx:id='other'/><app.Unknown");
var doc = computed(text);
int index = doc.entries.getFirst().index();
var value = BindingDocument.Values.computed("label", "accessibleText", "greeting");
String changed = apply(text, doc.write(index, value, false));
assertEquals(
text.replace("control = 'other'", "control = 'label'")
.replace("property='text' method", "property='accessibleText' method")
.replace("method = '#old'", "method = 'greeting'"),
changed);
assertTrue(computed(changed).write(index, value, false).isEmpty());
String removed = apply(changed, computed(changed).remove(index));
assertTrue(computed(removed).entries.isEmpty());
assertEquals(1, new BindingDocument(removed).entries.size());
assertTrue(removed.contains("<!-- preserve -->"));
}
@Test
void computedReplacementRemovesDirectAndComputedConflictsTogether() throws Exception {
String text =
existing()
.replace(
"<!-- after -->",
"<ComputedBinding control='label' property='text' method='#caption'/><!-- after"
+ " -->");
var doc = computed(text);
var value = BindingDocument.Values.computed("label", "text", "#greeting");
assertThrows(BindingDocument.Conflict.class, () -> doc.write(null, value, false));
String result = apply(text, doc.write(null, value, true));
assertTrue(new BindingDocument(result).entries.isEmpty());
assertEquals(1, computed(result).entries.size());
assertTrue(computed(result).errors().isEmpty());
assertTrue(result.contains("<!-- preserve -->"));
// Reverse replacement uses the same conflict policy.
assertThrows(
BindingDocument.Conflict.class,
() -> new BindingDocument(result).write(null, VALUE, false));
String direct = apply(result, new BindingDocument(result).write(null, VALUE, true));
assertTrue(computed(direct).entries.isEmpty());
assertEquals(1, new BindingDocument(direct).entries.size());
}
@Test
void computedNestedSyntaxIsVisibleRemovableButNotFlattened() throws Exception {
String text =
existing()
.replace(
"<app.Unknown",
"<ComputedBinding control='other'"
+ " property='text'><method>#caption</method></ComputedBinding><app.Unknown");
var doc = computed(text);
var entry = doc.entries.getFirst();
assertFalse(entry.editable());
assertThrows(
IllegalArgumentException.class,
() ->
doc.write(
entry.index(), BindingDocument.Values.computed("other", "text", "#new"), false));
assertTrue(computed(apply(text, doc.remove(entry.index()))).entries.isEmpty());
assertTrue(
computed(text.replace("<?import de.neitzel.fx.view.*?>", "<?import app.ComputedBinding?>"))
.entries
.isEmpty());
}
}
@@ -1,146 +0,0 @@
package de.neitzel.fxdesigner.kit;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.contracts.*;
import java.net.URI;
import java.util.*;
import org.junit.jupiter.api.Test;
class DirectBindingDocumentTest {
private static final String P = "de.neitzel.fx.view.";
private static final DirectBindingDocument.Values VALUE =
new DirectBindingDocument.Values("label", "text", "name", "view", "");
private String apply(String text, List<EditBatch.Replacement> edits) throws Exception {
String result =
new EditBatch(7, "binding", edits)
.applyTo(new DocumentSnapshot(URI.create("file:///test.fxml"), 7, text, true, false));
new DirectBindingDocument(result);
return result;
}
@Test
void createsRequiredContainersWithoutSerializingTheDocument() throws Exception {
for (String root :
List.of(
"<VBox/>",
"<VBox xmlns:fx='urn:other'/>",
"<VBox><!-- untouched -->\r\n</VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define/></VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define><"
+ P
+ "BindingList/></f:define></VBox>")) {
String text = "<?neitzel-model app.Person?>\n" + root;
var doc = new DirectBindingDocument(text);
String result = apply(text, doc.write(null, VALUE, false));
var created = new DirectBindingDocument(result);
assertEquals(1, created.entries.size());
assertEquals(VALUE, created.entries.getFirst().values());
assertTrue(result.startsWith("<?neitzel-model app.Person?>\n"));
assertFalse(result.contains("<?import"));
if (text.contains("untouched")) assertTrue(result.contains("<!-- untouched -->\r\n"));
if (text.contains("urn:other"))
assertTrue(result.contains("xmlns:fx1=\"http://javafx.com/fxml/1\""));
}
}
private String existing() {
return "<?neitzel-model app.Person?>\n"
+ "<?import de.neitzel.fx.view.*?>\n"
+ "<VBox xmlns:fx='http://javafx.com/fxml/1'><!-- before"
+ " --><fx:define><BindingList><!-- preserve --><DirectBinding control = 'label'"
+ " property='text' path = 'name' direction='view' converter='app.Old'"
+ " custom='untouched'/><!-- after --><app.Unknown"
+ " value='keep'/></BindingList></fx:define><Label fx:id='label'/></VBox>";
}
@Test
void changesOnlyRequestedAttributeRangesAndRetainsUnknownContent() throws Exception {
String text = existing();
var doc = new DirectBindingDocument(text);
int index = doc.entries.getFirst().index();
var value = new DirectBindingDocument.Values("label", "text", "home.street", "both", "app.New");
String updated = apply(text, doc.write(index, value, false));
assertEquals(
text.replace("path = 'name'", "path = 'home.street'")
.replace("direction='view'", "direction='both'")
.replace("converter='app.Old'", "converter='app.New'"),
updated);
assertTrue(new DirectBindingDocument(updated).write(index, value, false).isEmpty());
String escaped =
apply(
text,
doc.write(
index,
new DirectBindingDocument.Values("label", "text", "a'&<", "view", ""),
false));
assertEquals("a'&<", new DirectBindingDocument(escaped).entries.getFirst().values().path());
assertFalse(escaped.contains("converter='app.Old'"));
assertFalse(new DirectBindingDocument(escaped).errors().isEmpty());
}
@Test
void removesOnlyTheSelectedElementIncludingPairedTags() throws Exception {
String text =
existing()
.replace("custom='untouched'/>", "custom='untouched'><!-- inner --></DirectBinding>");
var doc = new DirectBindingDocument(text);
var tag = doc.source.tags.get(doc.entries.getFirst().index());
String result = apply(text, doc.remove(doc.entries.getFirst().index()));
assertEquals(text.substring(0, tag.start()) + text.substring(tag.end()), result);
assertTrue(result.contains("<!-- preserve --><!-- after -->"));
assertTrue(result.contains("app.Unknown"));
}
@Test
void replacesAllConflictingDirectAndComputedBindingsAtomically() throws Exception {
String text =
existing()
.replace(
"<!-- after -->",
"<ComputedBinding control='label' property='text' method='#caption'/><!-- after"
+ " -->");
var doc = new DirectBindingDocument(text);
assertTrue(doc.errors().stream().anyMatch(s -> s.contains("Duplicate")));
assertThrows(DirectBindingDocument.Conflict.class, () -> doc.write(null, VALUE, false));
String replaced = apply(text, doc.write(null, VALUE, true));
var parsed = new DirectBindingDocument(replaced);
assertEquals(1, parsed.entries.size());
assertFalse(replaced.contains("<ComputedBinding"));
assertTrue(parsed.errors().isEmpty());
assertTrue(replaced.contains("app.Unknown"));
}
@Test
void supportsExplicitElementsWrapperAndRefusesNestedPropertyEdits() throws Exception {
String text =
"<?import de.neitzel.fx.view.*?><VBox"
+ " xmlns:fx='http://javafx.com/fxml/1'><fx:define><BindingList><elements><DirectBinding"
+ " control='label'><property>text</property></DirectBinding></elements></BindingList></fx:define></VBox>";
var doc = new DirectBindingDocument(text);
assertFalse(doc.entries.getFirst().editable());
assertThrows(
IllegalArgumentException.class,
() -> doc.write(doc.entries.getFirst().index(), VALUE, false));
String result =
apply(
text,
doc.write(
null,
new DirectBindingDocument.Values("other", "text", "name", "view", ""),
false));
assertEquals(2, new DirectBindingDocument(result).entries.size());
}
@Test
void usesOnlyTheLibraryDeclarationsAndRejectsMalformedXml() throws Exception {
String text =
existing().replace("<?import de.neitzel.fx.view.*?>", "<?import app.DirectBinding?>");
assertTrue(new DirectBindingDocument(text).entries.isEmpty());
assertThrows(Exception.class, () -> new DirectBindingDocument("<VBox>"));
assertThrows(
Exception.class,
() -> new DirectBindingDocument("<!DOCTYPE x SYSTEM 'file:///etc/passwd'><x/>"));
}
}
@@ -482,6 +482,168 @@ public final class ProjectionHostChecks {
await(() -> fx(() -> source(pane).getText().equals(full)));
}
private static void computedField(DesignerPane pane, String field, String value)
throws Exception {
fx(
() -> {
((TextField) pane.lookup("#computed-" + field)).setText(value);
return null;
});
}
private static void selectComputed(DesignerPane pane, String control) throws Exception {
fx(
() -> {
var list = (ListView<Object>) pane.lookup("#computed-bindings");
list.getSelectionModel()
.select(
list.getItems().stream()
.filter(e -> e.toString().startsWith(control + "."))
.findFirst()
.orElseThrow());
return null;
});
}
private static void computedBindingEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
bindingTab(pane, "Computed bindings");
click(pane, "New Computed binding");
computedField(pane, "control", "action");
computedField(pane, "property", "text");
click(pane, "Find methods / Validate");
await(() -> fx(() -> !((ComboBox<?>) pane.lookup("#computed-sources")).getItems().isEmpty()));
fx(
() -> {
var methods =
(ComboBox<de.neitzel.fxdesigner.protocol.BindingMetadata.Computed>)
pane.lookup("#computed-sources");
var greeting =
methods.getItems().stream()
.filter(m -> m.method().equals("#greeting"))
.findFirst()
.orElseThrow();
check(greeting.status().equals("COMPATIBLE"), "Computed method type comparison failed");
methods.setValue(greeting);
return null;
});
check(documentText(document).equals(full), "Method choice changed source before Apply");
click(pane, "Apply Computed binding");
await(() -> !documentText(document).equals(full));
String created = documentText(document);
check(
created.contains(
"ComputedBinding control=\"action\" property=\"text\" method=\"#greeting\""),
"Computed creation failed");
click(pane, "Undo");
await(() -> documentText(document).equals(full));
click(pane, "Redo");
await(() -> documentText(document).equals(created));
await(() -> fx(() -> source(pane).getText().equals(created)));
selectComputed(pane, "action");
computedField(pane, "property", "accessibleText");
computedField(pane, "method", "greeting");
click(pane, "Apply Computed binding");
String changed =
created.replace(
"property=\"text\" method=\"#greeting\"",
"property=\"accessibleText\" method=\"greeting\"");
await(() -> documentText(document).equals(changed));
click(pane, "Undo");
await(() -> documentText(document).equals(created));
click(pane, "Redo");
await(() -> documentText(document).equals(changed));
await(() -> fx(() -> source(pane).getText().equals(changed)));
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
.get();
check(
java.nio.file.Files.readString(
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
.equals(changed),
"Computed save changed source");
click(pane, "Render drafts");
await(
() ->
fx(
() ->
object(pane, "home").getSceneGraphObject() instanceof SnapshotRegion r
&& r.image() != null
&& r.statusText().equals("Target runtime")));
selectComputed(pane, "action");
click(pane, "Remove Computed binding");
await(() -> !documentText(document).equals(changed));
String removed = documentText(document);
check(!removed.contains("ComputedBinding"), "Computed removal failed");
click(pane, "Undo");
await(() -> documentText(document).equals(changed));
click(pane, "Redo");
await(() -> documentText(document).equals(removed));
await(() -> fx(() -> source(pane).getText().equals(removed)));
click(pane, "New Computed binding");
computedField(pane, "control", "rankInput");
computedField(pane, "property", "text");
computedField(pane, "method", "#greeting");
click(pane, "Apply Computed binding");
await(
() ->
fx(
() ->
((Label) pane.lookup("#computed-status"))
.getText()
.contains("Replace conflicting bindings")));
check(documentText(document).equals(removed), "Computed silently replaced Direct binding");
fx(
() -> {
((CheckBox) pane.lookup("#computed-replace")).setSelected(true);
return null;
});
click(pane, "Apply Computed binding");
await(() -> !documentText(document).equals(removed));
String replaced = documentText(document);
check(
directDeclarations(replaced).stream()
.noneMatch(e -> e.getAttribute("control").equals("rankInput")),
"Computed replacement retained Direct conflict");
click(pane, "Undo");
await(() -> documentText(document).equals(removed));
click(pane, "Redo");
await(() -> documentText(document).equals(replaced));
await(() -> fx(() -> source(pane).getText().equals(replaced)));
selectComputed(pane, "rankInput");
computedField(pane, "method", "#invalidComputed");
click(pane, "Apply Computed binding");
await(() -> documentText(document).contains("method=\"#invalidComputed\""));
String invalid = documentText(document);
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:"))));
check(
java.nio.file.Files.readString(
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
.equals(invalid),
"Invalid Computed draft not saved");
click(pane, "Undo");
await(() -> documentText(document).equals(replaced));
click(pane, "Redo");
await(() -> documentText(document).equals(invalid));
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
}
private static void viewModelBindings(
DesignerPane pane, DocumentSession document, String original) throws Exception {
bindingTab(pane, "Discover targets");
@@ -859,6 +1021,7 @@ public final class ProjectionHostChecks {
check(documentText(document).equals(full), "Discovery handoff changed source before Apply");
converterEdits(pane, document, full);
directBindingEdits(pane, document, full);
computedBindingEdits(pane, document, full);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()