Add shared source-preserving Table and Column authoring

This commit is contained in:
2026-11-13 15:07:38 +01:00
parent 0d14ac14e0
commit 3417788194
9 changed files with 796 additions and 5 deletions
@@ -104,7 +104,7 @@ final class BindingDocument {
entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e)));
}
private boolean named(Element e, String type) {
boolean named(Element e, String type) {
if (e.getTagName().equals(PACKAGE + type)) return true;
if (!e.getTagName().equals(type)) return false;
boolean own = imports.contains(PACKAGE + type) || imports.contains(PACKAGE + "*");
@@ -372,7 +372,7 @@ final class BindingDocument {
return text.contains("\r\n") ? "\r\n" : "\n";
}
private Replacement append(Element container, String child, String attributes) {
Replacement append(Element container, String child, String attributes) {
var t = source.tags.get(indexes.get(container));
String eol = newline();
if (t.selfClosing())
@@ -45,6 +45,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (bindingEditor != null) bindingEditor.selectControl(id);
if (computedEditor != null) computedEditor.selectControl(id);
if (repeatEditor != null) repeatEditor.selectControl(id);
if (tableEditor != null) tableEditor.selectControl(id);
if (id == null || !projection.components().containsKey(id)) return;
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
componentInspector
@@ -117,6 +118,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private DesignerWorkflow bindingWorkflow;
private BindingBrowser bindingBrowser;
private BindingEditor bindingEditor, computedEditor, repeatEditor;
private TableEditor tableEditor;
private String validatedSource, validatingSource;
private boolean validationPreview;
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
@@ -211,6 +213,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (target != null) kit.editor().getSelection().select(target);
},
values -> validateBindings(values, false));
tableEditor = new TableEditor(this::editTable, this::navigateComponent);
bindingBrowser =
new BindingBrowser(
this::discoverBindings,
@@ -361,9 +364,12 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
computedTab.setClosable(false);
var repeatTab = new Tab("Repeat bindings", repeatEditor);
repeatTab.setClosable(false);
var tableTab = new Tab("Table / Columns", tableEditor);
tableTab.setClosable(false);
var bindings =
new TitledPane(
"Bindings", new TabPane(directBindingTab, computedTab, repeatTab, discoveryTab));
"Bindings",
new TabPane(directBindingTab, computedTab, repeatTab, tableTab, discoveryTab));
bindings.setExpanded(false);
center.setBottom(new VBox(properties, bindings));
output.setEditable(false);
@@ -506,6 +512,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
tableEditor.load(value.text(), value.writable());
submittedSource = null;
commitSource();
return;
@@ -530,6 +537,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
tableEditor.load(value.text(), value.writable());
if (!source.getText().equals(value.text())) {
int anchor = source.getAnchor(), caret = source.getCaretPosition();
var edits =
@@ -685,6 +693,20 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
repeatEditor.invalidateDiscovery();
}
private void editTable(TableEditor.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 Table again");
submit(intent.edits(), intent.label());
} catch (Exception error) {
tableEditor.error(error.getMessage());
}
});
}
private void editBinding(BindingEditor.Intent intent) {
withDraft(
() -> {
@@ -715,7 +737,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private boolean bindingPreviewAllowed() {
try {
var errors = new BindingDocument(draft.text()).errors();
var errors = new ArrayList<>(new BindingDocument(draft.text()).errors());
errors.addAll(new TableDocument(draft.text()).errors());
if (errors.isEmpty()) return true;
status.setText("Preview blocked: " + String.join("; ", errors));
if (projection != null)
@@ -0,0 +1,228 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.*;
import org.w3c.dom.*;
/** Table/Column intents over the authoritative XML, never over Kit's projection. */
final class TableDocument {
record Table(int index, String id, String collection, String row, boolean editable) {
public String toString() {
return (id.isEmpty() ? "Table #" + index : id) + " ← " + collection;
}
}
record Column(int index, String attribute, String header, String width, boolean editable) {
public String toString() {
return header + " (" + attribute + ")";
}
}
final BindingDocument xml;
final List<Table> tables = new ArrayList<>();
TableDocument(String text) throws Exception {
xml = new BindingDocument(text);
for (int i = 0; i < xml.elements.size(); i++) {
var e = xml.elements.get(i);
if (xml.named(e, "Table"))
tables.add(
new Table(
i,
e.getAttributeNS(BindingDocument.FX, "id"),
e.getAttribute("collection"),
e.getAttribute("row"),
!property(e, "collection") && !property(e, "row")));
}
}
private boolean property(Element e, String name) {
return children(e).stream().anyMatch(c -> c.getTagName().equals(name));
}
private static List<Element> children(Element e) {
var result = new ArrayList<Element>();
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element c) result.add(c);
return result;
}
private Element table(int index) {
if (tables.stream().noneMatch(t -> t.index() == index))
throw new IllegalArgumentException("Select a Table again");
return xml.elements.get(index);
}
private Element columnsContainer(int index) {
var e = table(index);
var wrappers = children(e).stream().filter(c -> c.getTagName().equals("columns")).toList();
if (wrappers.size() > 1
|| !wrappers.isEmpty() && children(e).stream().anyMatch(c -> xml.named(c, "Column")))
throw new IllegalArgumentException("Mixed Column containers; edit in Source");
return wrappers.isEmpty() ? e : wrappers.getFirst();
}
List<Column> columns(int index) {
return children(columnsContainer(index)).stream()
.filter(e -> xml.named(e, "Column"))
.map(
e ->
new Column(
xml.elements.indexOf(e),
e.getAttribute("attribute"),
e.getAttribute("header"),
e.getAttribute("width"),
children(e).isEmpty()))
.toList();
}
List<Replacement> configure(int index, String collection, String row) {
if (!tables.stream().filter(t -> t.index() == index).findFirst().orElseThrow().editable())
throw new IllegalArgumentException("Nested Table properties must be edited in Source");
return attributes(index, Map.of("collection", collection, "row", row));
}
List<Replacement> create(String collection, String row) {
var root = xml.document.getDocumentElement();
String name = root.getTagName();
if (BindingDocument.FX.equals(root.getNamespaceURI()) && root.getLocalName().equals("root"))
name = root.getAttribute("type");
if (!Set.of(
"VBox",
"HBox",
"Pane",
"AnchorPane",
"StackPane",
"FlowPane",
"TilePane",
"GridPane",
"javafx.scene.layout.VBox",
"javafx.scene.layout.HBox",
"javafx.scene.layout.Pane",
"javafx.scene.layout.AnchorPane",
"javafx.scene.layout.StackPane",
"javafx.scene.layout.FlowPane",
"javafx.scene.layout.TilePane",
"javafx.scene.layout.GridPane")
.contains(name))
throw new IllegalArgumentException(
"New Table requires a supported root Pane; insert it in Source for another parent");
Element parent =
children(root).stream()
.filter(c -> c.getTagName().equals("children"))
.findFirst()
.orElse(root);
String node =
"<de.neitzel.fx.view.Table collection=\""
+ escape(collection)
+ "\" row=\""
+ escape(row)
+ "\"/>";
return List.of(xml.append(parent, node, ""));
}
List<Replacement> removeTable(int index) {
if (table(index) == xml.document.getDocumentElement())
throw new IllegalArgumentException("A root Table cannot be removed; edit in Source");
return remove(index);
}
List<Replacement> writeColumn(
int table, Integer index, String attribute, String header, String width) {
if (index != null) {
var c = columns(table).stream().filter(v -> v.index() == index).findFirst().orElseThrow();
if (!c.editable())
throw new IllegalArgumentException("Nested Column properties must be edited in Source");
var values = new LinkedHashMap<String, String>();
values.put("attribute", attribute);
values.put("header", header);
values.put("width", width.isEmpty() ? null : width);
return attributes(index, values);
}
return List.of(
xml.append(
columnsContainer(table),
"<de.neitzel.fx.view.Column attribute=\""
+ escape(attribute)
+ "\" header=\""
+ escape(header)
+ "\""
+ (width.isEmpty() ? "" : " width=\"" + escape(width) + "\"")
+ "/>",
""));
}
List<Replacement> removeColumn(int table, int index) {
if (columns(table).stream().noneMatch(c -> c.index() == index))
throw new IllegalArgumentException("Select a Column again");
return remove(index);
}
List<Replacement> moveColumn(int table, int index, int delta) {
var columns = columns(table);
int from = -1;
for (int i = 0; i < columns.size(); i++) if (columns.get(i).index() == index) from = i;
if (from < 0) throw new IllegalArgumentException("Select a Column again");
int to = from + delta;
if (to < 0 || to >= columns.size()) return List.of();
var a = xml.source.tags.get(index);
var b = xml.source.tags.get(columns.get(to).index());
return List.of(
new Replacement(a.start(), a.end(), xml.text.substring(b.start(), b.end())),
new Replacement(b.start(), b.end(), xml.text.substring(a.start(), a.end())));
}
private List<Replacement> remove(int index) {
var t = xml.source.tags.get(index);
return List.of(new Replacement(t.start(), t.end(), ""));
}
private List<Replacement> attributes(int index, Map<String, String> values) {
var changed = new LinkedHashMap<String, String>();
var e = xml.elements.get(index);
values.forEach(
(k, v) -> {
if (v == null ? e.hasAttribute(k) : !v.equals(e.getAttribute(k))) changed.put(k, v);
});
return xml.source.attributes(index, changed);
}
private static String escape(String v) {
return XmlSource.escape(v, '"');
}
private static boolean path(String v) {
return v.matches(
"[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*");
}
List<String> errors() {
var errors = new ArrayList<String>();
for (var t : tables) {
if (!t.editable()) continue;
if (!path(t.collection())) errors.add("Invalid Table collection: " + t);
if (!path(t.row())) errors.add("Invalid Table Row class: " + t);
try {
var columns = columns(t.index());
if (columns.isEmpty()) errors.add("Table needs at least one Column: " + t);
var seen = new HashSet<String>();
for (var c : columns) {
if (!c.editable()) continue;
if (!path(c.attribute())) errors.add("Invalid Column Attribute: " + c.attribute());
if (!seen.add(c.attribute())) errors.add("Duplicate Column Attribute: " + c.attribute());
if (c.header().isBlank()) errors.add("Column needs a header: " + c.attribute());
if (!c.width().isEmpty())
try {
double w = Double.parseDouble(c.width());
if (!Double.isFinite(w) || w <= 0) throw new NumberFormatException();
} catch (NumberFormatException e) {
errors.add("Column width must be positive and finite: " + c.attribute());
}
}
} catch (IllegalArgumentException e) {
errors.add(e.getMessage());
}
}
return List.copyOf(errors);
}
}
@@ -0,0 +1,249 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.List;
import java.util.function.Consumer;
import javafx.scene.control.*;
import javafx.scene.layout.*;
/** Shared Table and ordered Column form. Applying an intent is always a host document command. */
final class TableEditor extends VBox {
record Intent(String source, String label, List<Replacement> edits) {}
private final ListView<TableDocument.Table> tables = new ListView<>();
private final ListView<TableDocument.Column> columns = new ListView<>();
private final TextField collection = field("collection"),
row = field("row"),
attribute = field("attribute"),
header = field("header"),
width = field("width");
private final Label status = new Label();
private final Consumer<Intent> edit;
private String source = "";
private TableDocument document;
private boolean loading;
private Integer table, column;
TableEditor(Consumer<Intent> edit, Consumer<String> navigate) {
super(5);
this.edit = edit;
setId("table-editor");
tables.setId("table-bindings");
columns.setId("table-columns");
status.setId("table-status");
status.setWrapText(true);
tables.setPrefHeight(65);
columns.setPrefHeight(65);
tables
.getSelectionModel()
.selectedItemProperty()
.addListener(
(o, a, b) -> {
if (!loading && b != null) select(b);
});
columns
.getSelectionModel()
.selectedItemProperty()
.addListener(
(o, a, b) -> {
if (!loading && b != null) {
column = b.index();
attribute.setText(b.attribute());
header.setText(b.header());
width.setText(b.width());
}
});
var tableFields =
new HBox(6, new Label("Collection"), collection, new Label("Row Component"), row);
var columnFields =
new HBox(
6,
new Label("Attribute"),
attribute,
new Label("Header"),
header,
new Label("Width (optional)"),
width);
getChildren()
.addAll(
tables,
tableFields,
new HBox(
6,
button(
"New Table",
() -> {
tables.getSelectionModel().clearSelection();
table = null;
column = null;
columns.getItems().clear();
collection.clear();
row.clear();
}),
button(
"Apply Table",
() ->
change(
"Edit Table",
() ->
table == null
? document.create(collection.getText(), row.getText())
: document.configure(
table, collection.getText(), row.getText()))),
button(
"Remove Table",
() -> change("Remove Table", () -> document.removeTable(requireTable()))),
button(
"Open selected Row Component",
() -> {
if (!row.getText().isBlank()) navigate.accept(row.getText());
})),
columns,
columnFields,
new HBox(
6,
button(
"New Column",
() -> {
column = null;
columns.getSelectionModel().clearSelection();
attribute.clear();
header.clear();
width.clear();
}),
button(
"Apply Column",
() ->
change(
"Edit Column",
() ->
document.writeColumn(
requireTable(),
column,
attribute.getText(),
header.getText(),
width.getText()))),
button(
"Remove Column",
() ->
change(
"Remove Column",
() -> document.removeColumn(requireTable(), requireColumn()))),
button(
"Column up",
() ->
change(
"Move Column",
() -> document.moveColumn(requireTable(), requireColumn(), -1))),
button(
"Column down",
() ->
change(
"Move Column",
() -> document.moveColumn(requireTable(), requireColumn(), 1)))),
status);
}
private TextField field(String name) {
var f = new TextField();
f.setId("table-" + name);
return f;
}
private Button button(String text, Runnable action) {
var b = new Button(text);
b.setOnAction(e -> action.run());
return b;
}
private int requireTable() {
if (table == null) throw new IllegalArgumentException("Select a Table");
return table;
}
private int requireColumn() {
if (column == null) throw new IllegalArgumentException("Select a Column");
return column;
}
private void change(String label, java.util.concurrent.Callable<List<Replacement>> operation) {
try {
var edits = operation.call();
if (!edits.isEmpty()) edit.accept(new Intent(source, label, edits));
} catch (Exception e) {
error(e.getMessage());
}
}
private void select(TableDocument.Table value) {
table = value.index();
collection.setText(value.collection());
row.setText(value.row());
column = null;
attribute.clear();
header.clear();
width.clear();
try {
columns.getItems().setAll(document.columns(table));
} catch (Exception e) {
columns.getItems().clear();
error(e.getMessage());
}
}
void selectControl(String id) {
if (id == null || id.isEmpty()) return;
tables.getItems().stream()
.filter(t -> id.equals(t.id()))
.findFirst()
.ifPresent(t -> tables.getSelectionModel().select(t));
}
void load(String text, boolean writable) {
setDisable(!writable);
if (source.equals(text)) return;
source = text;
loading = true;
try {
document = new TableDocument(text);
tables.getItems().setAll(document.tables);
var selected =
document.tables.stream()
.filter(t -> table != null && t.index() == table)
.findFirst()
.orElse(null);
tables.getSelectionModel().select(selected);
columns.getItems().clear();
column = null;
if (selected != null) select(selected);
else {
table = null;
collection.clear();
row.clear();
attribute.clear();
header.clear();
width.clear();
}
var errors = document.errors();
status.setText(
(errors.isEmpty() ? "No structural Table/Column errors." : String.join("\n", errors))
+ "\n"
+ "Column/Row alignment and type compatibility are unverified. Open Row Component to"
+ " review its positional fields; Column edits never rearrange the shared Row"
+ " layout.");
} catch (Exception e) {
document = null;
tables.getItems().clear();
columns.getItems().clear();
table = null;
column = null;
error("Table editor unavailable: " + e.getMessage());
} finally {
loading = false;
}
}
void error(String message) {
status.setText(message);
}
}
@@ -675,6 +675,146 @@ public final class ProjectionHostChecks {
await(() -> fx(() -> source(pane).getText().equals(after)));
}
private static void tableField(DesignerPane pane, String field, String value) throws Exception {
fx(
() -> {
((TextField) pane.lookup("#table-" + field)).setText(value);
return null;
});
}
private static void tableSelect(DesignerPane pane, String list, int index) throws Exception {
fx(
() -> {
((ListView<?>) pane.lookup("#table-" + list)).getSelectionModel().select(index);
return null;
});
}
private static void tableEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
bindingTab(pane, "Table / Columns");
tableSelect(pane, "bindings", 0);
tableSelect(pane, "columns", 0);
tableField(pane, "header", "Street & address");
tableField(pane, "width", "180");
check(documentText(document).equals(full), "Table form changed source before Apply");
click(pane, "Apply Column");
await(() -> !documentText(document).equals(full));
String edited = documentText(document);
check(
edited.contains("header=\"Street &amp; address\"") && edited.contains("width=\"180\""),
"Column fields missing");
repeatHistory(pane, document, full, edited);
// A temporary duplicate is saveable, but cannot start a new preview.
tableSelect(pane, "bindings", 0);
click(pane, "New Column");
tableField(pane, "attribute", "street");
tableField(pane, "header", "Second");
click(pane, "Apply Column");
await(() -> !documentText(document).equals(edited));
String duplicate = documentText(document);
repeatHistory(pane, document, edited, duplicate);
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(duplicate),
"Table invalid draft not saved");
click(pane, "Render drafts");
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& l.getText() != null
&& l.getText().startsWith("Preview blocked:")
&& l.getText().contains("Duplicate Column"))));
tableSelect(pane, "bindings", 0);
tableSelect(pane, "columns", 1);
click(pane, "Column up");
await(() -> !documentText(document).equals(duplicate));
String moved = documentText(document);
repeatHistory(pane, document, duplicate, moved);
check(
moved.indexOf("header=\"Second\"") < moved.indexOf("header=\"Street &amp; address\""),
"Column order not changed");
tableSelect(pane, "bindings", 0);
tableSelect(pane, "columns", 0);
click(pane, "Column down");
await(() -> documentText(document).equals(duplicate));
repeatHistory(pane, document, moved, duplicate);
tableSelect(pane, "bindings", 0);
tableSelect(pane, "columns", 1);
click(pane, "Remove Column");
await(() -> !documentText(document).equals(duplicate));
String removed = documentText(document);
repeatHistory(pane, document, duplicate, removed);
tableSelect(pane, "bindings", 0);
tableField(pane, "row", "fixture.components.AddressForm");
click(pane, "Apply Table");
await(() -> !documentText(document).equals(removed));
String configured = documentText(document);
repeatHistory(pane, document, removed, configured);
tableSelect(pane, "bindings", 0);
click(pane, "Remove Table");
await(() -> !documentText(document).equals(configured));
String noTable = documentText(document);
repeatHistory(pane, document, configured, noTable);
click(pane, "New Table");
tableField(pane, "collection", "visits");
tableField(pane, "row", "fixture.components.VisitRow");
click(pane, "Apply Table");
await(() -> !documentText(document).equals(noTable));
String created = documentText(document);
repeatHistory(pane, document, noTable, created);
tableSelect(pane, "bindings", 0);
click(pane, "New Column");
tableField(pane, "attribute", "street");
tableField(pane, "header", "Street");
tableField(pane, "width", "160");
click(pane, "Apply Column");
await(() -> !documentText(document).equals(created));
String valid = documentText(document);
repeatHistory(pane, document, created, valid);
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(valid),
"Table save changed source");
click(pane, "Interactive preview");
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& "Interactive preview running from captured drafts"
.equals(l.getText()))));
check(pane.interactivePid() > 0, "Table preview worker missing");
click(pane, "Stop preview");
await(() -> pane.interactivePid() < 0);
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
System.out.println(
"PASS Table/Column inspector: CRUD, reorder, exact undo/redo, invalid save, preview gating"
+ " and target load");
}
private static void repeatBindingEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
String base =
@@ -1273,6 +1413,7 @@ public final class ProjectionHostChecks {
directBindingEdits(pane, document, full);
computedBindingEdits(pane, document, full);
repeatBindingEdits(pane, document, full);
tableEdits(pane, document, full);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
@@ -0,0 +1,121 @@
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 TableDocumentTest {
private static final String START =
"<?neitzel-model app.Person?>\r\n"
+ "<?import de.neitzel.fx.view.*?><?import javafx.scene.layout.VBox?><VBox"
+ " xmlns:f='http://javafx.com/fxml/1'><!-- keep --><Table f:id='t' collection = 'visits'"
+ " row='app.Row' custom='keep'><columns><!-- before --><Column header = 'Street'"
+ " attribute='street' width='80' custom='keep'/><!-- between --><Column header='Number'"
+ " attribute='number'/></columns></Table><Label text='untouched'/></VBox>";
private String apply(String text, List<EditBatch.Replacement> edits) throws Exception {
var result =
new EditBatch(1, "Table edit", edits)
.applyTo(new DocumentSnapshot(URI.create("file:///view.fxml"), 1, text, true, false));
new TableDocument(result);
return result;
}
@Test
void editsOnlyChangedRangesAndRemovesOptionalWidth() throws Exception {
var d = new TableDocument(START);
int t = d.tables.getFirst().index();
int c = d.columns(t).getFirst().index();
assertEquals(
START.replace("collection = 'visits'", "collection = 'other'"),
apply(START, d.configure(t, "other", "app.Row")));
assertTrue(d.configure(t, "visits", "app.Row").isEmpty());
var changed = apply(START, d.writeColumn(t, c, "street", "A'&<", ""));
assertEquals(
START.replace("header = 'Street'", "header = 'A&apos;&amp;&lt;'").replace("width='80'", ""),
changed);
}
@Test
void reordersExactElementsWithoutMovingCommentsOrRowSource() throws Exception {
var d = new TableDocument(START);
int t = d.tables.getFirst().index();
int c = d.columns(t).getFirst().index();
String moved = apply(START, d.moveColumn(t, c, 1));
assertTrue(
moved.contains(
"<!-- before --><Column header='Number' attribute='number'/><!-- between --><Column"
+ " header = 'Street' attribute='street' width='80' custom='keep'/>"));
var next = new TableDocument(moved);
assertEquals(START, apply(moved, next.moveColumn(t, next.columns(t).getFirst().index(), 1)));
assertTrue(d.moveColumn(t, c, -1).isEmpty());
}
@Test
void createsAndRemovesTablesAndColumnsInSupportedContainers() throws Exception {
for (String root :
List.of(
"<VBox/>",
"<VBox><children/></VBox>",
"<VBox><children><!--keep--></children></VBox>")) {
var d = new TableDocument(root);
String created = apply(root, d.create("visits", "app.Row"));
var n = new TableDocument(created);
int t = n.tables.getFirst().index();
String withColumn = apply(created, n.writeColumn(t, null, "street", "Street", "120"));
var c = new TableDocument(withColumn);
assertTrue(c.errors().isEmpty());
assertTrue(apply(withColumn, c.removeTable(t)).contains("VBox"));
String removed = apply(withColumn, c.removeColumn(t, c.columns(t).getFirst().index()));
assertTrue(
new TableDocument(removed).errors().stream().anyMatch(e -> e.contains("at least one")));
}
}
@Test
void preservesNestedDeclarationsAndRejectsAmbiguousContainers() throws Exception {
String text =
START.replace("width='80' custom='keep'/>", "width='80'><header>Nested</header></Column>");
var d = new TableDocument(text);
int t = d.tables.getFirst().index();
assertFalse(d.columns(t).getFirst().editable());
assertThrows(
IllegalArgumentException.class,
() -> d.writeColumn(t, d.columns(t).getFirst().index(), "street", "X", ""));
var mixed =
new TableDocument(
START.replace("<columns>", "<Column header='Other' attribute='other'/><columns>"));
assertThrows(
IllegalArgumentException.class, () -> mixed.columns(mixed.tables.getFirst().index()));
var root = new TableDocument("<de.neitzel.fx.view.Table collection='visits' row='app.Row'/>");
assertThrows(IllegalArgumentException.class, () -> root.removeTable(0));
assertThrows(
IllegalArgumentException.class,
() -> new TableDocument("<Label/>").create("visits", "app.Row"));
}
@Test
void diagnosesInvalidDeclarationsWithoutPreventingEdits() throws Exception {
for (String width : List.of("0", "-2", "NaN", "Infinity", "oops")) {
var d = new TableDocument(START);
int t = d.tables.getFirst().index();
var changed =
new TableDocument(
apply(START, d.writeColumn(t, d.columns(t).getFirst().index(), "number", "", width)));
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("Duplicate")));
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("positive and finite")));
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("header")));
}
var invalid =
new TableDocument(START.replace("collection = 'visits'", "collection='invalid path'"));
assertFalse(invalid.errors().isEmpty());
}
@Test
void ignoresUnrelatedImportedNames() throws Exception {
assertTrue(new TableDocument("<?import app.Table?><VBox><Table/></VBox>").tables.isEmpty());
}
}