diff --git a/fx/src/main/java/de/neitzel/fx/view/ChildMessageMirror.java b/fx/src/main/java/de/neitzel/fx/view/ChildMessageMirror.java
new file mode 100644
index 0000000..3d3fffa
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ChildMessageMirror.java
@@ -0,0 +1,127 @@
+package de.neitzel.fx.view;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Carries one child ViewModel's current notices onto its parent.
+ *
+ * Each copy is keyed by the identity of the child {@link Message}. A later text change on that
+ * child notice updates the same copy, and dropping the child notice drops the copy. The copy's
+ * source is the Attribute name, a dot, and the child source. A further parent carries that copy
+ * the same way, so the source grows into a Path. The copy is not logged again.
+ */
+final class ChildMessageMirror implements MessageListener {
+
+ private final ViewModel parent;
+ private final String attribute;
+ private final ViewModel child;
+ private final Map copies = new IdentityHashMap<>();
+
+ private ChildMessageMirror(ViewModel parent, String attribute, ViewModel child) {
+ this.parent = parent;
+ this.attribute = attribute;
+ this.child = child;
+ }
+
+ /**
+ * Starts carrying {@code child} onto {@code parent} under {@code attribute}.
+ *
+ * Notices already on the child are copied, and notices added later are copied as they arrive.
+ *
+ * @param parent the parent ViewModel
+ * @param attribute the Attribute that leads to the child
+ * @param child the child ViewModel
+ * @return the mirror, so it can be detached when the Attribute is cleared
+ */
+ static ChildMessageMirror attach(ViewModel parent, String attribute, ViewModel child) {
+ ChildMessageMirror mirror = new ChildMessageMirror(parent, attribute, child);
+ child.addMessageListener(mirror);
+ for (Message message : List.copyOf(child.getMessages())) {
+ mirror.carry(message);
+ }
+ return mirror;
+ }
+
+ /**
+ * Stops carrying and drops this Attribute's copies from the parent.
+ */
+ void detach() {
+ child.removeMessageListener(this);
+ List dropped = new ArrayList<>(copies.values());
+ copies.clear();
+ for (Message copy : dropped) {
+ parent.removeCarriedMessage(copy);
+ }
+ }
+
+ /**
+ * Copies {@code message} onto the parent unless this parent already holds it.
+ *
+ * @param message the child notice
+ */
+ @Override
+ public void added(Message message) {
+ carry(message);
+ }
+
+ /**
+ * Drops the parent's copy of {@code message}.
+ *
+ * @param message the child notice
+ */
+ @Override
+ public void removed(Message message) {
+ Message copy = copies.remove(message);
+ if (copy != null) {
+ parent.removeCarriedMessage(copy);
+ }
+ }
+
+ /**
+ * Writes the child's new text onto the same parent copy.
+ *
+ * @param message the child notice
+ */
+ @Override
+ public void textChanged(Message message) {
+ Message copy = copies.get(message);
+ if (copy == null) {
+ return;
+ }
+ copy.setText(message.getText());
+ parent.noteMessageText(copy);
+ }
+
+ private void carry(Message message) {
+ if (copies.containsKey(message) || repeats(message)) {
+ return;
+ }
+ Message copy = message.withExtendedSource(attribute + "." + message.getSource());
+ copies.put(message, copy);
+ parent.addCarriedMessage(copy);
+ }
+
+ /**
+ * A cycle would carry a notice back onto a ViewModel that already holds it, or that holds
+ * the notice it was copied from.
+ *
+ * @param message the child notice
+ * @return {@code true} when the parent already has this notice or its origin
+ */
+ private boolean repeats(Message message) {
+ Message current = message;
+ Set seen = Collections.newSetFromMap(new IdentityHashMap<>());
+ while (current != null && seen.add(current)) {
+ if (parent.getMessages().contains(current)) {
+ return true;
+ }
+ current = current.origin();
+ }
+ return false;
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java
index aa87df9..9b730d1 100644
--- a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java
+++ b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java
@@ -57,11 +57,11 @@ final class DirectBindingApplier {
Converter converter = converter(context, converterName);
switch (binding.getDirection()) {
case "both" -> alignConverted(
- controlProperty, attribute, converter, viewModel, binding.getPath(), true, true);
+ controlProperty, attribute, converter, resolved.owner, resolved.name, true, true);
case "view" -> alignConverted(
- controlProperty, attribute, converter, viewModel, binding.getPath(), true, false);
+ controlProperty, attribute, converter, resolved.owner, resolved.name, true, false);
case "model" -> alignConverted(
- controlProperty, attribute, converter, viewModel, binding.getPath(), false, true);
+ controlProperty, attribute, converter, resolved.owner, resolved.name, false, true);
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
}
}
@@ -76,15 +76,22 @@ final class DirectBindingApplier {
}
/**
- * The Attribute a Path names, and whether that Attribute is complex.
+ * The Attribute a Path names, the ViewModel that owns it, and whether that Attribute is complex.
+ *
+ * {@code name} is the last segment. A conversion failure is recorded on {@code owner} with that
+ * name as the source, so a parent only carries the extended copy.
*/
private static final class Resolved {
private final Property> attribute;
private final boolean complex;
+ private final ViewModel owner;
+ private final String name;
- private Resolved(Property> attribute, boolean complex) {
+ private Resolved(Property> attribute, boolean complex, ViewModel owner, String name) {
this.attribute = attribute;
this.complex = complex;
+ this.owner = owner;
+ this.name = name;
}
}
@@ -123,7 +130,7 @@ final class DirectBindingApplier {
if (attribute == null) {
throw missing(path);
}
- return new Resolved(attribute, current.isComplex(last));
+ return new Resolved(attribute, current.isComplex(last), current, last);
}
private static IllegalArgumentException missing(String path) {
@@ -143,31 +150,35 @@ final class DirectBindingApplier {
}
}
+ /**
+ * Aligns a converted Attribute. Failures are recorded on {@code owner} with {@code sourceName},
+ * which is the last name of the Path.
+ */
@SuppressWarnings("unchecked")
private static void alignConverted(Property> controlProperty, Property> attribute, Converter converter,
- ViewModel viewModel, String path, boolean towardView, boolean towardModel) {
+ ViewModel owner, String sourceName, boolean towardView, boolean towardModel) {
Property control = (Property) controlProperty;
Property source = (Property) attribute;
Link link = new Link();
- pushControl(control, source, converter, viewModel, path, link);
+ pushControl(control, source, converter, owner, sourceName, link);
if (towardView) {
source.addListener((observable, oldValue, newValue) -> {
if (!link.applying) {
- pushControl(control, source, converter, viewModel, path, link);
+ pushControl(control, source, converter, owner, sourceName, link);
}
});
}
if (towardModel) {
control.addListener((observable, oldValue, newValue) -> {
if (!link.applying) {
- pushAttribute(control, source, converter, viewModel, path, link);
+ pushAttribute(control, source, converter, owner, sourceName, link);
}
});
}
}
private static void pushControl(Property control, Property attribute, Converter converter,
- ViewModel viewModel, String path, Link link) {
+ ViewModel owner, String source, Link link) {
try {
Object shown = converter.toControlValue(attribute.getValue());
link.applying = true;
@@ -177,14 +188,14 @@ final class DirectBindingApplier {
} finally {
link.applying = false;
}
- viewModel.clearBindingFailure(path);
+ owner.clearBindingFailure(source);
} catch (RuntimeException failure) {
- viewModel.reportBindingFailure(path, failureText(failure));
+ owner.reportBindingFailure(source, failureText(failure));
}
}
private static void pushAttribute(Property control, Property attribute, Converter converter,
- ViewModel viewModel, String path, Link link) {
+ ViewModel owner, String source, Link link) {
try {
Object converted = converter.toAttributeValue(control.getValue());
link.applying = true;
@@ -194,7 +205,7 @@ final class DirectBindingApplier {
} finally {
link.applying = false;
}
- viewModel.clearBindingFailure(path);
+ owner.clearBindingFailure(source);
} catch (RuntimeException failure) {
link.applying = true;
try {
@@ -202,7 +213,7 @@ final class DirectBindingApplier {
} finally {
link.applying = false;
}
- viewModel.reportBindingFailure(path, failureText(failure));
+ owner.reportBindingFailure(source, failureText(failure));
}
}
diff --git a/fx/src/main/java/de/neitzel/fx/view/Message.java b/fx/src/main/java/de/neitzel/fx/view/Message.java
index e35e426..9f7cf68 100644
--- a/fx/src/main/java/de/neitzel/fx/view/Message.java
+++ b/fx/src/main/java/de/neitzel/fx/view/Message.java
@@ -22,6 +22,7 @@ public final class Message {
private final Severity severity;
private String text;
private final String source;
+ private Message origin;
/**
* Creates a notice.
@@ -71,4 +72,28 @@ public final class Message {
void setText(String text) {
this.text = text;
}
+
+ /**
+ * Returns a copy whose source is extended for a parent ViewModel.
+ *
+ * The copy remembers this notice, so a cycle does not carry it back onto the ViewModel
+ * that already holds it.
+ *
+ * @param source the extended source
+ * @return the copy
+ */
+ Message withExtendedSource(String source) {
+ Message copy = new Message(severity, text, source);
+ copy.origin = this;
+ return copy;
+ }
+
+ /**
+ * Returns the notice this copy mirrors.
+ *
+ * @return that notice, or {@code null} when this notice was added on its own ViewModel
+ */
+ Message origin() {
+ return origin;
+ }
}
diff --git a/fx/src/main/java/de/neitzel/fx/view/MessageListener.java b/fx/src/main/java/de/neitzel/fx/view/MessageListener.java
new file mode 100644
index 0000000..08233ea
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/MessageListener.java
@@ -0,0 +1,28 @@
+package de.neitzel.fx.view;
+
+/**
+ * Hears a notice added, dropped, or edited on a ViewModel.
+ */
+interface MessageListener {
+
+ /**
+ * A notice was added.
+ *
+ * @param message the notice
+ */
+ void added(Message message);
+
+ /**
+ * A notice was dropped.
+ *
+ * @param message the notice
+ */
+ void removed(Message message);
+
+ /**
+ * A notice kept its place and changed its text.
+ *
+ * @param message the notice
+ */
+ void textChanged(Message message);
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
index 7a1d168..c7e7f20 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
@@ -33,7 +33,9 @@ public final class ViewLoader {
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
* A path that is not a read-write Attribute fails the load. The context creates a Controller,
* and a Converter when a Direct binding names one. A converter that cannot produce a value
- * keeps the previous value and records one error Message. It does not fail the load.
+ * keeps the previous value and records one error Message on the ViewModel that owns the last
+ * Attribute, with that Attribute's name as the source. A parent carries a copy with the source
+ * extended. It does not fail the load.
*
* @param fxml the FXML document
* @param model the Model instance
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
index fbcbd94..42577a1 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
@@ -64,7 +64,9 @@ import java.util.Set;
*
* Beside those attributes, the ViewModel keeps a list of {@link Message}s and the latest one.
* The developer can add a message of any severity. Error and fatal messages are written to the log.
- * Info and warn are not.
+ * Info and warn are not. A parent carries each current child message, with the source extended by
+ * the Attribute that leads to that child. Each further parent prepends its own Attribute. The
+ * carried copy is not logged again. When the child drops the message, the parent drops that copy.
*/
public class ViewModel {
@@ -75,6 +77,8 @@ public class ViewModel {
private final Set complexAttributes = new HashSet<>();
private final List messages = new ArrayList<>();
private final Map bindingFailures = new HashMap<>();
+ private final List messageListeners = new ArrayList<>();
+ private final Map childMirrors = new HashMap<>();
private Message latestMessage;
/**
@@ -268,12 +272,43 @@ public class ViewModel {
}
private void watchComplex(Property> property, Method setter, String name) {
+ if (property.getValue() instanceof ViewModel child) {
+ attachChild(name, child);
+ }
property.addListener((observable, oldValue, newValue) -> {
+ if (oldValue != newValue) {
+ detachChild(name);
+ }
Object written = newValue instanceof ViewModel viewModel ? viewModel.model() : newValue;
write(setter, name, written);
+ if (oldValue != newValue && newValue instanceof ViewModel child) {
+ attachChild(name, child);
+ }
});
}
+ /**
+ * Carries the child ViewModel's current notices, extending each source with this Attribute.
+ *
+ * @param name the Attribute that leads to the child
+ * @param child the child ViewModel
+ */
+ private void attachChild(String name, ViewModel child) {
+ childMirrors.put(name, ChildMessageMirror.attach(this, name, child));
+ }
+
+ /**
+ * Drops the copies carried for an Attribute and stops following that child.
+ *
+ * @param name the Attribute
+ */
+ private void detachChild(String name) {
+ ChildMessageMirror mirror = childMirrors.remove(name);
+ if (mirror != null) {
+ mirror.detach();
+ }
+ }
+
private void write(Method setter, String name, Object value) {
try {
setter.invoke(model, value);
@@ -311,12 +346,103 @@ public class ViewModel {
messages.add(message);
latestMessage = message;
logSevere(message);
+ notifyAdded(message);
}
/**
- * Records one error for a binding Path. A further failure of that Path replaces the text.
+ * Adds a copy of a child notice. The copy is not written to the log.
*
- * @param path the binding Path
+ * @param copy the carried notice
+ */
+ void addCarriedMessage(Message copy) {
+ messages.add(copy);
+ latestMessage = copy;
+ notifyAdded(copy);
+ }
+
+ /**
+ * Drops a carried copy. When it was the latest notice, the previous one becomes latest.
+ *
+ * @param copy the carried notice
+ */
+ void removeCarriedMessage(Message copy) {
+ if (!messages.remove(copy)) {
+ return;
+ }
+ if (latestMessage == copy) {
+ latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
+ }
+ notifyRemoved(copy);
+ }
+
+ /**
+ * Hears notices added on this ViewModel, including carried copies, and hears when they are
+ * dropped or their text changes.
+ *
+ * @param listener the listener
+ */
+ void addMessageListener(MessageListener listener) {
+ messageListeners.add(listener);
+ }
+
+ /**
+ * Stops hearing notices added on this ViewModel.
+ *
+ * @param listener the listener
+ */
+ void removeMessageListener(MessageListener listener) {
+ messageListeners.remove(listener);
+ }
+
+ /**
+ * Tells listeners that a notice was added.
+ *
+ * @param message the notice
+ */
+ private void notifyAdded(Message message) {
+ for (MessageListener listener : List.copyOf(messageListeners)) {
+ listener.added(message);
+ }
+ }
+
+ /**
+ * Tells listeners that a notice kept its place and changed its text.
+ *
+ * An in-place change does not log again and does not become a new latest notice.
+ *
+ * @param message the notice
+ */
+ void noteMessageText(Message message) {
+ notifyTextChanged(message);
+ }
+
+ /**
+ * Tells listeners that a notice was dropped.
+ *
+ * @param message the notice
+ */
+ private void notifyRemoved(Message message) {
+ for (MessageListener listener : List.copyOf(messageListeners)) {
+ listener.removed(message);
+ }
+ }
+
+ /**
+ * Tells listeners that a notice kept its place and changed its text.
+ *
+ * @param message the notice
+ */
+ private void notifyTextChanged(Message message) {
+ for (MessageListener listener : List.copyOf(messageListeners)) {
+ listener.textChanged(message);
+ }
+ }
+
+ /**
+ * Records one error whose source is an Attribute name. A further failure of that Attribute
+ * replaces the text of the same notice. Parents that carry the notice hear the new text.
+ *
+ * @param path the Attribute name that owns the failure
* @param text the error text
*/
void reportBindingFailure(String path, String text) {
@@ -324,6 +450,7 @@ public class ViewModel {
if (existing != null) {
existing.setText(text);
logSevere(existing);
+ notifyTextChanged(existing);
return;
}
Message message = new Message(Message.Severity.ERROR, text, path);
@@ -332,9 +459,9 @@ public class ViewModel {
}
/**
- * Drops the error recorded for a binding Path after a later conversion succeeds.
+ * Drops the error recorded for an Attribute after a later conversion succeeds.
*
- * @param path the binding Path
+ * @param path the Attribute name that owned the failure
*/
void clearBindingFailure(String path) {
Message existing = bindingFailures.remove(path);
@@ -345,6 +472,7 @@ public class ViewModel {
if (latestMessage == existing) {
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
}
+ notifyRemoved(existing);
}
/**
diff --git a/fx/src/test/java/de/neitzel/fx/view/CarryChildMessageTest.java b/fx/src/test/java/de/neitzel/fx/view/CarryChildMessageTest.java
new file mode 100644
index 0000000..542a229
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/CarryChildMessageTest.java
@@ -0,0 +1,356 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.fixture.Address;
+import de.neitzel.fx.view.fixture.Household;
+import de.neitzel.fx.view.fixture.Person;
+import de.neitzel.injection.ApplicationContext;
+import javafx.application.Platform;
+import javafx.beans.property.Property;
+import javafx.scene.control.TextField;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.fail;
+
+class CarryChildMessageTest {
+
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ CountDownLatch started = new CountDownLatch(1);
+ try {
+ Platform.startup(started::countDown);
+ } catch (IllegalStateException alreadyStarted) {
+ started.countDown();
+ }
+ if (!started.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX toolkit did not start");
+ }
+ }
+
+ @Test
+ void aStreetNoticeOnTheAddressAppearsOnThePersonAsAddressStreet() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ URL fxml = CarryChildMessageTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ ViewModel personModel = loaded.getViewModel();
+ ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
+ addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
+
+ assertEquals(1, addressModel.getMessages().size());
+ assertEquals("street", addressModel.getMessages().get(0).getSource());
+ assertEquals(1, personModel.getMessages().size());
+ Message carried = personModel.getMessages().get(0);
+ assertEquals("address.street", carried.getSource());
+ assertEquals("lane is closed", carried.getText());
+ assertEquals(Message.Severity.INFO, carried.getSeverity());
+ assertNotSame(addressModel.getMessages().get(0), carried);
+ assertEquals(carried, personModel.getLatestMessage());
+ });
+ }
+
+ @Test
+ void aNestedStreetNoticeReadsAsPersonAddressStreet() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ Household household = new Household();
+ household.setPerson(person);
+ URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, household, context);
+ ViewModel householdModel = loaded.getViewModel();
+ ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
+ ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
+ addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
+
+ assertEquals("street", addressModel.getMessages().get(0).getSource());
+ assertEquals("address.street", personModel.getMessages().get(0).getSource());
+ assertEquals(1, householdModel.getMessages().size());
+ Message carried = householdModel.getMessages().get(0);
+ assertEquals("person.address.street", carried.getSource());
+ assertEquals("lane is closed", carried.getText());
+ assertEquals(Message.Severity.INFO, carried.getSeverity());
+ });
+ }
+
+ @Test
+ void aBadStreetIsOwnedByTheAddressAndCarriedAsAddressStreet() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ streetField.setText("nope");
+
+ assertEquals("Main", person.getAddress().getStreet());
+ assertEquals("Main", streetField.getText());
+ ViewModel personModel = loaded.getViewModel();
+ ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
+ assertEquals(1, addressModel.getMessages().size());
+ Message owned = addressModel.getMessages().get(0);
+ assertEquals(Message.Severity.ERROR, owned.getSeverity());
+ assertEquals("street", owned.getSource());
+ assertEquals("not a street: nope", owned.getText());
+ assertEquals(1, personModel.getMessages().size());
+ Message carried = personModel.getMessages().get(0);
+ assertEquals("address.street", carried.getSource());
+ assertEquals("not a street: nope", carried.getText());
+ assertEquals(Message.Severity.ERROR, carried.getSeverity());
+ assertNotSame(owned, carried);
+ });
+ }
+
+ @Test
+ void aFixedStreetDropsTheNoticeFromTheAddressAndThePerson() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ streetField.setText("nope");
+ streetField.setText("ok Oak");
+
+ assertEquals("Oak", person.getAddress().getStreet());
+ ViewModel personModel = loaded.getViewModel();
+ ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
+ assertEquals(0, addressModel.getMessages().size());
+ assertNull(addressModel.getLatestMessage());
+ assertEquals(0, personModel.getMessages().size());
+ assertNull(personModel.getLatestMessage());
+ });
+ }
+
+ @Test
+ void anotherBadStreetUpdatesTheCarriedNoticeInPlace() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ Household household = new Household();
+ household.setPerson(person);
+ URL fxml = CarryChildMessageTest.class.getResource("household-street-converted.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, household, context);
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ streetField.setText("nope");
+
+ ViewModel householdModel = loaded.getViewModel();
+ ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
+ ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
+ assertEquals(1, addressModel.getMessages().size());
+ assertEquals(1, personModel.getMessages().size());
+ assertEquals(1, householdModel.getMessages().size());
+ Message owned = addressModel.getMessages().get(0);
+ Message personCopy = personModel.getMessages().get(0);
+ Message householdCopy = householdModel.getMessages().get(0);
+ assertEquals("street", owned.getSource());
+ assertEquals("address.street", personCopy.getSource());
+ assertEquals("person.address.street", householdCopy.getSource());
+
+ streetField.setText("still-bad");
+
+ assertEquals("Main", person.getAddress().getStreet());
+ assertEquals(1, addressModel.getMessages().size());
+ assertEquals(1, personModel.getMessages().size());
+ assertEquals(1, householdModel.getMessages().size());
+ assertSame(owned, addressModel.getMessages().get(0));
+ assertSame(personCopy, personModel.getMessages().get(0));
+ assertSame(householdCopy, householdModel.getMessages().get(0));
+ assertEquals("not a street: still-bad", owned.getText());
+ assertEquals("not a street: still-bad", personCopy.getText());
+ assertEquals("not a street: still-bad", householdCopy.getText());
+ assertSame(householdCopy, householdModel.getLatestMessage());
+ });
+ }
+
+ @Test
+ void clearingTheAddressDropsTheCarriedNoticeAndANewAddressBringsItsOwn() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ ViewModel personModel = loaded.getViewModel();
+ ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
+ addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
+ assertEquals("address.street", personModel.getMessages().get(0).getSource());
+
+ @SuppressWarnings("unchecked")
+ Property address = (Property) personModel.property("address");
+ address.setValue(null);
+
+ assertNull(person.getAddress());
+ assertEquals(0, personModel.getMessages().size());
+ assertNull(personModel.getLatestMessage());
+ assertEquals(1, addressModel.getMessages().size());
+ assertEquals("lane is closed", addressModel.getMessages().get(0).getText());
+
+ Address moved = new Address("Oak");
+ LoadedView movedView = ViewLoader.load(fxml, moved, context);
+ movedView.getViewModel().addMessage(new Message(Message.Severity.WARN, "under repair", "street"));
+ address.setValue(movedView.getViewModel());
+
+ assertSame(moved, person.getAddress());
+ assertEquals(1, personModel.getMessages().size());
+ Message carried = personModel.getMessages().get(0);
+ assertEquals("address.street", carried.getSource());
+ assertEquals("under repair", carried.getText());
+ assertEquals(Message.Severity.WARN, carried.getSeverity());
+ assertSame(carried, personModel.getLatestMessage());
+ });
+ }
+
+ @Test
+ void fixingTheStreetLeavesThePersonsOwnNotice() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ ViewModel personModel = loaded.getViewModel();
+ personModel.addMessage(new Message(Message.Severity.INFO, "keep me", "form"));
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ streetField.setText("nope");
+
+ ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
+ assertEquals(1, addressModel.getMessages().size());
+ assertEquals(2, personModel.getMessages().size());
+ Message carried = personModel.getMessages().get(1);
+ assertEquals("address.street", carried.getSource());
+ assertEquals("not a street: nope", carried.getText());
+ assertSame(carried, personModel.getLatestMessage());
+
+ streetField.setText("still-bad");
+
+ assertEquals(2, personModel.getMessages().size());
+ assertSame(carried, personModel.getMessages().get(1));
+ assertEquals("not a street: still-bad", carried.getText());
+ assertSame(carried, personModel.getLatestMessage());
+ assertEquals("keep me", personModel.getMessages().get(0).getText());
+
+ streetField.setText("ok Oak");
+
+ assertEquals("Oak", person.getAddress().getStreet());
+ assertEquals(0, addressModel.getMessages().size());
+ assertEquals(1, personModel.getMessages().size());
+ assertEquals("keep me", personModel.getLatestMessage().getText());
+ assertEquals("form", personModel.getLatestMessage().getSource());
+ });
+ }
+
+ @Test
+ void aFixedNestedStreetDropsTheNoticeUpThePath() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ Household household = new Household();
+ household.setPerson(person);
+ URL fxml = CarryChildMessageTest.class.getResource("household-street-converted.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, household, context);
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ streetField.setText("nope");
+ streetField.setText("ok Oak");
+
+ assertEquals("Oak", person.getAddress().getStreet());
+ ViewModel householdModel = loaded.getViewModel();
+ ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
+ ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
+ assertEquals(0, addressModel.getMessages().size());
+ assertEquals(0, personModel.getMessages().size());
+ assertEquals(0, householdModel.getMessages().size());
+ assertNull(addressModel.getLatestMessage());
+ assertNull(personModel.getLatestMessage());
+ assertNull(householdModel.getLatestMessage());
+ });
+ }
+
+ @Test
+ void aSharedAddressCarriesTheStreetNoticeOnHomeAndOnWork() throws Exception {
+ onFx(() -> {
+ Address shared = new Address("Main");
+ Person person = new Person("Ada");
+ person.setHome(shared);
+ person.setWork(shared);
+ URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ ViewModel personModel = loaded.getViewModel();
+ ViewModel homeModel = (ViewModel) personModel.property("home").getValue();
+ homeModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
+
+ assertEquals(2, personModel.getMessages().size());
+ Set sources = new HashSet<>();
+ for (Message message : personModel.getMessages()) {
+ sources.add(message.getSource());
+ assertEquals("lane is closed", message.getText());
+ }
+ assertEquals(Set.of("home.street", "work.street"), sources);
+ });
+ }
+
+ private static void onFx(FxAction action) throws Exception {
+ if (Platform.isFxApplicationThread()) {
+ action.run();
+ return;
+ }
+ AtomicReference thrown = new AtomicReference<>();
+ CountDownLatch done = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ try {
+ action.run();
+ } catch (Throwable failure) {
+ thrown.set(failure);
+ } finally {
+ done.countDown();
+ }
+ });
+ if (!done.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX thread did not finish");
+ }
+ Throwable failure = thrown.get();
+ if (failure instanceof Exception exception) {
+ throw exception;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ if (failure != null) {
+ throw new AssertionError(failure);
+ }
+ }
+
+ @FunctionalInterface
+ private interface FxAction {
+ void run() throws Exception;
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/CheckedStreetConverter.java b/fx/src/test/java/de/neitzel/fx/view/fixture/CheckedStreetConverter.java
new file mode 100644
index 0000000..34c5224
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/CheckedStreetConverter.java
@@ -0,0 +1,37 @@
+package de.neitzel.fx.view.fixture;
+
+import de.neitzel.fx.view.Converter;
+import jakarta.inject.Singleton;
+
+/**
+ * Accepts a street written as {@code ok } plus the street, and rejects anything else.
+ */
+@Singleton
+public class CheckedStreetConverter implements Converter {
+
+ /**
+ * Shows the street as it is stored.
+ *
+ * @param attributeValue the street
+ * @return the control text
+ */
+ @Override
+ public Object toControlValue(Object attributeValue) {
+ return attributeValue == null ? "" : attributeValue.toString();
+ }
+
+ /**
+ * Reads a street that starts with {@code ok }.
+ *
+ * @param controlValue the control text
+ * @return the street
+ */
+ @Override
+ public Object toAttributeValue(Object controlValue) {
+ String text = controlValue == null ? "" : controlValue.toString();
+ if (!text.startsWith("ok ")) {
+ throw new IllegalArgumentException("not a street: " + text);
+ }
+ return text.substring("ok ".length());
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/Household.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Household.java
new file mode 100644
index 0000000..dd5e8b0
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Household.java
@@ -0,0 +1,27 @@
+package de.neitzel.fx.view.fixture;
+
+/**
+ * Ordinary model that holds a person, so a test can read a message two levels up.
+ */
+public class Household {
+
+ private Person person;
+
+ /**
+ * Returns the person.
+ *
+ * @return the person, or {@code null} when the household has none
+ */
+ public Person getPerson() {
+ return person;
+ }
+
+ /**
+ * Sets the person.
+ *
+ * @param person the person
+ */
+ public void setPerson(Person person) {
+ this.person = person;
+ }
+}
diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-street-converted.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-street-converted.fxml
new file mode 100644
index 0000000..58c68a8
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/address-street-converted.fxml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/household-street-converted.fxml b/fx/src/test/resources/de/neitzel/fx/view/household-street-converted.fxml
new file mode 100644
index 0000000..5904c3c
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/household-street-converted.fxml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/plain-view.fxml b/fx/src/test/resources/de/neitzel/fx/view/plain-view.fxml
new file mode 100644
index 0000000..f3caa21
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/plain-view.fxml
@@ -0,0 +1,5 @@
+
+
+
+
+