u/john16384

FX Flow 0.6.1 released, declarative UI building for JavaFX
▲ 12 r/JavaFX

FX Flow 0.6.1 released, declarative UI building for JavaFX

I've released FX Flow 0.6.1, a JavaFX utility library focused on declarative UI construction, and reducing boilerplate around models, validation and reactive UI updates. See the end of this post for a full example.

Some highlights since the 0.5 release:

Validation feedback directly from domains

Domains can now contain Rules, which is a predicate associated with a Template explaining why a value is invalid. All built-in domain factory methods now provide validation messages. Domains can provide templates for out of range values, misaligned values, missing values, non matching values (regex), etc.

Together with the new ValidationEvent, AbstractMarkerPane and ValidationMarkerPane, validation feedback can be surfaced in the UI without wiring validation logic directly into controls. See the ValidationSampleApplication.

Coordinated observable updates

A new UpdatableValue type allows multiple values to be updated atomically, ensuring listeners never observe intermediate state because the listeners of the individual properties are only fired until all values have been updated:

// Create two updatable values (similar to properties):
UpdatableValue<String> firstName = UpdatableValue.of("Jane");
UpdatableValue<String> lastName = UpdatableValue.of("Smith");

// Observe them:
Observe.values(firstName, lastName)
    .subscribe((fn, ln) -> System.out.println(fn + " " + ln));  // prints "Jane Smith"

// Modify both properties at once:
UpdatableValue.set(
    firstName, "John",
    lastName, "Doe"
);

In this example, it will initially print Jane Smith followed by John Doe. The subscriber only sees the final (John, Doe) state. If you put a direct ChangeListener on one of these properties (and then use get to read the value of the other) you will not observe a temporary incorrect combination either (so you will never see Jane Doe or John Smith).

The advantage of using Observe.subscribe with multiple values is convenience, and that you also get notified if only one of the values changed (ie. from John Doe to John Miller) without having to manually monitor both properties.

Feedback and suggestions are welcome.

GitHub: https://github.com/int4-org/FX/releases

Full example:

public class ValidationSampleApplication extends Application {

  public static void main(String[] args) {
    Application.launch(args);
  }

  @Override
  public void start(Stage primaryStage) {
    StringModel name = StringModel.of(Domain.regex("[A-Z][a-z]+", Template.of("custom.startsWithCapital")));
    IntegerModel age = IntegerModel.of(null, Domain.bounded(18, 120));

    /*
     * Create a scene with a ValidationMarkerPane as the root. Markers will be overlaid
     * on the controls within the pane:
     */

    Scene scene = Scenes.create(
      ValidationMarkerPane.of().onMarkerCreated(this::installTooltip).content(
        Panes.vbox("form").nodes(
          Panes.grid("grid")
            .row("Name", FX.textField().promptText("Name (e.g. John)").model(name))
            .row("Age", FX.textField().promptText("Age (18-120)").model(age)),
          FX.button().text("Submit")
            .enable(Observe.booleans(name.valid(), age.valid()).allTrue())
        )
      )
    );

    /*
     * Add some basic styling.
     */

    scene.getStylesheets().add(StyleSheets.inline(
      """
      .form {
        -fx-padding: 2em;
        -fx-spacing: 1.5em;
        -fx-alignment: top-center;
      }
      .grid {
        -fx-hgap: 1em;
        -fx-vgap: 1em;
      }
      """
    ));

    primaryStage.setScene(scene);
    primaryStage.setTitle("Validation Marker Sample");
    primaryStage.sizeToScene();
    primaryStage.show();
  }

  private void installTooltip(Marker marker) {
    marker.validationIssueProperty().subscribe(issue -> {
      Tooltip tooltip = marker.getTooltip();

      if(tooltip == null) {
        tooltip = new Tooltip();

        marker.setTooltip(tooltip);
      }

      tooltip.setText(switch(issue) {
        case ValidationIssue.Invalid(Object _, Template template) -> toMessage(template);
        case ValidationIssue.Incompatible(Template template) -> toMessage(template);
      });
    });
  }

  static String toMessage(Template template) {
    return MessageFormat.format(
      switch(template.key()) {
        case "domain.missing" -> "Must not be empty";
        case "domain.invalid" -> "Must be a valid value";
        case "domain.notContained" -> "Must be one of {0}";
        case "domain.noMatch" -> "Must match regular expression {0}";
        case "domain.outOfRange" -> "Must be between {0} and {1}";
        case "domain.misaligned" -> "Must be a multiple of {1} starting from {0}";
        case "conversion.incompatible" -> "Must be a compatible value";
        case "custom.startsWithCapital" -> "Must consist of two or more letters and start with a capital";
        default -> "Invalid (" + template.key() + ")";
      },
      template.args().values().toArray()
    );
  }
}
u/john16384 — 12 days ago
▲ 9 r/java

New test helper, ExploratoryTestRunner, to test all states of your class

I like writing good exhaustive tests in JUnit, making use of @Nested to test all possible states, but I've found that for test subjects with a lot of state transitions, it can lead to huge unwieldy tests especially when it can take many steps to get to a specific state, and at each level you should probably try every allowed transition. This makes it hard to see if all paths are truly tested, and due to the size of the test it becomes difficult to modify or extend.

So over the past year I've been using a different way of testing. Instead of writing many nested levels with all possible steps, I've been writing tests that only define what possible actions can be applied to a subject under test. These actions are then automatically combined to explore all paths, where each path terminates until it reaches a state that has been seen before (or a selectable maximum depth has been reached).

For example, let's say I wrote a custom Queue implementation based on a LinkedHashSet that disallowed duplicates. I'd define actions to perform on the queue like:

() -> queue.poll();
() -> queue.offer(value);
() -> queue.remove(value);

To verify the queue works correctly, one can compare it with say an ArrayList. Each action is then defined by first updating your expectation, and returning the action to apply to the subject under test:

@Action
@ValueSource(strings = {"A", "B", "C"})
public Runnable enqueue(String value) {
    // update expectation (disallowing duplicates):
    if (!expectedQueue.contains(value)) {
        expectedQueue.add(value);
    }

    // the action on the subject:
    return () -> queue.offer(value);   
}

To verify the queue matches the expected queue one can define one or more assertion methods:

@Assertion
public void assertQueueContents() {
    assertThat(List.copyOf(queue))  // turn queue under test into a List
        .describedAs("Queue contents")
        .isEqualTo(expectedQueue);  // ensure it matches our expectation
}

In order for the ExploratoryTestRunner to prune paths with states that have already been reached before, the test code must implement the Explorable interface which requires the implementation of a snapshot method. This method should simply take the expected state (copying it if needed) and return an Object that can be compared with equals. Usually using a record here is optimal. For example:

public record State(List<String> queue) {}

@Override
public Object snapshot() {
    return new State(List.copyOf(expectedQueue));
}

The whole class then looks roughly like this:

class LinkedHashSetAsDeduplicatingQueueExploratoryTest {

  @Test
  void exploreQueueSemantics() {
    ExploratoryTestRunner.explore(QueueExplorable.class, QueueExplorable::new);
  }

  public static class QueueExplorable implements Explorable {

    private final Queue<String> queue = new MyQueue<>();
    private final List<String> expectedQueue = new ArrayList<>();

    public record State(List<String> queue) {}

    // snapshot, action and assertion methods omitted here

  }
}

When run, this ExploratoryTestRunner will explore all paths defined by the test class, creating new instances of QueueExplorable as needed. It will then report how many states it tested and what the deepest path was:

ExploratoryTestRunner: class 
examples.LinkedHashSetAsDeduplicatingQueueExploratoryTest$QueueExplorable -- Explored 660 paths, longest path: 5

If a failure occurs, this is reported by showing the path that leads to the failure, and which assertions failed (including a helpful trace line), for example:

org.opentest4j.AssertionFailedError: Path 61 failed: 
 - enqueue(A) -> State[queue=[A]]
 - enqueue(B) -> State[queue=[A, B]]
 - dequeue -> State[queue=[B]]
[Queue contents] 
expected: ["B"]
 but was: ["A"]
	at org.int4.common.test/examples.LinkedHashSetAsDeduplicatingQueueExploratoryTest$QueueExplorable.assertQueueContents(LinkedHashSetAsDeduplicatingQueueExploratoryTest.java:42)
[Peeked element] 
expected: "B"
 but was: "A"
	at org.int4.common.test/examples.LinkedHashSetAsDeduplicatingQueueExploratoryTest$QueueExplorable.assertPeekedElement(LinkedHashSetAsDeduplicatingQueueExploratoryTest.java:49)

The above example shows quite clearly that dequeue seems to have removed the wrong element (in this case because getLast was called instead of getFirst in the subject under test).

The ExploratoryTestRunner can be found here: https://github.com/int4-org/Common/tree/master/common-test

The full example test case is here: https://github.com/int4-org/Common/blob/master/common-test/examples/LinkedHashSetAsDeduplicatingQueueExploratoryTest.java

Another much more elaborate example (for a UI control): https://github.com/int4-org/FX/blob/master/fx-builders/src/test/java/org/int4/fx/builders/control/TextFieldControlExploratoryTest.java

reddit.com
u/john16384 — 23 days ago