From 8f34f51e9721b969de7fec2bea356f8dda851c07 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 30 Oct 2026 17:37:53 +0100 Subject: [PATCH] Let a Direct binding convert a value and keep one failure Message. A bad control value must leave the previous Attribute in place, and the ViewModel should keep a single error for that Path until the value is fixed. An info notice stays on the ViewModel and is not written to the log. Co-authored-by: Cursor --- .../java/de/neitzel/fx/view/BindingList.java | 6 +- .../java/de/neitzel/fx/view/Converter.java | 35 +++ .../de/neitzel/fx/view/DirectBinding.java | 24 +- .../neitzel/fx/view/DirectBindingApplier.java | 118 ++++++- .../main/java/de/neitzel/fx/view/Message.java | 74 +++++ .../java/de/neitzel/fx/view/ViewLoader.java | 6 +- .../java/de/neitzel/fx/view/ViewModel.java | 90 ++++++ .../fx/view/ConvertAndRecordFailureTest.java | 292 ++++++++++++++++++ .../de/neitzel/fx/view/fixture/AgeUnit.java | 19 ++ .../fx/view/fixture/PrefixedAgeConverter.java | 55 ++++ .../fx/view/fixture/ShownAgeConverter.java | 22 ++ .../fx/view/age-both-and-view-converted.fxml | 20 ++ .../neitzel/fx/view/age-both-converted.fxml | 16 + .../neitzel/fx/view/age-model-converted.fxml | 16 + .../neitzel/fx/view/age-view-converted.fxml | 16 + 15 files changed, 801 insertions(+), 8 deletions(-) create mode 100644 fx/src/main/java/de/neitzel/fx/view/Converter.java create mode 100644 fx/src/main/java/de/neitzel/fx/view/Message.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/ConvertAndRecordFailureTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/AgeUnit.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/PrefixedAgeConverter.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/ShownAgeConverter.java create mode 100644 fx/src/test/resources/de/neitzel/fx/view/age-both-and-view-converted.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/age-both-converted.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/age-model-converted.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/age-view-converted.fxml diff --git a/fx/src/main/java/de/neitzel/fx/view/BindingList.java b/fx/src/main/java/de/neitzel/fx/view/BindingList.java index 5d84696..a78fba8 100644 --- a/fx/src/main/java/de/neitzel/fx/view/BindingList.java +++ b/fx/src/main/java/de/neitzel/fx/view/BindingList.java @@ -1,5 +1,6 @@ package de.neitzel.fx.view; +import de.neitzel.injection.ApplicationContext; import javafx.beans.DefaultProperty; import java.util.ArrayList; @@ -40,10 +41,11 @@ public class BindingList { * * @param namespace objects addressed by {@code fx:id} * @param viewModel the ViewModel for this View + * @param context the container that creates a named Converter */ - void apply(Map namespace, ViewModel viewModel) { + void apply(Map namespace, ViewModel viewModel, ApplicationContext context) { for (DirectBinding binding : elements) { - DirectBindingApplier.apply(namespace, viewModel, binding); + DirectBindingApplier.apply(namespace, viewModel, binding, context); } } } diff --git a/fx/src/main/java/de/neitzel/fx/view/Converter.java b/fx/src/main/java/de/neitzel/fx/view/Converter.java new file mode 100644 index 0000000..d14da23 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/Converter.java @@ -0,0 +1,35 @@ +package de.neitzel.fx.view; + +/** + * The developer-written translation between a Direct binding's Attribute value and its control value. + *

+ * The container constructs the class and injects its services. {@link #toControlValue(Object)} turns + * the Attribute value into the control value. {@link #toAttributeValue(Object)} turns the control + * value into the Attribute value and runs only when the Direct binding runs toward the Model. + * A toward-view binding does not call the reverse method, so a converter used only toward the View + * can leave it unimplemented. When a method cannot produce a value, it throws a runtime exception. + * The binding then keeps the previous value. + */ +public interface Converter { + + /** + * Turns the Attribute value into the control value. + * + * @param attributeValue the current Attribute value, which may be null + * @return the value written to the control + */ + Object toControlValue(Object attributeValue); + + /** + * Turns the control value into the Attribute value. + *

+ * Called only when the Direct binding runs toward the Model ({@code model} or {@code both}). + * A toward-view binding does not require this method. + * + * @param controlValue the current control value + * @return the value written to the Attribute + */ + default Object toAttributeValue(Object controlValue) { + throw new UnsupportedOperationException("no reverse conversion for " + controlValue); + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java b/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java index 9938b32..fd29d08 100644 --- a/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java +++ b/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java @@ -4,8 +4,9 @@ package de.neitzel.fx.view; * A Direct binding declared in FXML. *

* The element names the control by {@code fx:id}, the control property, the Path, and the - * direction. A Path of one name is that Attribute of this View's ViewModel. Direction values - * are {@code view}, {@code model}, and {@code both}. + * direction, and an optional Converter class. A Path of one name is that Attribute of this + * View's ViewModel. Direction values are {@code view}, {@code model}, and {@code both}. + * The Converter value is the class name. The container creates that class. */ public class DirectBinding { @@ -13,6 +14,7 @@ public class DirectBinding { private String property; private String path; private String direction; + private String converter; /** * Sets the {@code fx:id} of the control. @@ -50,6 +52,15 @@ public class DirectBinding { this.direction = direction; } + /** + * Sets the Converter class name. The container creates that class. + * + * @param converter the converter class name, or {@code null} when values pass through + */ + public void setConverter(String converter) { + this.converter = converter; + } + /** * Returns the {@code fx:id} of the control. * @@ -85,4 +96,13 @@ public class DirectBinding { public String getDirection() { return direction; } + + /** + * Returns the Converter class name. + * + * @return the class name, or {@code null} when values pass through + */ + public String getConverter() { + return converter; + } } 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 73d170c..552119b 100644 --- a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java +++ b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java @@ -1,5 +1,6 @@ package de.neitzel.fx.view; +import de.neitzel.injection.ApplicationContext; import javafx.beans.property.Property; import java.lang.reflect.InvocationTargetException; @@ -20,8 +21,10 @@ final class DirectBindingApplier { * @param namespace objects addressed by {@code fx:id} * @param viewModel the ViewModel for this View * @param binding the declaration + * @param context the container that creates a named Converter */ - static void apply(Map namespace, ViewModel viewModel, DirectBinding binding) { + static void apply(Map namespace, ViewModel viewModel, DirectBinding binding, + ApplicationContext context) { Object control = namespace.get(binding.getControl()); if (control == null) { throw new IllegalArgumentException("No control \"" + binding.getControl() + "\""); @@ -32,14 +35,125 @@ final class DirectBindingApplier { throw new IllegalArgumentException( "No read-write attribute for path \"" + binding.getPath() + "\""); } + String converterName = binding.getConverter(); + if (converterName == null || converterName.isEmpty()) { + alignPlain(controlProperty, attribute, binding.getDirection()); + return; + } + Converter converter = converter(context, converterName); switch (binding.getDirection()) { + case "both" -> alignConverted( + controlProperty, attribute, converter, viewModel, binding.getPath(), true, true); + case "view" -> alignConverted( + controlProperty, attribute, converter, viewModel, binding.getPath(), true, false); + case "model" -> alignConverted( + controlProperty, attribute, converter, viewModel, binding.getPath(), false, true); + default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection()); + } + } + + private static void alignPlain(Property controlProperty, Property attribute, String direction) { + switch (direction) { case "both" -> alignBoth(controlProperty, attribute); case "view" -> alignTowardView(controlProperty, attribute); case "model" -> alignTowardModel(controlProperty, attribute); - default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection()); + default -> throw new IllegalArgumentException("Not a direction: " + direction); } } + private static Converter converter(ApplicationContext context, String className) { + try { + Class type = Class.forName(className); + Object instance = context.getComponent(type.asSubclass(Object.class)); + if (!(instance instanceof Converter created)) { + throw new IllegalArgumentException("Not a converter: " + className); + } + return created; + } catch (ClassNotFoundException failure) { + throw new IllegalArgumentException("No converter " + className, failure); + } + } + + @SuppressWarnings("unchecked") + private static void alignConverted(Property controlProperty, Property attribute, Converter converter, + ViewModel viewModel, String path, boolean towardView, boolean towardModel) { + Property control = (Property) controlProperty; + Property source = (Property) attribute; + Link link = new Link(); + pushControl(control, source, converter, viewModel, path, link); + if (towardView) { + source.addListener((observable, oldValue, newValue) -> { + if (!link.applying) { + pushControl(control, source, converter, viewModel, path, link); + } + }); + } + if (towardModel) { + control.addListener((observable, oldValue, newValue) -> { + if (!link.applying) { + pushAttribute(control, source, converter, viewModel, path, link); + } + }); + } + } + + private static void pushControl(Property control, Property attribute, Converter converter, + ViewModel viewModel, String path, Link link) { + try { + Object shown = converter.toControlValue(attribute.getValue()); + link.applying = true; + try { + control.setValue(shown); + link.committedControl = shown; + } finally { + link.applying = false; + } + viewModel.clearBindingFailure(path); + } catch (RuntimeException failure) { + viewModel.reportBindingFailure(path, failureText(failure)); + } + } + + private static void pushAttribute(Property control, Property attribute, Converter converter, + ViewModel viewModel, String path, Link link) { + try { + Object converted = converter.toAttributeValue(control.getValue()); + link.applying = true; + try { + attribute.setValue(converted); + link.committedControl = control.getValue(); + } finally { + link.applying = false; + } + viewModel.clearBindingFailure(path); + } catch (RuntimeException failure) { + link.applying = true; + try { + control.setValue(link.committedControl); + } finally { + link.applying = false; + } + viewModel.reportBindingFailure(path, failureText(failure)); + } + } + + private static String failureText(RuntimeException failure) { + String text = failure.getMessage(); + if (text == null || text.isEmpty()) { + return "Conversion failed"; + } + return text; + } + + /** + * Guards one converted binding against hearing its own writes, and remembers the last + * control value that was successfully shown. + */ + private static final class Link { + private boolean applying; + private Object committedControl; + } + private static Property controlProperty(Object control, String propertyName) { String methodName = propertyName + "Property"; try { diff --git a/fx/src/main/java/de/neitzel/fx/view/Message.java b/fx/src/main/java/de/neitzel/fx/view/Message.java new file mode 100644 index 0000000..e35e426 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/Message.java @@ -0,0 +1,74 @@ +package de.neitzel.fx.view; + +/** + * A notice on a ViewModel with a severity, a text, and a source. + */ +public final class Message { + + /** + * How serious a notice is. + */ + public enum Severity { + /** Informational notice. Not written to the log. */ + INFO, + /** Warning. Not written to the log. */ + WARN, + /** Error. Written to the log. */ + ERROR, + /** Fatal error. Written to the log. */ + FATAL + } + + private final Severity severity; + private String text; + private final String source; + + /** + * Creates a notice. + * + * @param severity how serious the notice is + * @param text the notice text + * @param source where the notice came from, such as a binding Path + */ + public Message(Severity severity, String text, String source) { + this.severity = severity; + this.text = text; + this.source = source; + } + + /** + * Returns how serious the notice is. + * + * @return the severity + */ + public Severity getSeverity() { + return severity; + } + + /** + * Returns the notice text. + * + * @return the text + */ + public String getText() { + return text; + } + + /** + * Returns where the notice came from. + * + * @return the source + */ + public String getSource() { + return source; + } + + /** + * Replaces the text of a binding failure that is already in the list. + * + * @param text the new text + */ + void setText(String text) { + this.text = text; + } +} 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 4189a94..6cb7ba8 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java @@ -24,7 +24,9 @@ public final class ViewLoader { * shows the Attribute and follows later property changes without writing the Attribute back. * Direction {@code model} shows the current value, writes the Attribute when the control * changes, and does not push later property changes to the control. A path that is not a - * read-write Attribute fails the load. The context is used when the document names a Controller. + * 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. * * @param fxml the FXML document * @param model the Model instance @@ -43,7 +45,7 @@ public final class ViewLoader { throw new IllegalArgumentException("View root must be a parent"); } for (BindingList bindingList : session.bindingLists()) { - bindingList.apply(loader.getNamespace(), viewModel); + bindingList.apply(loader.getNamespace(), viewModel, context); } return new LoadedView(root, viewModel); } catch (IOException failure) { 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 1e108a1..6a1b2d0 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java @@ -13,6 +13,8 @@ import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleLongProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.beans.BeanInfo; import java.beans.IntrospectionException; @@ -20,6 +22,8 @@ import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,11 +38,20 @@ import java.util.Map; * The property is set from the Attribute when the ViewModel is created. A null number or * boolean leaves the primitive property at its default. After that, a change of the property * writes the Attribute. A call to the entity's setter does not update the property. + *

+ * 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. */ public class ViewModel { + private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class); + private final Object model; private final Map> properties = new HashMap<>(); + private final List messages = new ArrayList<>(); + private final Map bindingFailures = new HashMap<>(); + private Message latestMessage; /** * Builds a ViewModel for one Model instance. @@ -160,4 +173,81 @@ public class ViewModel { throw new IllegalStateException("Could not write attribute " + name, failure); } } + + /** + * Returns the notices on this ViewModel, beside the Model attributes. + * + * @return the messages, oldest first + */ + public List getMessages() { + return Collections.unmodifiableList(messages); + } + + /** + * Returns the notice most recently added. + *

+ * An in-place update of a binding failure does not change which notice is latest. + * + * @return the latest notice, or {@code null} when there is none + */ + public Message getLatestMessage() { + return latestMessage; + } + + /** + * Adds a notice of any severity. + * + * @param message the notice + */ + public void addMessage(Message message) { + messages.add(message); + latestMessage = message; + logSevere(message); + } + + /** + * Records one error for a binding Path. A further failure of that Path replaces the text. + * + * @param path the binding Path + * @param text the error text + */ + void reportBindingFailure(String path, String text) { + Message existing = bindingFailures.get(path); + if (existing != null) { + existing.setText(text); + logSevere(existing); + return; + } + Message message = new Message(Message.Severity.ERROR, text, path); + bindingFailures.put(path, message); + addMessage(message); + } + + /** + * Drops the error recorded for a binding Path after a later conversion succeeds. + * + * @param path the binding Path + */ + void clearBindingFailure(String path) { + Message existing = bindingFailures.remove(path); + if (existing == null) { + return; + } + messages.remove(existing); + if (latestMessage == existing) { + latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1); + } + } + + /** + * Writes an error or fatal notice to the log. Info and warn are left out. + * + * @param message the notice + */ + private static void logSevere(Message message) { + Message.Severity severity = message.getSeverity(); + if (severity == Message.Severity.ERROR || severity == Message.Severity.FATAL) { + LOGGER.error(message.getText()); + } + } } diff --git a/fx/src/test/java/de/neitzel/fx/view/ConvertAndRecordFailureTest.java b/fx/src/test/java/de/neitzel/fx/view/ConvertAndRecordFailureTest.java new file mode 100644 index 0000000..5481f6a --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/ConvertAndRecordFailureTest.java @@ -0,0 +1,292 @@ +package de.neitzel.fx.view; + +import de.neitzel.fx.view.fixture.Person; +import de.neitzel.injection.ApplicationContext; +import javafx.application.Platform; +import javafx.beans.property.IntegerProperty; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class ConvertAndRecordFailureTest { + + @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 injectedConverterPrefixesTheAgeText() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + assertEquals("years:42", ageField.getText()); + }); + } + + @Test + void typingAPrefixedAgeUpdatesTheModel() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + ageField.setText("years:11"); + assertEquals(11, person.getAge()); + }); + } + + @Test + void towardViewLoadsWhenTheReverseMethodIsAbsent() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-view-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + Label ageLabel = (Label) loaded.getRoot().lookup("#ageLabel"); + assertEquals("shown 42", ageLabel.getText()); + ageLabel.setText("shown 99"); + assertEquals(42, person.getAge()); + }); + } + + @Test + void towardViewConverterFollowsALaterAge() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-and-view-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + Label ageLabel = (Label) loaded.getRoot().lookup("#ageLabel"); + assertEquals("shown 42", ageLabel.getText()); + ageField.setText("years:11"); + assertEquals(11, person.getAge()); + assertEquals("shown 11", ageLabel.getText()); + }); + } + + @Test + void towardModelConvertsTypedTextWithoutFollowingTheAttribute() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-model-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + assertEquals("years:42", ageField.getText()); + ((IntegerProperty) loaded.property("age")).set(7); + assertEquals("years:42", ageField.getText()); + assertEquals(Integer.valueOf(7), person.getAge()); + ageField.setText("years:8"); + assertEquals(Integer.valueOf(8), person.getAge()); + assertEquals("years:8", ageField.getText()); + }); + } + + @Test + void badAgeTextKeepsThePreviousIntAndOneErrorMessage() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + ageField.setText("nope"); + assertEquals(Integer.valueOf(42), person.getAge()); + assertEquals("years:42", ageField.getText()); + ViewModel viewModel = loaded.getViewModel(); + assertEquals(1, viewModel.getMessages().size()); + Message message = viewModel.getMessages().get(0); + assertEquals(Message.Severity.ERROR, message.getSeverity()); + assertEquals("age", message.getSource()); + assertEquals("not a whole number: nope", message.getText()); + assertSame(message, viewModel.getLatestMessage()); + }); + } + + @Test + void anotherBadAgeUpdatesTheSameMessage() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + ageField.setText("nope"); + ViewModel viewModel = loaded.getViewModel(); + Message first = viewModel.getMessages().get(0); + ageField.setText("still-bad"); + assertEquals(1, viewModel.getMessages().size()); + assertSame(first, viewModel.getMessages().get(0)); + assertEquals("not a whole number: still-bad", first.getText()); + assertEquals(Integer.valueOf(42), person.getAge()); + assertEquals("years:42", ageField.getText()); + }); + } + + @Test + void aValidAgeRemovesTheFailureMessage() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + ageField.setText("nope"); + ageField.setText("years:11"); + assertEquals(Integer.valueOf(11), person.getAge()); + assertEquals("years:11", ageField.getText()); + assertEquals(0, loaded.getViewModel().getMessages().size()); + assertNull(loaded.getViewModel().getLatestMessage()); + }); + } + + @Test + void developerInfoStaysLatestAndIsNotLogged() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + ViewModel viewModel = loaded.getViewModel(); + ageField.setText("nope"); + String log = captureErr(() -> viewModel.addMessage( + new Message(Message.Severity.INFO, "developer-info-notice", "form"))); + assertEquals(2, viewModel.getMessages().size()); + assertEquals("developer-info-notice", viewModel.getLatestMessage().getText()); + assertEquals(Message.Severity.INFO, viewModel.getLatestMessage().getSeverity()); + assertFalse(log.contains("developer-info-notice")); + ageField.setText("still-bad"); + assertEquals(2, viewModel.getMessages().size()); + assertEquals("not a whole number: still-bad", viewModel.getMessages().get(0).getText()); + assertEquals("developer-info-notice", viewModel.getLatestMessage().getText()); + }); + } + + @Test + void errorAndFatalMessagesAreWrittenToTheLog() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setAge(42); + URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField ageField = (TextField) loaded.getRoot().lookup("#ageField"); + ViewModel viewModel = loaded.getViewModel(); + String log = captureErr(() -> { + viewModel.addMessage(new Message(Message.Severity.WARN, "developer-warn-notice", "form")); + viewModel.addMessage(new Message(Message.Severity.ERROR, "developer-error-notice", "form")); + viewModel.addMessage(new Message(Message.Severity.FATAL, "developer-fatal-notice", "form")); + ageField.setText("nope"); + ageField.setText("still-bad"); + }); + assertFalse(log.contains("developer-warn-notice")); + assertTrue(log.contains("developer-error-notice")); + assertTrue(log.contains("developer-fatal-notice")); + assertTrue(log.contains("not a whole number: nope")); + assertTrue(log.contains("not a whole number: still-bad")); + assertEquals("not a whole number: still-bad", viewModel.getLatestMessage().getText()); + assertEquals(4, viewModel.getMessages().size()); + }); + } + + 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); + } + } + + private static String captureErr(FxAction action) throws Exception { + PrintStream previous = System.err; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + PrintStream captured = new PrintStream(buffer, true, StandardCharsets.UTF_8); + System.setErr(captured); + try { + action.run(); + captured.flush(); + return buffer.toString(StandardCharsets.UTF_8); + } finally { + System.setErr(previous); + } + } + + @FunctionalInterface + private interface FxAction { + void run() throws Exception; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/AgeUnit.java b/fx/src/test/java/de/neitzel/fx/view/fixture/AgeUnit.java new file mode 100644 index 0000000..212329a --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/fixture/AgeUnit.java @@ -0,0 +1,19 @@ +package de.neitzel.fx.view.fixture; + +import jakarta.inject.Singleton; + +/** + * Service whose text a converter must show, so a test can see that injection ran. + */ +@Singleton +public class AgeUnit { + + /** + * Returns the prefix written in front of an age. + * + * @return the prefix + */ + public String prefix() { + return "years:"; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/PrefixedAgeConverter.java b/fx/src/test/java/de/neitzel/fx/view/fixture/PrefixedAgeConverter.java new file mode 100644 index 0000000..1516f90 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/fixture/PrefixedAgeConverter.java @@ -0,0 +1,55 @@ +package de.neitzel.fx.view.fixture; + +import de.neitzel.fx.view.Converter; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +/** + * Converts an age using the prefix from {@link AgeUnit}. + */ +@Singleton +public class PrefixedAgeConverter implements Converter { + + private final AgeUnit ageUnit; + + /** + * Creates a converter that reads its prefix from the injected service. + * + * @param ageUnit the service that supplies the prefix + */ + @Inject + public PrefixedAgeConverter(AgeUnit ageUnit) { + this.ageUnit = ageUnit; + } + + /** + * Writes the injected prefix in front of the age. + * + * @param attributeValue the age + * @return the control text + */ + @Override + public Object toControlValue(Object attributeValue) { + return ageUnit.prefix() + attributeValue; + } + + /** + * Reads an age that starts with the injected prefix. + * + * @param controlValue the control text + * @return the age + */ + @Override + public Object toAttributeValue(Object controlValue) { + String text = controlValue == null ? "" : controlValue.toString(); + String prefix = ageUnit.prefix(); + if (!text.startsWith(prefix)) { + throw new IllegalArgumentException("not a whole number: " + text); + } + try { + return Integer.valueOf(text.substring(prefix.length())); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("not a whole number: " + text); + } + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/ShownAgeConverter.java b/fx/src/test/java/de/neitzel/fx/view/fixture/ShownAgeConverter.java new file mode 100644 index 0000000..50caae2 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/fixture/ShownAgeConverter.java @@ -0,0 +1,22 @@ +package de.neitzel.fx.view.fixture; + +import de.neitzel.fx.view.Converter; +import jakarta.inject.Singleton; + +/** + * Shows an age and has no reverse conversion. + */ +@Singleton +public class ShownAgeConverter implements Converter { + + /** + * Turns the age into label text. + * + * @param attributeValue the age + * @return the label text + */ + @Override + public Object toControlValue(Object attributeValue) { + return "shown " + attributeValue; + } +} diff --git a/fx/src/test/resources/de/neitzel/fx/view/age-both-and-view-converted.fxml b/fx/src/test/resources/de/neitzel/fx/view/age-both-and-view-converted.fxml new file mode 100644 index 0000000..83116dd --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/age-both-and-view-converted.fxml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/age-both-converted.fxml b/fx/src/test/resources/de/neitzel/fx/view/age-both-converted.fxml new file mode 100644 index 0000000..44af0af --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/age-both-converted.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/age-model-converted.fxml b/fx/src/test/resources/de/neitzel/fx/view/age-model-converted.fxml new file mode 100644 index 0000000..158a302 --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/age-model-converted.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/age-view-converted.fxml b/fx/src/test/resources/de/neitzel/fx/view/age-view-converted.fxml new file mode 100644 index 0000000..dd59cbc --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/age-view-converted.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + +