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
@@ -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());
}
}