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 247496d..0f2383c 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. * A parent document names a Component class as an element. The context creates that Component * and injects its services. The Component document {@code ClassName.fxml}, in the same package, * is loaded with the Component as {@code fx:root} and as the Controller. That document's 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 @@ + + + + +